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