Skip to main content

capsule_runtime/
runtime.rs

1use std::{
2    collections::{HashMap, HashSet},
3    fs,
4    path::{Path, PathBuf},
5    sync::{
6        atomic::{AtomicBool, AtomicU64, Ordering},
7        Arc, Mutex,
8    },
9};
10
11use murmur_artifact::{
12    current_platform, parse_hook_config_from_yaml, parse_tool_implementation_from_yaml,
13    read_lockfile, security_warning_link, verify_sha256, write_lockfile_atomic, AfterTask,
14    ArtifactImplementation, ArtifactRuntime, ContextConfig, HookBinding, LifecycleConfig, LockedArtifact,
15    LockedSha256, LockfileError, MurmurLock, Registry, RegistryError, RuntimeType,
16    TaskAcceptance, LOCK_VERSION, MANIFEST_FILENAME, PACKED_MANIFEST_ENTRY, W_SEC_003,
17};
18use serde_yaml::Value;
19use wasmtime::{
20    component::{Component, HasSelf, Linker, ResourceTable},
21    Config, Engine, Store,
22};
23use wasmtime_wasi::{DirPerms, FilePerms, WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
24use wasmtime_wasi_http::{
25    p2::{
26        bindings::http::types::ErrorCode as WasiHttpErrorCode,
27        body::HyperOutgoingBody,
28        types::{HostFutureIncomingResponse, OutgoingRequestConfig},
29        HttpResult, WasiHttpCtxView, WasiHttpHooks, WasiHttpView,
30    },
31    WasiHttpCtx,
32};
33
34use crate::{
35    a2a::{IncomingTask, TaskRegistry, TaskState},
36    agent,
37    artifact::{extract_manifest_yaml, extract_native_binary, extract_root_wasm, extract_skill_md},
38    bindings::host::murmur::{
39        self, artifact_manager::manage, message::send, tool_registry::invoke,
40    },
41    errors::RuntimeError,
42    hooks::{
43        dispatch_stage, HookEnvVars, HookEvent, HookRuntime, SessionContextData, ShellDispatchInfo,
44    },
45    identity::{self, CapsuleIdentity},
46    limits::{classify_guest_failure, EpochTicker, ExecutionLimiter, GuestFailure},
47    murmur_md,
48    network_policy::{
49        parse_network_allow_rules, validate_filesystem_scope, NetworkAllowRule, RequestTarget,
50    },
51    otel::OtelEmitter,
52    outgoing, sandbox,
53    shell::{
54        build_shell_env, build_wasi_env_allowlist, execute_shell, is_shell_interpreter,
55        shell_tool_manifest_yaml, split_shell_words, ShellResult,
56    },
57    streaming::{
58        emit_chunk_sse, emit_sse, emit_thinking_chunk_sse, SseBroadcast, SseEventBuffer,
59        StreamStatus, TaskStatusUpdateEvent,
60    },
61    trace::TraceWriter,
62    types::{
63        CapabilityPolicy, DispatchOutcome, InstalledArtifactSummary, LaunchResult,
64        ResolvedLockArtifact, StageRequest, StagedHookArtifact, StagedSession,
65    },
66};
67
68/// Versioned instance export name a guest built against the semver'd
69/// `murmur:capsule@0.1.0` WIT package carries. This is the only name the host
70/// resolves — the legacy unversioned fallback was removed after the dual-accept
71/// runtime shipped (see `wit/VERSIONING.md`).
72const WIT_CAPSULE_IFACE_VERSIONED: &str = "murmur:capsule/run@0.1.0";
73/// Versioned instance export name a guest built against `murmur:tool@0.1.0`
74/// carries. Only name the host resolves; see `WIT_CAPSULE_IFACE_VERSIONED`.
75const WIT_TOOL_IFACE_VERSIONED: &str = "murmur:tool/run@0.1.0";
76
77/// The host provides its guest-facing *import* interfaces under the versioned
78/// instance name only. The legacy unversioned provisions were dropped after the
79/// dual-accept runtime shipped; a guest importing only the
80/// unversioned name now fails to link. See `wit/VERSIONING.md`.
81const WIT_TOOL_REGISTRY_IFACE: &str = "murmur:tool-registry/invoke@0.1.0";
82const WIT_TEXT_CHUNKS_IFACE: &str = "murmur:text/chunks@0.1.0";
83const WIT_TASK_IFACE: &str = "murmur:task/task@0.1.0";
84
85/// Resolve a guest interface instance export by its versioned name. Returns
86/// `None` when the versioned name is absent, so a component that exports only
87/// the legacy unversioned name (or no recognizable name) surfaces as the
88/// missing-export error at the call site. See `wit/VERSIONING.md`.
89fn resolve_versioned_iface<T>(
90    instance: &wasmtime::component::Instance,
91    store: &mut Store<T>,
92    versioned: &str,
93) -> Option<wasmtime::component::ComponentExportIndex> {
94    instance.get_export_index(&mut *store, None, versioned)
95}
96
97/// Live-delivery buffer: only needs to cover the lag between fastest and slowest
98/// currently-connected reader.
99const SSE_BROADCAST_CAPACITY: usize = 128;
100
101/// Replay buffer: covers observers joining mid-task. Sized to handle the longest
102/// expected task turn without eviction. Can be tuned independently of broadcast capacity.
103/// Candidate for `LifecycleConfig` if operator tuning is needed in a future slice.
104const SSE_REPLAY_CAPACITY: usize = 512;
105
106/// Whether any staged hook can receive the `on-compaction` lifecycle event. Mirrors the
107/// binding match the runtime uses when it actually dispatches compaction, so MURMUR.md's
108/// "compaction configured" status reflects the real dispatch path rather than an artifact name.
109fn has_compaction_hook(hooks: &[StagedHookArtifact]) -> bool {
110    hooks
111        .iter()
112        .any(|h| matches!(h.config.binding, HookBinding::OnCompaction | HookBinding::All))
113}
114
115/// Resolves and verifies all artifacts, compiles components, and prepares session state.
116///
117/// This function is intentionally separate from `launch_session` so policy/capability
118/// checks can be layered in the seam between stage and launch without mixing concerns.
119pub fn stage_session(
120    registry: Arc<dyn Registry>,
121    request: StageRequest,
122) -> Result<StagedSession, RuntimeError> {
123    validate_capability_policy(&request.capability_policy)?;
124
125    let engine = build_engine()?;
126    // Start ticking before the first guest runs: `dispatch_stage` below invokes on-stage
127    // hooks, which are already subject to the epoch deadline.
128    let epoch_ticker = EpochTicker::spawn(&engine);
129    let lock_expectations = request.lock_expectations.map(|entries| {
130        entries
131            .into_iter()
132            .map(|entry| (entry.name.clone(), entry))
133            .collect::<HashMap<_, _>>()
134    });
135
136    let mut resolved_lock_artifacts = Vec::with_capacity(request.artifacts.len());
137    let mut installed_artifacts = Vec::with_capacity(request.artifacts.len());
138    let mut installed_manifests = Vec::with_capacity(request.artifacts.len());
139    let mut tool_components = HashMap::with_capacity(request.artifacts.len());
140    let mut hook_components = Vec::new();
141    // (name, binary_bytes) for native tool artifacts — installed after workdir creation
142    let mut native_binaries: Vec<(String, Vec<u8>)> = Vec::new();
143    // (name, skill_md_bytes) for skill artifacts — installed after workdir creation
144    let mut skill_files: Vec<(String, Vec<u8>)> = Vec::new();
145
146    for artifact in &request.artifacts {
147        // Local-source skill: resolve skill.md directly from the filesystem and skip the
148        // registry/lock pipeline entirely. Validated as skill-only at manifest parse time.
149        if let Some(source) = &artifact.source {
150            let skill_md = load_local_skill_md(&request.manifest_dir, source)?;
151            skill_files.push((artifact.name.clone(), skill_md));
152            installed_artifacts.push(InstalledArtifactSummary {
153                name: artifact.name.clone(),
154                version: artifact.version.clone(),
155                runtime: artifact.runtime.clone(),
156                implementation: None,
157            });
158            continue;
159        }
160
161        let (resolved_version, expected_hash) = match &lock_expectations {
162            Some(expected_by_name) => {
163                let expected = expected_by_name.get(&artifact.name).ok_or_else(|| {
164                    RuntimeError::LockMissingEntry {
165                        name: artifact.name.clone(),
166                    }
167                })?;
168
169                if artifact.version != expected.resolved_version {
170                    return Err(RuntimeError::LockVersionMismatch {
171                        name: artifact.name.clone(),
172                        requested: artifact.version.clone(),
173                        pinned: expected.resolved_version.clone(),
174                    });
175                }
176
177                (
178                    expected.resolved_version.clone(),
179                    Some(expected.sha256_wasm.clone()),
180                )
181            }
182            None => (artifact.version.clone(), None),
183        };
184
185        // Always pass current_platform(). LocalRegistry and RemoteRegistry both implement a
186        // fallback: if no platform-specific file exists they return the generic file. This
187        // means WASM artifacts (which have no platform file) resolve transparently, while
188        // native artifacts get their correct platform variant. Nexus must preserve this
189        // fallback behaviour — if it ever becomes strict (error on no platform match), WASM
190        // resolution will break.
191        let resolved = registry
192            .resolve_with_platform(&artifact.name, &resolved_version, Some(current_platform()))
193            .map_err(|err| map_registry_error(&artifact.name, &resolved_version, err))?;
194
195        verify_sha256(
196            &artifact.name,
197            &resolved_version,
198            &resolved.bytes,
199            &resolved.sha256,
200        )
201        .map_err(|_| RuntimeError::artifact_integrity_failed(&artifact.name, &resolved_version))?;
202
203        if let Some(expected) = expected_hash {
204            if resolved.sha256 != expected {
205                return Err(RuntimeError::artifact_integrity_failed(
206                    &artifact.name,
207                    &resolved_version,
208                ));
209            }
210        }
211
212        let manifest_yaml =
213            extract_manifest_yaml(&artifact.name, &resolved_version, &resolved.bytes)?;
214
215        installed_manifests.push((artifact.name.clone(), manifest_yaml.clone()));
216
217        let mut artifact_implementation: Option<ArtifactImplementation> = None;
218
219        match artifact.runtime {
220            ArtifactRuntime::Tool => {
221                let implementation = parse_tool_implementation_from_yaml(&manifest_yaml);
222                artifact_implementation = Some(implementation.clone());
223                match implementation {
224                    ArtifactImplementation::Native => {
225                        let binary = extract_native_binary(
226                            &artifact.name,
227                            &resolved_version,
228                            &resolved.bytes,
229                        )?;
230                        native_binaries.push((artifact.name.clone(), binary));
231                    }
232                    ArtifactImplementation::Wasm => {
233                        let tool_wasm =
234                            extract_root_wasm(&artifact.name, &resolved_version, &resolved.bytes)?;
235                        let tool_component = Component::new(&engine, tool_wasm).map_err(|err| {
236                            RuntimeError::ToolComponentCompile {
237                                name: artifact.name.clone(),
238                                version: resolved_version.clone(),
239                                message: err.to_string(),
240                            }
241                        })?;
242                        tool_components.insert(artifact.name.clone(), tool_component);
243                    }
244                }
245            }
246            ArtifactRuntime::Driver => {
247                let tool_wasm =
248                    extract_root_wasm(&artifact.name, &resolved_version, &resolved.bytes)?;
249                let tool_component = Component::new(&engine, tool_wasm).map_err(|err| {
250                    RuntimeError::ToolComponentCompile {
251                        name: artifact.name.clone(),
252                        version: resolved_version.clone(),
253                        message: err.to_string(),
254                    }
255                })?;
256                tool_components.insert(artifact.name.clone(), tool_component);
257            }
258            ArtifactRuntime::Hook => {
259                let hook_config = parse_hook_config_from_yaml(&manifest_yaml).map_err(|e| {
260                    RuntimeError::Runtime(format!(
261                        "hook {}@{} invalid config: {e}",
262                        artifact.name, resolved_version
263                    ))
264                })?;
265                let hook_wasm =
266                    extract_root_wasm(&artifact.name, &resolved_version, &resolved.bytes)?;
267                let hook_component = Component::new(&engine, hook_wasm).map_err(|err| {
268                    RuntimeError::ToolComponentCompile {
269                        name: artifact.name.clone(),
270                        version: resolved_version.clone(),
271                        message: err.to_string(),
272                    }
273                })?;
274                hook_components.push(StagedHookArtifact {
275                    name: artifact.name.clone(),
276                    version: resolved_version.clone(),
277                    component: hook_component,
278                    config: hook_config,
279                });
280            }
281            ArtifactRuntime::Skill => {
282                let skill_md =
283                    extract_skill_md(&artifact.name, &resolved_version, &resolved.bytes)?;
284                skill_files.push((artifact.name.clone(), skill_md));
285            }
286        }
287
288        resolved_lock_artifacts.push(ResolvedLockArtifact {
289            name: artifact.name.clone(),
290            resolved_version: resolved_version.clone(),
291            sha256_wasm: resolved.sha256,
292        });
293        installed_artifacts.push(InstalledArtifactSummary {
294            name: artifact.name.clone(),
295            version: resolved_version,
296            runtime: artifact.runtime.clone(),
297            implementation: artifact_implementation,
298        });
299    }
300
301    // For agent capsules (inference configured, empty WASM bytes) skip component compilation.
302    // For script capsules, compile the WASM component.
303    let capsule_component =
304        if request.inference.is_some() && request.capsule_component_bytes.is_empty() {
305            None
306        } else if request.capsule_component_bytes.is_empty() {
307            return Err(RuntimeError::CapsuleCompile(
308                "capsule component bytes are required for non-agent capsules".to_string(),
309            ));
310        } else {
311            Some(
312                Component::new(&engine, &request.capsule_component_bytes)
313                    .map_err(|err| RuntimeError::CapsuleCompile(err.to_string()))?,
314            )
315        };
316
317    let session_id = generate_session_id();
318    let (workdir, accessible_workdir) = if let Some(ref user_dir) = request.workdir {
319        let user_dir = if user_dir.is_absolute() {
320            user_dir.clone()
321        } else {
322            std::env::current_dir()
323                .map_err(|e| RuntimeError::Runtime(format!("failed to get cwd: {e}")))?
324                .join(user_dir)
325        };
326        if !user_dir.exists() || !user_dir.is_dir() {
327            return Err(RuntimeError::Runtime(format!(
328                "workdir '{}' does not exist or is not a directory",
329                user_dir.display()
330            )));
331        }
332        let session_dir = user_dir.join(".murmur").join(&session_id);
333        (session_dir, user_dir)
334    } else {
335        let dir = request.manifest_dir.join("workdir").join(&session_id);
336        let accessible = dir.clone();
337        (dir, accessible)
338    };
339    fs::create_dir_all(workdir.join("tools")).map_err(|source| RuntimeError::CreateWorkdir {
340        path: workdir.display().to_string(),
341        source,
342    })?;
343
344    for (name, manifest_yaml) in installed_manifests {
345        write_tool_manifest(&workdir, &name, &manifest_yaml)?;
346    }
347
348    install_native_binaries(&workdir, native_binaries)?;
349    install_skill_files(&workdir, skill_files)?;
350
351    // Write generic manifests for any shell binary not already covered by a custom manifest.
352    write_shell_tool_manifests(&workdir, &request.capability_policy.shell_allow)?;
353
354    // Dispatch on-stage hooks synchronously now that manifests are in place.
355    let stage_env = HookEnvVars::default();
356    dispatch_stage(
357        &engine,
358        &workdir,
359        &hook_components,
360        request.capability_policy.shell_allow.clone(),
361        &stage_env,
362        request.capability_policy.limits,
363    )?;
364
365    // Write MURMUR.md now for agent sessions so that tooling that inspects the workdir after
366    // staging (e.g. tests, debuggers) can read it. launch_session overwrites this file after
367    // port binding with the complete identity including capsule_url.
368    if let Some(ref inference) = request.inference {
369        let partial_identity = CapsuleIdentity {
370            capsule_name: request.capsule_name.clone(),
371            capsule_version: request.capsule_version.clone(),
372            session_id: session_id.clone(),
373            capsule_url: String::new(),
374        };
375        murmur_md::write_murmur_md(
376            &workdir,
377            Some(inference),
378            request.context.as_ref(),
379            has_compaction_hook(&hook_components),
380            &request.capability_policy,
381            &partial_identity,
382        );
383    }
384
385    // When --workdir is used, copy the project manifest to the accessible workdir so the agent
386    // can reference it by relative path from ".".
387    if request.workdir.is_some() {
388        let src = request.manifest_dir.join(MANIFEST_FILENAME);
389        let dst = accessible_workdir.join(MANIFEST_FILENAME);
390        if src.exists() && !dst.exists() {
391            if let Err(e) = fs::copy(&src, &dst) {
392                eprintln!(
393                    "[capsule-runtime] warning: failed to copy {MANIFEST_FILENAME} to workdir: {e}"
394                );
395            }
396        }
397    }
398
399    // Resolve lifecycle: manifest config + any override
400    let lifecycle = resolve_lifecycle(request.lifecycle, request.lifecycle_override.as_ref());
401
402    Ok(StagedSession {
403        session_id,
404        workdir,
405        accessible_workdir,
406        manifest_dir: request.manifest_dir,
407        capsule_name: request.capsule_name,
408        capsule_version: request.capsule_version,
409        capsule_url: String::new(), // set by launch_session after port binding
410        resolved_lock_artifacts,
411        installed_artifacts,
412        inference: request.inference,
413        context: request.context,
414        engine,
415        capsule_component,
416        tool_components,
417        hook_components,
418        allowlisted_tools: request.allowlisted_tools,
419        capability_policy: request.capability_policy,
420        otel_endpoint: request.otel_endpoint,
421        eval_config_json: request.eval_config_json,
422        case_id: request.case_id,
423        dataset_id: request.dataset_id,
424        lifecycle,
425        trace_include_tool_output: request
426            .trace
427            .as_ref()
428            .map(|t| t.include_tool_output)
429            .unwrap_or(false),
430        bind_addr: request.bind_addr,
431        internal_port: request.internal_port,
432        job_id: request.job_id,
433        registry,
434        _epoch_ticker: epoch_ticker,
435    })
436}
437
438/// Instantiates the staged capsule and executes it.
439///
440/// Agent capsules (inference configured) run the built-in native Rust loop.
441/// Script capsules (WASM component present) instantiate and call `murmur:capsule/run#run()`.
442pub fn launch_session(
443    mut staged: StagedSession,
444    on_url: impl FnOnce(&str),
445) -> Result<LaunchResult, RuntimeError> {
446    let network_allow_rules = parse_network_allow_rules(&staged.capability_policy.network_allow)?;
447    let shell_enforcement = sandbox::ShellEnforcement::resolve(&staged.capability_policy)
448        .map_err(RuntimeError::Runtime)?;
449    let inference_env = staged
450        .inference
451        .as_ref()
452        .map(inference_env_pairs)
453        .unwrap_or_default();
454
455    if let Some(ref inference) = staged.inference {
456        let workdir = staged.workdir.clone();
457        let system_prompt = resolve_system_prompt(&staged.manifest_dir, &workdir, inference)?;
458        let session_id = staged.session_id.clone();
459        let accessible_workdir = staged.accessible_workdir.clone();
460        let context_window = resolve_context_window(staged.context.as_ref());
461
462        let run_config = agent::AgentRunConfig {
463            context_window,
464            compaction_threshold: inference
465                .compaction
466                .as_ref()
467                .and_then(|c| c.threshold)
468                .unwrap_or(0.98),
469            compaction_model: inference.compaction.as_ref().and_then(|c| c.model.clone()),
470        };
471
472        // --- Identity and HTTP server setup ---
473
474        let rt = tokio::runtime::Builder::new_multi_thread()
475            .enable_all()
476            .build()
477            .map_err(|e| RuntimeError::Runtime(format!("failed to create tokio runtime: {e}")))?;
478
479        let (tcp_listener, external_port) = rt
480            .block_on(identity::bind_local_port(&staged.bind_addr, staged.internal_port))?;
481
482        let capsule_url = format!("localhost:{external_port}");
483        staged.capsule_url = capsule_url.clone();
484        on_url(&capsule_url);
485
486        let capsule_identity = CapsuleIdentity {
487            capsule_name: staged.capsule_name.clone(),
488            capsule_version: staged.capsule_version.clone(),
489            session_id: session_id.clone(),
490            capsule_url: capsule_url.clone(),
491        };
492
493        // Inject identity env vars into all WASI contexts for this session.
494        let mut all_env = inference_env.clone();
495        all_env.push((
496            "MURMUR_CAPSULE_NAME".to_string(),
497            staged.capsule_name.clone(),
498        ));
499        all_env.push((
500            "MURMUR_CAPSULE_VERSION".to_string(),
501            staged.capsule_version.clone(),
502        ));
503        all_env.push(("MURMUR_SESSION_ID".to_string(), session_id.clone()));
504        all_env.push(("MURMUR_CAPSULE_URL".to_string(), capsule_url.clone()));
505        if let Some(ref job_id) = staged.job_id {
506            all_env.push(("MURMUR_JOB_ID".to_string(), job_id.clone()));
507        }
508
509        murmur_md::write_murmur_md(
510            &workdir,
511            Some(inference),
512            staged.context.as_ref(),
513            has_compaction_hook(&staged.hook_components),
514            &staged.capability_policy,
515            &capsule_identity,
516        );
517
518        sandbox::warn_for_enforcement_tier(shell_enforcement.tier, &workdir, &staged.capability_policy);
519
520        let agent_card = identity::build_agent_card(
521            &capsule_identity,
522            &staged.installed_artifacts,
523            &staged.capability_policy,
524        );
525        let agent_card_json = agent_card.to_string();
526
527        // --- Lifecycle config ---
528        let effective_lifecycle = staged.lifecycle.clone();
529        let conversation_mode = effective_lifecycle.conversation_mode.clone();
530        let queue_capacity = match effective_lifecycle.task_acceptance {
531            TaskAcceptance::Queue => effective_lifecycle.queue_depth,
532            _ => 1,
533        };
534
535        // --- A2A task registry and incoming channel ---
536        let task_registry: Arc<Mutex<TaskRegistry>> = Arc::new(Mutex::new(TaskRegistry::new(
537            effective_lifecycle.queue_depth,
538            effective_lifecycle.task_acceptance.clone(),
539        )));
540        let (task_tx, mut task_rx) = tokio::sync::mpsc::channel::<IncomingTask>(queue_capacity);
541
542        let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
543
544        // SSE broadcast channel and replay buffer for SSE clients
545        let (sse_tx, _) = tokio::sync::broadcast::channel::<std::sync::Arc<String>>(SSE_BROADCAST_CAPACITY);
546        let sse_buffer = std::sync::Arc::new(Mutex::new(SseEventBuffer::new(SSE_REPLAY_CAPACITY)));
547
548        let capsule_name = staged.capsule_name.clone();
549        let capabilities = capability_names(&staged.capability_policy);
550
551        // Capture staged fields that move into the async block
552        let hook_components = staged.hook_components;
553        let tool_components = staged.tool_components;
554        let allowlisted_tools = staged.allowlisted_tools.clone();
555        let installed_artifacts = staged.installed_artifacts;
556        let engine = staged.engine.clone();
557        let capability_policy = staged.capability_policy.clone();
558        let shell_enforcement_for_state = shell_enforcement.clone();
559        let otel_endpoint = staged.otel_endpoint;
560        let eval_config_json = staged.eval_config_json;
561        let case_id = staged.case_id;
562        let dataset_id = staged.dataset_id;
563        let capsule_version = staged.capsule_version.clone();
564        let inference_model = inference.model.clone();
565        let trace_include_tool_output = staged.trace_include_tool_output;
566        let registry_for_pull = Arc::clone(&staged.registry);
567        let lock_path_for_pull = staged.manifest_dir.join("murmur.lock");
568
569        // task.md must live where the agent's own tools are preopened (accessible_workdir),
570        // not the internal `.murmur/<session_id>` bookkeeping dir (workdir) — otherwise this
571        // pre-seed check misses a `--task`-written file and the agent's own read of task.md
572        // 404s even after a task was delivered.
573        let workdir_task_md = accessible_workdir.join("task.md");
574
575        // Extra copies retained for the LaunchResult returned after block_on consumes the others.
576        let session_id_ret = session_id.clone();
577        let workdir_ret = workdir.clone();
578
579        // --- Agent loop inside a LocalSet (handles !Send WasiCtx) ---
580        let loop_result: Result<(), RuntimeError> = rt.block_on(async move {
581            let mut trace = TraceWriter::open(
582                &workdir,
583                session_id.clone(),
584                capsule_name.clone(),
585                capsule_version.clone(),
586                inference_model.clone(),
587                capabilities.clone(),
588                trace_include_tool_output,
589            )
590            .await
591            .map_err(|e| RuntimeError::AgentLoopFailed(format!("failed to open trace.jsonl: {e}")))?;
592
593            let mut otel = OtelEmitter::new(
594                otel_endpoint.clone(),
595                &workdir,
596                capsule_name.clone(),
597                capsule_version.clone(),
598            );
599
600            let local = tokio::task::LocalSet::new();
601            local
602                .run_until(async move {
603                    // Spawn HTTP server as a local task (uses spawn_local for !Send compat)
604                    let server_handle =
605                        tokio::task::spawn_local(identity::serve_http(
606                            tcp_listener,
607                            shutdown_rx,
608                            agent_card_json,
609                            Arc::clone(&task_registry),
610                            task_tx,
611                            effective_lifecycle.task_acceptance.clone(),
612                            sse_tx.clone(),
613                            Arc::clone(&sse_buffer),
614                            conversation_mode.clone(),
615                        ));
616
617                    // Read before `capability_policy` moves into the store state below.
618                    let hook_limits = capability_policy.limits;
619
620                    // Build CapsuleStoreState ONCE — reused across all task iterations
621                    let mut state = CapsuleStoreState {
622                        // Agent capsules have no WASM component of their own, so this state
623                        // never backs a `Store` and this limiter is never registered. It is
624                        // the per-tool limiters built in `dispatch_tool_async` (from
625                        // `capability_policy.limits`) that bound this path's guests.
626                        limits: capability_policy.limits.limiter(),
627                        table: ResourceTable::new(),
628                        wasi: build_wasi_ctx(&accessible_workdir, &all_env, &capability_policy)?,
629                        http: WasiHttpCtx::new(),
630                        http_hooks: NetworkPolicyHooks {
631                            network_allow_rules: network_allow_rules.clone(),
632                        },
633                        network_allow_rules,
634                        inference_env: all_env,
635                        engine: engine.clone(),
636                        workdir: workdir.clone(),
637                        accessible_workdir: accessible_workdir.clone(),
638                        tool_components,
639                        allowlisted_tools,
640                        installed_artifacts,
641                        session_id: session_id.clone(),
642                        pending_a2a_events: Vec::new(),
643                        capability_policy,
644                        shell_enforcement: shell_enforcement_for_state,
645                        current_traceparent: None,
646                        a2a_task_registry: Some(Arc::clone(&task_registry)),
647                        a2a_sse: Some((sse_tx.clone(), Arc::clone(&sse_buffer))),
648                        a2a_task_id: None,
649                        input_timeout_secs: effective_lifecycle.input_timeout_secs,
650                        a2a_chunk_event_id: Arc::new(AtomicU64::new(u64::MAX / 4)),
651                        a2a_chunks_emitted: Arc::new(AtomicBool::new(false)),
652                        registry: registry_for_pull,
653                        lock_path: lock_path_for_pull,
654                        driver_continuation_id: None,
655                        driver_continuation_context_id: None,
656                        driver_continuation_acked_len: 0,
657                    };
658
659                    // Create hooks ONCE — session_start fires once per capsule lifetime
660                    let mut hooks = HookRuntime::new(
661                        &engine,
662                        &workdir,
663                        &accessible_workdir,
664                        hook_components,
665                        SessionContextData {
666                            capsule_name: capsule_name.clone(),
667                            capsule_version: capsule_version.clone(),
668                            session_id: session_id.clone(),
669                            model: inference_model.clone(),
670                            capabilities: capabilities.clone(),
671                        },
672                        HookEnvVars {
673                            otel_endpoint: otel_endpoint.as_deref(),
674                            eval_config_json: eval_config_json.as_deref(),
675                            case_id: case_id.as_deref(),
676                            dataset_id: dataset_id.as_deref(),
677                        },
678                        hook_limits,
679                    )
680                    .await?;
681
682                    // on-session-start fires ONCE per launch, before the task loop —
683                    // regardless of task_acceptance. For queue capsules this is the single
684                    // session boundary that the per-task on-task-start events nest inside.
685                    hooks.emit(&workdir, HookEvent::SessionStart).await;
686
687                    let final_loop_result: Result<(), RuntimeError>;
688
689                    // ── LOOP BODY STARTS HERE ──────────────────────────────
690                    // Each iteration processes one task. Single/none modes break after
691                    // the first iteration; queue+sleep iterates until channel closes.
692                    'task_loop: loop {
693                        // ── WAIT FOR NEXT TASK ──
694                        let incoming: IncomingTask = match effective_lifecycle.task_acceptance {
695                            TaskAcceptance::None => {
696                                // Does not accept incoming tasks; run from task.md if present
697                                if workdir_task_md.exists() {
698                                    let task_id = format!("tsk_{}", uuid::Uuid::now_v7().simple());
699                                    let context_id = format!("ctx_{}", uuid::Uuid::now_v7().simple());
700                                    let bytes = tokio::fs::metadata(&workdir_task_md)
701                                        .await
702                                        .map(|m| m.len())
703                                        .unwrap_or(0);
704                                    let _ = trace
705                                        .write_task_start(
706                                            &task_id, &context_id, "task_md", bytes,
707                                        )
708                                        .await;
709                                    hooks
710                                        .emit(
711                                            &workdir,
712                                            HookEvent::TaskStart {
713                                                task_id: task_id.clone(),
714                                                context_id: context_id.clone(),
715                                                source: "task_md".to_string(),
716                                                input_bytes: bytes,
717                                            },
718                                        )
719                                        .await;
720                                    otel.begin_session(None);
721                                    state.current_traceparent = otel.outgoing_traceparent();
722                                    let result = agent::run_agent_loop(
723                                        &mut state,
724                                        &workdir,
725                                        inference,
726                                        system_prompt,
727                                        run_config,
728                                        &mut hooks,
729                                        &mut trace,
730                                        &mut otel,
731                                        None,
732                                        None,
733                                        &accessible_workdir,
734                                        &capsule_name,
735                                        &capsule_version,
736                                        conversation_mode.clone(),
737                                        Some(context_id.clone()),
738                                    )
739                                    .await;
740                                    let exit_str =
741                                        if result.is_ok() { "ok" } else { "failed" };
742                                    let _ = trace.write_task_end(&task_id, exit_str).await;
743                                    hooks
744                                        .emit(
745                                            &workdir,
746                                            HookEvent::TaskEnd {
747                                                task_id: task_id.clone(),
748                                                exit_status: exit_str.to_string(),
749                                            },
750                                        )
751                                        .await;
752                                    final_loop_result = result;
753                                    break 'task_loop;
754                                } else {
755                                    final_loop_result = Ok(());
756                                    break 'task_loop;
757                                }
758                            }
759                            TaskAcceptance::Single | TaskAcceptance::Queue => {
760                                if workdir_task_md.exists() {
761                                    // Backward compat: existing task.md → single run, no A2A
762                                    let task_id = format!("tsk_{}", uuid::Uuid::now_v7().simple());
763                                    let context_id = format!("ctx_{}", uuid::Uuid::now_v7().simple());
764                                    let bytes = tokio::fs::metadata(&workdir_task_md)
765                                        .await
766                                        .map(|m| m.len())
767                                        .unwrap_or(0);
768                                    let _ = trace
769                                        .write_task_start(
770                                            &task_id, &context_id, "task_md", bytes,
771                                        )
772                                        .await;
773                                    hooks
774                                        .emit(
775                                            &workdir,
776                                            HookEvent::TaskStart {
777                                                task_id: task_id.clone(),
778                                                context_id: context_id.clone(),
779                                                source: "task_md".to_string(),
780                                                input_bytes: bytes,
781                                            },
782                                        )
783                                        .await;
784                                    otel.begin_session(None);
785                                    state.current_traceparent = otel.outgoing_traceparent();
786                                    let result = agent::run_agent_loop(
787                                        &mut state,
788                                        &workdir,
789                                        inference,
790                                        system_prompt.clone(),
791                                        run_config.clone(),
792                                        &mut hooks,
793                                        &mut trace,
794                                        &mut otel,
795                                        None,
796                                        None,
797                                        &accessible_workdir,
798                                        &capsule_name,
799                                        &capsule_version,
800                                        conversation_mode.clone(),
801                                        Some(context_id.clone()),
802                                    )
803                                    .await;
804                                    let exit_str =
805                                        if result.is_ok() { "ok" } else { "failed" };
806                                    let _ = trace.write_task_end(&task_id, exit_str).await;
807                                    hooks
808                                        .emit(
809                                            &workdir,
810                                            HookEvent::TaskEnd {
811                                                task_id: task_id.clone(),
812                                                exit_status: exit_str.to_string(),
813                                            },
814                                        )
815                                        .await;
816                                    let _ = trace.flush().await;
817                                    if matches!(effective_lifecycle.task_acceptance, TaskAcceptance::Single) || result.is_err() {
818                                        final_loop_result = result;
819                                        break 'task_loop;
820                                    }
821                                    // Queue mode: remove task.md so the next iteration
822                                    // falls through to task_rx.recv() for queued subtasks.
823                                    let _ = tokio::fs::remove_file(&workdir_task_md).await;
824                                    continue 'task_loop;
825                                }
826                                // Wait for the next task from the mpsc channel.
827                                // queue+sleep mode waits indefinitely — no self-terminating
828                                // timeout. The host (mur-roost) is responsible for shutdown.
829                                // All other modes apply MURMUR_A2A_TIMEOUT_SECS (default 30 s).
830                                let is_queue_sleep =
831                                    matches!(effective_lifecycle.task_acceptance, TaskAcceptance::Queue)
832                                    && matches!(effective_lifecycle.after_task, AfterTask::Sleep);
833
834                                if is_queue_sleep {
835                                    match task_rx.recv().await {
836                                        Some(task) => task,
837                                        None => {
838                                            final_loop_result = Ok(());
839                                            break 'task_loop;
840                                        }
841                                    }
842                                } else {
843                                    let idle_timeout_secs: u64 =
844                                        std::env::var("MURMUR_A2A_TIMEOUT_SECS")
845                                            .ok()
846                                            .and_then(|v| v.parse().ok())
847                                            .unwrap_or(30);
848                                    match tokio::time::timeout(
849                                        std::time::Duration::from_secs(idle_timeout_secs),
850                                        task_rx.recv(),
851                                    )
852                                    .await
853                                    {
854                                        Ok(Some(task)) => task,
855                                        Ok(None) => {
856                                            final_loop_result = Ok(());
857                                            break 'task_loop;
858                                        }
859                                        Err(_elapsed) => {
860                                            eprintln!("[capsule-runtime] no A2A message received within timeout; running with empty task");
861                                            otel.begin_session(None);
862                                            state.current_traceparent = otel.outgoing_traceparent();
863                                            final_loop_result = agent::run_agent_loop(
864                                                &mut state,
865                                                &workdir,
866                                                inference,
867                                                system_prompt,
868                                                run_config,
869                                                &mut hooks,
870                                                &mut trace,
871                                                &mut otel,
872                                                None,
873                                                None,
874                                                &accessible_workdir,
875                                                &capsule_name,
876                                                &capsule_version,
877                                                conversation_mode.clone(),
878                                                None,
879                                            )
880                                            .await;
881                                            break 'task_loop;
882                                        }
883                                    }
884                                }
885                            }
886                        };
887
888                        // ── ACTIVATE TASK ──
889                        {
890                            let mut reg = task_registry.lock().unwrap();
891                            reg.start_task(incoming.task_id.clone(), incoming.context_id.clone());
892                        }
893                        if let Err(e) =
894                            tokio::fs::write(&workdir_task_md, &incoming.message_text).await
895                        {
896                            eprintln!(
897                                "[capsule-runtime] failed to write A2A message to task.md: {e}"
898                            );
899                        }
900                        let _ = trace
901                            .write_a2a_task_received(
902                                &incoming.task_id,
903                                &incoming.context_id,
904                                &incoming.message_id,
905                                incoming.traceparent.as_deref(),
906                            )
907                            .await;
908                        let _ = trace
909                            .write_task_start(
910                                &incoming.task_id,
911                                &incoming.context_id,
912                                "a2a",
913                                incoming.message_text.len() as u64,
914                            )
915                            .await;
916                        hooks
917                            .emit(
918                                &workdir,
919                                HookEvent::TaskStart {
920                                    task_id: incoming.task_id.clone(),
921                                    context_id: incoming.context_id.clone(),
922                                    source: "a2a".to_string(),
923                                    input_bytes: incoming.message_text.len() as u64,
924                                },
925                            )
926                            .await;
927
928                        // ── RUN AGENT LOOP ──
929                        otel.begin_session(incoming.traceparent.as_deref());
930                        state.current_traceparent = otel.outgoing_traceparent();
931                        state.a2a_task_id = Some(incoming.task_id.clone());
932                        let loop_result = agent::run_agent_loop(
933                            &mut state,
934                            &workdir,
935                            inference,
936                            system_prompt.clone(),
937                            run_config.clone(),
938                            &mut hooks,
939                            &mut trace,
940                            &mut otel,
941                            Some(incoming.task_id.clone()),
942                            Some((sse_tx.clone(), Arc::clone(&sse_buffer))),
943                            &accessible_workdir,
944                            &capsule_name,
945                            &capsule_version,
946                            conversation_mode.clone(),
947                            Some(incoming.context_id.clone()),
948                        )
949                        .await;
950
951                        // ── POST-LOOP SLOT UPDATE ──
952                        let exit_state = if loop_result.is_ok() {
953                            TaskState::Completed
954                        } else {
955                            TaskState::Failed
956                        };
957                        let exit_str = if loop_result.is_ok() { "ok" } else { "failed" };
958                        let _ = trace.write_task_end(&incoming.task_id, exit_str).await;
959                        hooks
960                            .emit(
961                                &workdir,
962                                HookEvent::TaskEnd {
963                                    task_id: incoming.task_id.clone(),
964                                    exit_status: exit_str.to_string(),
965                                },
966                            )
967                            .await;
968                        let _ = trace.flush().await;
969                        {
970                            let mut reg = task_registry.lock().unwrap();
971                            reg.finish_task(exit_state);
972                        }
973
974                        // ── DECIDE WHETHER TO CONTINUE ──
975                        match effective_lifecycle.after_task {
976                            AfterTask::Exit => {
977                                final_loop_result = loop_result;
978                                break 'task_loop;
979                            }
980                            AfterTask::Sleep => {
981                                if matches!(
982                                    effective_lifecycle.task_acceptance,
983                                    TaskAcceptance::Single
984                                ) {
985                                    // single mode always exits after one task
986                                    final_loop_result = loop_result;
987                                    break 'task_loop;
988                                }
989                                // Queue+sleep: clear task.md and wait for next task
990                                let _ = tokio::fs::remove_file(&workdir_task_md).await;
991                                continue 'task_loop;
992                            }
993                        }
994                    }
995                    // ── LOOP BODY ENDS HERE ────────────────────────────────
996
997                    // on-session-end fires ONCE per launch, after the task loop exits.
998                    // total_turns is the whole-launch aggregate accumulated by HookRuntime
999                    // (one per Inference event across every task), and exit_status reflects
1000                    // the loop's final result.
1001                    let session_exit_status =
1002                        if final_loop_result.is_ok() { "ok" } else { "failed" };
1003                    let session_total_turns = hooks.total_turns();
1004                    hooks
1005                        .emit(
1006                            &workdir,
1007                            HookEvent::SessionEnd {
1008                                total_turns: session_total_turns,
1009                                exit_status: session_exit_status.to_string(),
1010                            },
1011                        )
1012                        .await;
1013
1014                    let _ = trace.write_session_end_if_not_ended("failed").await;
1015                    otel.emit_session_end_if_not_ended("failed").await;
1016                    trace.flush().await.map_err(|e| {
1017                        RuntimeError::AgentLoopFailed(format!("failed to flush trace: {e}"))
1018                    })?;
1019
1020                    // Signal HTTP server to shut down, then wait for it
1021                    let _ = shutdown_tx.send(());
1022                    let _ = server_handle.await;
1023
1024                    final_loop_result
1025                })
1026                .await
1027        });
1028
1029        loop_result?;
1030
1031        return Ok(LaunchResult {
1032            session_id: session_id_ret,
1033            workdir: workdir_ret,
1034        });
1035    }
1036
1037    // Script capsule path — requires a compiled WASM component.
1038    let capsule_component = staged
1039        .capsule_component
1040        .ok_or(RuntimeError::CapsuleCompile(
1041            "non-agent capsule has no compiled component".to_string(),
1042        ))?;
1043
1044    let mut linker = Linker::new(&staged.engine);
1045    wasmtime_wasi::p2::add_to_linker_async(&mut linker)
1046        .map_err(|err| RuntimeError::Runtime(err.to_string()))?;
1047    wasmtime_wasi_http::p2::add_only_http_to_linker_sync(&mut linker)
1048        .map_err(|err| RuntimeError::Runtime(err.to_string()))?;
1049
1050    // `murmur:tool-registry/invoke@0.1.0` is the one host-provided interface a
1051    // capsule guest imports. Register it by hand against the same `invoke::Host`
1052    // impl on `CapsuleStoreState` (the generated `invoke::add_to_linker` would do
1053    // the same, but this keeps the registration alongside the tool-side ones).
1054    {
1055        let iface = WIT_TOOL_REGISTRY_IFACE;
1056        linker
1057            .instance(iface)
1058            .map_err(|err| RuntimeError::Runtime(err.to_string()))?
1059            .func_wrap(
1060                "invoke",
1061                |mut store: wasmtime::StoreContextMut<'_, CapsuleStoreState>,
1062                 (tool_name, input): (String, murmur::tool::run::ToolInput)|
1063                 -> wasmtime::Result<(Result<murmur::tool::run::ToolResult, String>,)> {
1064                    Ok((invoke::Host::invoke(store.data_mut(), tool_name, input),))
1065                },
1066            )
1067            .map_err(|err| RuntimeError::Runtime(err.to_string()))?;
1068    }
1069    manage::add_to_linker::<_, HasSelf<_>>(&mut linker, |state| state)
1070        .map_err(|err| RuntimeError::Runtime(err.to_string()))?;
1071    send::add_to_linker::<_, HasSelf<_>>(&mut linker, |state| state)
1072        .map_err(|err| RuntimeError::Runtime(err.to_string()))?;
1073
1074    // Read before `staged.capability_policy` moves into the store state below.
1075    let capsule_limits = staged.capability_policy.limits;
1076
1077    let state = CapsuleStoreState {
1078        table: ResourceTable::new(),
1079        wasi: build_wasi_ctx(
1080            &staged.accessible_workdir,
1081            &inference_env,
1082            &staged.capability_policy,
1083        )?,
1084        http: WasiHttpCtx::new(),
1085        http_hooks: NetworkPolicyHooks {
1086            network_allow_rules: network_allow_rules.clone(),
1087        },
1088        network_allow_rules,
1089        inference_env,
1090        engine: staged.engine.clone(),
1091        workdir: staged.workdir.clone(),
1092        accessible_workdir: staged.accessible_workdir.clone(),
1093        tool_components: staged.tool_components,
1094        allowlisted_tools: staged.allowlisted_tools,
1095        installed_artifacts: staged.installed_artifacts,
1096        session_id: staged.session_id.clone(),
1097        pending_a2a_events: Vec::new(),
1098        capability_policy: staged.capability_policy,
1099        shell_enforcement: shell_enforcement.clone(),
1100        current_traceparent: None,
1101        a2a_task_registry: None,
1102        a2a_sse: None,
1103        a2a_task_id: None,
1104        input_timeout_secs: None,
1105        a2a_chunk_event_id: Arc::new(AtomicU64::new(u64::MAX / 4)),
1106        a2a_chunks_emitted: Arc::new(AtomicBool::new(false)),
1107        registry: Arc::clone(&staged.registry),
1108        lock_path: staged.manifest_dir.join("murmur.lock"),
1109        driver_continuation_id: None,
1110        driver_continuation_context_id: None,
1111        driver_continuation_acked_len: 0,
1112        limits: capsule_limits.limiter(),
1113    };
1114
1115    let mut store = Store::new(&staged.engine, state);
1116    // Must precede instantiation: `Store::limiter` latches the instance/table/memory counts
1117    // the store enforces, and instantiation itself allocates against them.
1118    store.limiter(|state| &mut state.limits);
1119
1120    // Script capsule uses a multi-thread runtime so block_in_place works inside send::Host::send
1121    let rt = tokio::runtime::Builder::new_multi_thread()
1122        .enable_all()
1123        .build()
1124        .map_err(|e| RuntimeError::Runtime(format!("failed to create tokio runtime: {e}")))?;
1125
1126    rt.block_on(async {
1127        // Instantiation runs the guest's own start/init code, so it gets a deadline too.
1128        store.set_epoch_deadline(capsule_limits.deadline_ticks());
1129        let instantiated = linker
1130            .instantiate_async(&mut store, &capsule_component)
1131            .await;
1132        let instance = match instantiated {
1133            Ok(instance) => instance,
1134            Err(err) => return Err(capsule_guest_error(&err, &store.data().limits)),
1135        };
1136
1137        let capsule_iface =
1138            resolve_versioned_iface(&instance, &mut store, WIT_CAPSULE_IFACE_VERSIONED)
1139                .ok_or(RuntimeError::CapsuleExportMissing)?;
1140        let capsule_run = instance
1141            .get_export_index(&mut store, Some(&capsule_iface), "run")
1142            .and_then(|idx| instance.get_func(&mut store, idx))
1143            .ok_or(RuntimeError::CapsuleExportMissing)?;
1144
1145        let run = capsule_run
1146            .typed::<(), ()>(&store)
1147            .map_err(|err| RuntimeError::Runtime(err.to_string()))?;
1148
1149        // Fresh budget for `run` itself, so instantiation cost cannot eat into it.
1150        store.set_epoch_deadline(capsule_limits.deadline_ticks());
1151        let called = run.call_async(&mut store, ()).await;
1152        if let Err(err) = called {
1153            return Err(capsule_guest_error(&err, &store.data().limits));
1154        }
1155
1156        let returned = run.post_return_async(&mut store).await;
1157        match returned {
1158            Ok(()) => Ok(()),
1159            Err(err) => Err(capsule_guest_error(&err, &store.data().limits)),
1160        }
1161    })?;
1162
1163    // Drain any buffered a2a_send trace events from the capsule run.
1164    let pending = std::mem::take(&mut store.data_mut().pending_a2a_events);
1165    if !pending.is_empty() {
1166        rt.block_on(async {
1167            if let Ok(mut trace) = TraceWriter::open(
1168                &staged.workdir,
1169                staged.session_id.clone(),
1170                staged.capsule_name.clone(),
1171                staged.capsule_version.clone(),
1172                String::new(),
1173                Vec::new(),
1174                false,
1175            )
1176            .await
1177            {
1178                for (peer_url, message_id, task_id, context_id, traceparent) in pending {
1179                    let _ = trace
1180                        .write_a2a_send(
1181                            &peer_url,
1182                            &message_id,
1183                            &task_id,
1184                            &context_id,
1185                            traceparent.as_deref(),
1186                        )
1187                        .await;
1188                }
1189                let _ = trace.flush().await;
1190            }
1191        });
1192    }
1193
1194    // Notify the caller that the capsule has started (no URL for script capsules).
1195    on_url("");
1196
1197    Ok(LaunchResult {
1198        session_id: staged.session_id,
1199        workdir: staged.workdir,
1200    })
1201}
1202
1203fn resolve_system_prompt(
1204    manifest_dir: &Path,
1205    workdir: &Path,
1206    inference: &murmur_artifact::InferenceConfig,
1207) -> Result<Option<String>, RuntimeError> {
1208    if let Some(prompt) = inference.system_prompt.as_ref() {
1209        return Ok(Some(prompt.clone()));
1210    }
1211
1212    if let Some(path) = inference.system_prompt_file.as_ref() {
1213        let prompt_path = manifest_dir.join(path);
1214        return fs::read_to_string(&prompt_path)
1215            .map(Some)
1216            .map_err(|source| RuntimeError::SystemPromptFileRead {
1217                path: prompt_path.display().to_string(),
1218                source,
1219            });
1220    }
1221
1222    if let Some(art_name) = inference.system_prompt_artifact.as_ref() {
1223        let skill_path = workdir.join("tools").join(art_name).join("skill.md");
1224        return fs::read_to_string(&skill_path)
1225            .map(Some)
1226            .map_err(|source| RuntimeError::SystemPromptArtifactRead {
1227                name: art_name.clone(),
1228                source,
1229            });
1230    }
1231
1232    Ok(None)
1233}
1234
1235fn capability_names(policy: &CapabilityPolicy) -> Vec<String> {
1236    let mut names = Vec::new();
1237    if !policy.network_allow.is_empty() {
1238        names.push("network".to_string());
1239    }
1240    if policy.filesystem_scope.is_some() {
1241        names.push("filesystem".to_string());
1242    }
1243    if !policy.shell_allow.is_empty() {
1244        names.push("shell".to_string());
1245    }
1246    names
1247}
1248
1249/// Core message for the bash+network bypass warning (finding C-7 in
1250/// `murmur-security-assessment.md`) — shared verbatim between the stderr line and the
1251/// `logs/bootstrap.log` line so the two never drift apart.
1252const BASH_NETWORK_BYPASS_WARNING: &str = "capabilities.shell.allow includes \"bash\" and \
1253capabilities.network.allow is non-empty, but network.allow does not constrain bash's own \
1254outbound connections on this platform.";
1255
1256/// Warns (non-fatal — matches the warn-and-continue convention of `murmur_md::write_murmur_md`
1257/// and the murmur.yaml-copy warning above) when `policy.shell_allow` contains the exact binary
1258/// name `"bash"` and `policy.network_allow` is non-empty. The check is an exact match on the
1259/// literal `"bash"`, not `shell::is_shell_interpreter` (which also matches `sh`/`zsh`/`fish`/
1260/// `dash`/`ksh`) — a deliberate scope limit matching C-7's own scoping, not an oversight.
1261pub(crate) fn warn_if_bash_network_bypass(workdir: &Path, policy: &CapabilityPolicy) {
1262    let has_bash = policy.shell_allow.iter().any(|binary| binary == "bash");
1263    if has_bash && !policy.network_allow.is_empty() {
1264        let link = security_warning_link(W_SEC_003);
1265        eprintln!("[capsule-runtime] warning[{W_SEC_003}]: {BASH_NETWORK_BYPASS_WARNING} ({link})");
1266        agent::append_bootstrap_log(
1267            workdir,
1268            &format!("[capability-policy] warning[{W_SEC_003}]: {BASH_NETWORK_BYPASS_WARNING} ({link})"),
1269        );
1270    }
1271}
1272
1273/// Builds the single `Engine` a session runs every guest on.
1274///
1275/// `epoch_interruption` compiles a deadline check into wasm loop back-edges and function
1276/// entries. It only arms the mechanism: a deadline fires solely when some store has called
1277/// `set_epoch_deadline` *and* an [`EpochTicker`] is advancing this engine's epoch, both of
1278/// which `stage_session` sets up.
1279fn build_engine() -> Result<Engine, RuntimeError> {
1280    let mut config = Config::new();
1281    config.wasm_component_model(true);
1282    config.async_support(true);
1283    config.epoch_interruption(true);
1284    Engine::new(&config).map_err(|err| RuntimeError::Runtime(err.to_string()))
1285}
1286
1287/// Map a failed capsule-guest invocation onto the most specific `RuntimeError` available,
1288/// so a deadline or resource-limit trap is distinguishable from a guest panic rather than
1289/// collapsing into the generic `CapsuleTrap` bucket.
1290fn capsule_guest_error(error: &wasmtime::Error, limiter: &ExecutionLimiter) -> RuntimeError {
1291    match classify_guest_failure(error, limiter) {
1292        GuestFailure::DeadlineExceeded { seconds } => {
1293            RuntimeError::CapsuleDeadlineExceeded { seconds }
1294        }
1295        GuestFailure::ResourceLimit { message } => RuntimeError::CapsuleResourceLimit { message },
1296        GuestFailure::Other => RuntimeError::CapsuleTrap(error.to_string()),
1297    }
1298}
1299
1300fn map_registry_error(name: &str, version: &str, error: RegistryError) -> RuntimeError {
1301    match error {
1302        RegistryError::NotFound { .. } => RuntimeError::artifact_not_found(name, version),
1303        RegistryError::IntegrityMismatch { .. } => {
1304            RuntimeError::artifact_integrity_failed(name, version)
1305        }
1306        other => RuntimeError::Runtime(other.to_string()),
1307    }
1308}
1309
1310/// Build a guest's WASI context with a scoped environment: no host inheritance, only what
1311/// `policy.env_allow` declares (credential-filtered) plus the runtime's own `extra_env`.
1312///
1313/// `extra_env` is applied last so a manifest cannot shadow a runtime-owned `MURMUR_*` value
1314/// by allowlisting its name.
1315fn build_wasi_ctx(
1316    workdir: &Path,
1317    extra_env: &[(String, String)],
1318    policy: &CapabilityPolicy,
1319) -> Result<WasiCtx, RuntimeError> {
1320    let mut builder = WasiCtxBuilder::new();
1321    builder.inherit_stdio();
1322    for (key, value) in build_wasi_env_allowlist(policy) {
1323        builder.env(key, value);
1324    }
1325    for (key, value) in extra_env {
1326        builder.env(key, value);
1327    }
1328    builder
1329        .preopened_dir(workdir, ".", DirPerms::all(), FilePerms::all())
1330        .map_err(|err| RuntimeError::wasi(workdir.to_path_buf(), err.to_string()))?;
1331
1332    Ok(builder.build())
1333}
1334
1335fn inference_env_pairs(inference: &murmur_artifact::InferenceConfig) -> Vec<(String, String)> {
1336    let mut pairs = vec![
1337        (
1338            "MURMUR_INFERENCE_TRANSPORT".to_string(),
1339            inference.transport.clone(),
1340        ),
1341        (
1342            "MURMUR_INFERENCE_ENDPOINT".to_string(),
1343            inference.endpoint.clone().unwrap_or_default(),
1344        ),
1345        (
1346            "MURMUR_INFERENCE_MODEL".to_string(),
1347            inference.model.clone(),
1348        ),
1349        (
1350            "MURMUR_INFERENCE_DRIVER".to_string(),
1351            inference.driver.as_ref().map(|d| d.artifact.clone()).unwrap_or_default(),
1352        ),
1353    ];
1354
1355    if let Some(api_key) = inference.api_key.as_ref() {
1356        pairs.push(("MURMUR_INFERENCE_API_KEY".to_string(), api_key.clone()));
1357    }
1358
1359    if let Some(config) = inference.driver.as_ref().and_then(|d| d.config.as_ref()) {
1360        pairs.push(("MURMUR_INFERENCE_DRIVER_CONFIG".to_string(), config.clone()));
1361    }
1362
1363    pairs
1364}
1365
1366/// Suspend an A2A task in the InputRequired state, await external input, and resume.
1367///
1368/// Called from the `murmur:task/task#request-input` host function registered in the tool linker.
1369/// Emits SSE events for the state transitions. Returns `Err` on timeout (traps the WASM guest).
1370pub(crate) async fn request_input_impl(
1371    task_id: String,
1372    prompt: String,
1373    task_registry: Arc<Mutex<TaskRegistry>>,
1374    sse: Option<(SseBroadcast, Arc<Mutex<SseEventBuffer>>)>,
1375    input_timeout_secs: Option<u64>,
1376) -> wasmtime::Result<String> {
1377    use std::time::Duration;
1378    use tokio::sync::oneshot;
1379
1380    let (tx, rx) = oneshot::channel::<String>();
1381    {
1382        let mut reg = task_registry.lock().unwrap();
1383        reg.set_input_required(&task_id, prompt.clone(), tx)
1384            .map_err(|e| wasmtime::Error::msg(format!("request-input: {e}")))?;
1385    }
1386
1387    // Use high IDs to avoid overlapping with agent-loop SSE event IDs (which start at 0).
1388    let mut sse_event_id: u64 = u64::MAX / 2;
1389    emit_sse(
1390        &sse,
1391        &mut sse_event_id,
1392        "status",
1393        &TaskStatusUpdateEvent {
1394            id: task_id.clone(),
1395            context_id: None,
1396            status: StreamStatus { state: "input-required".into(), message: prompt.clone(), response: None },
1397            r#final: false,
1398        },
1399    )
1400    .await;
1401
1402    let result = match input_timeout_secs {
1403        Some(secs) => tokio::time::timeout(Duration::from_secs(secs), rx)
1404            .await
1405            .map_err(|_| ())
1406            .and_then(|r| r.map_err(|_| ())),
1407        None => rx.await.map_err(|_| ()),
1408    };
1409
1410    match result {
1411        Ok(text) => {
1412            emit_sse(
1413                &sse,
1414                &mut sse_event_id,
1415                "status",
1416                &TaskStatusUpdateEvent {
1417                    id: task_id.clone(),
1418                    context_id: None,
1419                    status: StreamStatus { state: "working".into(), message: "resumed".into(), response: None },
1420                    r#final: false,
1421                },
1422            )
1423            .await;
1424            Ok(text)
1425        }
1426        Err(()) => {
1427            {
1428                let mut reg = task_registry.lock().unwrap();
1429                reg.finish_task(TaskState::Failed);
1430            }
1431            emit_sse(
1432                &sse,
1433                &mut sse_event_id,
1434                "status",
1435                &TaskStatusUpdateEvent {
1436                    id: task_id.clone(),
1437                    context_id: None,
1438                    status: StreamStatus {
1439                        state: "failed".into(),
1440                        message: "input-timeout".into(),
1441                        response: None,
1442                    },
1443                    r#final: true,
1444                },
1445            )
1446            .await;
1447            Err(wasmtime::Error::msg("input-timeout"))
1448        }
1449    }
1450}
1451
1452pub(crate) struct NetworkPolicyHooks {
1453    pub(crate) network_allow_rules: Vec<NetworkAllowRule>,
1454}
1455
1456impl WasiHttpHooks for NetworkPolicyHooks {
1457    fn send_request(
1458        &mut self,
1459        request: hyper::Request<HyperOutgoingBody>,
1460        config: OutgoingRequestConfig,
1461    ) -> HttpResult<HostFutureIncomingResponse> {
1462        let target =
1463            RequestTarget::from_request(request.uri(), config.use_tls).ok_or_else(|| {
1464                wasmtime_wasi_http::p2::HttpError::from(WasiHttpErrorCode::HttpRequestDenied)
1465            })?;
1466
1467        if !self
1468            .network_allow_rules
1469            .iter()
1470            .any(|rule| rule.matches(&target))
1471        {
1472            return Err(wasmtime_wasi_http::p2::HttpError::from(
1473                WasiHttpErrorCode::HttpRequestDenied,
1474            ));
1475        }
1476
1477        Ok(wasmtime_wasi_http::p2::default_send_request(
1478            request, config,
1479        ))
1480    }
1481}
1482
1483pub(crate) struct CapsuleStoreState {
1484    /// Resource limiter for this store, registered via `Store::limiter`. Also the record of
1485    /// any growth request it denied, which `classify_guest_failure` reads to tell a
1486    /// limit trap apart from a guest panic.
1487    pub(crate) limits: ExecutionLimiter,
1488    pub(crate) table: ResourceTable,
1489    pub(crate) wasi: WasiCtx,
1490    pub(crate) http: WasiHttpCtx,
1491    pub(crate) http_hooks: NetworkPolicyHooks,
1492    pub(crate) network_allow_rules: Vec<NetworkAllowRule>,
1493    pub(crate) inference_env: Vec<(String, String)>,
1494    pub(crate) engine: Engine,
1495    pub(crate) workdir: PathBuf,
1496    pub(crate) accessible_workdir: PathBuf,
1497    pub(crate) tool_components: HashMap<String, Component>,
1498    pub(crate) allowlisted_tools: HashSet<String>,
1499    pub(crate) installed_artifacts: Vec<InstalledArtifactSummary>,
1500    pub(crate) session_id: String,
1501    /// Buffered outgoing A2A send events — drained into trace.jsonl after the capsule run.
1502    /// (peer_url, message_id, task_id, context_id, traceparent)
1503    pub(crate) pending_a2a_events: Vec<(String, String, String, String, Option<String>)>,
1504    pub(crate) capability_policy: CapabilityPolicy,
1505    /// Host-detected kernel enforcement tier + resolved network allowlist IPs for this
1506    /// session's shell subprocesses. Kept separate from `CapabilityPolicy` (which stays
1507    /// purely manifest-derived) since this is host-probed data, not manifest data.
1508    pub(crate) shell_enforcement: sandbox::ShellEnforcement,
1509    /// W3C traceparent for outgoing murmur:message/send calls — set by the runtime loop
1510    /// after each begin_session so the active session span propagates to peer capsules.
1511    pub(crate) current_traceparent: Option<String>,
1512    // ── A2A request-input support ────────────────────────────────────────────────
1513    /// Shared task registry — Some in A2A mode, None for script capsules.
1514    pub(crate) a2a_task_registry: Option<Arc<Mutex<TaskRegistry>>>,
1515    /// SSE broadcast channel — Some in A2A mode, None for script capsules.
1516    pub(crate) a2a_sse: Option<(SseBroadcast, Arc<Mutex<SseEventBuffer>>)>,
1517    /// Active A2A task ID — set per-task before run_agent_loop, None outside A2A context.
1518    pub(crate) a2a_task_id: Option<String>,
1519    /// Optional input timeout from lifecycle config.
1520    pub(crate) input_timeout_secs: Option<u64>,
1521    // ── A2A streaming text chunk support ─────────────────────────────────────────
1522    /// Monotonically increasing event ID counter for text chunk SSE events.
1523    /// Starts at u64::MAX/4 to avoid overlap with agent-loop status/artifact IDs (from 0).
1524    pub(crate) a2a_chunk_event_id: Arc<AtomicU64>,
1525    /// Set to true when any emit-chunk call is made during the current driver dispatch.
1526    /// Reset to false before each driver dispatch in run_agent_loop.
1527    pub(crate) a2a_chunks_emitted: Arc<AtomicBool>,
1528    /// Registry used to resolve additional artifacts requested at runtime via `manage.pull()`.
1529    pub(crate) registry: Arc<dyn Registry>,
1530    /// Path to this session's `murmur.lock`, consulted and updated by `manage.pull()`.
1531    pub(crate) lock_path: PathBuf,
1532    // ── Stateful-driver continuation ───────────────────────────────────
1533    /// Continuation id most recently returned by the inference driver via
1534    /// `tool-result.metadata["continuation_id"]`, held for the current session loop.
1535    /// `None` means "no continuation held" — every Turn resends the full `messages`
1536    /// array (the behavior of every driver shipped today, which never sets the key).
1537    pub(crate) driver_continuation_id: Option<String>,
1538    /// The `context_id` under which `driver_continuation_id` was established. Used as a
1539    /// cross-context safety guard: a held continuation is only reused when the current
1540    /// Turn's `context_id` matches this, so a driver-side continuation from one
1541    /// conversation is never carried into an unrelated Task within the same session loop.
1542    pub(crate) driver_continuation_context_id: Option<String>,
1543    /// Number of leading entries of the logical `messages` array the driver has already
1544    /// acknowledged. On an incremental Turn the host transmits only `messages[acked_len..]`.
1545    pub(crate) driver_continuation_acked_len: usize,
1546}
1547
1548impl CapsuleStoreState {
1549    /// Returns the held continuation `(id, acked_len)` iff a continuation is currently held
1550    /// **and** was established under `context_id`. The context-id guard is required for
1551    /// correctness: without it, an incremental send against a driver-side continuation from
1552    /// an unrelated conversation would silently corrupt the driver's context.
1553    pub(crate) fn active_continuation(&self, context_id: Option<&str>) -> Option<(&str, usize)> {
1554        let id = self.driver_continuation_id.as_deref()?;
1555        if self.driver_continuation_context_id.as_deref() != context_id {
1556            return None;
1557        }
1558        Some((id, self.driver_continuation_acked_len))
1559    }
1560
1561    /// Persist the continuation id a driver returned on this Turn, scoped to `context_id`,
1562    /// recording that the driver now knows the first `acked_len` entries of `messages`.
1563    pub(crate) fn record_continuation(
1564        &mut self,
1565        id: String,
1566        context_id: Option<String>,
1567        acked_len: usize,
1568    ) {
1569        self.driver_continuation_id = Some(id);
1570        self.driver_continuation_context_id = context_id;
1571        self.driver_continuation_acked_len = acked_len;
1572    }
1573
1574    /// Drop any held continuation id and its bookkeeping. Called when a driver stops
1575    /// returning the key ("not continuing") and at every `replace-context` commit.
1576    pub(crate) fn clear_continuation(&mut self) {
1577        self.driver_continuation_id = None;
1578        self.driver_continuation_context_id = None;
1579        self.driver_continuation_acked_len = 0;
1580    }
1581
1582    /// Advance the acked-message count for a currently-held, same-context continuation.
1583    /// Used when a Task's final `end_turn` persists an extra assistant message into the
1584    /// context history that the driver already knows (it generated it), so the next
1585    /// same-context Task sends only its new user message rather than re-including it.
1586    pub(crate) fn advance_continuation_acked_len(
1587        &mut self,
1588        context_id: Option<&str>,
1589        acked_len: usize,
1590    ) {
1591        if self.driver_continuation_id.is_some()
1592            && self.driver_continuation_context_id.as_deref() == context_id
1593        {
1594            self.driver_continuation_acked_len = acked_len;
1595        }
1596    }
1597}
1598
1599impl WasiView for CapsuleStoreState {
1600    fn ctx(&mut self) -> WasiCtxView<'_> {
1601        WasiCtxView {
1602            ctx: &mut self.wasi,
1603            table: &mut self.table,
1604        }
1605    }
1606}
1607
1608impl WasiHttpView for CapsuleStoreState {
1609    fn http(&mut self) -> WasiHttpCtxView<'_> {
1610        WasiHttpCtxView {
1611            ctx: &mut self.http,
1612            table: &mut self.table,
1613            hooks: &mut self.http_hooks,
1614        }
1615    }
1616}
1617
1618impl invoke::Host for CapsuleStoreState {
1619    fn invoke(
1620        &mut self,
1621        name: String,
1622        input: murmur::tool::run::ToolInput,
1623    ) -> Result<murmur::tool::run::ToolResult, String> {
1624        if !self.allowlisted_tools.contains(&name) {
1625            return Err(format!(
1626                "tool '{name}' is not declared in manifest allowlist"
1627            ));
1628        }
1629        tokio::task::block_in_place(|| {
1630            tokio::runtime::Handle::current().block_on(self.dispatch_tool_async(&name, input))
1631        })
1632    }
1633}
1634
1635impl send::Host for CapsuleStoreState {
1636    fn send(
1637        &mut self,
1638        peer_url: String,
1639        message: send::Message,
1640    ) -> Result<send::TaskResult, String> {
1641        // Enforce network policy
1642        let for_parse = if peer_url.contains("://") {
1643            peer_url.clone()
1644        } else {
1645            format!("http://{peer_url}")
1646        };
1647        let uri: http::Uri = for_parse
1648            .parse()
1649            .map_err(|e| format!("invalid peer URL '{peer_url}': {e}"))?;
1650        let target = RequestTarget::from_request(&uri, false)
1651            .ok_or_else(|| format!("invalid peer URL '{peer_url}'"))?;
1652        if !self
1653            .network_allow_rules
1654            .iter()
1655            .any(|rule| rule.matches(&target))
1656        {
1657            return Err(format!(
1658                "network policy: '{peer_url}' not in capabilities.network.allow"
1659            ));
1660        }
1661
1662        let message_id = message.message_id.clone();
1663        let outgoing_msg = outgoing::OutgoingMessage {
1664            message_id: message.message_id,
1665            context_id: message.context_id,
1666            text: message.text,
1667        };
1668
1669        // send_a2a_message is async; use block_in_place so we can call it from this sync
1670        // host function while inside a multi-thread Tokio runtime (script capsule path).
1671        let traceparent = self.current_traceparent.clone();
1672        let task = tokio::task::block_in_place(|| {
1673            tokio::runtime::Handle::current().block_on(outgoing::send_a2a_message(
1674                &peer_url,
1675                outgoing_msg,
1676                traceparent.clone(),
1677            ))
1678        })?;
1679
1680        self.pending_a2a_events.push((
1681            peer_url.clone(),
1682            message_id,
1683            task.id.clone(),
1684            task.context_id.clone(),
1685            traceparent,
1686        ));
1687        Ok(send::TaskResult {
1688            task_id: task.id,
1689            context_id: task.context_id,
1690            state: match task.status.state {
1691                crate::a2a::TaskState::Submitted => "submitted".to_string(),
1692                crate::a2a::TaskState::Working => "working".to_string(),
1693                crate::a2a::TaskState::InputRequired => "input-required".to_string(),
1694                crate::a2a::TaskState::Completed => "completed".to_string(),
1695                crate::a2a::TaskState::Failed => "failed".to_string(),
1696                crate::a2a::TaskState::Rejected => "rejected".to_string(),
1697            },
1698        })
1699    }
1700}
1701
1702impl manage::Host for CapsuleStoreState {
1703    fn list(&mut self) -> Vec<manage::ArtifactSummary> {
1704        let mut result: Vec<manage::ArtifactSummary> = self
1705            .installed_artifacts
1706            .iter()
1707            .filter(|a| a.runtime.is_llm_visible())
1708            .map(|artifact| manage::ArtifactSummary {
1709                name: artifact.name.clone(),
1710                version: artifact.version.clone(),
1711                runtime: runtime_type_to_wit(&artifact.runtime, artifact.implementation.as_ref()),
1712            })
1713            .collect();
1714
1715        let existing: std::collections::HashSet<String> =
1716            result.iter().map(|a| a.name.clone()).collect();
1717        for binary in &self.capability_policy.shell_allow {
1718            if !existing.contains(binary) {
1719                result.push(manage::ArtifactSummary {
1720                    name: binary.clone(),
1721                    version: "0.0.0".to_string(),
1722                    runtime: manage::RuntimeType::Native,
1723                });
1724            }
1725        }
1726
1727        result
1728    }
1729
1730    fn describe(&mut self, name: String) -> Result<manage::ArtifactInfo, String> {
1731        // Check installed artifacts first, then fall back to shell tool manifests on disk.
1732        let fallback_summary;
1733        let summary = if let Some(s) = self
1734            .installed_artifacts
1735            .iter()
1736            .find(|artifact| artifact.name == name)
1737        {
1738            s
1739        } else if self.capability_policy.shell_allow.iter().any(|b| b == &name) {
1740            fallback_summary = InstalledArtifactSummary {
1741                name: name.clone(),
1742                version: "0.0.0".to_string(),
1743                runtime: ArtifactRuntime::Tool,
1744                implementation: Some(ArtifactImplementation::Native),
1745            };
1746            &fallback_summary
1747        } else {
1748            return Err(format!("artifact '{name}' is not installed"));
1749        };
1750
1751        let manifest_path = self
1752            .workdir
1753            .join("tools")
1754            .join(&name)
1755            .join(PACKED_MANIFEST_ENTRY);
1756
1757        read_artifact_info_from_manifest(&manifest_path, summary)
1758    }
1759
1760    fn search(&mut self, query: String) -> Result<Vec<manage::ArtifactSummary>, String> {
1761        Err(format!("not implemented (query: {query})"))
1762    }
1763
1764    fn pull(&mut self, name: String, version: String) -> Result<manage::ArtifactSummary, String> {
1765        // 1. Resolve + verify against the registry's own self-reported hash.
1766        let resolved = self
1767            .registry
1768            .resolve_with_platform(&name, &version, Some(current_platform()))
1769            .map_err(|err| format!("failed to resolve {name}@{version}: {err}"))?;
1770
1771        verify_sha256(&name, &version, &resolved.bytes, &resolved.sha256).map_err(|_| {
1772            format!("artifact integrity check failed for {name}@{version}: registry-reported hash does not match downloaded bytes")
1773        })?;
1774
1775        // 2. Cross-check against any existing murmur.lock pin — a runtime pull must never
1776        // silently override what's already pinned for this artifact.
1777        let mut lock = match read_lockfile(&self.lock_path) {
1778            Ok(lock) => lock,
1779            Err(LockfileError::NotFound(_)) => MurmurLock {
1780                lock_version: LOCK_VERSION,
1781                artifacts: Vec::new(),
1782            },
1783            Err(err) => return Err(format!("failed to read murmur.lock: {err}")),
1784        };
1785
1786        if let Some(existing) = lock.artifact_for(&name) {
1787            if existing.resolved_version != version || existing.sha256.wasm != resolved.sha256 {
1788                return Err(format!(
1789                    "murmur.lock conflict for '{name}': pinned {}@{} (sha256 {}), but the \
1790                     registry now resolves {name}@{version} (sha256 {}) — refusing to override \
1791                     a pinned artifact at runtime",
1792                    existing.name, existing.resolved_version, existing.sha256.wasm, resolved.sha256
1793                ));
1794            }
1795        }
1796
1797        // 3. Extract murmur.yaml, dispatch extraction by runtime type, and write files under
1798        // <workdir>/tools/<name>/ — no disk writes happen before steps 1-2 succeed.
1799        let manifest_yaml =
1800            extract_manifest_yaml(&name, &version, &resolved.bytes).map_err(|err| err.to_string())?;
1801        write_tool_manifest(&self.workdir, &name, &manifest_yaml).map_err(|err| err.to_string())?;
1802
1803        let (artifact_runtime, implementation, wasm_component) = match resolved.meta.runtime {
1804            RuntimeType::Wasm => {
1805                let wasm_bytes =
1806                    extract_root_wasm(&name, &version, &resolved.bytes).map_err(|err| err.to_string())?;
1807                let component = Component::new(&self.engine, &wasm_bytes)
1808                    .map_err(|err| format!("failed to compile pulled component '{name}': {err}"))?;
1809                (
1810                    ArtifactRuntime::Tool,
1811                    Some(ArtifactImplementation::Wasm),
1812                    Some(component),
1813                )
1814            }
1815            RuntimeType::Native => {
1816                let binary = extract_native_binary(&name, &version, &resolved.bytes)
1817                    .map_err(|err| err.to_string())?;
1818                install_native_binaries(&self.workdir, vec![(name.clone(), binary)])
1819                    .map_err(|err| err.to_string())?;
1820                (ArtifactRuntime::Tool, Some(ArtifactImplementation::Native), None)
1821            }
1822            RuntimeType::Static => {
1823                let skill_md = extract_skill_md(&name, &version, &resolved.bytes)
1824                    .map_err(|err| err.to_string())?;
1825                install_skill_files(&self.workdir, vec![(name.clone(), skill_md)])
1826                    .map_err(|err| err.to_string())?;
1827                (ArtifactRuntime::Skill, None, None)
1828            }
1829        };
1830
1831        // 4. Files are on disk — now, and only now, update murmur.lock.
1832        if let Some(entry) = lock.artifacts.iter_mut().find(|entry| entry.name == name) {
1833            entry.resolved_version = version.clone();
1834            entry.sha256 = LockedSha256 {
1835                wasm: resolved.sha256.clone(),
1836            };
1837        } else {
1838            lock.artifacts.push(LockedArtifact {
1839                name: name.clone(),
1840                resolved_version: version.clone(),
1841                sha256: LockedSha256 {
1842                    wasm: resolved.sha256.clone(),
1843                },
1844            });
1845        }
1846        write_lockfile_atomic(&self.lock_path, &lock)
1847            .map_err(|err| format!("failed to write murmur.lock: {err}"))?;
1848
1849        // 5. Reflect the pulled artifact in in-memory session state so list()/describe() (and,
1850        // for WASM tools, invoke()) see it immediately.
1851        if let Some(component) = wasm_component {
1852            self.tool_components.insert(name.clone(), component);
1853        }
1854
1855        let summary = InstalledArtifactSummary {
1856            name: name.clone(),
1857            version: version.clone(),
1858            runtime: artifact_runtime,
1859            implementation,
1860        };
1861        if let Some(existing) = self
1862            .installed_artifacts
1863            .iter_mut()
1864            .find(|artifact| artifact.name == name)
1865        {
1866            *existing = summary.clone();
1867        } else {
1868            self.installed_artifacts.push(summary.clone());
1869        }
1870
1871        Ok(manage::ArtifactSummary {
1872            name,
1873            version,
1874            runtime: runtime_type_to_wit(&summary.runtime, summary.implementation.as_ref()),
1875        })
1876    }
1877
1878    fn remove(&mut self, name: String) -> Result<bool, String> {
1879        Err(format!("not implemented (name: {name})"))
1880    }
1881
1882    fn diagnostics(&mut self) -> Result<manage::RuntimeState, String> {
1883        Ok(manage::RuntimeState {
1884            capsule_id: self.session_id.clone(),
1885            installed: self.list(),
1886            capabilities: "artifact-manager/search and remove are not implemented"
1887                .to_string(),
1888        })
1889    }
1890}
1891
1892impl CapsuleStoreState {
1893    /// Async WASM tool dispatch — used by the agent loop for drivers and WASM tools.
1894    pub(crate) async fn dispatch_tool_async(
1895        &self,
1896        name: &str,
1897        input: murmur::tool::run::ToolInput,
1898    ) -> Result<murmur::tool::run::ToolResult, String> {
1899        let Some(component) = self.tool_components.get(name) else {
1900            return Err(format!("tool '{name}' is not available in this session"));
1901        };
1902
1903        let mut linker = Linker::new(&self.engine);
1904        wasmtime_wasi::p2::add_to_linker_async(&mut linker)
1905            .map_err(|err| format!("failed to add WASI linker for tool '{name}': {err}"))?;
1906        wasmtime_wasi_http::p2::add_only_http_to_linker_sync(&mut linker)
1907            .map_err(|err| format!("failed to add HTTP linker for tool '{name}': {err}"))?;
1908
1909        // Register murmur:text/chunks host functions (synchronous).
1910        // Components that do not import this interface ignore the registrations.
1911        // Both functions must be defined in a single .instance() call — Wasmtime
1912        // rejects a second .instance() for the same interface name. Registered
1913        // under the versioned name only (see WIT_TEXT_CHUNKS_IFACE / wit/VERSIONING.md).
1914        {
1915            let chunks_iface = WIT_TEXT_CHUNKS_IFACE;
1916            let sse_for_chunk = self.a2a_sse.clone();
1917            let task_id_for_chunk = self.a2a_task_id.clone();
1918            let chunk_event_id = Arc::clone(&self.a2a_chunk_event_id);
1919            let chunks_emitted_flag = Arc::clone(&self.a2a_chunks_emitted);
1920            let sse_for_thinking = self.a2a_sse.clone();
1921            let task_id_for_thinking = self.a2a_task_id.clone();
1922            let thinking_event_id = Arc::clone(&self.a2a_chunk_event_id);
1923
1924            let mut inst = linker.instance(chunks_iface).map_err(|err| {
1925                format!("failed to define {chunks_iface} instance for '{name}': {err}")
1926            })?;
1927
1928            inst.func_wrap(
1929                "emit-chunk",
1930                move |_store: wasmtime::StoreContextMut<'_, ToolStoreState>,
1931                      (chunk,): (String,)| {
1932                    chunks_emitted_flag.store(true, Ordering::Relaxed);
1933                    if let (Some((ref tx, ref buf)), Some(ref tid)) =
1934                        (&sse_for_chunk, &task_id_for_chunk)
1935                    {
1936                        emit_chunk_sse(tx, buf, &chunk_event_id, tid, &chunk);
1937                    }
1938                    Ok(())
1939                },
1940            )
1941            .map_err(|err| {
1942                format!("failed to register emit-chunk for tool '{name}': {err}")
1943            })?;
1944
1945            inst.func_wrap(
1946                "emit-thinking-chunk",
1947                move |_store: wasmtime::StoreContextMut<'_, ToolStoreState>,
1948                      (chunk,): (String,)| {
1949                    if let (Some((ref tx, ref buf)), Some(ref tid)) =
1950                        (&sse_for_thinking, &task_id_for_thinking)
1951                    {
1952                        emit_thinking_chunk_sse(tx, buf, &thinking_event_id, tid, &chunk);
1953                    }
1954                    Ok(())
1955                },
1956            )
1957            .map_err(|err| {
1958                format!("failed to register emit-thinking-chunk for tool '{name}': {err}")
1959            })?;
1960        }
1961
1962        // Register the murmur:task/task#request-input host function under the
1963        // versioned name only (see WIT_TASK_IFACE).
1964        // Components that do not import this interface ignore the registration.
1965        {
1966            let task_iface = WIT_TASK_IFACE;
1967            let ri_task_registry = self.a2a_task_registry.clone();
1968            let ri_sse = self.a2a_sse.clone();
1969            let ri_task_id = self.a2a_task_id.clone();
1970            let ri_timeout = self.input_timeout_secs;
1971            linker
1972                .instance(task_iface)
1973                .map_err(|err| {
1974                    format!("failed to define {task_iface} instance for '{name}': {err}")
1975                })?
1976                .func_wrap_async(
1977                    "request-input",
1978                    move |_store: wasmtime::StoreContextMut<'_, ToolStoreState>,
1979                          (prompt,): (String,)| {
1980                        let reg = ri_task_registry.clone();
1981                        let sse = ri_sse.clone();
1982                        let tid = ri_task_id.clone();
1983                        let fut: std::pin::Pin<
1984                            Box<
1985                                dyn std::future::Future<Output = wasmtime::Result<(String,)>>
1986                                    + Send,
1987                            >,
1988                        > = Box::pin(async move {
1989                            let result = match (reg, tid) {
1990                                (Some(reg), Some(tid)) => {
1991                                    request_input_impl(tid, prompt, reg, sse, ri_timeout).await
1992                                }
1993                                _ => Err(wasmtime::Error::msg(
1994                                    "request-input is not available outside an A2A task context",
1995                                )),
1996                            };
1997                            result.map(|s| (s,))
1998                        });
1999                        Box::new(fut)
2000                            as Box<
2001                                dyn std::future::Future<Output = wasmtime::Result<(String,)>>
2002                                    + Send
2003                                    + '_,
2004                            >
2005                    },
2006                )
2007                .map_err(|err| {
2008                    format!("failed to register request-input for tool '{name}': {err}")
2009                })?;
2010        }
2011
2012        let tool_limits = self.capability_policy.limits;
2013        let state = ToolStoreState {
2014            limits: tool_limits.limiter(),
2015            table: ResourceTable::new(),
2016            wasi: build_wasi_ctx(
2017                &self.accessible_workdir,
2018                &self.inference_env,
2019                &self.capability_policy,
2020            )
2021            .map_err(|err| format!("failed to build WASI context for tool '{name}': {err}"))?,
2022            http: WasiHttpCtx::new(),
2023            http_hooks: NetworkPolicyHooks {
2024                network_allow_rules: self.network_allow_rules.clone(),
2025            },
2026        };
2027
2028        let mut store = Store::new(&self.engine, state);
2029        // Registered before instantiation — see the capsule store for why.
2030        store.limiter(|state| &mut state.limits);
2031
2032        store.set_epoch_deadline(tool_limits.deadline_ticks());
2033        let instance = linker
2034            .instantiate_async(&mut store, component)
2035            .await
2036            .map_err(|err| format!("failed to instantiate tool '{name}': {err}"))?;
2037
2038        let tool_iface = resolve_versioned_iface(&instance, &mut store, WIT_TOOL_IFACE_VERSIONED)
2039            .ok_or_else(|| {
2040            RuntimeError::ToolExportMissing {
2041                name: name.to_string(),
2042            }
2043            .to_string()
2044        })?;
2045        let tool_run = instance
2046            .get_export_index(&mut store, Some(&tool_iface), "run")
2047            .and_then(|idx| instance.get_func(&mut store, idx))
2048            .ok_or_else(|| {
2049                RuntimeError::ToolExportMissing {
2050                    name: name.to_string(),
2051                }
2052                .to_string()
2053            })?;
2054
2055        let run = tool_run
2056            .typed::<(murmur::tool::run::ToolInput,), (murmur::tool::run::ToolResult,)>(&store)
2057            .map_err(|err| format!("failed to type-check tool '{name}' run export: {err}"))?;
2058
2059        // Fresh budget for `run` itself, so instantiation cost cannot eat into it. This is
2060        // also the driver path (`agent.rs` dispatches the inference driver through here),
2061        // which before this slice had no deadline of any kind.
2062        store.set_epoch_deadline(tool_limits.deadline_ticks());
2063        let called = run.call_async(&mut store, (input,)).await;
2064        let (result,) = match called {
2065            Ok(result) => result,
2066            Err(err) => {
2067                // Classified rather than folded into the generic "trapped" string, so a
2068                // deadline or limit trap is distinguishable on a path that reports failures
2069                // as plain text. `Other` reproduces the pre-slice wording verbatim.
2070                let failure = classify_guest_failure(&err, &store.data().limits);
2071                return Err(failure.message(&format!("tool '{name}'"), &err));
2072            }
2073        };
2074        run.post_return_async(&mut store)
2075            .await
2076            .map_err(|err| format!("tool '{name}' post-return failed: {err}"))?;
2077        Ok(result)
2078    }
2079
2080    /// Dispatch a tool call from the agent loop: native binary, shell, or WASM.
2081    pub(crate) async fn dispatch_agent_tool_async(
2082        &self,
2083        name: &str,
2084        input: murmur::tool::run::ToolInput,
2085    ) -> Result<DispatchOutcome, String> {
2086        // Native artifact: packaged binary in workdir/tools/<name>/<name>
2087        let native_bin = self.workdir.join("tools").join(name).join(name);
2088        if native_bin.exists() && !self.tool_components.contains_key(name) {
2089            return enforce_allowlist(&self.allowlisted_tools, name, || {
2090                dispatch_native_tool(
2091                    name,
2092                    input,
2093                    &native_bin,
2094                    &self.accessible_workdir,
2095                    &self.capability_policy,
2096                )
2097            })
2098            .map(DispatchOutcome::tool);
2099        }
2100
2101        // Shell tool — run on a blocking thread so the LocalSet stays free to handle
2102        // incoming HTTP requests (e.g. curl POSTing back to the same capsule's server).
2103        if self
2104            .capability_policy
2105            .shell_allow
2106            .iter()
2107            .any(|allowed| allowed == name)
2108        {
2109            let name = name.to_string();
2110            let workdir = self.accessible_workdir.clone();
2111            let env_overrides = self.inference_env.clone();
2112            let policy = self.capability_policy.clone();
2113            let enforcement = self.shell_enforcement.clone();
2114            return tokio::task::spawn_blocking(move || {
2115                dispatch_shell_tool(&name, input, &workdir, &env_overrides, &policy, &enforcement)
2116            })
2117            .await
2118            .map_err(|e| format!("shell tool panicked: {e}"));
2119        }
2120
2121        // Skill artifact: return skill.md content as the tool result (no WASM dispatch).
2122        // Works in capsules with no shell/file capabilities — the runtime reads the file.
2123        let skill_md_path = self.workdir.join("tools").join(name).join("skill.md");
2124        if skill_md_path.exists() {
2125            let content = fs::read_to_string(&skill_md_path)
2126                .map_err(|e| format!("failed to read skill '{name}': {e}"))?;
2127            return Ok(DispatchOutcome::skill(murmur::tool::run::ToolResult {
2128                status: murmur::tool::run::Status::Passed,
2129                summary: Some(format!("Skill {name} guidance")),
2130                data: Some(content),
2131                data_path: None,
2132                truncated: false,
2133                metadata: Vec::new(),
2134            }));
2135        }
2136
2137        // WASM tool
2138        if !self.allowlisted_tools.contains(name) {
2139            return Err(format!(
2140                "tool '{name}' is not declared in manifest allowlist"
2141            ));
2142        }
2143        self.dispatch_tool_async(name, input)
2144            .await
2145            .map(DispatchOutcome::tool)
2146    }
2147}
2148
2149fn read_artifact_info_from_manifest(
2150    manifest_path: &Path,
2151    fallback: &InstalledArtifactSummary,
2152) -> Result<manage::ArtifactInfo, String> {
2153    let manifest_content = fs::read_to_string(manifest_path)
2154        .map_err(|err| format!("failed to read {}: {err}", manifest_path.display()))?;
2155
2156    let value: Value = serde_yaml::from_str(&manifest_content)
2157        .map_err(|err| format!("failed to parse {}: {err}", manifest_path.display()))?;
2158
2159    let root = value.as_mapping();
2160
2161    let description = root
2162        .and_then(|mapping| mapping.get(Value::String("description".to_string())))
2163        .and_then(Value::as_str)
2164        .unwrap_or_default()
2165        .to_string();
2166
2167    let tags = root
2168        .and_then(|mapping| mapping.get(Value::String("tags".to_string())))
2169        .and_then(Value::as_sequence)
2170        .map(|items| {
2171            items
2172                .iter()
2173                .filter_map(Value::as_str)
2174                .map(ToString::to_string)
2175                .collect::<Vec<_>>()
2176        })
2177        .unwrap_or_default();
2178
2179    let input_schema = root
2180        .and_then(|mapping| mapping.get(Value::String("input_schema".to_string())))
2181        .map(yaml_to_json_string)
2182        .transpose()?;
2183    let output_schema = root
2184        .and_then(|mapping| mapping.get(Value::String("output_schema".to_string())))
2185        .map(yaml_to_json_string)
2186        .transpose()?;
2187
2188    Ok(manage::ArtifactInfo {
2189        name: fallback.name.clone(),
2190        version: fallback.version.clone(),
2191        description,
2192        tags,
2193        runtime: runtime_type_to_wit(&fallback.runtime, fallback.implementation.as_ref()),
2194        input_schema,
2195        output_schema,
2196    })
2197}
2198
2199fn yaml_to_json_string(value: &Value) -> Result<String, String> {
2200    if let Value::String(s) = value {
2201        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(s) {
2202            return serde_json::to_string(&parsed)
2203                .map_err(|err| format!("failed to convert schema to JSON: {err}"));
2204        }
2205    }
2206    serde_json::to_string(value).map_err(|err| format!("failed to convert schema to JSON: {err}"))
2207}
2208
2209fn runtime_type_to_wit(
2210    runtime: &ArtifactRuntime,
2211    implementation: Option<&ArtifactImplementation>,
2212) -> manage::RuntimeType {
2213    match (runtime, implementation) {
2214        (ArtifactRuntime::Tool, Some(ArtifactImplementation::Native)) => {
2215            manage::RuntimeType::Native
2216        }
2217        _ => manage::RuntimeType::Wasm,
2218    }
2219}
2220
2221struct ToolStoreState {
2222    /// Resource limiter for this store — see [`CapsuleStoreState::limits`].
2223    limits: ExecutionLimiter,
2224    table: ResourceTable,
2225    wasi: WasiCtx,
2226    http: WasiHttpCtx,
2227    http_hooks: NetworkPolicyHooks,
2228}
2229
2230impl WasiView for ToolStoreState {
2231    fn ctx(&mut self) -> WasiCtxView<'_> {
2232        WasiCtxView {
2233            ctx: &mut self.wasi,
2234            table: &mut self.table,
2235        }
2236    }
2237}
2238
2239impl WasiHttpView for ToolStoreState {
2240    fn http(&mut self) -> WasiHttpCtxView<'_> {
2241        WasiHttpCtxView {
2242            ctx: &mut self.http,
2243            table: &mut self.table,
2244            hooks: &mut self.http_hooks,
2245        }
2246    }
2247}
2248
2249/// Write a single artifact's `murmur.yaml` under `<workdir>/tools/<name>/`.
2250///
2251/// Used both by `stage_session` (for every artifact declared in the manifest) and by
2252/// `manage.pull()` (for the single artifact it just fetched at runtime).
2253fn write_tool_manifest(workdir: &Path, name: &str, manifest_yaml: &str) -> Result<(), RuntimeError> {
2254    let manifest_path = workdir.join("tools").join(name).join(PACKED_MANIFEST_ENTRY);
2255    let Some(parent) = manifest_path.parent() else {
2256        return Err(RuntimeError::Runtime(format!(
2257            "failed to derive parent for tool manifest path {}",
2258            manifest_path.display()
2259        )));
2260    };
2261
2262    fs::create_dir_all(parent).map_err(|source| RuntimeError::WriteToolManifest {
2263        path: manifest_path.display().to_string(),
2264        source,
2265    })?;
2266    fs::write(&manifest_path, manifest_yaml).map_err(|source| RuntimeError::WriteToolManifest {
2267        path: manifest_path.display().to_string(),
2268        source,
2269    })
2270}
2271
2272fn install_native_binaries(
2273    workdir: &Path,
2274    native_binaries: Vec<(String, Vec<u8>)>,
2275) -> Result<(), RuntimeError> {
2276    for (name, bytes) in native_binaries {
2277        let binary_path = workdir.join("tools").join(&name).join(&name);
2278        let Some(parent) = binary_path.parent() else {
2279            return Err(RuntimeError::Runtime(format!(
2280                "failed to derive parent for native binary path {}",
2281                binary_path.display()
2282            )));
2283        };
2284        fs::create_dir_all(parent).map_err(|source| RuntimeError::WriteToolManifest {
2285            path: binary_path.display().to_string(),
2286            source,
2287        })?;
2288        fs::write(&binary_path, &bytes).map_err(|source| RuntimeError::WriteToolManifest {
2289            path: binary_path.display().to_string(),
2290            source,
2291        })?;
2292        #[cfg(unix)]
2293        {
2294            use std::os::unix::fs::PermissionsExt;
2295            let mut perms = fs::metadata(&binary_path)
2296                .map_err(|source| RuntimeError::WriteToolManifest {
2297                    path: binary_path.display().to_string(),
2298                    source,
2299                })?
2300                .permissions();
2301            perms.set_mode(0o755);
2302            fs::set_permissions(&binary_path, perms).map_err(|source| {
2303                RuntimeError::WriteToolManifest {
2304                    path: binary_path.display().to_string(),
2305                    source,
2306                }
2307            })?;
2308        }
2309    }
2310    Ok(())
2311}
2312
2313/// Resolve and read a local-source skill's `skill.md` bytes.
2314///
2315/// `source` may be a relative or absolute path. Relative paths resolve against `manifest_dir`
2316/// (the directory containing `murmur.yaml`). The resolved path may be:
2317///   - a file (assumed to be `skill.md` itself), read directly, or
2318///   - a directory, in which case `skill.md` is located case-insensitively within it.
2319///
2320/// Errors before the workdir is created so that failures exit non-zero with no side effects.
2321fn load_local_skill_md(manifest_dir: &Path, source: &str) -> Result<Vec<u8>, RuntimeError> {
2322    let raw = Path::new(source);
2323    let path = if raw.is_absolute() {
2324        raw.to_path_buf()
2325    } else {
2326        manifest_dir.join(raw)
2327    };
2328
2329    if !path.exists() {
2330        return Err(RuntimeError::SkillSourceNotFound {
2331            path: path.display().to_string(),
2332        });
2333    }
2334
2335    let skill_md_path = if path.is_dir() {
2336        find_skill_md_in_dir(&path)?.ok_or_else(|| RuntimeError::SkillSourceMissingSkillMd {
2337            path: path.display().to_string(),
2338        })?
2339    } else {
2340        path.clone()
2341    };
2342
2343    fs::read(&skill_md_path).map_err(|source| RuntimeError::SkillSourceRead {
2344        path: skill_md_path.display().to_string(),
2345        source,
2346    })
2347}
2348
2349/// Locate `skill.md` in a directory, matching the filename case-insensitively. First match wins.
2350fn find_skill_md_in_dir(dir: &Path) -> Result<Option<std::path::PathBuf>, RuntimeError> {
2351    let entries = fs::read_dir(dir).map_err(|source| RuntimeError::SkillSourceRead {
2352        path: dir.display().to_string(),
2353        source,
2354    })?;
2355    for entry in entries {
2356        let entry = entry.map_err(|source| RuntimeError::SkillSourceRead {
2357            path: dir.display().to_string(),
2358            source,
2359        })?;
2360        if entry.file_name().to_string_lossy().to_lowercase() == "skill.md" {
2361            return Ok(Some(entry.path()));
2362        }
2363    }
2364    Ok(None)
2365}
2366
2367fn install_skill_files(
2368    workdir: &Path,
2369    skill_files: Vec<(String, Vec<u8>)>,
2370) -> Result<(), RuntimeError> {
2371    for (name, bytes) in skill_files {
2372        let skill_path = workdir.join("tools").join(&name).join("skill.md");
2373        let Some(parent) = skill_path.parent() else {
2374            return Err(RuntimeError::Runtime(format!(
2375                "failed to derive parent for skill.md path {}",
2376                skill_path.display()
2377            )));
2378        };
2379        fs::create_dir_all(parent).map_err(|source| RuntimeError::WriteToolManifest {
2380            path: skill_path.display().to_string(),
2381            source,
2382        })?;
2383        fs::write(&skill_path, &bytes).map_err(|source| RuntimeError::WriteToolManifest {
2384            path: skill_path.display().to_string(),
2385            source,
2386        })?;
2387    }
2388    Ok(())
2389}
2390
2391fn write_shell_tool_manifests(workdir: &Path, shell_allow: &[String]) -> Result<(), RuntimeError> {
2392    for binary in shell_allow {
2393        let manifest_path = workdir
2394            .join("tools")
2395            .join(binary)
2396            .join(PACKED_MANIFEST_ENTRY);
2397
2398        if manifest_path.exists() {
2399            continue;
2400        }
2401
2402        let Some(parent) = manifest_path.parent() else {
2403            return Err(RuntimeError::Runtime(format!(
2404                "failed to derive parent for shell manifest path {}",
2405                manifest_path.display()
2406            )));
2407        };
2408
2409        fs::create_dir_all(parent).map_err(|source| RuntimeError::WriteToolManifest {
2410            path: manifest_path.display().to_string(),
2411            source,
2412        })?;
2413
2414        fs::write(&manifest_path, shell_tool_manifest_yaml(binary)).map_err(|source| {
2415            RuntimeError::WriteToolManifest {
2416                path: manifest_path.display().to_string(),
2417                source,
2418            }
2419        })?;
2420    }
2421
2422    Ok(())
2423}
2424
2425/// Execute a native artifact binary.
2426///
2427/// The binary receives the serialized ToolInput JSON on stdin and must write a valid
2428/// ToolResult JSON object to stdout. The binary's working directory is the capsule workdir.
2429fn dispatch_native_tool(
2430    name: &str,
2431    input: murmur::tool::run::ToolInput,
2432    binary_path: &Path,
2433    workdir: &Path,
2434    policy: &CapabilityPolicy,
2435) -> Result<murmur::tool::run::ToolResult, String> {
2436    use std::{
2437        io::Write,
2438        process::{Command, Stdio},
2439    };
2440
2441    let input_json = serde_json::to_string(&serde_json::json!({
2442        "data": input.data,
2443        "log_path": input.log_path,
2444    }))
2445    .map_err(|e| format!("failed to serialize input for native tool '{name}': {e}"))?;
2446
2447    let env = build_shell_env(policy, &[], workdir)?;
2448
2449    let mut child = Command::new(binary_path)
2450        .current_dir(workdir)
2451        .env_clear()
2452        .envs(env)
2453        .stdin(Stdio::piped())
2454        .stdout(Stdio::piped())
2455        .stderr(Stdio::piped())
2456        .spawn()
2457        .map_err(|e| format!("failed to spawn native tool '{name}': {e}"))?;
2458
2459    if let Some(mut stdin) = child.stdin.take() {
2460        let _ = stdin.write_all(input_json.as_bytes());
2461        // stdin closes when dropped, signalling EOF to the child
2462    }
2463
2464    let output = child
2465        .wait_with_output()
2466        .map_err(|e| format!("native tool '{name}' failed to complete: {e}"))?;
2467
2468    let stdout = String::from_utf8_lossy(&output.stdout);
2469    let stderr = String::from_utf8_lossy(&output.stderr);
2470
2471    if stdout.trim().is_empty() {
2472        return Ok(murmur::tool::run::ToolResult {
2473            status: if output.status.success() {
2474                murmur::tool::run::Status::Passed
2475            } else {
2476                murmur::tool::run::Status::Error
2477            },
2478            summary: Some(format!(
2479                "native tool '{}' exited with {}",
2480                name, output.status
2481            )),
2482            data: if stderr.is_empty() {
2483                None
2484            } else {
2485                Some(stderr.to_string())
2486            },
2487            data_path: None,
2488            truncated: false,
2489            metadata: Vec::new(),
2490        });
2491    }
2492
2493    match serde_json::from_str::<serde_json::Value>(stdout.trim()) {
2494        Ok(json) => {
2495            let status = match json.get("status").and_then(|s| s.as_str()) {
2496                Some("passed") => murmur::tool::run::Status::Passed,
2497                Some("failed") => murmur::tool::run::Status::Failed,
2498                _ => murmur::tool::run::Status::Error,
2499            };
2500            let summary = json
2501                .get("summary")
2502                .and_then(|s| s.as_str())
2503                .map(|s| s.to_string());
2504            let data = json
2505                .get("data")
2506                .and_then(|d| d.as_str())
2507                .map(|s| s.to_string())
2508                .or_else(|| {
2509                    json.get("data")
2510                        .filter(|d| !d.is_null())
2511                        .map(|d| d.to_string())
2512                });
2513            Ok(murmur::tool::run::ToolResult {
2514                status,
2515                summary,
2516                data,
2517                data_path: None,
2518                truncated: false,
2519                metadata: Vec::new(),
2520            })
2521        }
2522        Err(_) => Ok(murmur::tool::run::ToolResult {
2523            status: if output.status.success() {
2524                murmur::tool::run::Status::Passed
2525            } else {
2526                murmur::tool::run::Status::Error
2527            },
2528            summary: Some(format!("native tool '{}' completed", name)),
2529            data: Some(stdout.to_string()),
2530            data_path: None,
2531            truncated: false,
2532            metadata: Vec::new(),
2533        }),
2534    }
2535}
2536
2537fn dispatch_shell_tool(
2538    name: &str,
2539    input: murmur::tool::run::ToolInput,
2540    workdir: &Path,
2541    env_overrides: &[(String, String)],
2542    policy: &CapabilityPolicy,
2543    enforcement: &sandbox::ShellEnforcement,
2544) -> DispatchOutcome {
2545    let command = match extract_shell_command(&input) {
2546        Ok(command) => command,
2547        Err(error) => {
2548            return DispatchOutcome::tool(murmur::tool::run::ToolResult {
2549                status: murmur::tool::run::Status::Error,
2550                summary: Some("shell command parsing failed".to_string()),
2551                data: Some(error),
2552                data_path: None,
2553                truncated: false,
2554                metadata: Vec::new(),
2555            });
2556        }
2557    };
2558
2559    let split_args: Vec<String>;
2560    let args: Vec<&str> = if is_shell_interpreter(name) {
2561        vec!["-c", command.as_str()]
2562    } else {
2563        split_args = split_shell_words(&command);
2564        split_args.iter().map(String::as_str).collect()
2565    };
2566
2567    match execute_shell(name, &args, env_overrides, workdir, policy, enforcement) {
2568        Ok(result) => {
2569            let shell = ShellDispatchInfo {
2570                command: command.clone(),
2571                exit_code: result.exit_code,
2572                stdout: result.stdout.clone(),
2573                stderr: result.stderr.clone(),
2574                stdout_bytes: result.stdout.len() as u64,
2575                stderr_bytes: result.stderr.len() as u64,
2576                duration_ms: result.duration_ms,
2577            };
2578            DispatchOutcome {
2579                result: shell_result_to_tool_result(&command, result),
2580                shell: Some(shell),
2581                is_skill: false,
2582            }
2583        }
2584        Err(error) => DispatchOutcome::tool(murmur::tool::run::ToolResult {
2585            status: murmur::tool::run::Status::Error,
2586            summary: Some("shell execution failed".to_string()),
2587            data: Some(error),
2588            data_path: None,
2589            truncated: false,
2590            metadata: Vec::new(),
2591        }),
2592    }
2593}
2594
2595fn extract_shell_command(input: &murmur::tool::run::ToolInput) -> Result<String, String> {
2596    let data = input
2597        .data
2598        .as_deref()
2599        .ok_or_else(|| "shell tool input.data is required".to_string())?;
2600
2601    let json: serde_json::Value = serde_json::from_str(data)
2602        .map_err(|error| format!("shell tool input must be valid JSON: {error}"))?;
2603
2604    let command = json
2605        .get("command")
2606        .and_then(serde_json::Value::as_str)
2607        .map(str::trim)
2608        .filter(|value| !value.is_empty())
2609        .ok_or_else(|| "shell tool input must include a non-empty 'command' string".to_string())?;
2610
2611    Ok(command.to_string())
2612}
2613
2614fn shell_result_to_tool_result(command: &str, result: ShellResult) -> murmur::tool::run::ToolResult {
2615    let mut data = format!(
2616        "$ {}\nExit code: {}\nStdout:\n{}\nStderr:\n{}",
2617        command, result.exit_code, result.stdout, result.stderr
2618    );
2619
2620    let mut metadata = Vec::new();
2621    if result.truncated {
2622        if let Some(path) = result.full_output_path.as_ref() {
2623            data.push_str(&format!(
2624                "\n\nOutput truncated. Full output written to {path}"
2625            ));
2626            metadata.push(("full_output_path".to_string(), path.clone()));
2627        } else {
2628            data.push_str("\n\nOutput truncated.");
2629        }
2630    }
2631
2632    murmur::tool::run::ToolResult {
2633        status: murmur::tool::run::Status::Passed,
2634        summary: Some(format!(
2635            "Shell command exited with code {}",
2636            result.exit_code
2637        )),
2638        data: Some(data),
2639        data_path: None,
2640        truncated: result.truncated,
2641        metadata,
2642    }
2643}
2644
2645fn resolve_context_window(context: Option<&ContextConfig>) -> u32 {
2646    context.and_then(|c| c.max_tokens).unwrap_or(0)
2647}
2648
2649fn validate_capability_policy(policy: &CapabilityPolicy) -> Result<(), RuntimeError> {
2650    parse_network_allow_rules(&policy.network_allow)?;
2651    if let Some(scope) = policy.filesystem_scope.as_deref() {
2652        validate_filesystem_scope(scope)?;
2653    }
2654
2655    Ok(())
2656}
2657
2658fn enforce_allowlist<T, F>(
2659    allowlisted_tools: &HashSet<String>,
2660    name: &str,
2661    dispatch: F,
2662) -> Result<T, String>
2663where
2664    F: FnOnce() -> Result<T, String>,
2665{
2666    if !allowlisted_tools.contains(name) {
2667        return Err(format!(
2668            "tool '{name}' is not declared in manifest allowlist"
2669        ));
2670    }
2671
2672    dispatch()
2673}
2674
2675fn generate_session_id() -> String {
2676    format!("ses_{}", uuid::Uuid::now_v7().simple())
2677}
2678
2679fn resolve_lifecycle(
2680    base: Option<LifecycleConfig>,
2681    override_: Option<&murmur_artifact::LifecycleOverride>,
2682) -> LifecycleConfig {
2683    let mut config = base.unwrap_or_default();
2684    if let Some(ov) = override_ {
2685        if let Some(ta) = &ov.task_acceptance {
2686            config.task_acceptance = ta.clone();
2687        }
2688        if let Some(at) = &ov.after_task {
2689            config.after_task = at.clone();
2690        }
2691    }
2692    config
2693}
2694
2695#[cfg(test)]
2696mod tests {
2697    use murmur_artifact::{ArtifactMeta, ArtifactRuntime, Registry, ResolvedArtifact, RuntimeType};
2698    use tempfile::TempDir;
2699
2700    use super::*;
2701
2702    fn bootstrap_log_contents(workdir: &Path) -> String {
2703        fs::read_to_string(workdir.join("logs").join("bootstrap.log")).unwrap_or_default()
2704    }
2705
2706    #[test]
2707    fn bash_and_network_together_trigger_warning() {
2708        let tmp = TempDir::new().unwrap();
2709        let policy = CapabilityPolicy {
2710            shell_allow: vec!["bash".to_string()],
2711            network_allow: vec!["https://api.example.com".to_string()],
2712            ..Default::default()
2713        };
2714        warn_if_bash_network_bypass(tmp.path(), &policy);
2715        let log = bootstrap_log_contents(tmp.path());
2716        assert!(log.contains("bash"), "log should mention bash: {log}");
2717        assert!(log.contains("network"), "log should mention network: {log}");
2718        assert!(log.contains(W_SEC_003), "log should carry its warning code: {log}");
2719        assert!(
2720            log.contains(&security_warning_link(W_SEC_003)),
2721            "log should link to the security-warnings doc page: {log}"
2722        );
2723    }
2724
2725    #[test]
2726    fn network_without_bash_does_not_warn() {
2727        let tmp = TempDir::new().unwrap();
2728        let policy = CapabilityPolicy {
2729            shell_allow: vec!["cargo".to_string(), "git".to_string()],
2730            network_allow: vec!["https://api.example.com".to_string()],
2731            ..Default::default()
2732        };
2733        warn_if_bash_network_bypass(tmp.path(), &policy);
2734        assert!(bootstrap_log_contents(tmp.path()).is_empty());
2735    }
2736
2737    #[test]
2738    fn bash_without_network_does_not_warn() {
2739        let tmp = TempDir::new().unwrap();
2740        let policy = CapabilityPolicy {
2741            shell_allow: vec!["bash".to_string()],
2742            network_allow: Vec::new(),
2743            ..Default::default()
2744        };
2745        warn_if_bash_network_bypass(tmp.path(), &policy);
2746        assert!(bootstrap_log_contents(tmp.path()).is_empty());
2747    }
2748
2749    #[test]
2750    fn neither_declared_does_not_warn() {
2751        let tmp = TempDir::new().unwrap();
2752        warn_if_bash_network_bypass(tmp.path(), &CapabilityPolicy::default());
2753        assert!(bootstrap_log_contents(tmp.path()).is_empty());
2754    }
2755
2756    #[test]
2757    fn non_bash_shell_interpreter_does_not_warn() {
2758        let tmp = TempDir::new().unwrap();
2759        let policy = CapabilityPolicy {
2760            shell_allow: vec!["sh".to_string()],
2761            network_allow: vec!["https://api.example.com".to_string()],
2762            ..Default::default()
2763        };
2764        warn_if_bash_network_bypass(tmp.path(), &policy);
2765        assert!(
2766            bootstrap_log_contents(tmp.path()).is_empty(),
2767            "exact match on \"bash\" literal must not fire for other shell interpreters like sh"
2768        );
2769    }
2770
2771    #[test]
2772    fn allowlist_blocks_unlisted_before_dispatch() {
2773        let allowlist = HashSet::from(["echo-tool".to_string()]);
2774        let mut dispatched = false;
2775
2776        let result = enforce_allowlist(&allowlist, "missing-tool", || {
2777            dispatched = true;
2778            Ok::<_, String>(())
2779        });
2780
2781        assert!(!dispatched);
2782        assert!(result.is_err());
2783        assert!(result
2784            .unwrap_err()
2785            .contains("not declared in manifest allowlist"));
2786    }
2787
2788    #[test]
2789    fn allowlist_dispatches_listed_tool() {
2790        let allowlist = HashSet::from(["echo-tool".to_string()]);
2791        let result = enforce_allowlist(&allowlist, "echo-tool", || Ok::<_, String>("ok")).unwrap();
2792        assert_eq!(result, "ok");
2793    }
2794
2795    /// Builds a component that exports a single empty instance under `iface`.
2796    /// `resolve_versioned_iface` only looks up the export index by instance
2797    /// name — it never inspects the instance's contents — so an empty instance
2798    /// is sufficient to exercise its versioned-name probe.
2799    fn iface_double(engine: &wasmtime::Engine, iface: &str) -> wasmtime::component::Component {
2800        let wat = format!(
2801            "(component\n\
2802             (instance $i)\n\
2803             (export \"{iface}\" (instance $i))\n\
2804             )"
2805        );
2806        let bytes = wat::parse_str(&wat).expect("component WAT parses");
2807        wasmtime::component::Component::new(engine, bytes).expect("component compiles")
2808    }
2809
2810    fn iface_test_engine() -> wasmtime::Engine {
2811        let mut config = wasmtime::Config::new();
2812        config.wasm_component_model(true);
2813        wasmtime::Engine::new(&config).expect("engine builds")
2814    }
2815
2816    fn instantiate_iface_double(
2817        engine: &wasmtime::Engine,
2818        iface: &str,
2819    ) -> (wasmtime::component::Instance, Store<()>) {
2820        let component = iface_double(engine, iface);
2821        let linker = wasmtime::component::Linker::new(engine);
2822        let mut store = Store::new(engine, ());
2823        let instance = linker
2824            .instantiate(&mut store, &component)
2825            .expect("component with no imports instantiates");
2826        (instance, store)
2827    }
2828
2829    /// A component exporting the versioned instance name (the shape a guest
2830    /// built against the semver'd WIT carries) resolves via the versioned probe.
2831    #[test]
2832    fn resolve_versioned_iface_finds_versioned_name() {
2833        let engine = iface_test_engine();
2834        let (instance, mut store) = instantiate_iface_double(&engine, WIT_CAPSULE_IFACE_VERSIONED);
2835        let found = resolve_versioned_iface(&instance, &mut store, WIT_CAPSULE_IFACE_VERSIONED);
2836        assert!(
2837            found.is_some(),
2838            "a component exporting the versioned name must resolve"
2839        );
2840    }
2841
2842    /// A component exporting only the legacy unversioned instance name no longer
2843    /// resolves — the fallback probe was removed, so the versioned-only lookup
2844    /// returns `None` and the call site surfaces a hard missing-export error.
2845    #[test]
2846    fn resolve_versioned_iface_rejects_unversioned_only_name() {
2847        let engine = iface_test_engine();
2848        let (instance, mut store) = instantiate_iface_double(&engine, "murmur:tool/run");
2849        let found = resolve_versioned_iface(&instance, &mut store, WIT_TOOL_IFACE_VERSIONED);
2850        assert!(
2851            found.is_none(),
2852            "a component exporting only the legacy unversioned name must no longer resolve"
2853        );
2854    }
2855
2856    /// A component exporting neither the versioned name nor any recognizable
2857    /// name resolves to `None` — the probe must not silently swallow a genuinely
2858    /// absent interface.
2859    #[test]
2860    fn resolve_versioned_iface_returns_none_when_neither_name_matches() {
2861        let engine = iface_test_engine();
2862        let (instance, mut store) = instantiate_iface_double(&engine, "murmur:capsule/nonexistent");
2863        let found = resolve_versioned_iface(&instance, &mut store, WIT_CAPSULE_IFACE_VERSIONED);
2864        assert!(
2865            found.is_none(),
2866            "a component exporting neither probed name must not resolve"
2867        );
2868    }
2869
2870    #[test]
2871    fn generate_session_id_is_unique() {
2872        let first = generate_session_id();
2873        let second = generate_session_id();
2874
2875        assert_ne!(first, second);
2876        assert!(first.starts_with("ses_"), "session id must start with ses_");
2877        assert_eq!(
2878            first.len(),
2879            36,
2880            "session id must be 36 chars (ses_ + 32 hex)"
2881        );
2882        uuid::Uuid::parse_str(&first[4..]).expect("session id suffix should be a valid UUID");
2883    }
2884
2885    #[test]
2886    fn sha_verification_happens_before_component_compile() {
2887        struct FakeRegistry;
2888
2889        impl Registry for FakeRegistry {
2890            fn resolve(
2891                &self,
2892                name: &str,
2893                version: &str,
2894            ) -> Result<ResolvedArtifact, RegistryError> {
2895                Ok(ResolvedArtifact {
2896                    meta: ArtifactMeta {
2897                        name: name.to_string(),
2898                        version: version.to_string(),
2899                        runtime: RuntimeType::Wasm,
2900                        artifact_runtime: "wasm".to_string(),
2901                        platforms: Vec::new(),
2902                        description: None,
2903                        tags: Vec::new(),
2904                    },
2905                    bytes: b"not-a-real-zip".to_vec().into(),
2906                    sha256: "definitely-wrong".to_string(),
2907                })
2908            }
2909
2910            fn publish(
2911                &self,
2912                _meta: ArtifactMeta,
2913                _bytes: &[u8],
2914            ) -> Result<murmur_artifact::PublishResult, RegistryError> {
2915                unreachable!()
2916            }
2917
2918            fn list_index(&self) -> Result<Vec<ArtifactMeta>, RegistryError> {
2919                unreachable!()
2920            }
2921        }
2922
2923        let tempdir = tempfile::tempdir().unwrap();
2924        let request = StageRequest {
2925            manifest_dir: tempdir.path().to_path_buf(),
2926            capsule_name: "test".to_string(),
2927            capsule_version: String::new(),
2928            capsule_component_bytes: b"not-a-component".to_vec(),
2929            artifacts: vec![crate::types::ArtifactRequest {
2930                name: "echo-tool".to_string(),
2931                version: "0.0.1".to_string(),
2932                runtime: ArtifactRuntime::Tool,
2933                source: None,
2934            }],
2935            allowlisted_tools: HashSet::from(["echo-tool".to_string()]),
2936            lock_expectations: None,
2937            capability_policy: CapabilityPolicy::default(),
2938            inference: None,
2939            context: None,
2940            otel_endpoint: None,
2941            eval_config_json: None,
2942            case_id: None,
2943            dataset_id: None,
2944            lifecycle: None,
2945            lifecycle_override: None,
2946            trace: None,
2947            workdir: None,
2948            bind_addr: "127.0.0.1".to_string(),
2949            internal_port: None,
2950            job_id: None,
2951        };
2952
2953        let err = match stage_session(Arc::new(FakeRegistry), request) {
2954            Ok(_) => panic!("expected stage_session to fail"),
2955            Err(err) => err,
2956        };
2957        assert!(matches!(
2958            err,
2959            RuntimeError::ArtifactIntegrityFailed { name, version }
2960                if name == "echo-tool" && version == "0.0.1"
2961        ));
2962    }
2963
2964    #[test]
2965    fn lock_expectation_mismatch_returns_integrity_failure() {
2966        struct FakeRegistry;
2967
2968        impl Registry for FakeRegistry {
2969            fn resolve(
2970                &self,
2971                name: &str,
2972                version: &str,
2973            ) -> Result<ResolvedArtifact, RegistryError> {
2974                Ok(ResolvedArtifact {
2975                    meta: ArtifactMeta {
2976                        name: name.to_string(),
2977                        version: version.to_string(),
2978                        runtime: RuntimeType::Wasm,
2979                        artifact_runtime: "wasm".to_string(),
2980                        platforms: Vec::new(),
2981                        description: None,
2982                        tags: Vec::new(),
2983                    },
2984                    bytes: b"not-a-real-zip".to_vec().into(),
2985                    sha256: murmur_artifact::sha256_hex(b"not-a-real-zip"),
2986                })
2987            }
2988
2989            fn publish(
2990                &self,
2991                _meta: ArtifactMeta,
2992                _bytes: &[u8],
2993            ) -> Result<murmur_artifact::PublishResult, RegistryError> {
2994                unreachable!()
2995            }
2996
2997            fn list_index(&self) -> Result<Vec<ArtifactMeta>, RegistryError> {
2998                unreachable!()
2999            }
3000        }
3001
3002        let tempdir = tempfile::tempdir().unwrap();
3003        let request = StageRequest {
3004            manifest_dir: tempdir.path().to_path_buf(),
3005            capsule_name: "test".to_string(),
3006            capsule_version: String::new(),
3007            capsule_component_bytes: Vec::new(),
3008            artifacts: vec![crate::types::ArtifactRequest {
3009                name: "echo-tool".to_string(),
3010                version: "0.0.1".to_string(),
3011                runtime: ArtifactRuntime::Tool,
3012                source: None,
3013            }],
3014            allowlisted_tools: HashSet::from(["echo-tool".to_string()]),
3015            lock_expectations: Some(vec![crate::types::LockExpectation {
3016                name: "echo-tool".to_string(),
3017                resolved_version: "0.0.1".to_string(),
3018                sha256_wasm: "different".to_string(),
3019            }]),
3020            capability_policy: CapabilityPolicy::default(),
3021            inference: None,
3022            context: None,
3023            otel_endpoint: None,
3024            eval_config_json: None,
3025            case_id: None,
3026            dataset_id: None,
3027            lifecycle: None,
3028            lifecycle_override: None,
3029            trace: None,
3030            workdir: None,
3031            bind_addr: "127.0.0.1".to_string(),
3032            internal_port: None,
3033            job_id: None,
3034        };
3035
3036        let err = match stage_session(Arc::new(FakeRegistry), request) {
3037            Ok(_) => panic!("expected stage_session to fail"),
3038            Err(err) => err,
3039        };
3040        assert!(matches!(
3041            err,
3042            RuntimeError::ArtifactIntegrityFailed { name, version }
3043                if name == "echo-tool" && version == "0.0.1"
3044        ));
3045    }
3046
3047    #[test]
3048    fn lock_expectations_require_entry_for_each_manifest_artifact() {
3049        struct FakeRegistry;
3050
3051        impl Registry for FakeRegistry {
3052            fn resolve(
3053                &self,
3054                _name: &str,
3055                _version: &str,
3056            ) -> Result<ResolvedArtifact, RegistryError> {
3057                unreachable!("lock validation should fail before any registry resolve call")
3058            }
3059
3060            fn publish(
3061                &self,
3062                _meta: ArtifactMeta,
3063                _bytes: &[u8],
3064            ) -> Result<murmur_artifact::PublishResult, RegistryError> {
3065                unreachable!()
3066            }
3067
3068            fn list_index(&self) -> Result<Vec<ArtifactMeta>, RegistryError> {
3069                unreachable!()
3070            }
3071        }
3072
3073        let tempdir = tempfile::tempdir().unwrap();
3074        let request = StageRequest {
3075            manifest_dir: tempdir.path().to_path_buf(),
3076            capsule_name: "test".to_string(),
3077            capsule_version: String::new(),
3078            capsule_component_bytes: Vec::new(),
3079            artifacts: vec![crate::types::ArtifactRequest {
3080                name: "echo-tool".to_string(),
3081                version: "0.0.1".to_string(),
3082                runtime: ArtifactRuntime::Tool,
3083                source: None,
3084            }],
3085            allowlisted_tools: HashSet::from(["echo-tool".to_string()]),
3086            lock_expectations: Some(vec![crate::types::LockExpectation {
3087                name: "different-tool".to_string(),
3088                resolved_version: "0.0.1".to_string(),
3089                sha256_wasm: "abc".to_string(),
3090            }]),
3091            capability_policy: CapabilityPolicy::default(),
3092            inference: None,
3093            context: None,
3094            otel_endpoint: None,
3095            eval_config_json: None,
3096            case_id: None,
3097            dataset_id: None,
3098            lifecycle: None,
3099            lifecycle_override: None,
3100            trace: None,
3101            workdir: None,
3102            bind_addr: "127.0.0.1".to_string(),
3103            internal_port: None,
3104            job_id: None,
3105        };
3106
3107        let err = match stage_session(Arc::new(FakeRegistry), request) {
3108            Ok(_) => panic!("expected stage_session to fail"),
3109            Err(err) => err,
3110        };
3111        assert!(matches!(
3112            err,
3113            RuntimeError::LockMissingEntry { name } if name == "echo-tool"
3114        ));
3115    }
3116
3117    #[test]
3118    fn lock_expectation_version_mismatch_is_reported() {
3119        struct FakeRegistry;
3120
3121        impl Registry for FakeRegistry {
3122            fn resolve(
3123                &self,
3124                _name: &str,
3125                _version: &str,
3126            ) -> Result<ResolvedArtifact, RegistryError> {
3127                unreachable!("version mismatch should fail before registry resolve")
3128            }
3129
3130            fn publish(
3131                &self,
3132                _meta: ArtifactMeta,
3133                _bytes: &[u8],
3134            ) -> Result<murmur_artifact::PublishResult, RegistryError> {
3135                unreachable!()
3136            }
3137
3138            fn list_index(&self) -> Result<Vec<ArtifactMeta>, RegistryError> {
3139                unreachable!()
3140            }
3141        }
3142
3143        let tempdir = tempfile::tempdir().unwrap();
3144        let request = StageRequest {
3145            manifest_dir: tempdir.path().to_path_buf(),
3146            capsule_name: "test".to_string(),
3147            capsule_version: String::new(),
3148            capsule_component_bytes: Vec::new(),
3149            artifacts: vec![crate::types::ArtifactRequest {
3150                name: "echo-tool".to_string(),
3151                version: "0.0.9".to_string(),
3152                runtime: ArtifactRuntime::Tool,
3153                source: None,
3154            }],
3155            allowlisted_tools: HashSet::from(["echo-tool".to_string()]),
3156            lock_expectations: Some(vec![crate::types::LockExpectation {
3157                name: "echo-tool".to_string(),
3158                resolved_version: "0.0.1".to_string(),
3159                sha256_wasm: "abc".to_string(),
3160            }]),
3161            capability_policy: CapabilityPolicy::default(),
3162            inference: None,
3163            context: None,
3164            otel_endpoint: None,
3165            eval_config_json: None,
3166            case_id: None,
3167            dataset_id: None,
3168            lifecycle: None,
3169            lifecycle_override: None,
3170            trace: None,
3171            workdir: None,
3172            bind_addr: "127.0.0.1".to_string(),
3173            internal_port: None,
3174            job_id: None,
3175        };
3176
3177        let err = match stage_session(Arc::new(FakeRegistry), request) {
3178            Ok(_) => panic!("expected stage_session to fail"),
3179            Err(err) => err,
3180        };
3181        assert!(matches!(
3182            err,
3183            RuntimeError::LockVersionMismatch {
3184                name,
3185                requested,
3186                pinned,
3187            } if name == "echo-tool" && requested == "0.0.9" && pinned == "0.0.1"
3188        ));
3189    }
3190
3191    // ── local-source skill resolution ──────────────────────────────────────
3192
3193    #[test]
3194    fn load_local_skill_md_reads_file_path() {
3195        let dir = tempfile::tempdir().unwrap();
3196        let skill = dir.path().join("skill.md");
3197        fs::write(&skill, b"# hi from file").unwrap();
3198        let bytes = load_local_skill_md(dir.path(), "skill.md").unwrap();
3199        assert_eq!(bytes, b"# hi from file");
3200    }
3201
3202    #[test]
3203    fn load_local_skill_md_finds_skill_md_in_directory_case_insensitive() {
3204        let manifest_dir = tempfile::tempdir().unwrap();
3205        let skill_dir = manifest_dir.path().join("skills").join("my-skill");
3206        fs::create_dir_all(&skill_dir).unwrap();
3207        // Uppercase filename — must still be found.
3208        fs::write(skill_dir.join("SKILL.MD"), b"# upper").unwrap();
3209        let bytes = load_local_skill_md(manifest_dir.path(), "skills/my-skill").unwrap();
3210        assert_eq!(bytes, b"# upper");
3211    }
3212
3213    #[test]
3214    fn load_local_skill_md_missing_path_errors_with_name() {
3215        let dir = tempfile::tempdir().unwrap();
3216        let err = load_local_skill_md(dir.path(), "does/not/exist").unwrap_err();
3217        match err {
3218            RuntimeError::SkillSourceNotFound { path } => {
3219                assert!(path.contains("does/not/exist"), "path was: {path}");
3220            }
3221            other => panic!("expected SkillSourceNotFound, got {other:?}"),
3222        }
3223    }
3224
3225    #[test]
3226    fn load_local_skill_md_directory_without_skill_md_errors() {
3227        let dir = tempfile::tempdir().unwrap();
3228        let empty = dir.path().join("empty");
3229        fs::create_dir_all(&empty).unwrap();
3230        let err = load_local_skill_md(dir.path(), "empty").unwrap_err();
3231        match err {
3232            RuntimeError::SkillSourceMissingSkillMd { path } => {
3233                assert!(path.contains("empty"), "path was: {path}");
3234            }
3235            other => panic!("expected SkillSourceMissingSkillMd, got {other:?}"),
3236        }
3237    }
3238
3239    #[test]
3240    fn load_local_skill_md_absolute_path_ignores_manifest_dir() {
3241        let manifest_dir = tempfile::tempdir().unwrap();
3242        let elsewhere = tempfile::tempdir().unwrap();
3243        let skill = elsewhere.path().join("skill.md");
3244        fs::write(&skill, b"# absolute").unwrap();
3245        let bytes =
3246            load_local_skill_md(manifest_dir.path(), &skill.to_string_lossy()).unwrap();
3247        assert_eq!(bytes, b"# absolute");
3248    }
3249
3250    #[test]
3251    fn stage_session_installs_local_source_skill_without_registry() {
3252        use murmur_artifact::{InferenceConfig, InferenceDriver};
3253
3254        // Registry must never be consulted for a local-source skill.
3255        struct PanicRegistry;
3256        impl Registry for PanicRegistry {
3257            fn resolve(&self, _: &str, _: &str) -> Result<ResolvedArtifact, RegistryError> {
3258                panic!("registry must not be called for a local-source skill");
3259            }
3260            fn publish(
3261                &self,
3262                _: ArtifactMeta,
3263                _: &[u8],
3264            ) -> Result<murmur_artifact::PublishResult, RegistryError> {
3265                unreachable!()
3266            }
3267            fn list_index(&self) -> Result<Vec<ArtifactMeta>, RegistryError> {
3268                unreachable!()
3269            }
3270        }
3271
3272        let project = tempfile::tempdir().unwrap();
3273        let skill_dir = project.path().join("skills").join("my-skill");
3274        fs::create_dir_all(&skill_dir).unwrap();
3275        fs::write(skill_dir.join("skill.md"), b"# my local skill").unwrap();
3276
3277        let inference = InferenceConfig {
3278            transport: "http".into(),
3279            endpoint: Some("http://localhost".into()),
3280            model: "claude-3-haiku".into(),
3281            api_key: None,
3282            driver: Some(InferenceDriver {
3283                artifact: "test-driver".into(),
3284                config: None,
3285            }),
3286            command: None,
3287            compaction: None,
3288            system_prompt: None,
3289            system_prompt_file: None,
3290            system_prompt_artifact: None,
3291            max_turns: 10,
3292        };
3293
3294        let request = StageRequest {
3295            manifest_dir: project.path().to_path_buf(),
3296            capsule_name: "test".to_string(),
3297            capsule_version: "0.0.1".to_string(),
3298            capsule_component_bytes: Vec::new(),
3299            artifacts: vec![crate::types::ArtifactRequest {
3300                name: "my-skill".to_string(),
3301                version: "local".to_string(),
3302                runtime: ArtifactRuntime::Skill,
3303                source: Some("skills/my-skill".to_string()),
3304            }],
3305            allowlisted_tools: HashSet::new(),
3306            lock_expectations: None,
3307            capability_policy: CapabilityPolicy::default(),
3308            inference: Some(inference),
3309            context: None,
3310            otel_endpoint: None,
3311            eval_config_json: None,
3312            case_id: None,
3313            dataset_id: None,
3314            lifecycle: None,
3315            lifecycle_override: None,
3316            trace: None,
3317            workdir: None,
3318            bind_addr: "127.0.0.1".to_string(),
3319            internal_port: None,
3320            job_id: None,
3321        };
3322
3323        let staged = stage_session(Arc::new(PanicRegistry), request).unwrap();
3324        let installed = staged.workdir.join("tools").join("my-skill").join("skill.md");
3325        assert!(installed.exists(), "skill.md not installed at {}", installed.display());
3326        assert_eq!(fs::read(&installed).unwrap(), b"# my local skill");
3327        // No lock artifact recorded for a local-source skill.
3328        assert!(staged.resolved_lock_artifacts.is_empty());
3329        assert_eq!(staged.installed_artifacts.len(), 1);
3330        assert_eq!(staged.installed_artifacts[0].version, "local");
3331        // MURMUR.md (written during staging) lists the skill as callable.
3332        let murmur_md = fs::read_to_string(staged.workdir.join("MURMUR.md")).unwrap();
3333        assert!(
3334            murmur_md.contains("**my-skill**"),
3335            "MURMUR.md missing skill listing:\n{murmur_md}"
3336        );
3337        assert!(
3338            murmur_md.contains("call by name to load guidance"),
3339            "MURMUR.md missing callable skill hint:\n{murmur_md}"
3340        );
3341    }
3342
3343    // ── manage.pull() ──────────────────────────────────────────────────────────
3344
3345    fn zip_with_files(files: &[(&str, &[u8])]) -> Vec<u8> {
3346        use std::io::Write as _;
3347        let mut cursor = std::io::Cursor::new(Vec::<u8>::new());
3348        {
3349            let mut zip = zip::ZipWriter::new(&mut cursor);
3350            let options = zip::write::SimpleFileOptions::default();
3351            for (name, bytes) in files {
3352                zip.start_file(*name, options).unwrap();
3353                zip.write_all(bytes).unwrap();
3354            }
3355            zip.finish().unwrap();
3356        }
3357        cursor.into_inner()
3358    }
3359
3360    fn build_test_state(
3361        registry: Arc<dyn Registry>,
3362        workdir: PathBuf,
3363        lock_path: PathBuf,
3364    ) -> CapsuleStoreState {
3365        let engine = build_engine().unwrap();
3366        let wasi = build_wasi_ctx(&workdir, &[], &CapabilityPolicy::default()).unwrap();
3367        CapsuleStoreState {
3368            limits: crate::limits::ExecutionLimits::default().limiter(),
3369            table: ResourceTable::new(),
3370            wasi,
3371            http: WasiHttpCtx::new(),
3372            http_hooks: NetworkPolicyHooks {
3373                network_allow_rules: Vec::new(),
3374            },
3375            network_allow_rules: Vec::new(),
3376            inference_env: Vec::new(),
3377            engine,
3378            workdir: workdir.clone(),
3379            accessible_workdir: workdir,
3380            tool_components: HashMap::new(),
3381            allowlisted_tools: HashSet::new(),
3382            installed_artifacts: Vec::new(),
3383            session_id: "ses_test".to_string(),
3384            pending_a2a_events: Vec::new(),
3385            capability_policy: CapabilityPolicy::default(),
3386            shell_enforcement: sandbox::ShellEnforcement::environment_only(),
3387            current_traceparent: None,
3388            a2a_task_registry: None,
3389            a2a_sse: None,
3390            a2a_task_id: None,
3391            input_timeout_secs: None,
3392            a2a_chunk_event_id: Arc::new(AtomicU64::new(0)),
3393            a2a_chunks_emitted: Arc::new(AtomicBool::new(false)),
3394            registry,
3395            lock_path,
3396            driver_continuation_id: None,
3397            driver_continuation_context_id: None,
3398            driver_continuation_acked_len: 0,
3399        }
3400    }
3401
3402    // ── Driver continuation bookkeeping on CapsuleStoreState ─────────────
3403
3404    fn continuation_test_state() -> CapsuleStoreState {
3405        let dir = tempfile::tempdir().unwrap();
3406        let workdir = dir.path().to_path_buf();
3407        let lock_path = workdir.join("murmur.lock");
3408        // Registry/workdir are irrelevant to continuation bookkeeping; reuse the helper.
3409        build_test_state(
3410            Arc::new(FakeSkillRegistry::new(Vec::new())),
3411            workdir,
3412            lock_path,
3413        )
3414    }
3415
3416    #[test]
3417    fn continuation_default_is_none_and_active_query_returns_none() {
3418        let state = continuation_test_state();
3419        assert!(state.driver_continuation_id.is_none());
3420        assert!(state.active_continuation(Some("ctx-a")).is_none());
3421        assert!(state.active_continuation(None).is_none());
3422    }
3423
3424    #[test]
3425    fn continuation_record_then_active_requires_matching_context() {
3426        // Scenario 6: a held continuation is only reused under the same context_id.
3427        let mut state = continuation_test_state();
3428        state.record_continuation("cont-1".into(), Some("ctx-a".into()), 2);
3429
3430        assert_eq!(
3431            state.active_continuation(Some("ctx-a")),
3432            Some(("cont-1", 2)),
3433            "same context → continuation is active"
3434        );
3435        assert!(
3436            state.active_continuation(Some("ctx-b")).is_none(),
3437            "different context → continuation must not be reused"
3438        );
3439        assert!(
3440            state.active_continuation(None).is_none(),
3441            "absent context → continuation must not be reused"
3442        );
3443    }
3444
3445    #[test]
3446    fn continuation_clear_drops_all_bookkeeping() {
3447        // Scenarios 3 & 5: driver silence / replace-context commit drops the held id.
3448        let mut state = continuation_test_state();
3449        state.record_continuation("cont-1".into(), Some("ctx-a".into()), 5);
3450        state.clear_continuation();
3451        assert!(state.driver_continuation_id.is_none());
3452        assert_eq!(state.driver_continuation_acked_len, 0);
3453        assert!(state.active_continuation(Some("ctx-a")).is_none());
3454    }
3455
3456    #[test]
3457    fn advance_acked_len_only_affects_same_context_held_continuation() {
3458        // Scenario 7: end_turn persists an assistant message the driver already knows; the
3459        // acked length advances so the next same-context Task wires only its new user message.
3460        let mut state = continuation_test_state();
3461        state.record_continuation("cont-1".into(), Some("ctx-a".into()), 1);
3462
3463        state.advance_continuation_acked_len(Some("ctx-b"), 9);
3464        assert_eq!(
3465            state.driver_continuation_acked_len, 1,
3466            "advancing under a different context must be a no-op"
3467        );
3468
3469        state.advance_continuation_acked_len(Some("ctx-a"), 2);
3470        assert_eq!(state.active_continuation(Some("ctx-a")), Some(("cont-1", 2)));
3471
3472        state.clear_continuation();
3473        state.advance_continuation_acked_len(Some("ctx-a"), 7);
3474        assert_eq!(
3475            state.driver_continuation_acked_len, 0,
3476            "advancing with no held continuation must be a no-op"
3477        );
3478    }
3479
3480    struct FakeSkillRegistry {
3481        bytes: Vec<u8>,
3482        sha256: String,
3483    }
3484
3485    impl FakeSkillRegistry {
3486        fn new(bytes: Vec<u8>) -> Self {
3487            let sha256 = murmur_artifact::sha256_hex(&bytes);
3488            Self { bytes, sha256 }
3489        }
3490    }
3491
3492    impl Registry for FakeSkillRegistry {
3493        fn resolve(&self, name: &str, version: &str) -> Result<ResolvedArtifact, RegistryError> {
3494            Ok(ResolvedArtifact {
3495                meta: ArtifactMeta {
3496                    name: name.to_string(),
3497                    version: version.to_string(),
3498                    runtime: RuntimeType::Static,
3499                    artifact_runtime: "skill".to_string(),
3500                    platforms: Vec::new(),
3501                    description: None,
3502                    tags: Vec::new(),
3503                },
3504                bytes: self.bytes.clone().into(),
3505                sha256: self.sha256.clone(),
3506            })
3507        }
3508
3509        fn publish(
3510            &self,
3511            _meta: ArtifactMeta,
3512            _bytes: &[u8],
3513        ) -> Result<murmur_artifact::PublishResult, RegistryError> {
3514            unreachable!()
3515        }
3516
3517        fn list_index(&self) -> Result<Vec<ArtifactMeta>, RegistryError> {
3518            unreachable!()
3519        }
3520    }
3521
3522    #[test]
3523    fn pull_happy_path_installs_artifact_and_updates_lock() {
3524        let artifact_bytes = zip_with_files(&[
3525            (PACKED_MANIFEST_ENTRY, b"name: my-skill\nversion: 1.0.0\nruntime: skill\n"),
3526            ("skill.md", b"# guidance"),
3527        ]);
3528        let registry = Arc::new(FakeSkillRegistry::new(artifact_bytes));
3529        let expected_sha256 = registry.sha256.clone();
3530
3531        let project = tempfile::tempdir().unwrap();
3532        let workdir = project.path().join("workdir");
3533        fs::create_dir_all(&workdir).unwrap();
3534        let lock_path = project.path().join("murmur.lock");
3535
3536        let mut state = build_test_state(registry, workdir.clone(), lock_path.clone());
3537
3538        let summary = manage::Host::pull(&mut state, "my-skill".to_string(), "1.0.0".to_string())
3539            .expect("pull should succeed");
3540        assert_eq!(summary.name, "my-skill");
3541        assert_eq!(summary.version, "1.0.0");
3542
3543        let installed_skill_md = workdir.join("tools").join("my-skill").join("skill.md");
3544        assert!(installed_skill_md.exists());
3545        assert_eq!(fs::read(&installed_skill_md).unwrap(), b"# guidance");
3546
3547        assert!(state
3548            .installed_artifacts
3549            .iter()
3550            .any(|a| a.name == "my-skill" && a.version == "1.0.0"));
3551
3552        let lock = read_lockfile(&lock_path).expect("murmur.lock should have been written");
3553        let entry = lock.artifact_for("my-skill").expect("lock entry for my-skill");
3554        assert_eq!(entry.resolved_version, "1.0.0");
3555        assert_eq!(entry.sha256.wasm, expected_sha256);
3556    }
3557
3558    #[test]
3559    fn pull_rejects_tampered_bytes_and_writes_nothing() {
3560        struct TamperedRegistry;
3561        impl Registry for TamperedRegistry {
3562            fn resolve(
3563                &self,
3564                name: &str,
3565                version: &str,
3566            ) -> Result<ResolvedArtifact, RegistryError> {
3567                Ok(ResolvedArtifact {
3568                    meta: ArtifactMeta {
3569                        name: name.to_string(),
3570                        version: version.to_string(),
3571                        runtime: RuntimeType::Static,
3572                        artifact_runtime: "skill".to_string(),
3573                        platforms: Vec::new(),
3574                        description: None,
3575                        tags: Vec::new(),
3576                    },
3577                    bytes: b"tampered-bytes".to_vec().into(),
3578                    sha256: "not-the-real-hash".to_string(),
3579                })
3580            }
3581
3582            fn publish(
3583                &self,
3584                _meta: ArtifactMeta,
3585                _bytes: &[u8],
3586            ) -> Result<murmur_artifact::PublishResult, RegistryError> {
3587                unreachable!()
3588            }
3589
3590            fn list_index(&self) -> Result<Vec<ArtifactMeta>, RegistryError> {
3591                unreachable!()
3592            }
3593        }
3594
3595        let project = tempfile::tempdir().unwrap();
3596        let workdir = project.path().join("workdir");
3597        fs::create_dir_all(&workdir).unwrap();
3598        let lock_path = project.path().join("murmur.lock");
3599
3600        let mut state = build_test_state(Arc::new(TamperedRegistry), workdir.clone(), lock_path.clone());
3601
3602        let err = manage::Host::pull(&mut state, "evil-tool".to_string(), "1.0.0".to_string())
3603            .expect_err("tampered bytes must be rejected");
3604        assert!(err.contains("integrity"), "unexpected error message: {err}");
3605
3606        assert!(!workdir.join("tools").join("evil-tool").exists());
3607        assert!(!lock_path.exists());
3608        assert!(state.installed_artifacts.is_empty());
3609    }
3610
3611    #[test]
3612    fn pull_rejects_lock_conflict_and_writes_nothing() {
3613        let artifact_bytes = zip_with_files(&[
3614            (PACKED_MANIFEST_ENTRY, b"name: my-skill\nversion: 2.0.0\nruntime: skill\n"),
3615            ("skill.md", b"# guidance v2"),
3616        ]);
3617        let registry = Arc::new(FakeSkillRegistry::new(artifact_bytes));
3618
3619        let project = tempfile::tempdir().unwrap();
3620        let workdir = project.path().join("workdir");
3621        fs::create_dir_all(&workdir).unwrap();
3622        let lock_path = project.path().join("murmur.lock");
3623
3624        // Pin a different version/hash for this artifact ahead of time.
3625        write_lockfile_atomic(
3626            &lock_path,
3627            &MurmurLock {
3628                lock_version: LOCK_VERSION,
3629                artifacts: vec![LockedArtifact {
3630                    name: "my-skill".to_string(),
3631                    resolved_version: "1.0.0".to_string(),
3632                    sha256: LockedSha256 {
3633                        wasm: "pinned-hash-from-earlier-pull".to_string(),
3634                    },
3635                }],
3636            },
3637        )
3638        .unwrap();
3639
3640        let mut state = build_test_state(registry, workdir.clone(), lock_path.clone());
3641
3642        let err = manage::Host::pull(&mut state, "my-skill".to_string(), "2.0.0".to_string())
3643            .expect_err("lock conflict must be rejected");
3644        assert!(err.contains("murmur.lock conflict"), "unexpected error message: {err}");
3645
3646        assert!(!workdir.join("tools").join("my-skill").join("skill.md").exists());
3647        assert!(state.installed_artifacts.is_empty());
3648
3649        // Lock must be left exactly as it was.
3650        let lock = read_lockfile(&lock_path).unwrap();
3651        let entry = lock.artifact_for("my-skill").unwrap();
3652        assert_eq!(entry.resolved_version, "1.0.0");
3653        assert_eq!(entry.sha256.wasm, "pinned-hash-from-earlier-pull");
3654    }
3655
3656    /// H-3 evidence-of-done: two independent `stage_session` calls against the same
3657    /// `--workdir` must share the same `accessible_workdir` (the checkpoint signing root),
3658    /// even though `workdir` (internal staging dir) is fresh each time. A checkpoint file
3659    /// signed by the first "session" (via a `SessionEnd` dispatch) must verify cleanly on
3660    /// the second, independent "session"'s `SessionStart` dispatch; tampering it between the
3661    /// two must cause the second session to quarantine it.
3662    #[test]
3663    fn checkpoint_signing_survives_resume_across_independent_stage_sessions() {
3664        use crate::checkpoint_sign::test_support::{with_home, HOME_LOCK};
3665        use crate::hooks::HookEvent;
3666        use murmur_artifact::{InferenceConfig, InferenceDriver};
3667
3668        struct EmptyRegistry;
3669        impl Registry for EmptyRegistry {
3670            fn resolve(&self, _: &str, _: &str) -> Result<ResolvedArtifact, RegistryError> {
3671                unreachable!("no artifacts declared in this test")
3672            }
3673            fn publish(
3674                &self,
3675                _: ArtifactMeta,
3676                _: &[u8],
3677            ) -> Result<murmur_artifact::PublishResult, RegistryError> {
3678                unreachable!()
3679            }
3680            fn list_index(&self) -> Result<Vec<ArtifactMeta>, RegistryError> {
3681                unreachable!()
3682            }
3683        }
3684
3685        fn minimal_inference() -> InferenceConfig {
3686            InferenceConfig {
3687                transport: "http".into(),
3688                endpoint: Some("http://localhost".into()),
3689                model: "test-model".into(),
3690                api_key: None,
3691                driver: Some(InferenceDriver {
3692                    artifact: "test-driver".into(),
3693                    config: None,
3694                }),
3695                command: None,
3696                compaction: None,
3697                system_prompt: None,
3698                system_prompt_file: None,
3699                system_prompt_artifact: None,
3700                max_turns: 10,
3701            }
3702        }
3703
3704        fn stage(user_dir: &Path, manifest_dir: &Path) -> StagedSession {
3705            let request = StageRequest {
3706                manifest_dir: manifest_dir.to_path_buf(),
3707                capsule_name: "test".to_string(),
3708                capsule_version: "0.0.1".to_string(),
3709                capsule_component_bytes: Vec::new(),
3710                artifacts: Vec::new(),
3711                allowlisted_tools: HashSet::new(),
3712                lock_expectations: None,
3713                capability_policy: CapabilityPolicy::default(),
3714                inference: Some(minimal_inference()),
3715                context: None,
3716                otel_endpoint: None,
3717                eval_config_json: None,
3718                case_id: None,
3719                dataset_id: None,
3720                lifecycle: None,
3721                lifecycle_override: None,
3722                trace: None,
3723                workdir: Some(user_dir.to_path_buf()),
3724                bind_addr: "127.0.0.1".to_string(),
3725                internal_port: None,
3726                job_id: None,
3727            };
3728            stage_session(Arc::new(EmptyRegistry), request).unwrap()
3729        }
3730
3731        async fn hook_runtime_for(staged: &StagedSession) -> HookRuntime {
3732            HookRuntime::new(
3733                &staged.engine,
3734                &staged.workdir,
3735                &staged.accessible_workdir,
3736                Vec::new(),
3737                SessionContextData {
3738                    capsule_name: staged.capsule_name.clone(),
3739                    capsule_version: staged.capsule_version.clone(),
3740                    session_id: staged.session_id.clone(),
3741                    model: "test-model".to_string(),
3742                    capabilities: Vec::new(),
3743                },
3744                HookEnvVars::default(),
3745                crate::limits::ExecutionLimits::default(),
3746            )
3747            .await
3748            .unwrap()
3749        }
3750
3751        let _guard = HOME_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3752        let home = tempfile::tempdir().unwrap();
3753        let manifest_dir = tempfile::tempdir().unwrap();
3754        let user_dir = tempfile::tempdir().unwrap();
3755
3756        let (first, second) = with_home(home.path(), || {
3757            let first = stage(user_dir.path(), manifest_dir.path());
3758            let second = stage(user_dir.path(), manifest_dir.path());
3759            (first, second)
3760        });
3761
3762        // The design doc's central empirical question: does `accessible_workdir` stay stable
3763        // (and thus usable as the checkpoint signing root) across two independent launches
3764        // against the same `--workdir`, even though the internal `workdir` is regenerated?
3765        assert_eq!(first.accessible_workdir, user_dir.path());
3766        assert_eq!(second.accessible_workdir, user_dir.path());
3767        assert_ne!(
3768            first.workdir, second.workdir,
3769            "internal staging workdir must be fresh per session_id"
3770        );
3771        assert_ne!(first.session_id, second.session_id);
3772
3773        let checkpoints = first.accessible_workdir.join("checkpoints");
3774        fs::create_dir_all(&checkpoints).unwrap();
3775        fs::write(checkpoints.join("summary.md"), "goals: ship it").unwrap();
3776
3777        with_home(home.path(), || {
3778            let rt = tokio::runtime::Runtime::new().unwrap();
3779            rt.block_on(async {
3780                // "Session one" ends, signing whatever checkpoint state exists.
3781                let mut hooks_one = hook_runtime_for(&first).await;
3782                hooks_one
3783                    .emit(
3784                        &first.workdir,
3785                        HookEvent::SessionEnd {
3786                            total_turns: 1,
3787                            exit_status: "ok".to_string(),
3788                        },
3789                    )
3790                    .await;
3791
3792                // "Session two" is an independently staged/launched resume against the same
3793                // --workdir. Its SessionStart must verify the checkpoint session one signed.
3794                let mut hooks_two = hook_runtime_for(&second).await;
3795                hooks_two
3796                    .emit(&second.workdir, HookEvent::SessionStart)
3797                    .await;
3798            });
3799        });
3800
3801        assert!(
3802            checkpoints.join("summary.md").exists(),
3803            "validly-signed checkpoint must survive an independent resume"
3804        );
3805        assert!(!checkpoints.join("summary.md.rejected").exists());
3806
3807        // Simulate tampering (e.g. a compromised tool) between the two sessions.
3808        fs::write(checkpoints.join("summary.md"), "tampered by a compromised tool").unwrap();
3809
3810        with_home(home.path(), || {
3811            let rt = tokio::runtime::Runtime::new().unwrap();
3812            rt.block_on(async {
3813                let mut hooks_three = hook_runtime_for(&second).await;
3814                hooks_three
3815                    .emit(&second.workdir, HookEvent::SessionStart)
3816                    .await;
3817            });
3818        });
3819
3820        assert!(
3821            !checkpoints.join("summary.md").exists(),
3822            "tampered checkpoint must be renamed away on the next resume"
3823        );
3824        assert!(checkpoints.join("summary.md.rejected").exists());
3825    }
3826
3827    /// Writes an executable shell script native-tool fixture that echoes the given env
3828    /// var names (space-separated `NAME=value`, empty string for an unset var) into the
3829    /// `data` field of a passing ToolResult JSON payload on stdout.
3830    fn write_env_echo_native_tool(dir: &Path, name: &str, env_vars: &[&str]) -> std::path::PathBuf {
3831        use std::os::unix::fs::PermissionsExt;
3832
3833        let script_path = dir.join(name);
3834        let echoes: String = env_vars
3835            .iter()
3836            .map(|var| format!(r#"echo -n "{var}=${var} ""#))
3837            .collect::<Vec<_>>()
3838            .join("\n");
3839        let script = format!(
3840            "#!/bin/sh\ncat >/dev/null\nprintf '{{\"status\":\"passed\",\"data\":\"'\n{echoes}\nprintf '\"}}'\n"
3841        );
3842        fs::write(&script_path, script).unwrap();
3843        fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)).unwrap();
3844        script_path
3845    }
3846
3847    #[test]
3848    fn dispatch_native_tool_gets_synthetic_home_matching_execute_shell() {
3849        let tmp = TempDir::new().unwrap();
3850        let binary = write_env_echo_native_tool(tmp.path(), "echo-home", &["HOME"]);
3851        let policy = CapabilityPolicy::default();
3852
3853        let result = dispatch_native_tool(
3854            "echo-home",
3855            murmur::tool::run::ToolInput {
3856                data: None,
3857                log_path: None,
3858            },
3859            &binary,
3860            tmp.path(),
3861            &policy,
3862        )
3863        .unwrap();
3864
3865        let expected_home = tmp.path().join(".capsule-home");
3866        let data = result.data.unwrap_or_default();
3867        assert!(
3868            data.contains(&format!("HOME={}", expected_home.display())),
3869            "expected synthetic HOME in native tool output, got: {data}"
3870        );
3871        assert!(expected_home.is_dir());
3872    }
3873
3874    #[test]
3875    fn dispatch_native_tool_strips_credential_shaped_var() {
3876        let tmp = TempDir::new().unwrap();
3877        let binary = write_env_echo_native_tool(tmp.path(), "echo-token", &["GITHUB_TOKEN"]);
3878        let policy = CapabilityPolicy::default();
3879
3880        std::env::set_var("GITHUB_TOKEN", "leaked-token");
3881        let result = dispatch_native_tool(
3882            "echo-token",
3883            murmur::tool::run::ToolInput {
3884                data: None,
3885                log_path: None,
3886            },
3887            &binary,
3888            tmp.path(),
3889            &policy,
3890        )
3891        .unwrap();
3892        std::env::remove_var("GITHUB_TOKEN");
3893
3894        let data = result.data.unwrap_or_default();
3895        assert!(
3896            !data.contains("leaked-token"),
3897            "GITHUB_TOKEN must not reach a native tool subprocess, got: {data}"
3898        );
3899    }
3900
3901    #[test]
3902    fn dispatch_native_tool_strips_wildcard_credential_pattern() {
3903        let tmp = TempDir::new().unwrap();
3904        let binary = write_env_echo_native_tool(tmp.path(), "echo-stripe", &["STRIPE_API_KEY"]);
3905        let policy = CapabilityPolicy::default();
3906
3907        std::env::set_var("STRIPE_API_KEY", "leaked-key");
3908        let result = dispatch_native_tool(
3909            "echo-stripe",
3910            murmur::tool::run::ToolInput {
3911                data: None,
3912                log_path: None,
3913            },
3914            &binary,
3915            tmp.path(),
3916            &policy,
3917        )
3918        .unwrap();
3919        std::env::remove_var("STRIPE_API_KEY");
3920
3921        let data = result.data.unwrap_or_default();
3922        assert!(
3923            !data.contains("leaked-key"),
3924            "*_API_KEY wildcard pattern must strip STRIPE_API_KEY from native tool subprocess, got: {data}"
3925        );
3926    }
3927
3928    #[test]
3929    fn dispatch_native_tool_keeps_safe_baseline_var() {
3930        let tmp = TempDir::new().unwrap();
3931        let binary = write_env_echo_native_tool(tmp.path(), "echo-cargo-home", &["CARGO_HOME"]);
3932        let policy = CapabilityPolicy::default();
3933
3934        std::env::set_var("CARGO_HOME", "/fake/cargo/home");
3935        let result = dispatch_native_tool(
3936            "echo-cargo-home",
3937            murmur::tool::run::ToolInput {
3938                data: None,
3939                log_path: None,
3940            },
3941            &binary,
3942            tmp.path(),
3943            &policy,
3944        )
3945        .unwrap();
3946        std::env::remove_var("CARGO_HOME");
3947
3948        let data = result.data.unwrap_or_default();
3949        assert!(
3950            data.contains("CARGO_HOME=/fake/cargo/home"),
3951            "safe baseline var CARGO_HOME must pass through to native tool subprocess, got: {data}"
3952        );
3953    }
3954
3955    #[test]
3956    fn dispatch_native_tool_composes_policy_strip_env() {
3957        let tmp = TempDir::new().unwrap();
3958        let binary =
3959            write_env_echo_native_tool(tmp.path(), "echo-mycompany", &["MYCOMPANY_SECRET"]);
3960        let policy = CapabilityPolicy {
3961            shell_strip_env: vec!["MYCOMPANY_*".to_string()],
3962            ..CapabilityPolicy::default()
3963        };
3964
3965        std::env::set_var("MYCOMPANY_SECRET", "leaked-secret");
3966        let result = dispatch_native_tool(
3967            "echo-mycompany",
3968            murmur::tool::run::ToolInput {
3969                data: None,
3970                log_path: None,
3971            },
3972            &binary,
3973            tmp.path(),
3974            &policy,
3975        )
3976        .unwrap();
3977        std::env::remove_var("MYCOMPANY_SECRET");
3978
3979        let data = result.data.unwrap_or_default();
3980        assert!(
3981            !data.contains("leaked-secret"),
3982            "policy.shell_strip_env pattern must compose for native tool subprocess, got: {data}"
3983        );
3984    }
3985}