Skip to main content

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}