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