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, ConversationMode, HookBinding,
15 InferenceConfig, InterpreterRuntimeGrant, LifecycleConfig, LockedArtifact, LockedSha256,
16 LockfileError, MurmurLock, Registry, RegistryError, RuntimeType, TaskAcceptance, LOCK_VERSION,
17 MANIFEST_FILENAME, PACKED_MANIFEST_ENTRY, W_SEC_003, W_SEC_006, W_SEC_007, W_SEC_008,
18 W_SEC_009, W_SEC_011,
19};
20use serde_yaml::Value;
21use wasmtime::{
22 component::{Component, HasSelf, Linker, ResourceTable},
23 Config, Engine, Store,
24};
25use wasmtime_wasi::{DirPerms, FilePerms, WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
26use wasmtime_wasi_http::{
27 p2::{
28 bindings::http::types::ErrorCode as WasiHttpErrorCode,
29 body::HyperOutgoingBody,
30 types::{HostFutureIncomingResponse, OutgoingRequestConfig},
31 HttpResult, WasiHttpCtxView, WasiHttpHooks, WasiHttpView,
32 },
33 WasiHttpCtx,
34};
35
36use crate::{
37 a2a::{IncomingTask, TaskRegistry, TaskState},
38 agent,
39 artifact::{extract_manifest_yaml, extract_native_binary, extract_root_wasm, extract_skill_md},
40 bindings::host::murmur::{
41 self, artifact_manager::manage, message::send, tool_registry::invoke,
42 },
43 cgroup,
44 containment::{achieved_containment_class, check_containment_floor},
45 errors::RuntimeError,
46 hooks::{
47 dispatch_stage, HookEnvVars, HookEvent, HookRuntime, SessionContextData, ShellDispatchInfo,
48 TaskReopen,
49 },
50 identity::{self, CapsuleIdentity},
51 inference_import::HookInferenceCtx,
52 limits::{classify_guest_failure, EpochTicker, ExecutionLimiter, GuestFailure},
53 murmur_md,
54 network_policy::{
55 effective_tool_network_rules, parse_network_allow_rules, resolve_scoped_dir,
56 validate_filesystem_scope, HookCapabilityGrant, NetworkAllowRule, RequestTarget,
57 ToolCapabilityGrant,
58 },
59 otel::OtelEmitter,
60 outgoing, resources, sandbox,
61 shell::{
62 build_shell_env, build_wasi_env_allowlist, execute_shell, is_shell_interpreter,
63 shell_tool_manifest_yaml, split_shell_words, ShellResult,
64 },
65 streaming::{
66 emit_chunk_sse, emit_sse, emit_thinking_chunk_sse, SseBroadcast, SseEventBuffer,
67 StreamStatus, TaskStatusUpdateEvent,
68 },
69 trace::TraceWriter,
70 types::{
71 ArtifactRequest, CapabilityPolicy, DispatchOutcome, InstalledArtifactSummary, LaunchResult,
72 ResolvedLockArtifact, StageRequest, StagedHookArtifact, StagedSession,
73 },
74};
75
76const WIT_CAPSULE_IFACE_VERSIONED: &str = "murmur:capsule/run@0.1.0";
81const WIT_TOOL_IFACE_VERSIONED: &str = "murmur:tool/run@0.1.0";
84
85const WIT_TOOL_REGISTRY_IFACE: &str = "murmur:tool-registry/invoke@0.1.0";
90const WIT_TEXT_CHUNKS_IFACE: &str = "murmur:text/chunks@0.1.0";
91const WIT_TASK_IFACE: &str = "murmur:task/task@0.1.0";
92
93fn resolve_versioned_iface<T>(
98 instance: &wasmtime::component::Instance,
99 store: &mut Store<T>,
100 versioned: &str,
101) -> Option<wasmtime::component::ComponentExportIndex> {
102 instance.get_export_index(&mut *store, None, versioned)
103}
104
105#[allow(clippy::too_many_arguments)]
129async fn run_task_with_reopens(
130 state: &mut CapsuleStoreState,
131 workdir: &Path,
132 inference: &InferenceConfig,
133 system_prompt: Option<String>,
134 run_config: agent::AgentRunConfig,
135 hooks: &mut HookRuntime,
136 trace: &mut TraceWriter,
137 otel: &mut OtelEmitter,
138 agent_task_id: Option<String>,
139 sse: Option<(SseBroadcast, Arc<Mutex<SseEventBuffer>>)>,
140 accessible_workdir: &Path,
141 capsule_name: &str,
142 capsule_version: &str,
143 mode: ConversationMode,
144 context_id: Option<String>,
145 trace_task_id: &str,
146) -> Result<(), RuntimeError> {
147 let task_md_path = accessible_workdir.join("task.md");
148 let original_task = tokio::fs::read_to_string(&task_md_path)
151 .await
152 .unwrap_or_default();
153 let mut feedback: Vec<(String, String)> = Vec::new();
155 let mut reopens_used: u32 = 0;
156
157 loop {
158 let remaining_turns = inference.max_turns.saturating_sub(trace.task_turns());
163 let mut attempt_inference = inference.clone();
164 attempt_inference.max_turns = remaining_turns;
165
166 let result = agent::run_agent_loop(
167 state,
168 workdir,
169 &attempt_inference,
170 system_prompt.clone(),
171 run_config.clone(),
172 hooks,
173 trace,
174 otel,
175 agent_task_id.clone(),
176 sse.clone(),
177 accessible_workdir,
178 capsule_name,
179 capsule_version,
180 mode.clone(),
181 context_id.clone(),
182 )
183 .await;
184
185 let exit_str = if result.is_ok() { "ok" } else { "failed" };
186
187 let reopen = hooks
189 .dispatch_task_end(trace_task_id.to_string(), exit_str.to_string())
190 .await;
191
192 match reopen {
193 Some(TaskReopen { hook_name, reason }) => {
194 let budget_ok = reopens_used < inference.max_task_reopens;
198 let turns_ok = trace.task_turns() < inference.max_turns;
199 if budget_ok && turns_ok {
200 reopens_used += 1;
201 feedback.push((hook_name.clone(), reason.clone()));
202 let _ = trace
203 .write_task_reopened(trace_task_id, &hook_name, &reason, reopens_used)
204 .await;
205 let rewritten = build_reopen_task_md(&original_task, &feedback);
209 if let Err(e) = tokio::fs::write(&task_md_path, rewritten.as_bytes()).await {
210 eprintln!(
211 "[capsule-runtime] failed to inject reopen feedback into task.md: {e}"
212 );
213 }
214 continue;
215 }
216 let _ = trace
220 .write_task_end(trace_task_id, "reopen_budget_exhausted", reopens_used)
221 .await;
222 return Err(RuntimeError::AgentLoopFailed(format!(
223 "task reopen budget exhausted after {reopens_used} reopen(s): hook \
224 '{hook_name}' still requested another reopen"
225 )));
226 }
227 None => {
228 let _ = trace
230 .write_task_end(trace_task_id, exit_str, reopens_used)
231 .await;
232 return result;
233 }
234 }
235 }
236}
237
238fn build_reopen_task_md(original: &str, feedback: &[(String, String)]) -> String {
242 let mut out = original.trim_end().to_string();
243 out.push_str(
244 "\n\n---\n\n# Reopen feedback\n\nThe previous attempt was not accepted. Address the \
245 following feedback, then continue.\n",
246 );
247 for (i, (hook_name, reason)) in feedback.iter().enumerate() {
248 out.push_str(&format!(
249 "\n## Reopen {} — from hook `{}`\n\n{}\n",
250 i + 1,
251 hook_name,
252 reason.trim()
253 ));
254 }
255 out
256}
257
258const SSE_BROADCAST_CAPACITY: usize = 128;
261
262const SSE_REPLAY_CAPACITY: usize = 512;
266
267fn has_compaction_hook(hooks: &[StagedHookArtifact]) -> bool {
271 hooks.iter().any(|h| {
272 matches!(
273 h.config.binding,
274 HookBinding::OnCompaction | HookBinding::All
275 )
276 })
277}
278
279pub fn stage_session(
284 registry: Arc<dyn Registry>,
285 request: StageRequest,
286) -> Result<StagedSession, RuntimeError> {
287 validate_capability_policy(&request.capability_policy)?;
288 let workdir_exec = request.capability_policy.workdir_exec_allowed;
297 let enforcement_tier = sandbox::detect_enforcement_tier();
303 let sealed_blocker = crate::containment::detect_sealed_blocker();
306 let achieved_containment = achieved_containment_class(enforcement_tier, workdir_exec);
307 check_containment_floor(
308 request.declared_containment_floor,
309 achieved_containment,
310 sealed_blocker,
311 workdir_exec,
312 )?;
313 let scope_report = crate::containment::scope_report_for_tier(
318 &request.capability_policy,
319 request.declared_containment_floor,
320 enforcement_tier,
321 sealed_blocker,
322 );
323 crate::staged_runtime::check_staged_runtime_floor(
329 &request.capability_policy.shell_staged_runtime,
330 request.declared_containment_floor,
331 )?;
332 crate::reachability::check_interpreted_entrypoints_reachable(
339 &request.capability_policy,
340 request.declared_containment_floor,
341 )?;
342 crate::network_namespace::check_egress_namespace(
358 !request.capability_policy.shell_allow.is_empty()
359 || !request.capability_policy.spawn_allow.is_empty(),
360 crate::network_namespace::detect_egress_namespace_blocker(),
361 )?;
362 warn_on_interpreter_runtime_grants(&request.capability_policy.shell_interpreter_runtime);
366 warn_on_workdir_exec(workdir_exec);
370 crate::reachability::warn_on_unreachable_toolchain_helpers(
378 &request.capability_policy,
379 request.declared_containment_floor,
380 );
381
382 let engine = build_engine()?;
383 let epoch_ticker = EpochTicker::spawn(&engine);
386 let lock_expectations = request.lock_expectations.map(|entries| {
387 entries
388 .into_iter()
389 .map(|entry| (entry.name.clone(), entry))
390 .collect::<HashMap<_, _>>()
391 });
392
393 let mut resolved_lock_artifacts = Vec::with_capacity(request.artifacts.len());
394 let mut installed_artifacts = Vec::with_capacity(request.artifacts.len());
395 let mut installed_manifests = Vec::with_capacity(request.artifacts.len());
396 let mut tool_components = HashMap::with_capacity(request.artifacts.len());
397 let mut artifact_grants: HashMap<String, ToolCapabilityGrant> = HashMap::new();
400 let mut hook_components = Vec::new();
401 let ceiling_network_allow_rules =
405 parse_network_allow_rules(&request.capability_policy.network_allow)?;
406 let mut native_binaries: Vec<(String, Vec<u8>)> = Vec::new();
408 let mut skill_files: Vec<(String, Vec<u8>)> = Vec::new();
410
411 for artifact in &request.artifacts {
412 if let Some(source) = &artifact.source {
415 let skill_md = load_local_skill_md(&request.manifest_dir, source)?;
416 skill_files.push((artifact.name.clone(), skill_md));
417 installed_artifacts.push(InstalledArtifactSummary {
418 name: artifact.name.clone(),
419 version: artifact.version.clone(),
420 runtime: artifact.runtime.clone(),
421 implementation: None,
422 });
423 continue;
424 }
425
426 let (resolved_version, expected_hash) = match &lock_expectations {
427 Some(expected_by_name) => {
428 let expected = expected_by_name.get(&artifact.name).ok_or_else(|| {
429 RuntimeError::LockMissingEntry {
430 name: artifact.name.clone(),
431 }
432 })?;
433
434 if artifact.version != expected.resolved_version {
435 return Err(RuntimeError::LockVersionMismatch {
436 name: artifact.name.clone(),
437 requested: artifact.version.clone(),
438 pinned: expected.resolved_version.clone(),
439 });
440 }
441
442 (
443 expected.resolved_version.clone(),
444 Some(expected.sha256_wasm.clone()),
445 )
446 }
447 None => (artifact.version.clone(), None),
448 };
449
450 let resolved = registry
457 .resolve_with_platform(&artifact.name, &resolved_version, Some(current_platform()))
458 .map_err(|err| map_registry_error(&artifact.name, &resolved_version, err))?;
459
460 verify_sha256(
461 &artifact.name,
462 &resolved_version,
463 &resolved.bytes,
464 &resolved.sha256,
465 )
466 .map_err(|_| RuntimeError::artifact_integrity_failed(&artifact.name, &resolved_version))?;
467
468 if let Some(expected) = expected_hash {
469 if resolved.sha256 != expected {
470 return Err(RuntimeError::artifact_integrity_failed(
471 &artifact.name,
472 &resolved_version,
473 ));
474 }
475 }
476
477 let manifest_yaml =
478 extract_manifest_yaml(&artifact.name, &resolved_version, &resolved.bytes)?;
479
480 installed_manifests.push((artifact.name.clone(), manifest_yaml.clone()));
481
482 let mut artifact_implementation: Option<ArtifactImplementation> = None;
483
484 match artifact.runtime {
485 ArtifactRuntime::Tool => {
486 let implementation = parse_tool_implementation_from_yaml(&manifest_yaml);
487 artifact_implementation = Some(implementation.clone());
488 match implementation {
489 ArtifactImplementation::Native => {
490 warn_on_unenforceable_native_capabilities(
495 &artifact.name,
496 artifact.capabilities.as_ref(),
497 );
498 let binary = extract_native_binary(
499 &artifact.name,
500 &resolved_version,
501 &resolved.bytes,
502 )?;
503 native_binaries.push((artifact.name.clone(), binary));
504 }
505 ArtifactImplementation::Wasm => {
506 stage_artifact_grant(
507 artifact,
508 &ceiling_network_allow_rules,
509 &mut artifact_grants,
510 )?;
511 let tool_wasm =
512 extract_root_wasm(&artifact.name, &resolved_version, &resolved.bytes)?;
513 let tool_component = Component::new(&engine, tool_wasm).map_err(|err| {
514 RuntimeError::ToolComponentCompile {
515 name: artifact.name.clone(),
516 version: resolved_version.clone(),
517 message: err.to_string(),
518 }
519 })?;
520 tool_components.insert(artifact.name.clone(), tool_component);
521 }
522 }
523 }
524 ArtifactRuntime::Driver => {
525 stage_artifact_grant(artifact, &ceiling_network_allow_rules, &mut artifact_grants)?;
530 let tool_wasm =
531 extract_root_wasm(&artifact.name, &resolved_version, &resolved.bytes)?;
532 let tool_component = Component::new(&engine, tool_wasm).map_err(|err| {
533 RuntimeError::ToolComponentCompile {
534 name: artifact.name.clone(),
535 version: resolved_version.clone(),
536 message: err.to_string(),
537 }
538 })?;
539 tool_components.insert(artifact.name.clone(), tool_component);
540 }
541 ArtifactRuntime::Hook => {
542 let hook_config = parse_hook_config_from_yaml(&manifest_yaml).map_err(|e| {
543 RuntimeError::Runtime(format!(
544 "hook {}@{} invalid config: {e}",
545 artifact.name, resolved_version
546 ))
547 })?;
548 let hook_wasm =
549 extract_root_wasm(&artifact.name, &resolved_version, &resolved.bytes)?;
550 let hook_component = Component::new(&engine, hook_wasm).map_err(|err| {
551 RuntimeError::ToolComponentCompile {
552 name: artifact.name.clone(),
553 version: resolved_version.clone(),
554 message: err.to_string(),
555 }
556 })?;
557 let grant = HookCapabilityGrant::derive(artifact.capabilities.as_ref())?;
564 warn_on_inert_hook_capabilities(&artifact.name, artifact.capabilities.as_ref());
565 hook_components.push(StagedHookArtifact {
566 name: artifact.name.clone(),
567 version: resolved_version.clone(),
568 component: hook_component,
569 config: hook_config,
570 grant,
571 on_overflow: artifact.on_overflow,
575 });
576 }
577 ArtifactRuntime::Skill => {
578 let skill_md =
579 extract_skill_md(&artifact.name, &resolved_version, &resolved.bytes)?;
580 skill_files.push((artifact.name.clone(), skill_md));
581 }
582 }
583
584 resolved_lock_artifacts.push(ResolvedLockArtifact {
585 name: artifact.name.clone(),
586 resolved_version: resolved_version.clone(),
587 sha256_wasm: resolved.sha256,
588 });
589 installed_artifacts.push(InstalledArtifactSummary {
590 name: artifact.name.clone(),
591 version: resolved_version,
592 runtime: artifact.runtime.clone(),
593 implementation: artifact_implementation,
594 });
595 }
596
597 let capsule_component =
600 if request.inference.is_some() && request.capsule_component_bytes.is_empty() {
601 None
602 } else if request.capsule_component_bytes.is_empty() {
603 return Err(RuntimeError::CapsuleCompile(
604 "capsule component bytes are required for non-agent capsules".to_string(),
605 ));
606 } else {
607 Some(
608 Component::new(&engine, &request.capsule_component_bytes)
609 .map_err(|err| RuntimeError::CapsuleCompile(err.to_string()))?,
610 )
611 };
612
613 let session_id = generate_session_id();
614 let (workdir, accessible_workdir) = if let Some(ref user_dir) = request.workdir {
615 let user_dir = if user_dir.is_absolute() {
616 user_dir.clone()
617 } else {
618 std::env::current_dir()
619 .map_err(|e| RuntimeError::Runtime(format!("failed to get cwd: {e}")))?
620 .join(user_dir)
621 };
622 if !user_dir.exists() || !user_dir.is_dir() {
623 return Err(RuntimeError::Runtime(format!(
624 "workdir '{}' does not exist or is not a directory",
625 user_dir.display()
626 )));
627 }
628 let session_dir = user_dir.join(".murmur").join(&session_id);
629 (session_dir, user_dir)
630 } else {
631 let dir = request.manifest_dir.join("workdir").join(&session_id);
632 let accessible = dir.clone();
633 (dir, accessible)
634 };
635 fs::create_dir_all(workdir.join("tools")).map_err(|source| RuntimeError::CreateWorkdir {
636 path: workdir.display().to_string(),
637 source,
638 })?;
639
640 for (name, manifest_yaml) in installed_manifests {
641 write_tool_manifest(&workdir, &name, &manifest_yaml)?;
642 }
643
644 install_native_binaries(&workdir, native_binaries)?;
645 install_skill_files(&workdir, skill_files)?;
646
647 write_shell_tool_manifests(&workdir, &request.capability_policy.shell_allow)?;
649
650 let stage_env = HookEnvVars::default();
652 dispatch_stage(
653 &engine,
654 &workdir,
655 &hook_components,
656 request.capability_policy.shell_allow.clone(),
657 &stage_env,
658 request.capability_policy.hook_limits(),
659 )?;
660
661 if let Some(ref inference) = request.inference {
665 let partial_identity = CapsuleIdentity {
666 capsule_name: request.capsule_name.clone(),
667 capsule_version: request.capsule_version.clone(),
668 session_id: session_id.clone(),
669 capsule_url: String::new(),
670 };
671 murmur_md::write_murmur_md(
672 &workdir,
673 Some(inference),
674 request.context.as_ref(),
675 has_compaction_hook(&hook_components),
676 &request.capability_policy,
677 &partial_identity,
678 );
679 }
680
681 if request.workdir.is_some() {
684 let src = request.manifest_dir.join(MANIFEST_FILENAME);
685 let dst = accessible_workdir.join(MANIFEST_FILENAME);
686 if src.exists() && !dst.exists() {
687 if let Err(e) = fs::copy(&src, &dst) {
688 eprintln!(
689 "[capsule-runtime] warning: failed to copy {MANIFEST_FILENAME} to workdir: {e}"
690 );
691 }
692 }
693 }
694
695 let lifecycle = resolve_lifecycle(request.lifecycle, request.lifecycle_override.as_ref());
697
698 Ok(StagedSession {
699 session_id,
700 workdir,
701 accessible_workdir,
702 manifest_dir: request.manifest_dir,
703 capsule_name: request.capsule_name,
704 capsule_version: request.capsule_version,
705 capsule_url: String::new(), resolved_lock_artifacts,
707 installed_artifacts,
708 inference: request.inference,
709 system_prompt_overridden: request.system_prompt_overridden,
710 context: request.context,
711 engine,
712 capsule_component,
713 tool_components,
714 artifact_grants,
715 hook_components,
716 allowlisted_tools: request.allowlisted_tools,
717 capability_policy: CapabilityPolicy {
722 containment_floor: request.declared_containment_floor,
723 ..request.capability_policy
724 },
725 otel_endpoint: request.otel_endpoint,
726 eval_config_json: request.eval_config_json,
727 case_id: request.case_id,
728 dataset_id: request.dataset_id,
729 lifecycle,
730 trace_include_tool_output: request
731 .trace
732 .as_ref()
733 .map(|t| t.include_tool_output)
734 .unwrap_or(false),
735 bind_addr: request.bind_addr,
736 internal_port: request.internal_port,
737 declared_containment_floor: request.declared_containment_floor,
738 scope_report,
739 registry,
740 _epoch_ticker: epoch_ticker,
741 })
742}
743
744pub fn launch_session(
749 mut staged: StagedSession,
750 on_url: impl FnOnce(&str),
751) -> Result<LaunchResult, RuntimeError> {
752 let network_allow_rules = parse_network_allow_rules(&staged.capability_policy.network_allow)?;
753
754 let has_native_artifact = staged.installed_artifacts.iter().any(|artifact| {
763 matches!(
764 artifact.implementation,
765 Some(murmur_artifact::ArtifactImplementation::Native)
766 )
767 });
768 let requires_process_bounding =
769 cgroup::requires_process_bounding(&staged.capability_policy, has_native_artifact);
770 let cgroup_scope = cgroup::prepare_scope(
771 requires_process_bounding,
772 &staged.capability_policy.resources,
773 &staged.session_id,
774 &staged.workdir,
775 )
776 .map_err(|reason| RuntimeError::CgroupDelegationUnavailable { reason })?;
777 let workdir_guard = Some(resources::WorkdirGuard::spawn(
778 &staged.workdir,
779 staged.capability_policy.resources.workdir_max_bytes,
780 ));
781
782 let shell_enforcement = sandbox::ShellEnforcement::resolve(
783 &staged.capability_policy,
784 staged.declared_containment_floor,
785 )
786 .map_err(RuntimeError::Runtime)?
787 .with_host_bounding(cgroup_scope, workdir_guard);
788 let inference_env = staged
789 .inference
790 .as_ref()
791 .map(inference_env_pairs)
792 .unwrap_or_default();
793
794 if let Some(ref inference) = staged.inference {
795 let workdir = staged.workdir.clone();
796 let system_prompt = resolve_system_prompt(&staged.manifest_dir, &workdir, inference)?;
797 let compaction_system_prompt =
798 resolve_compaction_system_prompt(&staged.manifest_dir, inference.compaction.as_ref())?;
799 let session_id = staged.session_id.clone();
800 let accessible_workdir = staged.accessible_workdir.clone();
801 let context_window = resolve_context_window(staged.context.as_ref());
802
803 let run_config = agent::AgentRunConfig {
804 context_window,
805 compaction_threshold: inference
806 .compaction
807 .as_ref()
808 .and_then(|c| c.threshold)
809 .unwrap_or(0.98),
810 compaction_model: inference.compaction.as_ref().and_then(|c| c.model.clone()),
811 compaction_system_prompt,
812 compaction_dump_summaries: inference
813 .compaction
814 .as_ref()
815 .and_then(|c| c.dump_summaries)
816 .unwrap_or(false),
817 max_output_tokens: inference
818 .max_tokens
819 .unwrap_or(agent::DEFAULT_MAX_OUTPUT_TOKENS),
820 };
821
822 let rt = tokio::runtime::Builder::new_multi_thread()
825 .enable_all()
826 .build()
827 .map_err(|e| RuntimeError::Runtime(format!("failed to create tokio runtime: {e}")))?;
828
829 let (tcp_listener, external_port) = rt.block_on(identity::bind_local_port(
830 &staged.bind_addr,
831 staged.internal_port,
832 ))?;
833
834 let capsule_url = format!("localhost:{external_port}");
835 staged.capsule_url = capsule_url.clone();
836 on_url(&capsule_url);
837
838 let capsule_identity = CapsuleIdentity {
839 capsule_name: staged.capsule_name.clone(),
840 capsule_version: staged.capsule_version.clone(),
841 session_id: session_id.clone(),
842 capsule_url: capsule_url.clone(),
843 };
844
845 let mut all_env = inference_env.clone();
847 all_env.push((
848 "MURMUR_CAPSULE_NAME".to_string(),
849 staged.capsule_name.clone(),
850 ));
851 all_env.push((
852 "MURMUR_CAPSULE_VERSION".to_string(),
853 staged.capsule_version.clone(),
854 ));
855 all_env.push(("MURMUR_SESSION_ID".to_string(), session_id.clone()));
856 all_env.push(("MURMUR_CAPSULE_URL".to_string(), capsule_url.clone()));
857
858 murmur_md::write_murmur_md(
859 &workdir,
860 Some(inference),
861 staged.context.as_ref(),
862 has_compaction_hook(&staged.hook_components),
863 &staged.capability_policy,
864 &capsule_identity,
865 );
866
867 sandbox::warn_for_enforcement_tier(
868 shell_enforcement.tier,
869 &workdir,
870 &staged.capability_policy,
871 );
872 sandbox::warn_for_missing_aggregate_bounding(
873 &workdir,
874 requires_process_bounding,
875 shell_enforcement.cgroup_scope.is_some(),
876 );
877
878 let agent_card = identity::build_agent_card(
879 &capsule_identity,
880 &staged.installed_artifacts,
881 &staged.capability_policy,
882 );
883 let agent_card_json = agent_card.to_string();
884
885 let effective_lifecycle = staged.lifecycle.clone();
887 let conversation_mode = effective_lifecycle.conversation_mode.clone();
888 let queue_capacity = match effective_lifecycle.task_acceptance {
889 TaskAcceptance::Queue => effective_lifecycle.queue_depth,
890 _ => 1,
891 };
892
893 let task_registry: Arc<Mutex<TaskRegistry>> = Arc::new(Mutex::new(TaskRegistry::new(
895 effective_lifecycle.queue_depth,
896 effective_lifecycle.task_acceptance.clone(),
897 )));
898 let (task_tx, mut task_rx) = tokio::sync::mpsc::channel::<IncomingTask>(queue_capacity);
899
900 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
901
902 let (sse_tx, _) =
904 tokio::sync::broadcast::channel::<std::sync::Arc<String>>(SSE_BROADCAST_CAPACITY);
905 let sse_buffer = std::sync::Arc::new(Mutex::new(SseEventBuffer::new(SSE_REPLAY_CAPACITY)));
906
907 let capsule_name = staged.capsule_name.clone();
908 let capabilities = capability_names(&staged.capability_policy);
909 let effective_grants = staged.scope_report.clone();
914
915 let hook_components = staged.hook_components;
917 let tool_components = staged.tool_components;
918 let artifact_grants = staged.artifact_grants;
919 let allowlisted_tools = staged.allowlisted_tools.clone();
920 let installed_artifacts = staged.installed_artifacts;
921 let engine = staged.engine.clone();
922 let capability_policy = staged.capability_policy.clone();
923 let shell_enforcement_for_state = shell_enforcement.clone();
924 let otel_endpoint = staged.otel_endpoint;
925 let eval_config_json = staged.eval_config_json;
926 let case_id = staged.case_id;
927 let dataset_id = staged.dataset_id;
928 let capsule_version = staged.capsule_version.clone();
929 let inference_model = inference.model.clone();
930 let inference_driver_name = inference
934 .driver
935 .as_ref()
936 .map(|d| d.artifact.clone())
937 .filter(|a| !a.is_empty());
938 let trace_include_tool_output = staged.trace_include_tool_output;
939 let trace_system_prompt = system_prompt.clone();
943 let system_prompt_overridden = staged.system_prompt_overridden;
944 let registry_for_pull = Arc::clone(&staged.registry);
945 let lock_path_for_pull = staged.manifest_dir.join("murmur.lock");
946
947 let workdir_task_md = accessible_workdir.join("task.md");
952
953 let session_id_ret = session_id.clone();
955 let workdir_ret = workdir.clone();
956
957 let loop_result: Result<(), RuntimeError> = rt.block_on(async move {
959 let mut trace = TraceWriter::open(
960 &workdir,
961 session_id.clone(),
962 capsule_name.clone(),
963 capsule_version.clone(),
964 inference_model.clone(),
965 capabilities.clone(),
966 effective_grants,
967 trace_include_tool_output,
968 trace_system_prompt,
969 system_prompt_overridden,
970 )
971 .await
972 .map_err(|e| RuntimeError::AgentLoopFailed(format!("failed to open trace.jsonl: {e}")))?;
973
974 let mut otel = OtelEmitter::new(
975 otel_endpoint.clone(),
976 &workdir,
977 capsule_name.clone(),
978 capsule_version.clone(),
979 );
980
981 let local = tokio::task::LocalSet::new();
982 local
983 .run_until(async move {
984 let server_handle =
986 tokio::task::spawn_local(identity::serve_http(
987 tcp_listener,
988 shutdown_rx,
989 agent_card_json,
990 Arc::clone(&task_registry),
991 task_tx,
992 effective_lifecycle.task_acceptance.clone(),
993 sse_tx.clone(),
994 Arc::clone(&sse_buffer),
995 conversation_mode.clone(),
996 ));
997
998 let hook_limits = capability_policy.hook_limits();
1002
1003 let mut state = CapsuleStoreState {
1005 limits: capability_policy.limits.limiter(),
1010 table: ResourceTable::new(),
1011 wasi: build_wasi_ctx(
1015 &accessible_workdir,
1016 None,
1017 &all_env,
1018 &capability_policy,
1019 )?,
1020 http: WasiHttpCtx::new(),
1021 http_hooks: NetworkPolicyHooks {
1022 network_allow_rules: network_allow_rules.clone(),
1023 },
1024 network_allow_rules,
1025 inference_env: all_env,
1026 engine: engine.clone(),
1027 workdir: workdir.clone(),
1028 accessible_workdir: accessible_workdir.clone(),
1029 tool_components,
1030 artifact_grants,
1031 allowlisted_tools,
1032 installed_artifacts,
1033 session_id: session_id.clone(),
1034 pending_a2a_events: Vec::new(),
1035 capability_policy,
1036 shell_enforcement: shell_enforcement_for_state,
1037 current_traceparent: None,
1038 a2a_task_registry: Some(Arc::clone(&task_registry)),
1039 a2a_sse: Some((sse_tx.clone(), Arc::clone(&sse_buffer))),
1040 a2a_task_id: None,
1041 input_timeout_secs: effective_lifecycle.input_timeout_secs,
1042 a2a_chunk_event_id: Arc::new(AtomicU64::new(u64::MAX / 4)),
1043 a2a_chunks_emitted: Arc::new(AtomicBool::new(false)),
1044 registry: registry_for_pull,
1045 lock_path: lock_path_for_pull,
1046 driver_continuation_id: None,
1047 driver_continuation_context_id: None,
1048 driver_continuation_acked_len: 0,
1049 };
1050
1051 let hook_inference = inference_driver_name
1057 .as_ref()
1058 .and_then(|driver_name| {
1059 state
1060 .tool_components
1061 .get(driver_name)
1062 .map(|component| (driver_name.clone(), component.clone()))
1063 })
1064 .map(|(driver_name, driver_component)| {
1065 let driver_grant = state.artifact_grants.get(&driver_name).cloned();
1068 Arc::new(HookInferenceCtx {
1069 driver_name,
1070 driver_component,
1071 model: inference_model.clone(),
1072 engine: state.engine.clone(),
1073 accessible_workdir: state.accessible_workdir.clone(),
1074 inference_env: state.inference_env.clone(),
1075 capability_policy: state.capability_policy.clone(),
1076 network_allow_rules: state.network_allow_rules.clone(),
1077 driver_grant,
1078 records: std::sync::Mutex::new(Vec::new()),
1079 })
1080 });
1081
1082 let mut hooks = HookRuntime::new(
1084 &engine,
1085 &workdir,
1086 &accessible_workdir,
1087 hook_components,
1088 SessionContextData {
1089 capsule_name: capsule_name.clone(),
1090 capsule_version: capsule_version.clone(),
1091 session_id: session_id.clone(),
1092 model: inference_model.clone(),
1093 capabilities: capabilities.clone(),
1094 },
1095 HookEnvVars {
1096 otel_endpoint: otel_endpoint.as_deref(),
1097 eval_config_json: eval_config_json.as_deref(),
1098 case_id: case_id.as_deref(),
1099 dataset_id: dataset_id.as_deref(),
1100 },
1101 hook_limits,
1102 hook_inference,
1103 )
1104 .await?;
1105
1106 hooks.emit(&workdir, HookEvent::SessionStart).await;
1110
1111 let final_loop_result: Result<(), RuntimeError>;
1112
1113 'task_loop: loop {
1117 let incoming: IncomingTask = match effective_lifecycle.task_acceptance {
1119 TaskAcceptance::None => {
1120 if workdir_task_md.exists() {
1122 let task_id = format!("tsk_{}", uuid::Uuid::now_v7().simple());
1123 let context_id = format!("ctx_{}", uuid::Uuid::now_v7().simple());
1124 let bytes = tokio::fs::metadata(&workdir_task_md)
1125 .await
1126 .map(|m| m.len())
1127 .unwrap_or(0);
1128 let _ = trace
1129 .write_task_start(
1130 &task_id, &context_id, "task_md", bytes,
1131 )
1132 .await;
1133 hooks
1134 .emit(
1135 &workdir,
1136 HookEvent::TaskStart {
1137 task_id: task_id.clone(),
1138 context_id: context_id.clone(),
1139 source: "task_md".to_string(),
1140 input_bytes: bytes,
1141 },
1142 )
1143 .await;
1144 otel.begin_session(None);
1145 state.current_traceparent = otel.outgoing_traceparent();
1146 let result = run_task_with_reopens(
1150 &mut state,
1151 &workdir,
1152 inference,
1153 system_prompt,
1154 run_config,
1155 &mut hooks,
1156 &mut trace,
1157 &mut otel,
1158 None,
1159 None,
1160 &accessible_workdir,
1161 &capsule_name,
1162 &capsule_version,
1163 conversation_mode.clone(),
1164 Some(context_id.clone()),
1165 &task_id,
1166 )
1167 .await;
1168 final_loop_result = result;
1169 break 'task_loop;
1170 } else {
1171 final_loop_result = Ok(());
1172 break 'task_loop;
1173 }
1174 }
1175 TaskAcceptance::Single | TaskAcceptance::Queue => {
1176 if workdir_task_md.exists() {
1177 let task_id = format!("tsk_{}", uuid::Uuid::now_v7().simple());
1179 let context_id = format!("ctx_{}", uuid::Uuid::now_v7().simple());
1180 let bytes = tokio::fs::metadata(&workdir_task_md)
1181 .await
1182 .map(|m| m.len())
1183 .unwrap_or(0);
1184 let _ = trace
1185 .write_task_start(
1186 &task_id, &context_id, "task_md", bytes,
1187 )
1188 .await;
1189 hooks
1190 .emit(
1191 &workdir,
1192 HookEvent::TaskStart {
1193 task_id: task_id.clone(),
1194 context_id: context_id.clone(),
1195 source: "task_md".to_string(),
1196 input_bytes: bytes,
1197 },
1198 )
1199 .await;
1200 otel.begin_session(None);
1201 state.current_traceparent = otel.outgoing_traceparent();
1202 let result = run_task_with_reopens(
1203 &mut state,
1204 &workdir,
1205 inference,
1206 system_prompt.clone(),
1207 run_config.clone(),
1208 &mut hooks,
1209 &mut trace,
1210 &mut otel,
1211 None,
1212 None,
1213 &accessible_workdir,
1214 &capsule_name,
1215 &capsule_version,
1216 conversation_mode.clone(),
1217 Some(context_id.clone()),
1218 &task_id,
1219 )
1220 .await;
1221 let _ = trace.flush().await;
1222 if matches!(effective_lifecycle.task_acceptance, TaskAcceptance::Single) || result.is_err() {
1223 final_loop_result = result;
1224 break 'task_loop;
1225 }
1226 let _ = tokio::fs::remove_file(&workdir_task_md).await;
1229 continue 'task_loop;
1230 }
1231 let is_queue_sleep =
1236 matches!(effective_lifecycle.task_acceptance, TaskAcceptance::Queue)
1237 && matches!(effective_lifecycle.after_task, AfterTask::Sleep);
1238
1239 if is_queue_sleep {
1240 match task_rx.recv().await {
1241 Some(task) => task,
1242 None => {
1243 final_loop_result = Ok(());
1244 break 'task_loop;
1245 }
1246 }
1247 } else {
1248 let idle_timeout_secs: u64 =
1249 std::env::var("MURMUR_A2A_TIMEOUT_SECS")
1250 .ok()
1251 .and_then(|v| v.parse().ok())
1252 .unwrap_or(30);
1253 match tokio::time::timeout(
1254 std::time::Duration::from_secs(idle_timeout_secs),
1255 task_rx.recv(),
1256 )
1257 .await
1258 {
1259 Ok(Some(task)) => task,
1260 Ok(None) => {
1261 final_loop_result = Ok(());
1262 break 'task_loop;
1263 }
1264 Err(_elapsed) => {
1265 eprintln!("[capsule-runtime] no A2A message received within timeout; running with empty task");
1266 otel.begin_session(None);
1267 state.current_traceparent = otel.outgoing_traceparent();
1268 final_loop_result = agent::run_agent_loop(
1269 &mut state,
1270 &workdir,
1271 inference,
1272 system_prompt,
1273 run_config,
1274 &mut hooks,
1275 &mut trace,
1276 &mut otel,
1277 None,
1278 None,
1279 &accessible_workdir,
1280 &capsule_name,
1281 &capsule_version,
1282 conversation_mode.clone(),
1283 None,
1284 )
1285 .await;
1286 break 'task_loop;
1287 }
1288 }
1289 }
1290 }
1291 };
1292
1293 {
1295 let mut reg = task_registry.lock().unwrap();
1296 reg.start_task(incoming.task_id.clone(), incoming.context_id.clone());
1297 }
1298 if let Err(e) =
1299 tokio::fs::write(&workdir_task_md, &incoming.message_text).await
1300 {
1301 eprintln!(
1302 "[capsule-runtime] failed to write A2A message to task.md: {e}"
1303 );
1304 }
1305 let _ = trace
1306 .write_a2a_task_received(
1307 &incoming.task_id,
1308 &incoming.context_id,
1309 &incoming.message_id,
1310 incoming.traceparent.as_deref(),
1311 )
1312 .await;
1313 let _ = trace
1314 .write_task_start(
1315 &incoming.task_id,
1316 &incoming.context_id,
1317 "a2a",
1318 incoming.message_text.len() as u64,
1319 )
1320 .await;
1321 hooks
1322 .emit(
1323 &workdir,
1324 HookEvent::TaskStart {
1325 task_id: incoming.task_id.clone(),
1326 context_id: incoming.context_id.clone(),
1327 source: "a2a".to_string(),
1328 input_bytes: incoming.message_text.len() as u64,
1329 },
1330 )
1331 .await;
1332
1333 otel.begin_session(incoming.traceparent.as_deref());
1335 state.current_traceparent = otel.outgoing_traceparent();
1336 state.a2a_task_id = Some(incoming.task_id.clone());
1337 let loop_result = run_task_with_reopens(
1338 &mut state,
1339 &workdir,
1340 inference,
1341 system_prompt.clone(),
1342 run_config.clone(),
1343 &mut hooks,
1344 &mut trace,
1345 &mut otel,
1346 Some(incoming.task_id.clone()),
1347 Some((sse_tx.clone(), Arc::clone(&sse_buffer))),
1348 &accessible_workdir,
1349 &capsule_name,
1350 &capsule_version,
1351 conversation_mode.clone(),
1352 Some(incoming.context_id.clone()),
1353 &incoming.task_id,
1354 )
1355 .await;
1356
1357 let exit_state = if loop_result.is_ok() {
1362 TaskState::Completed
1363 } else {
1364 TaskState::Failed
1365 };
1366 let _ = trace.flush().await;
1367 {
1368 let mut reg = task_registry.lock().unwrap();
1369 reg.finish_task(exit_state);
1370 }
1371
1372 match effective_lifecycle.after_task {
1374 AfterTask::Exit => {
1375 final_loop_result = loop_result;
1376 break 'task_loop;
1377 }
1378 AfterTask::Sleep => {
1379 if matches!(
1380 effective_lifecycle.task_acceptance,
1381 TaskAcceptance::Single
1382 ) {
1383 final_loop_result = loop_result;
1385 break 'task_loop;
1386 }
1387 let _ = tokio::fs::remove_file(&workdir_task_md).await;
1389 continue 'task_loop;
1390 }
1391 }
1392 }
1393 let session_exit_status =
1400 if final_loop_result.is_ok() { "ok" } else { "failed" };
1401 let session_total_turns = hooks.total_turns();
1402 hooks
1403 .emit(
1404 &workdir,
1405 HookEvent::SessionEnd {
1406 total_turns: session_total_turns,
1407 exit_status: session_exit_status.to_string(),
1408 },
1409 )
1410 .await;
1411
1412 hooks.drain_async_hooks().await;
1419 agent::flush_hook_dispatch_faults(&mut hooks, &mut trace).await;
1420 let _ = trace.write_session_end_if_not_ended("failed").await;
1421 otel.emit_session_end_if_not_ended("failed").await;
1422 trace.flush().await.map_err(|e| {
1423 RuntimeError::AgentLoopFailed(format!("failed to flush trace: {e}"))
1424 })?;
1425
1426 let _ = shutdown_tx.send(());
1428 let _ = server_handle.await;
1429
1430 final_loop_result
1431 })
1432 .await
1433 });
1434
1435 loop_result?;
1436
1437 return Ok(LaunchResult {
1438 session_id: session_id_ret,
1439 workdir: workdir_ret,
1440 });
1441 }
1442
1443 let capsule_component = staged
1445 .capsule_component
1446 .ok_or(RuntimeError::CapsuleCompile(
1447 "non-agent capsule has no compiled component".to_string(),
1448 ))?;
1449
1450 let mut linker = Linker::new(&staged.engine);
1451 wasmtime_wasi::p2::add_to_linker_async(&mut linker)
1452 .map_err(|err| RuntimeError::Runtime(err.to_string()))?;
1453 wasmtime_wasi_http::p2::add_only_http_to_linker_sync(&mut linker)
1454 .map_err(|err| RuntimeError::Runtime(err.to_string()))?;
1455
1456 {
1461 let iface = WIT_TOOL_REGISTRY_IFACE;
1462 linker
1463 .instance(iface)
1464 .map_err(|err| RuntimeError::Runtime(err.to_string()))?
1465 .func_wrap(
1466 "invoke",
1467 |mut store: wasmtime::StoreContextMut<'_, CapsuleStoreState>,
1468 (tool_name, input): (String, murmur::tool::run::ToolInput)|
1469 -> wasmtime::Result<(Result<murmur::tool::run::ToolResult, String>,)> {
1470 Ok((invoke::Host::invoke(store.data_mut(), tool_name, input),))
1471 },
1472 )
1473 .map_err(|err| RuntimeError::Runtime(err.to_string()))?;
1474 }
1475 manage::add_to_linker::<_, HasSelf<_>>(&mut linker, |state| state)
1476 .map_err(|err| RuntimeError::Runtime(err.to_string()))?;
1477 send::add_to_linker::<_, HasSelf<_>>(&mut linker, |state| state)
1478 .map_err(|err| RuntimeError::Runtime(err.to_string()))?;
1479
1480 let capsule_limits = staged.capability_policy.limits;
1482
1483 let state = CapsuleStoreState {
1484 table: ResourceTable::new(),
1485 wasi: build_wasi_ctx(
1486 &staged.accessible_workdir,
1487 None,
1489 &inference_env,
1490 &staged.capability_policy,
1491 )?,
1492 http: WasiHttpCtx::new(),
1493 http_hooks: NetworkPolicyHooks {
1494 network_allow_rules: network_allow_rules.clone(),
1495 },
1496 network_allow_rules,
1497 inference_env,
1498 engine: staged.engine.clone(),
1499 workdir: staged.workdir.clone(),
1500 accessible_workdir: staged.accessible_workdir.clone(),
1501 tool_components: staged.tool_components,
1502 artifact_grants: staged.artifact_grants,
1503 allowlisted_tools: staged.allowlisted_tools,
1504 installed_artifacts: staged.installed_artifacts,
1505 session_id: staged.session_id.clone(),
1506 pending_a2a_events: Vec::new(),
1507 capability_policy: staged.capability_policy,
1508 shell_enforcement: shell_enforcement.clone(),
1509 current_traceparent: None,
1510 a2a_task_registry: None,
1511 a2a_sse: None,
1512 a2a_task_id: None,
1513 input_timeout_secs: None,
1514 a2a_chunk_event_id: Arc::new(AtomicU64::new(u64::MAX / 4)),
1515 a2a_chunks_emitted: Arc::new(AtomicBool::new(false)),
1516 registry: Arc::clone(&staged.registry),
1517 lock_path: staged.manifest_dir.join("murmur.lock"),
1518 driver_continuation_id: None,
1519 driver_continuation_context_id: None,
1520 driver_continuation_acked_len: 0,
1521 limits: capsule_limits.limiter(),
1522 };
1523
1524 let mut store = Store::new(&staged.engine, state);
1525 store.limiter(|state| &mut state.limits);
1528
1529 let rt = tokio::runtime::Builder::new_multi_thread()
1531 .enable_all()
1532 .build()
1533 .map_err(|e| RuntimeError::Runtime(format!("failed to create tokio runtime: {e}")))?;
1534
1535 rt.block_on(async {
1536 store.set_epoch_deadline(capsule_limits.deadline_ticks());
1538 let instantiated = linker
1539 .instantiate_async(&mut store, &capsule_component)
1540 .await;
1541 let instance = match instantiated {
1542 Ok(instance) => instance,
1543 Err(err) => return Err(capsule_guest_error(&err, &store.data().limits)),
1544 };
1545
1546 let capsule_iface =
1547 resolve_versioned_iface(&instance, &mut store, WIT_CAPSULE_IFACE_VERSIONED)
1548 .ok_or(RuntimeError::CapsuleExportMissing)?;
1549 let capsule_run = instance
1550 .get_export_index(&mut store, Some(&capsule_iface), "run")
1551 .and_then(|idx| instance.get_func(&mut store, idx))
1552 .ok_or(RuntimeError::CapsuleExportMissing)?;
1553
1554 let run = capsule_run
1555 .typed::<(), ()>(&store)
1556 .map_err(|err| RuntimeError::Runtime(err.to_string()))?;
1557
1558 store.set_epoch_deadline(capsule_limits.deadline_ticks());
1560 let called = run.call_async(&mut store, ()).await;
1561 if let Err(err) = called {
1562 return Err(capsule_guest_error(&err, &store.data().limits));
1563 }
1564
1565 Ok(())
1566 })?;
1567
1568 let pending = std::mem::take(&mut store.data_mut().pending_a2a_events);
1570 if !pending.is_empty() {
1571 rt.block_on(async {
1572 if let Ok(mut trace) = TraceWriter::open(
1573 &staged.workdir,
1574 staged.session_id.clone(),
1575 staged.capsule_name.clone(),
1576 staged.capsule_version.clone(),
1577 String::new(),
1578 Vec::new(),
1579 staged.scope_report.clone(),
1580 false,
1581 None,
1582 false,
1583 )
1584 .await
1585 {
1586 for (peer_url, message_id, task_id, context_id, traceparent) in pending {
1587 let _ = trace
1588 .write_a2a_send(
1589 &peer_url,
1590 &message_id,
1591 &task_id,
1592 &context_id,
1593 traceparent.as_deref(),
1594 )
1595 .await;
1596 }
1597 let _ = trace.flush().await;
1598 }
1599 });
1600 }
1601
1602 on_url("");
1604
1605 Ok(LaunchResult {
1606 session_id: staged.session_id,
1607 workdir: staged.workdir,
1608 })
1609}
1610
1611fn read_prompt_file(manifest_dir: &Path, path: &str) -> Result<String, (PathBuf, std::io::Error)> {
1614 let prompt_path = manifest_dir.join(path);
1615 fs::read_to_string(&prompt_path).map_err(|source| (prompt_path, source))
1616}
1617
1618fn resolve_system_prompt(
1619 manifest_dir: &Path,
1620 workdir: &Path,
1621 inference: &murmur_artifact::InferenceConfig,
1622) -> Result<Option<String>, RuntimeError> {
1623 if let Some(prompt) = inference.system_prompt.as_ref() {
1624 return Ok(Some(prompt.clone()));
1625 }
1626
1627 if let Some(path) = inference.system_prompt_file.as_ref() {
1628 return read_prompt_file(manifest_dir, path)
1629 .map(Some)
1630 .map_err(|(prompt_path, source)| RuntimeError::SystemPromptFileRead {
1631 path: prompt_path.display().to_string(),
1632 source,
1633 });
1634 }
1635
1636 if let Some(art_name) = inference.system_prompt_artifact.as_ref() {
1637 let skill_path = workdir.join("tools").join(art_name).join("skill.md");
1638 return fs::read_to_string(&skill_path).map(Some).map_err(|source| {
1639 RuntimeError::SystemPromptArtifactRead {
1640 name: art_name.clone(),
1641 source,
1642 }
1643 });
1644 }
1645
1646 Ok(None)
1647}
1648
1649fn resolve_compaction_system_prompt(
1658 manifest_dir: &Path,
1659 compaction: Option<&murmur_artifact::CompactionConfig>,
1660) -> Result<Option<String>, RuntimeError> {
1661 let Some(compaction) = compaction else {
1662 return Ok(None);
1663 };
1664
1665 if let Some(prompt) = compaction.system_prompt.as_ref() {
1666 return Ok(Some(prompt.clone()));
1667 }
1668
1669 if let Some(path) = compaction.system_prompt_file.as_ref() {
1670 return read_prompt_file(manifest_dir, path)
1671 .map(Some)
1672 .map_err(
1673 |(prompt_path, source)| RuntimeError::CompactionSystemPromptFileRead {
1674 path: prompt_path.display().to_string(),
1675 source,
1676 },
1677 );
1678 }
1679
1680 Ok(None)
1681}
1682
1683fn capability_names(policy: &CapabilityPolicy) -> Vec<String> {
1684 let mut names = Vec::new();
1685 if !policy.network_allow.is_empty() {
1686 names.push("network".to_string());
1687 }
1688 if policy.filesystem_scope.is_some() {
1689 names.push("filesystem".to_string());
1690 }
1691 if !policy.shell_allow.is_empty() {
1692 names.push("shell".to_string());
1693 }
1694 names
1695}
1696
1697const BASH_NETWORK_BYPASS_WARNING: &str = "capabilities.shell.allow includes \"bash\" and \
1701capabilities.network.allow is non-empty, but network.allow does not constrain bash's own \
1702outbound connections on this platform.";
1703
1704pub(crate) fn warn_if_bash_network_bypass(workdir: &Path, policy: &CapabilityPolicy) {
1710 let has_bash = policy.shell_allow.iter().any(|binary| binary == "bash");
1711 if has_bash && !policy.network_allow.is_empty() {
1712 let link = security_warning_link(W_SEC_003);
1713 eprintln!("[capsule-runtime] warning[{W_SEC_003}]: {BASH_NETWORK_BYPASS_WARNING} ({link})");
1714 agent::append_bootstrap_log(
1715 workdir,
1716 &format!(
1717 "[capability-policy] warning[{W_SEC_003}]: {BASH_NETWORK_BYPASS_WARNING} ({link})"
1718 ),
1719 );
1720 }
1721}
1722
1723pub fn warn_on_interpreter_runtime_grants(grants: &[InterpreterRuntimeGrant]) {
1735 for grant in grants {
1736 let link = security_warning_link(W_SEC_009);
1737 let dirs = grant
1738 .dirs
1739 .iter()
1740 .map(|dir| {
1741 let list = if dir.list_dir { "list_dir" } else { "no-list" };
1742 format!("{} ({list})", dir.path)
1743 })
1744 .collect::<Vec<_>>()
1745 .join(", ");
1746 eprintln!(
1747 "[capsule-runtime] warning[{W_SEC_009}]: capabilities.shell.interpreter_runtime grants \
1748 '{}' host directories outside the workdir [{dirs}] — this couples the capsule to a \
1749 specific host distro/interpreter-version layout (e.g. /usr/lib/python3.11 breaks the \
1750 moment the host ships Python 3.12); the durable fix is the staged runtime bind-mount, \
1751 which this grant only bridges until ({link})",
1752 grant.binary
1753 );
1754 }
1755}
1756
1757pub fn warn_on_workdir_exec(workdir_exec: bool) {
1770 if !workdir_exec {
1771 return;
1772 }
1773 let link = security_warning_link(W_SEC_011);
1774 eprintln!(
1775 "[capsule-runtime] warning[{W_SEC_011}]: capabilities.filesystem.workdir_exec is true — \
1776 the session workdir keeps its Landlock Execute right, so anything the capsule writes \
1777 there can run regardless of capabilities.shell.allow; this capsule reports containment \
1778 class 'advisory' on every host, including a Landlock-capable one ({link})"
1779 );
1780}
1781
1782fn warn_on_inert_hook_capabilities(
1794 hook_name: &str,
1795 capabilities: Option<&murmur_artifact::Capabilities>,
1796) {
1797 let inert = inert_capability_sub_blocks(capabilities);
1798 if !inert.is_empty() {
1799 let link = security_warning_link(W_SEC_006);
1800 eprintln!(
1801 "[capsule-runtime] warning[{W_SEC_006}]: hook '{hook_name}' declares capabilities.{} \
1802 which the runtime does not apply per-hook — only capabilities.network and \
1803 capabilities.filesystem govern a hook ({link})",
1804 inert.join(", capabilities.")
1805 );
1806 }
1807}
1808
1809fn inert_capability_sub_blocks(
1813 capabilities: Option<&murmur_artifact::Capabilities>,
1814) -> Vec<&'static str> {
1815 let Some(capabilities) = capabilities else {
1816 return Vec::new();
1817 };
1818
1819 [
1820 ("shell", capabilities.shell.is_some()),
1821 ("spawn", capabilities.spawn.is_some()),
1822 ("env", capabilities.env.is_some()),
1823 ("limits", capabilities.limits.is_some()),
1824 ("resources", capabilities.resources.is_some()),
1828 ("containment", capabilities.containment.is_some()),
1831 ]
1832 .into_iter()
1833 .filter_map(|(name, present)| present.then_some(name))
1834 .collect()
1835}
1836
1837fn stage_artifact_grant(
1844 artifact: &ArtifactRequest,
1845 ceiling_network_allow_rules: &[NetworkAllowRule],
1846 artifact_grants: &mut HashMap<String, ToolCapabilityGrant>,
1847) -> Result<(), RuntimeError> {
1848 let Some(capabilities) = artifact.capabilities.as_ref() else {
1849 return Ok(());
1850 };
1851
1852 let grant = ToolCapabilityGrant::derive(Some(capabilities), ceiling_network_allow_rules)?;
1857 warn_on_out_of_ceiling_network_entries(&artifact.name, &grant.dropped_network_entries);
1858 warn_on_inert_tool_capabilities(&artifact.name, Some(capabilities));
1859 artifact_grants.insert(artifact.name.clone(), grant);
1860 Ok(())
1861}
1862
1863fn warn_on_out_of_ceiling_network_entries(artifact_name: &str, dropped: &[String]) {
1869 if dropped.is_empty() {
1870 return;
1871 }
1872
1873 let link = security_warning_link(W_SEC_007);
1874 eprintln!(
1875 "[capsule-runtime] warning[{W_SEC_007}]: artifact '{artifact_name}' declares \
1876 capabilities.network.allow entries the capsule-wide ceiling does not allow ({}) — \
1877 they are dropped, not granted, because per-artifact capabilities can only narrow \
1878 ({link})",
1879 dropped.join(", ")
1880 );
1881}
1882
1883fn warn_on_inert_tool_capabilities(
1888 artifact_name: &str,
1889 capabilities: Option<&murmur_artifact::Capabilities>,
1890) {
1891 let inert = inert_capability_sub_blocks(capabilities);
1892 if !inert.is_empty() {
1893 let link = security_warning_link(W_SEC_008);
1894 eprintln!(
1895 "[capsule-runtime] warning[{W_SEC_008}]: artifact '{artifact_name}' declares \
1896 capabilities.{} which per-artifact narrowing does not apply — only \
1897 capabilities.network and capabilities.filesystem narrow a tool or driver ({link})",
1898 inert.join(", capabilities.")
1899 );
1900 }
1901}
1902
1903fn warn_on_unenforceable_native_capabilities(
1908 artifact_name: &str,
1909 capabilities: Option<&murmur_artifact::Capabilities>,
1910) {
1911 if capabilities.is_none() {
1912 return;
1913 }
1914
1915 let link = security_warning_link(W_SEC_008);
1916 eprintln!(
1917 "[capsule-runtime] warning[{W_SEC_008}]: artifact '{artifact_name}' declares \
1918 per-artifact 'capabilities:' but ships a native implementation — narrowing applies \
1919 only to WASM tools and drivers, so this grant is not enforced ({link})"
1920 );
1921}
1922
1923fn build_engine() -> Result<Engine, RuntimeError> {
1930 let mut config = Config::new();
1931 config.wasm_component_model(true);
1932 config.epoch_interruption(true);
1933 Engine::new(&config).map_err(|err| RuntimeError::Runtime(err.to_string()))
1934}
1935
1936fn capsule_guest_error(error: &wasmtime::Error, limiter: &ExecutionLimiter) -> RuntimeError {
1940 match classify_guest_failure(error, limiter) {
1941 GuestFailure::DeadlineExceeded { seconds } => {
1942 RuntimeError::CapsuleDeadlineExceeded { seconds }
1943 }
1944 GuestFailure::ResourceLimit { message } => RuntimeError::CapsuleResourceLimit { message },
1945 GuestFailure::Other => RuntimeError::CapsuleTrap(error.to_string()),
1946 }
1947}
1948
1949fn map_registry_error(name: &str, version: &str, error: RegistryError) -> RuntimeError {
1950 match error {
1951 RegistryError::NotFound { .. } => RuntimeError::artifact_not_found(name, version),
1952 RegistryError::IntegrityMismatch { .. } => {
1953 RuntimeError::artifact_integrity_failed(name, version)
1954 }
1955 other => RuntimeError::Runtime(other.to_string()),
1956 }
1957}
1958
1959fn build_wasi_ctx(
1971 workdir: &Path,
1972 filesystem_scope: Option<&str>,
1973 extra_env: &[(String, String)],
1974 policy: &CapabilityPolicy,
1975) -> Result<WasiCtx, RuntimeError> {
1976 let mut builder = WasiCtxBuilder::new();
1977 builder.inherit_stdio();
1978 for (key, value) in build_wasi_env_allowlist(policy) {
1979 builder.env(key, value);
1980 }
1981 for (key, value) in extra_env {
1982 builder.env(key, value);
1983 }
1984
1985 let preopen_root = match filesystem_scope {
1988 None => workdir.to_path_buf(),
1989 Some(scope) => resolve_scoped_dir(workdir, scope)?,
1990 };
1991
1992 builder
1993 .preopened_dir(&preopen_root, ".", DirPerms::all(), FilePerms::all())
1994 .map_err(|err| RuntimeError::wasi(preopen_root, err.to_string()))?;
1995
1996 Ok(builder.build())
1997}
1998
1999fn inference_env_pairs(inference: &murmur_artifact::InferenceConfig) -> Vec<(String, String)> {
2000 let mut pairs = vec![
2001 (
2002 "MURMUR_INFERENCE_TRANSPORT".to_string(),
2003 inference.transport.clone(),
2004 ),
2005 (
2006 "MURMUR_INFERENCE_ENDPOINT".to_string(),
2007 inference.endpoint.clone().unwrap_or_default(),
2008 ),
2009 (
2010 "MURMUR_INFERENCE_MODEL".to_string(),
2011 inference.model.clone(),
2012 ),
2013 (
2014 "MURMUR_INFERENCE_DRIVER".to_string(),
2015 inference
2016 .driver
2017 .as_ref()
2018 .map(|d| d.artifact.clone())
2019 .unwrap_or_default(),
2020 ),
2021 ];
2022
2023 if let Some(api_key) = inference.api_key.as_ref() {
2024 pairs.push(("MURMUR_INFERENCE_API_KEY".to_string(), api_key.clone()));
2025 }
2026
2027 if let Some(config) = inference.driver.as_ref().and_then(|d| d.config.as_ref()) {
2028 pairs.push(("MURMUR_INFERENCE_DRIVER_CONFIG".to_string(), config.clone()));
2029 }
2030
2031 pairs
2032}
2033
2034pub(crate) async fn request_input_impl(
2039 task_id: String,
2040 prompt: String,
2041 task_registry: Arc<Mutex<TaskRegistry>>,
2042 sse: Option<(SseBroadcast, Arc<Mutex<SseEventBuffer>>)>,
2043 input_timeout_secs: Option<u64>,
2044) -> wasmtime::Result<String> {
2045 use std::time::Duration;
2046 use tokio::sync::oneshot;
2047
2048 let (tx, rx) = oneshot::channel::<String>();
2049 {
2050 let mut reg = task_registry.lock().unwrap();
2051 reg.set_input_required(&task_id, prompt.clone(), tx)
2052 .map_err(|e| wasmtime::Error::msg(format!("request-input: {e}")))?;
2053 }
2054
2055 let mut sse_event_id: u64 = u64::MAX / 2;
2057 emit_sse(
2058 &sse,
2059 &mut sse_event_id,
2060 "status",
2061 &TaskStatusUpdateEvent {
2062 id: task_id.clone(),
2063 context_id: None,
2064 status: StreamStatus {
2065 state: "input-required".into(),
2066 message: prompt.clone(),
2067 response: None,
2068 },
2069 r#final: false,
2070 },
2071 )
2072 .await;
2073
2074 let result = match input_timeout_secs {
2075 Some(secs) => tokio::time::timeout(Duration::from_secs(secs), rx)
2076 .await
2077 .map_err(|_| ())
2078 .and_then(|r| r.map_err(|_| ())),
2079 None => rx.await.map_err(|_| ()),
2080 };
2081
2082 match result {
2083 Ok(text) => {
2084 emit_sse(
2085 &sse,
2086 &mut sse_event_id,
2087 "status",
2088 &TaskStatusUpdateEvent {
2089 id: task_id.clone(),
2090 context_id: None,
2091 status: StreamStatus {
2092 state: "working".into(),
2093 message: "resumed".into(),
2094 response: None,
2095 },
2096 r#final: false,
2097 },
2098 )
2099 .await;
2100 Ok(text)
2101 }
2102 Err(()) => {
2103 {
2104 let mut reg = task_registry.lock().unwrap();
2105 reg.finish_task(TaskState::Failed);
2106 }
2107 emit_sse(
2108 &sse,
2109 &mut sse_event_id,
2110 "status",
2111 &TaskStatusUpdateEvent {
2112 id: task_id.clone(),
2113 context_id: None,
2114 status: StreamStatus {
2115 state: "failed".into(),
2116 message: "input-timeout".into(),
2117 response: None,
2118 },
2119 r#final: true,
2120 },
2121 )
2122 .await;
2123 Err(wasmtime::Error::msg("input-timeout"))
2124 }
2125 }
2126}
2127
2128pub(crate) struct NetworkPolicyHooks {
2129 pub(crate) network_allow_rules: Vec<NetworkAllowRule>,
2130}
2131
2132impl WasiHttpHooks for NetworkPolicyHooks {
2133 fn send_request(
2134 &mut self,
2135 request: hyper::Request<HyperOutgoingBody>,
2136 config: OutgoingRequestConfig,
2137 ) -> HttpResult<HostFutureIncomingResponse> {
2138 let target =
2139 RequestTarget::from_request(request.uri(), config.use_tls).ok_or_else(|| {
2140 wasmtime_wasi_http::p2::HttpError::from(WasiHttpErrorCode::HttpRequestDenied)
2141 })?;
2142
2143 if !self
2144 .network_allow_rules
2145 .iter()
2146 .any(|rule| rule.matches(&target))
2147 {
2148 return Err(wasmtime_wasi_http::p2::HttpError::from(
2149 WasiHttpErrorCode::HttpRequestDenied,
2150 ));
2151 }
2152
2153 Ok(wasmtime_wasi_http::p2::default_send_request(
2154 request, config,
2155 ))
2156 }
2157}
2158
2159pub(crate) struct CapsuleStoreState {
2160 pub(crate) limits: ExecutionLimiter,
2164 pub(crate) table: ResourceTable,
2165 pub(crate) wasi: WasiCtx,
2166 pub(crate) http: WasiHttpCtx,
2167 pub(crate) http_hooks: NetworkPolicyHooks,
2168 pub(crate) network_allow_rules: Vec<NetworkAllowRule>,
2169 pub(crate) inference_env: Vec<(String, String)>,
2170 pub(crate) engine: Engine,
2171 pub(crate) workdir: PathBuf,
2172 pub(crate) accessible_workdir: PathBuf,
2173 pub(crate) tool_components: HashMap<String, Component>,
2174 pub(crate) artifact_grants: HashMap<String, ToolCapabilityGrant>,
2177 pub(crate) allowlisted_tools: HashSet<String>,
2178 pub(crate) installed_artifacts: Vec<InstalledArtifactSummary>,
2179 pub(crate) session_id: String,
2180 pub(crate) pending_a2a_events: Vec<(String, String, String, String, Option<String>)>,
2183 pub(crate) capability_policy: CapabilityPolicy,
2184 pub(crate) shell_enforcement: sandbox::ShellEnforcement,
2188 pub(crate) current_traceparent: Option<String>,
2191 pub(crate) a2a_task_registry: Option<Arc<Mutex<TaskRegistry>>>,
2194 pub(crate) a2a_sse: Option<(SseBroadcast, Arc<Mutex<SseEventBuffer>>)>,
2196 pub(crate) a2a_task_id: Option<String>,
2198 pub(crate) input_timeout_secs: Option<u64>,
2200 pub(crate) a2a_chunk_event_id: Arc<AtomicU64>,
2204 pub(crate) a2a_chunks_emitted: Arc<AtomicBool>,
2207 pub(crate) registry: Arc<dyn Registry>,
2209 pub(crate) lock_path: PathBuf,
2211 pub(crate) driver_continuation_id: Option<String>,
2217 pub(crate) driver_continuation_context_id: Option<String>,
2222 pub(crate) driver_continuation_acked_len: usize,
2225}
2226
2227impl CapsuleStoreState {
2228 pub(crate) fn active_continuation(&self, context_id: Option<&str>) -> Option<(&str, usize)> {
2233 let id = self.driver_continuation_id.as_deref()?;
2234 if self.driver_continuation_context_id.as_deref() != context_id {
2235 return None;
2236 }
2237 Some((id, self.driver_continuation_acked_len))
2238 }
2239
2240 pub(crate) fn record_continuation(
2243 &mut self,
2244 id: String,
2245 context_id: Option<String>,
2246 acked_len: usize,
2247 ) {
2248 self.driver_continuation_id = Some(id);
2249 self.driver_continuation_context_id = context_id;
2250 self.driver_continuation_acked_len = acked_len;
2251 }
2252
2253 pub(crate) fn clear_continuation(&mut self) {
2256 self.driver_continuation_id = None;
2257 self.driver_continuation_context_id = None;
2258 self.driver_continuation_acked_len = 0;
2259 }
2260
2261 pub(crate) fn advance_continuation_acked_len(
2266 &mut self,
2267 context_id: Option<&str>,
2268 acked_len: usize,
2269 ) {
2270 if self.driver_continuation_id.is_some()
2271 && self.driver_continuation_context_id.as_deref() == context_id
2272 {
2273 self.driver_continuation_acked_len = acked_len;
2274 }
2275 }
2276}
2277
2278impl WasiView for CapsuleStoreState {
2279 fn ctx(&mut self) -> WasiCtxView<'_> {
2280 WasiCtxView {
2281 ctx: &mut self.wasi,
2282 table: &mut self.table,
2283 }
2284 }
2285}
2286
2287impl WasiHttpView for CapsuleStoreState {
2288 fn http(&mut self) -> WasiHttpCtxView<'_> {
2289 WasiHttpCtxView {
2290 ctx: &mut self.http,
2291 table: &mut self.table,
2292 hooks: &mut self.http_hooks,
2293 }
2294 }
2295}
2296
2297impl invoke::Host for CapsuleStoreState {
2298 fn invoke(
2299 &mut self,
2300 name: String,
2301 input: murmur::tool::run::ToolInput,
2302 ) -> Result<murmur::tool::run::ToolResult, String> {
2303 if !self.allowlisted_tools.contains(&name) {
2304 return Err(format!(
2305 "tool '{name}' is not declared in manifest allowlist"
2306 ));
2307 }
2308 tokio::task::block_in_place(|| {
2309 tokio::runtime::Handle::current().block_on(self.dispatch_tool_async(&name, input))
2310 })
2311 }
2312}
2313
2314impl send::Host for CapsuleStoreState {
2315 fn send(
2316 &mut self,
2317 peer_url: String,
2318 message: send::Message,
2319 ) -> Result<send::TaskResult, String> {
2320 let for_parse = if peer_url.contains("://") {
2322 peer_url.clone()
2323 } else {
2324 format!("http://{peer_url}")
2325 };
2326 let uri: http::Uri = for_parse
2327 .parse()
2328 .map_err(|e| format!("invalid peer URL '{peer_url}': {e}"))?;
2329 let target = RequestTarget::from_request(&uri, false)
2330 .ok_or_else(|| format!("invalid peer URL '{peer_url}'"))?;
2331 if !self
2332 .network_allow_rules
2333 .iter()
2334 .any(|rule| rule.matches(&target))
2335 {
2336 return Err(format!(
2337 "network policy: '{peer_url}' not in capabilities.network.allow"
2338 ));
2339 }
2340
2341 let message_id = message.message_id.clone();
2342 let outgoing_msg = outgoing::OutgoingMessage {
2343 message_id: message.message_id,
2344 context_id: message.context_id,
2345 text: message.text,
2346 };
2347
2348 let traceparent = self.current_traceparent.clone();
2351 let task = tokio::task::block_in_place(|| {
2352 tokio::runtime::Handle::current().block_on(outgoing::send_a2a_message(
2353 &peer_url,
2354 outgoing_msg,
2355 traceparent.clone(),
2356 ))
2357 })?;
2358
2359 self.pending_a2a_events.push((
2360 peer_url.clone(),
2361 message_id,
2362 task.id.clone(),
2363 task.context_id.clone(),
2364 traceparent,
2365 ));
2366 Ok(send::TaskResult {
2367 task_id: task.id,
2368 context_id: task.context_id,
2369 state: match task.status.state {
2370 crate::a2a::TaskState::Submitted => "submitted".to_string(),
2371 crate::a2a::TaskState::Working => "working".to_string(),
2372 crate::a2a::TaskState::InputRequired => "input-required".to_string(),
2373 crate::a2a::TaskState::Completed => "completed".to_string(),
2374 crate::a2a::TaskState::Failed => "failed".to_string(),
2375 crate::a2a::TaskState::Rejected => "rejected".to_string(),
2376 },
2377 })
2378 }
2379}
2380
2381impl manage::Host for CapsuleStoreState {
2382 fn list(&mut self) -> Vec<manage::ArtifactSummary> {
2383 let mut result: Vec<manage::ArtifactSummary> = self
2384 .installed_artifacts
2385 .iter()
2386 .filter(|a| a.runtime.is_llm_visible())
2387 .map(|artifact| manage::ArtifactSummary {
2388 name: artifact.name.clone(),
2389 version: artifact.version.clone(),
2390 runtime: runtime_type_to_wit(&artifact.runtime, artifact.implementation.as_ref()),
2391 })
2392 .collect();
2393
2394 let existing: std::collections::HashSet<String> =
2395 result.iter().map(|a| a.name.clone()).collect();
2396 for binary in &self.capability_policy.shell_allow {
2397 if !existing.contains(binary) {
2398 result.push(manage::ArtifactSummary {
2399 name: binary.clone(),
2400 version: "0.0.0".to_string(),
2401 runtime: manage::RuntimeType::Native,
2402 });
2403 }
2404 }
2405
2406 result
2407 }
2408
2409 fn describe(&mut self, name: String) -> Result<manage::ArtifactInfo, String> {
2410 let fallback_summary;
2412 let summary = if let Some(s) = self
2413 .installed_artifacts
2414 .iter()
2415 .find(|artifact| artifact.name == name)
2416 {
2417 s
2418 } else if self
2419 .capability_policy
2420 .shell_allow
2421 .iter()
2422 .any(|b| b == &name)
2423 {
2424 fallback_summary = InstalledArtifactSummary {
2425 name: name.clone(),
2426 version: "0.0.0".to_string(),
2427 runtime: ArtifactRuntime::Tool,
2428 implementation: Some(ArtifactImplementation::Native),
2429 };
2430 &fallback_summary
2431 } else {
2432 return Err(format!("artifact '{name}' is not installed"));
2433 };
2434
2435 let manifest_path = self
2436 .workdir
2437 .join("tools")
2438 .join(&name)
2439 .join(PACKED_MANIFEST_ENTRY);
2440
2441 read_artifact_info_from_manifest(&manifest_path, summary)
2442 }
2443
2444 fn search(&mut self, query: String) -> Result<Vec<manage::ArtifactSummary>, String> {
2445 Err(format!("not implemented (query: {query})"))
2446 }
2447
2448 fn pull(&mut self, name: String, version: String) -> Result<manage::ArtifactSummary, String> {
2449 let resolved = self
2451 .registry
2452 .resolve_with_platform(&name, &version, Some(current_platform()))
2453 .map_err(|err| format!("failed to resolve {name}@{version}: {err}"))?;
2454
2455 verify_sha256(&name, &version, &resolved.bytes, &resolved.sha256).map_err(|_| {
2456 format!("artifact integrity check failed for {name}@{version}: registry-reported hash does not match downloaded bytes")
2457 })?;
2458
2459 let mut lock = match read_lockfile(&self.lock_path) {
2462 Ok(lock) => lock,
2463 Err(LockfileError::NotFound(_)) => MurmurLock {
2464 lock_version: LOCK_VERSION,
2465 artifacts: Vec::new(),
2466 },
2467 Err(err) => return Err(format!("failed to read murmur.lock: {err}")),
2468 };
2469
2470 if let Some(existing) = lock.artifact_for(&name) {
2471 if existing.resolved_version != version || existing.sha256.wasm != resolved.sha256 {
2472 return Err(format!(
2473 "murmur.lock conflict for '{name}': pinned {}@{} (sha256 {}), but the \
2474 registry now resolves {name}@{version} (sha256 {}) — refusing to override \
2475 a pinned artifact at runtime",
2476 existing.name, existing.resolved_version, existing.sha256.wasm, resolved.sha256
2477 ));
2478 }
2479 }
2480
2481 let manifest_yaml = extract_manifest_yaml(&name, &version, &resolved.bytes)
2484 .map_err(|err| err.to_string())?;
2485 write_tool_manifest(&self.workdir, &name, &manifest_yaml).map_err(|err| err.to_string())?;
2486
2487 let (artifact_runtime, implementation, wasm_component) = match resolved.meta.runtime {
2488 RuntimeType::Wasm => {
2489 let wasm_bytes = extract_root_wasm(&name, &version, &resolved.bytes)
2490 .map_err(|err| err.to_string())?;
2491 let component = Component::new(&self.engine, &wasm_bytes)
2492 .map_err(|err| format!("failed to compile pulled component '{name}': {err}"))?;
2493 (
2494 ArtifactRuntime::Tool,
2495 Some(ArtifactImplementation::Wasm),
2496 Some(component),
2497 )
2498 }
2499 RuntimeType::Native => {
2500 let binary = extract_native_binary(&name, &version, &resolved.bytes)
2501 .map_err(|err| err.to_string())?;
2502 install_native_binaries(&self.workdir, vec![(name.clone(), binary)])
2503 .map_err(|err| err.to_string())?;
2504 (
2505 ArtifactRuntime::Tool,
2506 Some(ArtifactImplementation::Native),
2507 None,
2508 )
2509 }
2510 RuntimeType::Static => {
2511 let skill_md = extract_skill_md(&name, &version, &resolved.bytes)
2512 .map_err(|err| err.to_string())?;
2513 install_skill_files(&self.workdir, vec![(name.clone(), skill_md)])
2514 .map_err(|err| err.to_string())?;
2515 (ArtifactRuntime::Skill, None, None)
2516 }
2517 };
2518
2519 if let Some(entry) = lock.artifacts.iter_mut().find(|entry| entry.name == name) {
2521 entry.resolved_version = version.clone();
2522 entry.sha256 = LockedSha256 {
2523 wasm: resolved.sha256.clone(),
2524 };
2525 } else {
2526 lock.artifacts.push(LockedArtifact {
2527 name: name.clone(),
2528 resolved_version: version.clone(),
2529 sha256: LockedSha256 {
2530 wasm: resolved.sha256.clone(),
2531 },
2532 });
2533 }
2534 write_lockfile_atomic(&self.lock_path, &lock)
2535 .map_err(|err| format!("failed to write murmur.lock: {err}"))?;
2536
2537 if let Some(component) = wasm_component {
2540 self.tool_components.insert(name.clone(), component);
2541 }
2542
2543 let summary = InstalledArtifactSummary {
2544 name: name.clone(),
2545 version: version.clone(),
2546 runtime: artifact_runtime,
2547 implementation,
2548 };
2549 if let Some(existing) = self
2550 .installed_artifacts
2551 .iter_mut()
2552 .find(|artifact| artifact.name == name)
2553 {
2554 *existing = summary.clone();
2555 } else {
2556 self.installed_artifacts.push(summary.clone());
2557 }
2558
2559 Ok(manage::ArtifactSummary {
2560 name,
2561 version,
2562 runtime: runtime_type_to_wit(&summary.runtime, summary.implementation.as_ref()),
2563 })
2564 }
2565
2566 fn remove(&mut self, name: String) -> Result<bool, String> {
2567 Err(format!("not implemented (name: {name})"))
2568 }
2569
2570 fn diagnostics(&mut self) -> Result<manage::RuntimeState, String> {
2571 Ok(manage::RuntimeState {
2572 capsule_id: self.session_id.clone(),
2573 installed: self.list(),
2574 capabilities: "artifact-manager/search and remove are not implemented".to_string(),
2575 })
2576 }
2577}
2578
2579pub(crate) struct ToolInvokeEnv<'a> {
2584 pub(crate) engine: &'a Engine,
2585 pub(crate) accessible_workdir: &'a Path,
2586 pub(crate) inference_env: &'a [(String, String)],
2587 pub(crate) capability_policy: &'a CapabilityPolicy,
2588 pub(crate) network_allow_rules: &'a [NetworkAllowRule],
2591 pub(crate) artifact_grant: Option<&'a ToolCapabilityGrant>,
2595}
2596
2597pub(crate) struct ToolA2aWiring {
2607 sse: Option<(SseBroadcast, Arc<Mutex<SseEventBuffer>>)>,
2608 task_id: Option<String>,
2609 chunk_event_id: Arc<AtomicU64>,
2610 chunks_emitted: Arc<AtomicBool>,
2611 task_registry: Option<Arc<Mutex<TaskRegistry>>>,
2612 input_timeout_secs: Option<u64>,
2613}
2614
2615impl ToolA2aWiring {
2616 pub(crate) fn silent() -> Self {
2617 Self {
2618 sse: None,
2619 task_id: None,
2620 chunk_event_id: Arc::new(AtomicU64::new(0)),
2621 chunks_emitted: Arc::new(AtomicBool::new(false)),
2622 task_registry: None,
2623 input_timeout_secs: None,
2624 }
2625 }
2626}
2627
2628pub(crate) async fn invoke_tool_component(
2637 env: ToolInvokeEnv<'_>,
2638 a2a: ToolA2aWiring,
2639 name: &str,
2640 component: &Component,
2641 input: murmur::tool::run::ToolInput,
2642) -> Result<murmur::tool::run::ToolResult, String> {
2643 let ToolInvokeEnv {
2644 engine,
2645 accessible_workdir,
2646 inference_env,
2647 capability_policy,
2648 network_allow_rules,
2649 artifact_grant,
2650 } = env;
2651 let ToolA2aWiring {
2652 sse: a2a_sse,
2653 task_id: a2a_task_id,
2654 chunk_event_id: a2a_chunk_event_id,
2655 chunks_emitted: a2a_chunks_emitted,
2656 task_registry: a2a_task_registry,
2657 input_timeout_secs,
2658 } = a2a;
2659
2660 let mut linker = Linker::new(engine);
2661 wasmtime_wasi::p2::add_to_linker_async(&mut linker)
2662 .map_err(|err| format!("failed to add WASI linker for tool '{name}': {err}"))?;
2663 wasmtime_wasi_http::p2::add_only_http_to_linker_sync(&mut linker)
2664 .map_err(|err| format!("failed to add HTTP linker for tool '{name}': {err}"))?;
2665
2666 {
2672 let chunks_iface = WIT_TEXT_CHUNKS_IFACE;
2673 let sse_for_chunk = a2a_sse.clone();
2674 let task_id_for_chunk = a2a_task_id.clone();
2675 let chunk_event_id = Arc::clone(&a2a_chunk_event_id);
2676 let chunks_emitted_flag = Arc::clone(&a2a_chunks_emitted);
2677 let sse_for_thinking = a2a_sse.clone();
2678 let task_id_for_thinking = a2a_task_id.clone();
2679 let thinking_event_id = Arc::clone(&a2a_chunk_event_id);
2680
2681 let mut inst = linker.instance(chunks_iface).map_err(|err| {
2682 format!("failed to define {chunks_iface} instance for '{name}': {err}")
2683 })?;
2684
2685 inst.func_wrap(
2686 "emit-chunk",
2687 move |_store: wasmtime::StoreContextMut<'_, ToolStoreState>, (chunk,): (String,)| {
2688 chunks_emitted_flag.store(true, Ordering::Relaxed);
2689 if let (Some((ref tx, ref buf)), Some(ref tid)) =
2690 (&sse_for_chunk, &task_id_for_chunk)
2691 {
2692 emit_chunk_sse(tx, buf, &chunk_event_id, tid, &chunk);
2693 }
2694 Ok(())
2695 },
2696 )
2697 .map_err(|err| format!("failed to register emit-chunk for tool '{name}': {err}"))?;
2698
2699 inst.func_wrap(
2700 "emit-thinking-chunk",
2701 move |_store: wasmtime::StoreContextMut<'_, ToolStoreState>, (chunk,): (String,)| {
2702 if let (Some((ref tx, ref buf)), Some(ref tid)) =
2703 (&sse_for_thinking, &task_id_for_thinking)
2704 {
2705 emit_thinking_chunk_sse(tx, buf, &thinking_event_id, tid, &chunk);
2706 }
2707 Ok(())
2708 },
2709 )
2710 .map_err(|err| {
2711 format!("failed to register emit-thinking-chunk for tool '{name}': {err}")
2712 })?;
2713 }
2714
2715 {
2719 let task_iface = WIT_TASK_IFACE;
2720 let ri_task_registry = a2a_task_registry.clone();
2721 let ri_sse = a2a_sse.clone();
2722 let ri_task_id = a2a_task_id.clone();
2723 let ri_timeout = input_timeout_secs;
2724 linker
2725 .instance(task_iface)
2726 .map_err(|err| format!("failed to define {task_iface} instance for '{name}': {err}"))?
2727 .func_wrap_async(
2728 "request-input",
2729 move |_store: wasmtime::StoreContextMut<'_, ToolStoreState>,
2730 (prompt,): (String,)| {
2731 let reg = ri_task_registry.clone();
2732 let sse = ri_sse.clone();
2733 let tid = ri_task_id.clone();
2734 let fut: std::pin::Pin<
2735 Box<dyn std::future::Future<Output = wasmtime::Result<(String,)>> + Send>,
2736 > = Box::pin(async move {
2737 let result = match (reg, tid) {
2738 (Some(reg), Some(tid)) => {
2739 request_input_impl(tid, prompt, reg, sse, ri_timeout).await
2740 }
2741 _ => Err(wasmtime::Error::msg(
2742 "request-input is not available outside an A2A task context",
2743 )),
2744 };
2745 result.map(|s| (s,))
2746 });
2747 Box::new(fut)
2748 as Box<
2749 dyn std::future::Future<Output = wasmtime::Result<(String,)>>
2750 + Send
2751 + '_,
2752 >
2753 },
2754 )
2755 .map_err(|err| format!("failed to register request-input for tool '{name}': {err}"))?;
2756 }
2757
2758 let tool_limits = capability_policy.limits;
2759 let effective_network_rules = effective_tool_network_rules(artifact_grant, network_allow_rules);
2762 let filesystem_scope = artifact_grant.and_then(|grant| grant.filesystem_scope.as_deref());
2763 let state = ToolStoreState {
2764 limits: tool_limits.limiter(),
2765 table: ResourceTable::new(),
2766 wasi: build_wasi_ctx(
2767 accessible_workdir,
2768 filesystem_scope,
2769 inference_env,
2770 capability_policy,
2771 )
2772 .map_err(|err| format!("failed to build WASI context for tool '{name}': {err}"))?,
2773 http: WasiHttpCtx::new(),
2774 http_hooks: NetworkPolicyHooks {
2775 network_allow_rules: effective_network_rules.to_vec(),
2776 },
2777 };
2778
2779 let mut store = Store::new(engine, state);
2780 store.limiter(|state| &mut state.limits);
2782
2783 store.set_epoch_deadline(tool_limits.deadline_ticks());
2784 let instance = linker
2785 .instantiate_async(&mut store, component)
2786 .await
2787 .map_err(|err| format!("failed to instantiate tool '{name}': {err}"))?;
2788
2789 let tool_iface = resolve_versioned_iface(&instance, &mut store, WIT_TOOL_IFACE_VERSIONED)
2790 .ok_or_else(|| {
2791 RuntimeError::ToolExportMissing {
2792 name: name.to_string(),
2793 }
2794 .to_string()
2795 })?;
2796 let tool_run = instance
2797 .get_export_index(&mut store, Some(&tool_iface), "run")
2798 .and_then(|idx| instance.get_func(&mut store, idx))
2799 .ok_or_else(|| {
2800 RuntimeError::ToolExportMissing {
2801 name: name.to_string(),
2802 }
2803 .to_string()
2804 })?;
2805
2806 let run = tool_run
2807 .typed::<(murmur::tool::run::ToolInput,), (murmur::tool::run::ToolResult,)>(&store)
2808 .map_err(|err| format!("failed to type-check tool '{name}' run export: {err}"))?;
2809
2810 store.set_epoch_deadline(tool_limits.deadline_ticks());
2813 let called = run.call_async(&mut store, (input,)).await;
2814 let (result,) = match called {
2815 Ok(result) => result,
2816 Err(err) => {
2817 let failure = classify_guest_failure(&err, &store.data().limits);
2821 return Err(failure.message(&format!("tool '{name}'"), &err));
2822 }
2823 };
2824 Ok(result)
2825}
2826
2827impl CapsuleStoreState {
2828 pub(crate) async fn dispatch_tool_async(
2830 &self,
2831 name: &str,
2832 input: murmur::tool::run::ToolInput,
2833 ) -> Result<murmur::tool::run::ToolResult, String> {
2834 let Some(component) = self.tool_components.get(name) else {
2835 return Err(format!("tool '{name}' is not available in this session"));
2836 };
2837 invoke_tool_component(
2838 ToolInvokeEnv {
2839 engine: &self.engine,
2840 accessible_workdir: &self.accessible_workdir,
2841 inference_env: &self.inference_env,
2842 capability_policy: &self.capability_policy,
2843 network_allow_rules: &self.network_allow_rules,
2844 artifact_grant: self.artifact_grants.get(name),
2848 },
2849 ToolA2aWiring {
2850 sse: self.a2a_sse.clone(),
2851 task_id: self.a2a_task_id.clone(),
2852 chunk_event_id: Arc::clone(&self.a2a_chunk_event_id),
2853 chunks_emitted: Arc::clone(&self.a2a_chunks_emitted),
2854 task_registry: self.a2a_task_registry.clone(),
2855 input_timeout_secs: self.input_timeout_secs,
2856 },
2857 name,
2858 component,
2859 input,
2860 )
2861 .await
2862 }
2863
2864 pub(crate) async fn dispatch_agent_tool_async(
2866 &self,
2867 name: &str,
2868 input: murmur::tool::run::ToolInput,
2869 ) -> Result<DispatchOutcome, String> {
2870 let native_bin = self.workdir.join("tools").join(name).join(name);
2872 if native_bin.exists() && !self.tool_components.contains_key(name) {
2873 return enforce_allowlist(&self.allowlisted_tools, name, || {
2874 dispatch_native_tool(
2875 name,
2876 input,
2877 &native_bin,
2878 &self.accessible_workdir,
2879 &self.capability_policy,
2880 &self.shell_enforcement,
2881 )
2882 })
2883 .map(DispatchOutcome::tool);
2884 }
2885
2886 if self
2889 .capability_policy
2890 .shell_allow
2891 .iter()
2892 .any(|allowed| allowed == name)
2893 {
2894 let name = name.to_string();
2895 let workdir = self.accessible_workdir.clone();
2896 let env_overrides = self.inference_env.clone();
2897 let policy = self.capability_policy.clone();
2898 let enforcement = self.shell_enforcement.clone();
2899 return tokio::task::spawn_blocking(move || {
2900 dispatch_shell_tool(
2901 &name,
2902 input,
2903 &workdir,
2904 &env_overrides,
2905 &policy,
2906 &enforcement,
2907 )
2908 })
2909 .await
2910 .map_err(|e| format!("shell tool panicked: {e}"));
2911 }
2912
2913 let skill_md_path = self.workdir.join("tools").join(name).join("skill.md");
2916 if skill_md_path.exists() {
2917 let content = fs::read_to_string(&skill_md_path)
2918 .map_err(|e| format!("failed to read skill '{name}': {e}"))?;
2919 return Ok(DispatchOutcome::skill(murmur::tool::run::ToolResult {
2920 status: murmur::tool::run::Status::Passed,
2921 summary: Some(format!("Skill {name} guidance")),
2922 data: Some(content),
2923 data_path: None,
2924 truncated: false,
2925 metadata: Vec::new(),
2926 }));
2927 }
2928
2929 if !self.allowlisted_tools.contains(name) {
2931 return Err(format!(
2932 "tool '{name}' is not declared in manifest allowlist"
2933 ));
2934 }
2935 self.dispatch_tool_async(name, input)
2936 .await
2937 .map(DispatchOutcome::tool)
2938 }
2939}
2940
2941fn read_artifact_info_from_manifest(
2942 manifest_path: &Path,
2943 fallback: &InstalledArtifactSummary,
2944) -> Result<manage::ArtifactInfo, String> {
2945 let manifest_content = fs::read_to_string(manifest_path)
2946 .map_err(|err| format!("failed to read {}: {err}", manifest_path.display()))?;
2947
2948 let value: Value = serde_yaml::from_str(&manifest_content)
2949 .map_err(|err| format!("failed to parse {}: {err}", manifest_path.display()))?;
2950
2951 let root = value.as_mapping();
2952
2953 let description = root
2954 .and_then(|mapping| mapping.get(Value::String("description".to_string())))
2955 .and_then(Value::as_str)
2956 .unwrap_or_default()
2957 .to_string();
2958
2959 let tags = root
2960 .and_then(|mapping| mapping.get(Value::String("tags".to_string())))
2961 .and_then(Value::as_sequence)
2962 .map(|items| {
2963 items
2964 .iter()
2965 .filter_map(Value::as_str)
2966 .map(ToString::to_string)
2967 .collect::<Vec<_>>()
2968 })
2969 .unwrap_or_default();
2970
2971 let input_schema = root
2972 .and_then(|mapping| mapping.get(Value::String("input_schema".to_string())))
2973 .map(yaml_to_json_string)
2974 .transpose()?;
2975 let output_schema = root
2976 .and_then(|mapping| mapping.get(Value::String("output_schema".to_string())))
2977 .map(yaml_to_json_string)
2978 .transpose()?;
2979
2980 Ok(manage::ArtifactInfo {
2981 name: fallback.name.clone(),
2982 version: fallback.version.clone(),
2983 description,
2984 tags,
2985 runtime: runtime_type_to_wit(&fallback.runtime, fallback.implementation.as_ref()),
2986 input_schema,
2987 output_schema,
2988 })
2989}
2990
2991fn yaml_to_json_string(value: &Value) -> Result<String, String> {
2992 if let Value::String(s) = value {
2993 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(s) {
2994 return serde_json::to_string(&parsed)
2995 .map_err(|err| format!("failed to convert schema to JSON: {err}"));
2996 }
2997 }
2998 serde_json::to_string(value).map_err(|err| format!("failed to convert schema to JSON: {err}"))
2999}
3000
3001fn runtime_type_to_wit(
3002 runtime: &ArtifactRuntime,
3003 implementation: Option<&ArtifactImplementation>,
3004) -> manage::RuntimeType {
3005 match (runtime, implementation) {
3006 (ArtifactRuntime::Tool, Some(ArtifactImplementation::Native)) => {
3007 manage::RuntimeType::Native
3008 }
3009 _ => manage::RuntimeType::Wasm,
3010 }
3011}
3012
3013struct ToolStoreState {
3014 limits: ExecutionLimiter,
3016 table: ResourceTable,
3017 wasi: WasiCtx,
3018 http: WasiHttpCtx,
3019 http_hooks: NetworkPolicyHooks,
3020}
3021
3022impl WasiView for ToolStoreState {
3023 fn ctx(&mut self) -> WasiCtxView<'_> {
3024 WasiCtxView {
3025 ctx: &mut self.wasi,
3026 table: &mut self.table,
3027 }
3028 }
3029}
3030
3031impl WasiHttpView for ToolStoreState {
3032 fn http(&mut self) -> WasiHttpCtxView<'_> {
3033 WasiHttpCtxView {
3034 ctx: &mut self.http,
3035 table: &mut self.table,
3036 hooks: &mut self.http_hooks,
3037 }
3038 }
3039}
3040
3041fn write_tool_manifest(
3046 workdir: &Path,
3047 name: &str,
3048 manifest_yaml: &str,
3049) -> Result<(), RuntimeError> {
3050 let manifest_path = workdir.join("tools").join(name).join(PACKED_MANIFEST_ENTRY);
3051 let Some(parent) = manifest_path.parent() else {
3052 return Err(RuntimeError::Runtime(format!(
3053 "failed to derive parent for tool manifest path {}",
3054 manifest_path.display()
3055 )));
3056 };
3057
3058 fs::create_dir_all(parent).map_err(|source| RuntimeError::WriteToolManifest {
3059 path: manifest_path.display().to_string(),
3060 source,
3061 })?;
3062 fs::write(&manifest_path, manifest_yaml).map_err(|source| RuntimeError::WriteToolManifest {
3063 path: manifest_path.display().to_string(),
3064 source,
3065 })
3066}
3067
3068fn install_native_binaries(
3069 workdir: &Path,
3070 native_binaries: Vec<(String, Vec<u8>)>,
3071) -> Result<(), RuntimeError> {
3072 for (name, bytes) in native_binaries {
3073 let binary_path = workdir.join("tools").join(&name).join(&name);
3074 let Some(parent) = binary_path.parent() else {
3075 return Err(RuntimeError::Runtime(format!(
3076 "failed to derive parent for native binary path {}",
3077 binary_path.display()
3078 )));
3079 };
3080 fs::create_dir_all(parent).map_err(|source| RuntimeError::WriteToolManifest {
3081 path: binary_path.display().to_string(),
3082 source,
3083 })?;
3084 fs::write(&binary_path, &bytes).map_err(|source| RuntimeError::WriteToolManifest {
3085 path: binary_path.display().to_string(),
3086 source,
3087 })?;
3088 #[cfg(unix)]
3089 {
3090 use std::os::unix::fs::PermissionsExt;
3091 let mut perms = fs::metadata(&binary_path)
3092 .map_err(|source| RuntimeError::WriteToolManifest {
3093 path: binary_path.display().to_string(),
3094 source,
3095 })?
3096 .permissions();
3097 perms.set_mode(0o755);
3098 fs::set_permissions(&binary_path, perms).map_err(|source| {
3099 RuntimeError::WriteToolManifest {
3100 path: binary_path.display().to_string(),
3101 source,
3102 }
3103 })?;
3104 }
3105 }
3106 Ok(())
3107}
3108
3109fn load_local_skill_md(manifest_dir: &Path, source: &str) -> Result<Vec<u8>, RuntimeError> {
3118 let raw = Path::new(source);
3119 let path = if raw.is_absolute() {
3120 raw.to_path_buf()
3121 } else {
3122 manifest_dir.join(raw)
3123 };
3124
3125 if !path.exists() {
3126 return Err(RuntimeError::SkillSourceNotFound {
3127 path: path.display().to_string(),
3128 });
3129 }
3130
3131 let skill_md_path = if path.is_dir() {
3132 find_skill_md_in_dir(&path)?.ok_or_else(|| RuntimeError::SkillSourceMissingSkillMd {
3133 path: path.display().to_string(),
3134 })?
3135 } else {
3136 path.clone()
3137 };
3138
3139 fs::read(&skill_md_path).map_err(|source| RuntimeError::SkillSourceRead {
3140 path: skill_md_path.display().to_string(),
3141 source,
3142 })
3143}
3144
3145fn find_skill_md_in_dir(dir: &Path) -> Result<Option<std::path::PathBuf>, RuntimeError> {
3147 let entries = fs::read_dir(dir).map_err(|source| RuntimeError::SkillSourceRead {
3148 path: dir.display().to_string(),
3149 source,
3150 })?;
3151 for entry in entries {
3152 let entry = entry.map_err(|source| RuntimeError::SkillSourceRead {
3153 path: dir.display().to_string(),
3154 source,
3155 })?;
3156 if entry.file_name().to_string_lossy().to_lowercase() == "skill.md" {
3157 return Ok(Some(entry.path()));
3158 }
3159 }
3160 Ok(None)
3161}
3162
3163fn install_skill_files(
3164 workdir: &Path,
3165 skill_files: Vec<(String, Vec<u8>)>,
3166) -> Result<(), RuntimeError> {
3167 for (name, bytes) in skill_files {
3168 let skill_path = workdir.join("tools").join(&name).join("skill.md");
3169 let Some(parent) = skill_path.parent() else {
3170 return Err(RuntimeError::Runtime(format!(
3171 "failed to derive parent for skill.md path {}",
3172 skill_path.display()
3173 )));
3174 };
3175 fs::create_dir_all(parent).map_err(|source| RuntimeError::WriteToolManifest {
3176 path: skill_path.display().to_string(),
3177 source,
3178 })?;
3179 fs::write(&skill_path, &bytes).map_err(|source| RuntimeError::WriteToolManifest {
3180 path: skill_path.display().to_string(),
3181 source,
3182 })?;
3183 }
3184 Ok(())
3185}
3186
3187fn write_shell_tool_manifests(workdir: &Path, shell_allow: &[String]) -> Result<(), RuntimeError> {
3188 for binary in shell_allow {
3189 let manifest_path = workdir
3190 .join("tools")
3191 .join(binary)
3192 .join(PACKED_MANIFEST_ENTRY);
3193
3194 if manifest_path.exists() {
3195 continue;
3196 }
3197
3198 let Some(parent) = manifest_path.parent() else {
3199 return Err(RuntimeError::Runtime(format!(
3200 "failed to derive parent for shell manifest path {}",
3201 manifest_path.display()
3202 )));
3203 };
3204
3205 fs::create_dir_all(parent).map_err(|source| RuntimeError::WriteToolManifest {
3206 path: manifest_path.display().to_string(),
3207 source,
3208 })?;
3209
3210 fs::write(&manifest_path, shell_tool_manifest_yaml(binary)).map_err(|source| {
3211 RuntimeError::WriteToolManifest {
3212 path: manifest_path.display().to_string(),
3213 source,
3214 }
3215 })?;
3216 }
3217
3218 Ok(())
3219}
3220
3221fn dispatch_native_tool(
3226 name: &str,
3227 input: murmur::tool::run::ToolInput,
3228 binary_path: &Path,
3229 workdir: &Path,
3230 policy: &CapabilityPolicy,
3231 enforcement: &sandbox::ShellEnforcement,
3232) -> Result<murmur::tool::run::ToolResult, String> {
3233 use std::{
3234 io::Write,
3235 process::{Command, Stdio},
3236 };
3237
3238 let input_json = serde_json::to_string(&serde_json::json!({
3239 "data": input.data,
3240 "log_path": input.log_path,
3241 }))
3242 .map_err(|e| format!("failed to serialize input for native tool '{name}': {e}"))?;
3243
3244 enforcement.check_workdir_budget()?;
3245
3246 let env = build_shell_env(policy, &[], workdir)?;
3247
3248 let mut command = Command::new(binary_path);
3251 command
3252 .current_dir(workdir)
3253 .env_clear()
3254 .envs(env)
3255 .stdin(Stdio::piped())
3256 .stdout(Stdio::piped())
3257 .stderr(Stdio::piped());
3258
3259 sandbox::attach_process_limits(&mut command, enforcement);
3263 crate::sandbox::apply_fd_hygiene(&mut command);
3276
3277 let mut child = command
3278 .spawn()
3279 .map_err(|e| format!("failed to spawn native tool '{name}': {e}"))?;
3280
3281 if let Some(mut stdin) = child.stdin.take() {
3282 let _ = stdin.write_all(input_json.as_bytes());
3283 }
3285
3286 let output = child
3287 .wait_with_output()
3288 .map_err(|e| format!("native tool '{name}' failed to complete: {e}"))?;
3289
3290 let stdout = String::from_utf8_lossy(&output.stdout);
3291 let stderr = String::from_utf8_lossy(&output.stderr);
3292
3293 if stdout.trim().is_empty() {
3294 return Ok(murmur::tool::run::ToolResult {
3295 status: if output.status.success() {
3296 murmur::tool::run::Status::Passed
3297 } else {
3298 murmur::tool::run::Status::Error
3299 },
3300 summary: Some(format!(
3301 "native tool '{}' exited with {}",
3302 name, output.status
3303 )),
3304 data: if stderr.is_empty() {
3305 None
3306 } else {
3307 Some(stderr.to_string())
3308 },
3309 data_path: None,
3310 truncated: false,
3311 metadata: Vec::new(),
3312 });
3313 }
3314
3315 match serde_json::from_str::<serde_json::Value>(stdout.trim()) {
3316 Ok(json) => {
3317 let status = match json.get("status").and_then(|s| s.as_str()) {
3318 Some("passed") => murmur::tool::run::Status::Passed,
3319 Some("failed") => murmur::tool::run::Status::Failed,
3320 _ => murmur::tool::run::Status::Error,
3321 };
3322 let summary = json
3323 .get("summary")
3324 .and_then(|s| s.as_str())
3325 .map(|s| s.to_string());
3326 let data = json
3327 .get("data")
3328 .and_then(|d| d.as_str())
3329 .map(|s| s.to_string())
3330 .or_else(|| {
3331 json.get("data")
3332 .filter(|d| !d.is_null())
3333 .map(|d| d.to_string())
3334 });
3335 Ok(murmur::tool::run::ToolResult {
3336 status,
3337 summary,
3338 data,
3339 data_path: None,
3340 truncated: false,
3341 metadata: Vec::new(),
3342 })
3343 }
3344 Err(_) => Ok(murmur::tool::run::ToolResult {
3345 status: if output.status.success() {
3346 murmur::tool::run::Status::Passed
3347 } else {
3348 murmur::tool::run::Status::Error
3349 },
3350 summary: Some(format!("native tool '{}' completed", name)),
3351 data: Some(stdout.to_string()),
3352 data_path: None,
3353 truncated: false,
3354 metadata: Vec::new(),
3355 }),
3356 }
3357}
3358
3359fn dispatch_shell_tool(
3360 name: &str,
3361 input: murmur::tool::run::ToolInput,
3362 workdir: &Path,
3363 env_overrides: &[(String, String)],
3364 policy: &CapabilityPolicy,
3365 enforcement: &sandbox::ShellEnforcement,
3366) -> DispatchOutcome {
3367 let command = match extract_shell_command(&input) {
3368 Ok(command) => command,
3369 Err(error) => {
3370 return DispatchOutcome::tool(murmur::tool::run::ToolResult {
3371 status: murmur::tool::run::Status::Error,
3372 summary: Some("shell command parsing failed".to_string()),
3373 data: Some(error),
3374 data_path: None,
3375 truncated: false,
3376 metadata: Vec::new(),
3377 });
3378 }
3379 };
3380
3381 let split_args: Vec<String>;
3382 let args: Vec<&str> = if is_shell_interpreter(name) {
3383 vec!["-c", command.as_str()]
3384 } else {
3385 split_args = split_shell_words(&command);
3386 split_args.iter().map(String::as_str).collect()
3387 };
3388
3389 match execute_shell(name, &args, env_overrides, workdir, policy, enforcement) {
3390 Ok(result) => {
3391 let shell = ShellDispatchInfo {
3392 binary: result.binary.clone(),
3395 command: command.clone(),
3396 exit_code: result.exit_code,
3397 stdout: result.stdout.clone(),
3398 stderr: result.stderr.clone(),
3399 stdout_bytes: result.stdout.len() as u64,
3400 stderr_bytes: result.stderr.len() as u64,
3401 duration_ms: result.duration_ms,
3402 resource_limit: result.resource_limit_hit.clone(),
3403 };
3404 DispatchOutcome {
3405 result: shell_result_to_tool_result(&command, result),
3406 shell: Some(shell),
3407 is_skill: false,
3408 fatal: None,
3409 }
3410 }
3411 Err(error) => DispatchOutcome {
3412 result: murmur::tool::run::ToolResult {
3416 status: murmur::tool::run::Status::Error,
3417 summary: Some("shell execution failed".to_string()),
3418 data: Some(error.to_string()),
3419 data_path: None,
3420 truncated: false,
3421 metadata: Vec::new(),
3422 },
3423 shell: None,
3424 is_skill: false,
3425 fatal: error.session_fatal(),
3426 },
3427 }
3428}
3429
3430fn extract_shell_command(input: &murmur::tool::run::ToolInput) -> Result<String, String> {
3431 let data = input
3432 .data
3433 .as_deref()
3434 .ok_or_else(|| "shell tool input.data is required".to_string())?;
3435
3436 let json: serde_json::Value = serde_json::from_str(data)
3437 .map_err(|error| format!("shell tool input must be valid JSON: {error}"))?;
3438
3439 let command = json
3440 .get("command")
3441 .and_then(serde_json::Value::as_str)
3442 .map(str::trim)
3443 .filter(|value| !value.is_empty())
3444 .ok_or_else(|| "shell tool input must include a non-empty 'command' string".to_string())?;
3445
3446 Ok(command.to_string())
3447}
3448
3449fn shell_result_to_tool_result(
3450 command: &str,
3451 result: ShellResult,
3452) -> murmur::tool::run::ToolResult {
3453 let mut data = format!(
3454 "$ {}\nExit code: {}\nStdout:\n{}\nStderr:\n{}",
3455 command, result.exit_code, result.stdout, result.stderr
3456 );
3457
3458 let mut metadata = Vec::new();
3459 if result.truncated {
3460 if let Some(path) = result.full_output_path.as_ref() {
3461 data.push_str(&format!(
3462 "\n\nOutput truncated. Full output written to {path}"
3463 ));
3464 metadata.push(("full_output_path".to_string(), path.clone()));
3465 } else {
3466 data.push_str("\n\nOutput truncated.");
3467 }
3468 }
3469
3470 murmur::tool::run::ToolResult {
3471 status: murmur::tool::run::Status::Passed,
3472 summary: Some(format!(
3473 "Shell command exited with code {}",
3474 result.exit_code
3475 )),
3476 data: Some(data),
3477 data_path: None,
3478 truncated: result.truncated,
3479 metadata,
3480 }
3481}
3482
3483fn resolve_context_window(context: Option<&ContextConfig>) -> u32 {
3484 context.and_then(|c| c.max_tokens).unwrap_or(0)
3485}
3486
3487fn validate_capability_policy(policy: &CapabilityPolicy) -> Result<(), RuntimeError> {
3488 parse_network_allow_rules(&policy.network_allow)?;
3489 if let Some(scope) = policy.filesystem_scope.as_deref() {
3490 validate_filesystem_scope(scope)?;
3491 }
3492
3493 Ok(())
3494}
3495
3496fn enforce_allowlist<T, F>(
3497 allowlisted_tools: &HashSet<String>,
3498 name: &str,
3499 dispatch: F,
3500) -> Result<T, String>
3501where
3502 F: FnOnce() -> Result<T, String>,
3503{
3504 if !allowlisted_tools.contains(name) {
3505 return Err(format!(
3506 "tool '{name}' is not declared in manifest allowlist"
3507 ));
3508 }
3509
3510 dispatch()
3511}
3512
3513fn generate_session_id() -> String {
3514 format!("ses_{}", uuid::Uuid::now_v7().simple())
3515}
3516
3517fn resolve_lifecycle(
3518 base: Option<LifecycleConfig>,
3519 override_: Option<&murmur_artifact::LifecycleOverride>,
3520) -> LifecycleConfig {
3521 let mut config = base.unwrap_or_default();
3522 if let Some(ov) = override_ {
3523 if let Some(ta) = &ov.task_acceptance {
3524 config.task_acceptance = ta.clone();
3525 }
3526 if let Some(at) = &ov.after_task {
3527 config.after_task = at.clone();
3528 }
3529 }
3530 config
3531}
3532
3533#[cfg(test)]
3534mod tests {
3535 use murmur_artifact::{ArtifactMeta, ArtifactRuntime, Registry, ResolvedArtifact, RuntimeType};
3536 use tempfile::TempDir;
3537
3538 use super::*;
3539
3540 fn bootstrap_log_contents(workdir: &Path) -> String {
3541 fs::read_to_string(workdir.join("logs").join("bootstrap.log")).unwrap_or_default()
3542 }
3543
3544 fn inference_without_prompt() -> murmur_artifact::InferenceConfig {
3547 murmur_artifact::InferenceConfig {
3548 transport: "http".to_string(),
3549 endpoint: Some("http://127.0.0.1:1".to_string()),
3550 model: "test-model".to_string(),
3551 api_key: None,
3552 driver: None,
3553 command: None,
3554 compaction: None,
3555 system_prompt: None,
3556 system_prompt_file: None,
3557 system_prompt_artifact: None,
3558 max_turns: 10,
3559 max_task_reopens: 1,
3560 max_tokens: None,
3561 }
3562 }
3563
3564 #[test]
3565 fn resolve_system_prompt_returns_none_when_nothing_is_declared() {
3566 let tmp = TempDir::new().unwrap();
3567 let resolved =
3568 resolve_system_prompt(tmp.path(), tmp.path(), &inference_without_prompt()).unwrap();
3569 assert_eq!(resolved, None);
3570 }
3571
3572 #[test]
3573 fn resolve_system_prompt_returns_the_inline_prompt_verbatim() {
3574 let tmp = TempDir::new().unwrap();
3575 let inference = murmur_artifact::InferenceConfig {
3576 system_prompt: Some("Be terse.".to_string()),
3577 ..inference_without_prompt()
3578 };
3579 let resolved = resolve_system_prompt(tmp.path(), tmp.path(), &inference).unwrap();
3580 assert_eq!(resolved.as_deref(), Some("Be terse."));
3581 }
3582
3583 #[test]
3587 fn resolve_system_prompt_reads_the_prompt_file_verbatim_relative_to_the_manifest_dir() {
3588 let manifest_dir = TempDir::new().unwrap();
3589 let workdir = TempDir::new().unwrap();
3590 fs::write(manifest_dir.path().join("conventions.md"), " Be terse.\n").unwrap();
3591
3592 let inference = murmur_artifact::InferenceConfig {
3593 system_prompt_file: Some("conventions.md".to_string()),
3594 ..inference_without_prompt()
3595 };
3596 let resolved =
3597 resolve_system_prompt(manifest_dir.path(), workdir.path(), &inference).unwrap();
3598 assert_eq!(resolved.as_deref(), Some(" Be terse.\n"));
3599 }
3600
3601 #[test]
3602 fn resolve_system_prompt_errors_when_the_prompt_file_is_missing() {
3603 let tmp = TempDir::new().unwrap();
3604 let inference = murmur_artifact::InferenceConfig {
3605 system_prompt_file: Some("missing-conventions.md".to_string()),
3606 ..inference_without_prompt()
3607 };
3608 match resolve_system_prompt(tmp.path(), tmp.path(), &inference) {
3609 Err(RuntimeError::SystemPromptFileRead { path, .. }) => {
3610 assert!(path.ends_with("missing-conventions.md"), "got {path}");
3611 }
3612 other => panic!("expected SystemPromptFileRead, got {other:?}"),
3613 }
3614 }
3615
3616 #[test]
3620 fn resolve_system_prompt_reads_skill_md_from_the_staged_artifact() {
3621 let manifest_dir = TempDir::new().unwrap();
3622 let workdir = TempDir::new().unwrap();
3623 let skill_dir = workdir.path().join("tools").join("house-style");
3624 fs::create_dir_all(&skill_dir).unwrap();
3625 fs::write(skill_dir.join("skill.md"), "# House style\nBe terse.").unwrap();
3626
3627 let inference = murmur_artifact::InferenceConfig {
3628 system_prompt_artifact: Some("house-style".to_string()),
3629 ..inference_without_prompt()
3630 };
3631 let resolved =
3632 resolve_system_prompt(manifest_dir.path(), workdir.path(), &inference).unwrap();
3633 assert_eq!(resolved.as_deref(), Some("# House style\nBe terse."));
3634 }
3635
3636 #[test]
3637 fn resolve_system_prompt_errors_when_the_prompt_artifact_has_no_skill_md() {
3638 let tmp = TempDir::new().unwrap();
3639 let inference = murmur_artifact::InferenceConfig {
3640 system_prompt_artifact: Some("house-style".to_string()),
3641 ..inference_without_prompt()
3642 };
3643 match resolve_system_prompt(tmp.path(), tmp.path(), &inference) {
3644 Err(RuntimeError::SystemPromptArtifactRead { name, .. }) => {
3645 assert_eq!(name, "house-style");
3646 }
3647 other => panic!("expected SystemPromptArtifactRead, got {other:?}"),
3648 }
3649 }
3650
3651 #[test]
3655 fn resolve_system_prompt_ignores_cleared_declarations() {
3656 let tmp = TempDir::new().unwrap();
3657 let inference = murmur_artifact::InferenceConfig {
3658 system_prompt: Some("CLI prompt".to_string()),
3659 system_prompt_file: None,
3660 system_prompt_artifact: None,
3661 ..inference_without_prompt()
3662 };
3663 let resolved = resolve_system_prompt(tmp.path(), tmp.path(), &inference).unwrap();
3664 assert_eq!(resolved.as_deref(), Some("CLI prompt"));
3665 }
3666
3667 #[test]
3668 fn bash_and_network_together_trigger_warning() {
3669 let tmp = TempDir::new().unwrap();
3670 let policy = CapabilityPolicy {
3671 shell_allow: vec!["bash".to_string()],
3672 network_allow: vec!["https://api.example.com".to_string()],
3673 ..Default::default()
3674 };
3675 warn_if_bash_network_bypass(tmp.path(), &policy);
3676 let log = bootstrap_log_contents(tmp.path());
3677 assert!(log.contains("bash"), "log should mention bash: {log}");
3678 assert!(log.contains("network"), "log should mention network: {log}");
3679 assert!(
3680 log.contains(W_SEC_003),
3681 "log should carry its warning code: {log}"
3682 );
3683 assert!(
3684 log.contains(&security_warning_link(W_SEC_003)),
3685 "log should link to the diagnostics doc page: {log}"
3686 );
3687 }
3688
3689 fn compaction_config(
3690 system_prompt: Option<&str>,
3691 system_prompt_file: Option<&str>,
3692 ) -> murmur_artifact::CompactionConfig {
3693 murmur_artifact::CompactionConfig {
3694 threshold: None,
3695 model: Some("compaction-model".to_string()),
3696 system_prompt: system_prompt.map(str::to_string),
3697 system_prompt_file: system_prompt_file.map(str::to_string),
3698 dump_summaries: None,
3699 }
3700 }
3701
3702 #[test]
3705 fn compaction_system_prompt_file_resolves_relative_to_manifest_dir() {
3706 let manifest_dir = TempDir::new().unwrap();
3707 let body = "Summarize aggressively.\nKeep file paths.\n";
3708 fs::write(manifest_dir.path().join("compaction-instructions.md"), body).unwrap();
3709
3710 let resolved = resolve_compaction_system_prompt(
3711 manifest_dir.path(),
3712 Some(&compaction_config(None, Some("compaction-instructions.md"))),
3713 )
3714 .expect("file resolves");
3715
3716 assert_eq!(resolved, Some(body.to_string()));
3717 }
3718
3719 #[test]
3721 fn compaction_inline_system_prompt_resolves_without_reading_a_file() {
3722 let manifest_dir = TempDir::new().unwrap();
3723
3724 let resolved = resolve_compaction_system_prompt(
3725 manifest_dir.path(),
3726 Some(&compaction_config(Some("inline prompt"), None)),
3727 )
3728 .expect("inline prompt resolves");
3729
3730 assert_eq!(resolved, Some("inline prompt".to_string()));
3731 }
3732
3733 #[test]
3736 fn compaction_system_prompt_absent_resolves_to_none() {
3737 let manifest_dir = TempDir::new().unwrap();
3738
3739 assert_eq!(
3740 resolve_compaction_system_prompt(
3741 manifest_dir.path(),
3742 Some(&compaction_config(None, None))
3743 )
3744 .unwrap(),
3745 None
3746 );
3747 assert_eq!(
3748 resolve_compaction_system_prompt(manifest_dir.path(), None).unwrap(),
3749 None
3750 );
3751 }
3752
3753 #[test]
3757 fn compaction_system_prompt_file_missing_reports_compaction_variant() {
3758 let manifest_dir = TempDir::new().unwrap();
3759
3760 let err = resolve_compaction_system_prompt(
3761 manifest_dir.path(),
3762 Some(&compaction_config(None, Some("nope.md"))),
3763 )
3764 .expect_err("missing file must fail");
3765
3766 match &err {
3767 RuntimeError::CompactionSystemPromptFileRead { path, .. } => {
3768 assert!(
3769 path.ends_with("nope.md"),
3770 "error should name the resolved path, got {path}"
3771 );
3772 assert!(
3773 path.starts_with(&manifest_dir.path().display().to_string()),
3774 "path should be manifest-dir relative, got {path}"
3775 );
3776 }
3777 other => panic!("expected CompactionSystemPromptFileRead, got {other:?}"),
3778 }
3779 assert!(err
3780 .to_string()
3781 .contains("inference.compaction.system_prompt_file"));
3782 }
3783
3784 #[test]
3785 fn network_without_bash_does_not_warn() {
3786 let tmp = TempDir::new().unwrap();
3787 let policy = CapabilityPolicy {
3788 shell_allow: vec!["cargo".to_string(), "git".to_string()],
3789 network_allow: vec!["https://api.example.com".to_string()],
3790 ..Default::default()
3791 };
3792 warn_if_bash_network_bypass(tmp.path(), &policy);
3793 assert!(bootstrap_log_contents(tmp.path()).is_empty());
3794 }
3795
3796 #[test]
3797 fn bash_without_network_does_not_warn() {
3798 let tmp = TempDir::new().unwrap();
3799 let policy = CapabilityPolicy {
3800 shell_allow: vec!["bash".to_string()],
3801 network_allow: Vec::new(),
3802 ..Default::default()
3803 };
3804 warn_if_bash_network_bypass(tmp.path(), &policy);
3805 assert!(bootstrap_log_contents(tmp.path()).is_empty());
3806 }
3807
3808 #[test]
3809 fn neither_declared_does_not_warn() {
3810 let tmp = TempDir::new().unwrap();
3811 warn_if_bash_network_bypass(tmp.path(), &CapabilityPolicy::default());
3812 assert!(bootstrap_log_contents(tmp.path()).is_empty());
3813 }
3814
3815 #[test]
3816 fn non_bash_shell_interpreter_does_not_warn() {
3817 let tmp = TempDir::new().unwrap();
3818 let policy = CapabilityPolicy {
3819 shell_allow: vec!["sh".to_string()],
3820 network_allow: vec!["https://api.example.com".to_string()],
3821 ..Default::default()
3822 };
3823 warn_if_bash_network_bypass(tmp.path(), &policy);
3824 assert!(
3825 bootstrap_log_contents(tmp.path()).is_empty(),
3826 "exact match on \"bash\" literal must not fire for other shell interpreters like sh"
3827 );
3828 }
3829
3830 #[test]
3831 fn allowlist_blocks_unlisted_before_dispatch() {
3832 let allowlist = HashSet::from(["echo-tool".to_string()]);
3833 let mut dispatched = false;
3834
3835 let result = enforce_allowlist(&allowlist, "missing-tool", || {
3836 dispatched = true;
3837 Ok::<_, String>(())
3838 });
3839
3840 assert!(!dispatched);
3841 assert!(result.is_err());
3842 assert!(result
3843 .unwrap_err()
3844 .contains("not declared in manifest allowlist"));
3845 }
3846
3847 #[test]
3848 fn allowlist_dispatches_listed_tool() {
3849 let allowlist = HashSet::from(["echo-tool".to_string()]);
3850 let result = enforce_allowlist(&allowlist, "echo-tool", || Ok::<_, String>("ok")).unwrap();
3851 assert_eq!(result, "ok");
3852 }
3853
3854 fn iface_double(engine: &wasmtime::Engine, iface: &str) -> wasmtime::component::Component {
3859 let wat = format!(
3860 "(component\n\
3861 (instance $i)\n\
3862 (export \"{iface}\" (instance $i))\n\
3863 )"
3864 );
3865 let bytes = wat::parse_str(&wat).expect("component WAT parses");
3866 wasmtime::component::Component::new(engine, bytes).expect("component compiles")
3867 }
3868
3869 fn iface_test_engine() -> wasmtime::Engine {
3870 let mut config = wasmtime::Config::new();
3871 config.wasm_component_model(true);
3872 wasmtime::Engine::new(&config).expect("engine builds")
3873 }
3874
3875 fn instantiate_iface_double(
3876 engine: &wasmtime::Engine,
3877 iface: &str,
3878 ) -> (wasmtime::component::Instance, Store<()>) {
3879 let component = iface_double(engine, iface);
3880 let linker = wasmtime::component::Linker::new(engine);
3881 let mut store = Store::new(engine, ());
3882 let instance = linker
3883 .instantiate(&mut store, &component)
3884 .expect("component with no imports instantiates");
3885 (instance, store)
3886 }
3887
3888 #[test]
3891 fn resolve_versioned_iface_finds_versioned_name() {
3892 let engine = iface_test_engine();
3893 let (instance, mut store) = instantiate_iface_double(&engine, WIT_CAPSULE_IFACE_VERSIONED);
3894 let found = resolve_versioned_iface(&instance, &mut store, WIT_CAPSULE_IFACE_VERSIONED);
3895 assert!(
3896 found.is_some(),
3897 "a component exporting the versioned name must resolve"
3898 );
3899 }
3900
3901 #[test]
3905 fn resolve_versioned_iface_rejects_unversioned_only_name() {
3906 let engine = iface_test_engine();
3907 let (instance, mut store) = instantiate_iface_double(&engine, "murmur:tool/run");
3908 let found = resolve_versioned_iface(&instance, &mut store, WIT_TOOL_IFACE_VERSIONED);
3909 assert!(
3910 found.is_none(),
3911 "a component exporting only the legacy unversioned name must no longer resolve"
3912 );
3913 }
3914
3915 #[test]
3919 fn resolve_versioned_iface_returns_none_when_neither_name_matches() {
3920 let engine = iface_test_engine();
3921 let (instance, mut store) = instantiate_iface_double(&engine, "murmur:capsule/nonexistent");
3922 let found = resolve_versioned_iface(&instance, &mut store, WIT_CAPSULE_IFACE_VERSIONED);
3923 assert!(
3924 found.is_none(),
3925 "a component exporting neither probed name must not resolve"
3926 );
3927 }
3928
3929 #[test]
3930 fn generate_session_id_is_unique() {
3931 let first = generate_session_id();
3932 let second = generate_session_id();
3933
3934 assert_ne!(first, second);
3935 assert!(first.starts_with("ses_"), "session id must start with ses_");
3936 assert_eq!(
3937 first.len(),
3938 36,
3939 "session id must be 36 chars (ses_ + 32 hex)"
3940 );
3941 uuid::Uuid::parse_str(&first[4..]).expect("session id suffix should be a valid UUID");
3942 }
3943
3944 #[test]
3945 fn sha_verification_happens_before_component_compile() {
3946 struct FakeRegistry;
3947
3948 impl Registry for FakeRegistry {
3949 fn resolve(
3950 &self,
3951 name: &str,
3952 version: &str,
3953 ) -> Result<ResolvedArtifact, RegistryError> {
3954 Ok(ResolvedArtifact {
3955 meta: ArtifactMeta {
3956 name: name.to_string(),
3957 version: version.to_string(),
3958 runtime: RuntimeType::Wasm,
3959 artifact_runtime: "wasm".to_string(),
3960 platforms: Vec::new(),
3961 description: None,
3962 tags: Vec::new(),
3963 },
3964 bytes: b"not-a-real-zip".to_vec().into(),
3965 sha256: "definitely-wrong".to_string(),
3966 })
3967 }
3968
3969 fn publish(
3970 &self,
3971 _meta: ArtifactMeta,
3972 _bytes: &[u8],
3973 ) -> Result<murmur_artifact::PublishResult, RegistryError> {
3974 unreachable!()
3975 }
3976
3977 fn list_index(&self) -> Result<Vec<ArtifactMeta>, RegistryError> {
3978 unreachable!()
3979 }
3980 }
3981
3982 let tempdir = tempfile::tempdir().unwrap();
3983 let request = StageRequest {
3984 manifest_dir: tempdir.path().to_path_buf(),
3985 capsule_name: "test".to_string(),
3986 capsule_version: String::new(),
3987 capsule_component_bytes: b"not-a-component".to_vec(),
3988 artifacts: vec![crate::types::ArtifactRequest {
3989 name: "echo-tool".to_string(),
3990 version: "0.0.1".to_string(),
3991 runtime: ArtifactRuntime::Tool,
3992 source: None,
3993 on_overflow: Default::default(),
3994 capabilities: None,
3995 }],
3996 allowlisted_tools: HashSet::from(["echo-tool".to_string()]),
3997 lock_expectations: None,
3998 capability_policy: CapabilityPolicy::default(),
3999 inference: None,
4000 system_prompt_overridden: false,
4001 context: None,
4002 otel_endpoint: None,
4003 eval_config_json: None,
4004 case_id: None,
4005 dataset_id: None,
4006 lifecycle: None,
4007 lifecycle_override: None,
4008 trace: None,
4009 workdir: None,
4010 bind_addr: "127.0.0.1".to_string(),
4011 internal_port: None,
4012 declared_containment_floor: murmur_artifact::ContainmentClass::Advisory,
4013 };
4014
4015 let err = match stage_session(Arc::new(FakeRegistry), request) {
4016 Ok(_) => panic!("expected stage_session to fail"),
4017 Err(err) => err,
4018 };
4019 assert!(matches!(
4020 err,
4021 RuntimeError::ArtifactIntegrityFailed { name, version }
4022 if name == "echo-tool" && version == "0.0.1"
4023 ));
4024 }
4025
4026 #[test]
4027 fn lock_expectation_mismatch_returns_integrity_failure() {
4028 struct FakeRegistry;
4029
4030 impl Registry for FakeRegistry {
4031 fn resolve(
4032 &self,
4033 name: &str,
4034 version: &str,
4035 ) -> Result<ResolvedArtifact, RegistryError> {
4036 Ok(ResolvedArtifact {
4037 meta: ArtifactMeta {
4038 name: name.to_string(),
4039 version: version.to_string(),
4040 runtime: RuntimeType::Wasm,
4041 artifact_runtime: "wasm".to_string(),
4042 platforms: Vec::new(),
4043 description: None,
4044 tags: Vec::new(),
4045 },
4046 bytes: b"not-a-real-zip".to_vec().into(),
4047 sha256: murmur_artifact::sha256_hex(b"not-a-real-zip"),
4048 })
4049 }
4050
4051 fn publish(
4052 &self,
4053 _meta: ArtifactMeta,
4054 _bytes: &[u8],
4055 ) -> Result<murmur_artifact::PublishResult, RegistryError> {
4056 unreachable!()
4057 }
4058
4059 fn list_index(&self) -> Result<Vec<ArtifactMeta>, RegistryError> {
4060 unreachable!()
4061 }
4062 }
4063
4064 let tempdir = tempfile::tempdir().unwrap();
4065 let request = StageRequest {
4066 manifest_dir: tempdir.path().to_path_buf(),
4067 capsule_name: "test".to_string(),
4068 capsule_version: String::new(),
4069 capsule_component_bytes: Vec::new(),
4070 artifacts: vec![crate::types::ArtifactRequest {
4071 name: "echo-tool".to_string(),
4072 version: "0.0.1".to_string(),
4073 runtime: ArtifactRuntime::Tool,
4074 source: None,
4075 on_overflow: Default::default(),
4076 capabilities: None,
4077 }],
4078 allowlisted_tools: HashSet::from(["echo-tool".to_string()]),
4079 lock_expectations: Some(vec![crate::types::LockExpectation {
4080 name: "echo-tool".to_string(),
4081 resolved_version: "0.0.1".to_string(),
4082 sha256_wasm: "different".to_string(),
4083 }]),
4084 capability_policy: CapabilityPolicy::default(),
4085 inference: None,
4086 system_prompt_overridden: false,
4087 context: None,
4088 otel_endpoint: None,
4089 eval_config_json: None,
4090 case_id: None,
4091 dataset_id: None,
4092 lifecycle: None,
4093 lifecycle_override: None,
4094 trace: None,
4095 workdir: None,
4096 bind_addr: "127.0.0.1".to_string(),
4097 internal_port: None,
4098 declared_containment_floor: murmur_artifact::ContainmentClass::Advisory,
4099 };
4100
4101 let err = match stage_session(Arc::new(FakeRegistry), request) {
4102 Ok(_) => panic!("expected stage_session to fail"),
4103 Err(err) => err,
4104 };
4105 assert!(matches!(
4106 err,
4107 RuntimeError::ArtifactIntegrityFailed { name, version }
4108 if name == "echo-tool" && version == "0.0.1"
4109 ));
4110 }
4111
4112 #[test]
4113 fn lock_expectations_require_entry_for_each_manifest_artifact() {
4114 struct FakeRegistry;
4115
4116 impl Registry for FakeRegistry {
4117 fn resolve(
4118 &self,
4119 _name: &str,
4120 _version: &str,
4121 ) -> Result<ResolvedArtifact, RegistryError> {
4122 unreachable!("lock validation should fail before any registry resolve call")
4123 }
4124
4125 fn publish(
4126 &self,
4127 _meta: ArtifactMeta,
4128 _bytes: &[u8],
4129 ) -> Result<murmur_artifact::PublishResult, RegistryError> {
4130 unreachable!()
4131 }
4132
4133 fn list_index(&self) -> Result<Vec<ArtifactMeta>, RegistryError> {
4134 unreachable!()
4135 }
4136 }
4137
4138 let tempdir = tempfile::tempdir().unwrap();
4139 let request = StageRequest {
4140 manifest_dir: tempdir.path().to_path_buf(),
4141 capsule_name: "test".to_string(),
4142 capsule_version: String::new(),
4143 capsule_component_bytes: Vec::new(),
4144 artifacts: vec![crate::types::ArtifactRequest {
4145 name: "echo-tool".to_string(),
4146 version: "0.0.1".to_string(),
4147 runtime: ArtifactRuntime::Tool,
4148 source: None,
4149 on_overflow: Default::default(),
4150 capabilities: None,
4151 }],
4152 allowlisted_tools: HashSet::from(["echo-tool".to_string()]),
4153 lock_expectations: Some(vec![crate::types::LockExpectation {
4154 name: "different-tool".to_string(),
4155 resolved_version: "0.0.1".to_string(),
4156 sha256_wasm: "abc".to_string(),
4157 }]),
4158 capability_policy: CapabilityPolicy::default(),
4159 inference: None,
4160 system_prompt_overridden: false,
4161 context: None,
4162 otel_endpoint: None,
4163 eval_config_json: None,
4164 case_id: None,
4165 dataset_id: None,
4166 lifecycle: None,
4167 lifecycle_override: None,
4168 trace: None,
4169 workdir: None,
4170 bind_addr: "127.0.0.1".to_string(),
4171 internal_port: None,
4172 declared_containment_floor: murmur_artifact::ContainmentClass::Advisory,
4173 };
4174
4175 let err = match stage_session(Arc::new(FakeRegistry), request) {
4176 Ok(_) => panic!("expected stage_session to fail"),
4177 Err(err) => err,
4178 };
4179 assert!(matches!(
4180 err,
4181 RuntimeError::LockMissingEntry { name } if name == "echo-tool"
4182 ));
4183 }
4184
4185 #[test]
4186 fn lock_expectation_version_mismatch_is_reported() {
4187 struct FakeRegistry;
4188
4189 impl Registry for FakeRegistry {
4190 fn resolve(
4191 &self,
4192 _name: &str,
4193 _version: &str,
4194 ) -> Result<ResolvedArtifact, RegistryError> {
4195 unreachable!("version mismatch should fail before registry resolve")
4196 }
4197
4198 fn publish(
4199 &self,
4200 _meta: ArtifactMeta,
4201 _bytes: &[u8],
4202 ) -> Result<murmur_artifact::PublishResult, RegistryError> {
4203 unreachable!()
4204 }
4205
4206 fn list_index(&self) -> Result<Vec<ArtifactMeta>, RegistryError> {
4207 unreachable!()
4208 }
4209 }
4210
4211 let tempdir = tempfile::tempdir().unwrap();
4212 let request = StageRequest {
4213 manifest_dir: tempdir.path().to_path_buf(),
4214 capsule_name: "test".to_string(),
4215 capsule_version: String::new(),
4216 capsule_component_bytes: Vec::new(),
4217 artifacts: vec![crate::types::ArtifactRequest {
4218 name: "echo-tool".to_string(),
4219 version: "0.0.9".to_string(),
4220 runtime: ArtifactRuntime::Tool,
4221 source: None,
4222 on_overflow: Default::default(),
4223 capabilities: None,
4224 }],
4225 allowlisted_tools: HashSet::from(["echo-tool".to_string()]),
4226 lock_expectations: Some(vec![crate::types::LockExpectation {
4227 name: "echo-tool".to_string(),
4228 resolved_version: "0.0.1".to_string(),
4229 sha256_wasm: "abc".to_string(),
4230 }]),
4231 capability_policy: CapabilityPolicy::default(),
4232 inference: None,
4233 system_prompt_overridden: false,
4234 context: None,
4235 otel_endpoint: None,
4236 eval_config_json: None,
4237 case_id: None,
4238 dataset_id: None,
4239 lifecycle: None,
4240 lifecycle_override: None,
4241 trace: None,
4242 workdir: None,
4243 bind_addr: "127.0.0.1".to_string(),
4244 internal_port: None,
4245 declared_containment_floor: murmur_artifact::ContainmentClass::Advisory,
4246 };
4247
4248 let err = match stage_session(Arc::new(FakeRegistry), request) {
4249 Ok(_) => panic!("expected stage_session to fail"),
4250 Err(err) => err,
4251 };
4252 assert!(matches!(
4253 err,
4254 RuntimeError::LockVersionMismatch {
4255 name,
4256 requested,
4257 pinned,
4258 } if name == "echo-tool" && requested == "0.0.9" && pinned == "0.0.1"
4259 ));
4260 }
4261
4262 #[test]
4265 fn load_local_skill_md_reads_file_path() {
4266 let dir = tempfile::tempdir().unwrap();
4267 let skill = dir.path().join("skill.md");
4268 fs::write(&skill, b"# hi from file").unwrap();
4269 let bytes = load_local_skill_md(dir.path(), "skill.md").unwrap();
4270 assert_eq!(bytes, b"# hi from file");
4271 }
4272
4273 #[test]
4274 fn load_local_skill_md_finds_skill_md_in_directory_case_insensitive() {
4275 let manifest_dir = tempfile::tempdir().unwrap();
4276 let skill_dir = manifest_dir.path().join("skills").join("my-skill");
4277 fs::create_dir_all(&skill_dir).unwrap();
4278 fs::write(skill_dir.join("SKILL.MD"), b"# upper").unwrap();
4280 let bytes = load_local_skill_md(manifest_dir.path(), "skills/my-skill").unwrap();
4281 assert_eq!(bytes, b"# upper");
4282 }
4283
4284 #[test]
4285 fn load_local_skill_md_missing_path_errors_with_name() {
4286 let dir = tempfile::tempdir().unwrap();
4287 let err = load_local_skill_md(dir.path(), "does/not/exist").unwrap_err();
4288 match err {
4289 RuntimeError::SkillSourceNotFound { path } => {
4290 assert!(path.contains("does/not/exist"), "path was: {path}");
4291 }
4292 other => panic!("expected SkillSourceNotFound, got {other:?}"),
4293 }
4294 }
4295
4296 #[test]
4297 fn load_local_skill_md_directory_without_skill_md_errors() {
4298 let dir = tempfile::tempdir().unwrap();
4299 let empty = dir.path().join("empty");
4300 fs::create_dir_all(&empty).unwrap();
4301 let err = load_local_skill_md(dir.path(), "empty").unwrap_err();
4302 match err {
4303 RuntimeError::SkillSourceMissingSkillMd { path } => {
4304 assert!(path.contains("empty"), "path was: {path}");
4305 }
4306 other => panic!("expected SkillSourceMissingSkillMd, got {other:?}"),
4307 }
4308 }
4309
4310 #[test]
4311 fn load_local_skill_md_absolute_path_ignores_manifest_dir() {
4312 let manifest_dir = tempfile::tempdir().unwrap();
4313 let elsewhere = tempfile::tempdir().unwrap();
4314 let skill = elsewhere.path().join("skill.md");
4315 fs::write(&skill, b"# absolute").unwrap();
4316 let bytes = load_local_skill_md(manifest_dir.path(), &skill.to_string_lossy()).unwrap();
4317 assert_eq!(bytes, b"# absolute");
4318 }
4319
4320 #[test]
4321 fn stage_session_installs_local_source_skill_without_registry() {
4322 use murmur_artifact::{InferenceConfig, InferenceDriver};
4323
4324 struct PanicRegistry;
4326 impl Registry for PanicRegistry {
4327 fn resolve(&self, _: &str, _: &str) -> Result<ResolvedArtifact, RegistryError> {
4328 panic!("registry must not be called for a local-source skill");
4329 }
4330 fn publish(
4331 &self,
4332 _: ArtifactMeta,
4333 _: &[u8],
4334 ) -> Result<murmur_artifact::PublishResult, RegistryError> {
4335 unreachable!()
4336 }
4337 fn list_index(&self) -> Result<Vec<ArtifactMeta>, RegistryError> {
4338 unreachable!()
4339 }
4340 }
4341
4342 let project = tempfile::tempdir().unwrap();
4343 let skill_dir = project.path().join("skills").join("my-skill");
4344 fs::create_dir_all(&skill_dir).unwrap();
4345 fs::write(skill_dir.join("skill.md"), b"# my local skill").unwrap();
4346
4347 let inference = InferenceConfig {
4348 transport: "http".into(),
4349 endpoint: Some("http://localhost".into()),
4350 model: "claude-3-haiku".into(),
4351 api_key: None,
4352 driver: Some(InferenceDriver {
4353 artifact: "test-driver".into(),
4354 config: None,
4355 }),
4356 command: None,
4357 compaction: None,
4358 system_prompt: None,
4359 system_prompt_file: None,
4360 system_prompt_artifact: None,
4361 max_turns: 10,
4362 max_task_reopens: 1,
4363 max_tokens: None,
4364 };
4365
4366 let request = StageRequest {
4367 manifest_dir: project.path().to_path_buf(),
4368 capsule_name: "test".to_string(),
4369 capsule_version: "0.0.1".to_string(),
4370 capsule_component_bytes: Vec::new(),
4371 artifacts: vec![crate::types::ArtifactRequest {
4372 name: "my-skill".to_string(),
4373 version: "local".to_string(),
4374 runtime: ArtifactRuntime::Skill,
4375 source: Some("skills/my-skill".to_string()),
4376 on_overflow: Default::default(),
4377 capabilities: None,
4378 }],
4379 allowlisted_tools: HashSet::new(),
4380 lock_expectations: None,
4381 capability_policy: CapabilityPolicy::default(),
4382 inference: Some(inference),
4383 system_prompt_overridden: false,
4384 context: None,
4385 otel_endpoint: None,
4386 eval_config_json: None,
4387 case_id: None,
4388 dataset_id: None,
4389 lifecycle: None,
4390 lifecycle_override: None,
4391 trace: None,
4392 workdir: None,
4393 bind_addr: "127.0.0.1".to_string(),
4394 internal_port: None,
4395 declared_containment_floor: murmur_artifact::ContainmentClass::Advisory,
4396 };
4397
4398 let staged = stage_session(Arc::new(PanicRegistry), request).unwrap();
4399 let installed = staged
4400 .workdir
4401 .join("tools")
4402 .join("my-skill")
4403 .join("skill.md");
4404 assert!(
4405 installed.exists(),
4406 "skill.md not installed at {}",
4407 installed.display()
4408 );
4409 assert_eq!(fs::read(&installed).unwrap(), b"# my local skill");
4410 assert!(staged.resolved_lock_artifacts.is_empty());
4412 assert_eq!(staged.installed_artifacts.len(), 1);
4413 assert_eq!(staged.installed_artifacts[0].version, "local");
4414 let murmur_md = fs::read_to_string(staged.workdir.join("MURMUR.md")).unwrap();
4416 assert!(
4417 murmur_md.contains("**my-skill**"),
4418 "MURMUR.md missing skill listing:\n{murmur_md}"
4419 );
4420 assert!(
4421 murmur_md.contains("call by name to load guidance"),
4422 "MURMUR.md missing callable skill hint:\n{murmur_md}"
4423 );
4424 }
4425
4426 fn zip_with_files(files: &[(&str, &[u8])]) -> Vec<u8> {
4429 use std::io::Write as _;
4430 let mut cursor = std::io::Cursor::new(Vec::<u8>::new());
4431 {
4432 let mut zip = zip::ZipWriter::new(&mut cursor);
4433 let options = zip::write::SimpleFileOptions::default();
4434 for (name, bytes) in files {
4435 zip.start_file(*name, options).unwrap();
4436 zip.write_all(bytes).unwrap();
4437 }
4438 zip.finish().unwrap();
4439 }
4440 cursor.into_inner()
4441 }
4442
4443 fn build_test_state(
4444 registry: Arc<dyn Registry>,
4445 workdir: PathBuf,
4446 lock_path: PathBuf,
4447 ) -> CapsuleStoreState {
4448 let engine = build_engine().unwrap();
4449 let wasi = build_wasi_ctx(&workdir, None, &[], &CapabilityPolicy::default()).unwrap();
4450 CapsuleStoreState {
4451 limits: crate::limits::ExecutionLimits::default().limiter(),
4452 table: ResourceTable::new(),
4453 wasi,
4454 http: WasiHttpCtx::new(),
4455 http_hooks: NetworkPolicyHooks {
4456 network_allow_rules: Vec::new(),
4457 },
4458 network_allow_rules: Vec::new(),
4459 inference_env: Vec::new(),
4460 engine,
4461 workdir: workdir.clone(),
4462 accessible_workdir: workdir,
4463 tool_components: HashMap::new(),
4464 artifact_grants: HashMap::new(),
4465 allowlisted_tools: HashSet::new(),
4466 installed_artifacts: Vec::new(),
4467 session_id: "ses_test".to_string(),
4468 pending_a2a_events: Vec::new(),
4469 capability_policy: CapabilityPolicy::default(),
4470 shell_enforcement: sandbox::ShellEnforcement::environment_only(),
4471 current_traceparent: None,
4472 a2a_task_registry: None,
4473 a2a_sse: None,
4474 a2a_task_id: None,
4475 input_timeout_secs: None,
4476 a2a_chunk_event_id: Arc::new(AtomicU64::new(0)),
4477 a2a_chunks_emitted: Arc::new(AtomicBool::new(false)),
4478 registry,
4479 lock_path,
4480 driver_continuation_id: None,
4481 driver_continuation_context_id: None,
4482 driver_continuation_acked_len: 0,
4483 }
4484 }
4485
4486 fn continuation_test_state() -> CapsuleStoreState {
4489 let dir = tempfile::tempdir().unwrap();
4490 let workdir = dir.path().to_path_buf();
4491 let lock_path = workdir.join("murmur.lock");
4492 build_test_state(
4494 Arc::new(FakeSkillRegistry::new(Vec::new())),
4495 workdir,
4496 lock_path,
4497 )
4498 }
4499
4500 #[test]
4501 fn continuation_default_is_none_and_active_query_returns_none() {
4502 let state = continuation_test_state();
4503 assert!(state.driver_continuation_id.is_none());
4504 assert!(state.active_continuation(Some("ctx-a")).is_none());
4505 assert!(state.active_continuation(None).is_none());
4506 }
4507
4508 #[test]
4509 fn continuation_record_then_active_requires_matching_context() {
4510 let mut state = continuation_test_state();
4512 state.record_continuation("cont-1".into(), Some("ctx-a".into()), 2);
4513
4514 assert_eq!(
4515 state.active_continuation(Some("ctx-a")),
4516 Some(("cont-1", 2)),
4517 "same context → continuation is active"
4518 );
4519 assert!(
4520 state.active_continuation(Some("ctx-b")).is_none(),
4521 "different context → continuation must not be reused"
4522 );
4523 assert!(
4524 state.active_continuation(None).is_none(),
4525 "absent context → continuation must not be reused"
4526 );
4527 }
4528
4529 #[test]
4530 fn continuation_clear_drops_all_bookkeeping() {
4531 let mut state = continuation_test_state();
4533 state.record_continuation("cont-1".into(), Some("ctx-a".into()), 5);
4534 state.clear_continuation();
4535 assert!(state.driver_continuation_id.is_none());
4536 assert_eq!(state.driver_continuation_acked_len, 0);
4537 assert!(state.active_continuation(Some("ctx-a")).is_none());
4538 }
4539
4540 #[test]
4541 fn advance_acked_len_only_affects_same_context_held_continuation() {
4542 let mut state = continuation_test_state();
4545 state.record_continuation("cont-1".into(), Some("ctx-a".into()), 1);
4546
4547 state.advance_continuation_acked_len(Some("ctx-b"), 9);
4548 assert_eq!(
4549 state.driver_continuation_acked_len, 1,
4550 "advancing under a different context must be a no-op"
4551 );
4552
4553 state.advance_continuation_acked_len(Some("ctx-a"), 2);
4554 assert_eq!(
4555 state.active_continuation(Some("ctx-a")),
4556 Some(("cont-1", 2))
4557 );
4558
4559 state.clear_continuation();
4560 state.advance_continuation_acked_len(Some("ctx-a"), 7);
4561 assert_eq!(
4562 state.driver_continuation_acked_len, 0,
4563 "advancing with no held continuation must be a no-op"
4564 );
4565 }
4566
4567 struct FakeSkillRegistry {
4568 bytes: Vec<u8>,
4569 sha256: String,
4570 }
4571
4572 impl FakeSkillRegistry {
4573 fn new(bytes: Vec<u8>) -> Self {
4574 let sha256 = murmur_artifact::sha256_hex(&bytes);
4575 Self { bytes, sha256 }
4576 }
4577 }
4578
4579 impl Registry for FakeSkillRegistry {
4580 fn resolve(&self, name: &str, version: &str) -> Result<ResolvedArtifact, RegistryError> {
4581 Ok(ResolvedArtifact {
4582 meta: ArtifactMeta {
4583 name: name.to_string(),
4584 version: version.to_string(),
4585 runtime: RuntimeType::Static,
4586 artifact_runtime: "skill".to_string(),
4587 platforms: Vec::new(),
4588 description: None,
4589 tags: Vec::new(),
4590 },
4591 bytes: self.bytes.clone().into(),
4592 sha256: self.sha256.clone(),
4593 })
4594 }
4595
4596 fn publish(
4597 &self,
4598 _meta: ArtifactMeta,
4599 _bytes: &[u8],
4600 ) -> Result<murmur_artifact::PublishResult, RegistryError> {
4601 unreachable!()
4602 }
4603
4604 fn list_index(&self) -> Result<Vec<ArtifactMeta>, RegistryError> {
4605 unreachable!()
4606 }
4607 }
4608
4609 #[test]
4610 fn pull_happy_path_installs_artifact_and_updates_lock() {
4611 let artifact_bytes = zip_with_files(&[
4612 (
4613 PACKED_MANIFEST_ENTRY,
4614 b"name: my-skill\nversion: 1.0.0\nruntime: skill\n",
4615 ),
4616 ("skill.md", b"# guidance"),
4617 ]);
4618 let registry = Arc::new(FakeSkillRegistry::new(artifact_bytes));
4619 let expected_sha256 = registry.sha256.clone();
4620
4621 let project = tempfile::tempdir().unwrap();
4622 let workdir = project.path().join("workdir");
4623 fs::create_dir_all(&workdir).unwrap();
4624 let lock_path = project.path().join("murmur.lock");
4625
4626 let mut state = build_test_state(registry, workdir.clone(), lock_path.clone());
4627
4628 let summary = manage::Host::pull(&mut state, "my-skill".to_string(), "1.0.0".to_string())
4629 .expect("pull should succeed");
4630 assert_eq!(summary.name, "my-skill");
4631 assert_eq!(summary.version, "1.0.0");
4632
4633 let installed_skill_md = workdir.join("tools").join("my-skill").join("skill.md");
4634 assert!(installed_skill_md.exists());
4635 assert_eq!(fs::read(&installed_skill_md).unwrap(), b"# guidance");
4636
4637 assert!(state
4638 .installed_artifacts
4639 .iter()
4640 .any(|a| a.name == "my-skill" && a.version == "1.0.0"));
4641
4642 let lock = read_lockfile(&lock_path).expect("murmur.lock should have been written");
4643 let entry = lock
4644 .artifact_for("my-skill")
4645 .expect("lock entry for my-skill");
4646 assert_eq!(entry.resolved_version, "1.0.0");
4647 assert_eq!(entry.sha256.wasm, expected_sha256);
4648 }
4649
4650 #[test]
4651 fn pull_rejects_tampered_bytes_and_writes_nothing() {
4652 struct TamperedRegistry;
4653 impl Registry for TamperedRegistry {
4654 fn resolve(
4655 &self,
4656 name: &str,
4657 version: &str,
4658 ) -> Result<ResolvedArtifact, RegistryError> {
4659 Ok(ResolvedArtifact {
4660 meta: ArtifactMeta {
4661 name: name.to_string(),
4662 version: version.to_string(),
4663 runtime: RuntimeType::Static,
4664 artifact_runtime: "skill".to_string(),
4665 platforms: Vec::new(),
4666 description: None,
4667 tags: Vec::new(),
4668 },
4669 bytes: b"tampered-bytes".to_vec().into(),
4670 sha256: "not-the-real-hash".to_string(),
4671 })
4672 }
4673
4674 fn publish(
4675 &self,
4676 _meta: ArtifactMeta,
4677 _bytes: &[u8],
4678 ) -> Result<murmur_artifact::PublishResult, RegistryError> {
4679 unreachable!()
4680 }
4681
4682 fn list_index(&self) -> Result<Vec<ArtifactMeta>, RegistryError> {
4683 unreachable!()
4684 }
4685 }
4686
4687 let project = tempfile::tempdir().unwrap();
4688 let workdir = project.path().join("workdir");
4689 fs::create_dir_all(&workdir).unwrap();
4690 let lock_path = project.path().join("murmur.lock");
4691
4692 let mut state = build_test_state(
4693 Arc::new(TamperedRegistry),
4694 workdir.clone(),
4695 lock_path.clone(),
4696 );
4697
4698 let err = manage::Host::pull(&mut state, "evil-tool".to_string(), "1.0.0".to_string())
4699 .expect_err("tampered bytes must be rejected");
4700 assert!(err.contains("integrity"), "unexpected error message: {err}");
4701
4702 assert!(!workdir.join("tools").join("evil-tool").exists());
4703 assert!(!lock_path.exists());
4704 assert!(state.installed_artifacts.is_empty());
4705 }
4706
4707 #[test]
4708 fn pull_rejects_lock_conflict_and_writes_nothing() {
4709 let artifact_bytes = zip_with_files(&[
4710 (
4711 PACKED_MANIFEST_ENTRY,
4712 b"name: my-skill\nversion: 2.0.0\nruntime: skill\n",
4713 ),
4714 ("skill.md", b"# guidance v2"),
4715 ]);
4716 let registry = Arc::new(FakeSkillRegistry::new(artifact_bytes));
4717
4718 let project = tempfile::tempdir().unwrap();
4719 let workdir = project.path().join("workdir");
4720 fs::create_dir_all(&workdir).unwrap();
4721 let lock_path = project.path().join("murmur.lock");
4722
4723 write_lockfile_atomic(
4725 &lock_path,
4726 &MurmurLock {
4727 lock_version: LOCK_VERSION,
4728 artifacts: vec![LockedArtifact {
4729 name: "my-skill".to_string(),
4730 resolved_version: "1.0.0".to_string(),
4731 sha256: LockedSha256 {
4732 wasm: "pinned-hash-from-earlier-pull".to_string(),
4733 },
4734 }],
4735 },
4736 )
4737 .unwrap();
4738
4739 let mut state = build_test_state(registry, workdir.clone(), lock_path.clone());
4740
4741 let err = manage::Host::pull(&mut state, "my-skill".to_string(), "2.0.0".to_string())
4742 .expect_err("lock conflict must be rejected");
4743 assert!(
4744 err.contains("murmur.lock conflict"),
4745 "unexpected error message: {err}"
4746 );
4747
4748 assert!(!workdir
4749 .join("tools")
4750 .join("my-skill")
4751 .join("skill.md")
4752 .exists());
4753 assert!(state.installed_artifacts.is_empty());
4754
4755 let lock = read_lockfile(&lock_path).unwrap();
4757 let entry = lock.artifact_for("my-skill").unwrap();
4758 assert_eq!(entry.resolved_version, "1.0.0");
4759 assert_eq!(entry.sha256.wasm, "pinned-hash-from-earlier-pull");
4760 }
4761
4762 fn write_env_echo_native_tool(dir: &Path, name: &str, env_vars: &[&str]) -> std::path::PathBuf {
4766 use std::os::unix::fs::PermissionsExt;
4767
4768 let script_path = dir.join(name);
4769 let echoes: String = env_vars
4770 .iter()
4771 .map(|var| format!(r#"echo -n "{var}=${var} ""#))
4772 .collect::<Vec<_>>()
4773 .join("\n");
4774 let script = format!(
4775 "#!/bin/sh\ncat >/dev/null\nprintf '{{\"status\":\"passed\",\"data\":\"'\n{echoes}\nprintf '\"}}'\n"
4776 );
4777 fs::write(&script_path, script).unwrap();
4778 fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)).unwrap();
4779 script_path
4780 }
4781
4782 #[test]
4787 fn dispatch_shell_tool_reports_the_invoked_binary_separately_from_the_command() {
4788 let tmp = TempDir::new().unwrap();
4789 let policy = CapabilityPolicy {
4790 shell_allow: vec!["bash".to_string()],
4791 ..CapabilityPolicy::default()
4792 };
4793
4794 let outcome = dispatch_shell_tool(
4795 "bash",
4796 murmur::tool::run::ToolInput {
4797 data: Some(r#"{"command":"echo hi"}"#.to_string()),
4798 log_path: None,
4799 },
4800 tmp.path(),
4801 &[],
4802 &policy,
4803 &sandbox::ShellEnforcement::environment_only(),
4804 );
4805
4806 let shell = outcome
4807 .shell
4808 .expect("a successful shell call reports itself");
4809 assert!(
4810 Path::new(&shell.binary).is_absolute() && shell.binary.ends_with("bash"),
4811 "binary must be the resolved path of what ran, got {:?}",
4812 shell.binary
4813 );
4814 assert_eq!(
4815 shell.command, "echo hi",
4816 "command must still carry only the argument list"
4817 );
4818 assert_eq!(shell.exit_code, 0);
4819 }
4820
4821 #[cfg(target_os = "linux")]
4830 #[test]
4831 fn a_sealed_composed_root_failure_ends_the_session_not_just_the_tool_call() {
4832 if crate::network_namespace::skip_without_egress_namespace(
4833 "a_sealed_composed_root_failure_ends_the_session_not_just_the_tool_call",
4834 ) {
4835 return;
4836 }
4837 let tmp = TempDir::new().unwrap();
4838 let policy = CapabilityPolicy {
4839 shell_allow: vec!["bash".to_string()],
4840 ..CapabilityPolicy::default()
4841 };
4842
4843 let _guard = sandbox::ForceSealedRootFailureGuard::new();
4844 let outcome = dispatch_shell_tool(
4845 "bash",
4846 murmur::tool::run::ToolInput {
4847 data: Some(r#"{"command":"echo hi"}"#.to_string()),
4848 log_path: None,
4849 },
4850 tmp.path(),
4851 &[],
4852 &policy,
4853 &sandbox::sealed_test_enforcement(),
4854 );
4855
4856 assert!(
4857 matches!(outcome.result.status, murmur::tool::run::Status::Error),
4858 "the failed call must still read as a failed call"
4859 );
4860 let data = outcome.result.data.as_deref().unwrap_or_default();
4861 assert!(
4862 data.contains("composed root"),
4863 "the tool result must still say what happened: {data}"
4864 );
4865 let fatal = outcome
4866 .fatal
4867 .expect("a composed-root failure must be carried out as session-fatal");
4868 assert!(
4869 matches!(fatal, RuntimeError::SealedRootConstructionFailed { .. }),
4870 "must be the variant murmur-cli maps to E-RUN-014: {fatal}"
4871 );
4872 }
4873
4874 #[test]
4877 fn an_ordinary_shell_failure_leaves_the_session_running() {
4878 let tmp = TempDir::new().unwrap();
4879 let outcome = dispatch_shell_tool(
4880 "bash",
4881 murmur::tool::run::ToolInput {
4882 data: Some(r#"{"command":"echo hi"}"#.to_string()),
4883 log_path: None,
4884 },
4885 tmp.path(),
4886 &[],
4887 &CapabilityPolicy::default(),
4889 &sandbox::ShellEnforcement::environment_only(),
4890 );
4891
4892 assert!(matches!(
4893 outcome.result.status,
4894 murmur::tool::run::Status::Error
4895 ));
4896 assert!(
4897 outcome.fatal.is_none(),
4898 "a disallowed binary is the capsule's problem, not the session's"
4899 );
4900 }
4901
4902 #[test]
4908 fn shell_dispatch_writes_the_resolved_binary_into_trace_jsonl() {
4909 let tmp = TempDir::new().unwrap();
4910 let workdir = tmp.path().to_path_buf();
4911 let policy = CapabilityPolicy {
4912 shell_allow: vec!["bash".to_string()],
4913 ..CapabilityPolicy::default()
4914 };
4915
4916 let rt = tokio::runtime::Runtime::new().unwrap();
4917 let events: Vec<serde_json::Value> = rt.block_on(async {
4918 let outcome = dispatch_shell_tool(
4919 "bash",
4920 murmur::tool::run::ToolInput {
4921 data: Some(r#"{"command":"echo hi"}"#.to_string()),
4922 log_path: None,
4923 },
4924 &workdir,
4925 &[],
4926 &policy,
4927 &sandbox::ShellEnforcement::environment_only(),
4928 );
4929 let shell = outcome.shell.expect("the shell call reports itself");
4930
4931 let mut trace = TraceWriter::open(
4932 &workdir,
4933 "ses_test".to_string(),
4934 "cap".to_string(),
4935 "0.1.0".to_string(),
4936 "test-model".to_string(),
4937 Vec::new(),
4938 crate::containment::scope_report_for_tier(
4939 &policy,
4940 murmur_artifact::ContainmentClass::Advisory,
4941 sandbox::EnforcementTier::EnvironmentOnly,
4942 None,
4943 ),
4944 false,
4945 None,
4946 false,
4947 )
4948 .await
4949 .unwrap();
4950 trace
4951 .write_shell(
4952 1,
4953 shell.binary.clone(),
4954 shell.command.clone(),
4955 shell.exit_code,
4956 shell.stdout_bytes,
4957 shell.stderr_bytes,
4958 shell.duration_ms,
4959 shell.resource_limit.clone(),
4960 )
4961 .await
4962 .unwrap();
4963 trace.flush().await.unwrap();
4964
4965 fs::read_to_string(workdir.join("trace.jsonl"))
4966 .unwrap()
4967 .lines()
4968 .filter(|l| !l.is_empty())
4969 .map(|l| serde_json::from_str(l).unwrap())
4970 .collect()
4971 });
4972
4973 let shell_event = events
4974 .iter()
4975 .find(|e| e["event_type"] == "shell")
4976 .expect("trace.jsonl must carry a shell event");
4977 let binary = shell_event["binary"]
4978 .as_str()
4979 .expect("the shell event must carry a `binary` string");
4980 assert!(
4981 Path::new(binary).is_absolute() && binary.ends_with("bash"),
4982 "trace.jsonl's binary must be the resolved absolute path of what ran, got {binary:?}"
4983 );
4984 assert_eq!(shell_event["command"], "echo hi");
4985 }
4986
4987 #[test]
4988 fn dispatch_native_tool_gets_synthetic_home_matching_execute_shell() {
4989 let tmp = TempDir::new().unwrap();
4990 let binary = write_env_echo_native_tool(tmp.path(), "echo-home", &["HOME"]);
4991 let policy = CapabilityPolicy::default();
4992
4993 let result = dispatch_native_tool(
4994 "echo-home",
4995 murmur::tool::run::ToolInput {
4996 data: None,
4997 log_path: None,
4998 },
4999 &binary,
5000 tmp.path(),
5001 &policy,
5002 &sandbox::ShellEnforcement::environment_only(),
5003 )
5004 .unwrap();
5005
5006 let expected_home = tmp.path().join(".capsule-home");
5007 let data = result.data.unwrap_or_default();
5008 assert!(
5009 data.contains(&format!("HOME={}", expected_home.display())),
5010 "expected synthetic HOME in native tool output, got: {data}"
5011 );
5012 assert!(expected_home.is_dir());
5013 }
5014
5015 #[test]
5016 fn dispatch_native_tool_strips_credential_shaped_var() {
5017 let tmp = TempDir::new().unwrap();
5018 let binary = write_env_echo_native_tool(tmp.path(), "echo-token", &["GITHUB_TOKEN"]);
5019 let policy = CapabilityPolicy::default();
5020
5021 std::env::set_var("GITHUB_TOKEN", "leaked-token");
5022 let result = dispatch_native_tool(
5023 "echo-token",
5024 murmur::tool::run::ToolInput {
5025 data: None,
5026 log_path: None,
5027 },
5028 &binary,
5029 tmp.path(),
5030 &policy,
5031 &sandbox::ShellEnforcement::environment_only(),
5032 )
5033 .unwrap();
5034 std::env::remove_var("GITHUB_TOKEN");
5035
5036 let data = result.data.unwrap_or_default();
5037 assert!(
5038 !data.contains("leaked-token"),
5039 "GITHUB_TOKEN must not reach a native tool subprocess, got: {data}"
5040 );
5041 }
5042
5043 #[test]
5044 fn dispatch_native_tool_strips_wildcard_credential_pattern() {
5045 let tmp = TempDir::new().unwrap();
5046 let binary = write_env_echo_native_tool(tmp.path(), "echo-stripe", &["STRIPE_API_KEY"]);
5047 let policy = CapabilityPolicy::default();
5048
5049 std::env::set_var("STRIPE_API_KEY", "leaked-key");
5050 let result = dispatch_native_tool(
5051 "echo-stripe",
5052 murmur::tool::run::ToolInput {
5053 data: None,
5054 log_path: None,
5055 },
5056 &binary,
5057 tmp.path(),
5058 &policy,
5059 &sandbox::ShellEnforcement::environment_only(),
5060 )
5061 .unwrap();
5062 std::env::remove_var("STRIPE_API_KEY");
5063
5064 let data = result.data.unwrap_or_default();
5065 assert!(
5066 !data.contains("leaked-key"),
5067 "*_API_KEY wildcard pattern must strip STRIPE_API_KEY from native tool subprocess, got: {data}"
5068 );
5069 }
5070
5071 #[test]
5072 fn dispatch_native_tool_keeps_safe_baseline_var() {
5073 let tmp = TempDir::new().unwrap();
5074 let binary = write_env_echo_native_tool(tmp.path(), "echo-cargo-home", &["CARGO_HOME"]);
5075 let policy = CapabilityPolicy::default();
5076
5077 std::env::set_var("CARGO_HOME", "/fake/cargo/home");
5078 let result = dispatch_native_tool(
5079 "echo-cargo-home",
5080 murmur::tool::run::ToolInput {
5081 data: None,
5082 log_path: None,
5083 },
5084 &binary,
5085 tmp.path(),
5086 &policy,
5087 &sandbox::ShellEnforcement::environment_only(),
5088 )
5089 .unwrap();
5090 std::env::remove_var("CARGO_HOME");
5091
5092 let data = result.data.unwrap_or_default();
5093 assert!(
5094 data.contains("CARGO_HOME=/fake/cargo/home"),
5095 "safe baseline var CARGO_HOME must pass through to native tool subprocess, got: {data}"
5096 );
5097 }
5098
5099 #[test]
5100 fn dispatch_native_tool_composes_policy_strip_env() {
5101 let tmp = TempDir::new().unwrap();
5102 let binary =
5103 write_env_echo_native_tool(tmp.path(), "echo-mycompany", &["MYCOMPANY_SECRET"]);
5104 let policy = CapabilityPolicy {
5105 shell_strip_env: vec!["MYCOMPANY_*".to_string()],
5106 ..CapabilityPolicy::default()
5107 };
5108
5109 std::env::set_var("MYCOMPANY_SECRET", "leaked-secret");
5110 let result = dispatch_native_tool(
5111 "echo-mycompany",
5112 murmur::tool::run::ToolInput {
5113 data: None,
5114 log_path: None,
5115 },
5116 &binary,
5117 tmp.path(),
5118 &policy,
5119 &sandbox::ShellEnforcement::environment_only(),
5120 )
5121 .unwrap();
5122 std::env::remove_var("MYCOMPANY_SECRET");
5123
5124 let data = result.data.unwrap_or_default();
5125 assert!(
5126 !data.contains("leaked-secret"),
5127 "policy.shell_strip_env pattern must compose for native tool subprocess, got: {data}"
5128 );
5129 }
5130
5131 fn narrowing_ceiling() -> Vec<NetworkAllowRule> {
5136 parse_network_allow_rules(&[
5137 "http://127.0.0.1:1".to_string(),
5138 "http://127.0.0.1:2".to_string(),
5139 ])
5140 .unwrap()
5141 }
5142
5143 fn grant_of(network: Option<Vec<&str>>, scope: Option<&str>) -> ToolCapabilityGrant {
5144 let caps = murmur_artifact::Capabilities {
5145 network: network.map(|allow| murmur_artifact::NetworkCapabilities {
5146 allow: allow.into_iter().map(str::to_string).collect(),
5147 unix_sockets: false,
5148 }),
5149 filesystem: scope.map(|scope| murmur_artifact::FilesystemCapabilities {
5150 scope: Some(scope.to_string()),
5151 workdir_exec: false,
5152 }),
5153 shell: None,
5154 spawn: None,
5155 env: None,
5156 limits: None,
5157 resources: None,
5158 containment: None,
5159 };
5160 ToolCapabilityGrant::derive(Some(&caps), &narrowing_ceiling()).expect("grant is valid")
5161 }
5162
5163 fn send_through_tool_hooks(rules: &[NetworkAllowRule], uri: &str, use_tls: bool) -> bool {
5166 use http_body_util::{BodyExt, Empty};
5167
5168 let mut hooks = NetworkPolicyHooks {
5169 network_allow_rules: rules.to_vec(),
5170 };
5171 let body = Empty::<bytes::Bytes>::new()
5172 .map_err(|err| match err {})
5173 .boxed_unsync();
5174 let request = hyper::Request::builder()
5175 .uri(uri)
5176 .body(body)
5177 .expect("request builds");
5178 let config = wasmtime_wasi_http::p2::types::OutgoingRequestConfig {
5179 use_tls,
5180 connect_timeout: std::time::Duration::from_millis(1),
5181 first_byte_timeout: std::time::Duration::from_millis(1),
5182 between_bytes_timeout: std::time::Duration::from_millis(1),
5183 };
5184
5185 let rt = tokio::runtime::Runtime::new().unwrap();
5186 rt.block_on(async { hooks.send_request(request, config).is_ok() })
5187 }
5188
5189 #[test]
5192 fn ungranted_tool_keeps_the_whole_ceiling() {
5193 let ceiling = narrowing_ceiling();
5194 let rules = effective_tool_network_rules(None, &ceiling);
5195
5196 assert!(send_through_tool_hooks(
5197 rules,
5198 "http://127.0.0.1:1/x",
5199 false
5200 ));
5201 assert!(send_through_tool_hooks(
5202 rules,
5203 "http://127.0.0.1:2/x",
5204 false
5205 ));
5206 }
5207
5208 #[test]
5211 fn narrowed_tool_reaches_only_its_declared_host() {
5212 let ceiling = narrowing_ceiling();
5213 let grant = grant_of(Some(vec!["http://127.0.0.1:1"]), None);
5214 let narrowed = effective_tool_network_rules(Some(&grant), &ceiling);
5215
5216 assert!(
5217 send_through_tool_hooks(narrowed, "http://127.0.0.1:1/x", false),
5218 "the declared host stays reachable"
5219 );
5220 assert!(
5221 !send_through_tool_hooks(narrowed, "http://127.0.0.1:2/x", false),
5222 "the rest of the ceiling is dropped for this artifact"
5223 );
5224 let sibling = effective_tool_network_rules(None, &ceiling);
5226 assert!(send_through_tool_hooks(
5227 sibling,
5228 "http://127.0.0.1:2/x",
5229 false
5230 ));
5231 }
5232
5233 #[test]
5236 fn out_of_ceiling_entry_is_dropped_and_reported() {
5237 let ceiling = narrowing_ceiling();
5238 let grant = grant_of(
5239 Some(vec!["http://127.0.0.1:1", "https://evil.example.com"]),
5240 None,
5241 );
5242 let narrowed = effective_tool_network_rules(Some(&grant), &ceiling);
5243
5244 assert!(!send_through_tool_hooks(
5245 narrowed,
5246 "https://evil.example.com/x",
5247 true
5248 ));
5249 assert_eq!(
5250 grant.dropped_network_entries,
5251 vec!["https://evil.example.com".to_string()]
5252 );
5253 }
5254
5255 #[test]
5258 fn tool_without_filesystem_scope_preopens_the_workdir_itself() {
5259 let root = TempDir::new().unwrap();
5260 let missing = root.path().join("does-not-exist");
5261
5262 assert!(
5263 build_wasi_ctx(&missing, None, &[], &CapabilityPolicy::default()).is_err(),
5264 "an unscoped tool preopens the workdir, which must exist"
5265 );
5266 build_wasi_ctx(root.path(), None, &[], &CapabilityPolicy::default())
5267 .expect("an existing workdir preopens as before");
5268 assert!(
5269 !missing.exists(),
5270 "the unscoped path must not create anything"
5271 );
5272 }
5273
5274 #[test]
5277 fn tool_with_filesystem_scope_preopens_only_the_scoped_subtree() {
5278 let root = TempDir::new().unwrap();
5279 std::fs::write(root.path().join("secret.txt"), b"capsule state").unwrap();
5280
5281 build_wasi_ctx(
5282 root.path(),
5283 Some("cache"),
5284 &[],
5285 &CapabilityPolicy::default(),
5286 )
5287 .expect("a granted scope is created and preopened");
5288
5289 let scoped = root.path().join("cache");
5290 assert!(scoped.is_dir(), "the granted scope is created if missing");
5291 std::fs::write(scoped.join("entry.json"), b"{}").unwrap();
5292 assert!(scoped.join("entry.json").exists());
5293 assert!(
5294 root.path().join("secret.txt").exists(),
5295 "nothing outside the scope was touched"
5296 );
5297 }
5298
5299 #[test]
5302 fn unusable_filesystem_scope_is_a_hard_error() {
5303 let root = TempDir::new().unwrap();
5304 std::fs::write(root.path().join("cache"), b"not a directory").unwrap();
5306
5307 let policy = CapabilityPolicy::default();
5308 let err = match build_wasi_ctx(root.path(), Some("cache"), &[], &policy) {
5309 Ok(_) => panic!("an uncreatable scope must fail loudly"),
5310 Err(err) => err,
5311 };
5312 assert!(
5313 err.to_string().contains("cache"),
5314 "the error must name the scope, got: {err}"
5315 );
5316 }
5317
5318 #[test]
5322 fn real_dispatch_applies_the_filesystem_scope() {
5323 let engine = build_engine().unwrap();
5324 let workdir = TempDir::new().unwrap();
5325 let component = crate::inference_import::test_support::driver_double(
5326 &engine,
5327 0,
5328 r#"{"stop_reason":"end_turn","content":[{"type":"text","text":"ok"}]}"#,
5329 );
5330 let ceiling = narrowing_ceiling();
5331 let grant = grant_of(None, Some("tool-cache"));
5332
5333 let rt = tokio::runtime::Runtime::new().unwrap();
5334 let result = rt.block_on(async {
5335 invoke_tool_component(
5336 ToolInvokeEnv {
5337 engine: &engine,
5338 accessible_workdir: workdir.path(),
5339 inference_env: &[],
5340 capability_policy: &CapabilityPolicy::default(),
5341 network_allow_rules: &ceiling,
5342 artifact_grant: Some(&grant),
5343 },
5344 ToolA2aWiring::silent(),
5345 "scoped-tool",
5346 &component,
5347 murmur::tool::run::ToolInput {
5348 data: Some("{}".to_string()),
5349 log_path: None,
5350 },
5351 )
5352 .await
5353 });
5354
5355 assert!(result.is_ok(), "the scoped tool still runs: {result:?}");
5356 assert!(
5357 workdir.path().join("tool-cache").is_dir(),
5358 "the real dispatch path preopened the granted scope"
5359 );
5360 }
5361
5362 #[test]
5365 fn real_dispatch_without_a_grant_is_unchanged() {
5366 let engine = build_engine().unwrap();
5367 let workdir = TempDir::new().unwrap();
5368 let component = crate::inference_import::test_support::driver_double(
5369 &engine,
5370 0,
5371 r#"{"stop_reason":"end_turn","content":[{"type":"text","text":"ok"}]}"#,
5372 );
5373 let ceiling = narrowing_ceiling();
5374
5375 let rt = tokio::runtime::Runtime::new().unwrap();
5376 let result = rt.block_on(async {
5377 invoke_tool_component(
5378 ToolInvokeEnv {
5379 engine: &engine,
5380 accessible_workdir: workdir.path(),
5381 inference_env: &[],
5382 capability_policy: &CapabilityPolicy::default(),
5383 network_allow_rules: &ceiling,
5384 artifact_grant: None,
5385 },
5386 ToolA2aWiring::silent(),
5387 "plain-tool",
5388 &component,
5389 murmur::tool::run::ToolInput {
5390 data: Some("{}".to_string()),
5391 log_path: None,
5392 },
5393 )
5394 .await
5395 });
5396
5397 assert!(
5398 result.is_ok(),
5399 "an ungranted tool runs as before: {result:?}"
5400 );
5401 assert_eq!(
5402 std::fs::read_dir(workdir.path()).unwrap().count(),
5403 0,
5404 "no per-artifact subtree is created when nothing was granted"
5405 );
5406 }
5407
5408 #[test]
5411 fn staging_records_grants_only_for_declaring_artifacts() {
5412 let ceiling = narrowing_ceiling();
5413 let mut grants = HashMap::new();
5414
5415 let declared = ArtifactRequest {
5416 name: "scoped-tool".to_string(),
5417 version: "1.0.0".to_string(),
5418 runtime: ArtifactRuntime::Tool,
5419 source: None,
5420 on_overflow: Default::default(),
5421 capabilities: Some(murmur_artifact::Capabilities {
5422 network: Some(murmur_artifact::NetworkCapabilities {
5423 allow: vec!["http://127.0.0.1:1".to_string()],
5424 unix_sockets: false,
5425 }),
5426 filesystem: None,
5427 shell: None,
5428 spawn: None,
5429 env: None,
5430 limits: None,
5431 resources: None,
5432 containment: None,
5433 }),
5434 };
5435 let silent = ArtifactRequest {
5436 name: "plain-tool".to_string(),
5437 version: "1.0.0".to_string(),
5438 runtime: ArtifactRuntime::Tool,
5439 source: None,
5440 on_overflow: Default::default(),
5441 capabilities: None,
5442 };
5443
5444 stage_artifact_grant(&declared, &ceiling, &mut grants).unwrap();
5445 stage_artifact_grant(&silent, &ceiling, &mut grants).unwrap();
5446
5447 assert!(grants.contains_key("scoped-tool"));
5448 assert!(
5449 !grants.contains_key("plain-tool"),
5450 "an artifact with no capabilities block must stay absent so dispatch falls back \
5451 to the ceiling"
5452 );
5453 }
5454
5455 #[test]
5458 fn staging_rejects_an_escaping_filesystem_scope() {
5459 let mut grants = HashMap::new();
5460 let artifact = ArtifactRequest {
5461 name: "escaping-tool".to_string(),
5462 version: "1.0.0".to_string(),
5463 runtime: ArtifactRuntime::Tool,
5464 source: None,
5465 on_overflow: Default::default(),
5466 capabilities: Some(murmur_artifact::Capabilities {
5467 network: None,
5468 filesystem: Some(murmur_artifact::FilesystemCapabilities {
5469 scope: Some("../escape".to_string()),
5470 workdir_exec: false,
5471 }),
5472 shell: None,
5473 spawn: None,
5474 env: None,
5475 limits: None,
5476 resources: None,
5477 containment: None,
5478 }),
5479 };
5480
5481 let err = stage_artifact_grant(&artifact, &narrowing_ceiling(), &mut grants)
5482 .expect_err("an escaping scope must fail staging");
5483 assert!(matches!(err, RuntimeError::InvalidFilesystemScope { .. }));
5484 assert!(grants.is_empty());
5485 }
5486
5487 #[test]
5490 fn inert_sub_blocks_are_exactly_the_unconsumed_ones() {
5491 let caps = murmur_artifact::Capabilities {
5492 network: Some(murmur_artifact::NetworkCapabilities {
5493 allow: Vec::new(),
5494 unix_sockets: false,
5495 }),
5496 filesystem: Some(murmur_artifact::FilesystemCapabilities {
5497 scope: None,
5498 workdir_exec: false,
5499 }),
5500 shell: Some(murmur_artifact::ShellCapabilities {
5501 allow: vec!["bash".to_string()],
5502 strip_env: None,
5503 baseline_env: None,
5504 interpreter_runtime: Vec::new(),
5505 staged_runtime: Vec::new(),
5506 }),
5507 spawn: None,
5508 env: Some(murmur_artifact::EnvCapabilities { allow: Vec::new() }),
5509 limits: None,
5510 resources: None,
5511 containment: Some(murmur_artifact::ContainmentClass::Sealed),
5512 };
5513
5514 assert_eq!(
5515 inert_capability_sub_blocks(Some(&caps)),
5516 vec!["shell", "env", "containment"]
5517 );
5518 assert!(inert_capability_sub_blocks(None).is_empty());
5519 }
5520
5521 fn write_fake_claude_cli(dir: &Path) -> PathBuf {
5527 let script = dir.join("claude");
5528 fs::write(
5529 &script,
5530 "#!/bin/sh\ncat > /dev/null\nprintf '%s\\n' '{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"done\"}]}}'\nprintf '%s\\n' '{\"type\":\"result\",\"subtype\":\"success\",\"result\":\"done\"}'\n",
5531 )
5532 .unwrap();
5533 #[cfg(unix)]
5534 {
5535 use std::os::unix::fs::PermissionsExt;
5536 let mut perms = fs::metadata(&script).unwrap().permissions();
5537 perms.set_mode(0o755);
5538 fs::set_permissions(&script, perms).unwrap();
5539 }
5540 script
5541 }
5542
5543 fn on_task_end_reopen_double(
5548 engine: &wasmtime::Engine,
5549 reopen_limit: u32,
5550 reason: &str,
5551 ) -> wasmtime::component::Component {
5552 let reason_len = reason.len();
5553 let stubs = [
5554 "on-session-start",
5555 "on-inference",
5556 "on-tool-call",
5557 "on-shell",
5558 "on-compaction",
5559 "on-session-end",
5560 ]
5561 .iter()
5562 .map(|n| format!(" (export \"{n}\" (func $noop))"))
5563 .collect::<Vec<_>>()
5564 .join("\n");
5565 let wat = format!(
5566 r#"(component
5567 (core module $m
5568 (memory (export "memory") 1)
5569 (global $count (mut i32) (i32.const 0))
5570 (data (i32.const 300) "{reason}")
5571 (func (export "realloc") (param i32 i32 i32 i32) (result i32) i32.const 512)
5572 (func (export "ontaskend") (param i32 i32 i32 i32) (result i32)
5573 (i32.store (i32.const 128) (i32.const 0))
5574 (if (result i32) (i32.lt_u (global.get $count) (i32.const {reopen_limit}))
5575 (then
5576 (global.set $count (i32.add (global.get $count) (i32.const 1)))
5577 (i32.store (i32.const 132) (i32.const 4))
5578 (i32.store (i32.const 136) (i32.const 300))
5579 (i32.store (i32.const 140) (i32.const {reason_len}))
5580 (i32.const 128))
5581 (else
5582 (i32.store (i32.const 132) (i32.const 0))
5583 (i32.const 128))))
5584 (func (export "noop"))
5585 )
5586 (core instance $i (instantiate $m))
5587 (alias core export $i "memory" (core memory $mem))
5588 (alias core export $i "realloc" (core func $realloc))
5589
5590 (type $message (record (field "role" string) (field "content" string)))
5591 (type $tool-manifest (record (field "binary-name" string) (field "content" string)))
5592 (type $hook-output (variant
5593 (case "none")
5594 (case "replace-context" (list $message))
5595 (case "write-manifests" (list $tool-manifest))
5596 (case "artifact" string)
5597 (case "reopen-task" string)))
5598 (type $task-end-event (record
5599 (field "task-id" string)
5600 (field "exit-status" string)))
5601 (type $ft (func (param "event" $task-end-event) (result (result $hook-output (error string)))))
5602
5603 (func $te (type $ft)
5604 (canon lift (core func $i "ontaskend") (memory $mem) (realloc $realloc) string-encoding=utf8))
5605 (func $noop (canon lift (core func $i "noop")))
5606
5607 (instance $lc
5608 (export "message" (type $message))
5609 (export "tool-manifest" (type $tool-manifest))
5610 (export "hook-output" (type $hook-output))
5611 (export "task-end-event" (type $task-end-event))
5612 (export "on-task-end" (func $te))
5613{stubs}
5614 )
5615 (export "murmur:hook/lifecycle@0.5.0" (instance $lc))
5616)"#
5617 );
5618 let bytes = wat::parse_str(&wat).expect("on-task-end reopen double WAT parses");
5619 wasmtime::component::Component::new(engine, &bytes)
5620 .expect("on-task-end reopen double compiles")
5621 }
5622
5623 async fn run_reopen_scenario(
5627 reopen_limit: u32,
5628 max_task_reopens: u32,
5629 max_turns: u32,
5630 ) -> (Result<(), RuntimeError>, Vec<serde_json::Value>) {
5631 let dir = tempfile::tempdir().unwrap();
5632 let workdir = dir.path().to_path_buf();
5633 fs::create_dir_all(workdir.join("tools")).unwrap();
5634 fs::write(workdir.join("task.md"), "Original task: build the thing.").unwrap();
5635 let cli = write_fake_claude_cli(dir.path());
5636
5637 let inference = InferenceConfig {
5638 transport: "process".into(),
5639 endpoint: None,
5640 model: "test-model".into(),
5641 api_key: None,
5642 driver: None,
5643 command: Some(cli.to_string_lossy().to_string()),
5644 compaction: None,
5645 system_prompt: None,
5646 system_prompt_file: None,
5647 system_prompt_artifact: None,
5648 max_turns,
5649 max_task_reopens,
5650 max_tokens: None,
5651 };
5652
5653 let mut state = build_test_state(
5654 Arc::new(FakeSkillRegistry::new(Vec::new())),
5655 workdir.clone(),
5656 workdir.join("murmur.lock"),
5657 );
5658
5659 let mut trace = TraceWriter::open(
5660 &workdir,
5661 "ses_test".to_string(),
5662 "cap".to_string(),
5663 "0.1.0".to_string(),
5664 "test-model".to_string(),
5665 Vec::new(),
5666 crate::containment::scope_report_for_tier(
5667 &CapabilityPolicy::default(),
5668 murmur_artifact::ContainmentClass::Advisory,
5669 sandbox::EnforcementTier::EnvironmentOnly,
5670 None,
5671 ),
5672 false,
5673 None,
5674 false,
5675 )
5676 .await
5677 .unwrap();
5678
5679 let mut otel = OtelEmitter::new(None, &workdir, "cap".to_string(), "0.1.0".to_string());
5680
5681 let staged_hook = StagedHookArtifact {
5682 name: "gatekeeper".to_string(),
5683 version: "0.0.1".to_string(),
5684 component: on_task_end_reopen_double(&state.engine, reopen_limit, "tests still fail"),
5685 config: murmur_artifact::HookConfig {
5686 binding: HookBinding::OnTaskEnd,
5687 execution_mode: murmur_artifact::HookExecutionMode::Blocking,
5688 commit_policy: murmur_artifact::HookCommitPolicy::ReopenTask,
5689 },
5690 grant: HookCapabilityGrant::default(),
5691 on_overflow: Default::default(),
5692 };
5693
5694 let mut hooks = HookRuntime::new(
5695 &state.engine,
5696 &workdir,
5697 &workdir,
5698 vec![staged_hook],
5699 SessionContextData {
5700 capsule_name: "cap".to_string(),
5701 capsule_version: "0.1.0".to_string(),
5702 session_id: "ses_test".to_string(),
5703 model: "test-model".to_string(),
5704 capabilities: Vec::new(),
5705 },
5706 HookEnvVars::default(),
5707 crate::limits::ExecutionLimits::default(),
5708 None,
5709 )
5710 .await
5711 .unwrap();
5712
5713 let run_config = agent::AgentRunConfig {
5714 context_window: 0,
5715 compaction_threshold: 0.98,
5716 compaction_model: None,
5717 compaction_system_prompt: None,
5718 compaction_dump_summaries: false,
5719 max_output_tokens: 1024,
5720 };
5721
5722 trace
5724 .write_task_start("tsk_1", "ctx_1", "task_md", 8)
5725 .await
5726 .unwrap();
5727
5728 let result = run_task_with_reopens(
5729 &mut state,
5730 &workdir,
5731 &inference,
5732 None,
5733 run_config,
5734 &mut hooks,
5735 &mut trace,
5736 &mut otel,
5737 None,
5738 None,
5739 &workdir,
5740 "cap",
5741 "0.1.0",
5742 ConversationMode::Stateless,
5743 Some("ctx_1".to_string()),
5744 "tsk_1",
5745 )
5746 .await;
5747
5748 trace.flush().await.unwrap();
5749 let content = fs::read_to_string(workdir.join("trace.jsonl")).unwrap();
5750 let events: Vec<serde_json::Value> = content
5751 .lines()
5752 .filter(|l| !l.is_empty())
5753 .map(|l| serde_json::from_str(l).unwrap())
5754 .collect();
5755 (result, events)
5756 }
5757
5758 fn count_type(events: &[serde_json::Value], ty: &str) -> usize {
5759 events.iter().filter(|e| e["event_type"] == ty).count()
5760 }
5761
5762 #[tokio::test(flavor = "multi_thread")]
5766 async fn reopen_once_then_satisfied_end_to_end() {
5767 let (result, events) = run_reopen_scenario(1, 5, 10).await;
5768 assert!(
5769 result.is_ok(),
5770 "a satisfied hook ends the task Ok: {result:?}"
5771 );
5772 assert_eq!(
5773 count_type(&events, "inference"),
5774 2,
5775 "agent loop ran exactly twice"
5776 );
5777 assert_eq!(count_type(&events, "task_reopened"), 1);
5778 let re = events
5779 .iter()
5780 .find(|e| e["event_type"] == "task_reopened")
5781 .unwrap();
5782 assert_eq!(re["hook_name"], "gatekeeper");
5783 assert_eq!(re["reason"], "tests still fail");
5784 assert_eq!(re["reopen_number"], 1);
5785 let end = events
5786 .iter()
5787 .find(|e| e["event_type"] == "task_end")
5788 .unwrap();
5789 assert_eq!(end["reopen_count"], 1);
5790 assert_eq!(end["exit_status"], "ok");
5791 }
5792
5793 #[tokio::test(flavor = "multi_thread")]
5797 async fn reopen_budget_exhausted_end_to_end() {
5798 let (result, events) = run_reopen_scenario(99, 1, 10).await;
5799 assert!(
5800 result.is_err(),
5801 "an exhausted reopen budget is a task failure"
5802 );
5803 assert_eq!(count_type(&events, "inference"), 2, "1 original + 1 reopen");
5804 assert_eq!(count_type(&events, "task_reopened"), 1);
5805 let end = events
5806 .iter()
5807 .find(|e| e["event_type"] == "task_end")
5808 .unwrap();
5809 assert_eq!(end["reopen_count"], 1);
5810 assert_eq!(end["exit_status"], "reopen_budget_exhausted");
5811 }
5812
5813 #[tokio::test(flavor = "multi_thread")]
5818 async fn reopen_never_exceeds_max_turns_end_to_end() {
5819 let (result, events) = run_reopen_scenario(99, 5, 3).await;
5820 assert!(result.is_err());
5821 assert_eq!(
5822 count_type(&events, "inference"),
5823 3,
5824 "cumulative turns must never exceed max_turns"
5825 );
5826 assert_eq!(
5827 count_type(&events, "task_reopened"),
5828 2,
5829 "3 attempts ⇒ 2 reopens"
5830 );
5831 let end = events
5832 .iter()
5833 .find(|e| e["event_type"] == "task_end")
5834 .unwrap();
5835 assert_eq!(end["reopen_count"], 2);
5836 assert_eq!(end["exit_status"], "reopen_budget_exhausted");
5837 }
5838}