lattice_plugin_host/config_host.rs
1//! The `config` guest→host option-declaration seam (PH7.10).
2//!
3//! A config plugin implements the `config-plugin` world: it **imports** the
4//! `config` API (`register-option` / `get-option`) and **exports**
5//! `register-options` (the host calls it once to drive declaration). This module
6//! holds the `bindgen!` for that world plus the host-side registration logic —
7//! the type mapping from the WIT `option-type` to a native `OptionType` impl,
8//! factored here so it is unit-testable without a `Store` (the `host_services`
9//! precedent).
10//!
11//! **The canonical API is the WIT** (`config.wit`) — any component-model language
12//! calls `register-option` directly. A plugin option lands in the SAME
13//! [`ConfigRegistry`](lattice_config::ConfigRegistry) core options use, built as a
14//! concrete `Option<bool|i64|String>` via the public `OptionType` parse/format
15//! contract, so `:set` / `:describe-option` / `gen:options` completion /
16//! `OptionChanged` treat it uniformly with NO host kind-branch.
17//!
18//! Registration flow (the `register-events` precedent): the host sets the
19//! `ConfigRegistry` handle on `PluginState`, calls the guest's `register-options`
20//! export, and the guest calls the imported `register-option` — which registers
21//! directly into the handle (no drain step; options are declared synchronously,
22//! unlike event subscriptions which need bus wiring).
23
24use std::sync::Arc;
25
26use lattice_config::option::Option as ConfigOption;
27use lattice_config::{ConfigRegistry, OptionType};
28
29use crate::{
30 Component, PluginBudget, PluginHost, PluginHostError, PluginManifest, TrustTier, arm_store,
31 classify_trap,
32};
33
34pub(crate) mod bindings {
35 wasmtime::component::bindgen!({
36 world: "config-plugin",
37 path: "../lattice-wit/wit",
38 // `register-options` is wired into the SAME async linker as WASI + the
39 // `config` host funcs (`lib.rs`), so the export is async (the `events`
40 // world precedent: async export, sync `register-option`/`get-option` host
41 // funcs). Registration is off any hot path, so async is free.
42 exports: { default: async },
43 });
44}
45
46/// The native value type a plugin option maps to — the host-side mirror of the
47/// WIT `option-type` enum, kept as a plain enum (no bindgen type) so the
48/// registration logic is unit-testable without the generated bindings.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum PluginOptionKind {
51 Boolean,
52 Integer,
53 String,
54}
55
56/// The `register-option` host-service body (PH7.10). Builds a concrete
57/// `Option<bool|i64|String>` from the plugin's declaration and registers it into
58/// the SAME `ConfigRegistry` core options use. Returns `false` (registering
59/// nothing) if `default` doesn't parse for `kind` OR `name` collides with an
60/// existing option — a plugin must not silently shadow another option.
61///
62/// The name-collision check runs BEFORE any string leak (below), so a rejected
63/// registration allocates nothing.
64pub fn register_plugin_option(
65 registry: &ConfigRegistry,
66 name: &str,
67 kind: PluginOptionKind,
68 default: &str,
69 doc: &str,
70) -> bool {
71 if registry.lookup(name).is_some() {
72 return false;
73 }
74 match kind {
75 PluginOptionKind::Boolean => build_and_register::<bool>(registry, name, default, doc),
76 PluginOptionKind::Integer => build_and_register::<i64>(registry, name, default, doc),
77 PluginOptionKind::String => build_and_register::<String>(registry, name, default, doc),
78 }
79}
80
81/// Parse `default` for the concrete `T`, then register `Option<T>`. The default
82/// is parsed FIRST so a malformed default never leaks the name/doc strings.
83///
84/// A plugin's `name`/`doc` arrive as owned `String`s over WIT. PL8.F made
85/// `ConfigRegistry`'s option `name`/`doc` `Cow<'static, str>`, so these pass
86/// straight through as `Cow::Owned` — no `Box::leak`. The strings free with the
87/// entry on `ConfigRegistry::unregister` (PH7.12b.1b), so repeated
88/// `:plugin-reload` / `:reload-config` no longer grows the interned-string
89/// footprint (the leak PH7.12b.2 decision C deferred until the reload consumer
90/// existed to exercise it — now closed).
91fn build_and_register<T: OptionType>(
92 registry: &ConfigRegistry,
93 name: &str,
94 default: &str,
95 doc: &str,
96) -> bool {
97 let Ok(value) = T::parse(default) else {
98 return false;
99 };
100 // PL8.F: the plugin's runtime `name`/`doc` become `Cow::Owned` on the native
101 // option — no `Box::leak`. They free with the entry on
102 // `ConfigRegistry::unregister`, so repeated `:plugin-reload` / `:reload-config`
103 // no longer grows the interned-string footprint.
104 registry
105 .try_register(ConfigOption::<T>::new(
106 name.to_owned(),
107 value,
108 doc.to_owned(),
109 ))
110 .is_ok()
111}
112
113/// TC.3 — register an option whose value has structure.
114///
115/// The schema-taking peer of [`register_plugin_option`]. Kept here, beside it,
116/// because the two share every rule that is not about shape: auto-namespacing
117/// (the caller has already applied it), the name-collision refusal, and
118/// registering NOTHING when the declaration is bad.
119///
120/// The default is validated against the schema BEFORE registration for the same
121/// reason `build_and_register` parses before it allocates: a plugin whose own
122/// default does not fit its own declaration must get an error, not an option
123/// that exists and cannot hold a legal value — its later reads would then
124/// silently answer with a value nobody chose, which is the failure class this
125/// whole design is trying to end.
126pub fn register_structured_option(
127 registry: &ConfigRegistry,
128 name: &str,
129 schema: lattice_config::ConfigSchema,
130 default: lattice_config::ConfigValue,
131 doc: &str,
132) -> Result<(), String> {
133 if registry.lookup(name).is_some() {
134 return Err(format!("option `{name}` is already registered"));
135 }
136 lattice_config::schema::validate(&schema, &default)
137 .map_err(|e| format!("default does not fit the declared schema: {e}"))?;
138 registry
139 .try_register(ConfigOption::<lattice_config::ConfigValue>::structured(
140 name.to_owned(),
141 schema,
142 default,
143 doc.to_owned(),
144 ))
145 .map(|_| ())
146 .map_err(|e| e.to_string())
147}
148
149impl PluginHost {
150 /// Instantiate a `config-plugin` component under its capability grant, run its
151 /// `register-options` export to declare options into `registry`, and return
152 /// the names it registered. Grant / data-dir / WASI are identical to
153 /// [`instantiate_plugin`](PluginHost::instantiate_plugin) (shared
154 /// `build_plugin_wasi` + `new_store`).
155 ///
156 /// The registry handle is wired onto the `Store` BEFORE `register-options`
157 /// runs, so the guest's imported `register-option` / `get-option` reach it.
158 /// Options are declared synchronously (no drain / actor, unlike events); the
159 /// returned names are the drain of the plugin's `config_contributions` (the
160 /// PH7.12 teardown seam will unregister them).
161 pub async fn spawn_config_plugin(
162 &self,
163 component: &Component,
164 manifest: &PluginManifest,
165 tier: TrustTier,
166 budget: PluginBudget,
167 registry: &Arc<ConfigRegistry>,
168 ) -> Result<(crate::PluginId, Vec<String>), PluginHostError> {
169 let (wasi, outcome, _data_dir) = self.build_plugin_wasi(manifest, tier);
170 for denied in &outcome.denied {
171 tracing::warn!(
172 plugin = %manifest.id,
173 capability = ?denied,
174 "config plugin loaded with a withheld capability (reduced function)"
175 );
176 }
177 let mut store = self.new_store(wasi, outcome.grant, budget, Some(&manifest.id))?;
178 let bindings =
179 bindings::ConfigPlugin::instantiate_async(&mut store, component, &self.linker)
180 .await
181 .map_err(|e| PluginHostError::Instantiate(e.into()))?;
182
183 // A host-issued id so a config-only plugin keys `:list-plugins` /
184 // provenance uniformly with the seam plugins that mint one (picker /
185 // event). The config contributions register into `ConfigRegistry` by
186 // name, not by `SourceLayer::Plugin(id)`, so this id backs the loader's
187 // loaded-record, not per-option provenance. Allocated BEFORE
188 // `register-options` so the guest can also narrate from there (PO.5).
189 let id = self.alloc_id();
190
191 // Wire the registry BEFORE `register-options` runs so the guest's imported
192 // `register-option` / `get-option` reach it.
193 store.data_mut().config_registry = Some(Arc::clone(registry));
194 // PO.5: route this plugin's `logging` calls into the tracer (Layer 2).
195 store.data_mut().log_ctx = self.log_ctx_for(id);
196
197 arm_store(&mut store, budget)?;
198 bindings
199 .call_register_options(&mut store)
200 .await
201 .map_err(|source| PluginHostError::Trap {
202 func: "register-options",
203 kind: classify_trap(&source),
204 source: source.into(),
205 })?;
206
207 let names = store.data_mut().config_contributions.drain(..).collect();
208 Ok((id, names))
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 #![allow(clippy::unwrap_used, clippy::panic)]
215
216 use super::*;
217
218 /// A fresh, empty registry (no linkme core options) — the plugin options are
219 /// the only entries, so assertions are hermetic.
220 fn registry() -> ConfigRegistry {
221 ConfigRegistry::default()
222 }
223
224 #[test]
225 fn registers_each_type_and_reads_back_formatted() {
226 let r = registry();
227 assert!(register_plugin_option(
228 &r,
229 "plugin.flag",
230 PluginOptionKind::Boolean,
231 "true",
232 "a flag"
233 ));
234 assert!(register_plugin_option(
235 &r,
236 "plugin.count",
237 PluginOptionKind::Integer,
238 "3",
239 "a count"
240 ));
241 assert!(register_plugin_option(
242 &r,
243 "plugin.label",
244 PluginOptionKind::String,
245 "hi",
246 "a label"
247 ));
248
249 // Each lands in the registry, formatted through its native OptionType.
250 assert_eq!(r.lookup("plugin.flag").unwrap().get_formatted(), "true");
251 assert_eq!(r.lookup("plugin.count").unwrap().get_formatted(), "3");
252 assert_eq!(r.lookup("plugin.label").unwrap().get_formatted(), "hi");
253 // The mapped native type is reflected in the erased type_label.
254 assert_eq!(r.lookup("plugin.flag").unwrap().type_label(), "boolean");
255 assert_eq!(r.lookup("plugin.count").unwrap().type_label(), "integer");
256 assert_eq!(r.lookup("plugin.label").unwrap().type_label(), "string");
257 }
258
259 #[test]
260 fn bad_default_is_rejected_and_registers_nothing() {
261 let r = registry();
262 assert!(!register_plugin_option(
263 &r,
264 "plugin.count",
265 PluginOptionKind::Integer,
266 "not-a-number",
267 "count"
268 ));
269 assert!(
270 r.lookup("plugin.count").is_none(),
271 "a rejected default registers nothing"
272 );
273 }
274
275 #[test]
276 fn duplicate_name_is_rejected_keeping_the_original() {
277 let r = registry();
278 assert!(register_plugin_option(
279 &r,
280 "plugin.x",
281 PluginOptionKind::Boolean,
282 "true",
283 "first"
284 ));
285 // A second registration under the same name is refused (no silent shadow).
286 assert!(!register_plugin_option(
287 &r,
288 "plugin.x",
289 PluginOptionKind::String,
290 "hi",
291 "second"
292 ));
293 assert_eq!(
294 r.lookup("plugin.x").unwrap().type_label(),
295 "boolean",
296 "the original option is untouched"
297 );
298 }
299
300 #[test]
301 fn set_and_get_round_trip_through_the_registry() {
302 let r = registry();
303 register_plugin_option(&r, "plugin.count", PluginOptionKind::Integer, "3", "count");
304 // A plugin option is a first-class registry entry: `:set` works uniformly.
305 r.parse_and_set_command("plugin.count=7")
306 .expect(":set works");
307 assert_eq!(r.lookup("plugin.count").unwrap().get_formatted(), "7");
308 }
309}