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