Skip to main content

greentic_runner_host/
pack.rs

1use std::collections::{BTreeMap, HashMap, HashSet};
2use std::fs::File;
3use std::io::Read;
4use std::path::{Path, PathBuf};
5use std::str::FromStr;
6use std::sync::Arc;
7use std::time::Duration;
8
9use crate::cache::{ArtifactKey, CacheConfig, CacheManager, CpuPolicy, EngineProfile};
10use crate::component_api::{
11    self, node::ExecCtx as ComponentExecCtx, node::InvokeResult, node::NodeError,
12};
13use crate::identify_hint::IdentifyInstanceHint;
14use crate::oauth::{OAuthBrokerConfig, ResourceTokenRequest};
15use crate::provider::{ProviderBinding, ProviderRegistry};
16use crate::provider_core::{
17    schema_core::SchemaCorePre as LegacySchemaCorePre,
18    schema_core_path::SchemaCorePre as PathSchemaCorePre,
19    schema_core_schema::SchemaCorePre as SchemaSchemaCorePre,
20};
21use crate::provider_core_only;
22use crate::runtime_refs::RuntimeRefsInjection;
23use crate::runtime_wasmtime::{Component, Engine, Linker, ResourceTable};
24use anyhow::{Context, Result, anyhow, bail};
25use futures::executor::block_on;
26use greentic_distributor_client::dist::{
27    CachePolicy, DistClient, DistError, DistOptions, ResolvePolicy,
28};
29use greentic_interfaces_wasmtime::host_helpers::v1::{
30    self as host_v1, HostFns, add_all_v1_to_linker,
31    oauth_broker::OAuthBrokerHost as OAuthBrokerHostTrait,
32    runner_host_http::RunnerHostHttp,
33    runner_host_kv::RunnerHostKv,
34    runtime_config::{ConfigError, RuntimeConfigHost},
35    secrets_store::{SecretsError, SecretsErrorV1_1, SecretsStoreHost, SecretsStoreHostV1_1},
36    state_store::{
37        OpAck as StateOpAck, StateKey as HostStateKey, StateStoreError as StateError,
38        StateStoreHost, TenantCtx as StateTenantCtx,
39    },
40    telemetry_logger::{
41        OpAck as TelemetryAck, SpanContext as TelemetrySpanContext,
42        TelemetryLoggerError as TelemetryError, TelemetryLoggerHost,
43        TenantCtx as TelemetryTenantCtx,
44    },
45};
46use greentic_interfaces_wasmtime::http_client_client_v1_1::greentic::http::http_client as http_client_client_alias;
47use greentic_interfaces_wasmtime::instance_identity_instance_identity_describe_v0_1::InstanceIdentityDescribePre;
48use greentic_interfaces_wasmtime::instance_identity_v0_1::InstanceIdentityPre;
49use greentic_interfaces_wasmtime::{
50    http_client_client_v1_0::greentic::interfaces_types::types as http_types_v1_0,
51    http_client_client_v1_1::greentic::interfaces_types::types as http_types_v1_1,
52};
53use greentic_pack::builder as legacy_pack;
54use greentic_types::flow::FlowHasher;
55use greentic_types::{
56    ArtifactLocationV1, ComponentId, ComponentManifest, ComponentSourceRef, ComponentSourcesV1,
57    EXT_COMPONENT_SOURCES_V1, EnvId, ExtensionRef, Flow, FlowComponentRef, FlowId, FlowKind,
58    FlowMetadata, InputMapping, Node, NodeId, OutputMapping, Routing, StateKey as StoreStateKey,
59    TeamId, TelemetryHints, TenantCtx as TypesTenantCtx, TenantId, UserId, decode_pack_manifest,
60    pack_manifest::ExtensionInline,
61};
62use host_v1::http_client as host_http_client;
63use host_v1::http_client::{
64    HttpClientError, HttpClientErrorV1_1, HttpClientHost, HttpClientHostV1_1,
65    Request as HttpRequest, RequestOptionsV1_1 as HttpRequestOptionsV1_1,
66    RequestV1_1 as HttpRequestV1_1, Response as HttpResponse, ResponseV1_1 as HttpResponseV1_1,
67    TenantCtx as HttpTenantCtx, TenantCtxV1_1 as HttpTenantCtxV1_1,
68};
69use indexmap::IndexMap;
70use once_cell::sync::Lazy;
71use parking_lot::{Mutex, RwLock};
72use reqwest::blocking::Client as BlockingClient;
73use runner_core::normalize_under_root;
74use serde::{Deserialize, Serialize};
75use serde_cbor;
76use serde_json::{self, Value};
77use sha2::Digest;
78use tempfile::TempDir;
79use tokio::fs;
80use wasmparser::{Parser, Payload};
81use wasmtime::{Store, StoreContextMut};
82use wasmtime_wasi_http::WasiHttpCtx;
83use wasmtime_wasi_http::p2::{
84    WasiHttpCtxView, WasiHttpView, add_only_http_to_linker_sync as add_wasi_http_to_linker,
85};
86use wasmtime_wasi_tls::p2::LinkOptions;
87use wasmtime_wasi_tls::{WasiTlsCtx, WasiTlsCtxBuilder, WasiTlsCtxView, WasiTlsView};
88use zip::ZipArchive;
89
90use crate::runner::engine::{FlowContext, FlowEngine, FlowStatus};
91use crate::runner::flow_adapter::{FlowIR, flow_doc_to_ir, flow_ir_to_flow, is_native_op_key};
92use crate::runner::mocks::{HttpDecision, HttpMockRequest, HttpMockResponse, MockLayer};
93#[cfg(feature = "fault-injection")]
94use crate::testing::fault_injection::{FaultContext, FaultPoint, maybe_fail};
95
96use crate::config::HostConfig;
97use crate::fault;
98use crate::secrets::{
99    DynSecretsManager, canonicalize_secret_key, read_secret_blocking, write_secret_blocking,
100};
101use crate::storage::state::STATE_PREFIX;
102use crate::storage::{DynSessionStore, DynStateStore};
103use crate::verify;
104use crate::wasi::{PreopenSpec, RunnerWasiPolicy};
105use tracing::warn;
106use wasmtime_wasi::p2::add_to_linker_sync as add_wasi_to_linker;
107use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};
108
109use greentic_flow::model::FlowDoc;
110
111#[allow(dead_code)]
112pub struct PackRuntime {
113    /// Component artifact path (wasm file).
114    path: PathBuf,
115    /// Optional archive (.gtpack) used to load flows/manifests.
116    archive_path: Option<PathBuf>,
117    config: Arc<HostConfig>,
118    engine: Engine,
119    metadata: PackMetadata,
120    manifest: Option<greentic_types::PackManifest>,
121    legacy_manifest: Option<Box<legacy_pack::PackManifest>>,
122    component_manifests: HashMap<String, ComponentManifest>,
123    mocks: Option<Arc<MockLayer>>,
124    flows: Option<PackFlows>,
125    components: HashMap<String, PackComponent>,
126    http_client: Arc<BlockingClient>,
127    session_store: Option<DynSessionStore>,
128    state_store: Option<DynStateStore>,
129    wasi_policy: Arc<RunnerWasiPolicy>,
130    /// Lazily-parsed `assets/mcp-routes.json` sidecar — see
131    /// [`PackRuntime::mcp_routes`]. Read on first use rather than at load so a
132    /// pack with no MCP nodes never touches the archive for it.
133    mcp_routes: std::sync::OnceLock<Option<crate::runner::mcp_pack_routes::PackMcpRoutes>>,
134    assets_tempdir: Option<TempDir>,
135    provider_registry: RwLock<Option<ProviderRegistry>>,
136    /// Per-revision lazy cache of `describe-identify-instance` results,
137    /// keyed by `component_ref`. `None` value means the component does not
138    /// export the describe world (or the hint was malformed) — the
139    /// caller falls back to passing input headers through unchanged. The
140    /// outer `Option` distinguishes "not probed yet" from "probed and
141    /// has no hint". `ArcSwap`-driven revision swaps allocate a fresh
142    /// `PackRuntime` so this cache is naturally invalidated.
143    identify_hint_cache: RwLock<HashMap<String, Option<IdentifyInstanceHint>>>,
144    secrets: DynSecretsManager,
145    oauth_config: Option<OAuthBrokerConfig>,
146    cache: CacheManager,
147    /// `pack-config.v1.non_secret` map plumbed into each `HostState` for the
148    /// `greentic:runtime-config@1.0.0` host import. Defaults to `None` when no
149    /// producer (greentic-start) has materialized a `PackConfig` yet; in that
150    /// case all runtime-config lookups fall through to the secrets-store
151    /// compat shim.
152    runtime_config_non_secret: Option<Arc<BTreeMap<String, Value>>>,
153    /// `pack-config.v1.runtime_refs` (C5): per-pack `key → URI` bindings plus
154    /// the env-shared [`RuntimeRefResolver`]. Consulted by the
155    /// `greentic:runtime-config@1.0.0` host import AFTER `non_secret` and
156    /// BEFORE the compat shim. `None` when no producer set it yet.
157    ///
158    /// [`RuntimeRefResolver`]: crate::runtime_refs::RuntimeRefResolver
159    runtime_refs: Option<RuntimeRefsInjection>,
160}
161
162struct PackComponent {
163    #[allow(dead_code)]
164    name: String,
165    #[allow(dead_code)]
166    version: String,
167    component: Arc<Component>,
168}
169
170/// Outcome of calling a provider component's `identify-instance` export
171/// (`greentic:provider-instance-identity@0.1.0`). Callers MUST treat the
172/// three variants differently per the WIT contract.
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub enum IdentifyOutcome {
175    /// Component does not export the world — caller falls back to the
176    /// operator's statically-declared `provider_id`.
177    Unsupported,
178    /// Component exported the world and returned `None` — caller MUST
179    /// fail closed (401/404), no fallback.
180    NoMatch,
181    /// Component identified the payload as belonging to this
182    /// `provider_id` — caller routes to the matching `MessagingEndpoint`.
183    Identified(String),
184}
185
186impl IdentifyOutcome {
187    /// Merge `other` into `self` per the lattice
188    /// `Identified > NoMatch > Unsupported`. Used by callers fanning the probe
189    /// out over multiple packs (overlays) where the strongest signal across
190    /// packs wins.
191    pub fn merge_in(&mut self, other: IdentifyOutcome) {
192        match (&*self, &other) {
193            // Identified is the top — never gets overwritten.
194            (IdentifyOutcome::Identified(_), _) => {}
195            // Promote to Identified from anything else.
196            (_, IdentifyOutcome::Identified(_)) => *self = other,
197            // NoMatch promotes Unsupported but cannot downgrade itself.
198            (IdentifyOutcome::Unsupported, IdentifyOutcome::NoMatch) => *self = other,
199            _ => {}
200        }
201    }
202}
203
204fn run_on_wasi_thread<F, T>(task_name: &'static str, task: F) -> Result<T>
205where
206    F: FnOnce() -> Result<T> + Send + 'static,
207    T: Send + 'static,
208{
209    let builder = std::thread::Builder::new().name(format!("greentic-wasmtime-{task_name}"));
210    let handle = builder
211        .spawn(move || {
212            let pid = std::process::id();
213            let thread_id = std::thread::current().id();
214            let tokio_handle_present = tokio::runtime::Handle::try_current().is_ok();
215            tracing::info!(
216                event = "wasmtime.thread.start",
217                task = task_name,
218                pid,
219                thread_id = ?thread_id,
220                tokio_handle_present,
221                "starting Wasmtime thread"
222            );
223            task()
224        })
225        .context("failed to spawn Wasmtime thread")?;
226    handle
227        .join()
228        .map_err(|err| {
229            let reason = if let Some(msg) = err.downcast_ref::<&str>() {
230                msg.to_string()
231            } else if let Some(msg) = err.downcast_ref::<String>() {
232                msg.clone()
233            } else {
234                "unknown panic".to_string()
235            };
236            anyhow!("Wasmtime thread panicked: {reason}")
237        })
238        .and_then(|res| res)
239}
240
241#[derive(Debug, Default, Clone)]
242pub struct ComponentResolution {
243    /// Root of a materialized pack directory containing `manifest.cbor` and `components/`.
244    pub materialized_root: Option<PathBuf>,
245    /// Explicit overrides mapping component id -> wasm path.
246    pub overrides: HashMap<String, PathBuf>,
247    /// If true, do not fetch remote components; require cached artifacts.
248    pub dist_offline: bool,
249    /// Optional cache directory for resolved remote components.
250    pub dist_cache_dir: Option<PathBuf>,
251    /// Allow bundled components without wasm_sha256 (dev-only escape hatch).
252    pub allow_missing_hash: bool,
253}
254
255fn build_blocking_client() -> BlockingClient {
256    std::thread::spawn(|| {
257        BlockingClient::builder()
258            .no_proxy()
259            .build()
260            .expect("blocking client")
261    })
262    .join()
263    .expect("client build thread panicked")
264}
265
266fn normalize_pack_path(path: &Path) -> Result<(PathBuf, PathBuf)> {
267    let (root, candidate) = if path.is_absolute() {
268        let parent = path
269            .parent()
270            .ok_or_else(|| anyhow!("pack path {} has no parent", path.display()))?;
271        let root = parent
272            .canonicalize()
273            .with_context(|| format!("failed to canonicalize {}", parent.display()))?;
274        let file = path
275            .file_name()
276            .ok_or_else(|| anyhow!("pack path {} has no file name", path.display()))?;
277        (root, PathBuf::from(file))
278    } else {
279        let cwd = std::env::current_dir().context("failed to resolve current directory")?;
280        let base = if let Some(parent) = path.parent() {
281            cwd.join(parent)
282        } else {
283            cwd
284        };
285        let root = base
286            .canonicalize()
287            .with_context(|| format!("failed to canonicalize {}", base.display()))?;
288        let file = path
289            .file_name()
290            .ok_or_else(|| anyhow!("pack path {} has no file name", path.display()))?;
291        (root, PathBuf::from(file))
292    };
293    let safe = normalize_under_root(&root, &candidate)?;
294    Ok((root, safe))
295}
296
297static HTTP_CLIENT: Lazy<Arc<BlockingClient>> = Lazy::new(|| Arc::new(build_blocking_client()));
298
299/// Default for [`FlowDescriptor::entry`]. A flow is treated as an entrypoint
300/// unless it is explicitly tagged `internal`, so packs (and serialized
301/// descriptors) that predate the `entry` field keep their prior behaviour of
302/// every flow being externally routable.
303fn default_flow_entry() -> bool {
304    true
305}
306
307/// A flow is an *entry* flow — a valid target for an inbound provider event
308/// resolved by flow type — unless it is tagged `internal`. Internal flows are
309/// only reachable via `flow.call` (e.g. a dispatcher's sub-flows) and must
310/// never be selected for type-only routing. Untagged flows default to entry,
311/// preserving behaviour for packs that don't declare the distinction.
312fn tags_indicate_entry<'a, I>(tags: I) -> bool
313where
314    I: IntoIterator<Item = &'a str>,
315{
316    !tags.into_iter().any(|tag| tag == "internal")
317}
318
319#[derive(Debug, Clone, Serialize, Deserialize)]
320pub struct FlowDescriptor {
321    pub id: String,
322    #[serde(rename = "type")]
323    pub flow_type: String,
324    pub pack_id: String,
325    pub profile: String,
326    pub version: String,
327    #[serde(default)]
328    pub description: Option<String>,
329    /// Whether this flow is an entrypoint (see [`tags_indicate_entry`]).
330    #[serde(default = "default_flow_entry")]
331    pub entry: bool,
332}
333
334pub struct HostState {
335    #[allow(dead_code)]
336    pack_id: String,
337    config: Arc<HostConfig>,
338    http_client: Arc<BlockingClient>,
339    default_env: String,
340    #[allow(dead_code)]
341    session_store: Option<DynSessionStore>,
342    state_store: Option<DynStateStore>,
343    mocks: Option<Arc<MockLayer>>,
344    secrets: DynSecretsManager,
345    oauth_config: Option<OAuthBrokerConfig>,
346    exec_ctx: Option<ComponentExecCtx>,
347    component_ref: Option<String>,
348    provider_core_component: bool,
349    /// `pack-config.v1.non_secret` map for the `greentic:runtime-config@1.0.0`
350    /// host import. Populated by the producer (greentic-start) from the
351    /// deployed `PackConfig`; `None` when no PackConfig was published, in
352    /// which case lookups fall back to the secrets-store compat shim with
353    /// a once-per-process deprecation warning.
354    runtime_config_non_secret: Option<Arc<BTreeMap<String, Value>>>,
355    /// `pack-config.v1.runtime_refs` (C5) injection: per-pack `key → URI`
356    /// bindings plus the env-shared resolver. The host import resolves the
357    /// URI on every call so the value tracks `runtime.json` hot-reloads.
358    runtime_refs: Option<RuntimeRefsInjection>,
359}
360
361impl HostState {
362    #[allow(clippy::too_many_arguments)]
363    pub fn new(
364        pack_id: String,
365        config: Arc<HostConfig>,
366        http_client: Arc<BlockingClient>,
367        mocks: Option<Arc<MockLayer>>,
368        session_store: Option<DynSessionStore>,
369        state_store: Option<DynStateStore>,
370        secrets: DynSecretsManager,
371        oauth_config: Option<OAuthBrokerConfig>,
372        exec_ctx: Option<ComponentExecCtx>,
373        component_ref: Option<String>,
374        provider_core_component: bool,
375        runtime_config_non_secret: Option<Arc<BTreeMap<String, Value>>>,
376        runtime_refs: Option<RuntimeRefsInjection>,
377    ) -> Result<Self> {
378        let default_env = std::env::var("GREENTIC_ENV").unwrap_or_else(|_| "local".to_string());
379        Ok(Self {
380            pack_id,
381            config,
382            http_client,
383            default_env,
384            session_store,
385            state_store,
386            mocks,
387            secrets,
388            oauth_config,
389            exec_ctx,
390            component_ref,
391            provider_core_component,
392            runtime_config_non_secret,
393            runtime_refs,
394        })
395    }
396
397    fn instantiate_component_result(
398        linker: &mut Linker<ComponentState>,
399        store: &mut Store<ComponentState>,
400        component: &Component,
401        ctx: &ComponentExecCtx,
402        component_ref: &str,
403        operation: &str,
404        input_json: &str,
405    ) -> Result<InvokeResult> {
406        let pre_instance = linker.instantiate_pre(component)?;
407        match component_api::v0_6::ComponentPre::new(pre_instance) {
408            Ok(pre) => {
409                let envelope = component_api::envelope_v0_6(ctx, component_ref, input_json)?;
410                let operation_owned = operation.to_string();
411                let result = block_on(async {
412                    let bindings = pre.instantiate_async(&mut *store).await?;
413                    let node = bindings.greentic_component_node();
414                    node.call_invoke(&mut *store, &operation_owned, &envelope)
415                })?;
416                component_api::invoke_result_from_v0_6(result)
417            }
418            Err(err_v06) => {
419                if !is_missing_node_export(&err_v06, "0.6.0") {
420                    return Err(err_v06.into());
421                }
422                let pre_instance = linker.instantiate_pre(component)?;
423                match component_api::v0_5::ComponentPre::new(pre_instance) {
424                    Ok(pre) => {
425                        let result = block_on(async {
426                            let bindings = pre.instantiate_async(&mut *store).await?;
427                            let node = bindings.greentic_component_node();
428                            let ctx_v05 = component_api::exec_ctx_v0_5(ctx);
429                            let operation_owned = operation.to_string();
430                            let input_owned = input_json.to_string();
431                            node.call_invoke(&mut *store, &ctx_v05, &operation_owned, &input_owned)
432                        })?;
433                        Ok(component_api::invoke_result_from_v0_5(result))
434                    }
435                    Err(err) => {
436                        if !is_missing_node_export(&err, "0.5.0") {
437                            return Err(err.into());
438                        }
439                        let pre_instance = linker.instantiate_pre(component)?;
440                        match component_api::v0_4::ComponentPre::new(pre_instance) {
441                            Ok(pre) => {
442                                let result = block_on(async {
443                                    let bindings = pre.instantiate_async(&mut *store).await?;
444                                    let node = bindings.greentic_component_node();
445                                    let ctx_v04 = component_api::exec_ctx_v0_4(ctx);
446                                    let operation_owned = operation.to_string();
447                                    let input_owned = input_json.to_string();
448                                    node.call_invoke(
449                                        &mut *store,
450                                        &ctx_v04,
451                                        &operation_owned,
452                                        &input_owned,
453                                    )
454                                })?;
455                                Ok(component_api::invoke_result_from_v0_4(result))
456                            }
457                            Err(err_v04) => {
458                                if is_missing_node_export(&err_v04, "0.4.0") {
459                                    Self::try_v06_runtime(linker, store, component, input_json)
460                                } else {
461                                    Err(err_v04.into())
462                                }
463                            }
464                        }
465                    }
466                }
467            }
468        }
469    }
470
471    /// Fallback for v0.6 components that export `component-runtime::run(input, state)`
472    /// instead of the legacy `node::invoke(ctx, op, input)`.
473    fn try_v06_runtime(
474        linker: &mut Linker<ComponentState>,
475        store: &mut Store<ComponentState>,
476        component: &Component,
477        input_json: &str,
478    ) -> Result<InvokeResult> {
479        let pre_instance = linker.instantiate_pre(component)?;
480        let pre = component_api::v0_6_runtime::ComponentV0V6RuntimePre::new(pre_instance).map_err(
481            |err| err.context("component exports neither node@0.5/0.4 nor component-runtime@0.6"),
482        )?;
483
484        let result = block_on(async {
485            let bindings = pre.instantiate_async(&mut *store).await?;
486            let runtime = bindings.greentic_component_component_runtime();
487
488            // Encode input as CBOR — the component's run() expects CBOR bytes.
489            let input_value: Value = serde_json::from_str(input_json).unwrap_or(Value::Null);
490            let input_cbor =
491                serde_cbor::to_vec(&input_value).context("encode input as CBOR for v0.6")?;
492            let empty_state = serde_cbor::to_vec(&Value::Object(Default::default()))
493                .context("encode empty state")?;
494
495            let run_result = runtime
496                .call_run(&mut *store, &input_cbor, &empty_state)
497                .map_err(|err| err.context("v0.6 component-runtime::run call failed"))?;
498
499            // Decode output CBOR to JSON.
500            let output_value: Value = serde_cbor::from_slice(&run_result.output)
501                .context("decode v0.6 run output CBOR")?;
502            let output_json = serde_json::to_string(&output_value)
503                .context("serialize v0.6 run output to JSON")?;
504
505            Ok::<_, anyhow::Error>(output_json)
506        })?;
507
508        Ok(InvokeResult::Ok(result))
509    }
510
511    fn convert_invoke_result(result: InvokeResult) -> Result<Value> {
512        match result {
513            InvokeResult::Ok(body) => {
514                if body.is_empty() {
515                    return Ok(Value::Null);
516                }
517                serde_json::from_str(&body).or_else(|_| Ok(Value::String(body)))
518            }
519            InvokeResult::Err(NodeError {
520                code,
521                message,
522                retryable,
523                backoff_ms,
524                details,
525            }) => {
526                let mut obj = serde_json::Map::new();
527                obj.insert("ok".into(), Value::Bool(false));
528                let mut error = serde_json::Map::new();
529                error.insert("code".into(), Value::String(code));
530                error.insert("message".into(), Value::String(message));
531                error.insert("retryable".into(), Value::Bool(retryable));
532                if let Some(backoff) = backoff_ms {
533                    error.insert("backoff_ms".into(), Value::Number(backoff.into()));
534                }
535                if let Some(details) = details {
536                    error.insert(
537                        "details".into(),
538                        serde_json::from_str(&details).unwrap_or(Value::String(details)),
539                    );
540                }
541                obj.insert("error".into(), Value::Object(error));
542                Ok(Value::Object(obj))
543            }
544        }
545    }
546
547    /// Build a `TenantCtx` for secrets lookups that includes the team from the
548    /// execution context. `config.tenant_ctx()` only populates env + tenant;
549    /// without this, secrets scoped to a specific team are unreachable.
550    fn secrets_tenant_ctx(&self) -> TypesTenantCtx {
551        let mut ctx = self.config.tenant_ctx();
552        if let Some(exec_ctx) = self.exec_ctx.as_ref()
553            && let Some(team) = exec_ctx.tenant.team.as_ref()
554            && let Ok(team_id) = TeamId::from_str(team)
555        {
556            ctx = ctx.with_team(Some(team_id));
557        }
558        ctx
559    }
560
561    pub fn get_secret(&self, key: &str) -> Result<String> {
562        if provider_core_only::is_enabled() {
563            bail!(provider_core_only::blocked_message("secrets"))
564        }
565        if !self.config.secrets_policy.is_allowed(key) {
566            bail!("secret {key} is not permitted by bindings policy");
567        }
568        if let Some(mock) = &self.mocks
569            && let Some(value) = mock.secrets_lookup(key)
570        {
571            return Ok(value);
572        }
573        let ctx = self.secrets_tenant_ctx();
574        let canonical_key = canonicalize_secret_key(key);
575        let bytes = read_secret_blocking(&self.secrets, &ctx, &self.pack_id, &canonical_key)
576            .context("failed to read secret from manager")?;
577        let value = String::from_utf8(bytes).context("secret value is not valid UTF-8")?;
578        Ok(value)
579    }
580
581    fn allows_secret_write_in_provider_core_only(&self) -> bool {
582        self.provider_core_component || self.component_ref.is_none()
583    }
584
585    fn tenant_ctx_from_v1(&self, ctx: Option<StateTenantCtx>) -> Result<TypesTenantCtx> {
586        let tenant_raw = ctx
587            .as_ref()
588            .map(|ctx| ctx.tenant.clone())
589            .or_else(|| self.exec_ctx.as_ref().map(|ctx| ctx.tenant.tenant.clone()))
590            .unwrap_or_else(|| self.config.tenant.clone());
591        let env_raw = ctx
592            .as_ref()
593            .map(|ctx| ctx.env.clone())
594            .unwrap_or_else(|| self.default_env.clone());
595        let tenant_id = TenantId::from_str(&tenant_raw)
596            .with_context(|| format!("invalid tenant id `{tenant_raw}`"))?;
597        let env_id = EnvId::from_str(&env_raw)
598            .unwrap_or_else(|_| EnvId::from_str("local").expect("default env must be valid"));
599        let mut tenant_ctx = TypesTenantCtx::new(env_id, tenant_id);
600        if let Some(exec_ctx) = self.exec_ctx.as_ref() {
601            if let Some(team) = exec_ctx.tenant.team.as_ref() {
602                let team_id =
603                    TeamId::from_str(team).with_context(|| format!("invalid team id `{team}`"))?;
604                tenant_ctx = tenant_ctx.with_team(Some(team_id));
605            }
606            if let Some(user) = exec_ctx.tenant.user.as_ref() {
607                let user_id =
608                    UserId::from_str(user).with_context(|| format!("invalid user id `{user}`"))?;
609                tenant_ctx = tenant_ctx.with_user(Some(user_id));
610            }
611            tenant_ctx = tenant_ctx.with_flow(exec_ctx.flow_id.clone());
612            if let Some(node) = exec_ctx.node_id.as_ref() {
613                tenant_ctx = tenant_ctx.with_node(node.clone());
614            }
615            if let Some(session) = exec_ctx.tenant.correlation_id.as_ref() {
616                tenant_ctx = tenant_ctx.with_session(session.clone());
617            }
618            tenant_ctx.trace_id = exec_ctx.tenant.trace_id.clone();
619        }
620
621        if let Some(ctx) = ctx {
622            if let Some(team) = ctx.team.or(ctx.team_id) {
623                let team_id =
624                    TeamId::from_str(&team).with_context(|| format!("invalid team id `{team}`"))?;
625                tenant_ctx = tenant_ctx.with_team(Some(team_id));
626            }
627            if let Some(user) = ctx.user.or(ctx.user_id) {
628                let user_id =
629                    UserId::from_str(&user).with_context(|| format!("invalid user id `{user}`"))?;
630                tenant_ctx = tenant_ctx.with_user(Some(user_id));
631            }
632            if let Some(flow) = ctx.flow_id {
633                tenant_ctx = tenant_ctx.with_flow(flow);
634            }
635            if let Some(node) = ctx.node_id {
636                tenant_ctx = tenant_ctx.with_node(node);
637            }
638            if let Some(provider) = ctx.provider_id {
639                tenant_ctx = tenant_ctx.with_provider(provider);
640            }
641            if let Some(session) = ctx.session_id {
642                tenant_ctx = tenant_ctx.with_session(session);
643            }
644            tenant_ctx.trace_id = ctx.trace_id;
645        }
646        Ok(tenant_ctx)
647    }
648
649    fn send_http_request(
650        &mut self,
651        req: HttpRequest,
652        opts: Option<HttpRequestOptionsV1_1>,
653        _ctx: Option<HttpTenantCtx>,
654    ) -> Result<HttpResponse, HttpClientError> {
655        if !self.config.http_enabled {
656            return Err(HttpClientError {
657                code: "denied".into(),
658                message: "http client disabled by policy".into(),
659            });
660        }
661
662        let mut mock_state = None;
663        let raw_body = req.body.clone();
664        if let Some(mock) = &self.mocks
665            && let Ok(meta) = HttpMockRequest::new(&req.method, &req.url, raw_body.as_deref())
666        {
667            match mock.http_begin(&meta) {
668                HttpDecision::Mock(response) => {
669                    let headers = response
670                        .headers
671                        .iter()
672                        .map(|(k, v)| (k.clone(), v.clone()))
673                        .collect();
674                    return Ok(HttpResponse {
675                        status: response.status,
676                        headers,
677                        body: response.body.clone().map(|b| b.into_bytes()),
678                    });
679                }
680                HttpDecision::Deny(reason) => {
681                    return Err(HttpClientError {
682                        code: "denied".into(),
683                        message: reason,
684                    });
685                }
686                HttpDecision::Passthrough { record } => {
687                    mock_state = Some((meta, record));
688                }
689            }
690        }
691
692        let method = req.method.parse().unwrap_or(reqwest::Method::GET);
693        let mut builder = self.http_client.request(method, &req.url);
694        for (key, value) in req.headers {
695            if let Ok(header) = reqwest::header::HeaderName::from_bytes(key.as_bytes())
696                && let Ok(header_value) = reqwest::header::HeaderValue::from_str(&value)
697            {
698                builder = builder.header(header, header_value);
699            }
700        }
701
702        if let Some(body) = raw_body.clone() {
703            builder = builder.body(body);
704        }
705
706        if let Some(opts) = opts {
707            if let Some(timeout_ms) = opts.timeout_ms {
708                builder = builder.timeout(Duration::from_millis(timeout_ms as u64));
709            }
710            if opts.allow_insecure == Some(true) {
711                warn!(url = %req.url, "allow-insecure not supported; using default TLS validation");
712            }
713            if let Some(follow_redirects) = opts.follow_redirects
714                && !follow_redirects
715            {
716                warn!(url = %req.url, "follow-redirects=false not supported; using default client behaviour");
717            }
718        }
719
720        let response = match builder.send() {
721            Ok(resp) => resp,
722            Err(err) => {
723                warn!(url = %req.url, error = %err, "http client request failed");
724                return Err(HttpClientError {
725                    code: "unavailable".into(),
726                    message: err.to_string(),
727                });
728            }
729        };
730
731        let status = response.status().as_u16();
732        let headers_vec = response
733            .headers()
734            .iter()
735            .map(|(k, v)| {
736                (
737                    k.as_str().to_string(),
738                    v.to_str().unwrap_or_default().to_string(),
739                )
740            })
741            .collect::<Vec<_>>();
742        let body_bytes = response.bytes().ok().map(|b| b.to_vec());
743
744        if let Some((meta, true)) = mock_state.take()
745            && let Some(mock) = &self.mocks
746        {
747            let recorded = HttpMockResponse::new(
748                status,
749                headers_vec.clone().into_iter().collect(),
750                body_bytes
751                    .as_ref()
752                    .map(|b| String::from_utf8_lossy(b).into_owned()),
753            );
754            mock.http_record(&meta, &recorded);
755        }
756
757        Ok(HttpResponse {
758            status,
759            headers: headers_vec,
760            body: body_bytes,
761        })
762    }
763}
764
765#[cfg(test)]
766mod canonicalize_tests {
767    use crate::secrets::canonicalize_secret_key;
768
769    #[test]
770    fn upper_snake_to_lower_snake() {
771        assert_eq!(
772            canonicalize_secret_key("TELEGRAM_BOT_TOKEN"),
773            "telegram_bot_token"
774        );
775    }
776
777    #[test]
778    fn trim_and_replace_non_alphanumeric() {
779        assert_eq!(
780            canonicalize_secret_key("  webex-bot-token  "),
781            "webex_bot_token"
782        );
783    }
784
785    #[test]
786    fn preserve_existing_lower_snake_with_extra_underscores() {
787        assert_eq!(canonicalize_secret_key("MiXeD__Case"), "mixed__case");
788    }
789}
790
791impl SecretsStoreHost for HostState {
792    fn get(&mut self, key: String) -> Result<Option<Vec<u8>>, SecretsError> {
793        if provider_core_only::is_enabled() {
794            warn!(secret = %key, "provider-core only mode enabled; blocking secrets store");
795            return Err(SecretsError::Denied);
796        }
797        if !self.config.secrets_policy.is_allowed(&key) {
798            return Err(SecretsError::Denied);
799        }
800        if let Some(mock) = &self.mocks
801            && let Some(value) = mock.secrets_lookup(&key)
802        {
803            return Ok(Some(value.into_bytes()));
804        }
805        let ctx = self.secrets_tenant_ctx();
806        let canonical_key = canonicalize_secret_key(&key);
807        match read_secret_blocking(&self.secrets, &ctx, &self.pack_id, &canonical_key) {
808            Ok(bytes) => Ok(Some(bytes)),
809            Err(err) => {
810                warn!(secret = %key, canonical = %canonical_key, error = %err, "secret lookup failed");
811                Err(SecretsError::NotFound)
812            }
813        }
814    }
815}
816
817impl SecretsStoreHostV1_1 for HostState {
818    fn get(&mut self, key: String) -> Result<Option<Vec<u8>>, SecretsErrorV1_1> {
819        if provider_core_only::is_enabled() {
820            warn!(secret = %key, "provider-core only mode enabled; blocking secrets store");
821            return Err(SecretsErrorV1_1::Denied);
822        }
823        if !self.config.secrets_policy.is_allowed(&key) {
824            return Err(SecretsErrorV1_1::Denied);
825        }
826        if let Some(mock) = &self.mocks
827            && let Some(value) = mock.secrets_lookup(&key)
828        {
829            return Ok(Some(value.into_bytes()));
830        }
831        let ctx = self.secrets_tenant_ctx();
832        let canonical_key = canonicalize_secret_key(&key);
833        match read_secret_blocking(&self.secrets, &ctx, &self.pack_id, &canonical_key) {
834            Ok(bytes) => Ok(Some(bytes)),
835            Err(err) => {
836                warn!(secret = %key, canonical = %canonical_key, error = %err, "secret lookup failed");
837                Err(SecretsErrorV1_1::NotFound)
838            }
839        }
840    }
841
842    fn put(&mut self, key: String, value: Vec<u8>) {
843        if key.trim().is_empty() {
844            warn!(secret = %key, "secret write blocked: empty key");
845            panic!("secret write denied for key {key}: invalid key");
846        }
847        if provider_core_only::is_enabled() && !self.allows_secret_write_in_provider_core_only() {
848            warn!(
849                secret = %key,
850                component = self.component_ref.as_deref().unwrap_or("<pack>"),
851                "provider-core only mode enabled; blocking secrets store write"
852            );
853            panic!("secret write denied for key {key}: provider-core-only mode");
854        }
855        if !self.config.secrets_policy.is_allowed(&key) {
856            warn!(secret = %key, "secret write denied by bindings policy");
857            panic!("secret write denied for key {key}: policy");
858        }
859        let ctx = self.secrets_tenant_ctx();
860        let canonical_key = canonicalize_secret_key(&key);
861        if let Err(err) =
862            write_secret_blocking(&self.secrets, &ctx, &self.pack_id, &canonical_key, &value)
863        {
864            warn!(secret = %key, canonical = %canonical_key, error = %err, "secret write failed");
865            panic!("secret write failed for key {key}");
866        }
867    }
868}
869
870/// Process-global set of `pack-config.v1` keys for which the compat shim has
871/// already logged a deprecation warning. Used to debounce once-per-process
872/// per-key so resolving the same legacy key from many invocations does not
873/// spam the log.
874static WARNED_COMPAT_KEYS: Lazy<Mutex<HashSet<String>>> = Lazy::new(|| Mutex::new(HashSet::new()));
875
876fn warn_compat_fallback_once(key: &str) {
877    let mut warned = WARNED_COMPAT_KEYS.lock();
878    if warned.insert(key.to_string()) {
879        warn!(
880            key = %key,
881            "runtime-config key resolved via secrets-store compat fallback; \
882             move this value into pack-config.v1.non_secret"
883        );
884    }
885}
886
887impl RuntimeConfigHost for HostState {
888    fn get(&mut self, key: String) -> Result<Option<String>, ConfigError> {
889        if key.trim().is_empty() {
890            return Err(ConfigError::InvalidKey);
891        }
892
893        // 1) Primary channel: pack-config.v1.non_secret. Values are stored as
894        //    `serde_json::Value`; the WIT contract returns UTF-8 strings
895        //    conventionally JSON-encoded, so stringify here.
896        if let Some(map) = self.runtime_config_non_secret.as_ref()
897            && let Some(value) = map.get(&key)
898        {
899            return serde_json::to_string(value).map(Some).map_err(|err| {
900                warn!(key = %key, error = %err, "runtime-config value JSON-encode failed");
901                ConfigError::Internal
902            });
903        }
904
905        // 1b) C5 channel: pack-config.v1.runtime_refs. Resolved on every call
906        //     so values track `runtime.json` hot-reloads. The per-pack `refs`
907        //     map gates which keys this channel claims; non-bound keys fall
908        //     through to the compat shim.
909        if let Some(injection) = self.runtime_refs.as_ref()
910            && let Some(uri) = injection.refs.get(&key)
911        {
912            use crate::runtime_refs::RuntimeRefResolverError;
913            return match injection.resolver.resolve(uri) {
914                Ok(Some(value)) => serde_json::to_string(&value).map(Some).map_err(|err| {
915                    warn!(key = %key, error = %err, "runtime-ref value JSON-encode failed");
916                    ConfigError::Internal
917                }),
918                Ok(None) => Ok(None),
919                Err(err @ RuntimeRefResolverError::Invalid(_)) => {
920                    warn!(key = %key, error = %err, "runtime-ref rejected");
921                    Err(ConfigError::InvalidKey)
922                }
923                Err(err @ RuntimeRefResolverError::Internal(_)) => {
924                    warn!(key = %key, error = %err, "runtime-ref resolution failed");
925                    Err(ConfigError::Internal)
926                }
927            };
928        }
929
930        // 2) Compat fallback: try the secrets-store. Warn once per key per
931        //    process so this stays visible without spamming the log.
932        match SecretsStoreHost::get(self, key.clone()) {
933            Ok(Some(bytes)) => match String::from_utf8(bytes) {
934                Ok(value) => {
935                    warn_compat_fallback_once(&key);
936                    Ok(Some(value))
937                }
938                Err(_) => {
939                    warn!(
940                        key = %key,
941                        "runtime-config compat fallback found non-UTF-8 secret bytes; \
942                         returning not-found"
943                    );
944                    Err(ConfigError::Internal)
945                }
946            },
947            Ok(None) => Ok(None),
948            Err(SecretsError::NotFound) => Ok(None),
949            Err(SecretsError::Denied) => Err(ConfigError::Denied),
950            Err(SecretsError::InvalidKey) => Err(ConfigError::InvalidKey),
951            Err(SecretsError::Internal) => Err(ConfigError::Internal),
952        }
953    }
954}
955
956impl HttpClientHost for HostState {
957    fn send(
958        &mut self,
959        req: HttpRequest,
960        ctx: Option<HttpTenantCtx>,
961    ) -> Result<HttpResponse, HttpClientError> {
962        self.send_http_request(req, None, ctx)
963    }
964}
965
966impl HttpClientHostV1_1 for HostState {
967    fn send(
968        &mut self,
969        req: HttpRequestV1_1,
970        opts: Option<HttpRequestOptionsV1_1>,
971        ctx: Option<HttpTenantCtxV1_1>,
972    ) -> Result<HttpResponseV1_1, HttpClientErrorV1_1> {
973        let legacy_req = HttpRequest {
974            method: req.method,
975            url: req.url,
976            headers: req.headers,
977            body: req.body,
978        };
979        let legacy_ctx = ctx.map(|ctx| HttpTenantCtx {
980            env: ctx.env,
981            tenant: ctx.tenant,
982            tenant_id: ctx.tenant_id,
983            team: ctx.team,
984            team_id: ctx.team_id,
985            user: ctx.user,
986            user_id: ctx.user_id,
987            trace_id: ctx.trace_id,
988            correlation_id: ctx.correlation_id,
989            i18n_id: ctx.i18n_id,
990            attributes: ctx.attributes,
991            session_id: ctx.session_id,
992            flow_id: ctx.flow_id,
993            node_id: ctx.node_id,
994            provider_id: ctx.provider_id,
995            deadline_ms: ctx.deadline_ms,
996            attempt: ctx.attempt,
997            idempotency_key: ctx.idempotency_key,
998            impersonation: ctx.impersonation.map(|imp| http_types_v1_0::Impersonation {
999                actor_id: imp.actor_id,
1000                reason: imp.reason,
1001            }),
1002        });
1003
1004        self.send_http_request(legacy_req, opts, legacy_ctx)
1005            .map(|resp| HttpResponseV1_1 {
1006                status: resp.status,
1007                headers: resp.headers,
1008                body: resp.body,
1009            })
1010            .map_err(|err| HttpClientErrorV1_1 {
1011                code: err.code,
1012                message: err.message,
1013            })
1014    }
1015}
1016
1017impl StateStoreHost for HostState {
1018    fn read(
1019        &mut self,
1020        key: HostStateKey,
1021        ctx: Option<StateTenantCtx>,
1022    ) -> Result<Vec<u8>, StateError> {
1023        let store = match self.state_store.as_ref() {
1024            Some(store) => store.clone(),
1025            None => {
1026                return Err(StateError {
1027                    code: "unavailable".into(),
1028                    message: "state store not configured".into(),
1029                });
1030            }
1031        };
1032        let tenant_ctx = match self.tenant_ctx_from_v1(ctx) {
1033            Ok(ctx) => ctx,
1034            Err(err) => {
1035                return Err(StateError {
1036                    code: "invalid-ctx".into(),
1037                    message: err.to_string(),
1038                });
1039            }
1040        };
1041        #[cfg(feature = "fault-injection")]
1042        {
1043            let exec_ctx = self.exec_ctx.as_ref();
1044            let flow_id = exec_ctx
1045                .map(|ctx| ctx.flow_id.as_str())
1046                .unwrap_or("unknown");
1047            let node_id = exec_ctx.and_then(|ctx| ctx.node_id.as_deref());
1048            let attempt = exec_ctx.map(|ctx| ctx.tenant.attempt).unwrap_or(1);
1049            let fault_ctx = FaultContext {
1050                pack_id: self.pack_id.as_str(),
1051                flow_id,
1052                node_id,
1053                attempt,
1054            };
1055            if let Err(err) = maybe_fail(FaultPoint::StateRead, fault_ctx) {
1056                return Err(StateError {
1057                    code: "internal".into(),
1058                    message: err.to_string(),
1059                });
1060            }
1061        }
1062        let key = StoreStateKey::from(key);
1063        match store.get_json(&tenant_ctx, STATE_PREFIX, &key, None) {
1064            Ok(Some(value)) => Ok(serde_json::to_vec(&value).unwrap_or_else(|_| Vec::new())),
1065            Ok(None) => Err(StateError {
1066                code: "not_found".into(),
1067                message: "state key not found".into(),
1068            }),
1069            Err(err) => Err(StateError {
1070                code: "internal".into(),
1071                message: err.to_string(),
1072            }),
1073        }
1074    }
1075
1076    fn write(
1077        &mut self,
1078        key: HostStateKey,
1079        bytes: Vec<u8>,
1080        ctx: Option<StateTenantCtx>,
1081    ) -> Result<StateOpAck, StateError> {
1082        let store = match self.state_store.as_ref() {
1083            Some(store) => store.clone(),
1084            None => {
1085                return Err(StateError {
1086                    code: "unavailable".into(),
1087                    message: "state store not configured".into(),
1088                });
1089            }
1090        };
1091        let tenant_ctx = match self.tenant_ctx_from_v1(ctx) {
1092            Ok(ctx) => ctx,
1093            Err(err) => {
1094                return Err(StateError {
1095                    code: "invalid-ctx".into(),
1096                    message: err.to_string(),
1097                });
1098            }
1099        };
1100        #[cfg(feature = "fault-injection")]
1101        {
1102            let exec_ctx = self.exec_ctx.as_ref();
1103            let flow_id = exec_ctx
1104                .map(|ctx| ctx.flow_id.as_str())
1105                .unwrap_or("unknown");
1106            let node_id = exec_ctx.and_then(|ctx| ctx.node_id.as_deref());
1107            let attempt = exec_ctx.map(|ctx| ctx.tenant.attempt).unwrap_or(1);
1108            let fault_ctx = FaultContext {
1109                pack_id: self.pack_id.as_str(),
1110                flow_id,
1111                node_id,
1112                attempt,
1113            };
1114            if let Err(err) = maybe_fail(FaultPoint::StateWrite, fault_ctx) {
1115                return Err(StateError {
1116                    code: "internal".into(),
1117                    message: err.to_string(),
1118                });
1119            }
1120        }
1121        let key = StoreStateKey::from(key);
1122        let value = serde_json::from_slice(&bytes)
1123            .unwrap_or_else(|_| Value::String(String::from_utf8_lossy(&bytes).to_string()));
1124        match store.set_json(&tenant_ctx, STATE_PREFIX, &key, None, &value, None) {
1125            Ok(()) => Ok(StateOpAck::Ok),
1126            Err(err) => Err(StateError {
1127                code: "internal".into(),
1128                message: err.to_string(),
1129            }),
1130        }
1131    }
1132
1133    fn delete(
1134        &mut self,
1135        key: HostStateKey,
1136        ctx: Option<StateTenantCtx>,
1137    ) -> Result<StateOpAck, StateError> {
1138        let store = match self.state_store.as_ref() {
1139            Some(store) => store.clone(),
1140            None => {
1141                return Err(StateError {
1142                    code: "unavailable".into(),
1143                    message: "state store not configured".into(),
1144                });
1145            }
1146        };
1147        let tenant_ctx = match self.tenant_ctx_from_v1(ctx) {
1148            Ok(ctx) => ctx,
1149            Err(err) => {
1150                return Err(StateError {
1151                    code: "invalid-ctx".into(),
1152                    message: err.to_string(),
1153                });
1154            }
1155        };
1156        let key = StoreStateKey::from(key);
1157        match store.del(&tenant_ctx, STATE_PREFIX, &key) {
1158            Ok(_) => Ok(StateOpAck::Ok),
1159            Err(err) => Err(StateError {
1160                code: "internal".into(),
1161                message: err.to_string(),
1162            }),
1163        }
1164    }
1165}
1166
1167impl TelemetryLoggerHost for HostState {
1168    fn log(
1169        &mut self,
1170        span: TelemetrySpanContext,
1171        fields: Vec<(String, String)>,
1172        _ctx: Option<TelemetryTenantCtx>,
1173    ) -> Result<TelemetryAck, TelemetryError> {
1174        if let Some(mock) = &self.mocks
1175            && mock.telemetry_drain(&[("span_json", span.flow_id.as_str())])
1176        {
1177            return Ok(TelemetryAck::Ok);
1178        }
1179        let mut map = serde_json::Map::new();
1180        for (k, v) in fields {
1181            map.insert(k, Value::String(v));
1182        }
1183        tracing::info!(
1184            tenant = %span.tenant,
1185            flow_id = %span.flow_id,
1186            node = ?span.node_id,
1187            provider = %span.provider,
1188            fields = %serde_json::Value::Object(map.clone()),
1189            "telemetry log from pack"
1190        );
1191        Ok(TelemetryAck::Ok)
1192    }
1193}
1194
1195impl RunnerHostHttp for HostState {
1196    fn request(
1197        &mut self,
1198        method: String,
1199        url: String,
1200        headers: Vec<String>,
1201        body: Option<Vec<u8>>,
1202    ) -> Result<Vec<u8>, String> {
1203        let req = HttpRequest {
1204            method,
1205            url,
1206            headers: headers
1207                .chunks(2)
1208                .filter_map(|chunk| {
1209                    if chunk.len() == 2 {
1210                        Some((chunk[0].clone(), chunk[1].clone()))
1211                    } else {
1212                        None
1213                    }
1214                })
1215                .collect(),
1216            body,
1217        };
1218        match HttpClientHost::send(self, req, None) {
1219            Ok(resp) => Ok(resp.body.unwrap_or_default()),
1220            Err(err) => Err(err.message),
1221        }
1222    }
1223}
1224
1225impl RunnerHostKv for HostState {
1226    fn get(&mut self, _ns: String, _key: String) -> Option<String> {
1227        None
1228    }
1229
1230    fn put(&mut self, _ns: String, _key: String, _val: String) {}
1231}
1232
1233impl OAuthBrokerHostTrait for HostState {
1234    /// Returns an access-token JSON string (`{"access_token":…,"expires_at":…}`) for the given
1235    /// provider and scopes, or an empty string on error.
1236    ///
1237    /// `subject` is informational for MVP — tenant, env, and team are taken from the host context
1238    /// (config + default_env + oauth_config.team), NOT derived from `subject`.
1239    fn get_token(
1240        &mut self,
1241        provider_id: wasmtime::component::__internal::String,
1242        _subject: wasmtime::component::__internal::String,
1243        scopes: wasmtime::component::__internal::Vec<wasmtime::component::__internal::String>,
1244    ) -> wasmtime::component::__internal::String {
1245        let Some(cfg) = self.oauth_config.clone() else {
1246            return String::new();
1247        };
1248        let req = ResourceTokenRequest {
1249            http_base_url: cfg.http_base_url,
1250            env: self.default_env.clone(),
1251            tenant: self.config.tenant.clone(),
1252            team: cfg.team.clone(),
1253            resource_id: provider_id.to_string(),
1254            scopes: scopes.into_iter().collect(),
1255        };
1256        match crate::oauth::request_resource_token_blocking(
1257            &self.http_client,
1258            &req,
1259            cfg.shared_secret.as_deref(),
1260        ) {
1261            Ok(resp) => serde_json::to_string(&resp).unwrap_or_default(),
1262            Err(e) => {
1263                tracing::warn!(
1264                    provider = %provider_id,
1265                    error = %e,
1266                    "oauth get-token proxy failed"
1267                );
1268                String::new()
1269            }
1270        }
1271    }
1272
1273    /// Operator-time only — OAuth consent URL generation happens in the admin UI, not at runtime.
1274    fn get_consent_url(
1275        &mut self,
1276        _provider_id: wasmtime::component::__internal::String,
1277        _subject: wasmtime::component::__internal::String,
1278        _scopes: wasmtime::component::__internal::Vec<wasmtime::component::__internal::String>,
1279        _redirect_path: wasmtime::component::__internal::String,
1280        _extra_json: wasmtime::component::__internal::String,
1281    ) -> wasmtime::component::__internal::String {
1282        String::new()
1283    }
1284
1285    /// Operator-time only — OAuth code exchange happens in the admin UI, not at runtime.
1286    fn exchange_code(
1287        &mut self,
1288        _provider_id: wasmtime::component::__internal::String,
1289        _subject: wasmtime::component::__internal::String,
1290        _code: wasmtime::component::__internal::String,
1291        _redirect_path: wasmtime::component::__internal::String,
1292    ) -> wasmtime::component::__internal::String {
1293        String::new()
1294    }
1295}
1296
1297enum ManifestLoad {
1298    New {
1299        manifest: Box<greentic_types::PackManifest>,
1300        flows: PackFlows,
1301    },
1302    Legacy {
1303        manifest: Box<legacy_pack::PackManifest>,
1304        flows: PackFlows,
1305    },
1306}
1307
1308fn load_manifest_and_flows(path: &Path) -> Result<ManifestLoad> {
1309    let mut archive = ZipArchive::new(File::open(path)?)
1310        .with_context(|| format!("{} is not a valid gtpack", path.display()))?;
1311    let bytes = read_entry(&mut archive, "manifest.cbor")
1312        .with_context(|| format!("missing manifest.cbor in {}", path.display()))?;
1313    match decode_pack_manifest(&bytes) {
1314        Ok(manifest) => {
1315            let cache = PackFlows::from_manifest(manifest.clone());
1316            Ok(ManifestLoad::New {
1317                manifest: Box::new(manifest),
1318                flows: cache,
1319            })
1320        }
1321        Err(err) => {
1322            tracing::debug!(
1323                error = %err,
1324                pack = %path.display(),
1325                "decode_pack_manifest failed for archive; falling back to legacy manifest"
1326            );
1327            let legacy: legacy_pack::PackManifest = serde_cbor::from_slice(&bytes)
1328                .context("failed to decode legacy pack manifest from manifest.cbor")?;
1329            let flows = load_legacy_flows_from_archive(&mut archive, &legacy)?;
1330            Ok(ManifestLoad::Legacy {
1331                manifest: Box::new(legacy),
1332                flows,
1333            })
1334        }
1335    }
1336}
1337
1338fn load_manifest_and_flows_from_dir(root: &Path) -> Result<ManifestLoad> {
1339    let manifest_path = root.join("manifest.cbor");
1340    let bytes = std::fs::read(&manifest_path)
1341        .with_context(|| format!("missing manifest.cbor in {}", root.display()))?;
1342    match decode_pack_manifest(&bytes) {
1343        Ok(manifest) => {
1344            let cache = PackFlows::from_manifest(manifest.clone());
1345            Ok(ManifestLoad::New {
1346                manifest: Box::new(manifest),
1347                flows: cache,
1348            })
1349        }
1350        Err(err) => {
1351            tracing::debug!(
1352                error = %err,
1353                pack = %root.display(),
1354                "decode_pack_manifest failed for materialized pack; trying legacy manifest"
1355            );
1356            let legacy: legacy_pack::PackManifest = serde_cbor::from_slice(&bytes)
1357                .context("failed to decode legacy pack manifest from manifest.cbor")?;
1358            let flows = load_legacy_flows_from_dir(root, &legacy)?;
1359            Ok(ManifestLoad::Legacy {
1360                manifest: Box::new(legacy),
1361                flows,
1362            })
1363        }
1364    }
1365}
1366
1367fn load_legacy_flows_from_dir(
1368    root: &Path,
1369    manifest: &legacy_pack::PackManifest,
1370) -> Result<PackFlows> {
1371    build_legacy_flows(manifest, |rel_path| {
1372        let path = root.join(rel_path);
1373        std::fs::read(&path).with_context(|| format!("missing flow json {}", path.display()))
1374    })
1375}
1376
1377fn load_legacy_flows_from_archive(
1378    archive: &mut ZipArchive<File>,
1379    manifest: &legacy_pack::PackManifest,
1380) -> Result<PackFlows> {
1381    build_legacy_flows(manifest, |rel_path| {
1382        read_entry(archive, rel_path).with_context(|| format!("missing flow json {}", rel_path))
1383    })
1384}
1385
1386fn build_legacy_flows(
1387    manifest: &legacy_pack::PackManifest,
1388    mut read_json: impl FnMut(&str) -> Result<Vec<u8>>,
1389) -> Result<PackFlows> {
1390    let mut flows = HashMap::new();
1391    let mut descriptors = Vec::new();
1392
1393    for entry in &manifest.flows {
1394        let bytes = read_json(&entry.file_json)
1395            .with_context(|| format!("missing flow json {}", entry.file_json))?;
1396        let doc = parse_flow_doc_with_legacy_aliases(&bytes)?;
1397        let normalized = normalize_flow_doc(doc);
1398        let flow_ir = flow_doc_to_ir(normalized)?;
1399        let flow = flow_ir_to_flow(flow_ir)?;
1400
1401        descriptors.push(FlowDescriptor {
1402            id: entry.id.clone(),
1403            flow_type: entry.kind.clone(),
1404            pack_id: manifest.meta.pack_id.clone(),
1405            profile: manifest.meta.pack_id.clone(),
1406            version: manifest.meta.version.to_string(),
1407            description: None,
1408            // Legacy manifests carry no flow tags; treat every flow as an
1409            // entrypoint, matching prior behaviour.
1410            entry: true,
1411        });
1412        flows.insert(entry.id.clone(), flow);
1413    }
1414
1415    let mut entry_flows = manifest.meta.entry_flows.clone();
1416    if entry_flows.is_empty() {
1417        entry_flows = manifest.flows.iter().map(|f| f.id.clone()).collect();
1418    }
1419    let metadata = PackMetadata {
1420        pack_id: manifest.meta.pack_id.clone(),
1421        version: manifest.meta.version.to_string(),
1422        entry_flows,
1423        secret_requirements: Vec::new(),
1424    };
1425
1426    Ok(PackFlows {
1427        descriptors,
1428        flows,
1429        metadata,
1430    })
1431}
1432
1433fn parse_flow_doc_with_legacy_aliases(bytes: &[u8]) -> Result<FlowDoc> {
1434    let mut value: Value =
1435        serde_json::from_slice(bytes).context("failed to decode flow doc JSON")?;
1436    if let Some(map) = value.as_object_mut()
1437        && !map.contains_key("type")
1438        && let Some(flow_type) = map.remove("flow_type")
1439    {
1440        map.insert("type".to_string(), flow_type);
1441    }
1442    serde_json::from_value(value).context("failed to decode flow doc structure")
1443}
1444
1445pub struct ComponentState {
1446    pub host: HostState,
1447    wasi_ctx: WasiCtx,
1448    wasi_tls_ctx: WasiTlsCtx,
1449    wasi_http_ctx: WasiHttpCtx,
1450    resource_table: ResourceTable,
1451}
1452
1453/// Install the process-default rustls [`rustls::crypto::CryptoProvider`] exactly once.
1454///
1455/// `wasmtime-wasi-tls` 45's `RustlsProvider::default()` builds its
1456/// `rustls::ClientConfig` via `ClientConfig::builder()`, which resolves the
1457/// process-default provider. Our dependency graph enables BOTH the `ring` and
1458/// `aws_lc_rs` rustls backends, so there is no unambiguous implicit default and
1459/// the builder panics ("no process-level CryptoProvider available") the first
1460/// time [`WasiTlsCtxBuilder::build`] constructs the default provider. Install
1461/// the workspace-selected aws-lc-rs provider before that ever happens.
1462/// Idempotent: a returned `Err` means a default was already installed.
1463fn install_default_crypto_provider() {
1464    static ONCE: std::sync::Once = std::sync::Once::new();
1465    ONCE.call_once(|| {
1466        let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
1467    });
1468}
1469
1470impl ComponentState {
1471    pub fn new(host: HostState, policy: Arc<RunnerWasiPolicy>) -> Result<Self> {
1472        // Must run before `WasiTlsCtxBuilder::build()` below, which eagerly
1473        // constructs wasi-tls's default rustls provider.
1474        install_default_crypto_provider();
1475        let wasi_ctx = policy
1476            .instantiate()
1477            .context("failed to build WASI context")?;
1478        Ok(Self {
1479            host,
1480            wasi_ctx,
1481            wasi_tls_ctx: WasiTlsCtxBuilder::new().build(),
1482            wasi_http_ctx: WasiHttpCtx::new(),
1483            resource_table: ResourceTable::new(),
1484        })
1485    }
1486
1487    fn host_mut(&mut self) -> &mut HostState {
1488        &mut self.host
1489    }
1490
1491    fn should_cancel_host(&mut self) -> bool {
1492        false
1493    }
1494
1495    fn yield_now_host(&mut self) {
1496        // no-op cooperative yield
1497    }
1498}
1499
1500impl component_api::v0_4::greentic::component::control::Host for ComponentState {
1501    fn should_cancel(&mut self) -> bool {
1502        self.should_cancel_host()
1503    }
1504
1505    fn yield_now(&mut self) {
1506        self.yield_now_host();
1507    }
1508}
1509
1510impl component_api::v0_5::greentic::component::control::Host for ComponentState {
1511    fn should_cancel(&mut self) -> bool {
1512        self.should_cancel_host()
1513    }
1514
1515    fn yield_now(&mut self) {
1516        self.yield_now_host();
1517    }
1518}
1519
1520fn add_component_control_instance(
1521    linker: &mut Linker<ComponentState>,
1522    name: &str,
1523) -> wasmtime::Result<()> {
1524    let mut inst = linker.instance(name)?;
1525    inst.func_wrap(
1526        "should-cancel",
1527        |mut caller: StoreContextMut<'_, ComponentState>, (): ()| {
1528            let host = caller.data_mut();
1529            Ok((host.should_cancel_host(),))
1530        },
1531    )?;
1532    inst.func_wrap(
1533        "yield-now",
1534        |mut caller: StoreContextMut<'_, ComponentState>, (): ()| {
1535            let host = caller.data_mut();
1536            host.yield_now_host();
1537            Ok(())
1538        },
1539    )?;
1540    Ok(())
1541}
1542
1543fn add_component_control_to_linker(linker: &mut Linker<ComponentState>) -> wasmtime::Result<()> {
1544    add_component_control_instance(linker, "greentic:component/control@0.5.0")?;
1545    add_component_control_instance(linker, "greentic:component/control@0.4.0")?;
1546    Ok(())
1547}
1548
1549/// Reduced-authority linker for `identify-instance` and
1550/// `describe-identify-instance` probes (M1 IID Phase D).
1551///
1552/// Identity probes match an inbound webhook payload against known
1553/// per-endpoint discriminators (Telegram secret-token header, Slack
1554/// signing-secret, Teams JWT issuer, …) and return an endpoint id
1555/// or `none`. The WIT contract is a pure projection over `(headers,
1556/// body)` — no outbound HTTP, no persistent state, no secrets.
1557///
1558/// # Why this delegates to `register_all` (Wasmtime eager-import constraint)
1559///
1560/// Wasmtime's [`Linker::instantiate_pre`] type-checks the component's
1561/// **entire** import graph eagerly — not just the imports reachable from
1562/// the export the caller intends to invoke. A provider component that
1563/// exports `instance-identity-api` alongside `schema-core-api` typically
1564/// also imports `http-client`, `secrets-store`, etc. for the latter.
1565/// If the linker omits those imports, `instantiate_pre` fails with
1566/// `"a matching implementation was not found in the linker"` before the
1567/// identity export is even checked.
1568///
1569/// The ideal probe linker would register deny-shim handlers that satisfy
1570/// the import graph but trap on actual invocation. That requires
1571/// deny-shim support in `greentic-interfaces-wasmtime` (tracked as a
1572/// follow-up). Until then, probes use the same linker surface as normal
1573/// execution, with state-store disabled.
1574///
1575/// The reduced-authority boundary is enforced at the WASI policy layer
1576/// instead: probe call sites construct a locked-down
1577/// [`RunnerWasiPolicy`](crate::wasi::RunnerWasiPolicy) with no
1578/// preopens, no env passthrough, and no stdio inheritance. See
1579/// [`RunnerWasiPolicy::probe()`](crate::wasi::RunnerWasiPolicy::probe)
1580/// and the `probe_wasi_policy_is_locked_down` test.
1581pub fn register_identity_probe(linker: &mut Linker<ComponentState>) -> Result<()> {
1582    // Delegates to `register_all` with state-store disabled. See doc
1583    // comment above for the rationale (Wasmtime eager-import validation).
1584    register_all(linker, false)
1585}
1586
1587#[cfg(test)]
1588mod register_identity_probe_tests {
1589    use super::*;
1590
1591    /// Verify that [`register_identity_probe`] successfully links all
1592    /// imports needed by real provider components (wasi-core, wasi-tls,
1593    /// wasi-http, http-client, secrets-store, telemetry, etc.).
1594    ///
1595    /// Before this fix, the probe linker omitted most host imports.
1596    /// Wasmtime's `instantiate_pre` validates the **entire** import
1597    /// graph eagerly, so any provider with runtime imports (all real
1598    /// providers) would fail before the identity export was checked.
1599    #[test]
1600    fn register_identity_probe_links_successfully() {
1601        let engine = wasmtime::Engine::default();
1602        let mut linker = Linker::<ComponentState>::new(&engine);
1603        register_identity_probe(&mut linker).expect("probe linker registers all imports");
1604    }
1605
1606    /// Verify that the probe WASI policy has no preopens, no env, and
1607    /// no stdio — the only reduced-authority boundary available today.
1608    #[test]
1609    fn probe_wasi_policy_is_locked_down() {
1610        let policy = RunnerWasiPolicy::probe();
1611        assert!(!policy.inherit_stdio, "probe WASI must not inherit stdio");
1612        assert!(
1613            policy.preopens.is_empty(),
1614            "probe WASI must have no preopens"
1615        );
1616        assert!(
1617            policy.env_allow.is_empty(),
1618            "probe WASI must not allow env vars"
1619        );
1620        assert!(
1621            policy.env_set.is_empty(),
1622            "probe WASI must not set env vars"
1623        );
1624    }
1625}
1626
1627pub fn register_all(linker: &mut Linker<ComponentState>, allow_state_store: bool) -> Result<()> {
1628    install_default_crypto_provider();
1629
1630    add_wasi_to_linker(linker)?;
1631
1632    // Add wasi-tls types and turn on the feature in linker
1633    let mut opts = LinkOptions::default();
1634    opts.tls(true);
1635    wasmtime_wasi_tls::p2::add_to_linker(linker, &opts)?;
1636
1637    // Add wasi-http types and turn on the feature in linker
1638    add_wasi_http_to_linker(linker)?;
1639
1640    add_all_v1_to_linker(
1641        linker,
1642        HostFns {
1643            http_client_v1_1: Some(|state: &mut ComponentState| state.host_mut()),
1644            http_client: Some(|state: &mut ComponentState| state.host_mut()),
1645            oauth_broker: Some(|state: &mut ComponentState| state.host_mut()),
1646            runner_host_http: Some(|state: &mut ComponentState| state.host_mut()),
1647            runner_host_kv: Some(|state: &mut ComponentState| state.host_mut()),
1648            telemetry_logger: Some(|state: &mut ComponentState| state.host_mut()),
1649            state_store: allow_state_store.then_some(|state: &mut ComponentState| state.host_mut()),
1650            secrets_store_v1_1: Some(|state: &mut ComponentState| state.host_mut()),
1651            secrets_store: None,
1652            runtime_config: Some(|state: &mut ComponentState| state.host_mut()),
1653        },
1654    )?;
1655    add_http_client_client_world_aliases(linker)?;
1656    add_telemetry_logging_stub(linker)?;
1657    Ok(())
1658}
1659
1660/// Some generated MCP components import `greentic:telemetry/logging` for guest
1661/// instrumentation (it's baked in by the generator's telemetry wiring). The
1662/// runner emits its own telemetry via the native pipeline and does not consume
1663/// these guest events, so we satisfy the import with no-ops — otherwise such a
1664/// component fails to instantiate ("matching implementation was not found in the
1665/// linker"). Registered dynamically (`func_new`) so we don't need generated
1666/// bindings for the interface; the signature is resolved at instantiation.
1667fn add_telemetry_logging_stub(linker: &mut Linker<ComponentState>) -> Result<()> {
1668    let mut inst = match linker.instance("greentic:telemetry/logging") {
1669        Ok(inst) => inst,
1670        // Already defined by another registration path — nothing to do.
1671        Err(_) => return Ok(()),
1672    };
1673    // log: func(lvl: level, message: string, fields: fields)
1674    inst.func_new("log", |_store, _ty, _params, _results| Ok(()))?;
1675    // span-start: func(name: string, fields: fields) -> u64
1676    inst.func_new("span-start", |_store, _ty, _params, results| {
1677        if let Some(slot) = results.get_mut(0) {
1678            *slot = wasmtime::component::Val::U64(0);
1679        }
1680        Ok(())
1681    })?;
1682    // span-end: func(id: u64)
1683    inst.func_new("span-end", |_store, _ty, _params, _results| Ok(()))?;
1684    Ok(())
1685}
1686
1687fn add_http_client_client_world_aliases(linker: &mut Linker<ComponentState>) -> Result<()> {
1688    let mut inst_v1_1 = linker.instance("greentic:http/client@1.1.0")?;
1689    inst_v1_1.func_wrap(
1690        "send",
1691        move |mut caller: StoreContextMut<'_, ComponentState>,
1692              (req, opts, ctx): (
1693            http_client_client_alias::Request,
1694            Option<http_client_client_alias::RequestOptions>,
1695            Option<http_client_client_alias::TenantCtx>,
1696        )| {
1697            let host = caller.data_mut().host_mut();
1698            let result = HttpClientHostV1_1::send(
1699                host,
1700                alias_request_to_host(req),
1701                opts.map(alias_request_options_to_host),
1702                ctx.map(alias_tenant_ctx_to_host),
1703            );
1704            Ok((match result {
1705                Ok(resp) => Ok(alias_response_from_host(resp)),
1706                Err(err) => Err(alias_error_from_host(err)),
1707            },))
1708        },
1709    )?;
1710    let mut inst_v1_0 = linker.instance("greentic:http/client@1.0.0")?;
1711    inst_v1_0.func_wrap(
1712        "send",
1713        move |mut caller: StoreContextMut<'_, ComponentState>,
1714              (req, ctx): (
1715            host_http_client::Request,
1716            Option<host_http_client::TenantCtx>,
1717        )| {
1718            let host = caller.data_mut().host_mut();
1719            let result = HttpClientHost::send(host, req, ctx);
1720            Ok((result,))
1721        },
1722    )?;
1723    Ok(())
1724}
1725
1726fn alias_request_to_host(req: http_client_client_alias::Request) -> host_http_client::RequestV1_1 {
1727    host_http_client::RequestV1_1 {
1728        method: req.method,
1729        url: req.url,
1730        headers: req.headers,
1731        body: req.body,
1732    }
1733}
1734
1735fn alias_request_options_to_host(
1736    opts: http_client_client_alias::RequestOptions,
1737) -> host_http_client::RequestOptionsV1_1 {
1738    host_http_client::RequestOptionsV1_1 {
1739        timeout_ms: opts.timeout_ms,
1740        allow_insecure: opts.allow_insecure,
1741        follow_redirects: opts.follow_redirects,
1742    }
1743}
1744
1745fn alias_tenant_ctx_to_host(
1746    ctx: http_client_client_alias::TenantCtx,
1747) -> host_http_client::TenantCtxV1_1 {
1748    host_http_client::TenantCtxV1_1 {
1749        env: ctx.env,
1750        tenant: ctx.tenant,
1751        tenant_id: ctx.tenant_id,
1752        team: ctx.team,
1753        team_id: ctx.team_id,
1754        user: ctx.user,
1755        user_id: ctx.user_id,
1756        trace_id: ctx.trace_id,
1757        correlation_id: ctx.correlation_id,
1758        i18n_id: ctx.i18n_id,
1759        attributes: ctx.attributes,
1760        session_id: ctx.session_id,
1761        flow_id: ctx.flow_id,
1762        node_id: ctx.node_id,
1763        provider_id: ctx.provider_id,
1764        deadline_ms: ctx.deadline_ms,
1765        attempt: ctx.attempt,
1766        idempotency_key: ctx.idempotency_key,
1767        impersonation: ctx.impersonation.map(|imp| http_types_v1_1::Impersonation {
1768            actor_id: imp.actor_id,
1769            reason: imp.reason,
1770        }),
1771    }
1772}
1773
1774fn alias_response_from_host(
1775    resp: host_http_client::ResponseV1_1,
1776) -> http_client_client_alias::Response {
1777    http_client_client_alias::Response {
1778        status: resp.status,
1779        headers: resp.headers,
1780        body: resp.body,
1781    }
1782}
1783
1784fn alias_error_from_host(
1785    err: host_http_client::HttpClientErrorV1_1,
1786) -> http_client_client_alias::HostError {
1787    http_client_client_alias::HostError {
1788        code: err.code,
1789        message: err.message,
1790    }
1791}
1792
1793impl WasiView for ComponentState {
1794    fn ctx(&mut self) -> WasiCtxView<'_> {
1795        WasiCtxView {
1796            ctx: &mut self.wasi_ctx,
1797            table: &mut self.resource_table,
1798        }
1799    }
1800}
1801
1802impl WasiHttpView for ComponentState {
1803    fn http(&mut self) -> WasiHttpCtxView<'_> {
1804        WasiHttpCtxView {
1805            ctx: &mut self.wasi_http_ctx,
1806            table: &mut self.resource_table,
1807            hooks: Default::default(),
1808        }
1809    }
1810}
1811
1812impl WasiTlsView for ComponentState {
1813    fn tls(&mut self) -> WasiTlsCtxView<'_> {
1814        WasiTlsCtxView {
1815            ctx: &mut self.wasi_tls_ctx,
1816            table: &mut self.resource_table,
1817        }
1818    }
1819}
1820
1821#[allow(unsafe_code)]
1822unsafe impl Send for ComponentState {}
1823#[allow(unsafe_code)]
1824unsafe impl Sync for ComponentState {}
1825
1826impl PackRuntime {
1827    fn allows_state_store(&self, component_ref: &str) -> bool {
1828        if self.state_store.is_none() {
1829            return false;
1830        }
1831        if !self.config.state_store_policy.allow {
1832            return false;
1833        }
1834        let Some(manifest) = self.component_manifests.get(component_ref) else {
1835            // No manifest entry — allow state-store; Wasmtime rejects if not imported.
1836            return true;
1837        };
1838        // If manifest declares host.state capabilities, honour them.
1839        // If host.state is None (not declared in manifest), default to true so
1840        // components whose CBOR manifest omits the field still get state-store
1841        // linked — Wasmtime will reject at instantiation if not actually imported.
1842        manifest
1843            .capabilities
1844            .host
1845            .state
1846            .as_ref()
1847            .map(|caps| caps.read || caps.write)
1848            .unwrap_or(true)
1849    }
1850
1851    pub fn contains_component(&self, component_ref: &str) -> bool {
1852        self.components.contains_key(component_ref)
1853    }
1854
1855    /// Returns a clonable handle to the pack's state store, when one is
1856    /// configured. Used by the flow engine's built-in `state.get`/`state.set`
1857    /// operators which call into the same store that WASM components read
1858    /// through their `state.read`/`state.write` host imports.
1859    pub fn state_store_handle(&self) -> Option<crate::storage::DynStateStore> {
1860        self.state_store.clone()
1861    }
1862
1863    #[allow(clippy::too_many_arguments)]
1864    pub async fn load(
1865        path: impl AsRef<Path>,
1866        config: Arc<HostConfig>,
1867        mocks: Option<Arc<MockLayer>>,
1868        archive_source: Option<&Path>,
1869        session_store: Option<DynSessionStore>,
1870        state_store: Option<DynStateStore>,
1871        wasi_policy: Arc<RunnerWasiPolicy>,
1872        secrets: DynSecretsManager,
1873        oauth_config: Option<OAuthBrokerConfig>,
1874        verify_archive: bool,
1875        component_resolution: ComponentResolution,
1876    ) -> Result<Self> {
1877        let path = path.as_ref();
1878        let (_pack_root, safe_path) = normalize_pack_path(path)?;
1879        let path_meta = std::fs::metadata(&safe_path).ok();
1880        let is_dir = path_meta
1881            .as_ref()
1882            .map(|meta| meta.is_dir())
1883            .unwrap_or(false);
1884        let is_component = !is_dir
1885            && safe_path
1886                .extension()
1887                .and_then(|ext| ext.to_str())
1888                .map(|ext| ext.eq_ignore_ascii_case("wasm"))
1889                .unwrap_or(false);
1890        let archive_hint_path = if let Some(source) = archive_source {
1891            let (_, normalized) = normalize_pack_path(source)?;
1892            Some(normalized)
1893        } else if is_component || is_dir {
1894            None
1895        } else {
1896            Some(safe_path.clone())
1897        };
1898        let archive_hint = archive_hint_path.as_deref();
1899        if verify_archive {
1900            if let Some(verify_target) = archive_hint.and_then(|p| {
1901                std::fs::metadata(p)
1902                    .ok()
1903                    .filter(|meta| meta.is_file())
1904                    .map(|_| p)
1905            }) {
1906                verify::verify_pack(verify_target).await?;
1907                tracing::info!(pack_path = %verify_target.display(), "pack verification complete");
1908            } else {
1909                tracing::debug!("skipping archive verification (no archive source)");
1910            }
1911        }
1912        let engine = Engine::default();
1913        let engine_profile =
1914            EngineProfile::from_engine(&engine, CpuPolicy::Native, "default".to_string());
1915        let cache = CacheManager::new(CacheConfig::default(), engine_profile);
1916        let mut metadata = PackMetadata::fallback(&safe_path);
1917        let mut manifest = None;
1918        let mut legacy_manifest: Option<Box<legacy_pack::PackManifest>> = None;
1919        let mut flows = None;
1920        let materialized_root = component_resolution.materialized_root.clone().or_else(|| {
1921            if is_dir {
1922                Some(safe_path.clone())
1923            } else {
1924                None
1925            }
1926        });
1927        let (pack_assets_dir, assets_tempdir) =
1928            locate_pack_assets(materialized_root.as_deref(), archive_hint)?;
1929        let setup_yaml_exists = pack_assets_dir
1930            .as_ref()
1931            .map(|dir| dir.join("setup.yaml").is_file())
1932            .unwrap_or(false);
1933        tracing::info!(
1934            pack_root = %safe_path.display(),
1935            assets_setup_yaml_exists = setup_yaml_exists,
1936            "pack unpack metadata"
1937        );
1938
1939        if let Some(root) = materialized_root.as_ref() {
1940            match load_manifest_and_flows_from_dir(root) {
1941                Ok(ManifestLoad::New {
1942                    manifest: m,
1943                    flows: cache,
1944                }) => {
1945                    metadata = cache.metadata.clone();
1946                    manifest = Some(*m);
1947                    flows = Some(cache);
1948                }
1949                Ok(ManifestLoad::Legacy {
1950                    manifest: m,
1951                    flows: cache,
1952                }) => {
1953                    metadata = cache.metadata.clone();
1954                    legacy_manifest = Some(m);
1955                    flows = Some(cache);
1956                }
1957                Err(err) => {
1958                    warn!(error = %err, pack = %root.display(), "failed to parse materialized pack manifest");
1959                }
1960            }
1961        }
1962
1963        if manifest.is_none()
1964            && legacy_manifest.is_none()
1965            && let Some(archive_path) = archive_hint
1966        {
1967            let manifest_load = load_manifest_and_flows(archive_path).with_context(|| {
1968                format!(
1969                    "failed to load manifest.cbor from {}",
1970                    archive_path.display()
1971                )
1972            })?;
1973            match manifest_load {
1974                ManifestLoad::New {
1975                    manifest: m,
1976                    flows: cache,
1977                } => {
1978                    metadata = cache.metadata.clone();
1979                    manifest = Some(*m);
1980                    flows = Some(cache);
1981                }
1982                ManifestLoad::Legacy {
1983                    manifest: m,
1984                    flows: cache,
1985                } => {
1986                    metadata = cache.metadata.clone();
1987                    legacy_manifest = Some(m);
1988                    flows = Some(cache);
1989                }
1990            }
1991        }
1992        #[cfg(feature = "fault-injection")]
1993        {
1994            let fault_ctx = FaultContext {
1995                pack_id: metadata.pack_id.as_str(),
1996                flow_id: "unknown",
1997                node_id: None,
1998                attempt: 1,
1999            };
2000            maybe_fail(FaultPoint::PackResolve, fault_ctx)
2001                .map_err(|err| anyhow!(err.to_string()))?;
2002        }
2003        let mut pack_lock = None;
2004        for root in find_pack_lock_roots(&safe_path, is_dir, archive_hint) {
2005            pack_lock = load_pack_lock(&root)?;
2006            if pack_lock.is_some() {
2007                break;
2008            }
2009        }
2010        let component_sources_payload = if pack_lock.is_none() {
2011            if let Some(manifest) = manifest.as_ref() {
2012                manifest
2013                    .get_component_sources_v1()
2014                    .context("invalid component sources extension")?
2015            } else {
2016                None
2017            }
2018        } else {
2019            None
2020        };
2021        let component_sources = if let Some(lock) = pack_lock.as_ref() {
2022            Some(component_sources_table_from_pack_lock(
2023                lock,
2024                component_resolution.allow_missing_hash,
2025            )?)
2026        } else {
2027            component_sources_table(component_sources_payload.as_ref())?
2028        };
2029        let components = if is_component {
2030            let wasm_bytes = fs::read(&safe_path).await?;
2031            metadata = PackMetadata::from_wasm(&wasm_bytes)
2032                .unwrap_or_else(|| PackMetadata::fallback(&safe_path));
2033            let name = safe_path
2034                .file_stem()
2035                .map(|s| s.to_string_lossy().to_string())
2036                .unwrap_or_else(|| "component".to_string());
2037            let component = compile_component_with_cache(&cache, &engine, None, wasm_bytes).await?;
2038            let mut map = HashMap::new();
2039            map.insert(
2040                name.clone(),
2041                PackComponent {
2042                    name,
2043                    version: metadata.version.clone(),
2044                    component,
2045                },
2046            );
2047            map
2048        } else {
2049            let specs = component_specs(
2050                manifest.as_ref(),
2051                legacy_manifest.as_deref(),
2052                component_sources_payload.as_ref(),
2053                pack_lock.as_ref(),
2054            );
2055            if specs.is_empty() {
2056                HashMap::new()
2057            } else {
2058                let mut loaded = HashMap::new();
2059                let mut missing: HashSet<String> =
2060                    specs.iter().map(|spec| spec.id.clone()).collect();
2061                let mut searched = Vec::new();
2062
2063                if !component_resolution.overrides.is_empty() {
2064                    load_components_from_overrides(
2065                        &cache,
2066                        &engine,
2067                        &component_resolution.overrides,
2068                        &specs,
2069                        &mut missing,
2070                        &mut loaded,
2071                    )
2072                    .await?;
2073                    searched.push("override map".to_string());
2074                }
2075
2076                if let Some(component_sources) = component_sources.as_ref() {
2077                    load_components_from_sources(
2078                        &cache,
2079                        &engine,
2080                        component_sources,
2081                        &component_resolution,
2082                        &specs,
2083                        &mut missing,
2084                        &mut loaded,
2085                        materialized_root.as_deref(),
2086                        archive_hint,
2087                    )
2088                    .await?;
2089                    searched.push(format!("extension {}", EXT_COMPONENT_SOURCES_V1));
2090                }
2091
2092                if let Some(root) = materialized_root.as_ref() {
2093                    load_components_from_dir(
2094                        &cache,
2095                        &engine,
2096                        root,
2097                        &specs,
2098                        &mut missing,
2099                        &mut loaded,
2100                    )
2101                    .await?;
2102                    searched.push(format!("components dir {}", root.display()));
2103                }
2104
2105                if let Some(archive_path) = archive_hint {
2106                    load_components_from_archive(
2107                        &cache,
2108                        &engine,
2109                        archive_path,
2110                        &specs,
2111                        &mut missing,
2112                        &mut loaded,
2113                    )
2114                    .await?;
2115                    searched.push(format!("archive {}", archive_path.display()));
2116                }
2117
2118                if !missing.is_empty() {
2119                    let missing_list = missing.into_iter().collect::<Vec<_>>().join(", ");
2120                    let sources = if searched.is_empty() {
2121                        "no component sources".to_string()
2122                    } else {
2123                        searched.join(", ")
2124                    };
2125                    bail!(
2126                        "components missing: {}; looked in {}",
2127                        missing_list,
2128                        sources
2129                    );
2130                }
2131
2132                loaded
2133            }
2134        };
2135        let http_client = Arc::clone(&HTTP_CLIENT);
2136        let mut component_manifests = HashMap::new();
2137        if let Some(manifest) = manifest.as_ref() {
2138            for component in &manifest.components {
2139                component_manifests.insert(component.id.as_str().to_string(), component.clone());
2140            }
2141        }
2142        let mut pack_policy = (*wasi_policy).clone();
2143        if let Some(dir) = pack_assets_dir {
2144            tracing::debug!(path = %dir.display(), "preopening pack assets directory for WASI /assets");
2145            pack_policy =
2146                pack_policy.with_preopen(PreopenSpec::new(dir, "/assets").read_only(true));
2147        }
2148        let wasi_policy = Arc::new(pack_policy);
2149        Ok(Self {
2150            path: safe_path,
2151            archive_path: archive_hint.map(Path::to_path_buf),
2152            config,
2153            engine,
2154            metadata,
2155            manifest,
2156            legacy_manifest,
2157            component_manifests,
2158            mocks,
2159            flows,
2160            components,
2161            http_client,
2162            session_store,
2163            state_store,
2164            wasi_policy,
2165            mcp_routes: std::sync::OnceLock::new(),
2166            assets_tempdir,
2167            provider_registry: RwLock::new(None),
2168            identify_hint_cache: RwLock::new(HashMap::new()),
2169            secrets,
2170            oauth_config,
2171            cache,
2172            runtime_config_non_secret: None,
2173            runtime_refs: None,
2174        })
2175    }
2176
2177    /// Inject the `pack-config.v1.non_secret` map for this pack. Called by
2178    /// the producer (greentic-start, C4.3) after loading the deployed
2179    /// `PackConfig`. Passing `None` clears any previously-set map.
2180    pub fn set_runtime_config_non_secret(&mut self, map: Option<Arc<BTreeMap<String, Value>>>) {
2181        self.runtime_config_non_secret = map;
2182    }
2183
2184    /// Read-only accessor for the injected `pack-config.v1.non_secret` map.
2185    /// Used by the revision loader's tests to assert producer plumbing.
2186    pub fn runtime_config_non_secret(&self) -> Option<&Arc<BTreeMap<String, Value>>> {
2187        self.runtime_config_non_secret.as_ref()
2188    }
2189
2190    /// Inject the `pack-config.v1.runtime_refs` channel (C5): per-pack
2191    /// `key → URI` bindings plus the env-shared resolver. Called by
2192    /// greentic-start after loading the deployed `PackConfig`. Passing
2193    /// `None` clears any previously-set injection.
2194    pub fn set_runtime_refs(&mut self, injection: Option<RuntimeRefsInjection>) {
2195        self.runtime_refs = injection;
2196    }
2197
2198    /// Read-only accessor for the injected runtime-refs channel. Used by
2199    /// the revision loader's tests to assert producer plumbing.
2200    pub fn runtime_refs(&self) -> Option<&RuntimeRefsInjection> {
2201        self.runtime_refs.as_ref()
2202    }
2203
2204    pub async fn list_flows(&self) -> Result<Vec<FlowDescriptor>> {
2205        if let Some(cache) = &self.flows {
2206            return Ok(cache.descriptors.clone());
2207        }
2208        if let Some(manifest) = &self.manifest {
2209            let descriptors = manifest
2210                .flows
2211                .iter()
2212                .map(|flow| FlowDescriptor {
2213                    id: flow.id.as_str().to_string(),
2214                    flow_type: flow_kind_to_str(flow.kind).to_string(),
2215                    pack_id: manifest.pack_id.as_str().to_string(),
2216                    profile: manifest.pack_id.as_str().to_string(),
2217                    version: manifest.version.to_string(),
2218                    description: None,
2219                    entry: tags_indicate_entry(flow.tags.iter().map(String::as_str)),
2220                })
2221                .collect();
2222            return Ok(descriptors);
2223        }
2224        Ok(Vec::new())
2225    }
2226
2227    #[allow(dead_code)]
2228    pub async fn run_flow(
2229        &self,
2230        flow_id: &str,
2231        input: serde_json::Value,
2232    ) -> Result<serde_json::Value> {
2233        let pack = Arc::new(
2234            PackRuntime::load(
2235                &self.path,
2236                Arc::clone(&self.config),
2237                self.mocks.clone(),
2238                self.archive_path.as_deref(),
2239                self.session_store.clone(),
2240                self.state_store.clone(),
2241                Arc::clone(&self.wasi_policy),
2242                self.secrets.clone(),
2243                self.oauth_config.clone(),
2244                false,
2245                ComponentResolution::default(),
2246            )
2247            .await?,
2248        );
2249
2250        let engine = FlowEngine::new(vec![Arc::clone(&pack)], Arc::clone(&self.config)).await?;
2251        let retry_config = self.config.retry_config().into();
2252        let mocks = pack.mocks.as_deref();
2253        let tenant = self.config.tenant.as_str();
2254
2255        let ctx = FlowContext {
2256            tenant,
2257            pack_id: pack.metadata().pack_id.as_str(),
2258            flow_id,
2259            node_id: None,
2260            tool: None,
2261            action: None,
2262            session_id: None,
2263            provider_id: None,
2264            reply_scope: None,
2265            retry_config,
2266            attempt: 1,
2267            observer: None,
2268            mocks,
2269        };
2270
2271        let execution = engine.execute(ctx, input).await?;
2272        match execution.status {
2273            FlowStatus::Completed => Ok(execution.output),
2274            FlowStatus::Waiting(wait) => Ok(serde_json::json!({
2275                "status": "pending",
2276                "reason": wait.reason,
2277                "resume": wait.snapshot,
2278                "response": execution.output,
2279            })),
2280        }
2281    }
2282
2283    pub async fn invoke_component(
2284        &self,
2285        component_ref: &str,
2286        ctx: ComponentExecCtx,
2287        operation: &str,
2288        config_json: Option<String>,
2289        input_json: String,
2290    ) -> Result<Value> {
2291        let component_ref = resolve_component_key(component_ref, operation, |key| {
2292            self.components.contains_key(key)
2293        });
2294        let pack_component = self
2295            .components
2296            .get(component_ref)
2297            .with_context(|| format!("component '{component_ref}' not found in pack"))?;
2298        let engine = self.engine.clone();
2299        let config = Arc::clone(&self.config);
2300        let http_client = Arc::clone(&self.http_client);
2301        let mocks = self.mocks.clone();
2302        let session_store = self.session_store.clone();
2303        let state_store = self.state_store.clone();
2304        let secrets = Arc::clone(&self.secrets);
2305        let oauth_config = self.oauth_config.clone();
2306        let wasi_policy = Arc::clone(&self.wasi_policy);
2307        let pack_id = self.metadata().pack_id.clone();
2308        let allow_state_store = self.allows_state_store(component_ref);
2309        let component = pack_component.component.clone();
2310        let component_ref_owned = component_ref.to_string();
2311        let operation_owned = operation.to_string();
2312        let input_owned =
2313            Self::merge_component_config_into_input_json(config_json.as_deref(), &input_json)
2314                .context("merge component config into invocation payload")?;
2315        let ctx_owned = ctx;
2316        let runtime_config_non_secret = self.runtime_config_non_secret.clone();
2317        let runtime_refs = self.runtime_refs.clone();
2318
2319        run_on_wasi_thread("component.invoke", move || {
2320            let mut linker = Linker::new(&engine);
2321            register_all(&mut linker, allow_state_store)?;
2322            add_component_control_to_linker(&mut linker)?;
2323
2324            let host_state = HostState::new(
2325                pack_id.clone(),
2326                config,
2327                http_client,
2328                mocks,
2329                session_store,
2330                state_store,
2331                secrets,
2332                oauth_config,
2333                Some(ctx_owned.clone()),
2334                Some(component_ref_owned.clone()),
2335                false,
2336                runtime_config_non_secret,
2337                runtime_refs,
2338            )?;
2339            let store_state = ComponentState::new(host_state, wasi_policy)?;
2340            let mut store = wasmtime::Store::new(&engine, store_state);
2341
2342            let invoke_result = HostState::instantiate_component_result(
2343                &mut linker,
2344                &mut store,
2345                &component,
2346                &ctx_owned,
2347                &component_ref_owned,
2348                &operation_owned,
2349                &input_owned,
2350            )?;
2351            HostState::convert_invoke_result(invoke_result)
2352        })
2353    }
2354
2355    fn merge_component_config_into_input_json(
2356        config_json: Option<&str>,
2357        input_json: &str,
2358    ) -> Result<String> {
2359        let Some(config_json) = config_json else {
2360            return Ok(input_json.to_string());
2361        };
2362
2363        let config_value: Value =
2364            serde_json::from_str(config_json).context("parse component config JSON")?;
2365
2366        if let Ok(mut invocation) =
2367            serde_json::from_str::<greentic_types::InvocationEnvelope>(input_json)
2368        {
2369            let payload_value = serde_json::from_slice(&invocation.payload).unwrap_or_else(|_| {
2370                Value::String(String::from_utf8_lossy(&invocation.payload).into_owned())
2371            });
2372            invocation.payload = serde_json::to_vec(&serde_json::json!({
2373                "config": config_value,
2374                "input": payload_value,
2375            }))
2376            .context("serialize merged invocation payload")?;
2377            return serde_json::to_string(&invocation)
2378                .context("serialize merged invocation envelope");
2379        }
2380
2381        let input_value = serde_json::from_str(input_json)
2382            .unwrap_or_else(|_| Value::String(input_json.to_string()));
2383        serde_json::to_string(&serde_json::json!({
2384            "config": config_value,
2385            "input": input_value,
2386        }))
2387        .context("serialize merged component input")
2388    }
2389
2390    pub fn resolve_provider(
2391        &self,
2392        provider_id: Option<&str>,
2393        provider_type: Option<&str>,
2394    ) -> Result<ProviderBinding> {
2395        let registry = self.provider_registry()?;
2396        registry.resolve(provider_id, provider_type)
2397    }
2398
2399    pub async fn invoke_provider(
2400        &self,
2401        binding: &ProviderBinding,
2402        ctx: ComponentExecCtx,
2403        op: &str,
2404        input_json: Vec<u8>,
2405    ) -> Result<Value> {
2406        let component_ref_owned = binding.component_ref.clone();
2407        let pack_component = self.components.get(&component_ref_owned).with_context(|| {
2408            format!("provider component '{component_ref_owned}' not found in pack")
2409        })?;
2410        let component = pack_component.component.clone();
2411
2412        let engine = self.engine.clone();
2413        let config = Arc::clone(&self.config);
2414        let http_client = Arc::clone(&self.http_client);
2415        let mocks = self.mocks.clone();
2416        let session_store = self.session_store.clone();
2417        let state_store = self.state_store.clone();
2418        let secrets = Arc::clone(&self.secrets);
2419        let oauth_config = self.oauth_config.clone();
2420        let wasi_policy = Arc::clone(&self.wasi_policy);
2421        let pack_id = self.metadata().pack_id.clone();
2422        let allow_state_store = self.allows_state_store(&component_ref_owned);
2423        let input_owned = input_json;
2424        let op_owned = op.to_string();
2425        let ctx_owned = ctx;
2426        let world = binding.world.clone();
2427        let runtime_config_non_secret = self.runtime_config_non_secret.clone();
2428        let runtime_refs = self.runtime_refs.clone();
2429
2430        run_on_wasi_thread("provider.invoke", move || {
2431            let mut linker = Linker::new(&engine);
2432            register_all(&mut linker, allow_state_store)?;
2433            add_component_control_to_linker(&mut linker)?;
2434            let host_state = HostState::new(
2435                pack_id.clone(),
2436                config,
2437                http_client,
2438                mocks,
2439                session_store,
2440                state_store,
2441                secrets,
2442                oauth_config,
2443                Some(ctx_owned.clone()),
2444                Some(component_ref_owned.clone()),
2445                true,
2446                runtime_config_non_secret,
2447                runtime_refs,
2448            )?;
2449            let store_state = ComponentState::new(host_state, wasi_policy)?;
2450            let mut store = wasmtime::Store::new(&engine, store_state);
2451
2452            // Extension-provider worlds (greentic:extension-provider@0.2.0 /
2453            // @0.1.0) are introspection surfaces (list-channels,
2454            // describe-channel, *-schema, dry-run-encode) and deliberately do
2455            // NOT export a generic `invoke(op, input)` data-plane call. If a
2456            // pack declares such a world for the runtime data plane, dispatch
2457            // here would otherwise fall through to the legacy schema-core
2458            // bindings and fail with an opaque "no exported instance" wasmtime
2459            // error. Detect it up front (declared-world fast path, confirmed by
2460            // an instance-level probe) and surface a typed, downcastable
2461            // `ProviderInvokeError` instead. The data-plane routing for these
2462            // worlds is an open design question (see PR body NEEDS_DECISION);
2463            // legacy schema-core remains the default path below.
2464            if world.contains("extension-provider") {
2465                let pre_instance = linker.instantiate_pre(component.as_ref())?;
2466                let instance =
2467                    block_on(async { pre_instance.instantiate_async(&mut store).await })?;
2468                let detected =
2469                    crate::extension_provider::probe_provider_world(&mut store, &instance);
2470                let version = detected
2471                    .map(|w| w.version())
2472                    .unwrap_or("unknown")
2473                    .to_string();
2474                let typed = crate::extension_provider::ProviderInvokeError::Internal(format!(
2475                    "extension-provider@{version} world exposes no data-plane invoke; \
2476                     operation '{op_owned}' has no extension-provider equivalent (introspection \
2477                     surface only)"
2478                ));
2479                return Err(crate::extension_provider::into_anyhow(typed));
2480            }
2481
2482            let use_schema_core_schema = world.contains("provider-schema-core");
2483            let use_schema_core_path = world.contains("provider/schema-core");
2484            let result = if use_schema_core_schema {
2485                let pre_instance = linker.instantiate_pre(component.as_ref())?;
2486                let pre: SchemaSchemaCorePre<ComponentState> =
2487                    SchemaSchemaCorePre::new(pre_instance)?;
2488                let bindings = block_on(async { pre.instantiate_async(&mut store).await })?;
2489                let provider = bindings.greentic_provider_schema_core_schema_core_api();
2490                provider.call_invoke(&mut store, &op_owned, &input_owned)?
2491            } else if use_schema_core_path {
2492                let pre_instance = linker.instantiate_pre(component.as_ref())?;
2493                let path_attempt = (|| -> Result<Vec<u8>> {
2494                    let pre: PathSchemaCorePre<ComponentState> =
2495                        PathSchemaCorePre::new(pre_instance)?;
2496                    let bindings = block_on(async { pre.instantiate_async(&mut store).await })?;
2497                    let provider = bindings.greentic_provider_schema_core_api();
2498                    Ok(provider.call_invoke(&mut store, &op_owned, &input_owned)?)
2499                })();
2500                match path_attempt {
2501                    Ok(value) => value,
2502                    Err(path_err)
2503                        if path_err.to_string().contains("no exported instance named") =>
2504                    {
2505                        let pre_instance = linker.instantiate_pre(component.as_ref())?;
2506                        let pre: SchemaSchemaCorePre<ComponentState> =
2507                            SchemaSchemaCorePre::new(pre_instance)?;
2508                        let bindings = block_on(async { pre.instantiate_async(&mut store).await })?;
2509                        let provider = bindings.greentic_provider_schema_core_schema_core_api();
2510                        provider.call_invoke(&mut store, &op_owned, &input_owned)?
2511                    }
2512                    Err(path_err) => return Err(path_err),
2513                }
2514            } else {
2515                let pre_instance = linker.instantiate_pre(component.as_ref())?;
2516                let pre: LegacySchemaCorePre<ComponentState> =
2517                    LegacySchemaCorePre::new(pre_instance)?;
2518                let bindings = block_on(async { pre.instantiate_async(&mut store).await })?;
2519                let provider = bindings.greentic_provider_core_schema_core_api();
2520                provider.call_invoke(&mut store, &op_owned, &input_owned)?
2521            };
2522            deserialize_json_bytes(result)
2523        })
2524    }
2525
2526    /// Call the provider component's `identify-instance` export
2527    /// (`greentic:provider-instance-identity@0.1.0`) with the inbound
2528    /// payload bytes. Returns an [`IdentifyOutcome`] — see the variant
2529    /// docs for the per-case contract.
2530    ///
2531    /// # Payload shape (M1 IID.4d wrapper)
2532    ///
2533    /// `payload` is forwarded opaque to the component. The shape is set by
2534    /// the caller; the M1 IID.4d wrapper convention from `greentic-start`
2535    /// is `{headers: [{name,value}], body: <parsed-or-null>}` so providers
2536    /// whose discriminator lives in HTTP headers (Telegram via
2537    /// `x-telegram-bot-api-secret-token`) can identify the instance the
2538    /// same call shape that body-based providers (Teams, Slack, Webex,
2539    /// etc.) use. See the docstring on
2540    /// `greentic:provider-instance-identity/instance-identity-api.identify-instance`
2541    /// for the full contract; this host method does not parse or
2542    /// validate the bytes.
2543    ///
2544    /// # Host authority on identity probes
2545    ///
2546    /// The linker registers the full host import surface (Wasmtime
2547    /// validates all imports eagerly at `instantiate_pre`, not just
2548    /// those reachable from the invoked export). The WASI sandbox is
2549    /// locked down: no preopens, no env, no stdio. Deny-shim linker
2550    /// handlers (trap on call, satisfy at link time) are a follow-up
2551    /// in `greentic-interfaces-wasmtime`. See [`register_identity_probe`].
2552    pub async fn invoke_identify_instance(
2553        &self,
2554        binding: &ProviderBinding,
2555        payload: Vec<u8>,
2556    ) -> Result<IdentifyOutcome> {
2557        let component_ref_owned = binding.component_ref.clone();
2558        let pack_component = self.components.get(&component_ref_owned).with_context(|| {
2559            format!("provider component '{component_ref_owned}' not found in pack")
2560        })?;
2561        let component = pack_component.component.clone();
2562
2563        let engine = self.engine.clone();
2564        let config = Arc::clone(&self.config);
2565        let http_client = Arc::clone(&self.http_client);
2566        let mocks = self.mocks.clone();
2567        let session_store = self.session_store.clone();
2568        let state_store = self.state_store.clone();
2569        let secrets = Arc::clone(&self.secrets);
2570        let oauth_config = self.oauth_config.clone();
2571        let pack_id = self.metadata().pack_id.clone();
2572
2573        // Locked-down WASI policy: no preopens, no env, no stdio.
2574        // The linker registers all imports (Wasmtime requires it for
2575        // instantiate_pre), but the WASI sandbox is the tightest we
2576        // can enforce today. See [`register_identity_probe`] docs.
2577        let wasi_policy = Arc::new(RunnerWasiPolicy::probe());
2578        let runtime_config_non_secret = self.runtime_config_non_secret.clone();
2579        let runtime_refs = self.runtime_refs.clone();
2580        run_on_wasi_thread("provider.identify_instance", move || {
2581            let mut linker = Linker::new(&engine);
2582            register_identity_probe(&mut linker)?;
2583            let host_state = HostState::new(
2584                pack_id.clone(),
2585                config,
2586                http_client,
2587                mocks,
2588                session_store,
2589                state_store,
2590                secrets,
2591                oauth_config,
2592                None,
2593                Some(component_ref_owned.clone()),
2594                true,
2595                runtime_config_non_secret,
2596                runtime_refs,
2597            )?;
2598            let store_state = ComponentState::new(host_state, wasi_policy)?;
2599            let mut store = wasmtime::Store::new(&engine, store_state);
2600
2601            let pre_instance = linker.instantiate_pre(component.as_ref())?;
2602            let pre = match InstanceIdentityPre::<ComponentState>::new(pre_instance) {
2603                Ok(pre) => pre,
2604                Err(err) if is_missing_export_error(&format!("{err:#}")) => {
2605                    return Ok(IdentifyOutcome::Unsupported);
2606                }
2607                Err(err) => return Err(err.into()),
2608            };
2609            let bindings = block_on(async { pre.instantiate_async(&mut store).await })?;
2610            let api = bindings.greentic_provider_instance_identity_instance_identity_api();
2611            let result = api.call_identify_instance(&mut store, &payload)?;
2612            Ok(match result {
2613                Some(id) => IdentifyOutcome::Identified(id),
2614                None => IdentifyOutcome::NoMatch,
2615            })
2616        })
2617    }
2618
2619    /// Call the provider component's `describe-identify-instance` export
2620    /// (`greentic:provider-instance-identity/instance-identity-describe@0.1.0`)
2621    /// and parse the returned JSON into an [`IdentifyInstanceHint`].
2622    ///
2623    /// Returns `Ok(None)` for every "no hint available" case: the
2624    /// component does not export the describe world, the export returned
2625    /// `none`, the returned bytes are not valid JSON, or the `version`
2626    /// gate failed. The two malformed cases are warn-logged so a typo'd
2627    /// hint surfaces in operator logs without blocking ingest. Component
2628    /// traps and other infrastructure errors propagate as `Err`.
2629    ///
2630    /// This is the uncached probe — see [`resolve_identify_hint`] for the
2631    /// cached wrapper that callers SHOULD use on the inbound hot path.
2632    ///
2633    /// [`resolve_identify_hint`]: PackRuntime::resolve_identify_hint
2634    pub async fn invoke_describe_identify_instance(
2635        &self,
2636        binding: &ProviderBinding,
2637    ) -> Result<Option<IdentifyInstanceHint>> {
2638        let component_ref_owned = binding.component_ref.clone();
2639        let pack_component = self.components.get(&component_ref_owned).with_context(|| {
2640            format!("provider component '{component_ref_owned}' not found in pack")
2641        })?;
2642        let component = pack_component.component.clone();
2643
2644        let engine = self.engine.clone();
2645        let config = Arc::clone(&self.config);
2646        let http_client = Arc::clone(&self.http_client);
2647        let mocks = self.mocks.clone();
2648        let session_store = self.session_store.clone();
2649        let state_store = self.state_store.clone();
2650        let secrets = Arc::clone(&self.secrets);
2651        let oauth_config = self.oauth_config.clone();
2652        let pack_id = self.metadata().pack_id.clone();
2653
2654        // Locked-down WASI policy — same rationale as
2655        // `invoke_identify_instance`. See [`register_identity_probe`] docs.
2656        let wasi_policy = Arc::new(RunnerWasiPolicy::probe());
2657        let runtime_config_non_secret = self.runtime_config_non_secret.clone();
2658        let runtime_refs = self.runtime_refs.clone();
2659        run_on_wasi_thread("provider.describe_identify_instance", move || {
2660            let mut linker = Linker::new(&engine);
2661            register_identity_probe(&mut linker)?;
2662            let host_state = HostState::new(
2663                pack_id.clone(),
2664                config,
2665                http_client,
2666                mocks,
2667                session_store,
2668                state_store,
2669                secrets,
2670                oauth_config,
2671                None,
2672                Some(component_ref_owned.clone()),
2673                true,
2674                runtime_config_non_secret,
2675                runtime_refs,
2676            )?;
2677            let store_state = ComponentState::new(host_state, wasi_policy)?;
2678            let mut store = wasmtime::Store::new(&engine, store_state);
2679
2680            let pre_instance = linker.instantiate_pre(component.as_ref())?;
2681            let pre = match InstanceIdentityDescribePre::<ComponentState>::new(pre_instance) {
2682                Ok(pre) => pre,
2683                Err(err) if is_missing_export_error(&format!("{err:#}")) => {
2684                    return Ok(None);
2685                }
2686                Err(err) => return Err(err.into()),
2687            };
2688            let bindings = block_on(async { pre.instantiate_async(&mut store).await })?;
2689            let api = bindings.greentic_provider_instance_identity_instance_identity_describe_api();
2690            let raw = api.call_describe_identify_instance(&mut store)?;
2691            let Some(bytes) = raw else {
2692                // Component exported the world but said "no hint right now".
2693                // Per the WIT contract this is equivalent to a missing
2694                // export — unhinted fallback at the caller.
2695                return Ok(None);
2696            };
2697            match IdentifyInstanceHint::from_json(&bytes) {
2698                Ok(hint) => Ok(Some(hint)),
2699                Err(err) => {
2700                    // Malformed hint or wrong version. Don't fail closed:
2701                    // the contract demands the host fall back to unhinted
2702                    // (invoke identify-instance with the global allowlist).
2703                    // Warn so the provider author can fix the hint.
2704                    warn!(
2705                        event = "provider.describe_identify_instance.malformed",
2706                        component_ref = %component_ref_owned,
2707                        error = %err,
2708                        "ignoring malformed describe-identify-instance hint; \
2709                         falling back to unhinted wrapper"
2710                    );
2711                    Ok(None)
2712                }
2713            }
2714        })
2715    }
2716
2717    /// Cached wrapper around [`invoke_describe_identify_instance`]. The
2718    /// hint for a given `binding.component_ref` is invariant across
2719    /// inbound requests within a revision (it is a function of the
2720    /// component itself, not of the payload), so we probe lazily on
2721    /// first ask and reuse thereafter. `ArcSwap`-driven revision swaps
2722    /// allocate a fresh [`PackRuntime`], naturally invalidating the cache.
2723    ///
2724    /// Returns `None` when the component does not export the describe
2725    /// world, when the probe returns no hint, or when the probe fails
2726    /// (trap, timeout, instantiation error). Failures are warn-logged
2727    /// and cached — the same trap is logged once per revision per
2728    /// component, not per request.
2729    ///
2730    /// [`invoke_describe_identify_instance`]:
2731    ///     PackRuntime::invoke_describe_identify_instance
2732    pub async fn resolve_identify_hint(
2733        &self,
2734        binding: &ProviderBinding,
2735    ) -> Option<IdentifyInstanceHint> {
2736        if let Some(cached) = self.identify_hint_cache.read().get(&binding.component_ref) {
2737            return cached.clone();
2738        }
2739        let hint = match self.invoke_describe_identify_instance(binding).await {
2740            Ok(hint) => hint,
2741            Err(err) => {
2742                warn!(
2743                    event = "provider.describe_identify_instance.failed",
2744                    component_ref = %binding.component_ref,
2745                    error = %err,
2746                    "describe-identify-instance probe failed; \
2747                     falling back to unhinted wrapper for this component"
2748                );
2749                None
2750            }
2751        };
2752        // Tolerate a concurrent populate — `insert` is idempotent on the
2753        // same (component_ref, hint) shape and the probe is pure w.r.t.
2754        // the component, so re-probing on a write-race yields identical
2755        // bytes.
2756        self.identify_hint_cache
2757            .write()
2758            .insert(binding.component_ref.clone(), hint.clone());
2759        hint
2760    }
2761
2762    /// Fan out [`resolve_identify_hint`] over each requested `provider_type`.
2763    /// Result map is keyed by `provider_type`; `None` value means the
2764    /// pack has no binding for that type OR the binding's component does
2765    /// not export the describe world (unhinted — caller forwards input
2766    /// headers unfiltered for back-compat).
2767    ///
2768    /// `provider_id`-collision errors from [`ProviderRegistry::resolve`]
2769    /// against a `provider_type` query are propagated (M1.1 invariant
2770    /// violation, malformed pack).
2771    ///
2772    /// Fan out [`resolve_identify_hint`] across requested types. `None` value
2773    /// means the pack has no binding for that type OR the binding's component
2774    /// does not export the describe world.
2775    ///
2776    /// The per-binding loop is inlined (rather than factored into a shared
2777    /// `AsyncFnMut`-based helper) deliberately: routing through an
2778    /// `AsyncFnMut` closure destabilises HRTB `Send` inference for the
2779    /// returned future, which propagates up to host-level fan-out APIs and
2780    /// from there to downstream spawned-service consumers. See the
2781    /// regression test `identify_futures_are_send` on the host.
2782    ///
2783    /// [`resolve_identify_hint`]: PackRuntime::resolve_identify_hint
2784    pub async fn describe_identify_hints_by_provider_type(
2785        &self,
2786        provider_types: &[&str],
2787    ) -> Result<HashMap<String, Option<IdentifyInstanceHint>>> {
2788        let mut out = HashMap::with_capacity(provider_types.len());
2789        let registry = match self.provider_registry_optional()? {
2790            Some(registry) => registry,
2791            None => {
2792                for ty in provider_types {
2793                    out.insert((*ty).to_string(), None);
2794                }
2795                return Ok(out);
2796            }
2797        };
2798        for ty in provider_types {
2799            let Some(binding) = registry.try_resolve(None, Some(ty))? else {
2800                out.insert((*ty).to_string(), None);
2801                continue;
2802            };
2803            let hint = self.resolve_identify_hint(&binding).await;
2804            out.insert((*ty).to_string(), hint);
2805        }
2806        Ok(out)
2807    }
2808
2809    /// Unscoped legacy API: fan out [`invoke_identify_instance`] with the
2810    /// caller-supplied opaque `payload` bytes forwarded verbatim. No
2811    /// describe-identify-instance hint lookup, no per-provider header
2812    /// scoping. New callers should use the `_scoped` sibling for
2813    /// per-provider header allowlist scoping (Phase D).
2814    ///
2815    /// Loop inlined for the same reason as
2816    /// [`describe_identify_hints_by_provider_type`].
2817    ///
2818    /// [`invoke_identify_instance`]: PackRuntime::invoke_identify_instance
2819    /// [`describe_identify_hints_by_provider_type`]:
2820    ///     PackRuntime::describe_identify_hints_by_provider_type
2821    pub async fn identify_endpoints_by_provider_type(
2822        &self,
2823        provider_types: &[&str],
2824        payload: &[u8],
2825    ) -> Result<HashMap<String, IdentifyOutcome>> {
2826        let mut out = HashMap::with_capacity(provider_types.len());
2827        let registry = match self.provider_registry_optional()? {
2828            Some(registry) => registry,
2829            None => {
2830                for ty in provider_types {
2831                    out.insert((*ty).to_string(), IdentifyOutcome::Unsupported);
2832                }
2833                return Ok(out);
2834            }
2835        };
2836        for ty in provider_types {
2837            let Some(binding) = registry.try_resolve(None, Some(ty))? else {
2838                out.insert((*ty).to_string(), IdentifyOutcome::Unsupported);
2839                continue;
2840            };
2841            let outcome = self
2842                .invoke_identify_instance(&binding, payload.to_vec())
2843                .await?;
2844            out.insert((*ty).to_string(), outcome);
2845        }
2846        Ok(out)
2847    }
2848
2849    /// Per-provider scoped variant of [`identify_endpoints_by_provider_type`].
2850    ///
2851    /// The wrapper payload is built per-binding from `(headers, body)` and
2852    /// the component's cached identify-instance hint (see
2853    /// [`resolve_identify_hint`]): hinted providers see only the headers
2854    /// their hint declares; unhinted providers see every header passed in.
2855    /// Result-map semantics match the unscoped variant.
2856    ///
2857    /// Loop inlined for the same reason as
2858    /// [`describe_identify_hints_by_provider_type`].
2859    ///
2860    /// [`identify_endpoints_by_provider_type`]:
2861    ///     PackRuntime::identify_endpoints_by_provider_type
2862    /// [`resolve_identify_hint`]: PackRuntime::resolve_identify_hint
2863    /// [`describe_identify_hints_by_provider_type`]:
2864    ///     PackRuntime::describe_identify_hints_by_provider_type
2865    pub async fn identify_endpoints_by_provider_type_scoped(
2866        &self,
2867        provider_types: &[&str],
2868        headers: &[(String, String)],
2869        body: &Value,
2870    ) -> Result<HashMap<String, IdentifyOutcome>> {
2871        let mut out = HashMap::with_capacity(provider_types.len());
2872        let registry = match self.provider_registry_optional()? {
2873            Some(registry) => registry,
2874            None => {
2875                for ty in provider_types {
2876                    out.insert((*ty).to_string(), IdentifyOutcome::Unsupported);
2877                }
2878                return Ok(out);
2879            }
2880        };
2881        for ty in provider_types {
2882            let Some(binding) = registry.try_resolve(None, Some(ty))? else {
2883                out.insert((*ty).to_string(), IdentifyOutcome::Unsupported);
2884                continue;
2885            };
2886            let hint = self.resolve_identify_hint(&binding).await;
2887            let payload = build_scoped_identify_payload(headers, body, hint.as_ref());
2888            let outcome = self.invoke_identify_instance(&binding, payload).await?;
2889            out.insert((*ty).to_string(), outcome);
2890        }
2891        Ok(out)
2892    }
2893
2894    pub(crate) fn provider_registry(&self) -> Result<ProviderRegistry> {
2895        if let Some(registry) = self.provider_registry.read().clone() {
2896            return Ok(registry);
2897        }
2898        let manifest = self
2899            .manifest
2900            .as_ref()
2901            .context("pack manifest required for provider resolution")?;
2902        let env = std::env::var("GREENTIC_ENV").unwrap_or_else(|_| "local".to_string());
2903        let registry = ProviderRegistry::new(
2904            manifest,
2905            self.state_store.clone(),
2906            &self.config.tenant,
2907            &env,
2908        )?;
2909        *self.provider_registry.write() = Some(registry.clone());
2910        Ok(registry)
2911    }
2912
2913    pub(crate) fn provider_registry_optional(&self) -> Result<Option<ProviderRegistry>> {
2914        if self.manifest.is_none() {
2915            return Ok(None);
2916        }
2917        Ok(Some(self.provider_registry()?))
2918    }
2919
2920    pub fn load_flow(&self, flow_id: &str) -> Result<Flow> {
2921        if let Some(cache) = &self.flows {
2922            return cache
2923                .flows
2924                .get(flow_id)
2925                .cloned()
2926                .ok_or_else(|| anyhow!("flow '{flow_id}' not found in pack"));
2927        }
2928        if let Some(manifest) = &self.manifest {
2929            let entry = manifest
2930                .flows
2931                .iter()
2932                .find(|f| f.id.as_str() == flow_id)
2933                .ok_or_else(|| anyhow!("flow '{flow_id}' not found in manifest"))?;
2934            return Ok(entry.flow.clone());
2935        }
2936        bail!("flow '{flow_id}' not available (pack exports disabled)")
2937    }
2938
2939    pub fn metadata(&self) -> &PackMetadata {
2940        &self.metadata
2941    }
2942
2943    /// Read an asset file from the pack's assets directory.
2944    ///
2945    /// Accepts paths like `assets/cards/card-a.json` or `cards/card-a.json`
2946    /// (the `assets/` prefix is stripped automatically).
2947    pub fn read_asset(&self, asset_path: &str) -> Result<Vec<u8>> {
2948        let normalized = asset_path
2949            .trim_start_matches("assets/")
2950            .trim_start_matches("/assets/");
2951        // Try assets tempdir first (extracted from archive).
2952        if let Some(tempdir) = &self.assets_tempdir {
2953            let full = tempdir.path().join("assets").join(normalized);
2954            if full.exists() {
2955                return std::fs::read(&full)
2956                    .with_context(|| format!("read asset {}", full.display()));
2957            }
2958        }
2959        // Try materialized directory.
2960        let full = self.path.join("assets").join(normalized);
2961        if full.exists() {
2962            return std::fs::read(&full).with_context(|| format!("read asset {}", full.display()));
2963        }
2964        bail!("asset not found: {}", asset_path)
2965    }
2966
2967    pub fn component_manifest(&self, component_ref: &str) -> Option<&ComponentManifest> {
2968        self.component_manifests.get(component_ref)
2969    }
2970
2971    /// Iterate every `(component_ref, manifest)` this pack holds. Used by the
2972    /// agentic-worker component tool source to enumerate the operations a
2973    /// worker may call as tools without exposing the internal manifest map.
2974    pub fn component_manifest_entries(&self) -> impl Iterator<Item = (&str, &ComponentManifest)> {
2975        self.component_manifests
2976            .iter()
2977            .map(|(component_ref, manifest)| (component_ref.as_str(), manifest))
2978    }
2979
2980    /// Returns the raw agent config blobs embedded in this pack's manifest.
2981    ///
2982    /// Only present on the New (`greentic_types::PackManifest`) path; Legacy
2983    /// packs do not carry agent config and return an empty map. Callers
2984    /// (e.g. `TenantRuntime::from_packs`) deserialize these blobs into
2985    /// concrete `AgentConfig` structs via
2986    /// `agent_node::agent_configs_from_manifest`.
2987    pub fn manifest_agent_blobs(&self) -> std::collections::BTreeMap<String, serde_json::Value> {
2988        self.manifest
2989            .as_ref()
2990            .map(|m| m.agents.clone())
2991            .unwrap_or_default()
2992    }
2993
2994    /// Read the optional `agent-graph.json` sidecar embedded at the pack root.
2995    ///
2996    /// Returns the raw bytes when present, or `None` when the pack carries no
2997    /// sidecar (the common case). Tries the materialized pack directory first,
2998    /// then the `.gtpack` archive — mirroring [`load_schema_json`]'s resolution.
2999    /// IO/zip errors are logged and treated as "absent" so a damaged or
3000    /// unreadable sidecar never aborts pack loading; the caller
3001    /// (`graph_node::graph_config_from_sidecar`) then validates the bytes.
3002    ///
3003    /// [`load_schema_json`]: PackRuntime::load_schema_json
3004    pub fn read_agent_graph_sidecar(&self) -> Option<Vec<u8>> {
3005        self.read_pack_file("agent-graph.json")
3006    }
3007
3008    /// Raw agent-config blobs from the optional `dw-agents.json` sidecar.
3009    ///
3010    /// Designer-built packs (old greentic-pack, which cannot populate
3011    /// `manifest.agents`) embed their `AgentConfig` map here. Returns an empty
3012    /// map when the sidecar is absent or unparseable (lenient, mirroring
3013    /// [`PackRuntime::manifest_agent_blobs`]) so a damaged sidecar never aborts
3014    /// pack loading. `manifest.agents` remains authoritative; callers fill only
3015    /// the agent_ids the manifest did not carry.
3016    pub fn dw_agents_sidecar_blobs(&self) -> std::collections::BTreeMap<String, serde_json::Value> {
3017        let Some(bytes) = self.read_pack_file("dw-agents.json") else {
3018            return std::collections::BTreeMap::new();
3019        };
3020        match serde_json::from_slice::<std::collections::BTreeMap<String, serde_json::Value>>(
3021            &bytes,
3022        ) {
3023            Ok(map) => map,
3024            Err(error) => {
3025                tracing::warn!(error = %error, "ignoring malformed dw-agents.json sidecar");
3026                std::collections::BTreeMap::new()
3027            }
3028        }
3029    }
3030
3031    /// Read a single named file from the pack by its archive-relative path,
3032    /// trying the materialized pack directory first and the `.gtpack` archive as
3033    /// a fallback. Returns `None` when the file is absent (or on a read error,
3034    /// which is logged). Used for sidecar files (`agent-graph.json`) and bundled
3035    /// assets (`knowledge_corpus.json`, `assets/knowledge/*.txt`).
3036    /// The secrets manager this pack was built with — the HOST's, not one
3037    /// derived from the environment.
3038    ///
3039    /// The MCP flow node needs it to resolve a pack-carried route's credential.
3040    /// Deriving its own from `SECRETS_BACKEND` cannot work: that only knows
3041    /// `env` and `broker`, while an operator booting a bundle runs on
3042    /// greentic-start's dev store, which the runner has no variant for. So the
3043    /// only manager that can read the credential is the one the host already
3044    /// handed us.
3045    pub fn secrets(&self) -> &crate::secrets::DynSecretsManager {
3046        &self.secrets
3047    }
3048
3049    /// The pack's own MCP route sidecar, if it carries one.
3050    ///
3051    /// A pack's `mcp` node names only an opaque `server` id; resolving it
3052    /// otherwise needs the tenant's admin catalog, which a deployed runner has
3053    /// no credentials for. Parsed once and cached; every failure path yields
3054    /// `None` so the caller falls back to the admin catalog rather than taking
3055    /// the whole pack down.
3056    pub fn mcp_routes(&self) -> Option<&crate::runner::mcp_pack_routes::PackMcpRoutes> {
3057        self.mcp_routes
3058            .get_or_init(|| {
3059                self.read_pack_file(crate::runner::mcp_pack_routes::MCP_ROUTES_ENTRY)
3060                    .and_then(|bytes| {
3061                        crate::runner::mcp_pack_routes::PackMcpRoutes::from_sidecar_bytes(&bytes)
3062                    })
3063            })
3064            .as_ref()
3065    }
3066
3067    pub fn read_pack_file(&self, name: &str) -> Option<Vec<u8>> {
3068        // Materialized pack directory (root holds manifest.cbor + sidecars/assets).
3069        if self.path.is_dir() {
3070            let candidate = self.path.join(name);
3071            if candidate.exists() {
3072                match std::fs::read(&candidate) {
3073                    Ok(bytes) => return Some(bytes),
3074                    Err(error) => {
3075                        tracing::warn!(
3076                            path = %candidate.display(),
3077                            error = %error,
3078                            "failed to read {name} from pack directory"
3079                        );
3080                        return None;
3081                    }
3082                }
3083            }
3084        }
3085
3086        // `.gtpack` archive.
3087        let archive_path = self
3088            .archive_path
3089            .as_ref()
3090            .or_else(|| path_is_gtpack(&self.path).then_some(&self.path))?;
3091        let file = match File::open(archive_path) {
3092            Ok(file) => file,
3093            Err(error) => {
3094                tracing::warn!(
3095                    path = %archive_path.display(),
3096                    error = %error,
3097                    "failed to open pack archive while reading {name}"
3098                );
3099                return None;
3100            }
3101        };
3102        let mut archive = match ZipArchive::new(file) {
3103            Ok(archive) => archive,
3104            Err(error) => {
3105                tracing::warn!(
3106                    path = %archive_path.display(),
3107                    error = %error,
3108                    "failed to read pack archive while reading {name}"
3109                );
3110                return None;
3111            }
3112        };
3113        match archive.by_name(name) {
3114            Ok(mut entry) => {
3115                let mut bytes = Vec::new();
3116                if let Err(error) = entry.read_to_end(&mut bytes) {
3117                    tracing::warn!(
3118                        path = %archive_path.display(),
3119                        error = %error,
3120                        "failed to extract {name} from pack archive"
3121                    );
3122                    return None;
3123                }
3124                Some(bytes)
3125            }
3126            Err(zip::result::ZipError::FileNotFound) => None,
3127            Err(error) => {
3128                tracing::warn!(
3129                    path = %archive_path.display(),
3130                    error = %error,
3131                    "error reading {name} from pack archive"
3132                );
3133                None
3134            }
3135        }
3136    }
3137
3138    pub fn describe_component_contract_v0_6(&self, component_ref: &str) -> Result<Option<Value>> {
3139        let pack_component = self
3140            .components
3141            .get(component_ref)
3142            .with_context(|| format!("component '{component_ref}' not found in pack"))?;
3143        let engine = self.engine.clone();
3144        let config = Arc::clone(&self.config);
3145        let http_client = Arc::clone(&self.http_client);
3146        let mocks = self.mocks.clone();
3147        let session_store = self.session_store.clone();
3148        let state_store = self.state_store.clone();
3149        let secrets = Arc::clone(&self.secrets);
3150        let oauth_config = self.oauth_config.clone();
3151        let wasi_policy = Arc::clone(&self.wasi_policy);
3152        let pack_id = self.metadata().pack_id.clone();
3153        let allow_state_store = self.allows_state_store(component_ref);
3154        let component = pack_component.component.clone();
3155        let component_ref_owned = component_ref.to_string();
3156        let runtime_config_non_secret = self.runtime_config_non_secret.clone();
3157        let runtime_refs = self.runtime_refs.clone();
3158
3159        run_on_wasi_thread("component.describe", move || {
3160            let mut linker = Linker::new(&engine);
3161            register_all(&mut linker, allow_state_store)?;
3162            add_component_control_to_linker(&mut linker)?;
3163
3164            let host_state = HostState::new(
3165                pack_id.clone(),
3166                config,
3167                http_client,
3168                mocks,
3169                session_store,
3170                state_store,
3171                secrets,
3172                oauth_config,
3173                None,
3174                Some(component_ref_owned),
3175                false,
3176                runtime_config_non_secret,
3177                runtime_refs,
3178            )?;
3179            let store_state = ComponentState::new(host_state, wasi_policy)?;
3180            let mut store = wasmtime::Store::new(&engine, store_state);
3181            let pre_instance = linker.instantiate_pre(&component)?;
3182            let pre = match component_api::v0_6_descriptor::ComponentV0V6V0Pre::new(pre_instance) {
3183                Ok(pre) => pre,
3184                Err(_) => return Ok(None),
3185            };
3186            let bytes = block_on(async {
3187                let bindings = pre.instantiate_async(&mut store).await?;
3188                let descriptor = bindings.greentic_component_component_descriptor();
3189                descriptor.call_describe(&mut store)
3190            })?;
3191
3192            if bytes.is_empty() {
3193                return Ok(Some(Value::Null));
3194            }
3195            if let Ok(value) = serde_cbor::from_slice::<Value>(&bytes) {
3196                return Ok(Some(value));
3197            }
3198            if let Ok(value) = serde_json::from_slice::<Value>(&bytes) {
3199                return Ok(Some(value));
3200            }
3201            if let Ok(text) = String::from_utf8(bytes) {
3202                if let Ok(value) = serde_json::from_str::<Value>(&text) {
3203                    return Ok(Some(value));
3204                }
3205                return Ok(Some(Value::String(text)));
3206            }
3207            Ok(Some(Value::Null))
3208        })
3209    }
3210
3211    pub fn load_schema_json(&self, schema_ref: &str) -> Result<Option<Value>> {
3212        let rel = normalize_schema_ref(schema_ref)?;
3213        if self.path.is_dir() {
3214            let candidate = self.path.join(&rel);
3215            if candidate.exists() {
3216                let bytes = std::fs::read(&candidate).with_context(|| {
3217                    format!("failed to read schema file {}", candidate.display())
3218                })?;
3219                let value = serde_json::from_slice::<Value>(&bytes)
3220                    .with_context(|| format!("invalid schema JSON in {}", candidate.display()))?;
3221                return Ok(Some(value));
3222            }
3223        }
3224
3225        if let Some(archive_path) = self
3226            .archive_path
3227            .as_ref()
3228            .or_else(|| path_is_gtpack(&self.path).then_some(&self.path))
3229        {
3230            let file = File::open(archive_path)
3231                .with_context(|| format!("failed to open {}", archive_path.display()))?;
3232            let mut archive = ZipArchive::new(file)
3233                .with_context(|| format!("failed to read pack {}", archive_path.display()))?;
3234            match archive.by_name(&rel) {
3235                Ok(mut entry) => {
3236                    let mut bytes = Vec::new();
3237                    entry.read_to_end(&mut bytes)?;
3238                    let value = serde_json::from_slice::<Value>(&bytes).with_context(|| {
3239                        format!("invalid schema JSON in {}:{}", archive_path.display(), rel)
3240                    })?;
3241                    Ok(Some(value))
3242                }
3243                Err(zip::result::ZipError::FileNotFound) => Ok(None),
3244                Err(err) => Err(anyhow!(err)).with_context(|| {
3245                    format!(
3246                        "failed to read schema `{}` from {}",
3247                        rel,
3248                        archive_path.display()
3249                    )
3250                }),
3251            }
3252        } else {
3253            Ok(None)
3254        }
3255    }
3256
3257    pub fn required_secrets(&self) -> &[greentic_types::SecretRequirement] {
3258        &self.metadata.secret_requirements
3259    }
3260
3261    pub fn missing_secrets(
3262        &self,
3263        tenant_ctx: &TypesTenantCtx,
3264    ) -> Vec<greentic_types::SecretRequirement> {
3265        let env = tenant_ctx.env.as_str().to_string();
3266        let tenant = tenant_ctx.tenant.as_str().to_string();
3267        let team = tenant_ctx.team.as_ref().map(|t| t.as_str().to_string());
3268        self.required_secrets()
3269            .iter()
3270            .filter(|req| {
3271                // scope must match current context if provided
3272                if let Some(scope) = &req.scope {
3273                    if scope.env != env {
3274                        return false;
3275                    }
3276                    if scope.tenant != tenant {
3277                        return false;
3278                    }
3279                    if let Some(ref team_req) = scope.team
3280                        && team.as_ref() != Some(team_req)
3281                    {
3282                        return false;
3283                    }
3284                }
3285                let ctx = self.config.tenant_ctx();
3286                read_secret_blocking(
3287                    &self.secrets,
3288                    &ctx,
3289                    &self.metadata.pack_id,
3290                    canonicalize_secret_key(req.key.as_str()).as_str(),
3291                )
3292                .is_err()
3293            })
3294            .cloned()
3295            .collect()
3296    }
3297
3298    pub fn for_component_test(
3299        components: Vec<(String, PathBuf)>,
3300        flows: HashMap<String, FlowIR>,
3301        pack_id: &str,
3302        config: Arc<HostConfig>,
3303    ) -> Result<Self> {
3304        let engine = Engine::default();
3305        let engine_profile =
3306            EngineProfile::from_engine(&engine, CpuPolicy::Native, "default".to_string());
3307        let cache = CacheManager::new(CacheConfig::default(), engine_profile);
3308        let mut component_map = HashMap::new();
3309        for (name, path) in components {
3310            if !path.exists() {
3311                bail!("component artifact missing: {}", path.display());
3312            }
3313            let wasm_bytes = std::fs::read(&path)?;
3314            let component =
3315                Arc::new(Component::from_binary(&engine, &wasm_bytes).map_err(|err| {
3316                    anyhow!("failed to compile component {}: {err}", path.display())
3317                })?);
3318            component_map.insert(
3319                name.clone(),
3320                PackComponent {
3321                    name,
3322                    version: "0.0.0".into(),
3323                    component,
3324                },
3325            );
3326        }
3327
3328        let mut flow_map = HashMap::new();
3329        let mut descriptors = Vec::new();
3330        for (id, ir) in flows {
3331            let flow_type = ir.flow_type.clone();
3332            let flow = flow_ir_to_flow(ir)?;
3333            flow_map.insert(id.clone(), flow);
3334            descriptors.push(FlowDescriptor {
3335                id: id.clone(),
3336                flow_type,
3337                pack_id: pack_id.to_string(),
3338                profile: "test".into(),
3339                version: "0.0.0".into(),
3340                description: None,
3341                entry: true,
3342            });
3343        }
3344        let entry_flows = descriptors.iter().map(|flow| flow.id.clone()).collect();
3345        let metadata = PackMetadata {
3346            pack_id: pack_id.to_string(),
3347            version: "0.0.0".into(),
3348            entry_flows,
3349            secret_requirements: Vec::new(),
3350        };
3351        let flows_cache = PackFlows {
3352            descriptors: descriptors.clone(),
3353            flows: flow_map,
3354            metadata: metadata.clone(),
3355        };
3356
3357        Ok(Self {
3358            path: PathBuf::new(),
3359            archive_path: None,
3360            config,
3361            engine,
3362            metadata,
3363            manifest: None,
3364            legacy_manifest: None,
3365            component_manifests: HashMap::new(),
3366            mocks: None,
3367            flows: Some(flows_cache),
3368            components: component_map,
3369            http_client: Arc::clone(&HTTP_CLIENT),
3370            session_store: None,
3371            state_store: None,
3372            wasi_policy: Arc::new(RunnerWasiPolicy::new()),
3373            mcp_routes: std::sync::OnceLock::new(),
3374            assets_tempdir: None,
3375            provider_registry: RwLock::new(None),
3376            identify_hint_cache: RwLock::new(HashMap::new()),
3377            secrets: crate::secrets::default_manager()?,
3378            oauth_config: None,
3379            cache,
3380            runtime_config_non_secret: None,
3381            runtime_refs: None,
3382        })
3383    }
3384}
3385
3386/// Resolve a flow node's component reference to the key under which the
3387/// component is actually registered, given the requested `operation` and a
3388/// `is_registered` membership predicate over the pack's component keys.
3389///
3390/// greentic-pack resolves a component node to a bare component symbol
3391/// (e.g. `ai.greentic.component-templates`) and carries the operation
3392/// separately, so the full reference is the registration key. Older,
3393/// hand-authored flows instead pack the operation into the node id
3394/// (`qa.process`) while registering the component under the bare name
3395/// (`qa`). For those, fall back to the segment before the last dot — but
3396/// ONLY when that trailing segment IS the requested operation. Without the
3397/// suffix check, a missing dotted component whose prefix happens to be a
3398/// *different* registered component (`ai.greentic.component-templates` absent,
3399/// `ai.greentic` present) would silently resolve to the wrong component and
3400/// run it with the caller's tenant/session/state/secrets. Returns the
3401/// reference unchanged when neither form matches, so the caller's
3402/// "not found" error names the original reference.
3403fn resolve_component_key<'a>(
3404    component_ref: &'a str,
3405    operation: &str,
3406    is_registered: impl Fn(&str) -> bool,
3407) -> &'a str {
3408    if is_registered(component_ref) {
3409        return component_ref;
3410    }
3411    if let Some((prefix, suffix)) = component_ref.rsplit_once('.')
3412        && suffix == operation
3413        && is_registered(prefix)
3414    {
3415        return prefix;
3416    }
3417    component_ref
3418}
3419
3420#[cfg(test)]
3421mod resolve_component_key_tests {
3422    use super::resolve_component_key;
3423    use std::collections::HashSet;
3424
3425    fn registered(keys: &[&'static str]) -> impl Fn(&str) -> bool {
3426        let set: HashSet<&'static str> = keys.iter().copied().collect();
3427        move |key: &str| set.contains(key)
3428    }
3429
3430    #[test]
3431    fn full_reference_is_used_when_registered() {
3432        // greentic-pack's resolved symbol: full ref is the registration key.
3433        let is_reg = registered(&["ai.greentic.component-templates", "ai.greentic"]);
3434        assert_eq!(
3435            resolve_component_key("ai.greentic.component-templates", "handle_message", is_reg),
3436            "ai.greentic.component-templates"
3437        );
3438    }
3439
3440    #[test]
3441    fn legacy_packed_id_falls_back_when_suffix_is_operation() {
3442        // `qa.process` packs op into the id; component registered as `qa`.
3443        let is_reg = registered(&["qa"]);
3444        assert_eq!(resolve_component_key("qa.process", "process", is_reg), "qa");
3445    }
3446
3447    #[test]
3448    fn drifted_dotted_reference_does_not_fall_back_to_prefix() {
3449        // Full symbol absent, a *different* prefix component present, and the
3450        // trailing segment is NOT the requested operation -> must not silently
3451        // resolve to the prefix; return the original so the caller errors out.
3452        let is_reg = registered(&["ai.greentic"]);
3453        assert_eq!(
3454            resolve_component_key("ai.greentic.component-templates", "handle_message", is_reg),
3455            "ai.greentic.component-templates"
3456        );
3457    }
3458
3459    #[test]
3460    fn unregistered_reference_is_returned_unchanged() {
3461        let is_reg = registered(&[]);
3462        assert_eq!(resolve_component_key("foo", "bar", is_reg), "foo");
3463    }
3464}
3465
3466fn normalize_schema_ref(schema_ref: &str) -> Result<String> {
3467    let candidate = schema_ref.trim();
3468    if candidate.is_empty() {
3469        bail!("schema ref cannot be empty");
3470    }
3471    let path = Path::new(candidate);
3472    if path.is_absolute() {
3473        bail!("schema ref must be relative: {}", schema_ref);
3474    }
3475    let mut normalized = PathBuf::new();
3476    for component in path.components() {
3477        match component {
3478            std::path::Component::Normal(part) => normalized.push(part),
3479            std::path::Component::CurDir => {}
3480            _ => bail!("schema ref must not contain traversal: {}", schema_ref),
3481        }
3482    }
3483    let normalized = normalized
3484        .to_str()
3485        .map(ToString::to_string)
3486        .ok_or_else(|| anyhow!("schema ref must be valid UTF-8"))?;
3487    if normalized.is_empty() {
3488        bail!("schema ref cannot normalize to empty path");
3489    }
3490    Ok(normalized)
3491}
3492
3493fn path_is_gtpack(path: &Path) -> bool {
3494    path.extension()
3495        .and_then(|ext| ext.to_str())
3496        .map(|ext| ext.eq_ignore_ascii_case("gtpack"))
3497        .unwrap_or(false)
3498}
3499
3500fn is_missing_node_export(err: &wasmtime::Error, version: &str) -> bool {
3501    let message = err.to_string();
3502    message.contains("no exported instance named")
3503        && message.contains(&format!("greentic:component/node@{version}"))
3504}
3505
3506struct PackFlows {
3507    descriptors: Vec<FlowDescriptor>,
3508    flows: HashMap<String, Flow>,
3509    metadata: PackMetadata,
3510}
3511
3512const RUNTIME_FLOW_EXTENSION_IDS: [&str; 3] = [
3513    "greentic.pack.runtime_flow",
3514    "greentic.pack.flow_runtime",
3515    "greentic.pack.runtime_flows",
3516];
3517
3518#[derive(Debug, Deserialize)]
3519struct RuntimeFlowBundle {
3520    flows: Vec<RuntimeFlow>,
3521}
3522
3523#[derive(Debug, Deserialize)]
3524struct RuntimeFlow {
3525    id: String,
3526    #[serde(alias = "flow_type")]
3527    kind: FlowKind,
3528    #[serde(default)]
3529    schema_version: Option<String>,
3530    #[serde(default)]
3531    start: Option<String>,
3532    #[serde(default)]
3533    entrypoints: BTreeMap<String, Value>,
3534    nodes: BTreeMap<String, RuntimeNode>,
3535    #[serde(default)]
3536    metadata: Option<FlowMetadata>,
3537}
3538
3539#[derive(Debug, Deserialize)]
3540struct RuntimeNode {
3541    #[serde(alias = "component")]
3542    component_id: String,
3543    #[serde(default, alias = "operation")]
3544    operation_name: Option<String>,
3545    #[serde(default, alias = "payload", alias = "input")]
3546    operation_payload: Value,
3547    #[serde(default)]
3548    config: Value,
3549    #[serde(default)]
3550    routing: Option<Routing>,
3551    #[serde(default)]
3552    telemetry: Option<TelemetryHints>,
3553}
3554
3555fn deserialize_json_bytes(bytes: Vec<u8>) -> Result<Value> {
3556    if bytes.is_empty() {
3557        return Ok(Value::Null);
3558    }
3559    serde_json::from_slice(&bytes).or_else(|_| {
3560        String::from_utf8(bytes)
3561            .map(Value::String)
3562            .map_err(|err| anyhow!(err))
3563    })
3564}
3565
3566/// `wasmtime::component::bindgen!` returns this error shape when a
3567/// `*Pre::new(...)` call resolves a world whose required export is
3568/// absent on the component. We treat that as "component does not opt
3569/// in" and let the caller fall back to the operator's statically
3570/// declared instance. Mirrors the same pattern in `invoke_provider`
3571/// for the legacy/path schema-core fallback.
3572///
3573/// The match is intentionally narrow: the error must mention BOTH a
3574/// broad wasmtime marker (`"no exported instance named"` or
3575/// `"no exported function named"`) AND the identity-world-specific
3576/// name segment (`"instance-identity-api"`, `"identify-instance"`,
3577/// `"instance-identity-describe-api"`, or `"describe-identify-instance"`).
3578/// A component that exports the identity world with a malformed
3579/// signature or a typo'd function name will NOT be silently treated
3580/// as unsupported — it will surface as a hard error.
3581fn is_missing_export_error(message: &str) -> bool {
3582    let has_broad_marker = message.contains("no exported instance named")
3583        || message.contains("no exported function named");
3584    let has_identity_segment = message.contains("instance-identity-api")
3585        || message.contains("identify-instance")
3586        || message.contains("instance-identity-describe-api")
3587        || message.contains("describe-identify-instance");
3588    has_broad_marker && has_identity_segment
3589}
3590
3591/// Build the M1 IID.4d wrapper payload (`{ headers, body }`) scoped per
3592/// the provider's [`IdentifyInstanceHint`].
3593///
3594/// - `Some(hint)` ⇒ headers are filtered to ONLY those whose lowercase
3595///   name appears in [`hint.header_names()`](IdentifyInstanceHint::header_names).
3596///   A hint with no `Header` sources yields `"headers": []` — the
3597///   component is declaring that it identifies from the body alone.
3598/// - `None` ⇒ headers pass through unfiltered. The caller is responsible
3599///   for prefiltering (greentic-start applies a global allowlist at the
3600///   ingress boundary), so back-compat with not-yet-hinted providers
3601///   matches the pre-PR-B2 behavior exactly: every probed component
3602///   receives every allowlisted header.
3603///
3604/// `body` is forwarded verbatim regardless of hint shape. Body-path
3605/// short-circuit (using the hint's `BodyPath { json_pointer }` to skip
3606/// invoking `identify-instance` entirely) is a deliberately-deferred
3607/// Phase D follow-up — the current pass scopes the header allowlist only.
3608fn build_scoped_identify_payload(
3609    headers: &[(String, String)],
3610    body: &Value,
3611    hint: Option<&IdentifyInstanceHint>,
3612) -> Vec<u8> {
3613    let scoped_headers: Vec<&(String, String)> = match hint {
3614        // Hints carry 1-3 source headers in practice; a linear scan beats
3615        // a HashSet for that size (no hash + no allocation).
3616        Some(hint) => {
3617            let allowed = hint.header_names();
3618            headers
3619                .iter()
3620                .filter(|(name, _)| allowed.contains(&name.as_str()))
3621                .collect()
3622        }
3623        None => headers.iter().collect(),
3624    };
3625    let wrapper = serde_json::json!({
3626        "headers": scoped_headers
3627            .iter()
3628            .map(|(name, value)| serde_json::json!({ "name": name, "value": value }))
3629            .collect::<Vec<_>>(),
3630        "body": body,
3631    });
3632    serde_json::to_vec(&wrapper).expect("wrapper payload always serializes")
3633}
3634
3635#[cfg(test)]
3636mod build_scoped_identify_payload_tests {
3637    use super::*;
3638    use crate::identify_hint::HintSource;
3639    use serde_json::json;
3640
3641    fn hint(sources: Vec<HintSource>) -> IdentifyInstanceHint {
3642        IdentifyInstanceHint { sources }
3643    }
3644
3645    #[test]
3646    fn unhinted_passes_all_input_headers_through() {
3647        // Back-compat: components without describe-identify-instance must
3648        // continue to see every header the caller (greentic-start)
3649        // allowlisted. Pre-PR-B2 behavior verbatim.
3650        let headers = vec![
3651            (
3652                "x-telegram-bot-api-secret-token".into(),
3653                "telegram-tok".into(),
3654            ),
3655            ("x-future-routing-tag".into(), "abc".into()),
3656        ];
3657        let body = json!({ "update_id": 1 });
3658        let bytes = build_scoped_identify_payload(&headers, &body, None);
3659        let parsed: Value = serde_json::from_slice(&bytes).unwrap();
3660        assert_eq!(
3661            parsed["headers"],
3662            json!([
3663                { "name": "x-telegram-bot-api-secret-token", "value": "telegram-tok" },
3664                { "name": "x-future-routing-tag", "value": "abc" }
3665            ])
3666        );
3667        assert_eq!(parsed["body"], body);
3668    }
3669
3670    #[test]
3671    fn header_hint_filters_to_declared_names_only() {
3672        // Telegram-shape hint: declares one header, sees only that one.
3673        // Other allowlisted headers (e.g. a future Slack signature) MUST
3674        // NOT leak into the Telegram probe.
3675        let h = hint(vec![HintSource::Header {
3676            name: "x-telegram-bot-api-secret-token".into(),
3677        }]);
3678        let headers = vec![
3679            (
3680                "x-telegram-bot-api-secret-token".into(),
3681                "telegram-tok".into(),
3682            ),
3683            ("x-slack-signature".into(), "v0=sig".into()),
3684        ];
3685        let body = json!({});
3686        let bytes = build_scoped_identify_payload(&headers, &body, Some(&h));
3687        let parsed: Value = serde_json::from_slice(&bytes).unwrap();
3688        assert_eq!(
3689            parsed["headers"],
3690            json!([
3691                { "name": "x-telegram-bot-api-secret-token", "value": "telegram-tok" }
3692            ])
3693        );
3694    }
3695
3696    #[test]
3697    fn hints_without_header_sources_drop_all_headers() {
3698        // Body-path-only (Teams-shape) and degenerate-empty hints both yield
3699        // an empty `Header` source set; the wrapper MUST carry no headers
3700        // either way. Passing Telegram's secret token through to either is
3701        // the exact blast-radius bug PR-B2 closes.
3702        let headers = vec![(
3703            "x-telegram-bot-api-secret-token".into(),
3704            "should-not-leak".into(),
3705        )];
3706        let body = json!({ "anything": true });
3707        for h in [
3708            hint(vec![HintSource::BodyPath {
3709                json_pointer: "/recipient/id".into(),
3710            }]),
3711            hint(vec![]),
3712        ] {
3713            let bytes = build_scoped_identify_payload(&headers, &body, Some(&h));
3714            let parsed: Value = serde_json::from_slice(&bytes).unwrap();
3715            assert_eq!(parsed["headers"], json!([]), "hint={:?}", h.sources);
3716            assert_eq!(parsed["body"], body);
3717        }
3718    }
3719
3720    #[test]
3721    fn header_filter_preserves_input_order_and_dups() {
3722        // Multi-value headers and ordering matter to debuggability
3723        // (operators reading the wrapper from a probe should see the
3724        // headers in the same order they arrived). Filter is a
3725        // retain-only operation; no sort, no dedup.
3726        let h = hint(vec![HintSource::Header {
3727            name: "x-route".into(),
3728        }]);
3729        let headers = vec![
3730            ("x-route".into(), "a".into()),
3731            ("x-other".into(), "skip".into()),
3732            ("x-route".into(), "b".into()),
3733        ];
3734        let body = json!({});
3735        let bytes = build_scoped_identify_payload(&headers, &body, Some(&h));
3736        let parsed: Value = serde_json::from_slice(&bytes).unwrap();
3737        assert_eq!(
3738            parsed["headers"],
3739            json!([
3740                { "name": "x-route", "value": "a" },
3741                { "name": "x-route", "value": "b" }
3742            ])
3743        );
3744    }
3745}
3746
3747impl PackFlows {
3748    fn from_manifest(manifest: greentic_types::PackManifest) -> Self {
3749        if let Some(flows) = flows_from_runtime_extension(&manifest) {
3750            return flows;
3751        }
3752        let descriptors = manifest
3753            .flows
3754            .iter()
3755            .map(|entry| FlowDescriptor {
3756                id: entry.id.as_str().to_string(),
3757                flow_type: flow_kind_to_str(entry.kind).to_string(),
3758                pack_id: manifest.pack_id.as_str().to_string(),
3759                profile: manifest.pack_id.as_str().to_string(),
3760                version: manifest.version.to_string(),
3761                description: None,
3762                entry: tags_indicate_entry(entry.tags.iter().map(String::as_str)),
3763            })
3764            .collect();
3765        let mut flows = HashMap::new();
3766        for entry in &manifest.flows {
3767            flows.insert(entry.id.as_str().to_string(), entry.flow.clone());
3768        }
3769        Self {
3770            metadata: PackMetadata::from_manifest(&manifest),
3771            descriptors,
3772            flows,
3773        }
3774    }
3775}
3776
3777fn flows_from_runtime_extension(manifest: &greentic_types::PackManifest) -> Option<PackFlows> {
3778    let extensions = manifest.extensions.as_ref()?;
3779    let extension = extensions.iter().find_map(|(key, ext)| {
3780        if RUNTIME_FLOW_EXTENSION_IDS
3781            .iter()
3782            .any(|candidate| candidate == key)
3783        {
3784            Some(ext)
3785        } else {
3786            None
3787        }
3788    })?;
3789    let runtime_flows = match decode_runtime_flow_extension(extension) {
3790        Some(flows) if !flows.is_empty() => flows,
3791        _ => return None,
3792    };
3793
3794    let descriptors = runtime_flows
3795        .iter()
3796        .map(|flow| FlowDescriptor {
3797            id: flow.id.as_str().to_string(),
3798            flow_type: flow_kind_to_str(flow.kind).to_string(),
3799            pack_id: manifest.pack_id.as_str().to_string(),
3800            profile: manifest.pack_id.as_str().to_string(),
3801            version: manifest.version.to_string(),
3802            description: None,
3803            entry: tags_indicate_entry(flow.metadata.tags.iter().map(String::as_str)),
3804        })
3805        .collect::<Vec<_>>();
3806    let flows = runtime_flows
3807        .into_iter()
3808        .map(|flow| (flow.id.as_str().to_string(), flow))
3809        .collect();
3810
3811    Some(PackFlows {
3812        metadata: PackMetadata::from_manifest(manifest),
3813        descriptors,
3814        flows,
3815    })
3816}
3817
3818fn decode_runtime_flow_extension(extension: &ExtensionRef) -> Option<Vec<Flow>> {
3819    let value = match extension.inline.as_ref()? {
3820        ExtensionInline::Other(value) => value.clone(),
3821        _ => return None,
3822    };
3823
3824    if let Ok(bundle) = serde_json::from_value::<RuntimeFlowBundle>(value.clone()) {
3825        return Some(collect_runtime_flows(bundle.flows));
3826    }
3827
3828    if let Ok(flows) = serde_json::from_value::<Vec<RuntimeFlow>>(value.clone()) {
3829        return Some(collect_runtime_flows(flows));
3830    }
3831
3832    if let Ok(flows) = serde_json::from_value::<Vec<Flow>>(value) {
3833        return Some(flows);
3834    }
3835
3836    warn!(
3837        extension = %extension.kind,
3838        version = %extension.version,
3839        "runtime flow extension present but could not be decoded"
3840    );
3841    None
3842}
3843
3844fn collect_runtime_flows(flows: Vec<RuntimeFlow>) -> Vec<Flow> {
3845    flows
3846        .into_iter()
3847        .filter_map(|flow| match runtime_flow_to_flow(flow) {
3848            Ok(flow) => Some(flow),
3849            Err(err) => {
3850                warn!(error = %err, "failed to decode runtime flow");
3851                None
3852            }
3853        })
3854        .collect()
3855}
3856
3857fn runtime_flow_to_flow(runtime: RuntimeFlow) -> Result<Flow> {
3858    let flow_id = FlowId::from_str(&runtime.id)
3859        .with_context(|| format!("invalid flow id `{}`", runtime.id))?;
3860    let mut entrypoints = runtime.entrypoints;
3861    if entrypoints.is_empty()
3862        && let Some(start) = &runtime.start
3863    {
3864        entrypoints.insert("default".into(), Value::String(start.clone()));
3865    }
3866
3867    let mut nodes: IndexMap<NodeId, Node, FlowHasher> = IndexMap::default();
3868    for (id, node) in runtime.nodes {
3869        let node_id = NodeId::from_str(&id).with_context(|| format!("invalid node id `{id}`"))?;
3870        let component_id = ComponentId::from_str(&node.component_id)
3871            .with_context(|| format!("invalid component id `{}`", node.component_id))?;
3872        let operation_payload = if node.config.is_null() {
3873            node.operation_payload
3874        } else {
3875            serde_json::json!({
3876                "input": node.operation_payload,
3877                "config": node.config,
3878            })
3879        };
3880        let component = FlowComponentRef {
3881            id: component_id,
3882            pack_alias: None,
3883            operation: node.operation_name,
3884        };
3885        let routing = node.routing.unwrap_or(Routing::End);
3886        let telemetry = node.telemetry.unwrap_or_default();
3887        nodes.insert(
3888            node_id.clone(),
3889            Node {
3890                id: node_id,
3891                component,
3892                input: InputMapping {
3893                    mapping: operation_payload,
3894                },
3895                output: OutputMapping {
3896                    mapping: Value::Null,
3897                },
3898                err_map: None,
3899                routing,
3900                telemetry,
3901                conversational: false,
3902            },
3903        );
3904    }
3905
3906    Ok(Flow {
3907        schema_version: runtime.schema_version.unwrap_or_else(|| "1.0".to_string()),
3908        id: flow_id,
3909        kind: runtime.kind,
3910        entrypoints,
3911        nodes,
3912        metadata: runtime.metadata.unwrap_or_default(),
3913    })
3914}
3915
3916fn flow_kind_to_str(kind: greentic_types::FlowKind) -> &'static str {
3917    match kind {
3918        greentic_types::FlowKind::Messaging => "messaging",
3919        greentic_types::FlowKind::Event => "event",
3920        greentic_types::FlowKind::ComponentConfig => "component-config",
3921        greentic_types::FlowKind::Job => "job",
3922        greentic_types::FlowKind::Http => "http",
3923    }
3924}
3925
3926fn read_entry(archive: &mut ZipArchive<File>, name: &str) -> Result<Vec<u8>> {
3927    let mut file = archive
3928        .by_name(name)
3929        .with_context(|| format!("entry {name} missing from archive"))?;
3930    let mut buf = Vec::new();
3931    file.read_to_end(&mut buf)?;
3932    Ok(buf)
3933}
3934
3935fn normalize_flow_doc(mut doc: FlowDoc) -> FlowDoc {
3936    for node in doc.nodes.values_mut() {
3937        let Some((component_ref, payload)) = node
3938            .raw
3939            .iter()
3940            .next()
3941            .map(|(key, value)| (key.clone(), value.clone()))
3942        else {
3943            continue;
3944        };
3945        // Runner-native op-keys (`emit.*`, `mcp`, `dw.agent`, `sorla.call`, …)
3946        // are dispatched by the engine directly off the `component` string, so
3947        // they must survive verbatim — wrapping them in a `component.exec` node
3948        // would misclassify them as `NodeKind::Exec`. The runtime-flow load
3949        // path (`flow_doc_to_ir`) already preserves them; mirror that here for
3950        // the legacy flow-JSON path using the shared op-key predicate.
3951        if is_native_op_key(&component_ref) {
3952            node.operation = Some(component_ref);
3953            node.payload = payload;
3954            node.raw.clear();
3955            continue;
3956        }
3957        let (target_component, operation, input, config) =
3958            infer_component_exec(&payload, &component_ref);
3959        let mut payload_obj = serde_json::Map::new();
3960        // component.exec is meta; ensure the payload carries the actual target component.
3961        payload_obj.insert("component".into(), Value::String(target_component));
3962        payload_obj.insert("operation".into(), Value::String(operation));
3963        payload_obj.insert("input".into(), input);
3964        if let Some(cfg) = config {
3965            payload_obj.insert("config".into(), cfg);
3966        }
3967        node.operation = Some("component.exec".to_string());
3968        node.payload = Value::Object(payload_obj);
3969        node.raw.clear();
3970    }
3971    doc
3972}
3973
3974fn infer_component_exec(
3975    payload: &Value,
3976    component_ref: &str,
3977) -> (String, String, Value, Option<Value>) {
3978    let default_op = if component_ref.starts_with("templating.") {
3979        "render"
3980    } else {
3981        "invoke"
3982    }
3983    .to_string();
3984
3985    if let Value::Object(map) = payload {
3986        let has_embedded_component =
3987            map.get("component").is_some() || map.get("component_ref").is_some();
3988        let op = map
3989            .get("op")
3990            .or_else(|| map.get("operation"))
3991            .and_then(Value::as_str)
3992            .map(|s| s.to_string())
3993            .unwrap_or_else(|| {
3994                if has_embedded_component {
3995                    component_ref.to_string()
3996                } else {
3997                    default_op.clone()
3998                }
3999            });
4000
4001        let mut input = map.clone();
4002        let config = input.remove("config");
4003        let canonical_input = if has_embedded_component {
4004            input.get("input").cloned()
4005        } else {
4006            None
4007        };
4008        let component = input
4009            .get("component")
4010            .or_else(|| input.get("component_ref"))
4011            .and_then(Value::as_str)
4012            .map(|s| s.to_string())
4013            .unwrap_or_else(|| component_ref.to_string());
4014        input.remove("component");
4015        input.remove("component_ref");
4016        input.remove("op");
4017        input.remove("operation");
4018        let input = canonical_input.unwrap_or(Value::Object(input));
4019        return (component, op, input, config);
4020    }
4021
4022    (component_ref.to_string(), default_op, payload.clone(), None)
4023}
4024
4025#[derive(Clone, Debug)]
4026struct ComponentSpec {
4027    id: String,
4028    version: String,
4029    legacy_path: Option<String>,
4030}
4031
4032#[derive(Clone, Debug)]
4033struct ComponentSourceInfo {
4034    digest: Option<String>,
4035    source: ComponentSourceRef,
4036    artifact: ComponentArtifactLocation,
4037    expected_wasm_sha256: Option<String>,
4038    skip_digest_verification: bool,
4039}
4040
4041#[derive(Clone, Debug)]
4042enum ComponentArtifactLocation {
4043    Inline { wasm_path: String },
4044    Remote,
4045}
4046
4047#[derive(Clone, Debug, Deserialize)]
4048struct PackLockV1 {
4049    schema_version: u32,
4050    components: Vec<PackLockComponent>,
4051}
4052
4053#[derive(Clone, Debug, Deserialize)]
4054struct PackLockComponent {
4055    name: String,
4056    #[serde(default, rename = "source_ref")]
4057    source_ref: Option<String>,
4058    #[serde(default, rename = "ref")]
4059    legacy_ref: Option<String>,
4060    #[serde(default)]
4061    component_id: Option<ComponentId>,
4062    #[serde(default)]
4063    bundled: Option<bool>,
4064    #[serde(default, rename = "bundled_path")]
4065    bundled_path: Option<String>,
4066    #[serde(default, rename = "path")]
4067    legacy_path: Option<String>,
4068    #[serde(default)]
4069    wasm_sha256: Option<String>,
4070    #[serde(default, rename = "sha256")]
4071    legacy_sha256: Option<String>,
4072    #[serde(default)]
4073    resolved_digest: Option<String>,
4074    #[serde(default)]
4075    digest: Option<String>,
4076}
4077
4078fn component_specs(
4079    manifest: Option<&greentic_types::PackManifest>,
4080    legacy_manifest: Option<&legacy_pack::PackManifest>,
4081    component_sources: Option<&ComponentSourcesV1>,
4082    pack_lock: Option<&PackLockV1>,
4083) -> Vec<ComponentSpec> {
4084    if let Some(manifest) = manifest {
4085        if !manifest.components.is_empty() {
4086            return manifest
4087                .components
4088                .iter()
4089                .map(|entry| ComponentSpec {
4090                    id: entry.id.as_str().to_string(),
4091                    version: entry.version.to_string(),
4092                    legacy_path: None,
4093                })
4094                .collect();
4095        }
4096        if let Some(lock) = pack_lock {
4097            let mut seen = HashSet::new();
4098            let mut specs = Vec::new();
4099            for entry in &lock.components {
4100                let id = entry
4101                    .component_id
4102                    .as_ref()
4103                    .map(|id| id.as_str())
4104                    .unwrap_or(entry.name.as_str());
4105                if seen.insert(id.to_string()) {
4106                    specs.push(ComponentSpec {
4107                        id: id.to_string(),
4108                        version: "0.0.0".to_string(),
4109                        legacy_path: None,
4110                    });
4111                }
4112            }
4113            return specs;
4114        }
4115        if let Some(sources) = component_sources {
4116            let mut seen = HashSet::new();
4117            let mut specs = Vec::new();
4118            for entry in &sources.components {
4119                let id = entry
4120                    .component_id
4121                    .as_ref()
4122                    .map(|id| id.as_str())
4123                    .unwrap_or(entry.name.as_str());
4124                if seen.insert(id.to_string()) {
4125                    specs.push(ComponentSpec {
4126                        id: id.to_string(),
4127                        version: "0.0.0".to_string(),
4128                        legacy_path: None,
4129                    });
4130                }
4131            }
4132            return specs;
4133        }
4134    }
4135    if let Some(legacy_manifest) = legacy_manifest {
4136        return legacy_manifest
4137            .components
4138            .iter()
4139            .map(|entry| ComponentSpec {
4140                id: entry.name.clone(),
4141                version: entry.version.to_string(),
4142                legacy_path: Some(entry.file_wasm.clone()),
4143            })
4144            .collect();
4145    }
4146    Vec::new()
4147}
4148
4149fn component_sources_table(
4150    sources: Option<&ComponentSourcesV1>,
4151) -> Result<Option<HashMap<String, ComponentSourceInfo>>> {
4152    let Some(sources) = sources else {
4153        return Ok(None);
4154    };
4155    let mut table = HashMap::new();
4156    for entry in &sources.components {
4157        let artifact = match &entry.artifact {
4158            ArtifactLocationV1::Inline { wasm_path, .. } => ComponentArtifactLocation::Inline {
4159                wasm_path: wasm_path.clone(),
4160            },
4161            ArtifactLocationV1::Remote => ComponentArtifactLocation::Remote,
4162        };
4163        let info = ComponentSourceInfo {
4164            digest: Some(entry.resolved.digest.clone()),
4165            source: entry.source.clone(),
4166            artifact,
4167            expected_wasm_sha256: None,
4168            skip_digest_verification: false,
4169        };
4170        if let Some(component_id) = entry.component_id.as_ref() {
4171            table.insert(component_id.as_str().to_string(), info.clone());
4172        }
4173        table.insert(entry.name.clone(), info);
4174    }
4175    Ok(Some(table))
4176}
4177
4178fn load_pack_lock(path: &Path) -> Result<Option<PackLockV1>> {
4179    let lock_path = if path.is_dir() {
4180        let candidate = path.join("pack.lock");
4181        if candidate.exists() {
4182            Some(candidate)
4183        } else {
4184            let candidate = path.join("pack.lock.json");
4185            candidate.exists().then_some(candidate)
4186        }
4187    } else {
4188        None
4189    };
4190    let Some(lock_path) = lock_path else {
4191        return Ok(None);
4192    };
4193    let raw = std::fs::read_to_string(&lock_path)
4194        .with_context(|| format!("failed to read {}", lock_path.display()))?;
4195    let lock: PackLockV1 = serde_json::from_str(&raw).context("failed to parse pack.lock")?;
4196    if lock.schema_version != 1 {
4197        bail!("pack.lock schema_version must be 1");
4198    }
4199    Ok(Some(lock))
4200}
4201
4202fn find_pack_lock_roots(
4203    pack_path: &Path,
4204    is_dir: bool,
4205    archive_hint: Option<&Path>,
4206) -> Vec<PathBuf> {
4207    if is_dir {
4208        return vec![pack_path.to_path_buf()];
4209    }
4210    let mut roots = Vec::new();
4211    if let Some(archive_path) = archive_hint {
4212        if let Some(parent) = archive_path.parent() {
4213            roots.push(parent.to_path_buf());
4214            if let Some(grandparent) = parent.parent() {
4215                roots.push(grandparent.to_path_buf());
4216            }
4217        }
4218    } else if let Some(parent) = pack_path.parent() {
4219        roots.push(parent.to_path_buf());
4220        if let Some(grandparent) = parent.parent() {
4221            roots.push(grandparent.to_path_buf());
4222        }
4223    }
4224    roots
4225}
4226
4227fn normalize_sha256(digest: &str) -> Result<String> {
4228    let trimmed = digest.trim();
4229    if trimmed.is_empty() {
4230        bail!("sha256 digest cannot be empty");
4231    }
4232    if let Some(stripped) = trimmed.strip_prefix("sha256:") {
4233        if stripped.is_empty() {
4234            bail!("sha256 digest must include hex bytes after sha256:");
4235        }
4236        return Ok(trimmed.to_string());
4237    }
4238    if trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
4239        return Ok(format!("sha256:{trimmed}"));
4240    }
4241    bail!("sha256 digest must be hex or sha256:<hex>");
4242}
4243
4244fn component_sources_table_from_pack_lock(
4245    lock: &PackLockV1,
4246    allow_missing_hash: bool,
4247) -> Result<HashMap<String, ComponentSourceInfo>> {
4248    let mut table = HashMap::new();
4249    let mut names = HashSet::new();
4250    for entry in &lock.components {
4251        if !names.insert(entry.name.clone()) {
4252            bail!(
4253                "pack.lock contains duplicate component name `{}`",
4254                entry.name
4255            );
4256        }
4257        let source_ref = match (&entry.source_ref, &entry.legacy_ref) {
4258            (Some(primary), Some(legacy)) => {
4259                if primary != legacy {
4260                    bail!(
4261                        "pack.lock component {} has conflicting refs: {} vs {}",
4262                        entry.name,
4263                        primary,
4264                        legacy
4265                    );
4266                }
4267                primary.as_str()
4268            }
4269            (Some(primary), None) => primary.as_str(),
4270            (None, Some(legacy)) => legacy.as_str(),
4271            (None, None) => {
4272                bail!("pack.lock component {} missing source_ref", entry.name);
4273            }
4274        };
4275        let source: ComponentSourceRef = source_ref
4276            .parse()
4277            .with_context(|| format!("invalid component ref `{}`", source_ref))?;
4278        let bundled_path = match (&entry.bundled_path, &entry.legacy_path) {
4279            (Some(primary), Some(legacy)) => {
4280                if primary != legacy {
4281                    bail!(
4282                        "pack.lock component {} has conflicting bundled paths: {} vs {}",
4283                        entry.name,
4284                        primary,
4285                        legacy
4286                    );
4287                }
4288                Some(primary.clone())
4289            }
4290            (Some(primary), None) => Some(primary.clone()),
4291            (None, Some(legacy)) => Some(legacy.clone()),
4292            (None, None) => None,
4293        };
4294        let bundled = entry.bundled.unwrap_or(false) || bundled_path.is_some();
4295        let (artifact, digest, expected_wasm_sha256, skip_digest_verification) = if bundled {
4296            let wasm_path = bundled_path.ok_or_else(|| {
4297                anyhow!(
4298                    "pack.lock component {} marked bundled but bundled_path is missing",
4299                    entry.name
4300                )
4301            })?;
4302            let expected_raw = match (&entry.wasm_sha256, &entry.legacy_sha256) {
4303                (Some(primary), Some(legacy)) => {
4304                    if primary != legacy {
4305                        bail!(
4306                            "pack.lock component {} has conflicting wasm_sha256 values: {} vs {}",
4307                            entry.name,
4308                            primary,
4309                            legacy
4310                        );
4311                    }
4312                    Some(primary.as_str())
4313                }
4314                (Some(primary), None) => Some(primary.as_str()),
4315                (None, Some(legacy)) => Some(legacy.as_str()),
4316                (None, None) => None,
4317            };
4318            let expected = match expected_raw {
4319                Some(value) => Some(normalize_sha256(value)?),
4320                None => None,
4321            };
4322            if expected.is_none() && !allow_missing_hash {
4323                bail!(
4324                    "pack.lock component {} missing wasm_sha256 for bundled component",
4325                    entry.name
4326                );
4327            }
4328            (
4329                ComponentArtifactLocation::Inline { wasm_path },
4330                expected.clone(),
4331                expected,
4332                allow_missing_hash && expected_raw.is_none(),
4333            )
4334        } else {
4335            if source.is_tag() {
4336                bail!(
4337                    "component {} uses tag ref {} but is not bundled; rebuild the pack",
4338                    entry.name,
4339                    source
4340                );
4341            }
4342            let expected = entry
4343                .resolved_digest
4344                .as_deref()
4345                .or(entry.digest.as_deref())
4346                .ok_or_else(|| {
4347                    anyhow!(
4348                        "pack.lock component {} missing resolved_digest for remote component",
4349                        entry.name
4350                    )
4351                })?;
4352            (
4353                ComponentArtifactLocation::Remote,
4354                Some(normalize_digest(expected)),
4355                None,
4356                false,
4357            )
4358        };
4359        let info = ComponentSourceInfo {
4360            digest,
4361            source,
4362            artifact,
4363            expected_wasm_sha256,
4364            skip_digest_verification,
4365        };
4366        if let Some(component_id) = entry.component_id.as_ref() {
4367            let key = component_id.as_str().to_string();
4368            if table.contains_key(&key) {
4369                bail!(
4370                    "pack.lock contains duplicate component id `{}`",
4371                    component_id.as_str()
4372                );
4373            }
4374            table.insert(key, info.clone());
4375        }
4376        if entry.name
4377            != entry
4378                .component_id
4379                .as_ref()
4380                .map(|id| id.as_str())
4381                .unwrap_or("")
4382        {
4383            table.insert(entry.name.clone(), info);
4384        }
4385    }
4386    Ok(table)
4387}
4388
4389fn component_path_for_spec(root: &Path, spec: &ComponentSpec) -> PathBuf {
4390    if let Some(path) = &spec.legacy_path {
4391        return root.join(path);
4392    }
4393    root.join("components").join(format!("{}.wasm", spec.id))
4394}
4395
4396fn normalize_digest(digest: &str) -> String {
4397    if digest.starts_with("sha256:") || digest.starts_with("blake3:") {
4398        digest.to_string()
4399    } else {
4400        format!("sha256:{digest}")
4401    }
4402}
4403
4404fn compute_digest_for(bytes: &[u8], digest: &str) -> Result<String> {
4405    if digest.starts_with("blake3:") {
4406        let hash = blake3::hash(bytes);
4407        return Ok(format!("blake3:{}", hash.to_hex()));
4408    }
4409    let mut hasher = sha2::Sha256::new();
4410    hasher.update(bytes);
4411    Ok(format!("sha256:{}", to_hex(&hasher.finalize())))
4412}
4413
4414fn compute_sha256_digest_for(bytes: &[u8]) -> String {
4415    let mut hasher = sha2::Sha256::new();
4416    hasher.update(bytes);
4417    format!("sha256:{}", to_hex(&hasher.finalize()))
4418}
4419
4420fn build_artifact_key(cache: &CacheManager, digest: Option<&str>, bytes: &[u8]) -> ArtifactKey {
4421    let wasm_digest = digest
4422        .map(normalize_digest)
4423        .unwrap_or_else(|| compute_sha256_digest_for(bytes));
4424    ArtifactKey::new(cache.engine_profile_id().to_string(), wasm_digest)
4425}
4426
4427async fn compile_component_with_cache(
4428    cache: &CacheManager,
4429    engine: &Engine,
4430    digest: Option<&str>,
4431    bytes: Vec<u8>,
4432) -> Result<Arc<Component>> {
4433    let key = build_artifact_key(cache, digest, &bytes);
4434    cache.get_component(engine, &key, || Ok(bytes)).await
4435}
4436
4437fn verify_component_digest(component_id: &str, expected: &str, bytes: &[u8]) -> Result<()> {
4438    let normalized_expected = normalize_digest(expected);
4439    let actual = compute_digest_for(bytes, &normalized_expected)?;
4440    if normalize_digest(&actual) != normalized_expected {
4441        bail!(
4442            "component {component_id} digest mismatch: expected {normalized_expected}, got {actual}"
4443        );
4444    }
4445    Ok(())
4446}
4447
4448fn verify_wasm_sha256(component_id: &str, expected: &str, bytes: &[u8]) -> Result<()> {
4449    let normalized_expected = normalize_sha256(expected)?;
4450    let actual = compute_sha256_digest_for(bytes);
4451    if actual != normalized_expected {
4452        bail!(
4453            "component {component_id} bundled digest mismatch: expected {normalized_expected}, got {actual}"
4454        );
4455    }
4456    Ok(())
4457}
4458
4459fn to_hex(digest: &[u8]) -> String {
4460    digest.iter().map(|byte| format!("{byte:02x}")).collect()
4461}
4462
4463#[cfg(test)]
4464mod pack_lock_tests {
4465    use super::*;
4466    use tempfile::TempDir;
4467
4468    #[test]
4469    fn pack_lock_tag_ref_requires_bundle() {
4470        let lock = PackLockV1 {
4471            schema_version: 1,
4472            components: vec![PackLockComponent {
4473                name: "templates".to_string(),
4474                source_ref: Some("oci://registry.test/templates:latest".to_string()),
4475                legacy_ref: None,
4476                component_id: None,
4477                bundled: Some(false),
4478                bundled_path: None,
4479                legacy_path: None,
4480                wasm_sha256: None,
4481                legacy_sha256: None,
4482                resolved_digest: None,
4483                digest: None,
4484            }],
4485        };
4486        let err = component_sources_table_from_pack_lock(&lock, false).unwrap_err();
4487        assert!(
4488            err.to_string().contains("tag ref") && err.to_string().contains("rebuild the pack"),
4489            "unexpected error: {err}"
4490        );
4491    }
4492
4493    #[test]
4494    fn bundled_hash_mismatch_errors() {
4495        let rt = tokio::runtime::Runtime::new().expect("runtime");
4496        let temp = TempDir::new().expect("temp dir");
4497        let engine = Engine::default();
4498        let engine_profile =
4499            EngineProfile::from_engine(&engine, CpuPolicy::Native, "default".to_string());
4500        let cache_config = CacheConfig {
4501            root: temp.path().join("cache"),
4502            ..CacheConfig::default()
4503        };
4504        let cache = CacheManager::new(cache_config, engine_profile);
4505        let wasm_path = temp.path().join("component.wasm");
4506        let fixture_wasm = Path::new(env!("CARGO_MANIFEST_DIR"))
4507            .join("../../tests/fixtures/packs/secrets_store_smoke/components/echo_secret.wasm");
4508        let bytes = std::fs::read(&fixture_wasm).expect("read fixture wasm");
4509        std::fs::write(&wasm_path, &bytes).expect("write temp wasm");
4510
4511        let spec = ComponentSpec {
4512            id: "qa.process".to_string(),
4513            version: "0.0.0".to_string(),
4514            legacy_path: None,
4515        };
4516        let mut missing = HashSet::new();
4517        missing.insert(spec.id.clone());
4518
4519        let mut sources = HashMap::new();
4520        sources.insert(
4521            spec.id.clone(),
4522            ComponentSourceInfo {
4523                digest: Some("sha256:deadbeef".to_string()),
4524                source: ComponentSourceRef::Oci("registry.test/qa.process@sha256:deadbeef".into()),
4525                artifact: ComponentArtifactLocation::Inline {
4526                    wasm_path: "component.wasm".to_string(),
4527                },
4528                expected_wasm_sha256: Some("sha256:deadbeef".to_string()),
4529                skip_digest_verification: false,
4530            },
4531        );
4532
4533        let mut loaded = HashMap::new();
4534        let result = rt.block_on(load_components_from_sources(
4535            &cache,
4536            &engine,
4537            &sources,
4538            &ComponentResolution::default(),
4539            &[spec],
4540            &mut missing,
4541            &mut loaded,
4542            Some(temp.path()),
4543            None,
4544        ));
4545        let err = result.unwrap_err();
4546        assert!(
4547            err.to_string().contains("bundled digest mismatch"),
4548            "unexpected error: {err}"
4549        );
4550    }
4551}
4552
4553#[cfg(test)]
4554mod pack_resolution_prop_tests {
4555    use super::*;
4556    use greentic_types::{ArtifactLocationV1, ComponentSourceEntryV1, ResolvedComponentV1};
4557    use proptest::prelude::*;
4558    use proptest::test_runner::{Config as ProptestConfig, RngAlgorithm, TestRng, TestRunner};
4559    use std::collections::BTreeSet;
4560    use std::path::Path;
4561    use std::str::FromStr;
4562
4563    #[derive(Clone, Debug)]
4564    enum ResolveRequest {
4565        ById(String),
4566        ByName(String),
4567    }
4568
4569    #[derive(Clone, Debug, PartialEq, Eq)]
4570    struct ResolvedComponent {
4571        key: String,
4572        source: String,
4573        artifact: String,
4574        digest: Option<String>,
4575        expected_wasm_sha256: Option<String>,
4576        skip_digest_verification: bool,
4577    }
4578
4579    #[derive(Clone, Debug, PartialEq, Eq)]
4580    struct ResolveError {
4581        code: String,
4582        message: String,
4583        context_key: String,
4584    }
4585
4586    #[derive(Clone, Debug)]
4587    struct Scenario {
4588        pack_lock: Option<PackLockV1>,
4589        component_sources: Option<ComponentSourcesV1>,
4590        request: ResolveRequest,
4591        expected_sha256: Option<String>,
4592        bytes: Vec<u8>,
4593    }
4594
4595    fn resolve_component_test(
4596        sources: Option<&ComponentSourcesV1>,
4597        lock: Option<&PackLockV1>,
4598        request: &ResolveRequest,
4599    ) -> Result<ResolvedComponent, ResolveError> {
4600        let table = if let Some(lock) = lock {
4601            component_sources_table_from_pack_lock(lock, false).map_err(|err| ResolveError {
4602                code: classify_pack_lock_error(err.to_string().as_str()).to_string(),
4603                message: err.to_string(),
4604                context_key: request_key(request).to_string(),
4605            })?
4606        } else {
4607            let sources = component_sources_table(sources).map_err(|err| ResolveError {
4608                code: "component_sources_error".to_string(),
4609                message: err.to_string(),
4610                context_key: request_key(request).to_string(),
4611            })?;
4612            sources.ok_or_else(|| ResolveError {
4613                code: "missing_component_sources".to_string(),
4614                message: "component sources not provided".to_string(),
4615                context_key: request_key(request).to_string(),
4616            })?
4617        };
4618
4619        let key = request_key(request);
4620        let source = table.get(key).ok_or_else(|| ResolveError {
4621            code: "component_not_found".to_string(),
4622            message: format!("component {key} not found"),
4623            context_key: key.to_string(),
4624        })?;
4625
4626        Ok(ResolvedComponent {
4627            key: key.to_string(),
4628            source: source.source.to_string(),
4629            artifact: match source.artifact {
4630                ComponentArtifactLocation::Inline { .. } => "inline".to_string(),
4631                ComponentArtifactLocation::Remote => "remote".to_string(),
4632            },
4633            digest: source.digest.clone(),
4634            expected_wasm_sha256: source.expected_wasm_sha256.clone(),
4635            skip_digest_verification: source.skip_digest_verification,
4636        })
4637    }
4638
4639    fn request_key(request: &ResolveRequest) -> &str {
4640        match request {
4641            ResolveRequest::ById(value) => value.as_str(),
4642            ResolveRequest::ByName(value) => value.as_str(),
4643        }
4644    }
4645
4646    fn classify_pack_lock_error(message: &str) -> &'static str {
4647        if message.contains("duplicate component name") {
4648            "duplicate_name"
4649        } else if message.contains("duplicate component id") {
4650            "duplicate_id"
4651        } else if message.contains("conflicting refs") {
4652            "conflicting_ref"
4653        } else if message.contains("conflicting bundled paths") {
4654            "conflicting_bundled_path"
4655        } else if message.contains("conflicting wasm_sha256") {
4656            "conflicting_wasm_sha256"
4657        } else if message.contains("missing source_ref") {
4658            "missing_source_ref"
4659        } else if message.contains("marked bundled but bundled_path is missing") {
4660            "missing_bundled_path"
4661        } else if message.contains("missing wasm_sha256") {
4662            "missing_wasm_sha256"
4663        } else if message.contains("tag ref") && message.contains("not bundled") {
4664            "tag_ref_requires_bundle"
4665        } else if message.contains("missing resolved_digest") {
4666            "missing_resolved_digest"
4667        } else if message.contains("invalid component ref") {
4668            "invalid_component_ref"
4669        } else if message.contains("sha256 digest") {
4670            "invalid_sha256"
4671        } else {
4672            "unknown_error"
4673        }
4674    }
4675
4676    fn known_error_codes() -> BTreeSet<&'static str> {
4677        [
4678            "component_sources_error",
4679            "missing_component_sources",
4680            "component_not_found",
4681            "duplicate_name",
4682            "duplicate_id",
4683            "conflicting_ref",
4684            "conflicting_bundled_path",
4685            "conflicting_wasm_sha256",
4686            "missing_source_ref",
4687            "missing_bundled_path",
4688            "missing_wasm_sha256",
4689            "tag_ref_requires_bundle",
4690            "missing_resolved_digest",
4691            "invalid_component_ref",
4692            "invalid_sha256",
4693            "unknown_error",
4694        ]
4695        .into_iter()
4696        .collect()
4697    }
4698
4699    fn proptest_config() -> ProptestConfig {
4700        let cases = std::env::var("PROPTEST_CASES")
4701            .ok()
4702            .and_then(|value| value.parse::<u32>().ok())
4703            .unwrap_or(128);
4704        ProptestConfig {
4705            cases,
4706            failure_persistence: None,
4707            ..ProptestConfig::default()
4708        }
4709    }
4710
4711    fn proptest_seed() -> Option<[u8; 32]> {
4712        let seed = std::env::var("PROPTEST_SEED")
4713            .ok()
4714            .and_then(|value| value.parse::<u64>().ok())?;
4715        let mut bytes = [0u8; 32];
4716        bytes[..8].copy_from_slice(&seed.to_le_bytes());
4717        Some(bytes)
4718    }
4719
4720    fn run_cases(strategy: impl Strategy<Value = Scenario>, cases: u32, seed: Option<[u8; 32]>) {
4721        let config = ProptestConfig {
4722            cases,
4723            failure_persistence: None,
4724            ..ProptestConfig::default()
4725        };
4726        let mut runner = match seed {
4727            Some(bytes) => {
4728                TestRunner::new_with_rng(config, TestRng::from_seed(RngAlgorithm::ChaCha, &bytes))
4729            }
4730            None => TestRunner::new(config),
4731        };
4732        runner
4733            .run(&strategy, |scenario| {
4734                run_scenario(&scenario);
4735                Ok(())
4736            })
4737            .unwrap();
4738    }
4739
4740    fn run_scenario(scenario: &Scenario) {
4741        let known_codes = known_error_codes();
4742        let first = resolve_component_test(
4743            scenario.component_sources.as_ref(),
4744            scenario.pack_lock.as_ref(),
4745            &scenario.request,
4746        );
4747        let second = resolve_component_test(
4748            scenario.component_sources.as_ref(),
4749            scenario.pack_lock.as_ref(),
4750            &scenario.request,
4751        );
4752        assert_eq!(normalize_result(&first), normalize_result(&second));
4753
4754        if let Some(lock) = scenario.pack_lock.as_ref() {
4755            let lock_only = resolve_component_test(None, Some(lock), &scenario.request);
4756            assert_eq!(normalize_result(&first), normalize_result(&lock_only));
4757        }
4758
4759        if let Err(err) = first.as_ref() {
4760            assert!(
4761                known_codes.contains(err.code.as_str()),
4762                "unexpected error code {}: {}",
4763                err.code,
4764                err.message
4765            );
4766        }
4767
4768        if let Some(expected) = scenario.expected_sha256.as_deref() {
4769            let expected_ok =
4770                verify_wasm_sha256("test.component", expected, &scenario.bytes).is_ok();
4771            let actual = compute_sha256_digest_for(&scenario.bytes);
4772            if actual == normalize_sha256(expected).unwrap_or_default() {
4773                assert!(expected_ok, "expected sha256 match to succeed");
4774            } else {
4775                assert!(!expected_ok, "expected sha256 mismatch to fail");
4776            }
4777        }
4778    }
4779
4780    fn normalize_result(
4781        result: &Result<ResolvedComponent, ResolveError>,
4782    ) -> Result<ResolvedComponent, ResolveError> {
4783        match result {
4784            Ok(value) => Ok(value.clone()),
4785            Err(err) => Err(err.clone()),
4786        }
4787    }
4788
4789    fn scenario_strategy() -> impl Strategy<Value = Scenario> {
4790        let name = any::<u8>().prop_map(|n| format!("component{n}.core"));
4791        let alt_name = any::<u8>().prop_map(|n| format!("component_alt{n}.core"));
4792        let tag_ref = any::<bool>();
4793        let bundled = any::<bool>();
4794        let include_sha = any::<bool>();
4795        let include_component_id = any::<bool>();
4796        let request_by_id = any::<bool>();
4797        let use_lock = any::<bool>();
4798        let use_sources = any::<bool>();
4799        let bytes = prop::collection::vec(any::<u8>(), 1..64);
4800
4801        (
4802            name,
4803            alt_name,
4804            tag_ref,
4805            bundled,
4806            include_sha,
4807            include_component_id,
4808            request_by_id,
4809            use_lock,
4810            use_sources,
4811            bytes,
4812        )
4813            .prop_map(
4814                |(
4815                    name,
4816                    alt_name,
4817                    tag_ref,
4818                    bundled,
4819                    include_sha,
4820                    include_component_id,
4821                    request_by_id,
4822                    use_lock,
4823                    use_sources,
4824                    bytes,
4825                )| {
4826                    let component_id_str = if include_component_id {
4827                        alt_name.clone()
4828                    } else {
4829                        name.clone()
4830                    };
4831                    let component_id = ComponentId::from_str(&component_id_str).ok();
4832                    let source_ref = if tag_ref {
4833                        format!("oci://registry.test/{name}:v1")
4834                    } else {
4835                        format!(
4836                            "oci://registry.test/{name}@sha256:{}",
4837                            hex::encode([0x11u8; 32])
4838                        )
4839                    };
4840                    let expected_sha256 = if bundled && include_sha {
4841                        Some(compute_sha256_digest_for(&bytes))
4842                    } else {
4843                        None
4844                    };
4845
4846                    let lock_component = PackLockComponent {
4847                        name: name.clone(),
4848                        source_ref: Some(source_ref),
4849                        legacy_ref: None,
4850                        component_id,
4851                        bundled: Some(bundled),
4852                        bundled_path: if bundled {
4853                            Some(format!("components/{name}.wasm"))
4854                        } else {
4855                            None
4856                        },
4857                        legacy_path: None,
4858                        wasm_sha256: expected_sha256.clone(),
4859                        legacy_sha256: None,
4860                        resolved_digest: if bundled {
4861                            None
4862                        } else {
4863                            Some("sha256:deadbeef".to_string())
4864                        },
4865                        digest: None,
4866                    };
4867
4868                    let pack_lock = if use_lock {
4869                        Some(PackLockV1 {
4870                            schema_version: 1,
4871                            components: vec![lock_component],
4872                        })
4873                    } else {
4874                        None
4875                    };
4876
4877                    let component_sources = if use_sources {
4878                        Some(ComponentSourcesV1::new(vec![ComponentSourceEntryV1 {
4879                            name: name.clone(),
4880                            component_id: ComponentId::from_str(&name).ok(),
4881                            source: ComponentSourceRef::from_str(
4882                                "oci://registry.test/component@sha256:deadbeef",
4883                            )
4884                            .expect("component ref"),
4885                            resolved: ResolvedComponentV1 {
4886                                digest: "sha256:deadbeef".to_string(),
4887                                signature: None,
4888                                signed_by: None,
4889                            },
4890                            artifact: if bundled {
4891                                ArtifactLocationV1::Inline {
4892                                    wasm_path: format!("components/{name}.wasm"),
4893                                    manifest_path: None,
4894                                }
4895                            } else {
4896                                ArtifactLocationV1::Remote
4897                            },
4898                            licensing_hint: None,
4899                            metering_hint: None,
4900                        }]))
4901                    } else {
4902                        None
4903                    };
4904
4905                    let request = if request_by_id {
4906                        ResolveRequest::ById(component_id_str.clone())
4907                    } else {
4908                        ResolveRequest::ByName(name.clone())
4909                    };
4910
4911                    Scenario {
4912                        pack_lock,
4913                        component_sources,
4914                        request,
4915                        expected_sha256,
4916                        bytes,
4917                    }
4918                },
4919            )
4920    }
4921
4922    #[test]
4923    fn pack_resolution_proptest() {
4924        let seed = proptest_seed();
4925        run_cases(scenario_strategy(), proptest_config().cases, seed);
4926    }
4927
4928    #[test]
4929    fn pack_resolution_regression_seeds() {
4930        let seeds_path =
4931            Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/proptest-seeds.txt");
4932        let raw = std::fs::read_to_string(&seeds_path).expect("read proptest seeds");
4933        for line in raw.lines() {
4934            let line = line.trim();
4935            if line.is_empty() || line.starts_with('#') {
4936                continue;
4937            }
4938            let seed = line.parse::<u64>().expect("seed must be an integer");
4939            let mut bytes = [0u8; 32];
4940            bytes[..8].copy_from_slice(&seed.to_le_bytes());
4941            run_cases(scenario_strategy(), 1, Some(bytes));
4942        }
4943    }
4944}
4945
4946fn locate_pack_assets(
4947    materialized_root: Option<&Path>,
4948    archive_hint: Option<&Path>,
4949) -> Result<(Option<PathBuf>, Option<TempDir>)> {
4950    if let Some(root) = materialized_root {
4951        let assets = root.join("assets");
4952        if assets.is_dir() {
4953            return Ok((Some(assets), None));
4954        }
4955    }
4956    if let Some(path) = archive_hint
4957        && let Some((tempdir, assets)) = extract_assets_from_archive(path)?
4958    {
4959        return Ok((Some(assets), Some(tempdir)));
4960    }
4961    Ok((None, None))
4962}
4963
4964fn extract_assets_from_archive(path: &Path) -> Result<Option<(TempDir, PathBuf)>> {
4965    let file =
4966        File::open(path).with_context(|| format!("failed to open pack {}", path.display()))?;
4967    let mut archive =
4968        ZipArchive::new(file).with_context(|| format!("failed to read pack {}", path.display()))?;
4969    let temp = TempDir::new().context("failed to create temporary assets directory")?;
4970    let mut found = false;
4971    for idx in 0..archive.len() {
4972        let mut entry = archive.by_index(idx)?;
4973        let name = entry.name();
4974        if !name.starts_with("assets/") {
4975            continue;
4976        }
4977        let dest = temp.path().join(name);
4978        if name.ends_with('/') {
4979            std::fs::create_dir_all(&dest)?;
4980            found = true;
4981            continue;
4982        }
4983        if let Some(parent) = dest.parent() {
4984            std::fs::create_dir_all(parent)?;
4985        }
4986        let mut outfile = std::fs::File::create(&dest)?;
4987        std::io::copy(&mut entry, &mut outfile)?;
4988        found = true;
4989    }
4990    if found {
4991        let assets_path = temp.path().join("assets");
4992        Ok(Some((temp, assets_path)))
4993    } else {
4994        Ok(None)
4995    }
4996}
4997
4998fn dist_options_from(component_resolution: &ComponentResolution) -> DistOptions {
4999    let mut opts = DistOptions {
5000        allow_tags: true,
5001        ..DistOptions::default()
5002    };
5003    if let Some(cache_dir) = component_resolution.dist_cache_dir.clone() {
5004        opts.cache_dir = cache_dir;
5005    }
5006    if component_resolution.dist_offline {
5007        opts.offline = true;
5008    }
5009    opts
5010}
5011
5012#[allow(clippy::too_many_arguments)]
5013async fn load_components_from_sources(
5014    cache: &CacheManager,
5015    engine: &Engine,
5016    component_sources: &HashMap<String, ComponentSourceInfo>,
5017    component_resolution: &ComponentResolution,
5018    specs: &[ComponentSpec],
5019    missing: &mut HashSet<String>,
5020    into: &mut HashMap<String, PackComponent>,
5021    materialized_root: Option<&Path>,
5022    archive_hint: Option<&Path>,
5023) -> Result<()> {
5024    let mut archive = if let Some(path) = archive_hint {
5025        Some(
5026            ZipArchive::new(File::open(path)?)
5027                .with_context(|| format!("{} is not a valid gtpack", path.display()))?,
5028        )
5029    } else {
5030        None
5031    };
5032    let mut dist_client: Option<DistClient> = None;
5033
5034    for spec in specs {
5035        if !missing.contains(&spec.id) {
5036            continue;
5037        }
5038        let Some(source) = component_sources.get(&spec.id) else {
5039            continue;
5040        };
5041
5042        let bytes = match &source.artifact {
5043            ComponentArtifactLocation::Inline { wasm_path } => {
5044                if let Some(root) = materialized_root {
5045                    let path = root.join(wasm_path);
5046                    if path.exists() {
5047                        std::fs::read(&path).with_context(|| {
5048                            format!(
5049                                "failed to read inline component {} from {}",
5050                                spec.id,
5051                                path.display()
5052                            )
5053                        })?
5054                    } else if archive.is_none() {
5055                        bail!("inline component {} missing at {}", spec.id, path.display());
5056                    } else {
5057                        read_entry(
5058                            archive.as_mut().expect("archive present when needed"),
5059                            wasm_path,
5060                        )
5061                        .with_context(|| {
5062                            format!(
5063                                "inline component {} missing at {} in pack archive",
5064                                spec.id, wasm_path
5065                            )
5066                        })?
5067                    }
5068                } else if let Some(archive) = archive.as_mut() {
5069                    read_entry(archive, wasm_path).with_context(|| {
5070                        format!(
5071                            "inline component {} missing at {} in pack archive",
5072                            spec.id, wasm_path
5073                        )
5074                    })?
5075                } else {
5076                    bail!(
5077                        "inline component {} missing and no pack source available",
5078                        spec.id
5079                    );
5080                }
5081            }
5082            ComponentArtifactLocation::Remote => {
5083                if source.source.is_tag() {
5084                    bail!(
5085                        "component {} uses tag ref {} but is not bundled; rebuild the pack",
5086                        spec.id,
5087                        source.source
5088                    );
5089                }
5090                let client = dist_client.get_or_insert_with(|| {
5091                    DistClient::new(dist_options_from(component_resolution))
5092                });
5093                let reference = source.source.to_string();
5094                fault::maybe_fail_asset(&reference)
5095                    .await
5096                    .with_context(|| format!("fault injection blocked asset {reference}"))?;
5097                let digest = source.digest.as_deref().ok_or_else(|| {
5098                    anyhow!(
5099                        "component {} missing expected digest for remote component",
5100                        spec.id
5101                    )
5102                })?;
5103                let cache_path = if let Ok(cache_path) = client.fetch_digest(digest).await {
5104                    cache_path
5105                } else if component_resolution.dist_offline {
5106                    client
5107                        .fetch_digest(digest)
5108                        .await
5109                        .map_err(|err| dist_error_for_component(err, &spec.id, &reference))?
5110                } else {
5111                    let source = client
5112                        .parse_source(&reference)
5113                        .map_err(|err| dist_error_for_component(err, &spec.id, &reference))?;
5114                    let descriptor = client
5115                        .resolve(source, ResolvePolicy)
5116                        .await
5117                        .map_err(|err| dist_error_for_component(err, &spec.id, &reference))?;
5118                    let resolved = client
5119                        .fetch(&descriptor, CachePolicy)
5120                        .await
5121                        .map_err(|err| dist_error_for_component(err, &spec.id, &reference))?;
5122                    let expected = normalize_digest(digest);
5123                    let actual = normalize_digest(&resolved.digest);
5124                    if expected != actual {
5125                        bail!(
5126                            "component {} digest mismatch after fetch: expected {}, got {}",
5127                            spec.id,
5128                            expected,
5129                            actual
5130                        );
5131                    }
5132                    resolved.cache_path.ok_or_else(|| {
5133                        anyhow!(
5134                            "component {} resolved from {} but cache path is missing",
5135                            spec.id,
5136                            reference
5137                        )
5138                    })?
5139                };
5140                std::fs::read(&cache_path).with_context(|| {
5141                    format!(
5142                        "failed to read cached component {} from {}",
5143                        spec.id,
5144                        cache_path.display()
5145                    )
5146                })?
5147            }
5148        };
5149
5150        if let Some(expected) = source.expected_wasm_sha256.as_deref() {
5151            verify_wasm_sha256(&spec.id, expected, &bytes)?;
5152        } else if source.skip_digest_verification {
5153            let actual = compute_sha256_digest_for(&bytes);
5154            warn!(
5155                component_id = %spec.id,
5156                digest = %actual,
5157                "bundled component missing wasm_sha256; allowing due to flag"
5158            );
5159        } else {
5160            let expected = source.digest.as_deref().ok_or_else(|| {
5161                anyhow!(
5162                    "component {} missing expected digest for verification",
5163                    spec.id
5164                )
5165            })?;
5166            verify_component_digest(&spec.id, expected, &bytes)?;
5167        }
5168        let component =
5169            compile_component_with_cache(cache, engine, source.digest.as_deref(), bytes)
5170                .await
5171                .with_context(|| format!("failed to compile component {}", spec.id))?;
5172        into.insert(
5173            spec.id.clone(),
5174            PackComponent {
5175                name: spec.id.clone(),
5176                version: spec.version.clone(),
5177                component,
5178            },
5179        );
5180        missing.remove(&spec.id);
5181    }
5182
5183    Ok(())
5184}
5185
5186fn dist_error_for_component(err: DistError, component_id: &str, reference: &str) -> anyhow::Error {
5187    match err {
5188        DistError::NotFound { reference: missing } => anyhow!(
5189            "remote component {} is not cached for {}. Run `greentic-dist pull --lock <pack.lock>` or `greentic-dist pull {}`",
5190            component_id,
5191            missing,
5192            reference
5193        ),
5194        DistError::Offline { reference: blocked } => anyhow!(
5195            "offline mode blocked fetching component {} from {}; run `greentic-dist pull --lock <pack.lock>` or `greentic-dist pull {}`",
5196            component_id,
5197            blocked,
5198            reference
5199        ),
5200        DistError::Unauthorized { target } => anyhow!(
5201            "component {} requires authenticated source {}; run `greentic-dist pull --lock <pack.lock>` or `greentic-dist pull {}`",
5202            component_id,
5203            target,
5204            reference
5205        ),
5206        other => anyhow!(
5207            "failed to resolve component {} from {}: {}",
5208            component_id,
5209            reference,
5210            other
5211        ),
5212    }
5213}
5214
5215async fn load_components_from_overrides(
5216    cache: &CacheManager,
5217    engine: &Engine,
5218    overrides: &HashMap<String, PathBuf>,
5219    specs: &[ComponentSpec],
5220    missing: &mut HashSet<String>,
5221    into: &mut HashMap<String, PackComponent>,
5222) -> Result<()> {
5223    for spec in specs {
5224        if !missing.contains(&spec.id) {
5225            continue;
5226        }
5227        let Some(path) = overrides.get(&spec.id) else {
5228            continue;
5229        };
5230        let bytes = std::fs::read(path)
5231            .with_context(|| format!("failed to read override component {}", path.display()))?;
5232        let component = compile_component_with_cache(cache, engine, None, bytes)
5233            .await
5234            .with_context(|| {
5235                format!(
5236                    "failed to compile component {} from override {}",
5237                    spec.id,
5238                    path.display()
5239                )
5240            })?;
5241        into.insert(
5242            spec.id.clone(),
5243            PackComponent {
5244                name: spec.id.clone(),
5245                version: spec.version.clone(),
5246                component,
5247            },
5248        );
5249        missing.remove(&spec.id);
5250    }
5251    Ok(())
5252}
5253
5254async fn load_components_from_dir(
5255    cache: &CacheManager,
5256    engine: &Engine,
5257    root: &Path,
5258    specs: &[ComponentSpec],
5259    missing: &mut HashSet<String>,
5260    into: &mut HashMap<String, PackComponent>,
5261) -> Result<()> {
5262    for spec in specs {
5263        if !missing.contains(&spec.id) {
5264            continue;
5265        }
5266        let path = component_path_for_spec(root, spec);
5267        if !path.exists() {
5268            tracing::debug!(component = %spec.id, path = %path.display(), "materialized component missing; will try other sources");
5269            continue;
5270        }
5271        let bytes = std::fs::read(&path)
5272            .with_context(|| format!("failed to read component {}", path.display()))?;
5273        let component = compile_component_with_cache(cache, engine, None, bytes)
5274            .await
5275            .with_context(|| {
5276                format!(
5277                    "failed to compile component {} from {}",
5278                    spec.id,
5279                    path.display()
5280                )
5281            })?;
5282        into.insert(
5283            spec.id.clone(),
5284            PackComponent {
5285                name: spec.id.clone(),
5286                version: spec.version.clone(),
5287                component,
5288            },
5289        );
5290        missing.remove(&spec.id);
5291    }
5292    Ok(())
5293}
5294
5295async fn load_components_from_archive(
5296    cache: &CacheManager,
5297    engine: &Engine,
5298    path: &Path,
5299    specs: &[ComponentSpec],
5300    missing: &mut HashSet<String>,
5301    into: &mut HashMap<String, PackComponent>,
5302) -> Result<()> {
5303    let mut archive = ZipArchive::new(File::open(path)?)
5304        .with_context(|| format!("{} is not a valid gtpack", path.display()))?;
5305    for spec in specs {
5306        if !missing.contains(&spec.id) {
5307            continue;
5308        }
5309        let file_name = spec
5310            .legacy_path
5311            .clone()
5312            .unwrap_or_else(|| format!("components/{}.wasm", spec.id));
5313        let bytes = match read_entry(&mut archive, &file_name) {
5314            Ok(bytes) => bytes,
5315            Err(err) => {
5316                warn!(component = %spec.id, pack = %path.display(), error = %err, "component entry missing in pack archive");
5317                continue;
5318            }
5319        };
5320        let component = compile_component_with_cache(cache, engine, None, bytes)
5321            .await
5322            .with_context(|| format!("failed to compile component {}", spec.id))?;
5323        into.insert(
5324            spec.id.clone(),
5325            PackComponent {
5326                name: spec.id.clone(),
5327                version: spec.version.clone(),
5328                component,
5329            },
5330        );
5331        missing.remove(&spec.id);
5332    }
5333    Ok(())
5334}
5335
5336#[cfg(test)]
5337mod tests {
5338    use super::*;
5339    use greentic_flow::model::{FlowDoc, NodeDoc};
5340    use indexmap::IndexMap;
5341    use serde_json::json;
5342
5343    #[test]
5344    fn tags_indicate_entry_treats_internal_as_non_entry() {
5345        // `internal`-tagged flows are helpers reachable only via flow.call.
5346        assert!(!tags_indicate_entry(["internal"]));
5347        assert!(!tags_indicate_entry(["default", "internal"]));
5348        // The public entrypoint and untagged/other-tagged flows are entries.
5349        assert!(tags_indicate_entry(["default"]));
5350        assert!(tags_indicate_entry(["ui", "featured"]));
5351        assert!(tags_indicate_entry(std::iter::empty::<&str>()));
5352    }
5353
5354    #[test]
5355    fn flow_descriptor_deserializes_missing_entry_as_true() {
5356        // Descriptors serialized before the `entry` field default to entry,
5357        // preserving prior routing behaviour.
5358        let desc: FlowDescriptor = serde_json::from_value(json!({
5359            "id": "default",
5360            "type": "messaging",
5361            "pack_id": "weatherapi-pack",
5362            "profile": "weatherapi-pack",
5363            "version": "0.1.0"
5364        }))
5365        .expect("descriptor without `entry` must deserialize");
5366        assert!(desc.entry);
5367    }
5368
5369    /// Build a minimal `PackRuntime` rooted at `dir` so that `read_pack_file`
5370    /// resolves files from that directory via `self.path.is_dir()`.
5371    /// Mirrors the `for_component_test` constructor but sets `path` to the
5372    /// caller-supplied directory instead of `PathBuf::new()`.
5373    fn pack_runtime_for_dir(dir: &std::path::Path) -> PackRuntime {
5374        let engine = Engine::default();
5375        let engine_profile =
5376            EngineProfile::from_engine(&engine, CpuPolicy::Native, "default".to_string());
5377        let cache = CacheManager::new(CacheConfig::default(), engine_profile);
5378        let config = Arc::new(crate::config::HostConfig {
5379            tenant: "test-tenant".to_string(),
5380            bindings_path: std::path::PathBuf::from("/tmp/bindings.yaml"),
5381            flow_type_bindings: HashMap::new(),
5382            rate_limits: crate::config::RateLimits::default(),
5383            retry: crate::config::FlowRetryConfig::default(),
5384            http_enabled: false,
5385            secrets_policy: crate::config::SecretsPolicy::allow_all(),
5386            state_store_policy: crate::config::StateStorePolicy::default(),
5387            webhook_policy: crate::config::WebhookPolicy::default(),
5388            timers: Vec::new(),
5389            oauth: None,
5390            mocks: None,
5391            pack_bindings: Vec::new(),
5392            env_passthrough: Vec::new(),
5393            trace: crate::trace::TraceConfig::from_env(),
5394            validation: crate::validate::ValidationConfig::from_env(),
5395            operator_policy: crate::config::OperatorPolicy::allow_all(),
5396            fast2flow: Default::default(),
5397            #[cfg(feature = "agentic-worker")]
5398            agents: HashMap::new(),
5399            #[cfg(feature = "agentic-worker")]
5400            graphs: HashMap::new(),
5401        });
5402        PackRuntime {
5403            path: dir.to_path_buf(),
5404            archive_path: None,
5405            config,
5406            engine,
5407            metadata: PackMetadata {
5408                pack_id: "test-pack".to_string(),
5409                version: "0.0.0".to_string(),
5410                entry_flows: Vec::new(),
5411                secret_requirements: Vec::new(),
5412            },
5413            manifest: None,
5414            legacy_manifest: None,
5415            component_manifests: HashMap::new(),
5416            mocks: None,
5417            flows: None,
5418            components: HashMap::new(),
5419            http_client: Arc::clone(&HTTP_CLIENT),
5420            session_store: None,
5421            state_store: None,
5422            wasi_policy: Arc::new(crate::wasi::RunnerWasiPolicy::new()),
5423            mcp_routes: std::sync::OnceLock::new(),
5424            assets_tempdir: None,
5425            provider_registry: RwLock::new(None),
5426            identify_hint_cache: RwLock::new(HashMap::new()),
5427            secrets: crate::secrets::default_manager().expect("default secrets manager"),
5428            oauth_config: None,
5429            runtime_config_non_secret: None,
5430            runtime_refs: None,
5431            cache,
5432        }
5433    }
5434
5435    #[test]
5436    fn dw_agents_sidecar_blobs_reads_map_from_pack_dir() {
5437        let dir = tempfile::tempdir().unwrap();
5438        let agents = serde_json::json!({
5439            "greeter": { "agent_id": "greeter", "system_prompt": "hi", "tools": [],
5440                         "llm": { "provider": "openai", "model": "gpt-4o-mini" } }
5441        });
5442        std::fs::write(
5443            dir.path().join("dw-agents.json"),
5444            serde_json::to_vec(&agents).unwrap(),
5445        )
5446        .unwrap();
5447        let pack = pack_runtime_for_dir(dir.path());
5448        let blobs = pack.dw_agents_sidecar_blobs();
5449        assert!(blobs.contains_key("greeter"));
5450        assert_eq!(blobs["greeter"]["agent_id"], "greeter");
5451    }
5452
5453    #[test]
5454    fn dw_agents_sidecar_blobs_absent_is_empty() {
5455        let dir = tempfile::tempdir().unwrap();
5456        let pack = pack_runtime_for_dir(dir.path());
5457        assert!(pack.dw_agents_sidecar_blobs().is_empty());
5458    }
5459
5460    #[test]
5461    fn dw_agents_sidecar_blobs_malformed_is_empty() {
5462        let dir = tempfile::tempdir().unwrap();
5463        std::fs::write(dir.path().join("dw-agents.json"), b"not json").unwrap();
5464        let pack = pack_runtime_for_dir(dir.path());
5465        assert!(pack.dw_agents_sidecar_blobs().is_empty());
5466    }
5467
5468    #[test]
5469    fn normalizes_raw_component_to_component_exec() {
5470        let mut nodes = IndexMap::new();
5471        let mut raw = IndexMap::new();
5472        raw.insert(
5473            "templating.handlebars".into(),
5474            json!({ "template": "Hi {{name}}" }),
5475        );
5476        nodes.insert(
5477            "start".into(),
5478            NodeDoc {
5479                raw,
5480                routing: json!([{"out": true}]),
5481                ..Default::default()
5482            },
5483        );
5484        let doc = FlowDoc {
5485            id: "welcome".into(),
5486            title: None,
5487            description: None,
5488            flow_type: "messaging".into(),
5489            start: Some("start".into()),
5490            parameters: json!({}),
5491            tags: Vec::new(),
5492            schema_version: None,
5493            entrypoints: IndexMap::new(),
5494            meta: None,
5495            slot_schema: None,
5496            nodes,
5497        };
5498
5499        let normalized = normalize_flow_doc(doc);
5500        let node = normalized.nodes.get("start").expect("node exists");
5501        assert_eq!(node.operation.as_deref(), Some("component.exec"));
5502        assert!(node.raw.is_empty());
5503        let payload = node.payload.as_object().expect("payload object");
5504        assert_eq!(
5505            payload.get("component"),
5506            Some(&Value::String("templating.handlebars".into()))
5507        );
5508        assert_eq!(
5509            payload.get("operation"),
5510            Some(&Value::String("render".into()))
5511        );
5512        let input = payload.get("input").unwrap();
5513        assert_eq!(input, &json!({ "template": "Hi {{name}}" }));
5514    }
5515
5516    #[test]
5517    fn normalizes_canonical_operation_node_to_component_exec_with_config() {
5518        let mut nodes = IndexMap::new();
5519        let mut raw = IndexMap::new();
5520        raw.insert(
5521            "handle_message".into(),
5522            json!({
5523                "component": "oci://ghcr.io/greenticai/component/component-llm-openai:stable",
5524                "config": {
5525                    "provider": "ollama",
5526                    "base_url": "http://127.0.0.1:11434/v1",
5527                    "default_model": "llama3.2"
5528                },
5529                "input": {
5530                    "messages": [{
5531                        "role": "user",
5532                        "content": "Say hello from Ollama."
5533                    }]
5534                }
5535            }),
5536        );
5537        nodes.insert(
5538            "llm".into(),
5539            NodeDoc {
5540                raw,
5541                routing: json!([{"out": true}]),
5542                ..Default::default()
5543            },
5544        );
5545        let doc = FlowDoc {
5546            id: "ollama-repro".into(),
5547            title: None,
5548            description: None,
5549            flow_type: "messaging".into(),
5550            start: Some("llm".into()),
5551            parameters: json!({}),
5552            tags: Vec::new(),
5553            schema_version: None,
5554            entrypoints: IndexMap::new(),
5555            meta: None,
5556            slot_schema: None,
5557            nodes,
5558        };
5559
5560        let normalized = normalize_flow_doc(doc);
5561        let node = normalized.nodes.get("llm").expect("node exists");
5562        assert_eq!(node.operation.as_deref(), Some("component.exec"));
5563        assert!(node.raw.is_empty());
5564        let payload = node.payload.as_object().expect("payload object");
5565        assert_eq!(
5566            payload.get("component"),
5567            Some(&Value::String(
5568                "oci://ghcr.io/greenticai/component/component-llm-openai:stable".into()
5569            ))
5570        );
5571        assert_eq!(
5572            payload.get("operation"),
5573            Some(&Value::String("handle_message".into()))
5574        );
5575        assert_eq!(
5576            payload.get("config"),
5577            Some(&json!({
5578                "provider": "ollama",
5579                "base_url": "http://127.0.0.1:11434/v1",
5580                "default_model": "llama3.2"
5581            }))
5582        );
5583        assert_eq!(
5584            payload.get("input"),
5585            Some(&json!({
5586                "messages": [{
5587                    "role": "user",
5588                    "content": "Say hello from Ollama."
5589                }]
5590            }))
5591        );
5592    }
5593
5594    #[test]
5595    fn missing_export_error_detection_recognises_bindgen_shapes() {
5596        // Positive: identity-world missing-instance error
5597        assert!(is_missing_export_error(
5598            "instantiation: no exported instance named \
5599             `greentic:provider-instance-identity/instance-identity-api@0.1.0`"
5600        ));
5601        // Positive: identity-world missing-function error
5602        assert!(is_missing_export_error(
5603            "instantiation: no exported function named `identify-instance`"
5604        ));
5605        // Negative: unrelated trap
5606        assert!(!is_missing_export_error(
5607            "Wasm trap: out of bounds memory access"
5608        ));
5609        // Negative: a DIFFERENT world's missing export must NOT match —
5610        // e.g. schema-core missing is a hard error, not "unsupported"
5611        assert!(!is_missing_export_error(
5612            "instantiation: no exported instance named \
5613             `greentic:provider-schema-core/schema-core-api@1.0.0`"
5614        ));
5615        // Negative: broad marker present but for a non-identity function
5616        assert!(!is_missing_export_error(
5617            "instantiation: no exported function named `invoke`"
5618        ));
5619    }
5620
5621    #[test]
5622    fn identify_outcome_merge_in_follows_lattice() {
5623        let unsupported = || IdentifyOutcome::Unsupported;
5624        let no_match = || IdentifyOutcome::NoMatch;
5625        let id_a = || IdentifyOutcome::Identified("a".to_string());
5626        let id_b = || IdentifyOutcome::Identified("b".to_string());
5627
5628        // Unsupported is the floor — every other variant promotes it.
5629        let mut x = unsupported();
5630        x.merge_in(unsupported());
5631        assert_eq!(x, unsupported());
5632        let mut x = unsupported();
5633        x.merge_in(no_match());
5634        assert_eq!(x, no_match());
5635        let mut x = unsupported();
5636        x.merge_in(id_a());
5637        assert_eq!(x, id_a());
5638
5639        // NoMatch beats Unsupported but is overridable by Identified.
5640        let mut x = no_match();
5641        x.merge_in(unsupported());
5642        assert_eq!(x, no_match(), "NoMatch must not downgrade to Unsupported");
5643        let mut x = no_match();
5644        x.merge_in(no_match());
5645        assert_eq!(x, no_match());
5646        let mut x = no_match();
5647        x.merge_in(id_a());
5648        assert_eq!(x, id_a(), "Identified must override NoMatch");
5649
5650        // Identified is the top — nothing overwrites it (first id wins).
5651        let mut x = id_a();
5652        x.merge_in(unsupported());
5653        assert_eq!(x, id_a());
5654        let mut x = id_a();
5655        x.merge_in(no_match());
5656        assert_eq!(x, id_a());
5657        let mut x = id_a();
5658        x.merge_in(id_b());
5659        assert_eq!(
5660            x,
5661            id_a(),
5662            "first Identified wins; later id does not replace"
5663        );
5664    }
5665}
5666
5667#[cfg(test)]
5668mod identify_endpoints_pack_tests {
5669    use super::*;
5670    use crate::config::{
5671        FlowRetryConfig, HostConfig, OperatorPolicy, RateLimits, SecretsPolicy, StateStorePolicy,
5672        WebhookPolicy,
5673    };
5674    use crate::trace::TraceConfig;
5675    use crate::validate::ValidationConfig;
5676
5677    fn test_host_config() -> HostConfig {
5678        HostConfig {
5679            tenant: "test".to_string(),
5680            bindings_path: PathBuf::from("/tmp/bindings.yaml"),
5681            flow_type_bindings: HashMap::new(),
5682            rate_limits: RateLimits::default(),
5683            retry: FlowRetryConfig::default(),
5684            http_enabled: false,
5685            secrets_policy: SecretsPolicy::allow_all(),
5686            state_store_policy: StateStorePolicy::default(),
5687            webhook_policy: WebhookPolicy::default(),
5688            timers: Vec::new(),
5689            oauth: None,
5690            mocks: None,
5691            pack_bindings: Vec::new(),
5692            env_passthrough: Vec::new(),
5693            trace: TraceConfig::from_env(),
5694            validation: ValidationConfig::from_env(),
5695            operator_policy: OperatorPolicy::allow_all(),
5696            fast2flow: Default::default(),
5697            #[cfg(feature = "agentic-worker")]
5698            agents: HashMap::new(),
5699            #[cfg(feature = "agentic-worker")]
5700            graphs: HashMap::new(),
5701        }
5702    }
5703
5704    #[tokio::test]
5705    async fn no_manifest_returns_unsupported_for_all_types() {
5706        // A PackRuntime with manifest: None (e.g. legacy single-component
5707        // packs or the for_component_test constructor) has no provider
5708        // registry. Every requested type must map to Unsupported — NOT
5709        // NoMatch — so the caller knows it can fall back to the static
5710        // provider_id rather than failing closed.
5711        let pack = PackRuntime::for_component_test(
5712            Vec::new(),
5713            HashMap::new(),
5714            "test-pack",
5715            Arc::new(test_host_config()),
5716        )
5717        .expect("empty pack construction");
5718        let result = pack
5719            .identify_endpoints_by_provider_type(&["teams", "slack", "telegram"], b"{}")
5720            .await
5721            .expect("no-manifest path must succeed");
5722        assert_eq!(result.len(), 3);
5723        for ty in &["teams", "slack", "telegram"] {
5724            assert_eq!(
5725                result.get(*ty),
5726                Some(&IdentifyOutcome::Unsupported),
5727                "type '{ty}' must be Unsupported when pack has no manifest"
5728            );
5729        }
5730    }
5731
5732    #[tokio::test]
5733    async fn empty_provider_types_returns_empty_map() {
5734        let pack = PackRuntime::for_component_test(
5735            Vec::new(),
5736            HashMap::new(),
5737            "test-pack",
5738            Arc::new(test_host_config()),
5739        )
5740        .expect("empty pack construction");
5741        let result = pack
5742            .identify_endpoints_by_provider_type(&[], b"{}")
5743            .await
5744            .expect("empty types fast path");
5745        assert!(result.is_empty());
5746    }
5747}
5748
5749#[cfg(test)]
5750mod legacy_flow_normalize_tests {
5751    use super::*;
5752    use greentic_flow::model::{FlowDoc, NodeDoc};
5753    use indexmap::IndexMap;
5754    use serde_json::{Value, json};
5755
5756    /// Build a single-node `FlowDoc` whose only node carries the raw-YGTC
5757    /// op-key `op_key` with `payload`, mirroring the legacy flow-JSON shape the
5758    /// loader feeds into `normalize_flow_doc` -> `flow_doc_to_ir`.
5759    fn single_node_doc(op_key: &str, payload: Value) -> FlowDoc {
5760        let mut raw = IndexMap::new();
5761        raw.insert(op_key.to_string(), payload);
5762        let mut nodes = IndexMap::new();
5763        nodes.insert(
5764            "node".into(),
5765            NodeDoc {
5766                raw,
5767                routing: json!([{ "out": true }]),
5768                ..Default::default()
5769            },
5770        );
5771        FlowDoc {
5772            id: "legacy".into(),
5773            title: None,
5774            description: None,
5775            flow_type: "messaging".into(),
5776            start: Some("node".into()),
5777            parameters: json!({}),
5778            tags: Vec::new(),
5779            schema_version: None,
5780            entrypoints: IndexMap::new(),
5781            meta: None,
5782            slot_schema: None,
5783            nodes,
5784        }
5785    }
5786
5787    /// REGRESSION: the legacy flow-JSON load path (`normalize_flow_doc` ->
5788    /// `flow_doc_to_ir`) must preserve a raw-YGTC `{ mcp: { ... }, routing }`
5789    /// node VERBATIM as `component == "mcp"` with its payload intact, exactly
5790    /// like the runtime-flow (packc) path. Before the fix `normalize_flow_doc`
5791    /// rewrote every non-`emit.*` op-key into a `component.exec` node, so the
5792    /// engine saw `NodeKind::Exec` instead of the MCP dispatch arm.
5793    #[test]
5794    fn normalize_preserves_mcp_op_key_through_legacy_path() {
5795        let doc = single_node_doc(
5796            "mcp",
5797            json!({
5798                "server": "github",
5799                "tool": "get_issue",
5800                "arguments": { "id": "{{ entry.issue_id }}" },
5801                "output": "issue"
5802            }),
5803        );
5804
5805        let normalized = normalize_flow_doc(doc);
5806        let node = normalized.nodes.get("node").expect("node exists");
5807        // NOT rewritten into a `component.exec` wrapper.
5808        assert_eq!(
5809            node.operation.as_deref(),
5810            Some("mcp"),
5811            "the `mcp` op-key must survive normalization verbatim, not become component.exec"
5812        );
5813        assert!(node.raw.is_empty(), "raw op-key should be lowered");
5814
5815        // Lowering through the real adapter yields `component == "mcp"` with
5816        // the LOCKED ENCODING v2 payload (server/tool/arguments/output) intact.
5817        let ir = flow_doc_to_ir(normalized).expect("flow_doc_to_ir");
5818        let lowered = ir.nodes.get("node").expect("lowered node exists");
5819        assert_eq!(lowered.component, "mcp");
5820        assert_eq!(lowered.payload_expr["server"], json!("github"));
5821        assert_eq!(lowered.payload_expr["tool"], json!("get_issue"));
5822        assert_eq!(
5823            lowered.payload_expr["arguments"]["id"],
5824            json!("{{ entry.issue_id }}")
5825        );
5826        assert_eq!(lowered.payload_expr["output"], json!("issue"));
5827    }
5828
5829    /// No regression to the passthrough set: the other native op-keys-with-
5830    /// payload (`dw.agent`, `sorla.call`) must also survive `normalize_flow_doc`
5831    /// verbatim rather than being wrapped in `component.exec`.
5832    #[test]
5833    fn normalize_preserves_dw_agent_and_sorla_call_op_keys() {
5834        for op_key in ["dw.agent", "sorla.call"] {
5835            let doc = single_node_doc(op_key, json!({ "input": { "x": 1 } }));
5836            let normalized = normalize_flow_doc(doc);
5837            let node = normalized.nodes.get("node").expect("node exists");
5838            assert_eq!(
5839                node.operation.as_deref(),
5840                Some(op_key),
5841                "`{op_key}` must survive normalization verbatim, not become component.exec"
5842            );
5843            assert!(node.raw.is_empty());
5844
5845            let ir = flow_doc_to_ir(normalized).expect("flow_doc_to_ir");
5846            let lowered = ir.nodes.get("node").expect("lowered node exists");
5847            assert_eq!(lowered.component, op_key);
5848        }
5849    }
5850}
5851
5852#[derive(Clone, Debug, Default, Serialize, Deserialize)]
5853pub struct PackMetadata {
5854    pub pack_id: String,
5855    pub version: String,
5856    #[serde(default)]
5857    pub entry_flows: Vec<String>,
5858    #[serde(default)]
5859    pub secret_requirements: Vec<greentic_types::SecretRequirement>,
5860}
5861
5862impl PackMetadata {
5863    fn from_wasm(bytes: &[u8]) -> Option<Self> {
5864        let parser = Parser::new(0);
5865        for payload in parser.parse_all(bytes) {
5866            let payload = payload.ok()?;
5867            match payload {
5868                Payload::CustomSection(section) => {
5869                    if section.name() == "greentic.manifest"
5870                        && let Ok(meta) = Self::from_bytes(section.data())
5871                    {
5872                        return Some(meta);
5873                    }
5874                }
5875                Payload::DataSection(reader) => {
5876                    for segment in reader.into_iter().flatten() {
5877                        if let Ok(meta) = Self::from_bytes(segment.data) {
5878                            return Some(meta);
5879                        }
5880                    }
5881                }
5882                _ => {}
5883            }
5884        }
5885        None
5886    }
5887
5888    fn from_bytes(bytes: &[u8]) -> Result<Self, serde_cbor::Error> {
5889        #[derive(Deserialize)]
5890        struct RawManifest {
5891            pack_id: String,
5892            version: String,
5893            #[serde(default)]
5894            entry_flows: Vec<String>,
5895            #[serde(default)]
5896            flows: Vec<RawFlow>,
5897            #[serde(default)]
5898            secret_requirements: Vec<greentic_types::SecretRequirement>,
5899        }
5900
5901        #[derive(Deserialize)]
5902        struct RawFlow {
5903            id: String,
5904        }
5905
5906        let manifest: RawManifest = serde_cbor::from_slice(bytes)?;
5907        let mut entry_flows = if manifest.entry_flows.is_empty() {
5908            manifest.flows.iter().map(|f| f.id.clone()).collect()
5909        } else {
5910            manifest.entry_flows.clone()
5911        };
5912        entry_flows.retain(|id| !id.is_empty());
5913        Ok(Self {
5914            pack_id: manifest.pack_id,
5915            version: manifest.version,
5916            entry_flows,
5917            secret_requirements: manifest.secret_requirements,
5918        })
5919    }
5920
5921    pub fn fallback(path: &Path) -> Self {
5922        let pack_id = path
5923            .file_stem()
5924            .map(|s| s.to_string_lossy().into_owned())
5925            .unwrap_or_else(|| "unknown-pack".to_string());
5926        Self {
5927            pack_id,
5928            version: "0.0.0".to_string(),
5929            entry_flows: Vec::new(),
5930            secret_requirements: Vec::new(),
5931        }
5932    }
5933
5934    pub fn from_manifest(manifest: &greentic_types::PackManifest) -> Self {
5935        let entry_flows = manifest
5936            .flows
5937            .iter()
5938            .map(|flow| flow.id.as_str().to_string())
5939            .collect::<Vec<_>>();
5940        Self {
5941            pack_id: manifest.pack_id.as_str().to_string(),
5942            version: manifest.version.to_string(),
5943            entry_flows,
5944            secret_requirements: manifest.secret_requirements.clone(),
5945        }
5946    }
5947}