Skip to main content

greentic_ext_runtime/
runtime.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use arc_swap::ArcSwap;
5use tokio::sync::broadcast;
6use wasmtime::Engine;
7
8use crate::capability::{CapabilityRegistry, OfferedBinding};
9use crate::discovery::DiscoveryPaths;
10use crate::error::RuntimeError;
11use crate::loaded::{ExtensionId, HostOverrides, LoadedExtension, LoadedExtensionRef};
12
13/// Filename of the persistent enable/disable state document, located at
14/// `<home>/extensions-state.json`. Kept in sync with the constant of the
15/// same name in `greentic-ext-state` (single source of truth lives there;
16/// this duplicate exists only because the runtime intentionally does not
17/// depend on `greentic-ext-state` to avoid a circular crate dependency).
18const STATE_FILENAME: &str = "extensions-state.json";
19
20/// Configuration passed to [`ExtensionRuntime::new`].
21///
22/// Carries both the filesystem discovery paths and the [`HostOverrides`]
23/// bundle that every dispatch call injects into the wasmtime `HostState`.
24/// Callers that only need defaults (tests, simple CLI tools) can use
25/// [`RuntimeConfig::from_paths`]; production callers that need real
26/// i18n/secrets/HTTP backends chain [`RuntimeConfig::with_host_overrides`]
27/// before handing the config to the runtime.
28#[derive(Clone, Debug)]
29pub struct RuntimeConfig {
30    pub paths: DiscoveryPaths,
31    /// Host-function overrides threaded into every WASM dispatch.
32    /// Defaults to [`HostOverrides::default()`] (key-translator, empty
33    /// secrets, no HTTP client, empty allow-list, no broker weak ref).
34    /// Production callers replace this via [`RuntimeConfig::with_host_overrides`]
35    /// or the ergonomic [`ExtensionRuntime::with_host_overrides`] builder.
36    pub host_overrides: HostOverrides,
37}
38
39impl RuntimeConfig {
40    /// Construct a config from discovery paths, using production-safe
41    /// [`HostOverrides::default()`] (no HTTP client, empty secrets/i18n).
42    #[must_use]
43    pub fn from_paths(paths: DiscoveryPaths) -> Self {
44        Self {
45            paths,
46            host_overrides: HostOverrides::default(),
47        }
48    }
49
50    /// Replace the [`HostOverrides`] bundle. Returns `self` for builder-style
51    /// chaining:
52    ///
53    /// ```ignore
54    /// let config = RuntimeConfig::from_paths(paths)
55    ///     .with_host_overrides(production_overrides);
56    /// ```
57    #[must_use]
58    pub fn with_host_overrides(mut self, overrides: HostOverrides) -> Self {
59        self.host_overrides = overrides;
60        self
61    }
62}
63
64pub struct ExtensionRuntime {
65    engine: Engine,
66    config: RuntimeConfig,
67    loaded: ArcSwap<HashMap<ExtensionId, LoadedExtensionRef>>,
68    capability_registry: ArcSwap<CapabilityRegistry>,
69    events: broadcast::Sender<RuntimeEvent>,
70}
71
72#[derive(Debug, Clone)]
73pub enum RuntimeEvent {
74    ExtensionInstalled(ExtensionId),
75    ExtensionUpdated {
76        id: ExtensionId,
77        prev_version: String,
78    },
79    ExtensionRemoved(ExtensionId),
80    CapabilityRegistryRebuilt,
81    /// `~/.greentic/extensions-state.json` was created or modified. Subscribers
82    /// should reload extension state and re-apply their enable/disable filter.
83    StateFileChanged,
84}
85
86/// Returned by [`ExtensionRuntime::start_watcher`]. Dropping this stops the
87/// watcher thread cleanly (within ~200 ms).
88pub struct WatcherGuard {
89    stop_tx: Option<std::sync::mpsc::Sender<()>>,
90    join: Option<std::thread::JoinHandle<()>>,
91}
92
93impl Drop for WatcherGuard {
94    fn drop(&mut self) {
95        // Drop stop_tx first to signal the thread, then join.
96        drop(self.stop_tx.take());
97        if let Some(handle) = self.join.take() {
98            let _ = handle.join();
99        }
100    }
101}
102
103impl ExtensionRuntime {
104    pub fn new(config: RuntimeConfig) -> Result<Self, RuntimeError> {
105        let mut ec = wasmtime::Config::new();
106        ec.wasm_component_model(true);
107
108        // Persist compiled component artifacts to an on-disk cache. Without
109        // this, every `Component::from_file` recompiles the WASM via Cranelift
110        // on each boot — the dominant designer startup cost (~28 extensions,
111        // several seconds). The default cache keys on the module bytes plus the
112        // compiler settings, so a warm cache turns subsequent boots into a
113        // deserialize instead of a recompile. Failing to initialise the cache
114        // is non-fatal: we log and fall back to the no-cache (recompile) path.
115        match wasmtime::Cache::from_file(None) {
116            Ok(cache) => {
117                ec.cache(Some(cache));
118            }
119            Err(e) => {
120                tracing::warn!("wasmtime compilation cache disabled: {e}");
121            }
122        }
123
124        let engine = Engine::new(&ec).map_err(|e| RuntimeError::Wasmtime(e.into()))?;
125        let (tx, _) = broadcast::channel(64);
126        Ok(Self {
127            engine,
128            config,
129            loaded: ArcSwap::from_pointee(HashMap::new()),
130            capability_registry: ArcSwap::from_pointee(CapabilityRegistry::default()),
131            events: tx,
132        })
133    }
134
135    /// Construct a runtime with **no extensions loaded**, for downstream
136    /// unit tests that need an `ExtensionRuntime` instance but do not
137    /// exercise real WASM dispatch.
138    ///
139    /// Uses production-safe [`HostOverrides::default()`] and a throwaway
140    /// discovery path that is never read (no extension is ever loaded).
141    /// `list_tools` returns empty; `invoke_tool` returns a not-found error.
142    ///
143    /// This exists so crates like `greentic-aw-runtime` can build an
144    /// `Arc<ExtensionRuntime>` in `--features test-mock` unit tests
145    /// without a live extension directory. Do NOT use in production.
146    #[must_use]
147    pub fn for_test() -> Self {
148        let paths =
149            DiscoveryPaths::new(std::path::PathBuf::from("/nonexistent/aw-ext-runtime-test"));
150        Self::new(RuntimeConfig::from_paths(paths))
151            .expect("for_test ExtensionRuntime construction is infallible")
152    }
153
154    /// Replace the [`HostOverrides`] bundle used for every dispatch. Call
155    /// once at startup with adapters that wrap real backends (i18n
156    /// catalogue, secrets store, allow-listed HTTP client). Without this,
157    /// host fns resolve through [`HostOverrides::default`] — fine for unit
158    /// tests, not for production: i18n returns the key, secrets are empty,
159    /// http allow-list is empty.
160    ///
161    /// This is a thin ergonomic wrapper over
162    /// [`RuntimeConfig::with_host_overrides`] for callers that already hold
163    /// an `ExtensionRuntime` instance:
164    ///
165    /// ```ignore
166    /// let runtime = ExtensionRuntime::new(config)?
167    ///     .with_host_overrides(production_overrides);
168    /// ```
169    #[must_use]
170    pub fn with_host_overrides(mut self, host_overrides: HostOverrides) -> Self {
171        self.config.host_overrides = host_overrides;
172        self
173    }
174
175    #[must_use]
176    pub fn engine(&self) -> &Engine {
177        &self.engine
178    }
179
180    /// Sister modules (`runtime_roles`) reach for the active overrides via
181    /// this accessor instead of touching `config` directly.
182    #[must_use]
183    pub(crate) fn host_overrides(&self) -> &HostOverrides {
184        &self.config.host_overrides
185    }
186
187    #[must_use]
188    pub fn subscribe(&self) -> broadcast::Receiver<RuntimeEvent> {
189        self.events.subscribe()
190    }
191
192    #[must_use]
193    pub fn config(&self) -> &RuntimeConfig {
194        &self.config
195    }
196
197    #[must_use]
198    pub fn loaded(&self) -> Arc<HashMap<ExtensionId, LoadedExtensionRef>> {
199        self.loaded.load_full()
200    }
201
202    #[must_use]
203    pub fn capability_registry(&self) -> Arc<CapabilityRegistry> {
204        self.capability_registry.load_full()
205    }
206
207    pub fn register_loaded_from_dir(&mut self, dir: &std::path::Path) -> Result<(), RuntimeError> {
208        Self::verify_dir_signature(dir)?;
209        let loaded = LoadedExtension::load_from_dir(&self.engine, dir)?;
210        let id = loaded.id.clone();
211
212        // Build new registry: clone existing offerings, add new extension's offerings.
213        let mut new_registry = CapabilityRegistry::new();
214        for existing in self.capability_registry.load().offerings() {
215            new_registry.add_offering(existing.clone());
216        }
217        for cap in &loaded.describe.capabilities.offered {
218            let version: semver::Version = cap.version.parse().map_err(|e: semver::Error| {
219                RuntimeError::Wasmtime(anyhow::anyhow!("bad offered version: {e}"))
220            })?;
221            new_registry.add_offering(OfferedBinding {
222                extension_id: id.as_str().to_string(),
223                cap_id: cap.id.clone(),
224                version,
225                kind: loaded.kind,
226                export_path: String::new(),
227            });
228        }
229
230        // Atomically swap in new loaded map and registry.
231        let mut new_map = (**self.loaded.load()).clone();
232        new_map.insert(id.clone(), Arc::new(loaded));
233        self.loaded.store(Arc::new(new_map));
234        self.capability_registry.store(Arc::new(new_registry));
235
236        let _ = self.events.send(RuntimeEvent::ExtensionInstalled(id));
237        Ok(())
238    }
239
240    fn verify_dir_signature(dir: &std::path::Path) -> Result<(), RuntimeError> {
241        #[cfg(feature = "dev-allow-unsigned")]
242        if std::env::var("GREENTIC_EXT_ALLOW_UNSIGNED").is_ok() {
243            tracing::warn!(
244                extension_dir = %dir.display(),
245                "GREENTIC_EXT_ALLOW_UNSIGNED is set — signature verification skipped"
246            );
247            return Ok(());
248        }
249        let path = dir.join("describe.json");
250        let raw = std::fs::read_to_string(&path)?;
251        // Migrate a v1 describe to the current shape before deserializing, the
252        // same way `LoadedExtension::load_from_dir` does — the bundled fallback
253        // extensions are all v1 and would otherwise fail here with a raw
254        // "expected struct Knowledge". Unsigned describes (all bundled ones)
255        // carry no signature, so the self-consistency check below is unaffected
256        // by the migration.
257        let describe_value: serde_json::Value = serde_json::from_str(&raw)?;
258        let describe = crate::loaded::describe_from_value(describe_value)
259            .map_err(|e| RuntimeError::Wasmtime(anyhow::anyhow!("read describe.json: {e}")))?;
260        // Integrity: the describe is unmodified since signing. This is NOT
261        // authenticity — it proves nothing about *who* signed (an attacker can
262        // re-sign with their own key). Anchored authenticity
263        // (`verify_describe_with_key` against a trust-store / RootVerifier key)
264        // is the audit C1 follow-up; it needs a runtime trust store and the
265        // org-provisioned prod root key (both currently blocked).
266        greentic_extension_sdk_contract::verify_describe_self_consistent(&describe).map_err(
267            |e| RuntimeError::SignatureInvalid {
268                extension_id: describe.metadata.id.clone(),
269                reason: e.to_string(),
270            },
271        )?;
272        let pub_prefix = describe.signature.as_ref().map_or_else(
273            || "?".to_string(),
274            |s| s.public_key.chars().take(16).collect::<String>(),
275        );
276        tracing::info!(
277            extension_id = %describe.metadata.id,
278            key_prefix = %pub_prefix,
279            "extension signature verified"
280        );
281        Self::verify_dir_manifest(dir, &describe)?;
282        Ok(())
283    }
284
285    /// Verify the unpacked extension dir against its `manifest.json`
286    /// (whole-archive integrity ledger).
287    ///
288    /// Audit P5 hardening — fail **closed**:
289    /// - A missing `manifest.json` is now a hard error. Pre-ledger packs only
290    ///   load under the `dev-allow-unsigned` escape (checked upstream in
291    ///   [`verify_dir_signature`]); production refuses an unverifiable pack.
292    /// - The describe's manifest binding (`manifestSha256`) must match the
293    ///   on-disk `manifest.json`, so the (signed) describe transitively commits
294    ///   to the ledger — an attacker cannot swap the manifest without breaking
295    ///   the describe signature ([`verify_manifest_binding`]).
296    /// - Every file the manifest lists must then hash to the recorded sha256.
297    ///
298    /// Closes audit P0 #2 (wasm + sibling archive entries unsigned) and the C2
299    /// binding gap on the consumer side.
300    fn verify_dir_manifest(
301        dir: &std::path::Path,
302        describe: &greentic_extension_sdk_contract::DescribeJson,
303    ) -> Result<(), RuntimeError> {
304        use sha2::{Digest, Sha256};
305        let extension_id = describe.metadata.id.as_str();
306        let manifest_path = dir.join(greentic_extension_sdk_contract::MANIFEST_ENTRY_NAME);
307        if !manifest_path.exists() {
308            return Err(RuntimeError::SignatureInvalid {
309                extension_id: extension_id.to_string(),
310                reason: "manifest.json absent — refusing to load an extension without a \
311                         whole-archive integrity ledger (set GREENTIC_EXT_ALLOW_UNSIGNED \
312                         with the dev-allow-unsigned build for local dev)"
313                    .to_string(),
314            });
315        }
316        let raw = std::fs::read(&manifest_path)?;
317        // Binding: the signed describe commits to exactly this manifest, so the
318        // signature transitively covers the ledger (audit C2). Rejects both a
319        // swapped manifest and an unbound (legacy) describe carrying a manifest.
320        greentic_extension_sdk_contract::verify_manifest_binding(describe, &raw).map_err(|e| {
321            RuntimeError::SignatureInvalid {
322                extension_id: extension_id.to_string(),
323                reason: format!("manifest binding: {e}"),
324            }
325        })?;
326        let manifest: greentic_extension_sdk_contract::Manifest = serde_json::from_slice(&raw)
327            .map_err(|e| RuntimeError::SignatureInvalid {
328                extension_id: extension_id.to_string(),
329                reason: format!("manifest.json parse: {e}"),
330            })?;
331        if manifest.schema != greentic_extension_sdk_contract::MANIFEST_SCHEMA_V1 {
332            return Err(RuntimeError::SignatureInvalid {
333                extension_id: extension_id.to_string(),
334                reason: format!("manifest schema unsupported: {}", manifest.schema),
335            });
336        }
337        for entry in &manifest.entries {
338            let path = dir.join(&entry.path);
339            if !path.exists() {
340                return Err(RuntimeError::SignatureInvalid {
341                    extension_id: extension_id.to_string(),
342                    reason: format!("manifest lists missing file: {}", entry.path),
343                });
344            }
345            let bytes = std::fs::read(&path)?;
346            let computed = format!("{:x}", Sha256::digest(&bytes));
347            if computed != entry.sha256 {
348                return Err(RuntimeError::SignatureInvalid {
349                    extension_id: extension_id.to_string(),
350                    reason: format!(
351                        "manifest sha256 mismatch for {}: expected {} got {}",
352                        entry.path, entry.sha256, computed
353                    ),
354                });
355            }
356        }
357        tracing::info!(
358            extension_id = %extension_id,
359            entries = manifest.entries.len(),
360            "whole-archive manifest verified"
361        );
362        Ok(())
363    }
364
365    /// Spawns a watcher background thread. Events trigger reload of the
366    /// affected extension's directory. Returns a stop sender — dropping or
367    /// sending on it signals the watcher thread to exit. Also returns the
368    /// thread `JoinHandle` for callers that want to wait for clean shutdown.
369    pub fn start_watcher(self: Arc<Self>) -> Result<WatcherGuard, RuntimeError> {
370        let mut paths: Vec<std::path::PathBuf> =
371            self.config.paths.all().into_iter().cloned().collect();
372        // Also watch the parent of the extensions root so we receive events
373        // for `<home>/extensions-state.json`. Best-effort: if the home dir
374        // doesn't exist or has no parent we silently skip — the kind dirs
375        // are still watched.
376        if let Some(home) = self.config.paths.home()
377            && home.exists()
378            && !paths.iter().any(|p| p == home)
379        {
380            paths.push(home.to_path_buf());
381        }
382        let (rx, watch_handle) = crate::watcher::watch(&paths)?;
383        let (stop_tx, stop_rx) = std::sync::mpsc::channel::<()>();
384        let this = self.clone();
385        let join = std::thread::spawn(move || {
386            // Own the watch_handle here — dropping it closes the fs watcher
387            // and the tx side of the FsEvent channel when this thread exits.
388            let _watch_handle = watch_handle;
389            loop {
390                // Check stop signal (Ok = message received, Disconnected = sender dropped).
391                match stop_rx.try_recv() {
392                    Ok(()) | Err(std::sync::mpsc::TryRecvError::Disconnected) => break,
393                    Err(std::sync::mpsc::TryRecvError::Empty) => {}
394                }
395                match rx.recv_timeout(std::time::Duration::from_millis(200)) {
396                    Ok(event) => {
397                        if let Err(e) = this.handle_fs_event(&event) {
398                            tracing::warn!(error = %e, "hot reload failed");
399                        }
400                    }
401                    Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
402                    Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
403                }
404            }
405        });
406        Ok(WatcherGuard {
407            stop_tx: Some(stop_tx),
408            join: Some(join),
409        })
410    }
411
412    fn handle_fs_event(&self, event: &crate::watcher::FsEvent) -> Result<(), RuntimeError> {
413        use crate::watcher::FsEvent;
414        let path = match event {
415            FsEvent::Added(p) | FsEvent::Modified(p) | FsEvent::Removed(p) => p.clone(),
416        };
417
418        // Classify state file events first. The state file lives at
419        // `<home>/extensions-state.json`, which is outside the per-kind
420        // extension dirs, so `find_extension_dir` would return None — but
421        // matching by filename is cheaper and unambiguous.
422        if path.file_name().is_some_and(|n| n == STATE_FILENAME) {
423            let _ = self.events.send(RuntimeEvent::StateFileChanged);
424            return Ok(());
425        }
426
427        let ext_dir = find_extension_dir(&path);
428        match event {
429            FsEvent::Removed(_) => {
430                if let Some(dir) = ext_dir {
431                    self.handle_removal(&dir);
432                }
433            }
434            FsEvent::Added(_) | FsEvent::Modified(_) => {
435                if let Some(dir) = ext_dir {
436                    self.handle_added_or_modified(&dir)?;
437                }
438            }
439        }
440        Ok(())
441    }
442
443    fn handle_removal(&self, dir: &std::path::Path) {
444        let current = self.loaded.load();
445        let Some((id, _)) = current.iter().find(|(_, v)| v.source_dir == dir) else {
446            return;
447        };
448        let id = id.clone();
449        let mut new_map = (**current).clone();
450        new_map.remove(&id);
451        self.loaded.store(Arc::new(new_map));
452        let _ = self.events.send(RuntimeEvent::ExtensionRemoved(id));
453    }
454
455    fn handle_added_or_modified(&self, dir: &std::path::Path) -> Result<(), RuntimeError> {
456        let loaded = crate::loaded::LoadedExtension::load_from_dir(&self.engine, dir)?;
457        let id = loaded.id.clone();
458        let mut new_map = (**self.loaded.load()).clone();
459        let prev_version = new_map
460            .get(&id)
461            .map(|e| e.describe.metadata.version.clone());
462        new_map.insert(id.clone(), Arc::new(loaded));
463        self.loaded.store(Arc::new(new_map));
464        let event = match prev_version {
465            Some(prev) => RuntimeEvent::ExtensionUpdated {
466                id,
467                prev_version: prev,
468            },
469            None => RuntimeEvent::ExtensionInstalled(id),
470        };
471        let _ = self.events.send(event);
472        Ok(())
473    }
474}
475
476impl ExtensionRuntime {
477    /// Invoke a named tool on a loaded extension.
478    ///
479    /// Builds a fresh wasmtime Store + Instance, calls
480    /// `greentic:extension-design/tools::invoke-tool` (resolved
481    /// newest-first across 0.3.0/0.2.0/0.1.0; WIT errors surface as
482    /// `RuntimeError::Extension`), and returns the JSON result string.
483    pub fn invoke_tool(
484        &self,
485        ext_id: &str,
486        tool_name: &str,
487        args_json: &str,
488    ) -> Result<String, RuntimeError> {
489        self.invoke_tool_ctx(
490            ext_id,
491            tool_name,
492            args_json,
493            &crate::host_ports::HostCallContext::default(),
494        )
495    }
496
497    /// Like [`Self::invoke_tool`] but threads a per-call
498    /// [`crate::host_ports::HostCallContext`] (e.g. the caller's tenant slug)
499    /// into the host ports for this dispatch. Multi-tenant hosts (the
500    /// designer) use this so the LLM port can resolve roles per-tenant.
501    pub fn invoke_tool_ctx(
502        &self,
503        ext_id: &str,
504        tool_name: &str,
505        args_json: &str,
506        ctx: &crate::host_ports::HostCallContext,
507    ) -> Result<String, RuntimeError> {
508        let loaded = self
509            .loaded
510            .load()
511            .get(&crate::loaded::ExtensionId(ext_id.to_string()))
512            .cloned()
513            .ok_or_else(|| RuntimeError::NotFound(ext_id.to_string()))?;
514
515        let (mut store, instance) = loaded
516            .build_store_and_instance(&self.engine, self.config.host_overrides.clone(), ctx)
517            .map_err(RuntimeError::Wasmtime)?;
518
519        // Resolve the nested export: first the interface instance, then the function.
520        // This is the wasmtime 43 pattern: get_export_index(store, parent, name).
521        // The interface is resolved newest-first across the design version table;
522        // the matched version selects which `extension-error` ABI to deserialize
523        // (6-variant base at 0.3.0, 4-variant base at 0.2.0/0.1.0). The
524        // invoke-tool signature is identical across versions — only the error
525        // variant set differs.
526        let (iface_idx, iface_name, version) = resolve_iface_versions(
527            &mut store,
528            &instance,
529            "greentic:extension-design/tools",
530            DESIGN_VERSIONS,
531        )?;
532        warn_if_legacy_contract(ext_id, version, DESIGN_VERSIONS[0]);
533        let func_idx = instance
534            .get_export_index(&mut store, Some(&iface_idx), "invoke-tool")
535            .ok_or_else(|| {
536                RuntimeError::Wasmtime(anyhow::anyhow!(
537                    "interface '{iface_name}' does not export 'invoke-tool'"
538                ))
539            })?;
540
541        let call_args = (tool_name.to_string(), args_json.to_string());
542        // post_return is deprecated/no-op in wasmtime 43 — not called.
543        let mapped: Result<String, crate::types::HostExtensionError> = if version == "0.3.0" {
544            use crate::host_bindings::design_v03::greentic::extension_base0_2_0::types::ExtensionError as E2;
545            let func = instance
546                .get_typed_func::<(String, String), (Result<String, E2>,)>(&mut store, &func_idx)
547                .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
548            let (r,) = func
549                .call(&mut store, call_args)
550                .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
551            r.map_err(crate::ext_error::from_design_v03)
552        } else {
553            use crate::host_bindings::greentic::extension_base0_1_0::types::ExtensionError as E1;
554            let func = instance
555                .get_typed_func::<(String, String), (Result<String, E1>,)>(&mut store, &func_idx)
556                .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
557            let (r,) = func
558                .call(&mut store, call_args)
559                .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
560            r.map_err(crate::ext_error::from_design_v01)
561        };
562
563        mapped.map_err(RuntimeError::Extension)
564    }
565}
566
567impl ExtensionRuntime {
568    /// Evaluate a guardrail extension against `input_json` and return the
569    /// verdict as a JSON string.
570    ///
571    /// Loads the extension identified by `ext_id`, resolves the
572    /// `greentic:extension-design/guardrail@0.3.0` interface, calls the
573    /// `evaluate` export, and maps the returned `verdict` variant to
574    /// [`crate::GuardrailVerdictWire`] serialised as JSON.
575    ///
576    /// `input_json` must be a JSON object with fields matching the WIT
577    /// `guardrail-input` record:
578    ///
579    /// ```json
580    /// {
581    ///   "direction": "inbound",
582    ///   "content": "…",
583    ///   "agent_id": "…",
584    ///   "session_id": "…",
585    ///   "tenant_id": "…",
586    ///   "env_id": "…",
587    ///   "context": null
588    /// }
589    /// ```
590    ///
591    /// Returns the verdict as JSON, e.g. `{"kind":"accept"}` or
592    /// `{"kind":"deny","code":"…","message":"…","details":null}`.
593    ///
594    /// # Errors
595    ///
596    /// - [`RuntimeError::NotFound`] when no extension is loaded at `ext_id`.
597    /// - [`RuntimeError::Wasmtime`] when store/instance construction fails,
598    ///   the interface is not exported, the typed-func call fails, or
599    ///   `input_json` cannot be deserialised.
600    pub fn evaluate_guardrail(
601        &self,
602        ext_id: &str,
603        input_json: &str,
604    ) -> Result<String, RuntimeError> {
605        let loaded = self
606            .loaded
607            .load()
608            .get(&crate::loaded::ExtensionId(ext_id.to_string()))
609            .cloned()
610            .ok_or_else(|| RuntimeError::NotFound(ext_id.to_string()))?;
611
612        let (mut store, instance) = loaded
613            .build_store_and_instance(
614                &self.engine,
615                self.config.host_overrides.clone(),
616                &crate::host_ports::HostCallContext::default(),
617            )
618            .map_err(RuntimeError::Wasmtime)?;
619
620        // Guardrail interface only exists at 0.3.0 — a single-version table.
621        let (iface_idx, iface_name, _version) = resolve_iface_versions(
622            &mut store,
623            &instance,
624            "greentic:extension-design/guardrail",
625            GUARDRAIL_VERSIONS,
626        )?;
627
628        let func_idx = instance
629            .get_export_index(&mut store, Some(&iface_idx), "evaluate")
630            .ok_or_else(|| {
631                RuntimeError::Wasmtime(anyhow::anyhow!(
632                    "interface '{iface_name}' does not export 'evaluate'"
633                ))
634            })?;
635
636        let wire =
637            crate::guardrail_map::call_evaluate(&mut store, &instance, &func_idx, input_json)?;
638
639        serde_json::to_string(&wire).map_err(|e| RuntimeError::Wasmtime(e.into()))
640    }
641}
642
643impl ExtensionRuntime {
644    /// Validate extension-specific content against the extension's schema.
645    ///
646    /// Calls `greentic:extension-design/validation::validate-content`
647    /// (resolved against `@0.2.0` first, then `@0.1.0`).
648    /// `content_type` is an extension-defined label (e.g. `"AdaptiveCard"`
649    /// for the adaptive-cards extension); `content_json` is the content
650    /// payload as a JSON string.
651    ///
652    /// Returns a [`types::ValidateResult`] with a `valid` flag and a list of
653    /// diagnostics (error/warning/info/hint severities). Extensions that
654    /// don't export this interface surface a `Wasmtime` error — callers
655    /// that want graceful degradation should treat "interface not exported"
656    /// as "no validation available" rather than a hard failure.
657    pub fn validate_content(
658        &self,
659        ext_id: &str,
660        content_type: &str,
661        content_json: &str,
662    ) -> Result<crate::types::ValidateResult, RuntimeError> {
663        use crate::host_bindings::exports::greentic::extension_design0_2_0::validation::{
664            Diagnostic as WitDiagnostic, ValidateResult as WitValidateResult,
665        };
666        use crate::host_bindings::greentic::extension_base0_1_0::types::Severity as WitSeverity;
667
668        let loaded = self
669            .loaded
670            .load()
671            .get(&crate::loaded::ExtensionId(ext_id.to_string()))
672            .cloned()
673            .ok_or_else(|| RuntimeError::NotFound(ext_id.to_string()))?;
674
675        let (mut store, instance) = loaded
676            .build_store_and_instance(
677                &self.engine,
678                self.config.host_overrides.clone(),
679                &crate::host_ports::HostCallContext::default(),
680            )
681            .map_err(RuntimeError::Wasmtime)?;
682
683        let (iface_idx, iface_name) = resolve_design_iface(
684            &mut store,
685            &instance,
686            "greentic:extension-design/validation",
687        )?;
688        let func_idx = instance
689            .get_export_index(&mut store, Some(&iface_idx), "validate-content")
690            .ok_or_else(|| {
691                RuntimeError::Wasmtime(anyhow::anyhow!(
692                    "interface '{iface_name}' does not export 'validate-content'"
693                ))
694            })?;
695
696        let func = instance
697            .get_typed_func::<(String, String), (WitValidateResult,)>(&mut store, &func_idx)
698            .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
699
700        let (result,) = func
701            .call(
702                &mut store,
703                (content_type.to_string(), content_json.to_string()),
704            )
705            .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
706
707        let diagnostics = result
708            .diagnostics
709            .into_iter()
710            .map(|d: WitDiagnostic| crate::types::Diagnostic {
711                severity: match d.severity {
712                    WitSeverity::Error => crate::types::Severity::Error,
713                    WitSeverity::Warning => crate::types::Severity::Warning,
714                    WitSeverity::Info => crate::types::Severity::Info,
715                    WitSeverity::Hint => crate::types::Severity::Hint,
716                },
717                code: d.code,
718                message: d.message,
719                path: d.path,
720            })
721            .collect();
722
723        Ok(crate::types::ValidateResult {
724            valid: result.valid,
725            diagnostics,
726        })
727    }
728}
729
730/// Map a v2 describe `Tool` contribution to a host-side [`crate::types::ToolDefinition`].
731///
732/// `description` and `input_schema_json` come from the declarative
733/// `describe.json` tool entry (`Tool.description` / `Tool.input_schema`, the
734/// latter a JSON-Schema string mirroring `NodeType.config_schema`). A tool that
735/// omits them surfaces empty values, so callers can still offer the tool — but
736/// an empty input schema means the LLM cannot infer the tool's arguments.
737#[must_use]
738pub fn contribution_tool_to_definition(
739    t: &greentic_extension_sdk_contract::describe::contributions::Tool,
740) -> crate::types::ToolDefinition {
741    crate::types::ToolDefinition {
742        name: t.name.clone(),
743        description: t.description.clone().unwrap_or_default(),
744        input_schema_json: t.input_schema.clone().unwrap_or_default(),
745        output_schema_json: None,
746        capabilities: t.capabilities.clone(),
747        agentic_worker_metadata: None,
748        secret_requirements: t.secret_requirements.clone(),
749    }
750}
751
752impl ExtensionRuntime {
753    /// List all tools exposed by a loaded design extension.
754    ///
755    /// Calls `greentic:extension-design/tools::list-tools` (resolved
756    /// against `@0.2.0` first, then `@0.1.0`) for v1-contract
757    /// extensions. **v2 contract** (`apiVersion == "greentic.ai/v2"`)
758    /// reads the tools from `describe.contributions.tools[]` — the
759    /// runtime WIT no longer exports `list-tools` in that contract.
760    /// The declarative v2 entries only carry `name` + `export`, so
761    /// `description` / `input_schema_json` come back empty; callers
762    /// that need full schemas must introspect the named WIT export.
763    pub fn list_tools(
764        &self,
765        ext_id: &str,
766    ) -> Result<Vec<crate::types::ToolDefinition>, RuntimeError> {
767        use crate::host_bindings::exports::greentic::extension_design0_2_0::tools::ToolDefinition as WitToolDef;
768
769        let loaded = self
770            .loaded
771            .load()
772            .get(&crate::loaded::ExtensionId(ext_id.to_string()))
773            .cloned()
774            .ok_or_else(|| RuntimeError::NotFound(ext_id.to_string()))?;
775
776        // v2 declarative path: tools live in describe.json, not in WIT.
777        if loaded.describe.api_version == "greentic.ai/v2" {
778            return Ok(loaded
779                .describe
780                .contributions
781                .tools
782                .iter()
783                .map(contribution_tool_to_definition)
784                .collect());
785        }
786
787        // v1 WIT-call path.
788        let (mut store, instance) = loaded
789            .build_store_and_instance(
790                &self.engine,
791                self.config.host_overrides.clone(),
792                &crate::host_ports::HostCallContext::default(),
793            )
794            .map_err(RuntimeError::Wasmtime)?;
795
796        let (iface_idx, iface_name) =
797            resolve_design_iface(&mut store, &instance, "greentic:extension-design/tools")?;
798        let func_idx = instance
799            .get_export_index(&mut store, Some(&iface_idx), "list-tools")
800            .ok_or_else(|| {
801                RuntimeError::Wasmtime(anyhow::anyhow!(
802                    "interface '{iface_name}' does not export 'list-tools'"
803                ))
804            })?;
805
806        let func = instance
807            .get_typed_func::<(), (Vec<WitToolDef>,)>(&mut store, &func_idx)
808            .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
809
810        let (defs,) = func
811            .call(&mut store, ())
812            .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
813
814        Ok(defs
815            .into_iter()
816            .map(|d| crate::types::ToolDefinition {
817                name: d.name,
818                description: d.description,
819                input_schema_json: d.input_schema_json,
820                output_schema_json: d.output_schema_json,
821                capabilities: d.capabilities,
822                agentic_worker_metadata: d.agentic_worker_metadata,
823                secret_requirements: Vec::new(),
824            })
825            .collect())
826    }
827}
828
829impl ExtensionRuntime {
830    /// Retrieve system prompt fragments from a loaded design extension.
831    ///
832    /// Calls `greentic:extension-design/prompting::system-prompt-fragments`
833    /// (resolved against `@0.2.0` first, then `@0.1.0`).
834    pub fn prompt_fragments(
835        &self,
836        ext_id: &str,
837    ) -> Result<Vec<crate::types::PromptFragment>, RuntimeError> {
838        use crate::host_bindings::exports::greentic::extension_design0_2_0::prompting::PromptFragment as WitFrag;
839
840        let loaded = self
841            .loaded
842            .load()
843            .get(&crate::loaded::ExtensionId(ext_id.to_string()))
844            .cloned()
845            .ok_or_else(|| RuntimeError::NotFound(ext_id.to_string()))?;
846
847        let (mut store, instance) = loaded
848            .build_store_and_instance(
849                &self.engine,
850                self.config.host_overrides.clone(),
851                &crate::host_ports::HostCallContext::default(),
852            )
853            .map_err(RuntimeError::Wasmtime)?;
854
855        let (iface_idx, iface_name) =
856            resolve_design_iface(&mut store, &instance, "greentic:extension-design/prompting")?;
857        let func_idx = instance
858            .get_export_index(&mut store, Some(&iface_idx), "system-prompt-fragments")
859            .ok_or_else(|| {
860                RuntimeError::Wasmtime(anyhow::anyhow!(
861                    "interface '{iface_name}' does not export 'system-prompt-fragments'"
862                ))
863            })?;
864
865        let func = instance
866            .get_typed_func::<(), (Vec<WitFrag>,)>(&mut store, &func_idx)
867            .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
868
869        let (frags,) = func
870            .call(&mut store, ())
871            .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
872
873        Ok(frags
874            .into_iter()
875            .map(|f| crate::types::PromptFragment {
876                section: f.section,
877                content_markdown: f.content_markdown,
878                priority: f.priority,
879            })
880            .collect())
881    }
882}
883
884impl ExtensionRuntime {
885    /// List knowledge entries, optionally filtered by category.
886    ///
887    /// Calls `greentic:extension-design/knowledge::list-entries`
888    /// (resolved against `@0.2.0` first, then `@0.1.0`).
889    pub fn knowledge_list(
890        &self,
891        ext_id: &str,
892        category_filter: Option<&str>,
893    ) -> Result<Vec<crate::types::KnowledgeEntrySummary>, RuntimeError> {
894        use crate::host_bindings::exports::greentic::extension_design0_2_0::knowledge::EntrySummary as WitSummary;
895
896        let loaded = self
897            .loaded
898            .load()
899            .get(&crate::loaded::ExtensionId(ext_id.to_string()))
900            .cloned()
901            .ok_or_else(|| RuntimeError::NotFound(ext_id.to_string()))?;
902
903        let (mut store, instance) = loaded
904            .build_store_and_instance(
905                &self.engine,
906                self.config.host_overrides.clone(),
907                &crate::host_ports::HostCallContext::default(),
908            )
909            .map_err(RuntimeError::Wasmtime)?;
910
911        let (iface_idx, iface_name) =
912            resolve_design_iface(&mut store, &instance, "greentic:extension-design/knowledge")?;
913        let func_idx = instance
914            .get_export_index(&mut store, Some(&iface_idx), "list-entries")
915            .ok_or_else(|| {
916                RuntimeError::Wasmtime(anyhow::anyhow!(
917                    "interface '{iface_name}' does not export 'list-entries'"
918                ))
919            })?;
920
921        let func = instance
922            .get_typed_func::<(Option<String>,), (Vec<WitSummary>,)>(&mut store, &func_idx)
923            .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
924
925        let (entries,) = func
926            .call(&mut store, (category_filter.map(String::from),))
927            .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
928
929        Ok(entries.into_iter().map(wit_summary_to_host).collect())
930    }
931
932    /// Retrieve a single knowledge entry by ID.
933    ///
934    /// Calls `greentic:extension-design/knowledge::get-entry`, resolving the
935    /// interface newest-first across `@0.3.0`/`@0.2.0`/`@0.1.0`. The matched
936    /// version selects which `extension-error` ABI to deserialize (6-variant
937    /// base at `@0.3.0`, 4-variant base at `@0.2.0`/`@0.1.0`); WIT errors
938    /// surface as `RuntimeError::Extension`.
939    pub fn knowledge_get(
940        &self,
941        ext_id: &str,
942        entry_id: &str,
943    ) -> Result<crate::types::KnowledgeEntry, RuntimeError> {
944        let loaded = self
945            .loaded
946            .load()
947            .get(&crate::loaded::ExtensionId(ext_id.to_string()))
948            .cloned()
949            .ok_or_else(|| RuntimeError::NotFound(ext_id.to_string()))?;
950
951        let (mut store, instance) = loaded
952            .build_store_and_instance(
953                &self.engine,
954                self.config.host_overrides.clone(),
955                &crate::host_ports::HostCallContext::default(),
956            )
957            .map_err(RuntimeError::Wasmtime)?;
958
959        let (iface_idx, iface_name, version) = resolve_iface_versions(
960            &mut store,
961            &instance,
962            "greentic:extension-design/knowledge",
963            DESIGN_VERSIONS,
964        )?;
965        let func_idx = instance
966            .get_export_index(&mut store, Some(&iface_idx), "get-entry")
967            .ok_or_else(|| {
968                RuntimeError::Wasmtime(anyhow::anyhow!(
969                    "interface '{iface_name}' does not export 'get-entry'"
970                ))
971            })?;
972
973        let call_args = (entry_id.to_string(),);
974        let mapped: Result<crate::types::KnowledgeEntry, crate::types::HostExtensionError> =
975            if version == "0.3.0" {
976                use crate::host_bindings::design_v03::exports::greentic::extension_design0_3_0::knowledge::{
977                    Entry as WitEntry, ExtensionError as E2,
978                };
979                let func = instance
980                    .get_typed_func::<(String,), (Result<WitEntry, E2>,)>(&mut store, &func_idx)
981                    .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
982                let (r,) = func
983                    .call(&mut store, call_args)
984                    .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
985                r.map(|e| crate::types::KnowledgeEntry {
986                    id: e.id,
987                    title: e.title,
988                    category: e.category,
989                    tags: e.tags,
990                    content_json: e.content_json,
991                })
992                .map_err(crate::ext_error::from_design_v03)
993            } else {
994                use crate::host_bindings::exports::greentic::extension_design0_2_0::knowledge::{
995                    Entry as WitEntry, ExtensionError as E1,
996                };
997                let func = instance
998                    .get_typed_func::<(String,), (Result<WitEntry, E1>,)>(&mut store, &func_idx)
999                    .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1000                let (r,) = func
1001                    .call(&mut store, call_args)
1002                    .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1003                r.map(|e| crate::types::KnowledgeEntry {
1004                    id: e.id,
1005                    title: e.title,
1006                    category: e.category,
1007                    tags: e.tags,
1008                    content_json: e.content_json,
1009                })
1010                .map_err(crate::ext_error::from_design_v01)
1011            };
1012
1013        mapped.map_err(RuntimeError::Extension)
1014    }
1015
1016    /// Suggest knowledge entries matching a query.
1017    ///
1018    /// Calls `greentic:extension-design/knowledge::suggest-entries`
1019    /// (resolved against `@0.2.0` first, then `@0.1.0`).
1020    pub fn knowledge_suggest(
1021        &self,
1022        ext_id: &str,
1023        query: &str,
1024        limit: u32,
1025    ) -> Result<Vec<crate::types::KnowledgeEntrySummary>, RuntimeError> {
1026        use crate::host_bindings::exports::greentic::extension_design0_2_0::knowledge::EntrySummary as WitSummary;
1027
1028        let loaded = self
1029            .loaded
1030            .load()
1031            .get(&crate::loaded::ExtensionId(ext_id.to_string()))
1032            .cloned()
1033            .ok_or_else(|| RuntimeError::NotFound(ext_id.to_string()))?;
1034
1035        let (mut store, instance) = loaded
1036            .build_store_and_instance(
1037                &self.engine,
1038                self.config.host_overrides.clone(),
1039                &crate::host_ports::HostCallContext::default(),
1040            )
1041            .map_err(RuntimeError::Wasmtime)?;
1042
1043        let (iface_idx, iface_name) =
1044            resolve_design_iface(&mut store, &instance, "greentic:extension-design/knowledge")?;
1045        let func_idx = instance
1046            .get_export_index(&mut store, Some(&iface_idx), "suggest-entries")
1047            .ok_or_else(|| {
1048                RuntimeError::Wasmtime(anyhow::anyhow!(
1049                    "interface '{iface_name}' does not export 'suggest-entries'"
1050                ))
1051            })?;
1052
1053        let func = instance
1054            .get_typed_func::<(String, u32), (Vec<WitSummary>,)>(&mut store, &func_idx)
1055            .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1056
1057        let (entries,) = func
1058            .call(&mut store, (query.to_string(), limit))
1059            .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1060
1061        Ok(entries.into_iter().map(wit_summary_to_host).collect())
1062    }
1063}
1064
1065/// Convert a bindgen `EntrySummary` to the host-side type.
1066fn wit_summary_to_host(
1067    s: crate::host_bindings::exports::greentic::extension_design0_2_0::knowledge::EntrySummary,
1068) -> crate::types::KnowledgeEntrySummary {
1069    crate::types::KnowledgeEntrySummary {
1070        id: s.id,
1071        title: s.title,
1072        category: s.category,
1073        tags: s.tags,
1074    }
1075}
1076
1077impl ExtensionRuntime {
1078    /// Ask a deploy extension to validate a credentials JSON payload for the
1079    /// given target. Returns diagnostics; empty slice means valid.
1080    pub fn validate_credentials(
1081        &self,
1082        ext_id: &str,
1083        target_id: &str,
1084        credentials_json: &str,
1085    ) -> Result<Vec<crate::types::Diagnostic>, RuntimeError> {
1086        use crate::host_bindings::deploy::exports::greentic::extension_deploy0_1_0::targets::Diagnostic as WitDiagnostic;
1087        use crate::host_bindings::deploy::greentic::extension_base0_1_0::types::Severity as WitSeverity;
1088
1089        let loaded = self
1090            .loaded
1091            .load()
1092            .get(&crate::loaded::ExtensionId(ext_id.to_string()))
1093            .cloned()
1094            .ok_or_else(|| RuntimeError::NotFound(ext_id.to_string()))?;
1095
1096        let (mut store, instance) = loaded
1097            .build_store_and_instance(
1098                &self.engine,
1099                self.config.host_overrides.clone(),
1100                &crate::host_ports::HostCallContext::default(),
1101            )
1102            .map_err(RuntimeError::Wasmtime)?;
1103
1104        let (iface_idx, iface_name, _version) = resolve_iface_versions(
1105            &mut store,
1106            &instance,
1107            "greentic:extension-deploy/targets",
1108            DEPLOY_VERSIONS,
1109        )?;
1110        let func_idx = instance
1111            .get_export_index(&mut store, Some(&iface_idx), "validate-credentials")
1112            .ok_or_else(|| {
1113                RuntimeError::Wasmtime(anyhow::anyhow!(
1114                    "interface '{iface_name}' does not export 'validate-credentials'"
1115                ))
1116            })?;
1117
1118        let func = instance
1119            .get_typed_func::<(String, String), (Vec<WitDiagnostic>,)>(&mut store, &func_idx)
1120            .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1121
1122        let (result,) = func
1123            .call(
1124                &mut store,
1125                (target_id.to_string(), credentials_json.to_string()),
1126            )
1127            .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1128
1129        Ok(result
1130            .into_iter()
1131            .map(|d| crate::types::Diagnostic {
1132                severity: match d.severity {
1133                    WitSeverity::Error => crate::types::Severity::Error,
1134                    WitSeverity::Warning => crate::types::Severity::Warning,
1135                    WitSeverity::Info => crate::types::Severity::Info,
1136                    WitSeverity::Hint => crate::types::Severity::Hint,
1137                },
1138                code: d.code,
1139                message: d.message,
1140                path: d.path,
1141            })
1142            .collect())
1143    }
1144}
1145
1146impl ExtensionRuntime {
1147    /// Return the JSON Schema (as a string) describing credentials required
1148    /// by the given deploy target.
1149    pub fn credential_schema(&self, ext_id: &str, target_id: &str) -> Result<String, RuntimeError> {
1150        let loaded = self
1151            .loaded
1152            .load()
1153            .get(&crate::loaded::ExtensionId(ext_id.to_string()))
1154            .cloned()
1155            .ok_or_else(|| RuntimeError::NotFound(ext_id.to_string()))?;
1156
1157        let (mut store, instance) = loaded
1158            .build_store_and_instance(
1159                &self.engine,
1160                self.config.host_overrides.clone(),
1161                &crate::host_ports::HostCallContext::default(),
1162            )
1163            .map_err(RuntimeError::Wasmtime)?;
1164
1165        let (iface_idx, iface_name, version) = resolve_iface_versions(
1166            &mut store,
1167            &instance,
1168            "greentic:extension-deploy/targets",
1169            DEPLOY_VERSIONS,
1170        )?;
1171        let func_idx = instance
1172            .get_export_index(&mut store, Some(&iface_idx), "credential-schema")
1173            .ok_or_else(|| {
1174                RuntimeError::Wasmtime(anyhow::anyhow!(
1175                    "interface '{iface_name}' does not export 'credential-schema'"
1176                ))
1177            })?;
1178
1179        let call_args = (target_id.to_string(),);
1180        let mapped: Result<String, crate::types::HostExtensionError> = if version == "0.2.0" {
1181            use crate::host_bindings::deploy_v02::greentic::extension_base0_2_0::types::ExtensionError as E2;
1182            let func = instance
1183                .get_typed_func::<(String,), (Result<String, E2>,)>(&mut store, &func_idx)
1184                .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1185            let (r,) = func
1186                .call(&mut store, call_args)
1187                .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1188            r.map_err(crate::ext_error::from_deploy_v02)
1189        } else {
1190            use crate::host_bindings::deploy::greentic::extension_base0_1_0::types::ExtensionError as E1;
1191            let func = instance
1192                .get_typed_func::<(String,), (Result<String, E1>,)>(&mut store, &func_idx)
1193                .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1194            let (r,) = func
1195                .call(&mut store, call_args)
1196                .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1197            r.map_err(crate::ext_error::from_deploy_v01)
1198        };
1199
1200        mapped.map_err(RuntimeError::Extension)
1201    }
1202}
1203
1204impl ExtensionRuntime {
1205    /// Enumerate targets exported by a loaded deploy extension.
1206    ///
1207    /// Returns the `list-targets` output as host-side `TargetSummary` values.
1208    pub fn list_targets(
1209        &self,
1210        ext_id: &str,
1211    ) -> Result<Vec<crate::types::TargetSummary>, RuntimeError> {
1212        use crate::host_bindings::deploy::exports::greentic::extension_deploy0_1_0::targets::TargetSummary as WitTargetSummary;
1213
1214        let loaded = self
1215            .loaded
1216            .load()
1217            .get(&crate::loaded::ExtensionId(ext_id.to_string()))
1218            .cloned()
1219            .ok_or_else(|| RuntimeError::NotFound(ext_id.to_string()))?;
1220
1221        let (mut store, instance) = loaded
1222            .build_store_and_instance(
1223                &self.engine,
1224                self.config.host_overrides.clone(),
1225                &crate::host_ports::HostCallContext::default(),
1226            )
1227            .map_err(RuntimeError::Wasmtime)?;
1228
1229        let (iface_idx, iface_name, _version) = resolve_iface_versions(
1230            &mut store,
1231            &instance,
1232            "greentic:extension-deploy/targets",
1233            DEPLOY_VERSIONS,
1234        )?;
1235        let func_idx = instance
1236            .get_export_index(&mut store, Some(&iface_idx), "list-targets")
1237            .ok_or_else(|| {
1238                RuntimeError::Wasmtime(anyhow::anyhow!(
1239                    "interface '{iface_name}' does not export 'list-targets'"
1240                ))
1241            })?;
1242
1243        let func = instance
1244            .get_typed_func::<(), (Vec<WitTargetSummary>,)>(&mut store, &func_idx)
1245            .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1246
1247        let (result,) = func
1248            .call(&mut store, ())
1249            .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1250
1251        Ok(result
1252            .into_iter()
1253            .map(|t| crate::types::TargetSummary {
1254                id: t.id,
1255                display_name: t.display_name,
1256                description: t.description,
1257                icon_path: t.icon_path,
1258                supports_rollback: t.supports_rollback,
1259            })
1260            .collect())
1261    }
1262}
1263
1264/// Resolve a `greentic:extension-design/<iface>` export by trying `@0.2.0`
1265/// first and falling back to `@0.1.0`.
1266///
1267/// The runtime bumped its WIT to `@0.2.0` in v1.2.x, but several extensions
1268/// in the wild (http, llm-generic, webhook, platform-bootstrap, ...) were
1269/// built against `@0.1.0` and have not yet been rebuilt. Without a
1270/// fallback, every dispatch into those extensions fails with
1271/// `extension does not export interface 'greentic:extension-design/
1272/// tools@0.2.0'`. Returning the resolved iface name (with version suffix)
1273/// lets the nested `func_idx` lookup error name the version that was
1274/// actually picked.
1275///
1276/// `roles@0.2.0` deliberately uses its own dedicated lookup (see
1277/// `runtime_roles.rs`); it never existed at `@0.1.0`, so no fallback is
1278/// appropriate there.
1279/// Tracks extension ids already warned about a legacy WIT contract, so the
1280/// deprecation notice fires once per extension per process instead of on
1281/// every dispatch. Bounded by the number of distinct loaded extensions.
1282static LEGACY_CONTRACT_WARNED: std::sync::OnceLock<
1283    std::sync::Mutex<std::collections::HashSet<String>>,
1284> = std::sync::OnceLock::new();
1285
1286/// Emit a one-shot deprecation warning if `version` is not the newest entry
1287/// in `newest`. No-op when the extension is already on the current contract
1288/// or has been warned before.
1289fn warn_if_legacy_contract(ext_id: &str, version: &str, newest: &str) {
1290    if version == newest {
1291        return;
1292    }
1293    let set = LEGACY_CONTRACT_WARNED
1294        .get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()));
1295    let mut guard = match set.lock() {
1296        Ok(g) => g,
1297        Err(poisoned) => poisoned.into_inner(),
1298    };
1299    if guard.insert(ext_id.to_string()) {
1300        tracing::warn!(
1301            extension = %ext_id,
1302            contract = version,
1303            newest = newest,
1304            "extension uses a deprecated WIT contract version; rebuild against the current contract (unified 6-variant extension-error)"
1305        );
1306    }
1307}
1308
1309/// Resolve `base@<ver>` against the instance's exports, trying `versions`
1310/// in order (newest first). Returns the export index, the full resolved
1311/// interface name, and the bare version string that matched — dispatch
1312/// code branches on the version to pick the matching typed signature.
1313pub(crate) fn resolve_iface_versions(
1314    store: &mut wasmtime::Store<crate::host_state::HostState>,
1315    instance: &wasmtime::component::Instance,
1316    base: &str,
1317    versions: &[&'static str],
1318) -> Result<
1319    (
1320        wasmtime::component::ComponentExportIndex,
1321        String,
1322        &'static str,
1323    ),
1324    RuntimeError,
1325> {
1326    for &v in versions {
1327        let name = format!("{base}@{v}");
1328        if let Some(idx) = instance.get_export_index(&mut *store, None, &name) {
1329            return Ok((idx, name, v));
1330        }
1331    }
1332    Err(RuntimeError::Wasmtime(anyhow::anyhow!(
1333        "extension does not export interface '{base}' at any supported version ({versions:?})"
1334    )))
1335}
1336
1337/// Version tables per package family — newest first.
1338const DESIGN_VERSIONS: &[&str] = &["0.3.0", "0.2.0", "0.1.0"];
1339pub(crate) const DEPLOY_VERSIONS: &[&str] = &["0.2.0", "0.1.0"];
1340const BUNDLE_VERSIONS: &[&str] = &["0.2.0", "0.1.0"];
1341/// Guardrail interface only exists at 0.3.0 — single-version table.
1342const GUARDRAIL_VERSIONS: &[&str] = &["0.3.0"];
1343
1344fn resolve_design_iface(
1345    store: &mut wasmtime::Store<crate::host_state::HostState>,
1346    instance: &wasmtime::component::Instance,
1347    base: &str,
1348) -> Result<(wasmtime::component::ComponentExportIndex, String), RuntimeError> {
1349    resolve_iface_versions(store, instance, base, DESIGN_VERSIONS).map(|(idx, name, _)| (idx, name))
1350}
1351
1352fn find_extension_dir(p: &std::path::Path) -> Option<std::path::PathBuf> {
1353    let mut cur = p;
1354    loop {
1355        if cur.join("describe.json").exists() {
1356            return Some(cur.to_path_buf());
1357        }
1358        cur = cur.parent()?;
1359    }
1360}
1361
1362impl ExtensionRuntime {
1363    /// Render a bundle artefact by dispatching to a loaded bundle
1364    /// extension's `bundling.render` export.
1365    ///
1366    /// Mirrors the in-process call site that replaces the legacy
1367    /// `greentic-bundle ext render` subprocess pipeline. The host
1368    /// passes the designer session (flow JSON, content JSON, asset
1369    /// blobs, capability list) and a recipe-specific config string;
1370    /// the extension's WASM returns the rendered bytes (typically a
1371    /// `.gtpack` zip) plus the canonical filename and sha256 the
1372    /// extension wants written.
1373    ///
1374    /// Returns `RuntimeError::NotFound` when no extension is loaded
1375    /// at `ext_id`. The `bundling` interface is resolved newest-first
1376    /// across `@0.2.0`/`@0.1.0`; host-level failures surface as
1377    /// `RuntimeError::Wasmtime`, while the extension's WIT-level error
1378    /// surfaces as `RuntimeError::Extension` (6-variant base at `@0.2.0`,
1379    /// 4-variant base at `@0.1.0`).
1380    pub fn render_bundle(
1381        &self,
1382        ext_id: &str,
1383        recipe_id: &str,
1384        config_json: &str,
1385        session: crate::types::BundleSession,
1386    ) -> Result<crate::types::BundleArtifact, RuntimeError> {
1387        let loaded = self
1388            .loaded
1389            .load()
1390            .get(&crate::loaded::ExtensionId(ext_id.to_string()))
1391            .cloned()
1392            .ok_or_else(|| RuntimeError::NotFound(ext_id.to_string()))?;
1393
1394        let (mut store, instance) = loaded
1395            .build_store_and_instance(
1396                &self.engine,
1397                self.config.host_overrides.clone(),
1398                &crate::host_ports::HostCallContext::default(),
1399            )
1400            .map_err(RuntimeError::Wasmtime)?;
1401
1402        let (iface_idx, iface_name, version) = resolve_iface_versions(
1403            &mut store,
1404            &instance,
1405            "greentic:extension-bundle/bundling",
1406            BUNDLE_VERSIONS,
1407        )?;
1408        let func_idx = instance
1409            .get_export_index(&mut store, Some(&iface_idx), "render")
1410            .ok_or_else(|| {
1411                RuntimeError::Wasmtime(anyhow::anyhow!(
1412                    "interface '{iface_name}' does not export 'render'"
1413                ))
1414            })?;
1415
1416        let mapped: Result<crate::types::BundleArtifact, crate::types::HostExtensionError> =
1417            if version == "0.2.0" {
1418                use crate::host_bindings::bundle_v02::exports::greentic::extension_bundle0_2_0::bundling::{
1419                    BundleArtifact as WitBundleArtifact, DesignerSession as WitDesignerSession,
1420                };
1421                use crate::host_bindings::bundle_v02::greentic::extension_base0_2_0::types::ExtensionError as E2;
1422                let func = instance
1423                    .get_typed_func::<
1424                        (String, String, WitDesignerSession),
1425                        (Result<WitBundleArtifact, E2>,),
1426                    >(&mut store, &func_idx)
1427                    .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1428                let wit_session = WitDesignerSession {
1429                    flows_json: session.flows_json,
1430                    contents_json: session.contents_json,
1431                    assets: session.assets,
1432                    capabilities_used: session.capabilities_used,
1433                };
1434                let (r,) = func
1435                    .call(
1436                        &mut store,
1437                        (recipe_id.to_string(), config_json.to_string(), wit_session),
1438                    )
1439                    .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1440                r.map(|a| crate::types::BundleArtifact {
1441                    filename: a.filename,
1442                    bytes: a.bytes,
1443                    sha256: a.sha256,
1444                })
1445                .map_err(crate::ext_error::from_bundle_v02)
1446            } else {
1447                use crate::host_bindings::bundle::exports::greentic::extension_bundle0_1_0::bundling::{
1448                    BundleArtifact as WitBundleArtifact, DesignerSession as WitDesignerSession,
1449                };
1450                use crate::host_bindings::bundle::greentic::extension_base0_1_0::types::ExtensionError as E1;
1451                let func = instance
1452                    .get_typed_func::<
1453                        (String, String, WitDesignerSession),
1454                        (Result<WitBundleArtifact, E1>,),
1455                    >(&mut store, &func_idx)
1456                    .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1457                let wit_session = WitDesignerSession {
1458                    flows_json: session.flows_json,
1459                    contents_json: session.contents_json,
1460                    assets: session.assets,
1461                    capabilities_used: session.capabilities_used,
1462                };
1463                let (r,) = func
1464                    .call(
1465                        &mut store,
1466                        (recipe_id.to_string(), config_json.to_string(), wit_session),
1467                    )
1468                    .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1469                r.map(|a| crate::types::BundleArtifact {
1470                    filename: a.filename,
1471                    bytes: a.bytes,
1472                    sha256: a.sha256,
1473                })
1474                .map_err(crate::ext_error::from_bundle_v01)
1475            };
1476
1477        mapped.map_err(RuntimeError::Extension)
1478    }
1479}
1480
1481#[cfg(test)]
1482mod deploy_tests {
1483    use super::*;
1484
1485    #[test]
1486    fn list_targets_returns_error_for_unknown_extension() {
1487        let tmp = tempfile::TempDir::new().unwrap();
1488        let config =
1489            RuntimeConfig::from_paths(crate::DiscoveryPaths::new(tmp.path().to_path_buf()));
1490        let rt = ExtensionRuntime::new(config).unwrap();
1491        let err = rt.list_targets("does-not-exist").unwrap_err();
1492        match err {
1493            RuntimeError::NotFound(id) => assert_eq!(id, "does-not-exist"),
1494            other => panic!("expected NotFound, got {other:?}"),
1495        }
1496    }
1497
1498    #[test]
1499    fn credential_schema_returns_error_for_unknown_extension() {
1500        let tmp = tempfile::TempDir::new().unwrap();
1501        let config =
1502            RuntimeConfig::from_paths(crate::DiscoveryPaths::new(tmp.path().to_path_buf()));
1503        let rt = ExtensionRuntime::new(config).unwrap();
1504        let err = rt
1505            .credential_schema("does-not-exist", "some-target")
1506            .unwrap_err();
1507        assert!(matches!(err, RuntimeError::NotFound(_)));
1508    }
1509
1510    #[test]
1511    fn validate_credentials_returns_error_for_unknown_extension() {
1512        let tmp = tempfile::TempDir::new().unwrap();
1513        let config =
1514            RuntimeConfig::from_paths(crate::DiscoveryPaths::new(tmp.path().to_path_buf()));
1515        let rt = ExtensionRuntime::new(config).unwrap();
1516        let err = rt
1517            .validate_credentials("does-not-exist", "target", r"{}")
1518            .unwrap_err();
1519        assert!(matches!(err, RuntimeError::NotFound(_)));
1520    }
1521
1522    #[test]
1523    fn render_bundle_returns_error_for_unknown_extension() {
1524        let tmp = tempfile::TempDir::new().unwrap();
1525        let config =
1526            RuntimeConfig::from_paths(crate::DiscoveryPaths::new(tmp.path().to_path_buf()));
1527        let rt = ExtensionRuntime::new(config).unwrap();
1528        let err = rt
1529            .render_bundle(
1530                "does-not-exist",
1531                "standard",
1532                "{}",
1533                crate::types::BundleSession::default(),
1534            )
1535            .unwrap_err();
1536        assert!(matches!(err, RuntimeError::NotFound(_)));
1537    }
1538
1539    #[test]
1540    fn for_test_constructs_runtime_with_no_extensions() {
1541        let runtime = ExtensionRuntime::for_test();
1542        // No extensions are loaded.
1543        assert!(
1544            runtime.loaded().is_empty(),
1545            "for_test runtime must have zero loaded extensions"
1546        );
1547    }
1548}