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