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}