1use std::collections::{BTreeMap, HashMap};
2use std::fs::File;
3use std::io::Read;
4use std::path::{Path, PathBuf};
5use std::str::FromStr;
6use std::sync::Arc;
7
8use crate::component_api::component::greentic::component::control::Host as ComponentControlHost;
9use crate::component_api::{
10 ComponentPre, control, node::ExecCtx as ComponentExecCtx, node::InvokeResult, node::NodeError,
11};
12use crate::imports::register_all;
13use crate::oauth::{OAuthBrokerConfig, OAuthBrokerHost, OAuthHostContext};
14use crate::runtime_wasmtime::{Component, Engine, Linker, ResourceTable, WasmResult};
15use anyhow::{Context, Result, anyhow, bail};
16use greentic_interfaces_host::host_import::v0_2 as host_import_v0_2;
17use greentic_interfaces_host::host_import::v0_2::greentic::host_import::imports::{
18 HttpRequest as LegacyHttpRequest, HttpResponse as LegacyHttpResponse,
19 IfaceError as LegacyIfaceError, TenantCtx as LegacyTenantCtx,
20};
21use greentic_interfaces_host::host_import::v0_6::{
22 self as host_import_v0_6, iface_types, state, types,
23};
24use greentic_session::SessionKey as StoreSessionKey;
25use greentic_types::{
26 EnvId, Flow, FlowId, SessionCursor as StoreSessionCursor, SessionData,
27 StateKey as StoreStateKey, TeamId, TenantCtx as TypesTenantCtx, TenantId, UserId,
28 decode_pack_manifest,
29};
30use once_cell::sync::Lazy;
31use parking_lot::Mutex;
32use reqwest::blocking::Client as BlockingClient;
33use runner_core::normalize_under_root;
34use serde::{Deserialize, Serialize};
35use serde_cbor;
36use serde_json::{self, Value};
37use tokio::fs;
38use wasmparser::{Parser, Payload};
39use wasmtime::StoreContextMut;
40use zip::ZipArchive;
41
42use crate::runner::flow_adapter::{FlowIR, flow_ir_to_flow};
43use crate::runner::mocks::{HttpDecision, HttpMockRequest, HttpMockResponse, MockLayer};
44
45use crate::config::HostConfig;
46use crate::secrets::{DynSecretsManager, read_secret_blocking};
47use crate::storage::state::STATE_PREFIX;
48use crate::storage::{DynSessionStore, DynStateStore};
49use crate::verify;
50use crate::wasi::RunnerWasiPolicy;
51use tracing::warn;
52use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};
53
54#[cfg(test)]
55use greentic_flow::model::FlowDoc;
56
57#[allow(dead_code)]
58pub struct PackRuntime {
59 path: PathBuf,
61 archive_path: Option<PathBuf>,
63 config: Arc<HostConfig>,
64 engine: Engine,
65 metadata: PackMetadata,
66 manifest: Option<greentic_types::PackManifest>,
67 mocks: Option<Arc<MockLayer>>,
68 flows: Option<PackFlows>,
69 components: HashMap<String, PackComponent>,
70 http_client: Arc<BlockingClient>,
71 pre_cache: Mutex<HashMap<String, ComponentPre<ComponentState>>>,
72 session_store: Option<DynSessionStore>,
73 state_store: Option<DynStateStore>,
74 wasi_policy: Arc<RunnerWasiPolicy>,
75 secrets: DynSecretsManager,
76 oauth_config: Option<OAuthBrokerConfig>,
77}
78
79struct PackComponent {
80 #[allow(dead_code)]
81 name: String,
82 #[allow(dead_code)]
83 version: String,
84 component: Component,
85}
86
87fn build_blocking_client() -> BlockingClient {
88 std::thread::spawn(|| BlockingClient::builder().build().expect("blocking client"))
89 .join()
90 .expect("client build thread panicked")
91}
92
93fn normalize_pack_path(path: &Path) -> Result<(PathBuf, PathBuf)> {
94 let (root, candidate) = if path.is_absolute() {
95 let parent = path
96 .parent()
97 .ok_or_else(|| anyhow!("pack path {} has no parent", path.display()))?;
98 let root = parent
99 .canonicalize()
100 .with_context(|| format!("failed to canonicalize {}", parent.display()))?;
101 let file = path
102 .file_name()
103 .ok_or_else(|| anyhow!("pack path {} has no file name", path.display()))?;
104 (root, PathBuf::from(file))
105 } else {
106 let cwd = std::env::current_dir().context("failed to resolve current directory")?;
107 let base = if let Some(parent) = path.parent() {
108 cwd.join(parent)
109 } else {
110 cwd
111 };
112 let root = base
113 .canonicalize()
114 .with_context(|| format!("failed to canonicalize {}", base.display()))?;
115 let file = path
116 .file_name()
117 .ok_or_else(|| anyhow!("pack path {} has no file name", path.display()))?;
118 (root, PathBuf::from(file))
119 };
120 let safe = normalize_under_root(&root, &candidate)?;
121 Ok((root, safe))
122}
123
124static HTTP_CLIENT: Lazy<Arc<BlockingClient>> = Lazy::new(|| Arc::new(build_blocking_client()));
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct FlowDescriptor {
128 pub id: String,
129 #[serde(rename = "type")]
130 pub flow_type: String,
131 pub profile: String,
132 pub version: String,
133 #[serde(default)]
134 pub description: Option<String>,
135}
136
137pub struct HostState {
138 config: Arc<HostConfig>,
139 http_client: Arc<BlockingClient>,
140 default_env: String,
141 session_store: Option<DynSessionStore>,
142 state_store: Option<DynStateStore>,
143 mocks: Option<Arc<MockLayer>>,
144 secrets: DynSecretsManager,
145 oauth_config: Option<OAuthBrokerConfig>,
146 oauth_host: OAuthBrokerHost,
147}
148
149impl HostState {
150 #[allow(clippy::default_constructed_unit_structs)]
151 pub fn new(
152 config: Arc<HostConfig>,
153 http_client: Arc<BlockingClient>,
154 mocks: Option<Arc<MockLayer>>,
155 session_store: Option<DynSessionStore>,
156 state_store: Option<DynStateStore>,
157 secrets: DynSecretsManager,
158 oauth_config: Option<OAuthBrokerConfig>,
159 ) -> Result<Self> {
160 let default_env = std::env::var("GREENTIC_ENV").unwrap_or_else(|_| "local".to_string());
161 Ok(Self {
162 config,
163 http_client,
164 default_env,
165 session_store,
166 state_store,
167 mocks,
168 secrets,
169 oauth_config,
170 oauth_host: OAuthBrokerHost::default(),
171 })
172 }
173
174 pub fn get_secret(&self, key: &str) -> Result<String> {
175 if !self.config.secrets_policy.is_allowed(key) {
176 bail!("secret {key} is not permitted by bindings policy");
177 }
178 if let Some(mock) = &self.mocks
179 && let Some(value) = mock.secrets_lookup(key)
180 {
181 return Ok(value);
182 }
183 let bytes = read_secret_blocking(&self.secrets, key)
184 .context("failed to read secret from manager")?;
185 let value = String::from_utf8(bytes).context("secret value is not valid UTF-8")?;
186 Ok(value)
187 }
188
189 fn tenant_ctx_from_v6(&self, ctx: Option<types::TenantCtx>) -> Result<TypesTenantCtx> {
190 let tenant_raw = ctx
191 .as_ref()
192 .map(|ctx| ctx.tenant.clone())
193 .unwrap_or_else(|| self.config.tenant.clone());
194 let tenant_id = TenantId::from_str(&tenant_raw)
195 .with_context(|| format!("invalid tenant id `{tenant_raw}`"))?;
196 let env_id = EnvId::from_str(&self.default_env)
197 .unwrap_or_else(|_| EnvId::from_str("local").expect("default env must be valid"));
198 let mut tenant_ctx = TypesTenantCtx::new(env_id, tenant_id);
199 if let Some(ctx) = ctx {
200 if let Some(team) = ctx.team {
201 let team_id =
202 TeamId::from_str(&team).with_context(|| format!("invalid team id `{team}`"))?;
203 tenant_ctx = tenant_ctx.with_team(Some(team_id));
204 }
205 if let Some(user) = ctx.user {
206 let user_id =
207 UserId::from_str(&user).with_context(|| format!("invalid user id `{user}`"))?;
208 tenant_ctx = tenant_ctx.with_user(Some(user_id));
209 }
210 if let Some(flow) = ctx.flow_id {
211 tenant_ctx = tenant_ctx.with_flow(flow);
212 }
213 if let Some(node) = ctx.node_id {
214 tenant_ctx = tenant_ctx.with_node(node);
215 }
216 if let Some(provider) = ctx.provider_id {
217 tenant_ctx = tenant_ctx.with_provider(provider);
218 }
219 if let Some(session) = ctx.session_id {
220 tenant_ctx = tenant_ctx.with_session(session);
221 }
222 tenant_ctx.trace_id = ctx.trace_id;
223 }
224 Ok(tenant_ctx)
225 }
226
227 fn session_store_handle(&self) -> Result<DynSessionStore, types::IfaceError> {
228 self.session_store
229 .as_ref()
230 .cloned()
231 .ok_or(types::IfaceError::Unavailable)
232 }
233
234 fn state_store_handle(&self) -> Result<DynStateStore, types::IfaceError> {
235 self.state_store
236 .as_ref()
237 .cloned()
238 .ok_or(types::IfaceError::Unavailable)
239 }
240
241 fn ensure_user(ctx: &TypesTenantCtx) -> Result<UserId, types::IfaceError> {
242 ctx.user_id
243 .clone()
244 .or_else(|| ctx.user.clone())
245 .ok_or(types::IfaceError::InvalidArg)
246 }
247
248 fn ensure_flow(ctx: &TypesTenantCtx) -> Result<FlowId, types::IfaceError> {
249 let flow = ctx.flow_id().ok_or(types::IfaceError::InvalidArg)?;
250 FlowId::from_str(flow).map_err(|_| types::IfaceError::InvalidArg)
251 }
252
253 fn cursor_from_iface(cursor: iface_types::SessionCursor) -> StoreSessionCursor {
254 let mut store_cursor = StoreSessionCursor::new(cursor.node_pointer);
255 if let Some(reason) = cursor.wait_reason {
256 store_cursor = store_cursor.with_wait_reason(reason);
257 }
258 if let Some(marker) = cursor.outbox_marker {
259 store_cursor = store_cursor.with_outbox_marker(marker);
260 }
261 store_cursor
262 }
263}
264
265fn load_manifest_and_flows(path: &Path) -> Result<(greentic_types::PackManifest, PackFlows)> {
266 let mut archive = ZipArchive::new(File::open(path)?)
267 .with_context(|| format!("{} is not a valid gtpack", path.display()))?;
268 let bytes = read_entry(&mut archive, "manifest.cbor")
269 .with_context(|| format!("missing manifest.cbor in {}", path.display()))?;
270 let manifest = decode_pack_manifest(&bytes)
271 .context("failed to decode pack manifest from manifest.cbor")?;
272 let cache = PackFlows::from_manifest(manifest.clone());
273 Ok((manifest, cache))
274}
275
276pub struct ComponentState {
277 host: HostState,
278 wasi_ctx: WasiCtx,
279 resource_table: ResourceTable,
280}
281
282impl ComponentState {
283 pub fn new(host: HostState, policy: Arc<RunnerWasiPolicy>) -> Result<Self> {
284 let wasi_ctx = policy
285 .instantiate()
286 .context("failed to build WASI context")?;
287 Ok(Self {
288 host,
289 wasi_ctx,
290 resource_table: ResourceTable::new(),
291 })
292 }
293
294 fn host_mut(&mut self) -> &mut HostState {
295 &mut self.host
296 }
297}
298
299impl control::Host for ComponentState {
300 fn should_cancel(&mut self) -> bool {
301 false
302 }
303
304 fn yield_now(&mut self) {
305 }
307}
308
309fn add_component_control_to_linker(linker: &mut Linker<ComponentState>) -> wasmtime::Result<()> {
310 let mut inst = linker.instance("greentic:component/control@0.4.0")?;
311 inst.func_wrap(
312 "should-cancel",
313 |mut caller: StoreContextMut<'_, ComponentState>, (): ()| {
314 let host = caller.data_mut();
315 Ok((ComponentControlHost::should_cancel(host),))
316 },
317 )?;
318 inst.func_wrap(
319 "yield-now",
320 |mut caller: StoreContextMut<'_, ComponentState>, (): ()| {
321 let host = caller.data_mut();
322 ComponentControlHost::yield_now(host);
323 Ok(())
324 },
325 )?;
326 Ok(())
327}
328
329impl OAuthHostContext for ComponentState {
330 fn tenant_id(&self) -> &str {
331 &self.host.config.tenant
332 }
333
334 fn env(&self) -> &str {
335 &self.host.default_env
336 }
337
338 fn oauth_broker_host(&mut self) -> &mut OAuthBrokerHost {
339 &mut self.host.oauth_host
340 }
341
342 fn oauth_config(&self) -> Option<&OAuthBrokerConfig> {
343 self.host.oauth_config.as_ref()
344 }
345}
346
347impl WasiView for ComponentState {
348 fn ctx(&mut self) -> WasiCtxView<'_> {
349 WasiCtxView {
350 ctx: &mut self.wasi_ctx,
351 table: &mut self.resource_table,
352 }
353 }
354}
355
356#[allow(unsafe_code)]
357unsafe impl Send for ComponentState {}
358#[allow(unsafe_code)]
359unsafe impl Sync for ComponentState {}
360
361impl host_import_v0_6::HostImports for ComponentState {
362 fn secrets_get(
363 &mut self,
364 key: String,
365 ctx: Option<types::TenantCtx>,
366 ) -> WasmResult<Result<String, types::IfaceError>> {
367 host_import_v0_6::HostImports::secrets_get(self.host_mut(), key, ctx)
368 }
369
370 fn telemetry_emit(
371 &mut self,
372 span_json: String,
373 ctx: Option<types::TenantCtx>,
374 ) -> WasmResult<()> {
375 host_import_v0_6::HostImports::telemetry_emit(self.host_mut(), span_json, ctx)
376 }
377
378 fn http_fetch(
379 &mut self,
380 req: host_import_v0_6::http::HttpRequest,
381 ctx: Option<types::TenantCtx>,
382 ) -> WasmResult<Result<host_import_v0_6::http::HttpResponse, types::IfaceError>> {
383 host_import_v0_6::HostImports::http_fetch(self.host_mut(), req, ctx)
384 }
385
386 fn mcp_exec(
387 &mut self,
388 component: String,
389 action: String,
390 args_json: String,
391 ctx: Option<types::TenantCtx>,
392 ) -> WasmResult<Result<String, types::IfaceError>> {
393 host_import_v0_6::HostImports::mcp_exec(self.host_mut(), component, action, args_json, ctx)
394 }
395
396 fn state_get(
397 &mut self,
398 key: iface_types::StateKey,
399 ctx: Option<types::TenantCtx>,
400 ) -> WasmResult<Result<String, types::IfaceError>> {
401 host_import_v0_6::HostImports::state_get(self.host_mut(), key, ctx)
402 }
403
404 fn state_set(
405 &mut self,
406 key: iface_types::StateKey,
407 value_json: String,
408 ctx: Option<types::TenantCtx>,
409 ) -> WasmResult<Result<state::OpAck, types::IfaceError>> {
410 host_import_v0_6::HostImports::state_set(self.host_mut(), key, value_json, ctx)
411 }
412
413 fn session_update(
414 &mut self,
415 cursor: iface_types::SessionCursor,
416 ctx: Option<types::TenantCtx>,
417 ) -> WasmResult<Result<String, types::IfaceError>> {
418 host_import_v0_6::HostImports::session_update(self.host_mut(), cursor, ctx)
419 }
420}
421
422impl host_import_v0_2::HostImports for ComponentState {
423 fn secrets_get(
424 &mut self,
425 key: String,
426 ctx: Option<LegacyTenantCtx>,
427 ) -> WasmResult<Result<String, LegacyIfaceError>> {
428 host_import_v0_2::HostImports::secrets_get(self.host_mut(), key, ctx)
429 }
430
431 fn telemetry_emit(
432 &mut self,
433 span_json: String,
434 ctx: Option<LegacyTenantCtx>,
435 ) -> WasmResult<()> {
436 host_import_v0_2::HostImports::telemetry_emit(self.host_mut(), span_json, ctx)
437 }
438
439 fn tool_invoke(
440 &mut self,
441 tool: String,
442 action: String,
443 args_json: String,
444 ctx: Option<LegacyTenantCtx>,
445 ) -> WasmResult<Result<String, LegacyIfaceError>> {
446 host_import_v0_2::HostImports::tool_invoke(self.host_mut(), tool, action, args_json, ctx)
447 }
448
449 fn http_fetch(
450 &mut self,
451 req: host_import_v0_2::greentic::host_import::imports::HttpRequest,
452 ctx: Option<host_import_v0_2::greentic::host_import::imports::TenantCtx>,
453 ) -> WasmResult<
454 Result<
455 host_import_v0_2::greentic::host_import::imports::HttpResponse,
456 host_import_v0_2::greentic::host_import::imports::IfaceError,
457 >,
458 > {
459 host_import_v0_2::HostImports::http_fetch(self.host_mut(), req, ctx)
460 }
461}
462
463impl host_import_v0_6::HostImports for HostState {
464 fn secrets_get(
465 &mut self,
466 key: String,
467 _ctx: Option<types::TenantCtx>,
468 ) -> WasmResult<Result<String, types::IfaceError>> {
469 Ok(self.get_secret(&key).map_err(|err| {
470 tracing::warn!(secret = %key, error = %err, "secret lookup denied");
471 types::IfaceError::Denied
472 }))
473 }
474
475 fn telemetry_emit(
476 &mut self,
477 span_json: String,
478 _ctx: Option<types::TenantCtx>,
479 ) -> WasmResult<()> {
480 if let Some(mock) = &self.mocks
481 && mock.telemetry_drain(&[("span_json", span_json.as_str())])
482 {
483 return Ok(());
484 }
485 tracing::info!(span = %span_json, "telemetry emit from pack");
486 Ok(())
487 }
488
489 fn http_fetch(
490 &mut self,
491 req: host_import_v0_6::http::HttpRequest,
492 _ctx: Option<types::TenantCtx>,
493 ) -> WasmResult<Result<host_import_v0_6::http::HttpResponse, types::IfaceError>> {
494 let legacy_req = LegacyHttpRequest {
495 method: req.method,
496 url: req.url,
497 headers_json: req.headers_json,
498 body: req.body,
499 };
500 match host_import_v0_2::HostImports::http_fetch(self, legacy_req, None)? {
501 Ok(resp) => Ok(Ok(host_import_v0_6::http::HttpResponse {
502 status: resp.status,
503 headers_json: resp.headers_json,
504 body: resp.body,
505 })),
506 Err(err) => Ok(Err(map_legacy_error(err))),
507 }
508 }
509
510 fn mcp_exec(
511 &mut self,
512 component: String,
513 action: String,
514 args_json: String,
515 ctx: Option<types::TenantCtx>,
516 ) -> WasmResult<Result<String, types::IfaceError>> {
517 let _ = (component, action, args_json, ctx);
518 tracing::warn!("mcp.exec requested but MCP bridge is removed; returning unavailable");
519 Ok(Err(types::IfaceError::Unavailable))
520 }
521
522 fn state_get(
523 &mut self,
524 key: iface_types::StateKey,
525 ctx: Option<types::TenantCtx>,
526 ) -> WasmResult<Result<String, types::IfaceError>> {
527 let store = match self.state_store_handle() {
528 Ok(store) => store,
529 Err(err) => return Ok(Err(err)),
530 };
531 let tenant_ctx = match self.tenant_ctx_from_v6(ctx) {
532 Ok(ctx) => ctx,
533 Err(err) => {
534 tracing::warn!(error = %err, "invalid tenant context for state.get");
535 return Ok(Err(types::IfaceError::InvalidArg));
536 }
537 };
538 let key = StoreStateKey::from(key);
539 match store.get_json(&tenant_ctx, STATE_PREFIX, &key, None) {
540 Ok(Some(value)) => {
541 let result = if let Some(text) = value.as_str() {
542 text.to_string()
543 } else {
544 serde_json::to_string(&value).unwrap_or_else(|_| value.to_string())
545 };
546 Ok(Ok(result))
547 }
548 Ok(None) => Ok(Err(types::IfaceError::NotFound)),
549 Err(err) => {
550 tracing::warn!(error = %err, "state.get failed");
551 Ok(Err(types::IfaceError::Internal))
552 }
553 }
554 }
555
556 fn state_set(
557 &mut self,
558 key: iface_types::StateKey,
559 value_json: String,
560 ctx: Option<types::TenantCtx>,
561 ) -> WasmResult<Result<state::OpAck, types::IfaceError>> {
562 let store = match self.state_store_handle() {
563 Ok(store) => store,
564 Err(err) => return Ok(Err(err)),
565 };
566 let tenant_ctx = match self.tenant_ctx_from_v6(ctx) {
567 Ok(ctx) => ctx,
568 Err(err) => {
569 tracing::warn!(error = %err, "invalid tenant context for state.set");
570 return Ok(Err(types::IfaceError::InvalidArg));
571 }
572 };
573 let key = StoreStateKey::from(key);
574 let value = serde_json::from_str(&value_json).unwrap_or(Value::String(value_json));
575 match store.set_json(&tenant_ctx, STATE_PREFIX, &key, None, &value, None) {
576 Ok(()) => Ok(Ok(state::OpAck::Ok)),
577 Err(err) => {
578 tracing::warn!(error = %err, "state.set failed");
579 Ok(Err(types::IfaceError::Internal))
580 }
581 }
582 }
583
584 fn session_update(
585 &mut self,
586 cursor: iface_types::SessionCursor,
587 ctx: Option<types::TenantCtx>,
588 ) -> WasmResult<Result<String, types::IfaceError>> {
589 let store = match self.session_store_handle() {
590 Ok(store) => store,
591 Err(err) => return Ok(Err(err)),
592 };
593 let tenant_ctx = match self.tenant_ctx_from_v6(ctx) {
594 Ok(ctx) => ctx,
595 Err(err) => {
596 tracing::warn!(error = %err, "invalid tenant context for session.update");
597 return Ok(Err(types::IfaceError::InvalidArg));
598 }
599 };
600 let user = match Self::ensure_user(&tenant_ctx) {
601 Ok(user) => user,
602 Err(err) => return Ok(Err(err)),
603 };
604 let flow_id = match Self::ensure_flow(&tenant_ctx) {
605 Ok(flow) => flow,
606 Err(err) => return Ok(Err(err)),
607 };
608 let cursor = Self::cursor_from_iface(cursor);
609 let payload = SessionData {
610 tenant_ctx: tenant_ctx.clone(),
611 flow_id,
612 cursor: cursor.clone(),
613 context_json: serde_json::json!({
614 "node_pointer": cursor.node_pointer,
615 "wait_reason": cursor.wait_reason,
616 "outbox_marker": cursor.outbox_marker,
617 })
618 .to_string(),
619 };
620 if let Some(existing) = tenant_ctx.session_id() {
621 let key = StoreSessionKey::from(existing.to_string());
622 if let Err(err) = store.update_session(&key, payload) {
623 tracing::error!(error = %err, "failed to update session snapshot");
624 return Ok(Err(types::IfaceError::Internal));
625 }
626 return Ok(Ok(existing.to_string()));
627 }
628 match store.find_by_user(&tenant_ctx, &user) {
629 Ok(Some((key, _))) => {
630 if let Err(err) = store.update_session(&key, payload) {
631 tracing::error!(error = %err, "failed to update existing user session");
632 return Ok(Err(types::IfaceError::Internal));
633 }
634 return Ok(Ok(key.to_string()));
635 }
636 Ok(None) => {}
637 Err(err) => {
638 tracing::error!(error = %err, "session lookup failed");
639 return Ok(Err(types::IfaceError::Internal));
640 }
641 }
642 let key = match store.create_session(&tenant_ctx, payload.clone()) {
643 Ok(key) => key,
644 Err(err) => {
645 tracing::error!(error = %err, "failed to create session");
646 return Ok(Err(types::IfaceError::Internal));
647 }
648 };
649 let ctx_with_session = tenant_ctx.with_session(key.to_string());
650 let updated_payload = SessionData {
651 tenant_ctx: ctx_with_session.clone(),
652 ..payload
653 };
654 if let Err(err) = store.update_session(&key, updated_payload) {
655 tracing::warn!(error = %err, "failed to stamp session id after create");
656 }
657 Ok(Ok(key.to_string()))
658 }
659}
660
661impl host_import_v0_2::HostImports for HostState {
662 fn secrets_get(
663 &mut self,
664 key: String,
665 _ctx: Option<LegacyTenantCtx>,
666 ) -> WasmResult<Result<String, LegacyIfaceError>> {
667 Ok(self.get_secret(&key).map_err(|err| {
668 tracing::warn!(secret = %key, error = %err, "secret lookup denied");
669 LegacyIfaceError::Denied
670 }))
671 }
672
673 fn telemetry_emit(
674 &mut self,
675 span_json: String,
676 _ctx: Option<LegacyTenantCtx>,
677 ) -> WasmResult<()> {
678 if let Some(mock) = &self.mocks
679 && mock.telemetry_drain(&[("span_json", span_json.as_str())])
680 {
681 return Ok(());
682 }
683 tracing::info!(span = %span_json, "telemetry emit from pack");
684 Ok(())
685 }
686
687 fn tool_invoke(
688 &mut self,
689 tool: String,
690 action: String,
691 args_json: String,
692 ctx: Option<LegacyTenantCtx>,
693 ) -> WasmResult<Result<String, LegacyIfaceError>> {
694 let _ = (tool, action, args_json, ctx);
695 tracing::warn!("tool invoke requested but MCP bridge is removed; returning unavailable");
696 Ok(Err(LegacyIfaceError::Unavailable))
697 }
698
699 fn http_fetch(
700 &mut self,
701 req: LegacyHttpRequest,
702 _ctx: Option<LegacyTenantCtx>,
703 ) -> WasmResult<Result<LegacyHttpResponse, LegacyIfaceError>> {
704 if !self.config.http_enabled {
705 tracing::warn!(url = %req.url, "http fetch denied by policy");
706 return Ok(Err(LegacyIfaceError::Denied));
707 }
708
709 let mut mock_state = None;
710 let raw_body = req.body.clone();
711 if let Some(mock) = &self.mocks
712 && let Ok(meta) = HttpMockRequest::new(
713 &req.method,
714 &req.url,
715 raw_body.as_deref().map(|body| body.as_bytes()),
716 )
717 {
718 match mock.http_begin(&meta) {
719 HttpDecision::Mock(response) => {
720 let http = LegacyHttpResponse::from(&response);
721 return Ok(Ok(http));
722 }
723 HttpDecision::Deny(reason) => {
724 tracing::warn!(url = %req.url, reason = %reason, "http fetch blocked by mocks");
725 return Ok(Err(LegacyIfaceError::Denied));
726 }
727 HttpDecision::Passthrough { record } => {
728 mock_state = Some((meta, record));
729 }
730 }
731 }
732
733 let method = req.method.parse().unwrap_or(reqwest::Method::GET);
734 let mut builder = self.http_client.request(method, &req.url);
735
736 if let Some(headers_json) = req.headers_json.as_ref() {
737 match serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(headers_json) {
738 Ok(map) => {
739 for (key, value) in map {
740 if let Some(val) = value.as_str()
741 && let Ok(header) =
742 reqwest::header::HeaderName::from_bytes(key.as_bytes())
743 && let Ok(header_value) = reqwest::header::HeaderValue::from_str(val)
744 {
745 builder = builder.header(header, header_value);
746 }
747 }
748 }
749 Err(err) => {
750 tracing::warn!(error = %err, "failed to parse headers for http.fetch");
751 }
752 }
753 }
754
755 if let Some(body) = raw_body.clone() {
756 builder = builder.body(body);
757 }
758
759 let response = match builder.send() {
760 Ok(resp) => resp,
761 Err(err) => {
762 tracing::error!(url = %req.url, error = %err, "http fetch failed");
763 return Ok(Err(LegacyIfaceError::Unavailable));
764 }
765 };
766
767 let status = response.status().as_u16();
768 let headers_map = response
769 .headers()
770 .iter()
771 .map(|(k, v)| {
772 (
773 k.as_str().to_string(),
774 v.to_str().unwrap_or_default().to_string(),
775 )
776 })
777 .collect::<BTreeMap<_, _>>();
778 let headers_json = serde_json::to_string(&headers_map).ok();
779 let body = response.text().ok();
780
781 if let Some((meta, true)) = mock_state.take()
782 && let Some(mock) = &self.mocks
783 {
784 let recorded = HttpMockResponse::new(status, headers_map.clone(), body.clone());
785 mock.http_record(&meta, &recorded);
786 }
787
788 Ok(Ok(LegacyHttpResponse {
789 status,
790 headers_json,
791 body,
792 }))
793 }
794}
795
796impl PackRuntime {
797 #[allow(clippy::too_many_arguments)]
798 pub async fn load(
799 path: impl AsRef<Path>,
800 config: Arc<HostConfig>,
801 mocks: Option<Arc<MockLayer>>,
802 archive_source: Option<&Path>,
803 session_store: Option<DynSessionStore>,
804 state_store: Option<DynStateStore>,
805 wasi_policy: Arc<RunnerWasiPolicy>,
806 secrets: DynSecretsManager,
807 oauth_config: Option<OAuthBrokerConfig>,
808 verify_archive: bool,
809 ) -> Result<Self> {
810 let path = path.as_ref();
811 let (_pack_root, safe_path) = normalize_pack_path(path)?;
812 let is_component = safe_path
813 .extension()
814 .and_then(|ext| ext.to_str())
815 .map(|ext| ext.eq_ignore_ascii_case("wasm"))
816 .unwrap_or(false);
817 let archive_hint_path = if let Some(source) = archive_source {
818 let (_, normalized) = normalize_pack_path(source)?;
819 Some(normalized)
820 } else if is_component {
821 None
822 } else {
823 Some(safe_path.clone())
824 };
825 let archive_hint = archive_hint_path.as_deref();
826 if verify_archive {
827 let verify_target = archive_hint.unwrap_or(&safe_path);
828 verify::verify_pack(verify_target).await?;
829 tracing::info!(pack_path = %verify_target.display(), "pack verification complete");
830 }
831 let engine = Engine::default();
832 let wasm_bytes = fs::read(&safe_path).await?;
833 let mut metadata = PackMetadata::from_wasm(&wasm_bytes)
834 .unwrap_or_else(|| PackMetadata::fallback(&safe_path));
835 let mut manifest = None;
836 let flows = if let Some(archive_path) = archive_hint {
837 match load_manifest_and_flows(archive_path) {
838 Ok((m, cache)) => {
839 metadata = cache.metadata.clone();
840 manifest = Some(m);
841 Some(cache)
842 }
843 Err(err) => {
844 warn!(error = %err, pack = %archive_path.display(), "failed to parse pack manifest; skipping flows");
845 None
846 }
847 }
848 } else {
849 None
850 };
851 let components = if let Some(archive_path) = archive_hint {
852 match load_components_from_archive(&engine, archive_path, manifest.as_ref()) {
853 Ok(map) => map,
854 Err(err) => {
855 warn!(error = %err, pack = %archive_path.display(), "failed to load components from archive");
856 HashMap::new()
857 }
858 }
859 } else if is_component {
860 let name = safe_path
861 .file_stem()
862 .map(|s| s.to_string_lossy().to_string())
863 .unwrap_or_else(|| "component".to_string());
864 let component = Component::from_binary(&engine, &wasm_bytes)?;
865 let mut map = HashMap::new();
866 map.insert(
867 name.clone(),
868 PackComponent {
869 name,
870 version: metadata.version.clone(),
871 component,
872 },
873 );
874 map
875 } else {
876 HashMap::new()
877 };
878 let http_client = Arc::clone(&HTTP_CLIENT);
879 Ok(Self {
880 path: safe_path,
881 archive_path: archive_hint.map(Path::to_path_buf),
882 config,
883 engine,
884 metadata,
885 manifest,
886 mocks,
887 flows,
888 components,
889 http_client,
890 pre_cache: Mutex::new(HashMap::new()),
891 session_store,
892 state_store,
893 wasi_policy,
894 secrets,
895 oauth_config,
896 })
897 }
898
899 pub async fn list_flows(&self) -> Result<Vec<FlowDescriptor>> {
900 if let Some(cache) = &self.flows {
901 return Ok(cache.descriptors.clone());
902 }
903 if let Some(manifest) = &self.manifest {
904 let descriptors = manifest
905 .flows
906 .iter()
907 .map(|flow| FlowDescriptor {
908 id: flow.id.as_str().to_string(),
909 flow_type: flow_kind_to_str(flow.kind).to_string(),
910 profile: manifest.pack_id.as_str().to_string(),
911 version: manifest.version.to_string(),
912 description: None,
913 })
914 .collect();
915 return Ok(descriptors);
916 }
917 Ok(Vec::new())
918 }
919
920 #[allow(dead_code)]
921 pub async fn run_flow(
922 &self,
923 _flow_id: &str,
924 _input: serde_json::Value,
925 ) -> Result<serde_json::Value> {
926 Ok(serde_json::json!({}))
928 }
929
930 pub async fn invoke_component(
931 &self,
932 component_ref: &str,
933 ctx: ComponentExecCtx,
934 operation: &str,
935 _config_json: Option<String>,
936 input_json: String,
937 ) -> Result<Value> {
938 let pack_component = self
939 .components
940 .get(component_ref)
941 .with_context(|| format!("component '{component_ref}' not found in pack"))?;
942
943 let pre = if let Some(pre) = self.pre_cache.lock().get(component_ref).cloned() {
944 pre
945 } else {
946 let mut linker = Linker::new(&self.engine);
947 register_all(&mut linker, self.oauth_config.is_some())?;
948 add_component_control_to_linker(&mut linker)?;
949 let pre = ComponentPre::new(
950 linker
951 .instantiate_pre(&pack_component.component)
952 .map_err(|err| anyhow!(err))?,
953 )
954 .map_err(|err| anyhow!(err))?;
955 self.pre_cache
956 .lock()
957 .insert(component_ref.to_string(), pre.clone());
958 pre
959 };
960
961 let host_state = HostState::new(
962 Arc::clone(&self.config),
963 Arc::clone(&self.http_client),
964 self.mocks.clone(),
965 self.session_store.clone(),
966 self.state_store.clone(),
967 Arc::clone(&self.secrets),
968 self.oauth_config.clone(),
969 )?;
970 let store_state = ComponentState::new(host_state, Arc::clone(&self.wasi_policy))?;
971 let mut store = wasmtime::Store::new(&self.engine, store_state);
972 let bindings = pre
973 .instantiate_async(&mut store)
974 .await
975 .map_err(|err| anyhow!(err))?;
976 let node = bindings.greentic_component_node();
977
978 let result = node.call_invoke(&mut store, &ctx, operation, &input_json)?;
979
980 match result {
981 InvokeResult::Ok(body) => {
982 if body.is_empty() {
983 return Ok(Value::Null);
984 }
985 serde_json::from_str(&body).or_else(|_| Ok(Value::String(body)))
986 }
987 InvokeResult::Err(NodeError {
988 code,
989 message,
990 retryable,
991 backoff_ms,
992 details,
993 }) => {
994 let mut obj = serde_json::Map::new();
995 obj.insert("ok".into(), Value::Bool(false));
996 let mut error = serde_json::Map::new();
997 error.insert("code".into(), Value::String(code));
998 error.insert("message".into(), Value::String(message));
999 error.insert("retryable".into(), Value::Bool(retryable));
1000 if let Some(backoff) = backoff_ms {
1001 error.insert("backoff_ms".into(), Value::Number(backoff.into()));
1002 }
1003 if let Some(details) = details {
1004 error.insert(
1005 "details".into(),
1006 serde_json::from_str(&details).unwrap_or(Value::String(details)),
1007 );
1008 }
1009 obj.insert("error".into(), Value::Object(error));
1010 Ok(Value::Object(obj))
1011 }
1012 }
1013 }
1014
1015 pub fn load_flow(&self, flow_id: &str) -> Result<Flow> {
1016 if let Some(cache) = &self.flows {
1017 return cache
1018 .flows
1019 .get(flow_id)
1020 .cloned()
1021 .ok_or_else(|| anyhow!("flow '{flow_id}' not found in pack"));
1022 }
1023 if let Some(manifest) = &self.manifest {
1024 let entry = manifest
1025 .flows
1026 .iter()
1027 .find(|f| f.id.as_str() == flow_id)
1028 .ok_or_else(|| anyhow!("flow '{flow_id}' not found in manifest"))?;
1029 return Ok(entry.flow.clone());
1030 }
1031 bail!("flow '{flow_id}' not available (pack exports disabled)")
1032 }
1033
1034 pub fn metadata(&self) -> &PackMetadata {
1035 &self.metadata
1036 }
1037
1038 pub fn for_component_test(
1039 components: Vec<(String, PathBuf)>,
1040 flows: HashMap<String, FlowIR>,
1041 config: Arc<HostConfig>,
1042 ) -> Result<Self> {
1043 let engine = Engine::default();
1044 let mut component_map = HashMap::new();
1045 for (name, path) in components {
1046 if !path.exists() {
1047 bail!("component artifact missing: {}", path.display());
1048 }
1049 let wasm_bytes = std::fs::read(&path)?;
1050 let component = Component::from_binary(&engine, &wasm_bytes)
1051 .with_context(|| format!("failed to compile component {}", path.display()))?;
1052 component_map.insert(
1053 name.clone(),
1054 PackComponent {
1055 name,
1056 version: "0.0.0".into(),
1057 component,
1058 },
1059 );
1060 }
1061
1062 let mut flow_map = HashMap::new();
1063 let mut descriptors = Vec::new();
1064 for (id, ir) in flows {
1065 let flow_type = ir.flow_type.clone();
1066 let flow = flow_ir_to_flow(ir)?;
1067 flow_map.insert(id.clone(), flow);
1068 descriptors.push(FlowDescriptor {
1069 id: id.clone(),
1070 flow_type,
1071 profile: "test".into(),
1072 version: "0.0.0".into(),
1073 description: None,
1074 });
1075 }
1076 let flows_cache = PackFlows {
1077 descriptors: descriptors.clone(),
1078 flows: flow_map,
1079 metadata: PackMetadata::fallback(Path::new("component-test")),
1080 };
1081
1082 Ok(Self {
1083 path: PathBuf::new(),
1084 archive_path: None,
1085 config,
1086 engine,
1087 metadata: PackMetadata::fallback(Path::new("component-test")),
1088 manifest: None,
1089 mocks: None,
1090 flows: Some(flows_cache),
1091 components: component_map,
1092 http_client: Arc::clone(&HTTP_CLIENT),
1093 pre_cache: Mutex::new(HashMap::new()),
1094 session_store: None,
1095 state_store: None,
1096 wasi_policy: Arc::new(RunnerWasiPolicy::new()),
1097 secrets: crate::secrets::default_manager(),
1098 oauth_config: None,
1099 })
1100 }
1101}
1102
1103fn map_legacy_error(err: LegacyIfaceError) -> types::IfaceError {
1104 match err {
1105 LegacyIfaceError::InvalidArg => types::IfaceError::InvalidArg,
1106 LegacyIfaceError::NotFound => types::IfaceError::NotFound,
1107 LegacyIfaceError::Denied => types::IfaceError::Denied,
1108 LegacyIfaceError::Unavailable => types::IfaceError::Unavailable,
1109 LegacyIfaceError::Internal => types::IfaceError::Internal,
1110 }
1111}
1112
1113struct PackFlows {
1114 descriptors: Vec<FlowDescriptor>,
1115 flows: HashMap<String, Flow>,
1116 metadata: PackMetadata,
1117}
1118
1119impl PackFlows {
1120 fn from_manifest(manifest: greentic_types::PackManifest) -> Self {
1121 let descriptors = manifest
1122 .flows
1123 .iter()
1124 .map(|entry| FlowDescriptor {
1125 id: entry.id.as_str().to_string(),
1126 flow_type: flow_kind_to_str(entry.kind).to_string(),
1127 profile: manifest.pack_id.as_str().to_string(),
1128 version: manifest.version.to_string(),
1129 description: None,
1130 })
1131 .collect();
1132 let mut flows = HashMap::new();
1133 for entry in &manifest.flows {
1134 flows.insert(entry.id.as_str().to_string(), entry.flow.clone());
1135 }
1136 Self {
1137 metadata: PackMetadata::from_manifest(&manifest),
1138 descriptors,
1139 flows,
1140 }
1141 }
1142}
1143
1144fn flow_kind_to_str(kind: greentic_types::FlowKind) -> &'static str {
1145 match kind {
1146 greentic_types::FlowKind::Messaging => "messaging",
1147 greentic_types::FlowKind::Event => "event",
1148 greentic_types::FlowKind::ComponentConfig => "component-config",
1149 greentic_types::FlowKind::Job => "job",
1150 greentic_types::FlowKind::Http => "http",
1151 }
1152}
1153
1154fn read_entry(archive: &mut ZipArchive<File>, name: &str) -> Result<Vec<u8>> {
1155 let mut file = archive
1156 .by_name(name)
1157 .with_context(|| format!("entry {name} missing from archive"))?;
1158 let mut buf = Vec::new();
1159 file.read_to_end(&mut buf)?;
1160 Ok(buf)
1161}
1162
1163#[cfg(test)]
1164fn normalize_flow_doc(mut doc: FlowDoc) -> FlowDoc {
1165 for node in doc.nodes.values_mut() {
1166 if node.component.is_empty()
1167 && let Some((component_ref, payload)) = node.raw.iter().next()
1168 {
1169 if component_ref.starts_with("emit.") {
1170 node.component = component_ref.clone();
1171 node.payload = payload.clone();
1172 node.raw.clear();
1173 continue;
1174 }
1175 let (target_component, operation, input, config) =
1176 infer_component_exec(payload, component_ref);
1177 let mut payload_obj = serde_json::Map::new();
1178 payload_obj.insert("component".into(), Value::String(target_component));
1180 payload_obj.insert("operation".into(), Value::String(operation));
1181 payload_obj.insert("input".into(), input);
1182 if let Some(cfg) = config {
1183 payload_obj.insert("config".into(), cfg);
1184 }
1185 node.component = "component.exec".to_string();
1186 node.payload = Value::Object(payload_obj);
1187 }
1188 }
1189 doc
1190}
1191
1192#[cfg(test)]
1193fn infer_component_exec(
1194 payload: &Value,
1195 component_ref: &str,
1196) -> (String, String, Value, Option<Value>) {
1197 let default_op = if component_ref.starts_with("templating.") {
1198 "render"
1199 } else {
1200 "invoke"
1201 }
1202 .to_string();
1203
1204 if let Value::Object(map) = payload {
1205 let op = map
1206 .get("op")
1207 .or_else(|| map.get("operation"))
1208 .and_then(Value::as_str)
1209 .map(|s| s.to_string())
1210 .unwrap_or_else(|| default_op.clone());
1211
1212 let mut input = map.clone();
1213 let config = input.remove("config");
1214 let component = input
1215 .get("component")
1216 .or_else(|| input.get("component_ref"))
1217 .and_then(Value::as_str)
1218 .map(|s| s.to_string())
1219 .unwrap_or_else(|| component_ref.to_string());
1220 input.remove("component");
1221 input.remove("component_ref");
1222 input.remove("op");
1223 input.remove("operation");
1224 return (component, op, Value::Object(input), config);
1225 }
1226
1227 (component_ref.to_string(), default_op, payload.clone(), None)
1228}
1229
1230#[cfg(test)]
1231mod tests {
1232 use super::*;
1233 use greentic_flow::model::{FlowDoc, NodeDoc};
1234 use serde_json::json;
1235 use std::collections::BTreeMap;
1236
1237 #[test]
1238 fn normalizes_raw_component_to_component_exec() {
1239 let mut nodes = BTreeMap::new();
1240 let mut raw = BTreeMap::new();
1241 raw.insert(
1242 "templating.handlebars".into(),
1243 json!({ "template": "Hi {{name}}" }),
1244 );
1245 nodes.insert(
1246 "start".into(),
1247 NodeDoc {
1248 raw,
1249 routing: json!([{"out": true}]),
1250 ..Default::default()
1251 },
1252 );
1253 let doc = FlowDoc {
1254 id: "welcome".into(),
1255 title: None,
1256 description: None,
1257 flow_type: "messaging".into(),
1258 start: Some("start".into()),
1259 parameters: json!({}),
1260 tags: Vec::new(),
1261 entrypoints: BTreeMap::new(),
1262 nodes,
1263 };
1264
1265 let normalized = normalize_flow_doc(doc);
1266 let node = normalized.nodes.get("start").expect("node exists");
1267 assert_eq!(node.component, "component.exec");
1268 assert!(node.raw.is_empty() || node.raw.contains_key("templating.handlebars"));
1269 let payload = node.payload.as_object().expect("payload object");
1270 assert_eq!(
1271 payload.get("component"),
1272 Some(&Value::String("templating.handlebars".into()))
1273 );
1274 assert_eq!(
1275 payload.get("operation"),
1276 Some(&Value::String("render".into()))
1277 );
1278 let input = payload.get("input").unwrap();
1279 assert_eq!(input, &json!({ "template": "Hi {{name}}" }));
1280 }
1281}
1282
1283fn load_components_from_archive(
1284 engine: &Engine,
1285 path: &Path,
1286 manifest: Option<&greentic_types::PackManifest>,
1287) -> Result<HashMap<String, PackComponent>> {
1288 let mut archive = ZipArchive::new(File::open(path)?)
1289 .with_context(|| format!("{} is not a valid gtpack", path.display()))?;
1290 let mut components = HashMap::new();
1291 if let Some(manifest) = manifest {
1292 for entry in &manifest.components {
1293 let file_name = format!("components/{}.wasm", entry.id.as_str());
1294 let bytes = read_entry(&mut archive, &file_name)
1295 .with_context(|| format!("missing component {}", file_name))?;
1296 let component = Component::from_binary(engine, &bytes)
1297 .with_context(|| format!("failed to compile component {}", entry.id.as_str()))?;
1298 components.insert(
1299 entry.id.as_str().to_string(),
1300 PackComponent {
1301 name: entry.id.as_str().to_string(),
1302 version: entry.version.to_string(),
1303 component,
1304 },
1305 );
1306 }
1307 }
1308 Ok(components)
1309}
1310
1311#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1312pub struct PackMetadata {
1313 pub pack_id: String,
1314 pub version: String,
1315 #[serde(default)]
1316 pub entry_flows: Vec<String>,
1317}
1318
1319impl PackMetadata {
1320 fn from_wasm(bytes: &[u8]) -> Option<Self> {
1321 let parser = Parser::new(0);
1322 for payload in parser.parse_all(bytes) {
1323 let payload = payload.ok()?;
1324 match payload {
1325 Payload::CustomSection(section) => {
1326 if section.name() == "greentic.manifest"
1327 && let Ok(meta) = Self::from_bytes(section.data())
1328 {
1329 return Some(meta);
1330 }
1331 }
1332 Payload::DataSection(reader) => {
1333 for segment in reader.into_iter().flatten() {
1334 if let Ok(meta) = Self::from_bytes(segment.data) {
1335 return Some(meta);
1336 }
1337 }
1338 }
1339 _ => {}
1340 }
1341 }
1342 None
1343 }
1344
1345 fn from_bytes(bytes: &[u8]) -> Result<Self, serde_cbor::Error> {
1346 #[derive(Deserialize)]
1347 struct RawManifest {
1348 pack_id: String,
1349 version: String,
1350 #[serde(default)]
1351 entry_flows: Vec<String>,
1352 #[serde(default)]
1353 flows: Vec<RawFlow>,
1354 }
1355
1356 #[derive(Deserialize)]
1357 struct RawFlow {
1358 id: String,
1359 }
1360
1361 let manifest: RawManifest = serde_cbor::from_slice(bytes)?;
1362 let mut entry_flows = if manifest.entry_flows.is_empty() {
1363 manifest.flows.iter().map(|f| f.id.clone()).collect()
1364 } else {
1365 manifest.entry_flows.clone()
1366 };
1367 entry_flows.retain(|id| !id.is_empty());
1368 Ok(Self {
1369 pack_id: manifest.pack_id,
1370 version: manifest.version,
1371 entry_flows,
1372 })
1373 }
1374
1375 pub fn fallback(path: &Path) -> Self {
1376 let pack_id = path
1377 .file_stem()
1378 .map(|s| s.to_string_lossy().into_owned())
1379 .unwrap_or_else(|| "unknown-pack".to_string());
1380 Self {
1381 pack_id,
1382 version: "0.0.0".to_string(),
1383 entry_flows: Vec::new(),
1384 }
1385 }
1386
1387 pub fn from_manifest(manifest: &greentic_types::PackManifest) -> Self {
1388 let entry_flows = manifest
1389 .flows
1390 .iter()
1391 .map(|flow| flow.id.as_str().to_string())
1392 .collect::<Vec<_>>();
1393 Self {
1394 pack_id: manifest.pack_id.as_str().to_string(),
1395 version: manifest.version.to_string(),
1396 entry_flows,
1397 }
1398 }
1399}
1400
1401impl From<&HttpMockResponse> for LegacyHttpResponse {
1402 fn from(value: &HttpMockResponse) -> Self {
1403 let headers_json = serde_json::to_string(&value.headers).ok();
1404 Self {
1405 status: value.status,
1406 headers_json,
1407 body: value.body.clone(),
1408 }
1409 }
1410}