Skip to main content

lattice_plugin_host/
plugin_manager_host.rs

1//! PM.7: the host side of the `require` seam.
2//!
3//! Design: [`plugin-manager.md`](../../../docs/dev/architecture/plugin-manager.md)
4//! §3, §6. A guest (in practice the user's `init.rs`) calls
5//! `plugin-manager.require(spec)` from its `register-plugins` export; the
6//! `Host` impl records the spec into this Store's [`RequireContributions`],
7//! and [`PluginHost::spawn_plugin_manager_plugin`] drains them after the
8//! export returns.
9//!
10//! The record-then-drain split is the `register-mode` / `register-grammar`
11//! precedent, and here it is load-bearing rather than merely consistent: a
12//! `require` that resolved inline would put a git clone and a cargo build
13//! inside a guest call on the boot path. The host drains and runs the pipeline
14//! off-thread instead, so the editor draws its first frame without waiting on
15//! a network it may not even have.
16//!
17//! This module does **not** resolve, build or load anything. It converts a WIT
18//! spec into a host [`RequiredPlugin`] and hands it back; the pipeline that
19//! consumes it lives in `lattice-plugin-loader`, which is where `resolve` and
20//! `build_plugin` already live and where the loader's registries are reachable.
21//! Keeping the boundary this thin is what lets the pipeline be tested without
22//! standing up a wasm guest at all.
23
24use crate::{
25    Component, PluginBudget, PluginHost, PluginHostError, PluginManifest, TrustTier, arm_store,
26    classify_trap,
27};
28
29pub(crate) mod bindings {
30    wasmtime::component::bindgen!({
31        world: "plugin-manager-plugin",
32        path: "../lattice-wit/wit",
33        // `register-plugins` shares the async linker with WASI + logging, so
34        // the export is async; `require` itself is a sync host func (it only
35        // records into `PluginState`) — the `modes` / `config` shape.
36        exports: { default: async },
37    });
38}
39
40/// Where a required plugin comes from. The host-side mirror of the WIT
41/// `plugin-source` variant.
42///
43/// Deliberately re-declared here rather than shared with
44/// `lattice_plugin_loader::PluginSource`: the loader must not depend on the
45/// plugin host (the dependency runs the other way — `loader → host`), and a
46/// WIT-facing type that changed shape because a loader refactor touched it
47/// would be a public API breaking on an internal edit. The conversion is one
48/// `match` at the call site.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum RequiredSource {
51    Local(String),
52    Git { url: String, rev: Option<String> },
53    Prebuilt { url: String },
54}
55
56/// One plugin a guest declared via `require`.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct RequiredPlugin {
59    pub name: String,
60    pub source: RequiredSource,
61    /// The mode to enable once the plugin loads. The host carries this as an
62    /// opaque string and never interprets it — the mode is the plugin's own
63    /// surface (`feedback_mode_owns_its_surface`).
64    pub enable_mode: Option<String>,
65    pub pinned: bool,
66}
67
68/// The per-plugin accumulator the `plugin_manager::Host` impl records into
69/// during `register-plugins`. Drained after the export returns.
70#[derive(Default)]
71pub(crate) struct RequireContributions {
72    recorded: Vec<RequiredPlugin>,
73}
74
75impl RequireContributions {
76    pub fn record(&mut self, spec: RequiredPlugin) {
77        self.recorded.push(spec);
78    }
79
80    pub fn take(&mut self) -> Vec<RequiredPlugin> {
81        std::mem::take(&mut self.recorded)
82    }
83}
84
85/// Is `name` a single safe path component?
86///
87/// A required plugin's name becomes a directory under the cache root and the
88/// user's plugin root, so an unchecked name is a path-traversal write with the
89/// editor's full authority — `../../.ssh` is a plausible entry in a config
90/// file someone copy-pasted. Same gate the untrusted `manifest.id` already
91/// gets before it keys the writable data mount; this is the second untrusted
92/// string to reach a path, so it gets the same treatment rather than a
93/// bespoke one.
94pub fn is_safe_plugin_name(name: &str) -> bool {
95    !name.is_empty()
96        && name.len() <= 128
97        && name != "."
98        && name != ".."
99        && !name.starts_with('.')
100        && name
101            .chars()
102            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
103}
104
105impl PluginHost {
106    /// Instantiate `component` as a plugin-manager guest, call its
107    /// `register-plugins` export, and return the specs it declared.
108    ///
109    /// Returns the host-issued id alongside the specs — the `spawn_mode_plugin`
110    /// shape — so the loader can record the guest as loaded even when it
111    /// declared nothing.
112    ///
113    /// The specs are *declarations*, not loaded plugins: the caller runs
114    /// resolve → build → load off-thread (§5).
115    pub async fn spawn_plugin_manager_plugin(
116        &self,
117        component: &Component,
118        manifest: &PluginManifest,
119        budget: PluginBudget,
120        trust: TrustTier,
121    ) -> Result<(crate::PluginId, Vec<RequiredPlugin>), PluginHostError> {
122        let (wasi, outcome, _data_dir) = self.build_plugin_wasi(manifest, trust);
123        for denied in &outcome.denied {
124            tracing::warn!(
125                plugin = %manifest.id,
126                capability = ?denied,
127                "plugin-manager guest loaded with a withheld capability (reduced function)"
128            );
129        }
130        let mut store = self.new_store(wasi, outcome.grant, budget, Some(&manifest.id))?;
131        let bindings =
132            bindings::PluginManagerPlugin::instantiate_async(&mut store, component, &self.linker)
133                .await
134                .map_err(|e| PluginHostError::Instantiate(e.into()))?;
135        let plugin_id = self.alloc_id();
136        // PO.5: route this guest's `logging` calls into the tracer before the
137        // export runs, so an `init.rs` can narrate what it is requiring.
138        store.data_mut().log_ctx = self.log_ctx_for(plugin_id);
139
140        arm_store(&mut store, budget)?;
141        bindings
142            .call_register_plugins(&mut store)
143            .await
144            .map_err(|source| PluginHostError::Trap {
145                func: "register-plugins",
146                kind: classify_trap(&source),
147                source: source.into(),
148            })?;
149        Ok((plugin_id, store.data_mut().require_contributions.take()))
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn ordinary_names_are_accepted() {
159        for name in ["auto-pair", "vim_surround", "a", "plugin9"] {
160            assert!(is_safe_plugin_name(name), "{name} should be accepted");
161        }
162    }
163
164    #[test]
165    fn traversal_and_separators_are_rejected() {
166        // The failure this prevents is a write outside the cache root with
167        // the editor's own authority.
168        for name in [
169            "..",
170            ".",
171            "../evil",
172            "a/b",
173            "a\\b",
174            "/abs",
175            ".hidden",
176            "",
177            "with space",
178            "semi;colon",
179        ] {
180            assert!(!is_safe_plugin_name(name), "{name} must be rejected");
181        }
182    }
183
184    #[test]
185    fn an_absurdly_long_name_is_rejected() {
186        assert!(!is_safe_plugin_name(&"a".repeat(129)));
187        assert!(is_safe_plugin_name(&"a".repeat(128)));
188    }
189
190    #[test]
191    fn contributions_record_and_drain_once() {
192        let mut c = RequireContributions::default();
193        c.record(RequiredPlugin {
194            name: "demo".into(),
195            source: RequiredSource::Local("/tmp/demo".into()),
196            enable_mode: None,
197            pinned: false,
198        });
199        assert_eq!(c.take().len(), 1);
200        assert!(
201            c.take().is_empty(),
202            "a drained accumulator must not replay — a second drain would \
203             resolve and build every plugin twice"
204        );
205    }
206}