1use std::{
2 collections::{HashMap, HashSet},
3 fs,
4 path::{Path, PathBuf},
5 sync::{
6 atomic::{AtomicBool, AtomicU64, Ordering},
7 Arc, Mutex,
8 },
9 time::Instant,
10};
11
12use murmur_artifact::{
13 current_platform, is_credential_shaped_env_name, native_binary_verdict,
14 parse_hook_config_from_yaml, parse_tool_implementation_from_yaml, read_lockfile,
15 security_warning_link, verify_sha256, write_lockfile_atomic, AfterTask, ApiKeyReference,
16 ArtifactImplementation, ArtifactRuntime, ContextConfig, ConversationMode, HookBinding,
17 InferenceConfig, InterpreterRuntimeGrant, LifecycleConfig, LockedSha256, LockfileError,
18 MurmurLock, NativeBinaryVerdict, Registry, RegistryError, RuntimeType, TaskAcceptance,
19 LOCK_VERSION, MANIFEST_FILENAME, PACKED_MANIFEST_ENTRY, W_SEC_003, W_SEC_006, W_SEC_007,
20 W_SEC_008, W_SEC_009, W_SEC_011, W_SEC_013, W_SEC_014, W_SEC_015, W_SEC_016, W_SEC_017,
21 W_SEC_018, W_SEC_020, W_SEC_022, W_SEC_023, W_SEC_024, W_SEC_025, W_SEC_026, W_SEC_027,
22};
23use serde_yaml::Value;
24use wasmtime::{
25 component::{Component, HasSelf, Linker, ResourceTable},
26 Config, Engine, Store,
27};
28use wasmtime_wasi::{DirPerms, FilePerms, WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
29use wasmtime_wasi_http::{
30 p2::{
31 bindings::http::types::ErrorCode as WasiHttpErrorCode,
32 body::HyperOutgoingBody,
33 types::{HostFutureIncomingResponse, OutgoingRequestConfig},
34 HttpResult, WasiHttpCtxView, WasiHttpHooks, WasiHttpView,
35 },
36 WasiHttpCtx,
37};
38
39use crate::{
40 a2a::{IncomingTask, TaskRegistry, TaskState},
41 agent::{self, AgentLoopExit},
42 artifact::{extract_manifest_yaml, extract_native_binary, extract_root_wasm, extract_skill_md},
43 artifact_config::ARTIFACT_CONFIG_ENV,
44 bindings::host::murmur::{
45 self, artifact_manager::manage, message::send, tool_registry::invoke,
46 },
47 cgroup,
48 containment::{achieved_containment_class, check_containment_floor},
49 delegation::SpawnerHandle,
50 detached::{
51 self, demotion_tool_result, AbandonedDisposition, AbandonedWork, DetachPolicy,
52 DetachedRegistry, DetachedReport,
53 },
54 errors::RuntimeError,
55 hooks::{
56 dispatch_stage, HookEnvVars, HookEvent, HookRuntime, HookSeed, ResolvedCall,
57 SessionContextData, ShellDispatchInfo, TaskReopen,
58 },
59 identity::{self, CapsuleIdentity},
60 inference_credential::{config_holds_credential, InferenceCredential},
61 inference_gateway::InferenceGateway,
62 inference_import::HookInferenceCtx,
63 lanes::LaneQueue,
64 limits::{classify_guest_failure, EpochTicker, ExecutionLimiter, GuestFailure},
65 murmur_md,
66 network_policy::{
67 effective_tool_network_rules, parse_network_allow_rules, resolve_scoped_dir,
68 validate_filesystem_scope, HookCapabilityGrant, NetworkAllowRule, RequestTarget,
69 ToolCapabilityGrant,
70 },
71 origin::{stamp_for_peer, TaskOrigin, TaskProvenance, TrustClass},
72 otel::OtelEmitter,
73 outgoing,
74 protected_paths::{ProtectedPathRefusal, ProtectedPaths},
75 registration::SessionOutcome,
76 resources, running, sandbox,
77 sealed::UsernsGrant,
78 shell::{
79 build_shell_env, build_wasi_env_allowlist, is_shell_interpreter, run_shell,
80 shell_tool_manifest_yaml, split_shell_words, ShellOutcome, ShellResult,
81 },
82 spawn_credential::SpawnCredential,
83 spend::{MachineLedger, SpendMeter},
84 state_store::STATE_PREOPEN_NAME,
85 streaming::{
86 emit_chunk_sse, emit_sse, emit_thinking_chunk_sse, SseBroadcast, SseEventBuffer,
87 StreamStatus, TaskStatusUpdateEvent,
88 },
89 tool_annotations::ToolAnnotationMap,
90 trace::TraceWriter,
91 types::{
92 ArtifactRequest, CapabilityPolicy, DispatchOutcome, InstalledArtifactSummary, LaunchResult,
93 ResolvedLockArtifact, ResumeMode, ResumeRequest, StageRequest, StagedHookArtifact,
94 StagedSession,
95 },
96};
97
98const WIT_CAPSULE_IFACE_VERSIONED: &str = "murmur:capsule/run@0.1.0";
103const WIT_TOOL_IFACE_VERSIONED: &str = "murmur:tool/run@0.1.0";
106
107const WIT_TOOL_REGISTRY_IFACE: &str = "murmur:tool-registry/invoke@0.1.0";
112const WIT_TEXT_CHUNKS_IFACE: &str = "murmur:text/chunks@0.1.0";
113const WIT_TASK_IFACE: &str = "murmur:task/task@0.1.0";
114
115const TERMINATE_TEARDOWN_DEADLINE: std::time::Duration = std::time::Duration::from_secs(20);
122
123fn resolve_versioned_iface<T>(
128 instance: &wasmtime::component::Instance,
129 store: &mut Store<T>,
130 versioned: &str,
131) -> Option<wasmtime::component::ComponentExportIndex> {
132 instance.get_export_index(&mut *store, None, versioned)
133}
134
135#[allow(clippy::too_many_arguments)]
163async fn run_task_with_reopens(
164 state: &mut CapsuleStoreState,
165 workdir: &Path,
166 inference: &InferenceConfig,
167 max_task_reopens: u32,
168 system_prompt: Option<String>,
169 run_config: agent::AgentRunConfig,
170 hooks: &mut HookRuntime,
171 trace: &mut TraceWriter,
172 otel: &mut OtelEmitter,
173 agent_task_id: Option<String>,
174 sse: Option<(SseBroadcast, Arc<Mutex<SseEventBuffer>>)>,
175 accessible_workdir: &Path,
176 capsule_name: &str,
177 capsule_version: &str,
178 mode: ConversationMode,
179 context_id: Option<String>,
180 trace_task_id: &str,
181 seed: Option<HookSeed>,
182 cancel: Option<crate::cancel::CancelSignal>,
184) -> Result<AgentLoopExit, RuntimeError> {
185 let task_md_path = accessible_workdir.join("task.md");
186 let original_task = tokio::fs::read_to_string(&task_md_path)
189 .await
190 .unwrap_or_default();
191 let mut feedback: Vec<(String, String)> = Vec::new();
193 let mut reopens_used: u32 = 0;
194 let mut as_given = original_task.clone();
198
199 loop {
200 hooks.begin_task_attempt(original_task.clone(), as_given.clone());
204
205 let remaining_turns = inference.max_turns.saturating_sub(trace.task_turns());
210 let mut attempt_inference = inference.clone();
211 attempt_inference.max_turns = remaining_turns;
212
213 let result = agent::run_agent_loop(
214 state,
215 workdir,
216 &attempt_inference,
217 system_prompt.clone(),
218 run_config.clone(),
219 hooks,
220 trace,
221 otel,
222 agent_task_id.clone(),
223 sse.clone(),
224 accessible_workdir,
225 capsule_name,
226 capsule_version,
227 mode.clone(),
228 context_id.clone(),
229 seed.clone(),
233 cancel.clone(),
234 )
235 .await;
236
237 #[cfg(debug_assertions)]
240 {
241 if let Some(ms) = std::env::var("MURMUR_DEBUG_TASK_END_DELAY_MS")
242 .ok()
243 .and_then(|value| value.parse::<u64>().ok())
244 {
245 tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
246 }
247 }
248
249 let exit_str = match &result {
253 Ok(exit) => exit.as_str(),
254 Err(_) => "failed",
255 };
256
257 let reopen = hooks
259 .dispatch_task_end(trace_task_id.to_string(), exit_str.to_string())
260 .await;
261
262 let canceled = matches!(result, Ok(AgentLoopExit::Canceled));
266 match reopen.filter(|_| !canceled) {
267 Some(TaskReopen { hook_name, reason }) => {
268 let budget_ok = reopens_used < max_task_reopens;
272 let turns_ok = trace.task_turns() < inference.max_turns;
273 if budget_ok && turns_ok {
274 reopens_used += 1;
275 feedback.push((hook_name.clone(), reason.clone()));
276 let _ = trace
277 .write_task_reopened(trace_task_id, &hook_name, &reason, reopens_used)
278 .await;
279 let rewritten = build_reopen_task_md(&original_task, &feedback);
283 if let Err(e) = tokio::fs::write(&task_md_path, rewritten.as_bytes()).await {
284 eprintln!(
285 "[capsule-runtime] failed to inject reopen feedback into task.md: {e}"
286 );
287 }
288 as_given = rewritten;
289 continue;
290 }
291 let _ = trace
295 .write_task_end(trace_task_id, "reopen_budget_exhausted", reopens_used)
296 .await;
297 hooks.end_task();
298 return Err(RuntimeError::AgentLoopFailed(format!(
299 "task reopen budget exhausted after {reopens_used} reopen(s): hook \
300 '{hook_name}' still requested another reopen"
301 )));
302 }
303 None => {
304 let _ = trace
306 .write_task_end(trace_task_id, exit_str, reopens_used)
307 .await;
308 hooks.end_task();
309 return result;
310 }
311 }
312 }
313}
314
315fn build_reopen_task_md(original: &str, feedback: &[(String, String)]) -> String {
319 let mut out = original.trim_end().to_string();
320 out.push_str(
321 "\n\n---\n\n# Reopen feedback\n\nThe previous attempt was not accepted. Address the \
322 following feedback, then continue.\n",
323 );
324 for (i, (hook_name, reason)) in feedback.iter().enumerate() {
325 out.push_str(&format!(
326 "\n## Reopen {} — from hook `{}`\n\n{}\n",
327 i + 1,
328 hook_name,
329 reason.trim()
330 ));
331 }
332 out
333}
334
335const SSE_BROADCAST_CAPACITY: usize = 128;
338
339const SSE_REPLAY_CAPACITY: usize = 512;
343
344fn has_compaction_hook(hooks: &[StagedHookArtifact]) -> bool {
348 hooks.iter().any(|h| {
349 matches!(
350 h.config.binding,
351 HookBinding::OnCompaction | HookBinding::All
352 )
353 })
354}
355
356fn check_resume_launchable(
364 resume: &ResumeRequest,
365 context_id: Option<&str>,
366 context: Option<&ContextConfig>,
367 capsule_name: &str,
368 inference: Option<&InferenceConfig>,
369 hooks: &[StagedHookArtifact],
370) -> Result<(), RuntimeError> {
371 if resume.mode == ResumeMode::Compact && !has_compaction_hook(hooks) {
372 return Err(RuntimeError::ResumeCompactionHookMissing);
373 }
374 let context_id = context_id.unwrap_or("<unresolved>").to_string();
377 let missing = |reason: String| RuntimeError::ResumeRecordMissing {
378 session: resume.from_session.clone(),
379 context_id: context_id.clone(),
380 reason,
381 };
382 let Some(inference) = inference else {
386 return Err(missing(
387 "the capsule declares no inference block and keeps no conversation record".to_string(),
388 ));
389 };
390 if inference.transport == "process" {
391 return Err(missing(
392 "the capsule declares inference.transport: process, whose CLI owns its own \
393 conversation, and kept no conversation record"
394 .to_string(),
395 ));
396 }
397 let Some(record) = crate::conversation::resolve_record_name(context, capsule_name) else {
398 return Err(missing(
399 "the capsule declares context.record: off and kept no conversation record".to_string(),
400 ));
401 };
402 let root = crate::conversation::record_root(&record).map_err(&missing)?;
403 let path = crate::conversation::record_file(&root, &context_id);
404 if !path.is_file() {
405 return Err(missing(format!(
406 "no conversation record at {}",
407 path.display()
408 )));
409 }
410 Ok(())
411}
412
413pub fn stage_session(
418 registry: Arc<dyn Registry>,
419 request: StageRequest,
420) -> Result<StagedSession, RuntimeError> {
421 validate_capability_policy(&request.capability_policy)?;
422 let workdir_exec = request.capability_policy.workdir_exec_allowed;
431 let host_probe = sandbox::HostProbe::probe();
437 let enforcement_tier = host_probe.tier();
438 let sealed_blocker = host_probe.sealed_blocker();
441 let achieved_containment = achieved_containment_class(enforcement_tier, workdir_exec);
442 check_containment_floor(
443 request.declared_containment_floor,
444 achieved_containment,
445 sealed_blocker,
446 workdir_exec,
447 )?;
448 let exports_files = request
453 .exports
454 .as_ref()
455 .and_then(|exports| exports.files.clone());
456 let exports_peer_files = request
457 .exports
458 .as_ref()
459 .and_then(|exports| exports.peer_files.clone());
460 let state_stores = crate::state_store::state_store_reports(
465 request
466 .artifacts
467 .iter()
468 .map(|artifact| (artifact.name.as_str(), artifact.capabilities.as_ref())),
469 &request.capsule_name,
470 )?;
471 warn_on_inert_capsule_wide_state(request.capability_policy.state_declared);
472 warn_on_inert_capsule_wide_conversation(request.capability_policy.conversation_declared);
473 if let Some(record) = request
477 .context
478 .as_ref()
479 .filter(|context| context.record)
480 .and_then(|context| context.record_store.as_deref())
481 {
482 crate::conversation::validate_record_segment("context.record_store", record)?;
483 }
484 if let Some(context_id) = request.context_id.as_deref() {
485 crate::conversation::validate_record_segment("--context", context_id)?;
486 }
487 check_no_reserved_tool_names(
492 request
493 .artifacts
494 .iter()
495 .map(|artifact| artifact.name.as_str()),
496 )?;
497 check_env_allow_reaches_guests(&request.capability_policy)?;
500 let configured_artifacts = crate::artifact_config::configured_artifact_names(
504 request
505 .artifacts
506 .iter()
507 .map(|artifact| (artifact.name.as_str(), artifact.config.as_ref())),
508 )?;
509 let preopens =
514 crate::network_policy::preopen_reports(request.artifacts.iter().map(|artifact| {
515 (
516 artifact.name.as_str(),
517 &artifact.runtime,
518 artifact.capabilities.as_ref(),
519 )
520 }))?;
521 let scope_report = crate::containment::scope_report_for_tier(
522 &request.capability_policy,
523 request.declared_containment_floor,
524 enforcement_tier,
525 sealed_blocker,
526 host_probe.userns_grant(),
527 request.exports.as_ref(),
528 state_stores,
529 configured_artifacts,
530 preopens,
531 crate::cgroup::IoMaxReport::default(),
536 );
537 check_persistent_handle_ttl(
542 exports_peer_files.as_ref(),
543 &resolve_lifecycle(
544 request.lifecycle.clone(),
545 request.lifecycle_override.as_ref(),
546 ),
547 )?;
548 crate::staged_runtime::check_staged_runtime_floor(
554 &request.capability_policy.shell_staged_runtime,
555 request.declared_containment_floor,
556 )?;
557 crate::reachability::check_interpreted_entrypoints_reachable(
564 &request.capability_policy,
565 request.declared_containment_floor,
566 )?;
567 crate::network_namespace::check_egress_namespace(
583 !request.capability_policy.shell_allow.is_empty()
584 || !request.capability_policy.spawn_allow.is_empty(),
585 crate::network_namespace::detect_egress_namespace_blocker(),
586 )?;
587 let protected_paths =
593 ProtectedPaths::from_declared(&request.capability_policy.read_only_paths)?;
594 warn_on_interpreter_runtime_grants(&request.capability_policy.shell_interpreter_runtime);
598 warn_on_workdir_exec(workdir_exec);
602 warn_on_advisory_read_only(
606 &request.capability_policy.read_only_paths,
607 &request.capability_policy.shell_allow,
608 );
609 warn_on_userns_restriction_disabled_host_wide(scope_report.userns_grant);
615 crate::reachability::warn_on_unreachable_toolchain_helpers(
624 &request.capability_policy,
625 request.declared_containment_floor,
626 );
627
628 let engine = build_engine()?;
629 let epoch_ticker = EpochTicker::spawn(&engine);
632 let lock_expectations = request.lock_expectations.map(|entries| {
633 entries
634 .into_iter()
635 .map(|entry| (entry.name.clone(), entry))
636 .collect::<HashMap<_, _>>()
637 });
638
639 let mut resolved_lock_artifacts = Vec::with_capacity(request.artifacts.len());
640 let mut installed_artifacts = Vec::with_capacity(request.artifacts.len());
641 let mut installed_manifests = Vec::with_capacity(request.artifacts.len());
642 let mut tool_components = HashMap::with_capacity(request.artifacts.len());
643 let mut artifact_grants: HashMap<String, ToolCapabilityGrant> = HashMap::new();
646 let mut hook_components = Vec::new();
647 let ceiling_network_allow_rules =
651 parse_network_allow_rules(&request.capability_policy.network_allow)?;
652 let capsule_name = request.capsule_name.clone();
656 let mut native_binaries: Vec<(String, Vec<u8>)> = Vec::new();
658 let mut skill_files: Vec<(String, Vec<u8>)> = Vec::new();
660
661 for artifact in &request.artifacts {
662 if let Some(source) = &artifact.source {
665 let skill_md = load_local_skill_md(&request.manifest_dir, source)?;
666 skill_files.push((artifact.name.clone(), skill_md));
667 installed_artifacts.push(InstalledArtifactSummary {
668 name: artifact.name.clone(),
669 version: artifact.version.clone(),
670 runtime: artifact.runtime.clone(),
671 implementation: None,
672 });
673 continue;
674 }
675
676 let (resolved_version, expected_hash) = match &lock_expectations {
677 Some(expected_by_name) => {
678 let expected = expected_by_name.get(&artifact.name).ok_or_else(|| {
679 RuntimeError::LockMissingEntry {
680 name: artifact.name.clone(),
681 }
682 })?;
683
684 if artifact.version != expected.resolved_version {
685 return Err(RuntimeError::LockVersionMismatch {
686 name: artifact.name.clone(),
687 requested: artifact.version.clone(),
688 pinned: expected.resolved_version.clone(),
689 });
690 }
691
692 (
693 expected.resolved_version.clone(),
694 Some(expected.sha256.clone()),
695 )
696 }
697 None => (artifact.version.clone(), None),
698 };
699
700 let resolved = registry
707 .resolve_with_platform(&artifact.name, &resolved_version, Some(current_platform()))
708 .map_err(|err| map_registry_error(&artifact.name, &resolved_version, err))?;
709
710 verify_sha256(
711 &artifact.name,
712 &resolved_version,
713 &resolved.bytes,
714 &resolved.sha256,
715 )
716 .map_err(|_| RuntimeError::artifact_integrity_failed(&artifact.name, &resolved_version))?;
717
718 let resolved_platform =
723 (resolved.meta.runtime == RuntimeType::Native).then(|| current_platform().to_string());
724
725 if let Some(expected) = expected_hash {
726 if resolved.sha256 != expected {
727 return Err(RuntimeError::artifact_integrity_failed(
728 &artifact.name,
729 &resolved_version,
730 ));
731 }
732 }
733
734 let manifest_yaml =
735 extract_manifest_yaml(&artifact.name, &resolved_version, &resolved.bytes)?;
736
737 installed_manifests.push((artifact.name.clone(), manifest_yaml.clone()));
738
739 let mut artifact_implementation: Option<ArtifactImplementation> = None;
740
741 match artifact.runtime {
742 ArtifactRuntime::Tool => {
743 let implementation = parse_tool_implementation_from_yaml(&manifest_yaml);
744 artifact_implementation = Some(implementation.clone());
745 match implementation {
746 ArtifactImplementation::Native => {
747 warn_on_unenforceable_native_capabilities(
752 &artifact.name,
753 artifact.capabilities.as_ref(),
754 );
755 warn_on_inert_native_config(&artifact.name, artifact.config.as_ref());
758 let binary = extract_native_binary(
759 &artifact.name,
760 &resolved_version,
761 &resolved.bytes,
762 )?;
763 native_binaries.push((artifact.name.clone(), binary));
764 }
765 ArtifactImplementation::Wasm => {
766 stage_artifact_grant(
767 artifact,
768 &ceiling_network_allow_rules,
769 &capsule_name,
770 &mut artifact_grants,
771 )?;
772 let tool_wasm =
773 extract_root_wasm(&artifact.name, &resolved_version, &resolved.bytes)?;
774 let tool_component = Component::new(&engine, tool_wasm).map_err(|err| {
775 RuntimeError::ToolComponentCompile {
776 name: artifact.name.clone(),
777 version: resolved_version.clone(),
778 message: err.to_string(),
779 }
780 })?;
781 tool_components.insert(artifact.name.clone(), tool_component);
782 }
783 }
784 }
785 ArtifactRuntime::Driver => {
786 stage_artifact_grant(
791 artifact,
792 &ceiling_network_allow_rules,
793 &capsule_name,
794 &mut artifact_grants,
795 )?;
796 let tool_wasm =
797 extract_root_wasm(&artifact.name, &resolved_version, &resolved.bytes)?;
798 let tool_component = Component::new(&engine, tool_wasm).map_err(|err| {
799 RuntimeError::ToolComponentCompile {
800 name: artifact.name.clone(),
801 version: resolved_version.clone(),
802 message: err.to_string(),
803 }
804 })?;
805 tool_components.insert(artifact.name.clone(), tool_component);
806 }
807 ArtifactRuntime::Hook => {
808 let hook_config = parse_hook_config_from_yaml(&manifest_yaml).map_err(|e| {
809 RuntimeError::Runtime(format!(
810 "hook {}@{} invalid config: {e}",
811 artifact.name, resolved_version
812 ))
813 })?;
814 let hook_wasm =
815 extract_root_wasm(&artifact.name, &resolved_version, &resolved.bytes)?;
816 let hook_component = Component::new(&engine, hook_wasm).map_err(|err| {
817 RuntimeError::ToolComponentCompile {
818 name: artifact.name.clone(),
819 version: resolved_version.clone(),
820 message: err.to_string(),
821 }
822 })?;
823 let mut grant =
830 HookCapabilityGrant::derive(artifact.capabilities.as_ref(), &capsule_name)?;
831 if let Some(store) = grant.state_store.as_deref() {
834 grant.state_dir = Some(crate::state_store::ensure_state_store(store)?);
835 }
836 grant.config_json = artifact
840 .config
841 .as_ref()
842 .map(|config| {
843 crate::artifact_config::lower_artifact_config(&artifact.name, config)
844 })
845 .transpose()?;
846 warn_on_inert_hook_capabilities(&artifact.name, artifact.capabilities.as_ref());
847 hook_components.push(StagedHookArtifact {
848 name: artifact.name.clone(),
849 version: resolved_version.clone(),
850 component: hook_component,
851 config: hook_config,
852 grant,
853 on_overflow: artifact.on_overflow,
857 });
858 }
859 ArtifactRuntime::Skill => {
860 let skill_md =
861 extract_skill_md(&artifact.name, &resolved_version, &resolved.bytes)?;
862 skill_files.push((artifact.name.clone(), skill_md));
863 }
864 }
865
866 resolved_lock_artifacts.push(ResolvedLockArtifact {
867 name: artifact.name.clone(),
868 resolved_version: resolved_version.clone(),
869 sha256: resolved.sha256,
870 platform: resolved_platform,
871 });
872 installed_artifacts.push(InstalledArtifactSummary {
873 name: artifact.name.clone(),
874 version: resolved_version,
875 runtime: artifact.runtime.clone(),
876 implementation: artifact_implementation,
877 });
878 }
879
880 let session_id = generate_session_id();
882
883 let spend = stage_spend_meter(
888 request.inference.as_ref(),
889 request.machine_tokens_per_day,
890 &session_id,
891 )?;
892 let inference_gateway = stage_inference_gateway(
893 request.inference.as_ref(),
894 request.credentials_file.as_deref(),
895 &installed_manifests,
896 &installed_artifacts,
897 &spend,
898 )?;
899
900 if let Some(ref resume) = request.resume {
904 check_resume_launchable(
905 resume,
906 request.context_id.as_deref(),
907 request.context.as_ref(),
908 &request.capsule_name,
909 request.inference.as_ref(),
910 &hook_components,
911 )?;
912 }
913
914 let capsule_component =
917 if request.inference.is_some() && request.capsule_component_bytes.is_empty() {
918 None
919 } else if request.capsule_component_bytes.is_empty() {
920 return Err(RuntimeError::CapsuleCompile(
921 "capsule component bytes are required for non-agent capsules".to_string(),
922 ));
923 } else {
924 Some(
925 Component::new(&engine, &request.capsule_component_bytes)
926 .map_err(|err| RuntimeError::CapsuleCompile(err.to_string()))?,
927 )
928 };
929
930 let (workdir, accessible_workdir) = if let Some(ref user_dir) = request.workdir {
931 let user_dir = if user_dir.is_absolute() {
932 user_dir.clone()
933 } else {
934 std::env::current_dir()
935 .map_err(|e| RuntimeError::Runtime(format!("failed to get cwd: {e}")))?
936 .join(user_dir)
937 };
938 if !user_dir.exists() || !user_dir.is_dir() {
939 return Err(RuntimeError::Runtime(format!(
940 "workdir '{}' does not exist or is not a directory",
941 user_dir.display()
942 )));
943 }
944 let session_dir = user_dir.join(".murmur").join(&session_id);
945 (session_dir, user_dir)
946 } else {
947 let dir = request.manifest_dir.join("workdir").join(&session_id);
948 let accessible = dir.clone();
949 (dir, accessible)
950 };
951 if let Some(ref export) = exports_files {
955 crate::resource_plane::check_export_root(&accessible_workdir, export)?;
956 }
957 if let Some(ref export) = exports_peer_files {
958 crate::resource_plane::check_peer_files_root(&accessible_workdir, export)?;
959 }
960
961 fs::create_dir_all(workdir.join("tools")).map_err(|source| RuntimeError::CreateWorkdir {
962 path: workdir.display().to_string(),
963 source,
964 })?;
965
966 for (name, manifest_yaml) in &installed_manifests {
967 write_tool_manifest(&workdir, name, manifest_yaml)?;
968 }
969
970 install_native_binaries(&workdir, native_binaries)?;
971 install_skill_files(&workdir, skill_files)?;
972
973 write_shell_tool_manifests(&workdir, &request.capability_policy.shell_allow)?;
975
976 write_peer_handoff_tool_manifests(
981 &workdir,
982 exports_peer_files.is_some(),
983 !request.capability_policy.peer_fetch_allow.is_empty(),
984 )?;
985
986 write_delegate_task_tool_manifest(&workdir, &request.capability_policy.spawn_allow)?;
990
991 write_submit_plan_tool_manifest(&workdir, request.capability_policy.plan_submit)?;
994
995 let tool_annotations = if protected_paths.is_empty() {
1000 ToolAnnotationMap::default()
1001 } else {
1002 warn_on_unannotated_tool_schemas(&installed_manifests);
1003 ToolAnnotationMap::from_workdir(&workdir)
1004 };
1005
1006 let stage_env = HookEnvVars::default();
1008 dispatch_stage(
1009 &engine,
1010 &workdir,
1011 &hook_components,
1012 request.capability_policy.shell_allow.clone(),
1013 &stage_env,
1014 request.capability_policy.hook_limits(),
1015 )?;
1016
1017 if let Some(ref inference) = request.inference {
1021 let partial_identity = CapsuleIdentity {
1022 capsule_name: request.capsule_name.clone(),
1023 capsule_version: request.capsule_version.clone(),
1024 session_id: session_id.clone(),
1025 capsule_url: String::new(),
1026 };
1027 murmur_md::write_murmur_md(
1028 &workdir,
1029 Some(inference),
1030 request.context.as_ref(),
1031 has_compaction_hook(&hook_components),
1032 &request.capability_policy,
1033 &partial_identity,
1034 );
1035 }
1036
1037 if request.workdir.is_some() {
1040 let src = request.manifest_dir.join(MANIFEST_FILENAME);
1041 let dst = accessible_workdir.join(MANIFEST_FILENAME);
1042 if src.exists() && !dst.exists() {
1043 if let Err(e) = fs::copy(&src, &dst) {
1044 eprintln!(
1045 "[capsule-runtime] warning: failed to copy {MANIFEST_FILENAME} to workdir: {e}"
1046 );
1047 }
1048 }
1049 }
1050
1051 let lifecycle = resolve_lifecycle(request.lifecycle, request.lifecycle_override.as_ref());
1053
1054 Ok(StagedSession {
1055 session_id,
1056 workdir,
1057 accessible_workdir,
1058 manifest_dir: request.manifest_dir,
1059 capsule_name: request.capsule_name,
1060 capsule_version: request.capsule_version,
1061 capsule_url: String::new(), resolved_lock_artifacts,
1063 installed_artifacts,
1064 inference: request.inference,
1065 inference_gateway,
1066 spend,
1067 system_prompt_overridden: request.system_prompt_overridden,
1068 context: request.context,
1069 context_id: request.context_id,
1070 resume: request.resume,
1071 engine,
1072 capsule_component,
1073 tool_components,
1074 artifact_grants,
1075 hook_components,
1076 allowlisted_tools: request.allowlisted_tools,
1077 capability_policy: CapabilityPolicy {
1082 containment_floor: request.declared_containment_floor,
1083 ..request.capability_policy
1084 },
1085 otel_endpoint: request.otel_endpoint,
1086 eval_config_json: request.eval_config_json,
1087 case_id: request.case_id,
1088 dataset_id: request.dataset_id,
1089 lifecycle,
1090 trace_capture: request
1091 .trace
1092 .as_ref()
1093 .map(|t| t.capture)
1094 .unwrap_or_default(),
1095 trace_retain: request.trace.as_ref().and_then(|t| t.retain),
1096 host_probe,
1097 protected_paths,
1098 tool_annotations,
1099 bind_addr: request.bind_addr,
1100 internal_port: request.internal_port,
1101 declared_containment_floor: request.declared_containment_floor,
1102 scope_report,
1103 exports_files,
1104 exports_peer_files,
1105 registry,
1106 _epoch_ticker: epoch_ticker,
1107 spawn_credential: None,
1110 spawn_grant: request.spawn_grant,
1111 spawner: SpawnerHandle::from_env()?,
1115 })
1116}
1117
1118pub fn launch_session(
1127 staged: StagedSession,
1128 on_url: impl FnOnce(&str),
1129) -> Result<LaunchResult, RuntimeError> {
1130 launch(staged, on_url, false)
1131}
1132
1133pub fn launch_session_handling_sigterm(
1142 staged: StagedSession,
1143 on_url: impl FnOnce(&str),
1144) -> Result<LaunchResult, RuntimeError> {
1145 launch(staged, on_url, true)
1146}
1147
1148fn launch(
1149 mut staged: StagedSession,
1150 on_url: impl FnOnce(&str),
1151 handle_sigterm: bool,
1152) -> Result<LaunchResult, RuntimeError> {
1153 let network_allow_rules = parse_network_allow_rules(&staged.capability_policy.network_allow)?;
1154
1155 let mut roost_session = RoostSession::register(&mut staged)?;
1160
1161 let mut delegation = DelegationReport::open(&staged)?;
1166
1167 let has_native_artifact = staged.installed_artifacts.iter().any(|artifact| {
1176 matches!(
1177 artifact.implementation,
1178 Some(murmur_artifact::ArtifactImplementation::Native)
1179 )
1180 });
1181 let requires_process_bounding =
1182 cgroup::requires_process_bounding(&staged.capability_policy, has_native_artifact);
1183 let prepared_scope = cgroup::prepare_scope(
1184 requires_process_bounding,
1185 &staged.capability_policy.resources,
1186 &staged.session_id,
1187 &staged.workdir,
1188 )
1189 .map_err(|reason| RuntimeError::CgroupDelegationUnavailable { reason })?;
1190 staged.scope_report.io_max = prepared_scope.io_max.clone();
1195 cgroup::warn_for_unenforced_io_max(&staged.workdir, &prepared_scope.io_max);
1198 let cgroup_scope = prepared_scope.scope;
1199 let workdir_guard = Some(resources::WorkdirGuard::spawn(
1200 &staged.workdir,
1201 staged.capability_policy.resources.workdir_max_bytes,
1202 ));
1203
1204 let shell_enforcement = sandbox::ShellEnforcement::resolve(
1205 &staged.capability_policy,
1206 staged.declared_containment_floor,
1207 staged.host_probe,
1208 )
1209 .map_err(RuntimeError::Runtime)?
1210 .with_host_bounding(cgroup_scope, workdir_guard);
1211 let inference_env = staged
1212 .inference
1213 .as_ref()
1214 .map(|inference| inference_env_pairs(inference, staged.inference_gateway.as_deref()))
1215 .unwrap_or_default();
1216 let inference_gateway = staged.inference_gateway.clone();
1217 let spend = Arc::clone(&staged.spend);
1218
1219 if let Some(ref inference) = staged.inference {
1220 let workdir = staged.workdir.clone();
1221 let system_prompt = resolve_system_prompt(&staged.manifest_dir, &workdir, inference)?;
1222 let compaction_system_prompt =
1223 resolve_compaction_system_prompt(&staged.manifest_dir, inference.compaction.as_ref())?;
1224 let session_id = staged.session_id.clone();
1225 let accessible_workdir = staged.accessible_workdir.clone();
1226 let context_window = resolve_context_window(staged.context.as_ref());
1227 let seed_budget = staged
1228 .context
1229 .as_ref()
1230 .map(|c| c.seed_budget)
1231 .unwrap_or(murmur_artifact::DEFAULT_SEED_BUDGET);
1232 let seed_budget_tokens = agent::seed_budget_tokens(context_window, seed_budget);
1235
1236 let run_config = agent::AgentRunConfig {
1237 context_window,
1238 compaction_threshold: inference
1239 .compaction
1240 .as_ref()
1241 .and_then(|c| c.threshold)
1242 .unwrap_or(0.98),
1243 compaction_model: inference.compaction.as_ref().and_then(|c| c.model.clone()),
1244 compaction_system_prompt,
1245 compaction_dump_summaries: inference
1246 .compaction
1247 .as_ref()
1248 .and_then(|c| c.dump_summaries)
1249 .unwrap_or(false),
1250 max_output_tokens: inference
1251 .max_tokens
1252 .unwrap_or(agent::DEFAULT_MAX_OUTPUT_TOKENS),
1253 seed_budget,
1254 seed_overflow_margin: staged
1255 .context
1256 .as_ref()
1257 .map(|c| c.seed_overflow_margin)
1258 .unwrap_or(murmur_artifact::DEFAULT_SEED_OVERFLOW_MARGIN),
1259 conversation_root: resolve_conversation_root(
1260 staged.context.as_ref(),
1261 &staged.capsule_name,
1262 inference,
1263 &workdir,
1264 ),
1265 record_owner: staged
1269 .context
1270 .as_ref()
1271 .and_then(|context| context.retain)
1272 .map(|_| staged.capsule_name.clone()),
1273 resume: staged.resume.as_ref().map(|resume| resume.mode),
1274 };
1275
1276 let rt = tokio::runtime::Builder::new_multi_thread()
1279 .enable_all()
1280 .build()
1281 .map_err(|e| RuntimeError::Runtime(format!("failed to create tokio runtime: {e}")))?;
1282
1283 let (tcp_listener, external_port) = rt.block_on(identity::bind_local_port(
1284 &staged.bind_addr,
1285 staged.internal_port,
1286 ))?;
1287
1288 let capsule_url = format!("localhost:{external_port}");
1289 staged.capsule_url = capsule_url.clone();
1290
1291 let running_record = running_record_for(&staged, &session_id, &capsule_url);
1294
1295 #[cfg(not(unix))]
1300 let _ = handle_sigterm;
1301 #[cfg(unix)]
1302 let sigterm = if !handle_sigterm {
1303 None
1304 } else {
1305 let _runtime = rt.enter();
1306 match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
1307 Ok(sigterm) => Some(sigterm),
1308 Err(e) => {
1309 eprintln!(
1310 "[capsule-runtime] could not install a SIGTERM handler; SIGTERM will end this session without its teardown: {e}"
1311 );
1312 None
1313 }
1314 }
1315 };
1316
1317 let capsule_identity = CapsuleIdentity {
1318 capsule_name: staged.capsule_name.clone(),
1319 capsule_version: staged.capsule_version.clone(),
1320 session_id: session_id.clone(),
1321 capsule_url: capsule_url.clone(),
1322 };
1323
1324 let mut all_env = inference_env.clone();
1326 all_env.push((
1327 "MURMUR_CAPSULE_NAME".to_string(),
1328 staged.capsule_name.clone(),
1329 ));
1330 all_env.push((
1331 "MURMUR_CAPSULE_VERSION".to_string(),
1332 staged.capsule_version.clone(),
1333 ));
1334 all_env.push(("MURMUR_SESSION_ID".to_string(), session_id.clone()));
1335 all_env.push(("MURMUR_CAPSULE_URL".to_string(), capsule_url.clone()));
1336
1337 murmur_md::write_murmur_md(
1338 &workdir,
1339 Some(inference),
1340 staged.context.as_ref(),
1341 has_compaction_hook(&staged.hook_components),
1342 &staged.capability_policy,
1343 &capsule_identity,
1344 );
1345
1346 sandbox::warn_for_enforcement_tier(
1347 shell_enforcement.tier,
1348 &workdir,
1349 &staged.capability_policy,
1350 );
1351 sandbox::warn_for_missing_aggregate_bounding(
1352 &workdir,
1353 requires_process_bounding,
1354 shell_enforcement.cgroup_scope.is_some(),
1355 );
1356 warn_for_unreachable_delegation_outcomes(
1357 &workdir,
1358 !staged.capability_policy.spawn_allow.is_empty(),
1359 &staged.lifecycle,
1360 );
1361 warn_for_unreachable_shell_completions(
1362 &workdir,
1363 !staged.capability_policy.shell_allow.is_empty(),
1364 &staged.lifecycle,
1365 );
1366
1367 let agent_card = identity::build_agent_card(
1368 &capsule_identity,
1369 &staged.installed_artifacts,
1370 &staged.capability_policy,
1371 );
1372 let agent_card_json = agent_card.to_string();
1373
1374 let effective_lifecycle = staged.lifecycle.clone();
1376 let conversation_mode = effective_lifecycle.conversation_mode.clone();
1377 let supplied_context_id = staged.context_id.clone();
1381 let trace_resumed_from = staged
1386 .resume
1387 .as_ref()
1388 .map(|resume| resume.from_session.clone());
1389 let reconcile_from_session = trace_resumed_from.clone();
1392 let trace_context_id = supplied_context_id.clone();
1393 let trace_spawned_by = staged
1396 .spawner
1397 .as_ref()
1398 .map(|handle| handle.session_id.clone());
1399 let trace_spawn_delegation_id = staged
1400 .spawner
1401 .as_ref()
1402 .map(|handle| handle.delegation_id.clone());
1403 let queue_capacity = match effective_lifecycle.task_acceptance {
1404 TaskAcceptance::Queue => effective_lifecycle.queue_depth,
1405 _ => 1,
1406 };
1407
1408 let task_registry: Arc<Mutex<TaskRegistry>> = Arc::new(Mutex::new(TaskRegistry::new(
1410 effective_lifecycle.queue_depth,
1411 effective_lifecycle.task_acceptance.clone(),
1412 )));
1413 let (task_tx, mut task_rx) = tokio::sync::mpsc::channel::<IncomingTask>(queue_capacity);
1414
1415 let (detached, mut completion_rx) = DetachedRegistry::new();
1418
1419 let live_delegations = Arc::new(crate::cancel::LiveDelegations::new());
1422
1423 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
1424
1425 let (sse_tx, _) =
1427 tokio::sync::broadcast::channel::<std::sync::Arc<String>>(SSE_BROADCAST_CAPACITY);
1428 let sse_buffer = std::sync::Arc::new(Mutex::new(SseEventBuffer::new(SSE_REPLAY_CAPACITY)));
1429
1430 let capsule_name = staged.capsule_name.clone();
1431 let capabilities = capability_names(&staged.capability_policy);
1432 let effective_grants = staged.scope_report.clone();
1437 let exports_files = staged.exports_files.clone();
1442 let resource_containment = staged.scope_report.achieved_containment;
1443 let resource_accessible_workdir = accessible_workdir.clone();
1444
1445 let exports_peer_files = staged.exports_peer_files.clone();
1450 let peer_mint_key = match exports_peer_files {
1451 Some(_) => Some(std::sync::Arc::new(
1452 crate::peer_handoff::PeerMintKey::generate().map_err(RuntimeError::Runtime)?,
1453 )),
1454 None => None,
1455 };
1456 let own_peer_audience = crate::peer_handoff::own_audience(&capsule_identity);
1460 let peer_fetch_rules =
1461 parse_network_allow_rules(&staged.capability_policy.peer_fetch_allow)?;
1462
1463 let delegation_plane = roost_session.endpoint().map(|(roost_url, credential)| {
1468 std::sync::Arc::new(
1469 crate::delegation_plane::DelegationPlane::new(
1470 roost_url.to_string(),
1471 credential,
1472 accessible_workdir.clone(),
1473 session_id.clone(),
1474 std::time::Duration::from_secs(effective_lifecycle.delegation_deadline_secs),
1475 Arc::clone(&staged.registry),
1476 staged.capability_policy.env_allow.clone(),
1477 )
1478 .reporting_to(format!("http://{capsule_url}")),
1481 )
1482 });
1483
1484 let hook_components = staged.hook_components;
1486 let tool_components = staged.tool_components;
1487 let artifact_grants = staged.artifact_grants;
1488 let allowlisted_tools = staged.allowlisted_tools.clone();
1489 let installed_artifacts = staged.installed_artifacts;
1490 let engine = staged.engine.clone();
1491 let capability_policy = staged.capability_policy.clone();
1492 let protected_paths = staged.protected_paths.clone();
1493 let tool_annotations = staged.tool_annotations.clone();
1494 let shell_enforcement_for_state = shell_enforcement.clone();
1495 let otel_endpoint = staged.otel_endpoint;
1496 let eval_config_json = staged.eval_config_json;
1497 let case_id = staged.case_id;
1498 let dataset_id = staged.dataset_id;
1499 let capsule_version = staged.capsule_version.clone();
1500 let inference_model = inference.model.clone();
1501 let inference_driver_name = inference
1505 .driver
1506 .as_ref()
1507 .map(|d| d.artifact.clone())
1508 .filter(|a| !a.is_empty());
1509 let trace_capture = staged.trace_capture;
1510 let trace_retain = staged.trace_retain;
1514 let context_retain = staged.context.as_ref().and_then(|context| context.retain);
1515 let retention_conversation_root = run_config.conversation_root.clone();
1516 let trace_system_prompt = system_prompt.clone();
1520 let system_prompt_overridden = staged.system_prompt_overridden;
1521 let registry_for_pull = Arc::clone(&staged.registry);
1522 let lock_path_for_pull = staged.manifest_dir.join("murmur.lock");
1523
1524 let workdir_task_md = accessible_workdir.join("task.md");
1529
1530 let session_id_ret = session_id.clone();
1532 let workdir_ret = workdir.clone();
1533
1534 let loop_result: Result<(), RuntimeError> = rt.block_on(async move {
1536 let mut trace = TraceWriter::open(
1537 &workdir,
1538 session_id.clone(),
1539 capsule_name.clone(),
1540 capsule_version.clone(),
1541 inference_model.clone(),
1542 capabilities.clone(),
1543 effective_grants,
1544 trace_capture,
1545 trace_system_prompt,
1546 system_prompt_overridden,
1547 trace_resumed_from,
1548 trace_context_id,
1549 trace_spawned_by,
1550 trace_spawn_delegation_id,
1551 )
1552 .await
1553 .map_err(|e| RuntimeError::AgentLoopFailed(format!("failed to open trace.jsonl: {e}")))?;
1554 trace.set_spend_ceilings(spend.session_ceiling(), spend.machine_ceiling());
1555
1556 let tools_declared: Vec<String> =
1565 agent::inventory::build_tool_inventory(&workdir, inference.system_prompt_artifact.as_deref())
1566 .iter()
1567 .filter_map(|t| t.get("name").and_then(serde_json::Value::as_str))
1568 .map(str::to_string)
1569 .collect();
1570 let inference_credential = inference_gateway
1571 .as_ref()
1572 .and_then(|gateway| gateway.credential())
1573 .cloned();
1574 trace.set_credential_source(
1575 inference_credential
1576 .as_ref()
1577 .map_or("none", |credential| credential.source().trace_name()),
1578 );
1579 trace
1580 .write_session_start(inference.max_turns, tools_declared)
1581 .await
1582 .map_err(|e| RuntimeError::AgentLoopFailed(format!("trace write failed: {e}")))?;
1583
1584 apply_retention(
1588 &mut trace,
1589 &workdir,
1590 &session_id,
1591 trace_retain.as_ref(),
1592 context_retain.as_ref(),
1593 retention_conversation_root.as_deref(),
1594 &capsule_name,
1595 supplied_context_id.as_deref(),
1596 )
1597 .await;
1598
1599 let resource_trace = crate::trace::ResourceTraceAppender::open(
1604 &workdir,
1605 session_id.clone(),
1606 trace.session_event_id().to_string(),
1607 )
1608 .await
1609 .ok()
1610 .map(std::sync::Arc::new);
1611 if let (Some(credential), Some(appender)) = (&inference_credential, &resource_trace) {
1615 credential.attach_trace(Arc::clone(appender));
1616 }
1617 let plan_trace = crate::trace::PlanTraceAppender::open(
1621 &workdir,
1622 session_id.clone(),
1623 trace.session_event_id().to_string(),
1624 )
1625 .ok()
1626 .map(std::sync::Arc::new);
1627 let resource_plane = std::sync::Arc::new(crate::resource_plane::ResourcePlane::new(
1628 &resource_accessible_workdir,
1629 exports_files.as_ref(),
1630 resource_containment,
1631 task_registry.lock().unwrap().resource_generation(),
1632 resource_trace.clone(),
1633 ));
1634 let peer_plane = std::sync::Arc::new(crate::peer_handoff::PeerPlane::new(
1638 &resource_accessible_workdir,
1639 exports_peer_files
1640 .as_ref()
1641 .zip(peer_mint_key.as_ref())
1642 .map(|(export, key)| (export, std::sync::Arc::clone(key))),
1643 session_id.clone(),
1644 resource_containment,
1645 task_registry.lock().unwrap().resource_generation(),
1646 resource_trace.clone(),
1647 ));
1648
1649 let mut otel = OtelEmitter::new(
1650 otel_endpoint.clone(),
1651 &workdir,
1652 capsule_name.clone(),
1653 capsule_version.clone(),
1654 );
1655
1656 let local = tokio::task::LocalSet::new();
1657 local
1658 .run_until(async move {
1659 let server_handle =
1661 tokio::task::spawn_local(identity::serve_http(
1662 tcp_listener,
1663 shutdown_rx,
1664 agent_card_json,
1665 Arc::clone(&task_registry),
1666 task_tx,
1667 effective_lifecycle.task_acceptance.clone(),
1668 sse_tx.clone(),
1669 Arc::clone(&sse_buffer),
1670 conversation_mode.clone(),
1671 std::sync::Arc::clone(&resource_plane),
1672 std::sync::Arc::clone(&peer_plane),
1673 session_id.clone(),
1674 Some(Arc::clone(&detached)),
1675 Arc::clone(&live_delegations),
1676 ));
1677
1678 let hook_limits = capability_policy.hook_limits();
1682
1683 let mut state = CapsuleStoreState {
1685 limits: capability_policy.limits.limiter(),
1690 table: ResourceTable::new(),
1691 wasi: build_wasi_ctx(
1695 &accessible_workdir,
1696 None,
1697 None,
1700 None,
1703 &all_env,
1704 &capability_policy,
1705 )?,
1706 http: WasiHttpCtx::new(),
1707 http_hooks: NetworkPolicyHooks {
1708 network_allow_rules: network_allow_rules.clone(),
1709 inference_gateway: None,
1710 },
1711 network_allow_rules,
1712 peer_fetch_rules,
1713 peer_plane: Some(std::sync::Arc::clone(&peer_plane)),
1714 peer_own_audience: own_peer_audience,
1715 peer_trace: resource_trace,
1716 delegation: delegation_plane,
1717 plan_trace,
1718 plan_counter: AtomicU64::new(0),
1719 inference_env: all_env,
1720 inference_gateway,
1721 spend,
1722 engine: engine.clone(),
1723 workdir: workdir.clone(),
1724 accessible_workdir: accessible_workdir.clone(),
1725 tool_components,
1726 artifact_grants,
1727 allowlisted_tools,
1728 installed_artifacts,
1729 session_id: session_id.clone(),
1730 pending_a2a_events: Vec::new(),
1731 capability_policy,
1732 protected_paths,
1733 tool_annotations,
1734 shell_enforcement: shell_enforcement_for_state,
1735 current_traceparent: None,
1736 current_task_provenance: None,
1737 current_context_id: None,
1738 live_delegations: Arc::clone(&live_delegations),
1739 detached: Some(Arc::clone(&detached)),
1740 shell_grace_secs: effective_lifecycle.shell_grace_secs,
1741 a2a_task_registry: Some(Arc::clone(&task_registry)),
1742 a2a_sse: Some((sse_tx.clone(), Arc::clone(&sse_buffer))),
1743 a2a_task_id: None,
1744 input_timeout_secs: effective_lifecycle.input_timeout_secs,
1745 a2a_chunk_event_id: Arc::new(AtomicU64::new(u64::MAX / 4)),
1746 a2a_chunks_emitted: Arc::new(AtomicBool::new(false)),
1747 registry: registry_for_pull,
1748 lock_path: lock_path_for_pull,
1749 driver_continuation_id: None,
1750 driver_continuation_context_id: None,
1751 driver_continuation_acked_len: 0,
1752 };
1753
1754 let hook_inference = inference_driver_name
1760 .as_ref()
1761 .and_then(|driver_name| {
1762 state
1763 .tool_components
1764 .get(driver_name)
1765 .map(|component| (driver_name.clone(), component.clone()))
1766 })
1767 .map(|(driver_name, driver_component)| {
1768 let driver_grant = state.artifact_grants.get(&driver_name).cloned();
1771 Arc::new(HookInferenceCtx {
1772 driver_name,
1773 driver_component,
1774 model: inference_model.clone(),
1775 engine: state.engine.clone(),
1776 accessible_workdir: state.accessible_workdir.clone(),
1777 workdir: state.workdir.clone(),
1778 inference_env: state.inference_env.clone(),
1779 capability_policy: state.capability_policy.clone(),
1780 network_allow_rules: state.network_allow_rules.clone(),
1781 driver_grant,
1782 inference_gateway: state.inference_gateway.clone(),
1783 spend: Arc::clone(&state.spend),
1784 records: std::sync::Mutex::new(Vec::new()),
1785 spend_refusals: std::sync::Mutex::new(Vec::new()),
1786 })
1787 });
1788
1789 let mut hooks = HookRuntime::new(
1791 &engine,
1792 &workdir,
1793 &accessible_workdir,
1794 hook_components,
1795 SessionContextData {
1796 capsule_name: capsule_name.clone(),
1797 capsule_version: capsule_version.clone(),
1798 session_id: session_id.clone(),
1799 model: inference_model.clone(),
1800 capabilities: capabilities.clone(),
1801 },
1802 HookEnvVars {
1803 otel_endpoint: otel_endpoint.as_deref(),
1804 eval_config_json: eval_config_json.as_deref(),
1805 case_id: case_id.as_deref(),
1806 dataset_id: dataset_id.as_deref(),
1807 },
1808 hook_limits,
1809 hook_inference,
1810 run_config.conversation_root.clone(),
1811 )
1812 .await?;
1813
1814 hooks.emit(&workdir, HookEvent::SessionStart).await;
1818
1819 let _running_record = write_running_record(&running_record, &workdir);
1838 on_url(&capsule_url);
1839
1840 let mut final_loop_result: Result<AgentLoopExit, RuntimeError> =
1844 Ok(AgentLoopExit::Ok);
1845
1846 let mut closing_out = false;
1854
1855 let mut lanes = LaneQueue::new();
1859
1860 let mut queued_cancel_event_id: u64 = u64::MAX / 8;
1864
1865 if let (Some(from_session), Some(sessions_root)) =
1875 (reconcile_from_session.as_deref(), workdir.parent())
1876 {
1877 if let Some(report) =
1878 crate::detached_reconcile::reconcile_prior_session(
1879 sessions_root,
1880 from_session,
1881 &session_id,
1882 &task_context_id(supplied_context_id.as_deref()),
1883 )
1884 .await
1885 {
1886 enqueue_detached_report(
1887 DetachedReport::Lost(report),
1888 &task_registry,
1889 &mut lanes,
1890 &mut trace,
1891 )
1892 .await;
1893 }
1894 }
1895
1896 let terminating = crate::cancel::CancelSignal::new();
1900 #[cfg(unix)]
1901 if let Some(mut sigterm) = sigterm {
1902 let task_registry = Arc::clone(&task_registry);
1903 let terminating = terminating.clone();
1904 tokio::spawn(async move {
1905 if sigterm.recv().await.is_none() {
1906 return;
1907 }
1908 let _ = task_registry.lock().unwrap().cancel_every_live();
1911 terminating.cancel();
1912 eprintln!(
1913 "[capsule-runtime] SIGTERM received — cancelling live tasks and ending the session"
1914 );
1915 std::thread::spawn(|| {
1916 std::thread::sleep(TERMINATE_TEARDOWN_DEADLINE);
1917 std::process::exit(143);
1918 });
1919 if sigterm.recv().await.is_some() {
1920 std::process::exit(143);
1921 }
1922 });
1923 }
1924
1925 'task_loop: loop {
1929 if terminating.is_canceled() {
1930 close_lanes_on_termination(
1931 &mut lanes,
1932 &task_registry,
1933 &mut trace,
1934 &detached,
1935 &live_delegations,
1936 &Some((sse_tx.clone(), Arc::clone(&sse_buffer))),
1937 &mut queued_cancel_event_id,
1938 )
1939 .await;
1940 break 'task_loop;
1941 }
1942 let (incoming_lane, incoming) = if closing_out {
1944 let active = task_registry.lock().unwrap().active_lane();
1945 match lanes.next(active) {
1946 Some(selected) => selected,
1947 None => break 'task_loop,
1948 }
1949 } else {
1950 match effective_lifecycle.task_acceptance {
1951 TaskAcceptance::None => {
1952 if workdir_task_md.exists() {
1954 let task_id = format!("tsk_{}", uuid::Uuid::now_v7().simple());
1955 let context_id = task_context_id(supplied_context_id.as_deref());
1956 let bytes = tokio::fs::metadata(&workdir_task_md)
1957 .await
1958 .map(|m| m.len())
1959 .unwrap_or(0);
1960 let provenance =
1961 TaskProvenance::derive(TaskOrigin::User, None);
1962 let _ = trace
1963 .write_task_start(
1964 &task_id,
1965 &context_id,
1966 "task_md",
1967 provenance,
1968 None,
1971 bytes,
1972 )
1973 .await;
1974 let seed = hooks
1975 .dispatch_task_start(
1976 task_id.clone(),
1977 context_id.clone(),
1978 "task_md".to_string(),
1979 bytes,
1980 seed_budget_tokens,
1981 u64::from(context_window),
1982 agent::prior_history_tokens(
1983 run_config.conversation_root.as_deref(),
1984 &workdir,
1985 &conversation_mode,
1986 Some(&context_id),
1987 run_config.resume.is_some(),
1988 ),
1989 )
1990 .await;
1991 otel.begin_session(None);
1992 state.current_traceparent = otel.outgoing_traceparent();
1993 state.current_task_provenance = Some(provenance);
1994 state.current_context_id = Some(context_id.clone());
1995 let result = run_task_with_reopens(
1999 &mut state,
2000 &workdir,
2001 inference,
2002 effective_lifecycle.max_task_reopens,
2003 system_prompt.clone(),
2004 run_config.clone(),
2005 &mut hooks,
2006 &mut trace,
2007 &mut otel,
2008 None,
2009 None,
2010 &accessible_workdir,
2011 &capsule_name,
2012 &capsule_version,
2013 conversation_mode.clone(),
2014 Some(context_id.clone()),
2015 &task_id,
2016 seed,
2017 Some(terminating.clone()),
2020 )
2021 .await;
2022 let failed = result.is_err();
2023 final_loop_result = result;
2024 if failed {
2025 break 'task_loop;
2026 }
2027 closing_out = true;
2028 continue 'task_loop;
2029 } else {
2030 closing_out = true;
2031 continue 'task_loop;
2032 }
2033 }
2034 TaskAcceptance::Single | TaskAcceptance::Queue => {
2035 if workdir_task_md.exists() {
2036 let task_id = format!("tsk_{}", uuid::Uuid::now_v7().simple());
2038 let context_id = task_context_id(supplied_context_id.as_deref());
2039 let bytes = tokio::fs::metadata(&workdir_task_md)
2040 .await
2041 .map(|m| m.len())
2042 .unwrap_or(0);
2043 let provenance =
2044 TaskProvenance::derive(TaskOrigin::User, None);
2045 let _ = trace
2046 .write_task_start(
2047 &task_id,
2048 &context_id,
2049 "task_md",
2050 provenance,
2051 None,
2054 bytes,
2055 )
2056 .await;
2057 let seed = hooks
2058 .dispatch_task_start(
2059 task_id.clone(),
2060 context_id.clone(),
2061 "task_md".to_string(),
2062 bytes,
2063 seed_budget_tokens,
2064 u64::from(context_window),
2065 agent::prior_history_tokens(
2066 run_config.conversation_root.as_deref(),
2067 &workdir,
2068 &conversation_mode,
2069 Some(&context_id),
2070 run_config.resume.is_some(),
2071 ),
2072 )
2073 .await;
2074 otel.begin_session(None);
2075 state.current_traceparent = otel.outgoing_traceparent();
2076 state.current_task_provenance = Some(provenance);
2077 state.current_context_id = Some(context_id.clone());
2078 let result = run_task_with_reopens(
2079 &mut state,
2080 &workdir,
2081 inference,
2082 effective_lifecycle.max_task_reopens,
2083 system_prompt.clone(),
2084 run_config.clone(),
2085 &mut hooks,
2086 &mut trace,
2087 &mut otel,
2088 None,
2089 None,
2090 &accessible_workdir,
2091 &capsule_name,
2092 &capsule_version,
2093 conversation_mode.clone(),
2094 Some(context_id.clone()),
2095 &task_id,
2096 seed,
2097 Some(terminating.clone()),
2098 )
2099 .await;
2100 let _ = trace.flush().await;
2101 let failed = result.is_err();
2102 let single = matches!(
2103 effective_lifecycle.task_acceptance,
2104 TaskAcceptance::Single
2105 );
2106 if single || failed {
2107 final_loop_result = result;
2108 if failed {
2109 break 'task_loop;
2110 }
2111 closing_out = true;
2112 continue 'task_loop;
2113 }
2114 let _ = tokio::fs::remove_file(&workdir_task_md).await;
2117 continue 'task_loop;
2118 }
2119 let is_queue_sleep =
2124 effective_lifecycle.can_receive_background_tasks();
2125
2126 loop {
2127 while let Ok(report) = completion_rx.try_recv() {
2132 enqueue_detached_report(
2133 report,
2134 &task_registry,
2135 &mut lanes,
2136 &mut trace,
2137 )
2138 .await;
2139 }
2140 while let Ok(task) = task_rx.try_recv() {
2145 lanes.push(task);
2146 }
2147 if terminating.is_canceled() {
2148 close_lanes_on_termination(
2149 &mut lanes,
2150 &task_registry,
2151 &mut trace,
2152 &detached,
2153 &live_delegations,
2154 &Some((sse_tx.clone(), Arc::clone(&sse_buffer))),
2155 &mut queued_cancel_event_id,
2156 )
2157 .await;
2158 break 'task_loop;
2159 }
2160 let active = task_registry.lock().unwrap().active_lane();
2161 if let Some(selected) = lanes.next(active) {
2162 break selected;
2163 }
2164
2165 let arrived = if is_queue_sleep {
2166 tokio::select! {
2172 () = terminating.canceled() => break 'task_loop,
2173 arrived = task_rx.recv() => match arrived {
2174 Some(task) => task,
2175 None => {
2176 final_loop_result = Ok(AgentLoopExit::Ok);
2177 break 'task_loop;
2178 }
2179 },
2180 Some(report) = completion_rx.recv() => {
2181 enqueue_detached_report(
2182 report,
2183 &task_registry,
2184 &mut lanes,
2185 &mut trace,
2186 )
2187 .await;
2188 continue;
2189 }
2190 }
2191 } else {
2192 let idle_timeout_secs: u64 =
2193 std::env::var("MURMUR_A2A_TIMEOUT_SECS")
2194 .ok()
2195 .and_then(|v| v.parse().ok())
2196 .unwrap_or(30);
2197 match tokio::time::timeout(
2202 std::time::Duration::from_secs(idle_timeout_secs),
2203 async {
2204 tokio::select! {
2205 () = terminating.canceled() => Woke::Terminating,
2206 arrived = task_rx.recv() => Woke::Task(arrived),
2207 Some(report) = completion_rx.recv() => {
2208 Woke::Report(report)
2209 }
2210 }
2211 },
2212 )
2213 .await
2214 {
2215 Ok(Woke::Report(report)) => {
2218 enqueue_detached_report(
2219 report,
2220 &task_registry,
2221 &mut lanes,
2222 &mut trace,
2223 )
2224 .await;
2225 continue;
2226 }
2227 Ok(Woke::Terminating) => break 'task_loop,
2228 Ok(Woke::Task(Some(task))) => task,
2229 Ok(Woke::Task(None)) => {
2230 final_loop_result = Ok(AgentLoopExit::Ok);
2231 break 'task_loop;
2232 }
2233 Err(_elapsed) => {
2234 eprintln!("[capsule-runtime] no A2A message received within timeout; running with empty task");
2235 otel.begin_session(None);
2236 state.current_traceparent = otel.outgoing_traceparent();
2237 final_loop_result = agent::run_agent_loop(
2238 &mut state,
2239 &workdir,
2240 inference,
2241 system_prompt,
2242 run_config,
2243 &mut hooks,
2244 &mut trace,
2245 &mut otel,
2246 None,
2247 None,
2248 &accessible_workdir,
2249 &capsule_name,
2250 &capsule_version,
2251 conversation_mode.clone(),
2252 None,
2253 None,
2257 Some(terminating.clone()),
2260 )
2261 .await;
2262 break 'task_loop;
2263 }
2264 }
2265 };
2266 lanes.push(arrived);
2269 }
2270 }
2271 }
2272 };
2273
2274 let cancel_signal = {
2280 let mut reg = task_registry.lock().unwrap();
2281 if reg.is_canceled(&incoming.task_id) {
2282 None
2283 } else {
2284 reg.start_task(
2285 incoming.task_id.clone(),
2286 incoming.context_id.clone(),
2287 incoming_lane,
2288 );
2289 Some(reg.cancel_watch(&incoming.task_id))
2290 }
2291 };
2292 let Some(cancel_signal) = cancel_signal else {
2293 record_canceled_before_start(
2294 &incoming,
2295 &mut trace,
2296 &detached,
2297 &live_delegations,
2298 &Some((sse_tx.clone(), Arc::clone(&sse_buffer))),
2299 &mut queued_cancel_event_id,
2300 )
2301 .await;
2302 continue 'task_loop;
2303 };
2304 if let Err(e) =
2305 tokio::fs::write(&workdir_task_md, &incoming.message_text).await
2306 {
2307 eprintln!(
2308 "[capsule-runtime] failed to write A2A message to task.md: {e}"
2309 );
2310 }
2311 if incoming.source == crate::a2a::SOURCE_A2A {
2315 let _ = trace
2316 .write_a2a_task_received(
2317 &incoming.task_id,
2318 &incoming.context_id,
2319 &incoming.message_id,
2320 incoming.traceparent.as_deref(),
2321 )
2322 .await;
2323 }
2324 let _ = trace
2325 .write_task_start(
2326 &incoming.task_id,
2327 &incoming.context_id,
2328 incoming.source,
2329 incoming.provenance,
2330 incoming.delegation_id.as_deref(),
2331 incoming.message_text.len() as u64,
2332 )
2333 .await;
2334 if let Some(delegation_id) = &incoming.delegation_id {
2337 state.close_started_delegation(delegation_id).await;
2338 }
2339 let seed = hooks
2340 .dispatch_task_start(
2341 incoming.task_id.clone(),
2342 incoming.context_id.clone(),
2343 incoming.source.to_string(),
2344 incoming.message_text.len() as u64,
2345 seed_budget_tokens,
2346 u64::from(context_window),
2347 agent::prior_history_tokens(
2348 run_config.conversation_root.as_deref(),
2349 &workdir,
2350 &conversation_mode,
2351 Some(&incoming.context_id),
2352 run_config.resume.is_some(),
2353 ),
2354 )
2355 .await;
2356
2357 otel.begin_session(incoming.traceparent.as_deref());
2359 state.current_traceparent = otel.outgoing_traceparent();
2360 state.current_task_provenance = Some(incoming.provenance);
2361 state.current_context_id = Some(incoming.context_id.clone());
2362 state.a2a_task_id = Some(incoming.task_id.clone());
2363 let loop_result = run_task_with_reopens(
2364 &mut state,
2365 &workdir,
2366 inference,
2367 effective_lifecycle.max_task_reopens,
2368 system_prompt.clone(),
2369 run_config.clone(),
2370 &mut hooks,
2371 &mut trace,
2372 &mut otel,
2373 Some(incoming.task_id.clone()),
2374 Some((sse_tx.clone(), Arc::clone(&sse_buffer))),
2375 &accessible_workdir,
2376 &capsule_name,
2377 &capsule_version,
2378 conversation_mode.clone(),
2379 Some(incoming.context_id.clone()),
2380 &incoming.task_id,
2381 seed,
2382 Some(cancel_signal),
2383 )
2384 .await;
2385
2386 let exit_state = match &loop_result {
2394 Ok(AgentLoopExit::Canceled) => TaskState::Canceled,
2395 Ok(_) => TaskState::Completed,
2396 Err(_) => TaskState::Failed,
2397 };
2398 let _ = trace.flush().await;
2399 {
2400 let mut reg = task_registry.lock().unwrap();
2401 reg.finish_task(exit_state);
2402 reg.advance_resource_generation();
2405 }
2406
2407 if terminating.is_canceled() {
2410 final_loop_result = loop_result;
2411 close_lanes_on_termination(
2412 &mut lanes,
2413 &task_registry,
2414 &mut trace,
2415 &detached,
2416 &live_delegations,
2417 &Some((sse_tx.clone(), Arc::clone(&sse_buffer))),
2418 &mut queued_cancel_event_id,
2419 )
2420 .await;
2421 break 'task_loop;
2422 }
2423
2424 if closing_out {
2428 final_loop_result = loop_result;
2429 continue 'task_loop;
2430 }
2431 match effective_lifecycle.after_task {
2432 AfterTask::Exit => {
2433 final_loop_result = loop_result;
2434 break 'task_loop;
2435 }
2436 AfterTask::Sleep => {
2437 if matches!(
2438 effective_lifecycle.task_acceptance,
2439 TaskAcceptance::Single
2440 ) {
2441 final_loop_result = loop_result;
2443 break 'task_loop;
2444 }
2445 let _ = tokio::fs::remove_file(&workdir_task_md).await;
2447 continue 'task_loop;
2448 }
2449 }
2450 }
2451 let session_exit_status = match &final_loop_result {
2460 Ok(exit) => exit.as_str(),
2461 Err(_) => "failed",
2462 };
2463 let session_total_turns = hooks.total_turns();
2464 hooks
2465 .emit(
2466 &workdir,
2467 HookEvent::SessionEnd {
2468 total_turns: session_total_turns,
2469 exit_status: session_exit_status.to_string(),
2470 },
2471 )
2472 .await;
2473
2474 hooks.drain_async_hooks().await;
2481 agent::flush_hook_dispatch_faults(&mut hooks, &mut trace).await;
2482
2483 let abandoned_at_ms = crate::trace::timestamp_ms();
2488 let mut abandoned: Vec<AbandonedWork> = Vec::new();
2494 for work in detached.outstanding() {
2495 abandoned.push(AbandonedWork {
2496 work_id: work.work_id,
2497 binary: work.binary,
2498 command: work.command,
2499 disposition: AbandonedDisposition::StillRunning {
2500 running_ms: abandoned_at_ms.saturating_sub(work.started_at_ms),
2501 },
2502 });
2503 }
2504 while let Ok(report) = completion_rx.try_recv() {
2510 match report {
2511 DetachedReport::Completed(completion) => {
2512 if abandoned
2513 .iter()
2514 .any(|work| work.work_id == completion.work_id)
2515 {
2516 continue;
2517 }
2518 let status = completion.status();
2519 abandoned.push(AbandonedWork {
2520 work_id: completion.work_id,
2521 binary: completion.binary,
2522 command: completion.command,
2523 disposition: AbandonedDisposition::FinishedTooLate {
2524 exit_code: completion.exit_code,
2525 duration_ms: completion.duration_ms,
2526 output_path: completion.output_path,
2527 output_bytes: completion.output_bytes,
2528 resource_limit: completion.resource_limit,
2529 wait_error: completion.error,
2530 status,
2531 },
2532 });
2533 }
2534 DetachedReport::Lost(_) => {}
2535 }
2536 }
2537 if !abandoned.is_empty() {
2541 let report = detached::abandonment_report_text(&session_id, &abandoned);
2542 eprintln!("{report}");
2543 agent::append_bootstrap_log(&workdir, &report);
2544 for work in &abandoned {
2545 let _ = trace
2546 .write_shell_abandoned(
2547 &work.work_id,
2548 &work.binary,
2549 &work.command,
2550 work.running_ms(),
2551 work.recovered(),
2552 )
2553 .await;
2554 }
2555 }
2556
2557 let _ = trace
2558 .write_session_end_if_not_ended(session_exit_status)
2559 .await;
2560 otel.emit_session_end_if_not_ended("failed").await;
2561 trace.flush().await.map_err(|e| {
2562 RuntimeError::AgentLoopFailed(format!("failed to flush trace: {e}"))
2563 })?;
2564
2565 let _ = shutdown_tx.send(());
2567 let _ = server_handle.await;
2568
2569 final_loop_result.map(|_| ())
2570 })
2571 .await
2572 });
2573
2574 loop_result?;
2575
2576 delegation.complete();
2577 roost_session.complete();
2578 return Ok(LaunchResult {
2579 session_id: session_id_ret,
2580 workdir: workdir_ret,
2581 });
2582 }
2583
2584 let capsule_component = staged
2586 .capsule_component
2587 .ok_or(RuntimeError::CapsuleCompile(
2588 "non-agent capsule has no compiled component".to_string(),
2589 ))?;
2590
2591 let mut linker = Linker::new(&staged.engine);
2592 wasmtime_wasi::p2::add_to_linker_async(&mut linker)
2593 .map_err(|err| RuntimeError::Runtime(err.to_string()))?;
2594 wasmtime_wasi_http::p2::add_only_http_to_linker_sync(&mut linker)
2595 .map_err(|err| RuntimeError::Runtime(err.to_string()))?;
2596
2597 {
2602 let iface = WIT_TOOL_REGISTRY_IFACE;
2603 linker
2604 .instance(iface)
2605 .map_err(|err| RuntimeError::Runtime(err.to_string()))?
2606 .func_wrap(
2607 "invoke",
2608 |mut store: wasmtime::StoreContextMut<'_, CapsuleStoreState>,
2609 (tool_name, input): (String, murmur::tool::run::ToolInput)|
2610 -> wasmtime::Result<(Result<murmur::tool::run::ToolResult, String>,)> {
2611 Ok((invoke::Host::invoke(store.data_mut(), tool_name, input),))
2612 },
2613 )
2614 .map_err(|err| RuntimeError::Runtime(err.to_string()))?;
2615 }
2616 manage::add_to_linker::<_, HasSelf<_>>(&mut linker, |state| state)
2617 .map_err(|err| RuntimeError::Runtime(err.to_string()))?;
2618 send::add_to_linker::<_, HasSelf<_>>(&mut linker, |state| state)
2619 .map_err(|err| RuntimeError::Runtime(err.to_string()))?;
2620
2621 let capsule_limits = staged.capability_policy.limits;
2623
2624 let state = CapsuleStoreState {
2625 table: ResourceTable::new(),
2626 wasi: build_wasi_ctx(
2627 &staged.accessible_workdir,
2628 None,
2633 None,
2634 None,
2635 &inference_env,
2636 &staged.capability_policy,
2637 )?,
2638 http: WasiHttpCtx::new(),
2639 http_hooks: NetworkPolicyHooks {
2640 network_allow_rules: network_allow_rules.clone(),
2641 inference_gateway: None,
2642 },
2643 network_allow_rules,
2644 peer_fetch_rules: Vec::new(),
2649 peer_plane: None,
2650 peer_own_audience: String::new(),
2651 peer_trace: None,
2652 plan_trace: None,
2655 plan_counter: AtomicU64::new(0),
2656 delegation: None,
2661 inference_env,
2662 inference_gateway: staged.inference_gateway.clone(),
2663 spend: Arc::clone(&staged.spend),
2664 engine: staged.engine.clone(),
2665 workdir: staged.workdir.clone(),
2666 accessible_workdir: staged.accessible_workdir.clone(),
2667 tool_components: staged.tool_components,
2668 artifact_grants: staged.artifact_grants,
2669 allowlisted_tools: staged.allowlisted_tools,
2670 installed_artifacts: staged.installed_artifacts,
2671 session_id: staged.session_id.clone(),
2672 pending_a2a_events: Vec::new(),
2673 capability_policy: staged.capability_policy,
2674 protected_paths: staged.protected_paths,
2675 tool_annotations: staged.tool_annotations,
2676 shell_enforcement: shell_enforcement.clone(),
2677 current_traceparent: None,
2678 current_task_provenance: None,
2679 current_context_id: None,
2680 live_delegations: Arc::new(crate::cancel::LiveDelegations::new()),
2681 detached: None,
2685 shell_grace_secs: 0,
2686 a2a_task_registry: None,
2687 a2a_sse: None,
2688 a2a_task_id: None,
2689 input_timeout_secs: None,
2690 a2a_chunk_event_id: Arc::new(AtomicU64::new(u64::MAX / 4)),
2691 a2a_chunks_emitted: Arc::new(AtomicBool::new(false)),
2692 registry: Arc::clone(&staged.registry),
2693 lock_path: staged.manifest_dir.join("murmur.lock"),
2694 driver_continuation_id: None,
2695 driver_continuation_context_id: None,
2696 driver_continuation_acked_len: 0,
2697 limits: capsule_limits.limiter(),
2698 };
2699
2700 let mut store = Store::new(&staged.engine, state);
2701 store.limiter(|state| &mut state.limits);
2704
2705 let rt = tokio::runtime::Builder::new_multi_thread()
2707 .enable_all()
2708 .build()
2709 .map_err(|e| RuntimeError::Runtime(format!("failed to create tokio runtime: {e}")))?;
2710
2711 rt.block_on(async {
2712 store.set_epoch_deadline(capsule_limits.deadline_ticks());
2714 let instantiated = linker
2715 .instantiate_async(&mut store, &capsule_component)
2716 .await;
2717 let instance = match instantiated {
2718 Ok(instance) => instance,
2719 Err(err) => return Err(capsule_guest_error(&err, &store.data().limits)),
2720 };
2721
2722 let capsule_iface =
2723 resolve_versioned_iface(&instance, &mut store, WIT_CAPSULE_IFACE_VERSIONED)
2724 .ok_or(RuntimeError::CapsuleExportMissing)?;
2725 let capsule_run = instance
2726 .get_export_index(&mut store, Some(&capsule_iface), "run")
2727 .and_then(|idx| instance.get_func(&mut store, idx))
2728 .ok_or(RuntimeError::CapsuleExportMissing)?;
2729
2730 let run = capsule_run
2731 .typed::<(), ()>(&store)
2732 .map_err(|err| RuntimeError::Runtime(err.to_string()))?;
2733
2734 store.set_epoch_deadline(capsule_limits.deadline_ticks());
2736 let called = run.call_async(&mut store, ()).await;
2737 if let Err(err) = called {
2738 return Err(capsule_guest_error(&err, &store.data().limits));
2739 }
2740
2741 Ok(())
2742 })?;
2743
2744 let pending = std::mem::take(&mut store.data_mut().pending_a2a_events);
2746 if !pending.is_empty() {
2747 rt.block_on(async {
2748 if let Ok(mut trace) = TraceWriter::open(
2749 &staged.workdir,
2750 staged.session_id.clone(),
2751 staged.capsule_name.clone(),
2752 staged.capsule_version.clone(),
2753 String::new(),
2754 Vec::new(),
2755 staged.scope_report.clone(),
2756 murmur_artifact::TraceCapture::None,
2759 None,
2760 false,
2761 None,
2762 None,
2763 None,
2764 None,
2765 )
2766 .await
2767 {
2768 for (peer_url, message_id, task_id, context_id, traceparent, trust) in pending {
2769 let _ = trace
2770 .write_a2a_send(
2771 &peer_url,
2772 &message_id,
2773 &task_id,
2774 &context_id,
2775 traceparent.as_deref(),
2776 trust,
2777 )
2778 .await;
2779 }
2780 let _ = trace.flush().await;
2781 }
2782 });
2783 }
2784
2785 on_url("");
2787
2788 delegation.complete();
2789 roost_session.complete();
2790 Ok(LaunchResult {
2791 session_id: staged.session_id,
2792 workdir: staged.workdir,
2793 })
2794}
2795
2796fn running_record_for(
2802 staged: &StagedSession,
2803 session_id: &str,
2804 capsule_url: &str,
2805) -> running::RunningRecord {
2806 let pid = std::process::id();
2807 running::RunningRecord {
2808 session_id: session_id.to_string(),
2809 url: capsule_url.to_string(),
2810 pid,
2811 process_start: running::process_start_token(pid).unwrap_or_default(),
2814 capsule_name: staged.capsule_name.clone(),
2815 capsule_version: staged.capsule_version.clone(),
2816 workdir: staged.workdir.clone(),
2817 outlives_launcher: !running::has_controlling_terminal(),
2818 started_at: chrono::Utc::now().to_rfc3339(),
2819 }
2820}
2821
2822fn write_running_record(
2828 record: &running::RunningRecord,
2829 workdir: &Path,
2830) -> Option<running::RunningGuard> {
2831 match running::RunningGuard::write(record) {
2832 Ok(guard) => Some(guard),
2833 Err(reason) => {
2834 let link = security_warning_link(W_SEC_023);
2835 let message = format!(
2836 "this session's record under ~/.murmur/running/ could not be written, so \
2837 `mur watch` and `mur cancel` cannot reach it by session address — only by the \
2838 URL it announces: {reason}"
2839 );
2840 eprintln!("[capsule-runtime] warning[{W_SEC_023}]: {message} ({link})");
2841 agent::append_bootstrap_log(
2842 workdir,
2843 &format!("[running-record] warning[{W_SEC_023}]: {message} ({link})"),
2844 );
2845 None
2846 }
2847 }
2848}
2849
2850struct RoostSession {
2857 registered: Option<(String, SpawnCredential)>,
2860 outcome: SessionOutcome,
2861}
2862
2863impl RoostSession {
2864 fn register(staged: &mut StagedSession) -> Result<Self, RuntimeError> {
2867 if staged.capability_policy.spawn_allow.is_empty() && staged.spawn_grant.is_none() {
2875 return Ok(Self {
2876 registered: None,
2877 outcome: SessionOutcome::Failed,
2879 });
2880 }
2881
2882 let roost_url = match std::env::var("MURMUR_ROOST_URL") {
2883 Ok(value) if !value.trim().is_empty() => value.trim().to_string(),
2884 _ => {
2885 return Err(RuntimeError::SpawnRegistrationFailed {
2886 roost_url: "<unset>".to_string(),
2887 reason: "MURMUR_ROOST_URL is not set".to_string(),
2888 })
2889 }
2890 };
2891
2892 let credential = crate::registration::register_session(
2893 &roost_url,
2894 &staged.session_id,
2895 &staged.capsule_name,
2896 &staged.capsule_version,
2897 staged.spawn_grant.as_ref(),
2898 )?;
2899 staged.set_spawn_credential(credential.clone());
2900 Ok(Self {
2901 registered: Some((roost_url, credential)),
2904 outcome: SessionOutcome::Failed,
2905 })
2906 }
2907
2908 fn endpoint(&self) -> Option<(&str, SpawnCredential)> {
2915 self.registered
2916 .as_ref()
2917 .map(|(url, credential)| (url.as_str(), credential.clone()))
2918 }
2919
2920 fn complete(&mut self) {
2921 self.outcome = SessionOutcome::Complete;
2922 }
2923}
2924
2925impl Drop for RoostSession {
2926 fn drop(&mut self) {
2927 if let Some((roost_url, credential)) = self.registered.take() {
2928 crate::registration::deregister_session(&roost_url, &credential, self.outcome);
2929 }
2930 }
2931}
2932
2933struct DelegationReport {
2945 handle: Option<SpawnerHandle>,
2947 capsule_name: String,
2948 capsule_version: String,
2949 session_id: String,
2950 accessible_workdir: PathBuf,
2953 session_workdir: PathBuf,
2955 started: Instant,
2956 status: crate::delegation::DelegationStatus,
2957}
2958
2959impl DelegationReport {
2960 fn open(staged: &StagedSession) -> Result<Self, RuntimeError> {
2962 Ok(Self {
2963 handle: staged.spawner.clone(),
2964 capsule_name: staged.capsule_name.clone(),
2965 capsule_version: staged.capsule_version.clone(),
2966 session_id: staged.session_id.clone(),
2967 accessible_workdir: staged.accessible_workdir.clone(),
2968 session_workdir: staged.workdir.clone(),
2969 started: Instant::now(),
2970 status: crate::delegation::DelegationStatus::Error,
2971 })
2972 }
2973
2974 fn complete(&mut self) {
2975 self.status = crate::delegation::DelegationStatus::Ok;
2976 }
2977
2978 fn result_path(&self) -> Option<String> {
2985 let relative = Path::new("out").join("result.txt");
2986 if self.accessible_workdir.join(&relative).is_file() {
2987 return Some("out/result.txt".to_string());
2988 }
2989 let session_result = self.session_workdir.join(&relative);
2990 if session_result.is_file() {
2991 return session_result
2992 .strip_prefix(&self.accessible_workdir)
2993 .ok()
2994 .map(|path| path.to_string_lossy().replace('\\', "/"));
2995 }
2996 None
2997 }
2998}
2999
3000impl Drop for DelegationReport {
3001 fn drop(&mut self) {
3009 let Some(handle) = self.handle.take() else {
3010 return;
3011 };
3012 let Some(address) = handle.report_to.clone() else {
3013 return;
3014 };
3015 let outcome = crate::delegation::DelegationOutcome {
3016 delegation_id: handle.delegation_id.clone(),
3017 capsule_name: self.capsule_name.clone(),
3018 capsule_version: self.capsule_version.clone(),
3019 session_id: self.session_id.clone(),
3020 status: self.status,
3021 result_path: self.result_path(),
3022 workdir: self.accessible_workdir.display().to_string(),
3023 duration_ms: self
3024 .started
3025 .elapsed()
3026 .as_millis()
3027 .try_into()
3028 .unwrap_or(u64::MAX),
3029 detail: None,
3032 reported_by: crate::delegation::Reporter::Child,
3033 delivered: false,
3034 delivery_error: None,
3035 };
3036 crate::delegation::report_completion(&handle, &address, outcome, &self.accessible_workdir);
3037 }
3038}
3039
3040fn read_prompt_file(manifest_dir: &Path, path: &str) -> Result<String, (PathBuf, std::io::Error)> {
3043 let prompt_path = manifest_dir.join(path);
3044 fs::read_to_string(&prompt_path).map_err(|source| (prompt_path, source))
3045}
3046
3047fn resolve_system_prompt(
3048 manifest_dir: &Path,
3049 workdir: &Path,
3050 inference: &murmur_artifact::InferenceConfig,
3051) -> Result<Option<String>, RuntimeError> {
3052 if let Some(prompt) = inference.system_prompt.as_ref() {
3053 return Ok(Some(prompt.clone()));
3054 }
3055
3056 if let Some(path) = inference.system_prompt_file.as_ref() {
3057 return read_prompt_file(manifest_dir, path)
3058 .map(Some)
3059 .map_err(|(prompt_path, source)| RuntimeError::SystemPromptFileRead {
3060 path: prompt_path.display().to_string(),
3061 source,
3062 });
3063 }
3064
3065 if let Some(art_name) = inference.system_prompt_artifact.as_ref() {
3066 let skill_path = workdir.join("tools").join(art_name).join("skill.md");
3067 return fs::read_to_string(&skill_path).map(Some).map_err(|source| {
3068 RuntimeError::SystemPromptArtifactRead {
3069 name: art_name.clone(),
3070 source,
3071 }
3072 });
3073 }
3074
3075 Ok(None)
3076}
3077
3078fn resolve_compaction_system_prompt(
3087 manifest_dir: &Path,
3088 compaction: Option<&murmur_artifact::CompactionConfig>,
3089) -> Result<Option<String>, RuntimeError> {
3090 let Some(compaction) = compaction else {
3091 return Ok(None);
3092 };
3093
3094 if let Some(prompt) = compaction.system_prompt.as_ref() {
3095 return Ok(Some(prompt.clone()));
3096 }
3097
3098 if let Some(path) = compaction.system_prompt_file.as_ref() {
3099 return read_prompt_file(manifest_dir, path)
3100 .map(Some)
3101 .map_err(
3102 |(prompt_path, source)| RuntimeError::CompactionSystemPromptFileRead {
3103 path: prompt_path.display().to_string(),
3104 source,
3105 },
3106 );
3107 }
3108
3109 Ok(None)
3110}
3111
3112fn capability_names(policy: &CapabilityPolicy) -> Vec<String> {
3113 let mut names = Vec::new();
3114 if !policy.network_allow.is_empty() {
3115 names.push("network".to_string());
3116 }
3117 if policy.filesystem_scope.is_some() {
3118 names.push("filesystem".to_string());
3119 }
3120 if !policy.shell_allow.is_empty() {
3121 names.push("shell".to_string());
3122 }
3123 names
3124}
3125
3126const BASH_NETWORK_BYPASS_WARNING: &str = "capabilities.shell.allow includes \"bash\" and \
3130capabilities.network.allow is non-empty, but network.allow does not constrain bash's own \
3131outbound connections on this platform.";
3132
3133pub(crate) fn warn_if_bash_network_bypass(workdir: &Path, policy: &CapabilityPolicy) {
3139 let has_bash = policy.shell_allow.iter().any(|binary| binary == "bash");
3140 if has_bash && !policy.network_allow.is_empty() {
3141 let link = security_warning_link(W_SEC_003);
3142 eprintln!("[capsule-runtime] warning[{W_SEC_003}]: {BASH_NETWORK_BYPASS_WARNING} ({link})");
3143 agent::append_bootstrap_log(
3144 workdir,
3145 &format!(
3146 "[capability-policy] warning[{W_SEC_003}]: {BASH_NETWORK_BYPASS_WARNING} ({link})"
3147 ),
3148 );
3149 }
3150}
3151
3152const NO_COMPLETION_LANE_WARNING: &str = "this capsule declares capabilities.spawn.allow, but \
3153its lifecycle block cannot receive a delegation's outcome: delegate-task returns as soon as the \
3154sub-capsule is running, and what the sub-capsule did arrives afterwards as a background task. \
3155Declare lifecycle.task_acceptance: queue with lifecycle.after_task: sleep, and a \
3156lifecycle.queue_depth covering how many delegations one turn issues, or every outcome this \
3157capsule delegates for will be posted to a session that has already exited.";
3158
3159pub(crate) fn unreachable_delegation_outcomes_warning(
3168 can_delegate: bool,
3169 lifecycle: &LifecycleConfig,
3170) -> Option<(&'static str, &'static str)> {
3171 (can_delegate && !lifecycle.can_receive_background_tasks())
3172 .then_some((W_SEC_020, NO_COMPLETION_LANE_WARNING))
3173}
3174
3175pub(crate) fn warn_for_unreachable_delegation_outcomes(
3177 workdir: &Path,
3178 can_delegate: bool,
3179 lifecycle: &LifecycleConfig,
3180) {
3181 if let Some((code, message)) = unreachable_delegation_outcomes_warning(can_delegate, lifecycle)
3182 {
3183 let link = security_warning_link(code);
3184 eprintln!("[capsule-runtime] warning[{code}]: {message} ({link})");
3185 agent::append_bootstrap_log(
3186 workdir,
3187 &format!("[capability-policy] warning[{code}]: {message} ({link})"),
3188 );
3189 }
3190}
3191
3192const NO_SHELL_COMPLETION_LANE_WARNING: &str = "this capsule declares \
3193capabilities.shell.allow, but its lifecycle block cannot receive a background command's \
3194completion: a shell command that outruns lifecycle.shell_grace_secs is demoted to the background, \
3195and its exit code and output path arrive afterwards as a background task. Declare \
3196lifecycle.task_acceptance: queue with lifecycle.after_task: sleep, or every command this capsule \
3197demotes will be discarded at session end and reported to the operator instead of to the agent.";
3198
3199pub(crate) fn unreachable_shell_completions_warning(
3208 can_run_shell: bool,
3209 lifecycle: &LifecycleConfig,
3210) -> Option<(&'static str, &'static str)> {
3211 (can_run_shell && !lifecycle.can_receive_background_tasks())
3212 .then_some((W_SEC_022, NO_SHELL_COMPLETION_LANE_WARNING))
3213}
3214
3215pub(crate) fn warn_for_unreachable_shell_completions(
3217 workdir: &Path,
3218 can_run_shell: bool,
3219 lifecycle: &LifecycleConfig,
3220) {
3221 if let Some((code, message)) = unreachable_shell_completions_warning(can_run_shell, lifecycle) {
3222 let link = security_warning_link(code);
3223 eprintln!("[capsule-runtime] warning[{code}]: {message} ({link})");
3224 agent::append_bootstrap_log(
3225 workdir,
3226 &format!("[capability-policy] warning[{code}]: {message} ({link})"),
3227 );
3228 }
3229}
3230
3231pub fn warn_on_interpreter_runtime_grants(grants: &[InterpreterRuntimeGrant]) {
3243 for grant in grants {
3244 let link = security_warning_link(W_SEC_009);
3245 let dirs = grant
3246 .dirs
3247 .iter()
3248 .map(|dir| {
3249 let list = if dir.list_dir { "list_dir" } else { "no-list" };
3250 format!("{} ({list})", dir.path)
3251 })
3252 .collect::<Vec<_>>()
3253 .join(", ");
3254 eprintln!(
3255 "[capsule-runtime] warning[{W_SEC_009}]: capabilities.shell.interpreter_runtime grants \
3256 '{}' host directories outside the workdir [{dirs}] — this couples the capsule to a \
3257 specific host distro/interpreter-version layout (e.g. /usr/lib/python3.11 breaks the \
3258 moment the host ships Python 3.12); the durable fix is the staged runtime bind-mount, \
3259 which this grant only bridges until ({link})",
3260 grant.binary
3261 );
3262 }
3263}
3264
3265pub fn warn_on_workdir_exec(workdir_exec: bool) {
3278 if !workdir_exec {
3279 return;
3280 }
3281 let link = security_warning_link(W_SEC_011);
3282 eprintln!(
3283 "[capsule-runtime] warning[{W_SEC_011}]: capabilities.filesystem.workdir_exec is true — \
3284 the session workdir keeps its Landlock Execute right, so anything the capsule writes \
3285 there can run regardless of capabilities.shell.allow; this capsule reports containment \
3286 class 'advisory' on every host, including a Landlock-capable one ({link})"
3287 );
3288}
3289
3290#[derive(Debug, Clone, PartialEq, Eq)]
3296pub struct SecretShapedEnvGrant {
3297 pub name: String,
3299 pub outlives_launcher: bool,
3301}
3302
3303pub fn secret_shaped_env_grants(
3319 policy: &CapabilityPolicy,
3320 lifecycle: &LifecycleConfig,
3321) -> Vec<SecretShapedEnvGrant> {
3322 let mut grants: Vec<SecretShapedEnvGrant> = Vec::new();
3323
3324 for name in &policy.env_allow {
3325 if crate::shell::credential_backstop_drops(name, &policy.shell_strip_env) {
3326 continue;
3327 }
3328 if !is_credential_shaped_env_name(name) {
3329 continue;
3330 }
3331 if grants.iter().any(|grant| &grant.name == name) {
3332 continue;
3333 }
3334 grants.push(SecretShapedEnvGrant {
3335 name: name.clone(),
3336 outlives_launcher: lifecycle.after_task == AfterTask::Sleep,
3337 });
3338 }
3339
3340 grants
3341}
3342
3343fn secret_shaped_env_grant_message(grant: &SecretShapedEnvGrant) -> String {
3345 let name = &grant.name;
3346 let held = if grant.outlives_launcher {
3347 "murmur does not broker this secret and cannot withdraw it, and lifecycle.after_task: \
3348 sleep keeps this capsule alive past the task that launched it, so it holds that value \
3349 with nothing left waiting on it"
3350 } else {
3351 "murmur does not broker this secret and cannot withdraw it: for as long as the capsule \
3352 runs, the capsule holds it"
3353 };
3354
3355 format!(
3356 "capabilities.env.allow names '{name}', a credential-shaped variable the credential \
3357 backstop does not drop — every WASM guest this capsule runs observes the host's value. \
3358 {held}"
3359 )
3360}
3361
3362pub fn warn_on_secret_shaped_env_grants(
3384 policy: &CapabilityPolicy,
3385 lifecycle: Option<&LifecycleConfig>,
3386 lifecycle_override: Option<&murmur_artifact::LifecycleOverride>,
3387) {
3388 let resolved = resolve_lifecycle(lifecycle.cloned(), lifecycle_override);
3389 for grant in secret_shaped_env_grants(policy, &resolved) {
3390 let link = security_warning_link(W_SEC_024);
3391 eprintln!(
3392 "[capsule-runtime] warning[{W_SEC_024}]: {} ({link})",
3393 secret_shaped_env_grant_message(&grant)
3394 );
3395 }
3396}
3397
3398pub fn warn_on_inference_endpoint_in_network_allow(
3407 policy: &CapabilityPolicy,
3408 inference: Option<&InferenceConfig>,
3409) {
3410 for entry in inference_endpoint_allow_entries(&policy.network_allow, inference) {
3411 let link = security_warning_link(W_SEC_025);
3412 eprintln!(
3413 "[capsule-runtime] warning[{W_SEC_025}]: capabilities.network.allow entry '{entry}' \
3414 names the inference endpoint; inference no longer uses it — the runtime reaches the \
3415 provider itself — so the entry now only grants tools, subprocesses and the driver \
3416 direct reach to that host without the key ({link})"
3417 );
3418 }
3419}
3420
3421pub fn warn_on_launch_only_inference_credential(
3431 inference: Option<&InferenceConfig>,
3432 credentials_file: Option<&Path>,
3433) {
3434 let Some(reference) = inference
3435 .filter(|inference| inference.transport == "http")
3436 .and_then(|inference| inference.api_key.as_ref())
3437 else {
3438 return;
3439 };
3440 let (source, name) = match reference {
3441 ApiKeyReference::Literal(_) => (
3442 "inference.api_key is written literally in murmur.yaml".to_string(),
3443 "<NAME>",
3444 ),
3445 ApiKeyReference::Environment(name) => {
3446 if credentials_file.is_some_and(|path| config_holds_credential(path, name))
3447 || std::env::var_os(name).is_none_or(|value| value.is_empty())
3448 {
3449 return;
3450 }
3451 (
3452 format!(
3453 "inference.api_key: ${{{name}}} is read from the environment variable {name}, \
3454 because credentials.{name} is not set in the global config"
3455 ),
3456 name.as_str(),
3457 )
3458 }
3459 };
3460 let link = security_warning_link(W_SEC_027);
3461 eprintln!(
3462 "[capsule-runtime] warning[{W_SEC_027}]: {source}, so the key is read once at launch and \
3463 this capsule cannot pick up a rotated key until it is restarted; store the key with \
3464 `mur config set -g credentials.{name} <key>` to have it re-read ({link})"
3465 );
3466}
3467
3468pub fn warn_on_machine_spend_ceiling_under_process_transport(
3476 machine_tokens_per_day: Option<u64>,
3477 inference: Option<&InferenceConfig>,
3478) {
3479 if machine_tokens_per_day.is_none()
3480 || !inference.is_some_and(|inference| inference.transport == "process")
3481 {
3482 return;
3483 }
3484 let link = security_warning_link(W_SEC_026);
3485 eprintln!(
3486 "[capsule-runtime] warning[{W_SEC_026}]: spend.machine_tokens_per_day is set and this \
3487 capsule uses transport: process — the CLI reaches its provider with its own credentials, \
3488 so murmur neither counts nor limits this capsule's spend ({link})"
3489 );
3490}
3491
3492fn inference_endpoint_allow_entries<'a>(
3496 network_allow: &'a [String],
3497 inference: Option<&InferenceConfig>,
3498) -> Vec<&'a str> {
3499 let Some(endpoint) = inference
3500 .filter(|inference| inference.transport == "http")
3501 .and_then(|inference| inference.endpoint.as_deref())
3502 else {
3503 return Vec::new();
3504 };
3505 let Some(target) = endpoint
3506 .parse::<http::Uri>()
3507 .ok()
3508 .and_then(|uri| RequestTarget::from_request(&uri, uri.scheme_str() == Some("https")))
3509 else {
3510 return Vec::new();
3511 };
3512 network_allow
3513 .iter()
3514 .filter(|entry| NetworkAllowRule::parse(entry).is_ok_and(|rule| rule.matches(&target)))
3515 .map(String::as_str)
3516 .collect()
3517}
3518
3519fn warn_on_advisory_read_only(read_only: &[String], shell_allow: &[String]) {
3533 for binary in crate::containment::read_only_advisory_for(read_only, shell_allow) {
3536 let link = security_warning_link(W_SEC_017);
3537 eprintln!(
3538 "[capsule-runtime] warning[{W_SEC_017}]: capabilities.filesystem.read_only is \
3539 declared and capabilities.shell.allow includes '{binary}', an interpreter that \
3540 can construct a write the dispatch check cannot read — the declaration is \
3541 advisory for that binary. It still holds for every tool call and for every \
3542 shell command whose write the dispatch check can identify ({link})"
3543 );
3544 }
3545}
3546
3547fn warn_on_unannotated_tool_schemas(installed_manifests: &[(String, String)]) {
3567 for (tool, manifest_yaml) in installed_manifests {
3568 let Some(schema) = crate::tool_annotations::schema_from_manifest_yaml(manifest_yaml) else {
3569 continue;
3570 };
3571 let properties = crate::tool_annotations::unannotated_path_properties(&schema);
3572 if properties.is_empty() {
3573 continue;
3574 }
3575 let named = properties
3576 .iter()
3577 .map(|property| format!("'{property}'"))
3578 .collect::<Vec<_>>()
3579 .join(", ");
3580 let (noun, pronoun) = if properties.len() == 1 {
3581 ("property", "it")
3582 } else {
3583 ("properties", "them")
3584 };
3585 let link = security_warning_link(W_SEC_018);
3586 eprintln!(
3587 "[capsule-runtime] warning[{W_SEC_018}]: capabilities.filesystem.read_only is \
3588 declared and the tool '{tool}' declares the {noun} {named} with no murmur \
3589 annotation in effect — calls naming {pronoun} are judged by key name. Annotate a \
3590 destination property with \"format\": \"{destination}\", any object the tool only \
3591 stores with \"format\": \"{opaque}\", and a destination derived from another \
3592 property — or the absence of any — with the schema-root \"{keyword}\" list ({link})",
3593 destination = crate::tool_annotations::FORMAT_DESTINATION,
3594 opaque = crate::tool_annotations::FORMAT_OPAQUE,
3595 keyword = crate::tool_annotations::KEYWORD_DESTINATIONS,
3596 );
3597 }
3598}
3599
3600pub fn warn_on_userns_restriction_disabled_host_wide(grant: Option<UsernsGrant>) {
3617 if grant != Some(UsernsGrant::RestrictionDisabledHostWide) {
3618 return;
3619 }
3620 let link = security_warning_link(W_SEC_013);
3621 eprintln!(
3622 "[capsule-runtime] warning[{W_SEC_013}]: kernel.apparmor_restrict_unprivileged_userns is \
3623 off on this host, so unprivileged user namespaces are unrestricted for every binary on \
3624 the machine, not just for mur — this is what makes sealed containment and the capsule \
3625 network namespace work here, and it is not the configuration murmur ships. To get the \
3626 narrow, mur-only grant instead: restore the knob to 1 (removing any \
3627 /etc/sysctl.d/*-userns.conf drop-in that sets it to 0), then install and load the shipped \
3628 profile with `sudo install -m 644 packaging/apparmor/{profile} {path} && sudo \
3629 apparmor_parser -r {path}`. Nothing is refused because of this ({link})",
3630 profile = crate::sealed::SEALED_APPARMOR_PROFILE_NAME,
3631 path = crate::sealed::SEALED_APPARMOR_PROFILE_PATH,
3632 );
3633}
3634
3635fn warn_on_inert_capsule_wide_state(state_declared: bool) {
3648 if !state_declared {
3649 return;
3650 }
3651 let link = security_warning_link(W_SEC_014);
3652 eprintln!(
3653 "[capsule-runtime] warning[{W_SEC_014}]: capsule-wide capabilities.state is declared, but \
3654 a durable state store is granted per artifact — nothing reads a top-level declaration, \
3655 so no store was created and no 'state' preopen exists. Move the block onto the tool, \
3656 driver or hook entry that needs it ({link})"
3657 );
3658}
3659
3660fn warn_on_inert_capsule_wide_conversation(conversation_declared: bool) {
3668 if !conversation_declared {
3669 return;
3670 }
3671 let link = security_warning_link(W_SEC_016);
3672 eprintln!(
3673 "[capsule-runtime] warning[{W_SEC_016}]: capsule-wide capabilities.conversation is \
3674 declared, but the murmur:conversation/read grant is per artifact — nothing reads a \
3675 top-level declaration, so no artifact can read the conversation record. Move the block \
3676 onto the hook entry that needs it ({link})"
3677 );
3678}
3679
3680fn warn_on_inert_hook_capabilities(
3692 hook_name: &str,
3693 capabilities: Option<&murmur_artifact::Capabilities>,
3694) {
3695 let inert = inert_capability_sub_blocks(capabilities);
3696 if !inert.is_empty() {
3697 let link = security_warning_link(W_SEC_006);
3698 eprintln!(
3699 "[capsule-runtime] warning[{W_SEC_006}]: hook '{hook_name}' declares capabilities.{} \
3700 which the runtime does not apply per-hook — only capabilities.network, \
3701 capabilities.filesystem, capabilities.state and capabilities.task_io govern a hook \
3702 ({link})",
3703 inert.join(", capabilities.")
3704 );
3705 }
3706}
3707
3708fn inert_capability_sub_blocks(
3712 capabilities: Option<&murmur_artifact::Capabilities>,
3713) -> Vec<&'static str> {
3714 let Some(capabilities) = capabilities else {
3715 return Vec::new();
3716 };
3717
3718 [
3719 ("shell", capabilities.shell.is_some()),
3720 ("spawn", capabilities.spawn.is_some()),
3721 ("env", capabilities.env.is_some()),
3722 ("limits", capabilities.limits.is_some()),
3723 ("resources", capabilities.resources.is_some()),
3727 ("containment", capabilities.containment.is_some()),
3730 ]
3731 .into_iter()
3732 .filter_map(|(name, present)| present.then_some(name))
3733 .collect()
3734}
3735
3736fn stage_artifact_grant(
3748 artifact: &ArtifactRequest,
3749 ceiling_network_allow_rules: &[NetworkAllowRule],
3750 capsule_name: &str,
3751 artifact_grants: &mut HashMap<String, ToolCapabilityGrant>,
3752) -> Result<(), RuntimeError> {
3753 if artifact.capabilities.is_none() && artifact.config.is_none() {
3754 return Ok(());
3755 }
3756 let capabilities = artifact.capabilities.as_ref();
3757
3758 let mut grant =
3765 ToolCapabilityGrant::derive(capabilities, ceiling_network_allow_rules, capsule_name)?;
3766 if let Some(store) = grant.state_store.as_deref() {
3770 grant.state_dir = Some(crate::state_store::ensure_state_store(store)?);
3771 }
3772 grant.config_json = artifact
3775 .config
3776 .as_ref()
3777 .map(|config| crate::artifact_config::lower_artifact_config(&artifact.name, config))
3778 .transpose()?;
3779 warn_on_out_of_ceiling_network_entries(&artifact.name, &grant.dropped_network_entries);
3780 warn_on_inert_tool_capabilities(&artifact.name, capabilities);
3781 artifact_grants.insert(artifact.name.clone(), grant);
3782 Ok(())
3783}
3784
3785fn warn_on_out_of_ceiling_network_entries(artifact_name: &str, dropped: &[String]) {
3791 if dropped.is_empty() {
3792 return;
3793 }
3794
3795 let link = security_warning_link(W_SEC_007);
3796 eprintln!(
3797 "[capsule-runtime] warning[{W_SEC_007}]: artifact '{artifact_name}' declares \
3798 capabilities.network.allow entries the capsule-wide ceiling does not allow ({}) — \
3799 they are dropped, not granted, because per-artifact capabilities can only narrow \
3800 ({link})",
3801 dropped.join(", ")
3802 );
3803}
3804
3805fn warn_on_inert_tool_capabilities(
3810 artifact_name: &str,
3811 capabilities: Option<&murmur_artifact::Capabilities>,
3812) {
3813 let inert = inert_capability_sub_blocks(capabilities);
3814 if !inert.is_empty() {
3815 let link = security_warning_link(W_SEC_008);
3816 eprintln!(
3817 "[capsule-runtime] warning[{W_SEC_008}]: artifact '{artifact_name}' declares \
3818 capabilities.{} which per-artifact narrowing does not apply — only \
3819 capabilities.network, capabilities.filesystem and capabilities.state apply to a tool \
3820 or driver ({link})",
3821 inert.join(", capabilities.")
3822 );
3823 }
3824}
3825
3826fn warn_on_unenforceable_native_capabilities(
3831 artifact_name: &str,
3832 capabilities: Option<&murmur_artifact::Capabilities>,
3833) {
3834 if capabilities.is_none() {
3835 return;
3836 }
3837
3838 let link = security_warning_link(W_SEC_008);
3839 eprintln!(
3840 "[capsule-runtime] warning[{W_SEC_008}]: artifact '{artifact_name}' declares \
3841 per-artifact 'capabilities:' but ships a native implementation — narrowing applies \
3842 only to WASM tools and drivers, so this grant is not enforced ({link})"
3843 );
3844}
3845
3846fn warn_on_inert_native_config(artifact_name: &str, config: Option<&serde_yaml::Value>) {
3852 if config.is_none() {
3853 return;
3854 }
3855
3856 let link = security_warning_link(W_SEC_015);
3857 eprintln!(
3858 "[capsule-runtime] warning[{W_SEC_015}]: artifact '{artifact_name}' declares 'config:' \
3859 but ships a native implementation — a native tool runs as a host subprocess and reads no \
3860 per-artifact config, so no MURMUR_ARTIFACT_CONFIG is delivered ({link})"
3861 );
3862}
3863
3864pub(crate) fn build_engine() -> Result<Engine, RuntimeError> {
3871 let mut config = Config::new();
3872 config.wasm_component_model(true);
3873 config.epoch_interruption(true);
3874 Engine::new(&config).map_err(|err| RuntimeError::Runtime(err.to_string()))
3875}
3876
3877fn capsule_guest_error(error: &wasmtime::Error, limiter: &ExecutionLimiter) -> RuntimeError {
3881 match classify_guest_failure(error, limiter) {
3882 GuestFailure::DeadlineExceeded { seconds } => {
3883 RuntimeError::CapsuleDeadlineExceeded { seconds }
3884 }
3885 GuestFailure::ResourceLimit { message } => RuntimeError::CapsuleResourceLimit { message },
3886 GuestFailure::Other => RuntimeError::CapsuleTrap(error.to_string()),
3887 }
3888}
3889
3890fn map_registry_error(name: &str, version: &str, error: RegistryError) -> RuntimeError {
3891 match error {
3892 RegistryError::NotFound { .. } => RuntimeError::artifact_not_found(name, version),
3893 RegistryError::IntegrityMismatch { .. } => {
3894 RuntimeError::artifact_integrity_failed(name, version)
3895 }
3896 other => RuntimeError::Runtime(other.to_string()),
3897 }
3898}
3899
3900fn build_wasi_ctx(
3926 workdir: &Path,
3927 filesystem_scope: Option<&str>,
3928 state_dir: Option<&Path>,
3929 config_json: Option<&str>,
3930 extra_env: &[(String, String)],
3931 policy: &CapabilityPolicy,
3932) -> Result<WasiCtx, RuntimeError> {
3933 let mut builder = WasiCtxBuilder::new();
3934 builder.inherit_stdio();
3935 for (key, value) in build_wasi_env_allowlist(policy) {
3936 builder.env(key, value);
3937 }
3938 if let Some(config_json) = config_json {
3939 builder.env(ARTIFACT_CONFIG_ENV, config_json);
3940 }
3941 for (key, value) in extra_env {
3942 builder.env(key, value);
3943 }
3944
3945 let preopen_root = match filesystem_scope {
3948 None => workdir.to_path_buf(),
3949 Some(scope) => resolve_scoped_dir(workdir, scope)?,
3950 };
3951
3952 builder
3953 .preopened_dir(&preopen_root, ".", DirPerms::all(), FilePerms::all())
3954 .map_err(|err| RuntimeError::wasi(preopen_root, err.to_string()))?;
3955
3956 if let Some(state_dir) = state_dir {
3957 builder
3958 .preopened_dir(
3959 state_dir,
3960 STATE_PREOPEN_NAME,
3961 DirPerms::all(),
3962 FilePerms::all(),
3963 )
3964 .map_err(|err| RuntimeError::wasi(state_dir.to_path_buf(), err.to_string()))?;
3965 }
3966
3967 Ok(builder.build())
3968}
3969
3970fn inference_env_pairs(
3975 inference: &murmur_artifact::InferenceConfig,
3976 gateway: Option<&InferenceGateway>,
3977) -> Vec<(String, String)> {
3978 let mut pairs = vec![
3979 (
3980 "MURMUR_INFERENCE_TRANSPORT".to_string(),
3981 inference.transport.clone(),
3982 ),
3983 (
3984 "MURMUR_INFERENCE_ENDPOINT".to_string(),
3985 gateway
3986 .map(InferenceGateway::driver_endpoint)
3987 .unwrap_or_default(),
3988 ),
3989 (
3990 "MURMUR_INFERENCE_MODEL".to_string(),
3991 inference.model.clone(),
3992 ),
3993 (
3994 "MURMUR_INFERENCE_DRIVER".to_string(),
3995 inference
3996 .driver
3997 .as_ref()
3998 .map(|d| d.artifact.clone())
3999 .unwrap_or_default(),
4000 ),
4001 ];
4002
4003 if let Some(config) = inference.driver.as_ref().and_then(|d| d.config.as_ref()) {
4004 pairs.push(("MURMUR_INFERENCE_DRIVER_CONFIG".to_string(), config.clone()));
4005 }
4006
4007 pairs
4008}
4009
4010pub(crate) async fn request_input_impl(
4015 task_id: String,
4016 prompt: String,
4017 task_registry: Arc<Mutex<TaskRegistry>>,
4018 sse: Option<(SseBroadcast, Arc<Mutex<SseEventBuffer>>)>,
4019 input_timeout_secs: Option<u64>,
4020) -> wasmtime::Result<String> {
4021 use std::time::Duration;
4022 use tokio::sync::oneshot;
4023
4024 let (tx, rx) = oneshot::channel::<String>();
4025 let cancel = {
4026 let mut reg = task_registry.lock().unwrap();
4027 reg.set_input_required(&task_id, prompt.clone(), tx)
4028 .map_err(|e| wasmtime::Error::msg(format!("request-input: {e}")))?;
4029 reg.cancel_watch(&task_id)
4030 };
4031
4032 let mut sse_event_id: u64 = u64::MAX / 2;
4034 emit_sse(
4035 &sse,
4036 &mut sse_event_id,
4037 "status",
4038 &TaskStatusUpdateEvent {
4039 id: task_id.clone(),
4040 context_id: None,
4041 status: StreamStatus {
4042 state: "input-required".into(),
4043 message: prompt.clone(),
4044 response: None,
4045 },
4046 r#final: false,
4047 },
4048 )
4049 .await;
4050
4051 let waited = async {
4055 match input_timeout_secs {
4056 Some(secs) => tokio::time::timeout(Duration::from_secs(secs), rx)
4057 .await
4058 .map_err(|_| ())
4059 .and_then(|r| r.map_err(|_| ())),
4060 None => rx.await.map_err(|_| ()),
4061 }
4062 };
4063 let result = tokio::select! {
4064 biased;
4065 () = cancel.canceled() => {
4066 cancel.note_phase(crate::cancel::PHASE_INPUT);
4067 Err(InputWaitEnd::Canceled)
4068 }
4069 waited = waited => waited.map_err(|()| InputWaitEnd::TimedOut),
4070 };
4071
4072 match result {
4073 Ok(text) => {
4074 emit_sse(
4075 &sse,
4076 &mut sse_event_id,
4077 "status",
4078 &TaskStatusUpdateEvent {
4079 id: task_id.clone(),
4080 context_id: None,
4081 status: StreamStatus {
4082 state: "working".into(),
4083 message: "resumed".into(),
4084 response: None,
4085 },
4086 r#final: false,
4087 },
4088 )
4089 .await;
4090 Ok(text)
4091 }
4092 Err(InputWaitEnd::Canceled) => Err(wasmtime::Error::msg("task-canceled")),
4096 Err(InputWaitEnd::TimedOut) => {
4097 {
4098 let mut reg = task_registry.lock().unwrap();
4099 reg.finish_task(TaskState::Failed);
4100 reg.advance_resource_generation();
4101 }
4102 emit_sse(
4103 &sse,
4104 &mut sse_event_id,
4105 "status",
4106 &TaskStatusUpdateEvent {
4107 id: task_id.clone(),
4108 context_id: None,
4109 status: StreamStatus {
4110 state: "failed".into(),
4111 message: "input-timeout".into(),
4112 response: None,
4113 },
4114 r#final: true,
4115 },
4116 )
4117 .await;
4118 Err(wasmtime::Error::msg("input-timeout"))
4119 }
4120 }
4121}
4122
4123enum InputWaitEnd {
4128 Canceled,
4129 TimedOut,
4130}
4131
4132pub(crate) struct NetworkPolicyHooks {
4133 pub(crate) network_allow_rules: Vec<NetworkAllowRule>,
4134 pub(crate) inference_gateway: Option<Arc<InferenceGateway>>,
4139}
4140
4141impl WasiHttpHooks for NetworkPolicyHooks {
4142 fn send_request(
4143 &mut self,
4144 request: hyper::Request<HyperOutgoingBody>,
4145 config: OutgoingRequestConfig,
4146 ) -> HttpResult<HostFutureIncomingResponse> {
4147 if let Some(gateway) = self.inference_gateway.as_ref() {
4148 if InferenceGateway::is_addressed_to_gateway(request.uri()) {
4149 if !gateway.spend.has_open_admission() {
4154 return Err(wasmtime_wasi_http::p2::HttpError::from(
4155 WasiHttpErrorCode::HttpRequestDenied,
4156 ));
4157 }
4158 let gateway = Arc::clone(gateway);
4159 return Ok(HostFutureIncomingResponse::pending(
4160 wasmtime_wasi::runtime::spawn(async move {
4161 Ok(gateway.send(request, config).await)
4162 }),
4163 ));
4164 }
4165 }
4166
4167 let target =
4168 RequestTarget::from_request(request.uri(), config.use_tls).ok_or_else(|| {
4169 wasmtime_wasi_http::p2::HttpError::from(WasiHttpErrorCode::HttpRequestDenied)
4170 })?;
4171
4172 if !self
4173 .network_allow_rules
4174 .iter()
4175 .any(|rule| rule.matches(&target))
4176 {
4177 return Err(wasmtime_wasi_http::p2::HttpError::from(
4178 WasiHttpErrorCode::HttpRequestDenied,
4179 ));
4180 }
4181
4182 Ok(wasmtime_wasi_http::p2::default_send_request(
4183 request, config,
4184 ))
4185 }
4186}
4187
4188pub(crate) struct CapsuleStoreState {
4189 pub(crate) limits: ExecutionLimiter,
4193 pub(crate) table: ResourceTable,
4194 pub(crate) wasi: WasiCtx,
4195 pub(crate) http: WasiHttpCtx,
4196 pub(crate) http_hooks: NetworkPolicyHooks,
4197 pub(crate) network_allow_rules: Vec<NetworkAllowRule>,
4198 pub(crate) peer_fetch_rules: Vec<NetworkAllowRule>,
4201 pub(crate) peer_plane: Option<Arc<crate::peer_handoff::PeerPlane>>,
4205 pub(crate) peer_own_audience: String,
4207 pub(crate) peer_trace: Option<Arc<crate::trace::ResourceTraceAppender>>,
4212 pub(crate) delegation: Option<Arc<crate::delegation_plane::DelegationPlane>>,
4217 pub(crate) plan_trace: Option<Arc<crate::trace::PlanTraceAppender>>,
4222 pub(crate) plan_counter: AtomicU64,
4225 pub(crate) inference_env: Vec<(String, String)>,
4226 pub(crate) inference_gateway: Option<Arc<InferenceGateway>>,
4230 pub(crate) spend: Arc<SpendMeter>,
4233 pub(crate) engine: Engine,
4234 pub(crate) workdir: PathBuf,
4235 pub(crate) accessible_workdir: PathBuf,
4236 pub(crate) tool_components: HashMap<String, Component>,
4237 pub(crate) artifact_grants: HashMap<String, ToolCapabilityGrant>,
4240 pub(crate) allowlisted_tools: HashSet<String>,
4241 pub(crate) installed_artifacts: Vec<InstalledArtifactSummary>,
4242 pub(crate) session_id: String,
4243 pub(crate) pending_a2a_events:
4246 Vec<(String, String, String, String, Option<String>, TrustClass)>,
4247 pub(crate) capability_policy: CapabilityPolicy,
4248 pub(crate) protected_paths: ProtectedPaths,
4253 pub(crate) tool_annotations: ToolAnnotationMap,
4257 pub(crate) shell_enforcement: sandbox::ShellEnforcement,
4261 pub(crate) current_traceparent: Option<String>,
4264 pub(crate) current_task_provenance: Option<TaskProvenance>,
4270 pub(crate) current_context_id: Option<String>,
4274 pub(crate) live_delegations: Arc<crate::cancel::LiveDelegations>,
4281 pub(crate) detached: Option<Arc<DetachedRegistry>>,
4286 pub(crate) shell_grace_secs: u64,
4289 pub(crate) a2a_task_registry: Option<Arc<Mutex<TaskRegistry>>>,
4292 pub(crate) a2a_sse: Option<(SseBroadcast, Arc<Mutex<SseEventBuffer>>)>,
4294 pub(crate) a2a_task_id: Option<String>,
4296 pub(crate) input_timeout_secs: Option<u64>,
4298 pub(crate) a2a_chunk_event_id: Arc<AtomicU64>,
4302 pub(crate) a2a_chunks_emitted: Arc<AtomicBool>,
4305 pub(crate) registry: Arc<dyn Registry>,
4307 pub(crate) lock_path: PathBuf,
4309 pub(crate) driver_continuation_id: Option<String>,
4315 pub(crate) driver_continuation_context_id: Option<String>,
4320 pub(crate) driver_continuation_acked_len: usize,
4323}
4324
4325impl CapsuleStoreState {
4326 pub(crate) fn task_cancel_signal(&self) -> Option<crate::cancel::CancelSignal> {
4331 let registry = self.a2a_task_registry.as_ref()?;
4332 let task_id = self.a2a_task_id.as_deref()?;
4333 Some(registry.lock().unwrap().cancel_watch(task_id))
4334 }
4335
4336 pub(crate) fn active_continuation(&self, context_id: Option<&str>) -> Option<(&str, usize)> {
4341 let id = self.driver_continuation_id.as_deref()?;
4342 if self.driver_continuation_context_id.as_deref() != context_id {
4343 return None;
4344 }
4345 Some((id, self.driver_continuation_acked_len))
4346 }
4347
4348 pub(crate) fn record_continuation(
4351 &mut self,
4352 id: String,
4353 context_id: Option<String>,
4354 acked_len: usize,
4355 ) {
4356 self.driver_continuation_id = Some(id);
4357 self.driver_continuation_context_id = context_id;
4358 self.driver_continuation_acked_len = acked_len;
4359 }
4360
4361 pub(crate) fn clear_continuation(&mut self) {
4364 self.driver_continuation_id = None;
4365 self.driver_continuation_context_id = None;
4366 self.driver_continuation_acked_len = 0;
4367 }
4368
4369 pub(crate) fn advance_continuation_acked_len(
4374 &mut self,
4375 context_id: Option<&str>,
4376 acked_len: usize,
4377 ) {
4378 if self.driver_continuation_id.is_some()
4379 && self.driver_continuation_context_id.as_deref() == context_id
4380 {
4381 self.driver_continuation_acked_len = acked_len;
4382 }
4383 }
4384}
4385
4386impl WasiView for CapsuleStoreState {
4387 fn ctx(&mut self) -> WasiCtxView<'_> {
4388 WasiCtxView {
4389 ctx: &mut self.wasi,
4390 table: &mut self.table,
4391 }
4392 }
4393}
4394
4395impl WasiHttpView for CapsuleStoreState {
4396 fn http(&mut self) -> WasiHttpCtxView<'_> {
4397 WasiHttpCtxView {
4398 ctx: &mut self.http,
4399 table: &mut self.table,
4400 hooks: &mut self.http_hooks,
4401 }
4402 }
4403}
4404
4405impl invoke::Host for CapsuleStoreState {
4406 fn invoke(
4407 &mut self,
4408 name: String,
4409 input: murmur::tool::run::ToolInput,
4410 ) -> Result<murmur::tool::run::ToolResult, String> {
4411 if !self.allowlisted_tools.contains(&name) {
4412 return Err(format!(
4413 "tool '{name}' is not declared in manifest allowlist"
4414 ));
4415 }
4416 tokio::task::block_in_place(|| {
4417 tokio::runtime::Handle::current().block_on(self.dispatch_tool_async(&name, input))
4418 })
4419 }
4420}
4421
4422impl send::Host for CapsuleStoreState {
4423 fn send(
4424 &mut self,
4425 peer_url: String,
4426 message: send::Message,
4427 ) -> Result<send::TaskResult, String> {
4428 check_destination_allowed(
4429 &self.network_allow_rules,
4430 &peer_url,
4431 "capabilities.network.allow",
4432 )?;
4433
4434 let message_id = message.message_id.clone();
4435 let outgoing_msg = outgoing::OutgoingMessage {
4436 message_id: message.message_id,
4437 context_id: message.context_id,
4438 text: message.text,
4439 };
4440
4441 let traceparent = self.current_traceparent.clone();
4444 let sender_task = self.current_task_provenance;
4448 let task = tokio::task::block_in_place(|| {
4449 tokio::runtime::Handle::current().block_on(outgoing::send_a2a_message(
4450 &peer_url,
4451 outgoing_msg,
4452 traceparent.clone(),
4453 sender_task,
4454 ))
4455 })?;
4456
4457 self.pending_a2a_events.push((
4458 peer_url.clone(),
4459 message_id,
4460 task.id.clone(),
4461 task.context_id.clone(),
4462 traceparent,
4463 stamp_for_peer(sender_task).trust(),
4464 ));
4465 Ok(send::TaskResult {
4466 task_id: task.id,
4467 context_id: task.context_id,
4468 state: match task.status.state {
4469 crate::a2a::TaskState::Submitted => "submitted".to_string(),
4470 crate::a2a::TaskState::Working => "working".to_string(),
4471 crate::a2a::TaskState::InputRequired => "input-required".to_string(),
4472 crate::a2a::TaskState::Completed => "completed".to_string(),
4473 crate::a2a::TaskState::Failed => "failed".to_string(),
4474 crate::a2a::TaskState::Rejected => "rejected".to_string(),
4475 crate::a2a::TaskState::Canceled => "canceled".to_string(),
4476 },
4477 })
4478 }
4479}
4480
4481impl manage::Host for CapsuleStoreState {
4482 fn list(&mut self) -> Vec<manage::ArtifactSummary> {
4483 let mut result: Vec<manage::ArtifactSummary> = self
4484 .installed_artifacts
4485 .iter()
4486 .filter(|a| a.runtime.is_llm_visible())
4487 .map(|artifact| manage::ArtifactSummary {
4488 name: artifact.name.clone(),
4489 version: artifact.version.clone(),
4490 runtime: runtime_type_to_wit(&artifact.runtime, artifact.implementation.as_ref()),
4491 })
4492 .collect();
4493
4494 let existing: std::collections::HashSet<String> =
4495 result.iter().map(|a| a.name.clone()).collect();
4496 for binary in &self.capability_policy.shell_allow {
4497 if !existing.contains(binary) {
4498 result.push(manage::ArtifactSummary {
4499 name: binary.clone(),
4500 version: "0.0.0".to_string(),
4501 runtime: manage::RuntimeType::Native,
4502 });
4503 }
4504 }
4505
4506 result
4507 }
4508
4509 fn describe(&mut self, name: String) -> Result<manage::ArtifactInfo, String> {
4510 let fallback_summary;
4512 let summary = if let Some(s) = self
4513 .installed_artifacts
4514 .iter()
4515 .find(|artifact| artifact.name == name)
4516 {
4517 s
4518 } else if self
4519 .capability_policy
4520 .shell_allow
4521 .iter()
4522 .any(|b| b == &name)
4523 {
4524 fallback_summary = InstalledArtifactSummary {
4525 name: name.clone(),
4526 version: "0.0.0".to_string(),
4527 runtime: ArtifactRuntime::Tool,
4528 implementation: Some(ArtifactImplementation::Native),
4529 };
4530 &fallback_summary
4531 } else {
4532 return Err(format!("artifact '{name}' is not installed"));
4533 };
4534
4535 let manifest_path = self
4536 .workdir
4537 .join("tools")
4538 .join(&name)
4539 .join(PACKED_MANIFEST_ENTRY);
4540
4541 read_artifact_info_from_manifest(&manifest_path, summary)
4542 }
4543
4544 fn search(&mut self, query: String) -> Result<Vec<manage::ArtifactSummary>, String> {
4545 Err(format!("not implemented (query: {query})"))
4546 }
4547
4548 fn pull(&mut self, name: String, version: String) -> Result<manage::ArtifactSummary, String> {
4549 let resolved = self
4551 .registry
4552 .resolve_with_platform(&name, &version, Some(current_platform()))
4553 .map_err(|err| format!("failed to resolve {name}@{version}: {err}"))?;
4554
4555 verify_sha256(&name, &version, &resolved.bytes, &resolved.sha256).map_err(|_| {
4556 format!("artifact integrity check failed for {name}@{version}: registry-reported hash does not match downloaded bytes")
4557 })?;
4558
4559 let mut lock = match read_lockfile(&self.lock_path) {
4562 Ok(lock) => lock,
4563 Err(LockfileError::NotFound(_)) => MurmurLock {
4564 lock_version: LOCK_VERSION,
4565 artifacts: Vec::new(),
4566 },
4567 Err(err) => return Err(format!("failed to read murmur.lock: {err}")),
4568 };
4569
4570 let incoming_sha256 = if resolved.meta.runtime == RuntimeType::Native {
4574 LockedSha256::for_one_platform(current_platform(), resolved.sha256.clone())
4575 } else {
4576 LockedSha256::any(resolved.sha256.clone())
4577 };
4578
4579 if let Some(existing) = lock.artifact_for(&name) {
4580 if let Some(conflict) = existing.conflict_with(&version, &incoming_sha256) {
4581 return Err(format!(
4582 "murmur.lock conflict for '{name}': {conflict} — refusing to override a \
4583 pinned artifact at runtime"
4584 ));
4585 }
4586 }
4587
4588 let manifest_yaml = extract_manifest_yaml(&name, &version, &resolved.bytes)
4591 .map_err(|err| err.to_string())?;
4592 write_tool_manifest(&self.workdir, &name, &manifest_yaml).map_err(|err| err.to_string())?;
4593
4594 let (artifact_runtime, implementation, wasm_component) = match resolved.meta.runtime {
4595 RuntimeType::Wasm => {
4596 let wasm_bytes = extract_root_wasm(&name, &version, &resolved.bytes)
4597 .map_err(|err| err.to_string())?;
4598 let component = Component::new(&self.engine, &wasm_bytes)
4599 .map_err(|err| format!("failed to compile pulled component '{name}': {err}"))?;
4600 (
4601 ArtifactRuntime::Tool,
4602 Some(ArtifactImplementation::Wasm),
4603 Some(component),
4604 )
4605 }
4606 RuntimeType::Native => {
4607 let binary = extract_native_binary(&name, &version, &resolved.bytes)
4608 .map_err(|err| err.to_string())?;
4609 install_native_binaries(&self.workdir, vec![(name.clone(), binary)])
4610 .map_err(|err| err.to_string())?;
4611 (
4612 ArtifactRuntime::Tool,
4613 Some(ArtifactImplementation::Native),
4614 None,
4615 )
4616 }
4617 RuntimeType::Static => {
4618 let skill_md = extract_skill_md(&name, &version, &resolved.bytes)
4619 .map_err(|err| err.to_string())?;
4620 install_skill_files(&self.workdir, vec![(name.clone(), skill_md)])
4621 .map_err(|err| err.to_string())?;
4622 (ArtifactRuntime::Skill, None, None)
4623 }
4624 };
4625
4626 lock.upsert(&name, &version, incoming_sha256);
4628 write_lockfile_atomic(&self.lock_path, &lock)
4629 .map_err(|err| format!("failed to write murmur.lock: {err}"))?;
4630
4631 if let Some(component) = wasm_component {
4634 self.tool_components.insert(name.clone(), component);
4635 }
4636
4637 let summary = InstalledArtifactSummary {
4638 name: name.clone(),
4639 version: version.clone(),
4640 runtime: artifact_runtime,
4641 implementation,
4642 };
4643 if let Some(existing) = self
4644 .installed_artifacts
4645 .iter_mut()
4646 .find(|artifact| artifact.name == name)
4647 {
4648 *existing = summary.clone();
4649 } else {
4650 self.installed_artifacts.push(summary.clone());
4651 }
4652
4653 Ok(manage::ArtifactSummary {
4654 name,
4655 version,
4656 runtime: runtime_type_to_wit(&summary.runtime, summary.implementation.as_ref()),
4657 })
4658 }
4659
4660 fn remove(&mut self, name: String) -> Result<bool, String> {
4661 Err(format!("not implemented (name: {name})"))
4662 }
4663
4664 fn diagnostics(&mut self) -> Result<manage::RuntimeState, String> {
4665 Ok(manage::RuntimeState {
4666 capsule_id: self.session_id.clone(),
4667 installed: self.list(),
4668 capabilities: "artifact-manager/search and remove are not implemented".to_string(),
4669 })
4670 }
4671}
4672
4673fn gateway_for_store(
4677 gateway: Option<&Arc<InferenceGateway>>,
4678 name: &str,
4679) -> Option<Arc<InferenceGateway>> {
4680 gateway
4681 .filter(|gateway| gateway.driver_name == name)
4682 .cloned()
4683}
4684
4685fn stage_inference_gateway(
4696 inference: Option<&InferenceConfig>,
4697 credentials_file: Option<&Path>,
4698 installed_manifests: &[(String, String)],
4699 installed_artifacts: &[InstalledArtifactSummary],
4700 spend: &Arc<SpendMeter>,
4701) -> Result<Option<Arc<InferenceGateway>>, RuntimeError> {
4702 let Some(inference) = inference.filter(|inference| inference.transport == "http") else {
4703 return Ok(None);
4704 };
4705 let credential = inference
4706 .api_key
4707 .as_ref()
4708 .map(|reference| InferenceCredential::resolve(reference, credentials_file).map(Arc::new))
4709 .transpose()?;
4710 let (Some(driver), Some(endpoint)) = (inference.driver.as_ref(), inference.endpoint.as_deref())
4711 else {
4712 return Ok(None);
4713 };
4714 let Some((_, manifest_yaml)) = installed_manifests
4715 .iter()
4716 .find(|(name, _)| *name == driver.artifact)
4717 else {
4718 return Ok(None);
4719 };
4720 let refuse = |reason: Option<String>| RuntimeError::DriverDeclaresNoInferenceAuth {
4721 name: driver.artifact.clone(),
4722 version: installed_artifacts
4723 .iter()
4724 .find(|artifact| artifact.name == driver.artifact)
4725 .map(|artifact| artifact.version.clone())
4726 .unwrap_or_default(),
4727 reason,
4728 };
4729 let auth = match murmur_artifact::parse_inference_auth(manifest_yaml) {
4730 Ok(Some(auth)) => auth,
4731 Ok(None) => return Err(refuse(None)),
4732 Err(err) => return Err(refuse(Some(err.to_string()))),
4733 };
4734 InferenceGateway::new(
4735 driver.artifact.clone(),
4736 endpoint,
4737 auth,
4738 credential,
4739 Arc::clone(spend),
4740 )
4741 .map(|gateway| Some(Arc::new(gateway)))
4742}
4743
4744fn stage_spend_meter(
4752 inference: Option<&InferenceConfig>,
4753 machine_tokens_per_day: Option<u64>,
4754 session_id: &str,
4755) -> Result<Arc<SpendMeter>, RuntimeError> {
4756 let metered = inference.filter(|inference| {
4757 inference.transport == "http"
4758 && inference
4759 .driver
4760 .as_ref()
4761 .is_some_and(|driver| !driver.artifact.is_empty())
4762 });
4763 let machine = match (metered, machine_tokens_per_day) {
4764 (Some(_), Some(ceiling)) => Some(MachineLedger::open(session_id, ceiling)?),
4765 _ => None,
4766 };
4767 Ok(Arc::new(SpendMeter::new(
4768 metered.and_then(|inference| inference.max_session_tokens),
4769 machine,
4770 )))
4771}
4772
4773pub(crate) struct ToolInvokeEnv<'a> {
4778 pub(crate) engine: &'a Engine,
4779 pub(crate) accessible_workdir: &'a Path,
4780 pub(crate) inference_env: &'a [(String, String)],
4781 pub(crate) capability_policy: &'a CapabilityPolicy,
4782 pub(crate) network_allow_rules: &'a [NetworkAllowRule],
4785 pub(crate) artifact_grant: Option<&'a ToolCapabilityGrant>,
4789 pub(crate) inference_gateway: Option<&'a Arc<InferenceGateway>>,
4792}
4793
4794pub(crate) struct ToolA2aWiring {
4804 sse: Option<(SseBroadcast, Arc<Mutex<SseEventBuffer>>)>,
4805 task_id: Option<String>,
4806 chunk_event_id: Arc<AtomicU64>,
4807 chunks_emitted: Arc<AtomicBool>,
4808 task_registry: Option<Arc<Mutex<TaskRegistry>>>,
4809 input_timeout_secs: Option<u64>,
4810}
4811
4812impl ToolA2aWiring {
4813 pub(crate) fn silent() -> Self {
4814 Self {
4815 sse: None,
4816 task_id: None,
4817 chunk_event_id: Arc::new(AtomicU64::new(0)),
4818 chunks_emitted: Arc::new(AtomicBool::new(false)),
4819 task_registry: None,
4820 input_timeout_secs: None,
4821 }
4822 }
4823}
4824
4825pub(crate) async fn invoke_tool_component(
4834 env: ToolInvokeEnv<'_>,
4835 a2a: ToolA2aWiring,
4836 name: &str,
4837 component: &Component,
4838 input: murmur::tool::run::ToolInput,
4839) -> Result<murmur::tool::run::ToolResult, String> {
4840 let ToolInvokeEnv {
4841 engine,
4842 accessible_workdir,
4843 inference_env,
4844 capability_policy,
4845 network_allow_rules,
4846 artifact_grant,
4847 inference_gateway,
4848 } = env;
4849 let ToolA2aWiring {
4850 sse: a2a_sse,
4851 task_id: a2a_task_id,
4852 chunk_event_id: a2a_chunk_event_id,
4853 chunks_emitted: a2a_chunks_emitted,
4854 task_registry: a2a_task_registry,
4855 input_timeout_secs,
4856 } = a2a;
4857
4858 let mut linker = Linker::new(engine);
4859 wasmtime_wasi::p2::add_to_linker_async(&mut linker)
4860 .map_err(|err| format!("failed to add WASI linker for tool '{name}': {err}"))?;
4861 wasmtime_wasi_http::p2::add_only_http_to_linker_sync(&mut linker)
4862 .map_err(|err| format!("failed to add HTTP linker for tool '{name}': {err}"))?;
4863
4864 {
4870 let chunks_iface = WIT_TEXT_CHUNKS_IFACE;
4871 let sse_for_chunk = a2a_sse.clone();
4872 let task_id_for_chunk = a2a_task_id.clone();
4873 let chunk_event_id = Arc::clone(&a2a_chunk_event_id);
4874 let chunks_emitted_flag = Arc::clone(&a2a_chunks_emitted);
4875 let sse_for_thinking = a2a_sse.clone();
4876 let task_id_for_thinking = a2a_task_id.clone();
4877 let thinking_event_id = Arc::clone(&a2a_chunk_event_id);
4878
4879 let mut inst = linker.instance(chunks_iface).map_err(|err| {
4880 format!("failed to define {chunks_iface} instance for '{name}': {err}")
4881 })?;
4882
4883 inst.func_wrap(
4884 "emit-chunk",
4885 move |_store: wasmtime::StoreContextMut<'_, ToolStoreState>, (chunk,): (String,)| {
4886 chunks_emitted_flag.store(true, Ordering::Relaxed);
4887 if let (Some((ref tx, ref buf)), Some(ref tid)) =
4888 (&sse_for_chunk, &task_id_for_chunk)
4889 {
4890 emit_chunk_sse(tx, buf, &chunk_event_id, tid, &chunk);
4891 }
4892 Ok(())
4893 },
4894 )
4895 .map_err(|err| format!("failed to register emit-chunk for tool '{name}': {err}"))?;
4896
4897 inst.func_wrap(
4898 "emit-thinking-chunk",
4899 move |_store: wasmtime::StoreContextMut<'_, ToolStoreState>, (chunk,): (String,)| {
4900 if let (Some((ref tx, ref buf)), Some(ref tid)) =
4901 (&sse_for_thinking, &task_id_for_thinking)
4902 {
4903 emit_thinking_chunk_sse(tx, buf, &thinking_event_id, tid, &chunk);
4904 }
4905 Ok(())
4906 },
4907 )
4908 .map_err(|err| {
4909 format!("failed to register emit-thinking-chunk for tool '{name}': {err}")
4910 })?;
4911 }
4912
4913 {
4917 let task_iface = WIT_TASK_IFACE;
4918 let ri_task_registry = a2a_task_registry.clone();
4919 let ri_sse = a2a_sse.clone();
4920 let ri_task_id = a2a_task_id.clone();
4921 let ri_timeout = input_timeout_secs;
4922 linker
4923 .instance(task_iface)
4924 .map_err(|err| format!("failed to define {task_iface} instance for '{name}': {err}"))?
4925 .func_wrap_async(
4926 "request-input",
4927 move |_store: wasmtime::StoreContextMut<'_, ToolStoreState>,
4928 (prompt,): (String,)| {
4929 let reg = ri_task_registry.clone();
4930 let sse = ri_sse.clone();
4931 let tid = ri_task_id.clone();
4932 let fut: std::pin::Pin<
4933 Box<dyn std::future::Future<Output = wasmtime::Result<(String,)>> + Send>,
4934 > = Box::pin(async move {
4935 let result = match (reg, tid) {
4936 (Some(reg), Some(tid)) => {
4937 request_input_impl(tid, prompt, reg, sse, ri_timeout).await
4938 }
4939 _ => Err(wasmtime::Error::msg(
4940 "request-input is not available outside an A2A task context",
4941 )),
4942 };
4943 result.map(|s| (s,))
4944 });
4945 Box::new(fut)
4946 as Box<
4947 dyn std::future::Future<Output = wasmtime::Result<(String,)>>
4948 + Send
4949 + '_,
4950 >
4951 },
4952 )
4953 .map_err(|err| format!("failed to register request-input for tool '{name}': {err}"))?;
4954 }
4955
4956 let tool_limits = capability_policy.limits;
4957 let effective_network_rules = effective_tool_network_rules(artifact_grant, network_allow_rules);
4960 let filesystem_scope = artifact_grant.and_then(|grant| grant.filesystem_scope.as_deref());
4961 let state_dir = artifact_grant.and_then(|grant| grant.state_dir.as_deref());
4964 let config_json = artifact_grant.and_then(|grant| grant.config_json.as_deref());
4968 let state = ToolStoreState {
4969 limits: tool_limits.limiter(),
4970 table: ResourceTable::new(),
4971 wasi: build_wasi_ctx(
4972 accessible_workdir,
4973 filesystem_scope,
4974 state_dir,
4975 config_json,
4976 inference_env,
4977 capability_policy,
4978 )
4979 .map_err(|err| format!("failed to build WASI context for tool '{name}': {err}"))?,
4980 http: WasiHttpCtx::new(),
4981 http_hooks: NetworkPolicyHooks {
4982 network_allow_rules: effective_network_rules.to_vec(),
4983 inference_gateway: gateway_for_store(inference_gateway, name),
4984 },
4985 };
4986
4987 let mut store = Store::new(engine, state);
4988 store.limiter(|state| &mut state.limits);
4990
4991 store.set_epoch_deadline(tool_limits.deadline_ticks());
4992 let instance = linker
4993 .instantiate_async(&mut store, component)
4994 .await
4995 .map_err(|err| format!("failed to instantiate tool '{name}': {err}"))?;
4996
4997 let tool_iface = resolve_versioned_iface(&instance, &mut store, WIT_TOOL_IFACE_VERSIONED)
4998 .ok_or_else(|| {
4999 RuntimeError::ToolExportMissing {
5000 name: name.to_string(),
5001 }
5002 .to_string()
5003 })?;
5004 let tool_run = instance
5005 .get_export_index(&mut store, Some(&tool_iface), "run")
5006 .and_then(|idx| instance.get_func(&mut store, idx))
5007 .ok_or_else(|| {
5008 RuntimeError::ToolExportMissing {
5009 name: name.to_string(),
5010 }
5011 .to_string()
5012 })?;
5013
5014 let run = tool_run
5015 .typed::<(murmur::tool::run::ToolInput,), (murmur::tool::run::ToolResult,)>(&store)
5016 .map_err(|err| format!("failed to type-check tool '{name}' run export: {err}"))?;
5017
5018 store.set_epoch_deadline(tool_limits.deadline_ticks());
5021 let called = run.call_async(&mut store, (input,)).await;
5022 let (result,) = match called {
5023 Ok(result) => result,
5024 Err(err) => {
5025 let failure = classify_guest_failure(&err, &store.data().limits);
5029 return Err(failure.message(&format!("tool '{name}'"), &err));
5030 }
5031 };
5032 Ok(result)
5033}
5034
5035fn fence_tool_result(name: &str, result: &mut murmur::tool::run::ToolResult) {
5044 let data = result.data.take();
5045 let summary = result.summary.take();
5046 let text = data
5047 .or(summary)
5048 .unwrap_or_else(|| "tool returned no data".to_string());
5049 result.data = Some(crate::fence::wrap_untrusted(
5050 &crate::fence::tool_source(name),
5051 &text,
5052 ));
5053}
5054
5055impl CapsuleStoreState {
5056 pub(crate) async fn dispatch_tool_async(
5060 &self,
5061 name: &str,
5062 input: murmur::tool::run::ToolInput,
5063 ) -> Result<murmur::tool::run::ToolResult, String> {
5064 self.dispatch_component_async(name, input, None).await
5065 }
5066
5067 pub(crate) async fn dispatch_driver_async(
5070 &self,
5071 name: &str,
5072 input: murmur::tool::run::ToolInput,
5073 ) -> Result<murmur::tool::run::ToolResult, String> {
5074 self.dispatch_component_async(name, input, self.inference_gateway.as_ref())
5075 .await
5076 }
5077
5078 async fn dispatch_component_async(
5079 &self,
5080 name: &str,
5081 input: murmur::tool::run::ToolInput,
5082 inference_gateway: Option<&Arc<InferenceGateway>>,
5083 ) -> Result<murmur::tool::run::ToolResult, String> {
5084 let Some(component) = self.tool_components.get(name) else {
5085 return Err(format!("tool '{name}' is not available in this session"));
5086 };
5087 invoke_tool_component(
5088 ToolInvokeEnv {
5089 engine: &self.engine,
5090 accessible_workdir: &self.accessible_workdir,
5091 inference_env: &self.inference_env,
5092 capability_policy: &self.capability_policy,
5093 network_allow_rules: &self.network_allow_rules,
5094 artifact_grant: self.artifact_grants.get(name),
5098 inference_gateway,
5099 },
5100 ToolA2aWiring {
5101 sse: self.a2a_sse.clone(),
5102 task_id: self.a2a_task_id.clone(),
5103 chunk_event_id: Arc::clone(&self.a2a_chunk_event_id),
5104 chunks_emitted: Arc::clone(&self.a2a_chunks_emitted),
5105 task_registry: self.a2a_task_registry.clone(),
5106 input_timeout_secs: self.input_timeout_secs,
5107 },
5108 name,
5109 component,
5110 input,
5111 )
5112 .await
5113 }
5114
5115 pub(crate) fn resolve_call(
5125 &self,
5126 name: &str,
5127 input: &murmur::tool::run::ToolInput,
5128 ) -> ResolvedCall {
5129 let as_tool = || {
5130 let json = input.data.clone().unwrap_or_default();
5131 ResolvedCall::Tool {
5132 tool_name: name.to_string(),
5133 input_bytes: json.len() as u64,
5134 input: json,
5135 }
5136 };
5137 if name == SHARE_FILE_TOOL
5138 || name == FETCH_PEER_FILE_TOOL
5139 || name == DELEGATE_TASK_TOOL
5140 || name == SUBMIT_PLAN_TOOL
5141 {
5142 return as_tool();
5143 }
5144 let native_bin = self.workdir.join("tools").join(name).join(name);
5145 if native_bin.exists() && !self.tool_components.contains_key(name) {
5146 return as_tool();
5147 }
5148 match resolve_shell_call(
5149 name,
5150 input,
5151 &self.accessible_workdir,
5152 &self.capability_policy,
5153 ) {
5154 Some(ResolvedShellCall {
5155 binary,
5156 command,
5157 argv,
5158 script,
5159 recipe,
5160 }) => ResolvedCall::Shell {
5161 binary,
5162 command,
5163 argv,
5164 script,
5165 recipe,
5166 },
5167 None => as_tool(),
5168 }
5169 }
5170
5171 pub(crate) fn has_protected_paths(&self) -> bool {
5177 !self.protected_paths.is_empty()
5178 }
5179
5180 pub(crate) fn check_protected_paths(
5186 &self,
5187 call: &ResolvedCall,
5188 ) -> Option<ProtectedPathRefusal> {
5189 self.protected_paths
5190 .check_call(&self.accessible_workdir, call, &self.tool_annotations)
5191 }
5192
5193 pub(crate) async fn dispatch_agent_tool_async(
5202 &self,
5203 name: &str,
5204 input: murmur::tool::run::ToolInput,
5205 gate: Option<&mut crate::agent::CallGate<'_>>,
5206 ) -> Result<DispatchOutcome, String> {
5207 let mut outcome = self.dispatch_agent_tool_unfenced(name, input, gate).await?;
5208 if !outcome.is_skill {
5216 fence_tool_result(name, &mut outcome.result);
5217 }
5218 Ok(outcome)
5219 }
5220
5221 async fn dispatch_agent_tool_unfenced(
5232 &self,
5233 name: &str,
5234 input: murmur::tool::run::ToolInput,
5235 gate: Option<&mut crate::agent::CallGate<'_>>,
5236 ) -> Result<DispatchOutcome, String> {
5237 if name == SHARE_FILE_TOOL {
5242 return self
5243 .dispatch_share_file(input)
5244 .await
5245 .map(DispatchOutcome::tool);
5246 }
5247 if name == FETCH_PEER_FILE_TOOL {
5248 return self
5249 .dispatch_fetch_peer_file(input)
5250 .await
5251 .map(DispatchOutcome::tool);
5252 }
5253 if name == DELEGATE_TASK_TOOL {
5254 return self
5255 .dispatch_delegate_task(input)
5256 .await
5257 .map(DispatchOutcome::tool);
5258 }
5259 if name == SUBMIT_PLAN_TOOL {
5260 return self
5261 .dispatch_submit_plan(input, gate)
5262 .await
5263 .map(DispatchOutcome::tool);
5264 }
5265
5266 let native_bin = self.workdir.join("tools").join(name).join(name);
5268 if native_bin.exists() && !self.tool_components.contains_key(name) {
5269 return enforce_allowlist(&self.allowlisted_tools, name, || {
5270 dispatch_native_tool(
5271 name,
5272 input,
5273 &native_bin,
5274 &self.accessible_workdir,
5275 &self.workdir,
5276 &self.capability_policy,
5277 &self.shell_enforcement,
5278 )
5279 })
5280 .map(DispatchOutcome::tool);
5281 }
5282
5283 if self
5286 .capability_policy
5287 .shell_allow
5288 .iter()
5289 .any(|allowed| allowed == name)
5290 {
5291 let name = name.to_string();
5292 let accessible_workdir = self.accessible_workdir.clone();
5293 let session_workdir = self.workdir.clone();
5294 let env_overrides = self.inference_env.clone();
5295 let policy = self.capability_policy.clone();
5296 let enforcement = self.shell_enforcement.clone();
5297 let detach = match (&self.detached, &self.current_context_id) {
5302 (Some(registry), Some(context_id)) => Some(DetachPolicy {
5303 grace: std::time::Duration::from_secs(self.shell_grace_secs),
5304 registry: Arc::clone(registry),
5305 command: String::new(),
5306 context_id: context_id.clone(),
5307 provenance: self.current_task_provenance,
5308 }),
5309 _ => None,
5310 };
5311 return tokio::task::spawn_blocking(move || {
5312 dispatch_shell_tool(
5313 &name,
5314 input,
5315 &accessible_workdir,
5316 &session_workdir,
5317 &env_overrides,
5318 &policy,
5319 &enforcement,
5320 detach,
5321 )
5322 })
5323 .await
5324 .map_err(|e| format!("shell tool panicked: {e}"));
5325 }
5326
5327 let skill_md_path = self.workdir.join("tools").join(name).join("skill.md");
5330 if skill_md_path.exists() {
5331 let content = fs::read_to_string(&skill_md_path)
5332 .map_err(|e| format!("failed to read skill '{name}': {e}"))?;
5333 return Ok(DispatchOutcome::skill(murmur::tool::run::ToolResult {
5334 status: murmur::tool::run::Status::Passed,
5335 summary: Some(format!("Skill {name} guidance")),
5336 data: Some(content),
5337 data_path: None,
5338 truncated: false,
5339 metadata: Vec::new(),
5340 }));
5341 }
5342
5343 if !self.allowlisted_tools.contains(name) {
5345 return Err(format!(
5346 "tool '{name}' is not declared in manifest allowlist"
5347 ));
5348 }
5349 self.dispatch_tool_async(name, input)
5350 .await
5351 .map(DispatchOutcome::tool)
5352 }
5353
5354 async fn dispatch_share_file(
5364 &self,
5365 input: murmur::tool::run::ToolInput,
5366 ) -> Result<murmur::tool::run::ToolResult, String> {
5367 let args = parse_tool_json_input(SHARE_FILE_TOOL, &input)?;
5368 let path = required_string_arg(SHARE_FILE_TOOL, &args, "path")?;
5369 let peer = required_string_arg(SHARE_FILE_TOOL, &args, "peer")?;
5370 let ttl = args.get("ttl").and_then(serde_json::Value::as_str);
5371
5372 let Some(plane) = self.peer_plane.as_ref().filter(|plane| plane.is_declared()) else {
5373 return Err(format!(
5374 "'{SHARE_FILE_TOOL}' needs an exports.peer_files block in murmur.yaml; \
5375 this capsule declares none"
5376 ));
5377 };
5378
5379 let ttl_secs =
5380 match ttl {
5381 None => None,
5382 Some(text) => Some(murmur_artifact::parse_duration_secs(text).map_err(
5383 |message| format!("'{SHARE_FILE_TOOL}' was given an unusable ttl: {message}"),
5384 )?),
5385 };
5386
5387 let audience = match self.peer_audience_for(&peer).await {
5388 Ok(audience) => audience,
5389 Err(reason) => {
5390 self.trace_mint(
5391 None,
5392 &path,
5393 "",
5394 None,
5395 "peer_unreachable",
5396 Some(reason.clone()),
5397 )
5398 .await;
5399 return Err(reason);
5400 }
5401 };
5402
5403 match plane.mint_handle(&path, &audience, ttl_secs) {
5404 Ok(minted) => {
5405 self.trace_mint(
5406 Some(minted.handle_id.clone()),
5407 &minted.path,
5408 &minted.audience,
5409 Some(minted.expires_at_ms),
5410 "ok",
5411 None,
5412 )
5413 .await;
5414 Ok(json_tool_result(
5415 format!("Minted a handle for '{}' addressed to {}", path, audience),
5416 serde_json::json!({
5417 "handle": minted.handle,
5418 "handle_id": minted.handle_id,
5419 "expires_at_ms": minted.expires_at_ms,
5420 "audience": minted.audience,
5421 }),
5422 ))
5423 }
5424 Err(error) => {
5425 let reason = error.message();
5426 self.trace_mint(
5427 None,
5428 &path,
5429 &audience,
5430 None,
5431 error.code(),
5432 Some(reason.clone()),
5433 )
5434 .await;
5435 Err(format!(
5438 "'{SHARE_FILE_TOOL}' refused '{path}': {reason}. Only files under the \
5439 declared exports.peer_files.root may be shared."
5440 ))
5441 }
5442 }
5443 }
5444
5445 async fn write_delegation_start(&self, notice: &crate::delegation_plane::DelegationLaunch) {
5452 if let Some(trace) = &self.peer_trace {
5453 trace
5454 .write_delegation_start(
5455 ¬ice.delegation_id,
5456 ¬ice.capsule,
5457 ¬ice.version,
5458 ¬ice.child_session_id,
5459 ¬ice.child_workdir,
5460 )
5461 .await;
5462 }
5463 }
5464
5465 async fn close_started_delegation(&self, delegation_id: &str) {
5478 let Some(started) = self.live_delegations.finish(delegation_id) else {
5479 return;
5483 };
5484 let Some(trace) = &self.peer_trace else {
5485 return;
5486 };
5487 let recorded = crate::delegation::read_completion(&started.workdir);
5488 let (outcome, reason, child_session_id, duration_ms) = match recorded {
5489 Some(outcome) => (
5490 outcome.status.as_str().to_string(),
5491 outcome.detail.clone(),
5492 Some(outcome.session_id.clone()).filter(|id| !id.is_empty()),
5493 outcome.duration_ms,
5494 ),
5495 None => (
5496 "unknown".to_string(),
5497 Some(format!(
5498 "a completion arrived for this delegation, but no readable {} was left in {}",
5499 crate::delegation::COMPLETION_FILE,
5500 started.workdir.display()
5501 )),
5502 None,
5503 started
5504 .started
5505 .elapsed()
5506 .as_millis()
5507 .try_into()
5508 .unwrap_or(u64::MAX),
5509 ),
5510 };
5511 trace
5512 .write_delegation(
5513 &started.capsule,
5514 &started.version,
5515 Some(delegation_id.to_string()),
5516 child_session_id,
5517 duration_ms,
5518 &outcome,
5519 reason,
5520 )
5521 .await;
5522 }
5523
5524 async fn dispatch_delegate_task(
5543 &self,
5544 input: murmur::tool::run::ToolInput,
5545 ) -> Result<murmur::tool::run::ToolResult, String> {
5546 let args = parse_tool_json_input(DELEGATE_TASK_TOOL, &input)?;
5547 let request = crate::delegation_plane::DelegationRequest {
5548 capsule: required_string_arg(DELEGATE_TASK_TOOL, &args, "capsule")?,
5549 version: required_string_arg(DELEGATE_TASK_TOOL, &args, "version")?,
5550 task: required_string_arg(DELEGATE_TASK_TOOL, &args, "task")?,
5551 };
5552
5553 let Some(plane) = self.delegation.as_ref() else {
5554 return Err(format!(
5555 "'{DELEGATE_TASK_TOOL}' needs a capabilities.spawn.allow list in murmur.yaml; \
5556 this capsule declares none"
5557 ));
5558 };
5559
5560 let plane = Arc::clone(plane);
5565 let started = std::time::Instant::now();
5566 let (launch_tx, mut launch_rx) = tokio::sync::mpsc::unbounded_channel();
5573 let live = Arc::clone(&self.live_delegations);
5574 let child_root = self.accessible_workdir.clone();
5575 let origin = crate::delegation_plane::DelegationOrigin {
5576 context_id: self.current_context_id.clone().unwrap_or_default(),
5577 launched: Some(Arc::new(
5578 move |notice: crate::delegation_plane::DelegationLaunch| {
5579 live.register(
5583 notice.delegation_id.clone(),
5584 crate::cancel::LiveDelegation {
5585 workdir: child_root.join(¬ice.child_workdir),
5586 capsule: notice.capsule.clone(),
5587 version: notice.version.clone(),
5588 child_session_id: notice.child_session_id.clone(),
5589 child_workdir: notice.child_workdir.clone(),
5590 started: std::time::Instant::now(),
5591 },
5592 );
5593 let _ = launch_tx.send(notice);
5594 },
5595 )),
5596 trust: self.current_task_provenance.map(|task| task.trust()),
5600 };
5601 let cancel = self.task_cancel_signal();
5604 let mut starting = tokio::task::spawn_blocking(move || plane.start(&request, &origin));
5605 let mut notices_open = true;
5606 let joined = loop {
5607 tokio::select! {
5608 biased;
5609 () = async {
5610 match &cancel {
5611 Some(signal) => signal.canceled().await,
5612 None => std::future::pending().await,
5613 }
5614 } => {
5615 if let Some(signal) = &cancel {
5616 signal.note_phase(crate::cancel::PHASE_DELEGATION);
5617 }
5618 while let Ok(notice) = launch_rx.try_recv() {
5621 self.write_delegation_start(¬ice).await;
5622 }
5623 return Err(format!(
5624 "'{DELEGATE_TASK_TOOL}' was canceled while the sub-capsule was starting; \
5625 any child that came up is still running and is named in the cancel's \
5626 residue"
5627 ));
5628 }
5629 notice = launch_rx.recv(), if notices_open => match notice {
5630 Some(notice) => self.write_delegation_start(¬ice).await,
5631 None => notices_open = false,
5633 },
5634 joined = &mut starting => break joined,
5635 }
5636 };
5637 while let Ok(notice) = launch_rx.try_recv() {
5640 self.write_delegation_start(¬ice).await;
5641 }
5642 let result = joined.map_err(|error| format!("'{DELEGATE_TASK_TOOL}' panicked: {error}"))?;
5643
5644 let duration_ms = started.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
5645 let status = result.status;
5646 if status != crate::delegation_plane::DelegationStatus::Started {
5651 self.live_delegations.release(&result.delegation_id);
5655 if let Some(trace) = &self.peer_trace {
5656 trace
5657 .write_delegation(
5658 &result.capsule,
5659 &result.version,
5660 Some(result.delegation_id.clone()).filter(|id| !id.is_empty()),
5661 Some(result.session_id.clone()).filter(|id| !id.is_empty()),
5662 duration_ms,
5663 status.as_str(),
5664 Some(result.output.clone()),
5665 )
5666 .await;
5667 }
5668 }
5669
5670 if status == crate::delegation_plane::DelegationStatus::Refused {
5674 return Err(result.output);
5675 }
5676
5677 let summary = format!(
5678 "Delegated to {}@{}: {}",
5679 result.capsule,
5680 result.version,
5681 status.as_str()
5682 );
5683 let mut data = serde_json::json!({
5684 "delegation_id": result.delegation_id,
5685 "session_id": result.session_id,
5686 "capsule": result.capsule,
5687 "version": result.version,
5688 "status": status.as_str(),
5689 });
5690 match &result.child_workdir {
5691 Some(workdir) => {
5695 data["child_workdir"] = serde_json::Value::String(workdir.clone());
5696 }
5697 None => {
5699 data["output"] = serde_json::Value::String(result.output.clone());
5700 }
5701 }
5702 Ok(murmur::tool::run::ToolResult {
5703 status: if status == crate::delegation_plane::DelegationStatus::Started {
5704 murmur::tool::run::Status::Passed
5705 } else {
5706 murmur::tool::run::Status::Failed
5707 },
5708 summary: Some(summary),
5709 data: Some(data.to_string()),
5710 data_path: None,
5711 truncated: result.truncated,
5712 metadata: Vec::new(),
5713 })
5714 }
5715
5716 async fn dispatch_submit_plan(
5732 &self,
5733 input: murmur::tool::run::ToolInput,
5734 mut gate: Option<&mut crate::agent::CallGate<'_>>,
5735 ) -> Result<murmur::tool::run::ToolResult, String> {
5736 if !self.capability_policy.plan_submit {
5737 return Err(format!(
5738 "'{SUBMIT_PLAN_TOOL}' needs a capabilities.plan.submit: true declaration in \
5739 murmur.yaml; this capsule declares none"
5740 ));
5741 }
5742
5743 let args = parse_tool_json_input(SUBMIT_PLAN_TOOL, &input)?;
5744 let Some(serde_json::Value::Object(plan)) = args.get("plan") else {
5745 return Err(format!(
5746 "'{SUBMIT_PLAN_TOOL}' requires a 'plan' argument holding a JSON object with an \
5747 'id' and a 'steps' array"
5748 ));
5749 };
5750 let plan_id = plan
5751 .get("id")
5752 .and_then(serde_json::Value::as_str)
5753 .unwrap_or_default()
5754 .to_string();
5755 let plan_path = self.write_plan_file(plan)?;
5756
5757 let installed_tools: HashSet<String> = fs::read_dir(self.workdir.join("tools"))
5761 .into_iter()
5762 .flatten()
5763 .flatten()
5764 .filter(|entry| entry.path().is_dir())
5765 .filter_map(|entry| entry.file_name().into_string().ok())
5766 .filter(|name| name != SUBMIT_PLAN_TOOL)
5767 .collect();
5768
5769 let (request_tx, mut request_rx) = tokio::sync::mpsc::unbounded_channel::<PlanRequest>();
5773
5774 let gates = gate.as_ref().is_some_and(|gate| gate.gates(self));
5778
5779 let capability_policy = self.capability_policy.clone();
5780 let scheduler_workdir = self.accessible_workdir.clone();
5781 let scheduler_session_workdir = self.workdir.clone();
5782 let session_id = self.session_id.clone();
5783 let current_context_id = self.current_context_id.clone();
5786 let spawn_credential = self.delegation.as_ref().map(|plane| plane.credential());
5789 let plan_trace = self.plan_trace.clone();
5790 let registry = Arc::clone(&self.registry);
5791 let gate_tx = request_tx.clone();
5792 let mut scheduling = tokio::task::spawn_blocking(move || {
5793 let gate_step = move |call: &crate::plan::PlannedCall<'_>| -> Option<String> {
5794 if !gates {
5795 return None;
5796 }
5797 let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
5798 if gate_tx
5801 .send(PlanRequest::Gate(
5802 GatedStepCall::from_planned(call),
5803 reply_tx,
5804 ))
5805 .is_err()
5806 {
5807 return Some(PLAN_GATE_UNREACHABLE.to_string());
5808 }
5809 reply_rx
5810 .blocking_recv()
5811 .unwrap_or_else(|_| Some(PLAN_GATE_UNREACHABLE.to_string()))
5812 };
5813 let invoke_tool = move |name: &str, input: murmur::tool::run::ToolInput| {
5814 let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
5815 request_tx
5816 .send(PlanRequest::Invoke(name.to_string(), input, reply_tx))
5817 .map_err(|_| "the session stopped answering plan tool calls".to_string())?;
5818 reply_rx
5819 .blocking_recv()
5820 .map_err(|_| "the session stopped answering plan tool calls".to_string())?
5821 };
5822 let ctx = crate::plan::SchedulerContext {
5823 accessible_workdir: scheduler_workdir,
5824 session_workdir: scheduler_session_workdir,
5825 capability_policy,
5826 installed_tools,
5827 capsule_versions: HashMap::new(),
5831 current_session_id: Some(session_id),
5832 current_context_id,
5833 registry,
5834 spawn_credential,
5835 trace: plan_trace.as_deref(),
5836 gate_step: &gate_step,
5837 invoke_tool: &invoke_tool,
5838 };
5839 crate::plan::execute(&plan_path, &ctx)
5840 });
5841
5842 let mut requests_open = true;
5843 let joined = loop {
5844 tokio::select! {
5845 request = request_rx.recv(), if requests_open => match request {
5846 Some(request) => self.answer_plan_request(request, gate.as_deref_mut()).await,
5847 None => requests_open = false,
5849 },
5850 joined = &mut scheduling => break joined,
5851 }
5852 };
5853 while let Ok(request) = request_rx.try_recv() {
5856 self.answer_plan_request(request, gate.as_deref_mut()).await;
5857 }
5858 let report = joined.map_err(|error| format!("'{SUBMIT_PLAN_TOOL}' panicked: {error}"))?;
5859
5860 let mut succeeded = 0usize;
5861 let mut failed = 0usize;
5862 let mut skipped = 0usize;
5863 let steps: Vec<serde_json::Value> = report
5864 .results
5865 .iter()
5866 .map(|result| {
5867 match result.status {
5868 crate::plan::StepStatus::Success => succeeded += 1,
5869 crate::plan::StepStatus::Failed => failed += 1,
5870 crate::plan::StepStatus::Skipped => skipped += 1,
5871 }
5872 serde_json::json!({
5873 "step_id": result.step_id,
5874 "status": result.status.as_str(),
5875 "output": result.output,
5876 "error": result.error,
5877 })
5878 })
5879 .collect();
5880 let data = serde_json::json!({
5881 "plan_id": plan_id,
5882 "completed": report.completed,
5883 "failed_step": report.failed_step,
5884 "steps": steps,
5885 });
5886 let summary = format!(
5887 "Plan '{plan_id}': {} steps, {succeeded} succeeded, {failed} failed, {skipped} skipped",
5888 report.results.len()
5889 );
5890
5891 Ok(murmur::tool::run::ToolResult {
5892 status: if report.completed {
5895 murmur::tool::run::Status::Passed
5896 } else {
5897 murmur::tool::run::Status::Failed
5898 },
5899 summary: Some(summary),
5900 data: Some(data.to_string()),
5901 data_path: None,
5902 truncated: false,
5903 metadata: Vec::new(),
5904 })
5905 }
5906
5907 async fn answer_plan_request(
5913 &self,
5914 request: PlanRequest,
5915 gate: Option<&mut crate::agent::CallGate<'_>>,
5916 ) {
5917 match request {
5918 PlanRequest::Gate(call, reply) => {
5919 let verdict = match gate {
5920 Some(gate) => {
5921 let resolved = call.resolve(&self.accessible_workdir);
5922 match gate.check(self, &resolved).await {
5925 Ok(verdict) => verdict,
5926 Err(error) => Some(error.to_string()),
5927 }
5928 }
5929 None => None,
5930 };
5931 let _ = reply.send(verdict);
5932 }
5933 PlanRequest::Invoke(name, input, reply) => {
5934 let outcome = Box::pin(self.dispatch_agent_tool_unfenced(&name, input, gate))
5943 .await
5944 .map(|outcome| outcome.result);
5945 let _ = reply.send(outcome);
5946 }
5947 }
5948 }
5949
5950 fn write_plan_file(
5955 &self,
5956 plan: &serde_json::Map<String, serde_json::Value>,
5957 ) -> Result<PathBuf, String> {
5958 let plans_dir = self.workdir.join("plans");
5959 fs::create_dir_all(&plans_dir)
5960 .map_err(|error| format!("'{SUBMIT_PLAN_TOOL}' could not create plans/: {error}"))?;
5961 let ordinal = self.plan_counter.fetch_add(1, Ordering::Relaxed) + 1;
5962 let plan_path = plans_dir.join(format!("plan-{ordinal}.json"));
5963 let body = serde_json::Value::Object(plan.clone()).to_string();
5964 fs::write(&plan_path, body).map_err(|error| {
5965 format!(
5966 "'{SUBMIT_PLAN_TOOL}' could not write {}: {error}",
5967 plan_path.display()
5968 )
5969 })?;
5970 Ok(plan_path)
5971 }
5972
5973 async fn peer_audience_for(&self, peer: &str) -> Result<String, String> {
5975 check_destination_allowed(
5976 &self.network_allow_rules,
5977 peer,
5978 "capabilities.network.allow",
5979 )?;
5980 let card = crate::outgoing::fetch_agent_card(peer)
5981 .await
5982 .map_err(|error| format!("peer_unreachable: {error}"))?;
5983 crate::peer_handoff::audience_from_card(&card)
5984 .map_err(|error| format!("peer_unreachable: {error}"))
5985 }
5986
5987 #[allow(clippy::too_many_arguments)]
5988 async fn trace_mint(
5989 &self,
5990 handle_id: Option<String>,
5991 path: &str,
5992 audience: &str,
5993 expires_at_ms: Option<u64>,
5994 outcome: &str,
5995 reason: Option<String>,
5996 ) {
5997 if let Some(trace) = &self.peer_trace {
5998 trace
5999 .write_peer_handle_mint(handle_id, path, audience, expires_at_ms, outcome, reason)
6000 .await;
6001 }
6002 }
6003
6004 async fn dispatch_fetch_peer_file(
6011 &self,
6012 input: murmur::tool::run::ToolInput,
6013 ) -> Result<murmur::tool::run::ToolResult, String> {
6014 let args = parse_tool_json_input(FETCH_PEER_FILE_TOOL, &input)?;
6015 let peer = required_string_arg(FETCH_PEER_FILE_TOOL, &args, "peer")?;
6016 let handle = required_string_arg(FETCH_PEER_FILE_TOOL, &args, "handle")?;
6017 let handle_id = crate::peer_handoff::handle_id(&handle);
6018
6019 if let Err(reason) = check_destination_allowed(
6021 &self.peer_fetch_rules,
6022 &peer,
6023 "capabilities.peer_fetch.allow",
6024 ) {
6025 self.trace_fetch(
6026 &peer,
6027 &handle_id,
6028 None,
6029 "peer_not_allowed",
6030 Some(reason.clone()),
6031 )
6032 .await;
6033 return Err(reason);
6034 }
6035
6036 let response = match crate::outgoing::redeem_peer_handle(
6037 &peer,
6038 &handle,
6039 &self.peer_own_audience,
6040 )
6041 .await
6042 {
6043 Ok(response) => response,
6044 Err(error) => {
6045 self.trace_fetch(
6046 &peer,
6047 &handle_id,
6048 None,
6049 "peer_unreachable",
6050 Some(error.clone()),
6051 )
6052 .await;
6053 return Err(format!(
6054 "'{FETCH_PEER_FILE_TOOL}' could not reach {peer}: {error}"
6055 ));
6056 }
6057 };
6058
6059 if response.status != 200 {
6060 let code = serde_json::from_slice::<serde_json::Value>(&response.body)
6063 .ok()
6064 .and_then(|body| {
6065 body.get("error")
6066 .and_then(serde_json::Value::as_str)
6067 .map(str::to_string)
6068 })
6069 .unwrap_or_else(|| format!("http_{}", response.status));
6070 let reason = format!("{peer} refused the handle: {code}");
6071 self.trace_fetch(&peer, &handle_id, None, &code, Some(reason.clone()))
6072 .await;
6073 return Err(format!("'{FETCH_PEER_FILE_TOOL}' failed: {reason}"));
6074 }
6075
6076 let sha256 = murmur_artifact::sha256_hex(&response.body);
6077 if let Some(etag) = response.header("etag") {
6080 let expected = format!("\"sha256:{sha256}\"");
6081 if etag != expected {
6082 let reason = format!("{peer} served an etag that does not describe its own body");
6083 self.trace_fetch(
6084 &peer,
6085 &handle_id,
6086 None,
6087 "etag_mismatch",
6088 Some(reason.clone()),
6089 )
6090 .await;
6091 return Err(format!("'{FETCH_PEER_FILE_TOOL}' failed: {reason}"));
6092 }
6093 }
6094 let generation = response
6095 .header("x-murmur-generation")
6096 .and_then(|value| value.parse::<u64>().ok());
6097
6098 let basename = crate::peer_handoff::decode_payload_unverified(&handle).map(|p| p.p);
6101 let stored_path = crate::peer_handoff::stored_path_for(&handle_id, basename.as_deref());
6102 let absolute = self.accessible_workdir.join(&stored_path);
6103 if let Some(parent) = absolute.parent() {
6104 if let Err(error) = fs::create_dir_all(parent) {
6105 let reason = format!("failed to create {}: {error}", parent.display());
6106 self.trace_fetch(&peer, &handle_id, None, "io_error", Some(reason.clone()))
6107 .await;
6108 return Err(format!("'{FETCH_PEER_FILE_TOOL}' failed: {reason}"));
6109 }
6110 }
6111 if let Err(error) = fs::write(&absolute, &response.body) {
6112 let reason = format!("failed to write {stored_path}: {error}");
6113 self.trace_fetch(&peer, &handle_id, None, "io_error", Some(reason.clone()))
6114 .await;
6115 return Err(format!("'{FETCH_PEER_FILE_TOOL}' failed: {reason}"));
6116 }
6117
6118 if let Some(trace) = &self.peer_trace {
6119 trace
6120 .write_peer_file_fetch(
6121 &peer,
6122 &handle_id,
6123 Some(stored_path.clone()),
6124 Some(response.body.len() as u64),
6125 Some(sha256.clone()),
6126 "ok",
6127 None,
6128 )
6129 .await;
6130 }
6131
6132 let mut data = serde_json::json!({
6133 "path": stored_path,
6134 "bytes": response.body.len(),
6135 "sha256": sha256,
6136 "peer": peer,
6137 });
6138 if let Some(generation) = generation {
6139 data["generation"] = serde_json::json!(generation);
6140 }
6141 Ok(json_tool_result(
6142 format!(
6143 "Stored {} bytes from {peer} at {stored_path}",
6144 response.body.len()
6145 ),
6146 data,
6147 ))
6148 }
6149
6150 async fn trace_fetch(
6151 &self,
6152 peer: &str,
6153 handle_id: &str,
6154 stored_path: Option<String>,
6155 outcome: &str,
6156 reason: Option<String>,
6157 ) {
6158 if let Some(trace) = &self.peer_trace {
6159 trace
6160 .write_peer_file_fetch(peer, handle_id, stored_path, None, None, outcome, reason)
6161 .await;
6162 }
6163 }
6164}
6165
6166fn check_destination_allowed(
6173 rules: &[NetworkAllowRule],
6174 peer_url: &str,
6175 field: &str,
6176) -> Result<(), String> {
6177 let for_parse = if peer_url.contains("://") {
6178 peer_url.to_string()
6179 } else {
6180 format!("http://{peer_url}")
6181 };
6182 let uri: http::Uri = for_parse
6183 .parse()
6184 .map_err(|e| format!("invalid peer URL '{peer_url}': {e}"))?;
6185 let target = RequestTarget::from_request(&uri, false)
6186 .ok_or_else(|| format!("invalid peer URL '{peer_url}'"))?;
6187 if rules.iter().any(|rule| rule.matches(&target)) {
6188 return Ok(());
6189 }
6190 Err(format!("network policy: '{peer_url}' not in {field}"))
6191}
6192
6193fn parse_tool_json_input(
6195 tool: &str,
6196 input: &murmur::tool::run::ToolInput,
6197) -> Result<serde_json::Map<String, serde_json::Value>, String> {
6198 let raw = input.data.as_deref().unwrap_or("{}");
6199 let raw = if raw.trim().is_empty() { "{}" } else { raw };
6200 match serde_json::from_str::<serde_json::Value>(raw) {
6201 Ok(serde_json::Value::Object(map)) => Ok(map),
6202 _ => Err(format!("'{tool}' expects a JSON object as its input")),
6203 }
6204}
6205
6206fn required_string_arg(
6207 tool: &str,
6208 args: &serde_json::Map<String, serde_json::Value>,
6209 key: &str,
6210) -> Result<String, String> {
6211 args.get(key)
6212 .and_then(serde_json::Value::as_str)
6213 .map(str::trim)
6214 .filter(|value| !value.is_empty())
6215 .map(str::to_string)
6216 .ok_or_else(|| format!("'{tool}' requires a non-empty '{key}'"))
6217}
6218
6219fn json_tool_result(summary: String, data: serde_json::Value) -> murmur::tool::run::ToolResult {
6222 murmur::tool::run::ToolResult {
6223 status: murmur::tool::run::Status::Passed,
6224 summary: Some(summary),
6225 data: Some(data.to_string()),
6226 data_path: None,
6227 truncated: false,
6228 metadata: Vec::new(),
6229 }
6230}
6231
6232fn read_artifact_info_from_manifest(
6233 manifest_path: &Path,
6234 fallback: &InstalledArtifactSummary,
6235) -> Result<manage::ArtifactInfo, String> {
6236 let manifest_content = fs::read_to_string(manifest_path)
6237 .map_err(|err| format!("failed to read {}: {err}", manifest_path.display()))?;
6238
6239 let value: Value = serde_yaml::from_str(&manifest_content)
6240 .map_err(|err| format!("failed to parse {}: {err}", manifest_path.display()))?;
6241
6242 let root = value.as_mapping();
6243
6244 let description = root
6245 .and_then(|mapping| mapping.get(Value::String("description".to_string())))
6246 .and_then(Value::as_str)
6247 .unwrap_or_default()
6248 .to_string();
6249
6250 let tags = root
6251 .and_then(|mapping| mapping.get(Value::String("tags".to_string())))
6252 .and_then(Value::as_sequence)
6253 .map(|items| {
6254 items
6255 .iter()
6256 .filter_map(Value::as_str)
6257 .map(ToString::to_string)
6258 .collect::<Vec<_>>()
6259 })
6260 .unwrap_or_default();
6261
6262 let input_schema = root
6263 .and_then(|mapping| mapping.get(Value::String("input_schema".to_string())))
6264 .map(yaml_to_json_string)
6265 .transpose()?;
6266 let output_schema = root
6267 .and_then(|mapping| mapping.get(Value::String("output_schema".to_string())))
6268 .map(yaml_to_json_string)
6269 .transpose()?;
6270
6271 Ok(manage::ArtifactInfo {
6272 name: fallback.name.clone(),
6273 version: fallback.version.clone(),
6274 description,
6275 tags,
6276 runtime: runtime_type_to_wit(&fallback.runtime, fallback.implementation.as_ref()),
6277 input_schema,
6278 output_schema,
6279 })
6280}
6281
6282fn yaml_to_json_string(value: &Value) -> Result<String, String> {
6283 if let Value::String(s) = value {
6284 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(s) {
6285 return serde_json::to_string(&parsed)
6286 .map_err(|err| format!("failed to convert schema to JSON: {err}"));
6287 }
6288 }
6289 serde_json::to_string(value).map_err(|err| format!("failed to convert schema to JSON: {err}"))
6290}
6291
6292fn runtime_type_to_wit(
6293 runtime: &ArtifactRuntime,
6294 implementation: Option<&ArtifactImplementation>,
6295) -> manage::RuntimeType {
6296 match (runtime, implementation) {
6297 (ArtifactRuntime::Tool, Some(ArtifactImplementation::Native)) => {
6298 manage::RuntimeType::Native
6299 }
6300 _ => manage::RuntimeType::Wasm,
6301 }
6302}
6303
6304struct ToolStoreState {
6305 limits: ExecutionLimiter,
6307 table: ResourceTable,
6308 wasi: WasiCtx,
6309 http: WasiHttpCtx,
6310 http_hooks: NetworkPolicyHooks,
6311}
6312
6313impl WasiView for ToolStoreState {
6314 fn ctx(&mut self) -> WasiCtxView<'_> {
6315 WasiCtxView {
6316 ctx: &mut self.wasi,
6317 table: &mut self.table,
6318 }
6319 }
6320}
6321
6322impl WasiHttpView for ToolStoreState {
6323 fn http(&mut self) -> WasiHttpCtxView<'_> {
6324 WasiHttpCtxView {
6325 ctx: &mut self.http,
6326 table: &mut self.table,
6327 hooks: &mut self.http_hooks,
6328 }
6329 }
6330}
6331
6332pub(crate) const RESERVED_TOOL_NAMES: [&str; 4] = [
6344 SHARE_FILE_TOOL,
6345 FETCH_PEER_FILE_TOOL,
6346 DELEGATE_TASK_TOOL,
6347 SUBMIT_PLAN_TOOL,
6348];
6349
6350#[must_use]
6355pub(crate) fn is_reserved_tool_name(name: &str) -> bool {
6356 RESERVED_TOOL_NAMES.contains(&name)
6357}
6358
6359pub fn check_no_reserved_tool_names<'a, I>(names: I) -> Result<(), RuntimeError>
6365where
6366 I: IntoIterator<Item = &'a str>,
6367{
6368 for name in names {
6369 if is_reserved_tool_name(name) {
6370 return Err(RuntimeError::ReservedToolName {
6371 name: name.to_string(),
6372 });
6373 }
6374 }
6375 Ok(())
6376}
6377
6378#[derive(Debug, Clone, PartialEq, Eq)]
6381pub struct StrippedEnvAllowEntry {
6382 pub name: String,
6384 pub pattern: String,
6386 pub source: crate::shell::BackstopPatternSource,
6388}
6389
6390pub fn stripped_env_allow_entries(policy: &CapabilityPolicy) -> Vec<StrippedEnvAllowEntry> {
6395 let mut entries: Vec<StrippedEnvAllowEntry> = Vec::new();
6396 for name in &policy.env_allow {
6397 if entries.iter().any(|entry| &entry.name == name) {
6398 continue;
6399 }
6400 if let Some(matched) =
6401 crate::shell::credential_backstop_match(name, &policy.shell_strip_env)
6402 {
6403 entries.push(StrippedEnvAllowEntry {
6404 name: name.clone(),
6405 pattern: matched.pattern,
6406 source: matched.source,
6407 });
6408 }
6409 }
6410 entries
6411}
6412
6413pub fn check_env_allow_reaches_guests(policy: &CapabilityPolicy) -> Result<(), RuntimeError> {
6421 let entries = stripped_env_allow_entries(policy);
6422 if entries.is_empty() {
6423 return Ok(());
6424 }
6425 Err(RuntimeError::EnvAllowStrippedByBackstop { entries })
6426}
6427
6428pub(crate) fn describe_stripped_env_allow_entries(entries: &[StrippedEnvAllowEntry]) -> String {
6430 entries
6431 .iter()
6432 .map(|entry| {
6433 let source = match entry.source {
6434 crate::shell::BackstopPatternSource::Builtin => "credential backstop",
6435 crate::shell::BackstopPatternSource::StripEnv => "capabilities.shell.strip_env",
6436 };
6437 format!("'{}' ({source} pattern '{}')", entry.name, entry.pattern)
6438 })
6439 .collect::<Vec<_>>()
6440 .join(", ")
6441}
6442
6443fn write_tool_manifest(
6452 workdir: &Path,
6453 name: &str,
6454 manifest_yaml: &str,
6455) -> Result<(), RuntimeError> {
6456 if is_reserved_tool_name(name) {
6457 return Err(RuntimeError::ReservedToolName {
6458 name: name.to_string(),
6459 });
6460 }
6461 write_tool_manifest_unchecked(workdir, name, manifest_yaml)
6462}
6463
6464fn write_runtime_provided_tool_manifest(
6470 workdir: &Path,
6471 name: &str,
6472 manifest_yaml: &str,
6473) -> Result<(), RuntimeError> {
6474 if !is_reserved_tool_name(name) {
6475 return Err(RuntimeError::RuntimeProvidedToolNotReserved {
6476 name: name.to_string(),
6477 });
6478 }
6479 write_tool_manifest_unchecked(workdir, name, manifest_yaml)
6480}
6481
6482fn write_tool_manifest_unchecked(
6484 workdir: &Path,
6485 name: &str,
6486 manifest_yaml: &str,
6487) -> Result<(), RuntimeError> {
6488 let manifest_path = workdir.join("tools").join(name).join(PACKED_MANIFEST_ENTRY);
6489 let Some(parent) = manifest_path.parent() else {
6490 return Err(RuntimeError::Runtime(format!(
6491 "failed to derive parent for tool manifest path {}",
6492 manifest_path.display()
6493 )));
6494 };
6495
6496 fs::create_dir_all(parent).map_err(|source| RuntimeError::WriteToolManifest {
6497 path: manifest_path.display().to_string(),
6498 source,
6499 })?;
6500 fs::write(&manifest_path, manifest_yaml).map_err(|source| RuntimeError::WriteToolManifest {
6501 path: manifest_path.display().to_string(),
6502 source,
6503 })
6504}
6505
6506fn install_native_binaries(
6512 workdir: &Path,
6513 native_binaries: Vec<(String, Vec<u8>)>,
6514) -> Result<(), RuntimeError> {
6515 let host_platform = current_platform();
6516 for (name, bytes) in &native_binaries {
6517 if let NativeBinaryVerdict::Mismatch { binary_platform } =
6518 native_binary_verdict(bytes, host_platform)
6519 {
6520 return Err(RuntimeError::NativeBinaryPlatformMismatch {
6521 name: name.clone(),
6522 binary_platform: binary_platform.to_string(),
6523 host_platform: host_platform.to_string(),
6524 });
6525 }
6526 }
6527
6528 for (name, bytes) in native_binaries {
6529 let binary_path = workdir.join("tools").join(&name).join(&name);
6530 let Some(parent) = binary_path.parent() else {
6531 return Err(RuntimeError::Runtime(format!(
6532 "failed to derive parent for native binary path {}",
6533 binary_path.display()
6534 )));
6535 };
6536 fs::create_dir_all(parent).map_err(|source| RuntimeError::WriteToolManifest {
6537 path: binary_path.display().to_string(),
6538 source,
6539 })?;
6540 fs::write(&binary_path, &bytes).map_err(|source| RuntimeError::WriteToolManifest {
6541 path: binary_path.display().to_string(),
6542 source,
6543 })?;
6544 #[cfg(unix)]
6545 {
6546 use std::os::unix::fs::PermissionsExt;
6547 let mut perms = fs::metadata(&binary_path)
6548 .map_err(|source| RuntimeError::WriteToolManifest {
6549 path: binary_path.display().to_string(),
6550 source,
6551 })?
6552 .permissions();
6553 perms.set_mode(0o755);
6554 fs::set_permissions(&binary_path, perms).map_err(|source| {
6555 RuntimeError::WriteToolManifest {
6556 path: binary_path.display().to_string(),
6557 source,
6558 }
6559 })?;
6560 }
6561 }
6562 Ok(())
6563}
6564
6565fn load_local_skill_md(manifest_dir: &Path, source: &str) -> Result<Vec<u8>, RuntimeError> {
6574 let raw = Path::new(source);
6575 let path = if raw.is_absolute() {
6576 raw.to_path_buf()
6577 } else {
6578 manifest_dir.join(raw)
6579 };
6580
6581 if !path.exists() {
6582 return Err(RuntimeError::SkillSourceNotFound {
6583 path: path.display().to_string(),
6584 });
6585 }
6586
6587 let skill_md_path = if path.is_dir() {
6588 find_skill_md_in_dir(&path)?.ok_or_else(|| RuntimeError::SkillSourceMissingSkillMd {
6589 path: path.display().to_string(),
6590 })?
6591 } else {
6592 path.clone()
6593 };
6594
6595 fs::read(&skill_md_path).map_err(|source| RuntimeError::SkillSourceRead {
6596 path: skill_md_path.display().to_string(),
6597 source,
6598 })
6599}
6600
6601fn find_skill_md_in_dir(dir: &Path) -> Result<Option<std::path::PathBuf>, RuntimeError> {
6603 let entries = fs::read_dir(dir).map_err(|source| RuntimeError::SkillSourceRead {
6604 path: dir.display().to_string(),
6605 source,
6606 })?;
6607 for entry in entries {
6608 let entry = entry.map_err(|source| RuntimeError::SkillSourceRead {
6609 path: dir.display().to_string(),
6610 source,
6611 })?;
6612 if entry.file_name().to_string_lossy().to_lowercase() == "skill.md" {
6613 return Ok(Some(entry.path()));
6614 }
6615 }
6616 Ok(None)
6617}
6618
6619fn install_skill_files(
6620 workdir: &Path,
6621 skill_files: Vec<(String, Vec<u8>)>,
6622) -> Result<(), RuntimeError> {
6623 for (name, bytes) in skill_files {
6624 let skill_path = workdir.join("tools").join(&name).join("skill.md");
6625 let Some(parent) = skill_path.parent() else {
6626 return Err(RuntimeError::Runtime(format!(
6627 "failed to derive parent for skill.md path {}",
6628 skill_path.display()
6629 )));
6630 };
6631 fs::create_dir_all(parent).map_err(|source| RuntimeError::WriteToolManifest {
6632 path: skill_path.display().to_string(),
6633 source,
6634 })?;
6635 fs::write(&skill_path, &bytes).map_err(|source| RuntimeError::WriteToolManifest {
6636 path: skill_path.display().to_string(),
6637 source,
6638 })?;
6639 }
6640 Ok(())
6641}
6642
6643fn check_persistent_handle_ttl(
6653 peer_files: Option<&murmur_artifact::PeerFilesExport>,
6654 lifecycle: &LifecycleConfig,
6655) -> Result<(), RuntimeError> {
6656 let Some(export) = peer_files else {
6657 return Ok(());
6658 };
6659 if lifecycle.after_task != murmur_artifact::AfterTask::Sleep {
6660 return Ok(());
6661 }
6662 let ceiling = murmur_artifact::PERSISTENT_PEER_HANDLE_TTL_CEILING_SECS;
6663 match export.max_ttl_secs {
6664 Some(declared) if declared <= ceiling => Ok(()),
6665 declared => Err(RuntimeError::PersistentCapsuleNeedsHandleTtl {
6666 declared_secs: declared,
6667 ceiling_secs: ceiling,
6668 }),
6669 }
6670}
6671
6672fn write_peer_handoff_tool_manifests(
6675 workdir: &Path,
6676 can_mint: bool,
6677 can_fetch: bool,
6678) -> Result<(), RuntimeError> {
6679 if can_mint {
6680 write_runtime_provided_tool_manifest(workdir, SHARE_FILE_TOOL, SHARE_FILE_TOOL_MANIFEST)?;
6681 }
6682 if can_fetch {
6683 write_runtime_provided_tool_manifest(
6684 workdir,
6685 FETCH_PEER_FILE_TOOL,
6686 FETCH_PEER_FILE_TOOL_MANIFEST,
6687 )?;
6688 }
6689 Ok(())
6690}
6691
6692pub(crate) const SHARE_FILE_TOOL: &str = "share-file";
6694
6695pub(crate) const FETCH_PEER_FILE_TOOL: &str = "fetch-peer-file";
6697
6698const SHARE_FILE_TOOL_MANIFEST: &str = concat!(
6702 "name: share-file\n",
6703 "version: 0.0.0\n",
6704 "runtime: tool\n",
6705 "implementation: native\n",
6706 "description: \"Mint an opaque handle a named peer can use to fetch one file from this ",
6707 "capsule's declared peer export. `path` is relative to exports.peer_files.root; `peer` is ",
6708 "the peer's address. Returns a handle to put in a message to that peer — it names no ",
6709 "filesystem path and is redeemable only by that peer.\"\n",
6710 "input_schema: '",
6711 r#"{"type":"object","properties":{"path":{"type":"string"},"peer":{"type":"string"},"ttl":{"type":"string"}},"required":["path","peer"]}"#,
6712 "'\n",
6713);
6714
6715const FETCH_PEER_FILE_TOOL_MANIFEST: &str = concat!(
6718 "name: fetch-peer-file\n",
6719 "version: 0.0.0\n",
6720 "runtime: tool\n",
6721 "implementation: native\n",
6722 "description: \"Redeem a handle a peer sent and store the file it names in this capsule's ",
6723 "workdir. Returns the stored path, size and SHA-256 — never the file's contents. Read the ",
6724 "stored path if you need what is in it.\"\n",
6725 "input_schema: '",
6726 r#"{"type":"object","properties":{"peer":{"type":"string"},"handle":{"type":"string"}},"required":["peer","handle"]}"#,
6727 "'\n",
6728);
6729
6730fn write_delegate_task_tool_manifest(
6737 workdir: &Path,
6738 spawn_allow: &[String],
6739) -> Result<(), RuntimeError> {
6740 if spawn_allow.is_empty() {
6741 return Ok(());
6742 }
6743 write_runtime_provided_tool_manifest(
6744 workdir,
6745 DELEGATE_TASK_TOOL,
6746 &delegate_task_tool_manifest(spawn_allow),
6747 )
6748}
6749
6750pub(crate) const DELEGATE_TASK_TOOL: &str = "delegate-task";
6752
6753fn delegate_task_tool_manifest(spawn_allow: &[String]) -> String {
6759 let allowed = serde_json::to_string(spawn_allow).unwrap_or_else(|_| "[]".to_string());
6760 let schema = format!(
6761 r#"{{"type":"object","properties":{{"capsule":{{"type":"string","enum":{allowed}}},"version":{{"type":"string"}},"task":{{"type":"string"}}}},"required":["capsule","version","task"]}}"#
6762 );
6763 format!(
6764 "name: {DELEGATE_TASK_TOOL}\n\
6765 version: 0.0.0\n\
6766 runtime: tool\n\
6767 implementation: native\n\
6768 description: \"Hand one task to one sub-capsule and wait for its answer. `capsule` must \
6769 be one of the names this capsule is allowed to delegate to; `version` is that capsule's \
6770 exact version, because there is no latest or stable alias; `task` is the whole of what \
6771 the sub-capsule is told, so state the objective in full. The sub-capsule runs as its own \
6772 process with its own workdir and this call does not return until it has finished. \
6773 Returns its answer text.\"\n\
6774 input_schema: '{schema}'\n",
6775 )
6776}
6777
6778fn write_submit_plan_tool_manifest(workdir: &Path, plan_submit: bool) -> Result<(), RuntimeError> {
6785 if !plan_submit {
6786 return Ok(());
6787 }
6788 write_runtime_provided_tool_manifest(workdir, SUBMIT_PLAN_TOOL, SUBMIT_PLAN_TOOL_MANIFEST)
6789}
6790
6791enum PlanRequest {
6794 Gate(GatedStepCall, tokio::sync::oneshot::Sender<Option<String>>),
6797 Invoke(
6799 String,
6800 murmur::tool::run::ToolInput,
6801 tokio::sync::oneshot::Sender<Result<murmur::tool::run::ToolResult, String>>,
6802 ),
6803}
6804
6805enum GatedStepCall {
6807 Tool {
6808 name: String,
6809 input_json: String,
6810 },
6811 Shell {
6812 binary: String,
6813 command: String,
6814 argv: Vec<String>,
6815 },
6816}
6817
6818impl GatedStepCall {
6819 fn from_planned(call: &crate::plan::PlannedCall<'_>) -> Self {
6820 match call {
6821 crate::plan::PlannedCall::Tool { name, input_json } => Self::Tool {
6822 name: (*name).to_string(),
6823 input_json: (*input_json).to_string(),
6824 },
6825 crate::plan::PlannedCall::Shell {
6826 binary,
6827 command,
6828 argv,
6829 } => Self::Shell {
6830 binary: (*binary).to_string(),
6831 command: (*command).to_string(),
6832 argv: argv.to_vec(),
6833 },
6834 }
6835 }
6836
6837 fn resolve(&self, workdir: &Path) -> ResolvedCall {
6847 match self {
6848 Self::Tool { name, input_json } => ResolvedCall::Tool {
6849 tool_name: name.clone(),
6850 input_bytes: input_json.len() as u64,
6851 input: input_json.clone(),
6852 },
6853 Self::Shell {
6854 binary,
6855 command,
6856 argv,
6857 } => {
6858 let script = (is_shell_interpreter(binary)
6859 && argv.first().is_some_and(|first| first == "-c"))
6860 .then(|| argv.get(1).cloned().unwrap_or_default());
6861 let recipe = match script {
6862 Some(_) => None,
6863 None => crate::recipes::resolve_recipe(workdir, binary, argv),
6864 };
6865 ResolvedCall::Shell {
6866 binary: crate::sandbox::resolve_invoked_binary_path(binary),
6867 command: command.clone(),
6868 argv: argv.clone(),
6869 script,
6870 recipe,
6871 }
6872 }
6873 }
6874 }
6875}
6876
6877pub(crate) const SUBMIT_PLAN_TOOL: &str = "submit-plan";
6879
6880const PLAN_GATE_UNREACHABLE: &str =
6885 "Refused: the session stopped answering policy checks, so this step was not dispatched.";
6886
6887const SUBMIT_PLAN_TOOL_MANIFEST: &str = concat!(
6893 "name: submit-plan\n",
6894 "version: 0.0.0\n",
6895 "runtime: tool\n",
6896 "implementation: native\n",
6897 "description: \"Hand one plan to the runtime and get back every step's result. `plan` is an ",
6898 "object with an `id` and a `steps` array; each step has an `id` and exactly one of `tool`, ",
6899 "`shell` or `capsule`, plus optional `input`, `depends_on`, `if`, `on_error` and `retries`. ",
6900 "The call does not return until every step has finished. Independent `shell` and `capsule` ",
6901 "steps run at the same time; `tool` steps are dispatched one at a time even when nothing ",
6902 "orders them. A step's output is reachable from a later step as ",
6903 "$<step id>.output and its status as $<step id>.status, anywhere in that step's input.\"\n",
6904 "input_schema: '",
6905 r#"{"type":"object","properties":{"plan":{"type":"object"}},"required":["plan"]}"#,
6906 "'\n",
6907);
6908
6909fn write_shell_tool_manifests(workdir: &Path, shell_allow: &[String]) -> Result<(), RuntimeError> {
6910 for binary in shell_allow {
6911 let manifest_path = workdir
6912 .join("tools")
6913 .join(binary)
6914 .join(PACKED_MANIFEST_ENTRY);
6915
6916 if manifest_path.exists() {
6917 continue;
6918 }
6919
6920 let Some(parent) = manifest_path.parent() else {
6921 return Err(RuntimeError::Runtime(format!(
6922 "failed to derive parent for shell manifest path {}",
6923 manifest_path.display()
6924 )));
6925 };
6926
6927 fs::create_dir_all(parent).map_err(|source| RuntimeError::WriteToolManifest {
6928 path: manifest_path.display().to_string(),
6929 source,
6930 })?;
6931
6932 fs::write(&manifest_path, shell_tool_manifest_yaml(binary)).map_err(|source| {
6933 RuntimeError::WriteToolManifest {
6934 path: manifest_path.display().to_string(),
6935 source,
6936 }
6937 })?;
6938 }
6939
6940 Ok(())
6941}
6942
6943fn dispatch_native_tool(
6948 name: &str,
6949 input: murmur::tool::run::ToolInput,
6950 binary_path: &Path,
6951 accessible_workdir: &Path,
6952 session_workdir: &Path,
6953 policy: &CapabilityPolicy,
6954 enforcement: &sandbox::ShellEnforcement,
6955) -> Result<murmur::tool::run::ToolResult, String> {
6956 use std::{
6957 io::Write,
6958 process::{Command, Stdio},
6959 };
6960
6961 let input_json = serde_json::to_string(&serde_json::json!({
6962 "data": input.data,
6963 "log_path": input.log_path,
6964 }))
6965 .map_err(|e| format!("failed to serialize input for native tool '{name}': {e}"))?;
6966
6967 enforcement.check_workdir_budget()?;
6968
6969 let env = build_shell_env(policy, &[], session_workdir)?;
6970
6971 let mut command = Command::new(binary_path);
6974 command
6975 .current_dir(accessible_workdir)
6976 .env_clear()
6977 .envs(env)
6978 .stdin(Stdio::piped())
6979 .stdout(Stdio::piped())
6980 .stderr(Stdio::piped());
6981
6982 sandbox::attach_process_limits(&mut command, enforcement);
6986 crate::sandbox::apply_fd_hygiene(&mut command);
6999
7000 let mut child = command
7001 .spawn()
7002 .map_err(|e| format!("failed to spawn native tool '{name}': {e}"))?;
7003
7004 if let Some(mut stdin) = child.stdin.take() {
7005 let _ = stdin.write_all(input_json.as_bytes());
7006 }
7008
7009 let output = child
7010 .wait_with_output()
7011 .map_err(|e| format!("native tool '{name}' failed to complete: {e}"))?;
7012
7013 let stdout = String::from_utf8_lossy(&output.stdout);
7014 let stderr = String::from_utf8_lossy(&output.stderr);
7015
7016 if stdout.trim().is_empty() {
7017 return Ok(murmur::tool::run::ToolResult {
7018 status: if output.status.success() {
7019 murmur::tool::run::Status::Passed
7020 } else {
7021 murmur::tool::run::Status::Error
7022 },
7023 summary: Some(format!(
7024 "native tool '{}' exited with {}",
7025 name, output.status
7026 )),
7027 data: if stderr.is_empty() {
7028 None
7029 } else {
7030 Some(stderr.to_string())
7031 },
7032 data_path: None,
7033 truncated: false,
7034 metadata: Vec::new(),
7035 });
7036 }
7037
7038 match serde_json::from_str::<serde_json::Value>(stdout.trim()) {
7039 Ok(json) => {
7040 let status = match json.get("status").and_then(|s| s.as_str()) {
7041 Some("passed") => murmur::tool::run::Status::Passed,
7042 Some("failed") => murmur::tool::run::Status::Failed,
7043 _ => murmur::tool::run::Status::Error,
7044 };
7045 let summary = json
7046 .get("summary")
7047 .and_then(|s| s.as_str())
7048 .map(|s| s.to_string());
7049 let data = json
7050 .get("data")
7051 .and_then(|d| d.as_str())
7052 .map(|s| s.to_string())
7053 .or_else(|| {
7054 json.get("data")
7055 .filter(|d| !d.is_null())
7056 .map(|d| d.to_string())
7057 });
7058 Ok(murmur::tool::run::ToolResult {
7059 status,
7060 summary,
7061 data,
7062 data_path: None,
7063 truncated: false,
7064 metadata: Vec::new(),
7065 })
7066 }
7067 Err(_) => Ok(murmur::tool::run::ToolResult {
7068 status: if output.status.success() {
7069 murmur::tool::run::Status::Passed
7070 } else {
7071 murmur::tool::run::Status::Error
7072 },
7073 summary: Some(format!("native tool '{}' completed", name)),
7074 data: Some(stdout.to_string()),
7075 data_path: None,
7076 truncated: false,
7077 metadata: Vec::new(),
7078 }),
7079 }
7080}
7081
7082pub(crate) struct ResolvedShellCall {
7087 pub binary: String,
7091 pub command: String,
7094 pub argv: Vec<String>,
7096 pub script: Option<String>,
7098 pub recipe: Option<String>,
7102}
7103
7104pub(crate) fn resolve_shell_call(
7114 name: &str,
7115 input: &murmur::tool::run::ToolInput,
7116 workdir: &Path,
7117 policy: &CapabilityPolicy,
7118) -> Option<ResolvedShellCall> {
7119 resolve_shell_call_inner(name, input, workdir, policy).ok()
7120}
7121
7122fn resolve_shell_call_inner(
7126 name: &str,
7127 input: &murmur::tool::run::ToolInput,
7128 workdir: &Path,
7129 policy: &CapabilityPolicy,
7130) -> Result<ResolvedShellCall, String> {
7131 if !policy.shell_allow.iter().any(|allowed| allowed == name) {
7132 return Err(format!(
7133 "binary '{name}' is not in capabilities.shell.allow"
7134 ));
7135 }
7136 let command = extract_shell_command(input)?;
7137 let (argv, script) = if is_shell_interpreter(name) {
7138 (
7139 vec!["-c".to_string(), command.clone()],
7140 Some(command.clone()),
7141 )
7142 } else {
7143 (split_shell_words(&command), None)
7144 };
7145 let recipe = match script {
7148 Some(_) => None,
7149 None => crate::recipes::resolve_recipe(workdir, name, &argv),
7150 };
7151 Ok(ResolvedShellCall {
7152 binary: crate::sandbox::resolve_invoked_binary_path(name),
7153 command,
7154 argv,
7155 script,
7156 recipe,
7157 })
7158}
7159
7160#[allow(clippy::too_many_arguments)]
7164fn dispatch_shell_tool(
7165 name: &str,
7166 input: murmur::tool::run::ToolInput,
7167 accessible_workdir: &Path,
7168 session_workdir: &Path,
7169 env_overrides: &[(String, String)],
7170 policy: &CapabilityPolicy,
7171 enforcement: &sandbox::ShellEnforcement,
7172 detach: Option<DetachPolicy>,
7173) -> DispatchOutcome {
7174 let resolved = match resolve_shell_call_inner(name, &input, accessible_workdir, policy) {
7175 Ok(resolved) => resolved,
7176 Err(error) => {
7177 return DispatchOutcome::tool(murmur::tool::run::ToolResult {
7178 status: murmur::tool::run::Status::Error,
7179 summary: Some("shell command parsing failed".to_string()),
7180 data: Some(error),
7181 data_path: None,
7182 truncated: false,
7183 metadata: Vec::new(),
7184 });
7185 }
7186 };
7187 let ResolvedShellCall {
7188 command,
7189 argv,
7190 script,
7191 recipe,
7192 ..
7193 } = resolved;
7194 let args: Vec<&str> = argv.iter().map(String::as_str).collect();
7195
7196 let detach = detach.map(|detach| DetachPolicy {
7197 command: command.clone(),
7198 ..detach
7199 });
7200
7201 match run_shell(
7202 name,
7203 &args,
7204 env_overrides,
7205 accessible_workdir,
7206 session_workdir,
7207 policy,
7208 enforcement,
7209 detach,
7210 ) {
7211 Ok(ShellOutcome::Finished(result)) => {
7212 let shell = ShellDispatchInfo {
7213 binary: result.binary.clone(),
7216 command: command.clone(),
7217 argv: argv.clone(),
7218 script: script.clone(),
7219 recipe: recipe.clone(),
7220 exit_code: result.exit_code,
7221 stdout: result.stdout.clone(),
7222 stderr: result.stderr.clone(),
7223 stdout_bytes: result.stdout.len() as u64,
7224 stderr_bytes: result.stderr.len() as u64,
7225 duration_ms: result.duration_ms,
7226 resource_limit: result.resource_limit_hit.clone(),
7227 };
7228 DispatchOutcome {
7229 result: shell_result_to_tool_result(name, &command, result),
7230 shell: Some(shell),
7231 detached: None,
7232 is_skill: false,
7233 fatal: None,
7234 }
7235 }
7236 Ok(ShellOutcome::Detached(info)) => DispatchOutcome {
7240 result: demotion_tool_result(&info.work_id),
7241 shell: None,
7242 detached: Some(info),
7243 is_skill: false,
7244 fatal: None,
7245 },
7246 Err(error) => DispatchOutcome {
7247 result: murmur::tool::run::ToolResult {
7251 status: murmur::tool::run::Status::Error,
7252 summary: Some("shell execution failed".to_string()),
7253 data: Some(error.to_string()),
7254 data_path: None,
7255 truncated: false,
7256 metadata: Vec::new(),
7257 },
7258 shell: None,
7259 detached: None,
7260 is_skill: false,
7261 fatal: error.session_fatal(),
7262 },
7263 }
7264}
7265
7266fn extract_shell_command(input: &murmur::tool::run::ToolInput) -> Result<String, String> {
7267 let data = input
7268 .data
7269 .as_deref()
7270 .ok_or_else(|| "shell tool input.data is required".to_string())?;
7271
7272 let json: serde_json::Value = serde_json::from_str(data)
7273 .map_err(|error| format!("shell tool input must be valid JSON: {error}"))?;
7274
7275 let command = json
7276 .get("command")
7277 .and_then(serde_json::Value::as_str)
7278 .map(str::trim)
7279 .filter(|value| !value.is_empty())
7280 .ok_or_else(|| "shell tool input must include a non-empty 'command' string".to_string())?;
7281
7282 Ok(command.to_string())
7283}
7284
7285fn shell_command_line(name: &str, command: &str) -> String {
7296 if is_shell_interpreter(name) {
7297 format!("$ {command}")
7298 } else {
7299 format!("$ {name} {command}")
7300 }
7301}
7302
7303fn shell_result_to_tool_result(
7304 name: &str,
7305 command: &str,
7306 result: ShellResult,
7307) -> murmur::tool::run::ToolResult {
7308 let mut data = format!(
7309 "{}\nExit code: {}\nStdout:\n{}\nStderr:\n{}",
7310 shell_command_line(name, command),
7311 result.exit_code,
7312 result.stdout,
7313 result.stderr
7314 );
7315
7316 let mut metadata = Vec::new();
7317 if result.truncated {
7318 if let Some(path) = result.full_output_path.as_ref() {
7319 data.push_str(&format!(
7320 "\n\nOutput truncated. Full output written to {path}"
7321 ));
7322 metadata.push(("full_output_path".to_string(), path.clone()));
7323 } else {
7324 data.push_str("\n\nOutput truncated.");
7325 }
7326 }
7327
7328 murmur::tool::run::ToolResult {
7329 status: murmur::tool::run::Status::Passed,
7330 summary: Some(format!(
7331 "Shell command exited with code {}",
7332 result.exit_code
7333 )),
7334 data: Some(data),
7335 data_path: None,
7336 truncated: result.truncated,
7337 metadata,
7338 }
7339}
7340
7341fn task_context_id(supplied: Option<&str>) -> String {
7347 supplied
7348 .map(str::to_string)
7349 .unwrap_or_else(|| format!("ctx_{}", uuid::Uuid::now_v7().simple()))
7350}
7351
7352fn resolve_conversation_root(
7360 context: Option<&ContextConfig>,
7361 capsule_name: &str,
7362 inference: &murmur_artifact::InferenceConfig,
7363 workdir: &Path,
7364) -> Option<PathBuf> {
7365 if inference.transport == "process" {
7366 return None;
7367 }
7368 let record = crate::conversation::resolve_record_name(context, capsule_name)?;
7369 match crate::conversation::record_root(&record) {
7370 Ok(root) => Some(root),
7371 Err(reason) => {
7372 let message = format!(
7373 "[conversation] the record for '{record}' could not be located ({reason}); \
7374 this session runs unrecorded"
7375 );
7376 eprintln!("[capsule-runtime] {message}");
7377 agent::append_bootstrap_log(workdir, &message);
7378 None
7379 }
7380 }
7381}
7382
7383#[allow(clippy::too_many_arguments)]
7396async fn apply_retention(
7397 trace: &mut TraceWriter,
7398 workdir: &Path,
7399 session_id: &str,
7400 trace_retain: Option<&murmur_artifact::TraceRetainConfig>,
7401 context_retain: Option<&murmur_artifact::ContextRetainConfig>,
7402 conversation_root: Option<&Path>,
7403 capsule_name: &str,
7404 context_id: Option<&str>,
7405) {
7406 use crate::trace::{
7407 RETENTION_REASON_MAX_MESSAGES, RETENTION_STORE_RECORDS, RETENTION_STORE_SESSIONS,
7408 };
7409
7410 let now_ms = crate::retention::now_ms();
7411
7412 if let (Some(policy), Some(sessions_root)) = (trace_retain, workdir.parent()) {
7413 let pruned = crate::retention::prune_sessions(sessions_root, session_id, policy, now_ms);
7414 for reason in [
7415 crate::trace::RETENTION_REASON_MAX_SESSIONS,
7416 crate::trace::RETENTION_REASON_MAX_AGE,
7417 ] {
7418 let targets: Vec<String> = pruned
7419 .iter()
7420 .filter(|session| session.reason == reason)
7421 .map(|session| session.name.clone())
7422 .collect();
7423 if targets.is_empty() {
7424 continue;
7425 }
7426 let _ = trace
7427 .write_retention(RETENTION_STORE_SESSIONS, reason, targets, None)
7428 .await;
7429 }
7430 }
7431
7432 let (Some(policy), Some(root)) = (context_retain, conversation_root) else {
7433 return;
7434 };
7435
7436 let pruned = crate::retention::prune_records(root, capsule_name, context_id, policy, now_ms);
7437 if !pruned.is_empty() {
7438 let targets: Vec<String> = pruned
7439 .iter()
7440 .map(|record| record.context_id.clone())
7441 .collect();
7442 let _ = trace
7443 .write_retention(
7444 RETENTION_STORE_RECORDS,
7445 crate::trace::RETENTION_REASON_MAX_AGE,
7446 targets,
7447 None,
7448 )
7449 .await;
7450 }
7451
7452 let (Some(keep), Some(context_id)) = (policy.max_messages, context_id) else {
7457 return;
7458 };
7459 let path = crate::conversation::record_file(root, context_id);
7460 match crate::conversation::read_header(&path) {
7461 Some(header) if header.capsule != capsule_name => return,
7465 _ => {}
7466 }
7467 match crate::retention::truncate_record(&path, keep, capsule_name) {
7468 Ok(outcome) if outcome.dropped > 0 => {
7469 let _ = trace
7470 .write_retention(
7471 RETENTION_STORE_RECORDS,
7472 RETENTION_REASON_MAX_MESSAGES,
7473 vec![context_id.to_string()],
7474 Some(outcome.dropped),
7475 )
7476 .await;
7477 }
7478 Ok(_) => {}
7479 Err(reason) => {
7480 let message = format!(
7481 "[retention] {} could not be truncated ({reason}); this record keeps growing",
7482 path.display()
7483 );
7484 eprintln!("[capsule-runtime] {message}");
7485 agent::append_bootstrap_log(workdir, &message);
7486 }
7487 }
7488}
7489
7490fn resolve_context_window(context: Option<&ContextConfig>) -> u32 {
7491 context.and_then(|c| c.max_tokens).unwrap_or(0)
7492}
7493
7494fn validate_capability_policy(policy: &CapabilityPolicy) -> Result<(), RuntimeError> {
7495 parse_network_allow_rules(&policy.network_allow)?;
7496 if let Some(scope) = policy.filesystem_scope.as_deref() {
7497 validate_filesystem_scope(scope)?;
7498 }
7499
7500 Ok(())
7501}
7502
7503fn enforce_allowlist<T, F>(
7504 allowlisted_tools: &HashSet<String>,
7505 name: &str,
7506 dispatch: F,
7507) -> Result<T, String>
7508where
7509 F: FnOnce() -> Result<T, String>,
7510{
7511 if !allowlisted_tools.contains(name) {
7512 return Err(format!(
7513 "tool '{name}' is not declared in manifest allowlist"
7514 ));
7515 }
7516
7517 dispatch()
7518}
7519
7520enum Woke {
7523 Task(Option<IncomingTask>),
7524 Report(DetachedReport),
7525 Terminating,
7527}
7528
7529async fn record_canceled_before_start(
7536 task: &IncomingTask,
7537 trace: &mut TraceWriter,
7538 detached: &Arc<DetachedRegistry>,
7539 live_delegations: &crate::cancel::LiveDelegations,
7540 sse: &Option<(SseBroadcast, Arc<Mutex<SseEventBuffer>>)>,
7541 event_id: &mut u64,
7542) {
7543 let residue = crate::cancel::Residue::snapshot(Some(detached), live_delegations);
7544 let _ = trace
7545 .write_task_canceled(
7546 &task.task_id,
7547 None,
7548 crate::cancel::PHASE_QUEUED,
7549 residue.detached_work_ids(),
7550 residue.delegation_ids(),
7551 )
7552 .await;
7553 let _ = trace.flush().await;
7554 emit_sse(
7555 sse,
7556 event_id,
7557 "status",
7558 &crate::streaming::TaskStatusUpdateEvent {
7559 id: task.task_id.clone(),
7560 context_id: Some(task.context_id.clone()),
7561 status: crate::streaming::StreamStatus {
7562 state: "canceled".into(),
7563 message: "task canceled before it started".into(),
7564 response: None,
7565 },
7566 r#final: true,
7567 },
7568 )
7569 .await;
7570}
7571
7572async fn close_lanes_on_termination(
7578 lanes: &mut LaneQueue,
7579 task_registry: &Arc<Mutex<TaskRegistry>>,
7580 trace: &mut TraceWriter,
7581 detached: &Arc<DetachedRegistry>,
7582 live_delegations: &crate::cancel::LiveDelegations,
7583 sse: &Option<(SseBroadcast, Arc<Mutex<SseEventBuffer>>)>,
7584 event_id: &mut u64,
7585) {
7586 while let Some((_, task)) = lanes.next(None) {
7588 if task_registry.lock().unwrap().is_canceled(&task.task_id) {
7589 record_canceled_before_start(&task, trace, detached, live_delegations, sse, event_id)
7590 .await;
7591 }
7592 }
7593}
7594
7595async fn enqueue_detached_report(
7613 report: DetachedReport,
7614 task_registry: &Arc<Mutex<TaskRegistry>>,
7615 lanes: &mut LaneQueue,
7616 trace: &mut TraceWriter,
7617) {
7618 let task = match report {
7619 DetachedReport::Completed(completion) => {
7620 let task = IncomingTask {
7621 task_id: format!("tsk_{}", uuid::Uuid::now_v7().simple()),
7622 context_id: completion.context_id.clone(),
7623 message_id: format!("msg_{}", uuid::Uuid::now_v7().simple()),
7624 message_text: completion.message_text(),
7625 provenance: completion.provenance,
7626 traceparent: None,
7630 source: crate::a2a::SOURCE_DETACHED_SHELL,
7631 delegation_id: None,
7635 };
7636 let _ = trace
7637 .write_shell_completed(
7638 &completion.work_id,
7639 &completion.binary,
7640 &completion.command,
7641 completion.exit_code,
7642 completion.duration_ms,
7643 &completion.output_path,
7644 completion.output_bytes,
7645 completion.resource_limit.clone(),
7646 completion.status(),
7647 &task.task_id,
7648 )
7649 .await;
7650 task
7651 }
7652 DetachedReport::Lost(report) => IncomingTask {
7656 task_id: report.task_id.clone(),
7657 context_id: report.context_id.clone(),
7658 message_id: format!("msg_{}", uuid::Uuid::now_v7().simple()),
7659 message_text: report.message_text(),
7660 provenance: report.provenance,
7661 traceparent: None,
7662 source: crate::a2a::SOURCE_DETACHED_LOST,
7663 delegation_id: None,
7664 },
7665 };
7666
7667 task_registry
7668 .lock()
7669 .unwrap()
7670 .enqueue(&task.task_id, &task.context_id);
7671 lanes.push(task);
7672}
7673
7674fn generate_session_id() -> String {
7675 format!("ses_{}", uuid::Uuid::now_v7().simple())
7676}
7677
7678fn resolve_lifecycle(
7679 base: Option<LifecycleConfig>,
7680 override_: Option<&murmur_artifact::LifecycleOverride>,
7681) -> LifecycleConfig {
7682 let mut config = base.unwrap_or_default();
7683 if let Some(ov) = override_ {
7684 if let Some(ta) = &ov.task_acceptance {
7685 config.task_acceptance = ta.clone();
7686 }
7687 if let Some(at) = &ov.after_task {
7688 config.after_task = at.clone();
7689 }
7690 }
7691 config
7692}
7693
7694#[cfg(test)]
7695mod tests {
7696 use murmur_artifact::LockedArtifact;
7697
7698 #[test]
7703 fn the_delegation_manifest_carries_the_granted_names_and_nothing_else() {
7704 let manifest =
7705 super::delegate_task_tool_manifest(&["worker".to_string(), "reviewer".to_string()]);
7706 let parsed: serde_yaml::Value =
7707 serde_yaml::from_str(&manifest).expect("the generated manifest is YAML");
7708 assert_eq!(parsed["name"].as_str(), Some("delegate-task"));
7709 assert_eq!(parsed["runtime"].as_str(), Some("tool"));
7710 assert_eq!(parsed["implementation"].as_str(), Some("native"));
7711
7712 let schema: serde_json::Value =
7713 serde_json::from_str(parsed["input_schema"].as_str().expect("a schema string"))
7714 .expect("the schema is JSON");
7715 assert_eq!(
7716 schema["required"],
7717 serde_json::json!(["capsule", "version", "task"])
7718 );
7719 assert_eq!(
7720 schema["properties"]["capsule"]["enum"],
7721 serde_json::json!(["worker", "reviewer"]),
7722 "the enum is the capsule's own spawn.allow, in declaration order"
7723 );
7724 for absent in ["roost", "url", "credential", "approval", "workdir"] {
7726 assert!(
7727 !schema["properties"]
7728 .as_object()
7729 .expect("an object")
7730 .contains_key(absent),
7731 "'{absent}' must not be an argument the agent supplies"
7732 );
7733 }
7734 }
7735
7736 #[test]
7739 fn an_ungranted_capsule_is_written_no_delegation_tool() {
7740 let dir = tempfile::tempdir().unwrap();
7741 super::write_delegate_task_tool_manifest(dir.path(), &[]).unwrap();
7742 assert!(!dir.path().join("tools").join("delegate-task").exists());
7743
7744 super::write_delegate_task_tool_manifest(dir.path(), &["worker".to_string()]).unwrap();
7745 assert!(dir
7746 .path()
7747 .join("tools")
7748 .join("delegate-task")
7749 .join(PACKED_MANIFEST_ENTRY)
7750 .exists());
7751 }
7752
7753 #[test]
7758 fn the_plan_manifest_declares_one_object_argument() {
7759 let parsed: serde_yaml::Value =
7760 serde_yaml::from_str(super::SUBMIT_PLAN_TOOL_MANIFEST).expect("the manifest is YAML");
7761 assert_eq!(parsed["name"].as_str(), Some("submit-plan"));
7762 assert_eq!(parsed["version"].as_str(), Some("0.0.0"));
7763 assert_eq!(parsed["runtime"].as_str(), Some("tool"));
7764 assert_eq!(parsed["implementation"].as_str(), Some("native"));
7765
7766 let schema: serde_json::Value =
7767 serde_json::from_str(parsed["input_schema"].as_str().expect("a schema string"))
7768 .expect("the schema is JSON");
7769 assert_eq!(schema["required"], serde_json::json!(["plan"]));
7770 assert_eq!(schema["properties"]["plan"]["type"], "object");
7771
7772 let description = parsed["description"].as_str().expect("a description");
7773 assert!(description.contains("does not return until every step has finished"));
7774 assert!(description.contains("at the same time"));
7775 assert!(description.contains("$<step id>.output"));
7776 }
7777
7778 #[test]
7781 fn an_ungranted_capsule_is_written_no_plan_tool() {
7782 let dir = tempfile::tempdir().unwrap();
7783 super::write_submit_plan_tool_manifest(dir.path(), false).unwrap();
7784 assert!(!dir.path().join("tools").join("submit-plan").exists());
7785
7786 super::write_submit_plan_tool_manifest(dir.path(), true).unwrap();
7787 assert!(dir
7788 .path()
7789 .join("tools")
7790 .join("submit-plan")
7791 .join(PACKED_MANIFEST_ENTRY)
7792 .exists());
7793 }
7794
7795 #[test]
7801 fn the_reserved_set_covers_every_runtime_provided_tool() {
7802 assert_eq!(
7803 super::RESERVED_TOOL_NAMES.len(),
7804 4,
7805 "a new runtime-provided tool must be added to RESERVED_TOOL_NAMES, and this arity \
7806 raised, before its writer can succeed"
7807 );
7808 for name in [
7809 super::SHARE_FILE_TOOL,
7810 super::FETCH_PEER_FILE_TOOL,
7811 super::DELEGATE_TASK_TOOL,
7812 super::SUBMIT_PLAN_TOOL,
7813 ] {
7814 assert!(
7815 super::is_reserved_tool_name(name),
7816 "'{name}' is answered by the runtime and must be reserved"
7817 );
7818 }
7819 assert!(!super::is_reserved_tool_name("bash"));
7821 assert!(!super::is_reserved_tool_name("Delegate-Task"));
7823 }
7824
7825 #[test]
7829 fn an_artifact_manifest_write_refuses_a_reserved_name() {
7830 let dir = tempfile::tempdir().unwrap();
7831 super::write_delegate_task_tool_manifest(dir.path(), &["worker".to_string()]).unwrap();
7832 let synthetic = dir
7833 .path()
7834 .join("tools")
7835 .join(super::DELEGATE_TASK_TOOL)
7836 .join(PACKED_MANIFEST_ENTRY);
7837 let before = std::fs::read_to_string(&synthetic).unwrap();
7838
7839 for name in super::RESERVED_TOOL_NAMES {
7840 let error = super::write_tool_manifest(dir.path(), name, "name: impostor\n")
7841 .expect_err("a reserved name must not be written as an artifact manifest");
7842 assert!(
7843 matches!(error, RuntimeError::ReservedToolName { .. }),
7844 "got {error:?}"
7845 );
7846 assert!(
7847 error.to_string().contains(name),
7848 "the refusal names the collision: {error}"
7849 );
7850 }
7851
7852 assert_eq!(
7853 std::fs::read_to_string(&synthetic).unwrap(),
7854 before,
7855 "the synthetic manifest survives the refused write byte for byte"
7856 );
7857 assert!(
7858 !dir.path()
7859 .join("tools")
7860 .join(super::SHARE_FILE_TOOL)
7861 .exists(),
7862 "a refused write creates no directory"
7863 );
7864 }
7865
7866 #[test]
7868 fn a_runtime_provided_write_refuses_an_unreserved_name() {
7869 let dir = tempfile::tempdir().unwrap();
7870 let error =
7871 super::write_runtime_provided_tool_manifest(dir.path(), "summon-task", "name: x\n")
7872 .expect_err("an unreserved name must not be written as a runtime-provided tool");
7873 assert!(
7874 matches!(error, RuntimeError::RuntimeProvidedToolNotReserved { .. }),
7875 "got {error:?}"
7876 );
7877 assert!(
7878 error.to_string().contains("RESERVED_TOOL_NAMES"),
7879 "the refusal says where to add the name: {error}"
7880 );
7881 assert!(!dir.path().join("tools").exists(), "and writes no file");
7882 }
7883
7884 #[test]
7887 fn a_declared_artifact_under_a_reserved_name_is_refused() {
7888 for name in super::RESERVED_TOOL_NAMES {
7889 let error = super::check_no_reserved_tool_names([name, "corpus"])
7890 .expect_err("a reserved name must be refused as an artifact name");
7891 let rendered = error.to_string();
7892 assert!(rendered.contains(name), "{rendered}");
7893 for reserved in super::RESERVED_TOOL_NAMES {
7894 assert!(
7895 rendered.contains(reserved),
7896 "the refusal lists the whole reserved set: {rendered}"
7897 );
7898 }
7899 }
7900 super::check_no_reserved_tool_names(["corpus", "bash", "share_file"])
7901 .expect("a capsule with no collision is untouched");
7902 }
7903
7904 const CONTEXT_WINDOW_MANIFEST: &str = r#"name: windowed
7908version: 0.1.0
7909runtime: capsule
7910artifacts: []
7911inference:
7912 transport: http
7913 endpoint: https://api.anthropic.com
7914 model: claude-opus-4-5
7915 max_tokens: 4096
7916 driver:
7917 artifact: murmur-driver-anthropic
7918"#;
7919
7920 fn dispatched_context_window(manifest_yaml: &str) -> u64 {
7924 let manifest = murmur_artifact::RuntimeManifest::from_yaml_str(manifest_yaml)
7925 .expect("the manifest under test parses");
7926 u64::from(resolve_context_window(manifest.context.as_ref()))
7927 }
7928
7929 #[test]
7932 fn a_declared_context_window_reaches_on_task_start() {
7933 let yaml = format!("{CONTEXT_WINDOW_MANIFEST}context:\n max_tokens: 200000\n");
7934 assert_eq!(dispatched_context_window(&yaml), 200_000);
7935 }
7936
7937 #[test]
7940 fn no_context_block_sends_a_zero_context_window() {
7941 assert_eq!(dispatched_context_window(CONTEXT_WINDOW_MANIFEST), 0);
7942 }
7943
7944 fn peer_export(max_ttl_secs: Option<u64>) -> murmur_artifact::PeerFilesExport {
7947 murmur_artifact::PeerFilesExport {
7948 root: "out/".to_string(),
7949 max_ttl_secs,
7950 max_bytes: 10 * 1024 * 1024,
7951 }
7952 }
7953
7954 fn lifecycle_with(after_task: murmur_artifact::AfterTask) -> LifecycleConfig {
7955 LifecycleConfig {
7956 after_task,
7957 ..Default::default()
7958 }
7959 }
7960
7961 #[test]
7964 fn an_ephemeral_capsule_may_declare_any_handle_ttl_or_none() {
7965 for declared in [None, Some(1), Some(900), Some(86_400), Some(u64::MAX)] {
7966 assert!(
7967 check_persistent_handle_ttl(
7968 Some(&peer_export(declared)),
7969 &lifecycle_with(murmur_artifact::AfterTask::Exit),
7970 )
7971 .is_ok(),
7972 "exit + max_ttl {declared:?} must launch"
7973 );
7974 }
7975 }
7976
7977 #[test]
7978 fn a_persistent_capsule_must_declare_a_handle_ttl_at_or_under_the_ceiling() {
7979 for declared in [Some(1), Some(600), Some(900)] {
7980 assert!(
7981 check_persistent_handle_ttl(
7982 Some(&peer_export(declared)),
7983 &lifecycle_with(murmur_artifact::AfterTask::Sleep),
7984 )
7985 .is_ok(),
7986 "sleep + max_ttl {declared:?} must launch"
7987 );
7988 }
7989 for declared in [None, Some(901), Some(1800), Some(3600)] {
7990 let error = check_persistent_handle_ttl(
7991 Some(&peer_export(declared)),
7992 &lifecycle_with(murmur_artifact::AfterTask::Sleep),
7993 )
7994 .expect_err("sleep + max_ttl {declared:?} must refuse");
7995 assert!(matches!(
7996 error,
7997 RuntimeError::PersistentCapsuleNeedsHandleTtl { .. }
7998 ));
7999 let rendered = error.to_string();
8000 assert!(
8001 rendered.contains("exports.peer_files.max_ttl"),
8002 "{rendered}"
8003 );
8004 assert!(
8005 rendered.contains("lifecycle.after_task: sleep"),
8006 "{rendered}"
8007 );
8008 assert!(rendered.contains("900s"), "{rendered}");
8009 assert!(rendered.contains("durability"), "{rendered}");
8010 }
8011 }
8012
8013 #[test]
8015 fn a_capsule_without_peer_files_is_unaffected_by_the_ttl_rule() {
8016 for after_task in [
8017 murmur_artifact::AfterTask::Exit,
8018 murmur_artifact::AfterTask::Sleep,
8019 ] {
8020 assert!(check_persistent_handle_ttl(None, &lifecycle_with(after_task)).is_ok());
8021 }
8022 }
8023 use murmur_artifact::{ArtifactMeta, ArtifactRuntime, Registry, ResolvedArtifact, RuntimeType};
8024 use tempfile::TempDir;
8025
8026 use super::*;
8027
8028 fn bootstrap_log_contents(workdir: &Path) -> String {
8029 fs::read_to_string(workdir.join("logs").join("bootstrap.log")).unwrap_or_default()
8030 }
8031
8032 fn lifecycle(task_acceptance: TaskAcceptance, after_task: AfterTask) -> LifecycleConfig {
8033 LifecycleConfig {
8034 task_acceptance,
8035 after_task,
8036 ..LifecycleConfig::default()
8037 }
8038 }
8039
8040 #[test]
8043 fn warn_for_unreachable_delegation_outcomes_writes_the_code_and_link_to_bootstrap_log() {
8044 let temp = TempDir::new().unwrap();
8045 warn_for_unreachable_delegation_outcomes(temp.path(), true, &LifecycleConfig::default());
8046 let log = bootstrap_log_contents(temp.path());
8047
8048 assert!(log.contains(W_SEC_020), "log was: {log}");
8049 assert!(
8050 log.contains(&security_warning_link(W_SEC_020)),
8051 "log was: {log}"
8052 );
8053 assert!(log.contains("lifecycle.task_acceptance"), "log was: {log}");
8054 assert!(log.contains("lifecycle.after_task"), "log was: {log}");
8055 }
8056
8057 #[test]
8060 fn only_a_delegating_capsule_that_cannot_be_told_is_warned() {
8061 assert!(unreachable_delegation_outcomes_warning(
8062 true,
8063 &lifecycle(TaskAcceptance::Queue, AfterTask::Sleep)
8064 )
8065 .is_none());
8066 for lifecycle in [
8067 lifecycle(TaskAcceptance::Queue, AfterTask::Exit),
8068 lifecycle(TaskAcceptance::Single, AfterTask::Sleep),
8069 lifecycle(TaskAcceptance::None, AfterTask::Sleep),
8070 ] {
8071 assert!(
8072 unreachable_delegation_outcomes_warning(true, &lifecycle).is_some(),
8073 "{lifecycle:?} cannot receive a completion"
8074 );
8075 assert!(
8076 unreachable_delegation_outcomes_warning(false, &lifecycle).is_none(),
8077 "a capsule that cannot delegate is not warned about delegating"
8078 );
8079 }
8080 }
8081
8082 #[test]
8086 fn warn_for_unreachable_shell_completions_writes_the_code_and_link_to_bootstrap_log() {
8087 let temp = TempDir::new().unwrap();
8088 warn_for_unreachable_shell_completions(temp.path(), true, &LifecycleConfig::default());
8089 let log = bootstrap_log_contents(temp.path());
8090
8091 assert!(log.contains(W_SEC_022), "log was: {log}");
8092 assert!(
8093 log.contains(&security_warning_link(W_SEC_022)),
8094 "log was: {log}"
8095 );
8096 assert!(log.contains("lifecycle.shell_grace_secs"), "log was: {log}");
8097 assert!(log.contains("lifecycle.task_acceptance"), "log was: {log}");
8098 assert!(log.contains("lifecycle.after_task"), "log was: {log}");
8099 }
8100
8101 #[test]
8104 fn only_a_shell_running_capsule_that_cannot_be_told_is_warned() {
8105 assert!(unreachable_shell_completions_warning(
8106 true,
8107 &lifecycle(TaskAcceptance::Queue, AfterTask::Sleep)
8108 )
8109 .is_none());
8110 for lifecycle in [
8111 lifecycle(TaskAcceptance::Queue, AfterTask::Exit),
8112 lifecycle(TaskAcceptance::Single, AfterTask::Sleep),
8113 lifecycle(TaskAcceptance::None, AfterTask::Sleep),
8114 ] {
8115 assert_eq!(
8116 unreachable_shell_completions_warning(true, &lifecycle).map(|(code, _)| code),
8117 Some(W_SEC_022),
8118 "{lifecycle:?} cannot receive a completion"
8119 );
8120 assert!(
8121 unreachable_shell_completions_warning(false, &lifecycle).is_none(),
8122 "a capsule that cannot run shell commands is not warned about them"
8123 );
8124 }
8125 }
8126
8127 #[test]
8131 fn the_shell_completion_warning_ignores_the_grace_period() {
8132 for shell_grace_secs in [0, 1, 10, 3_600] {
8133 let receiving = LifecycleConfig {
8134 task_acceptance: TaskAcceptance::Queue,
8135 after_task: AfterTask::Sleep,
8136 shell_grace_secs,
8137 ..LifecycleConfig::default()
8138 };
8139 assert!(
8140 unreachable_shell_completions_warning(true, &receiving).is_none(),
8141 "queue+sleep is silent at every grace period, including {shell_grace_secs}"
8142 );
8143 let discarding = LifecycleConfig {
8144 after_task: AfterTask::Exit,
8145 ..receiving
8146 };
8147 assert!(
8148 unreachable_shell_completions_warning(true, &discarding).is_some(),
8149 "exit warns at every grace period, including {shell_grace_secs}"
8150 );
8151 }
8152 }
8153
8154 fn env_allow_policy(names: &[&str], strip: &[&str]) -> CapabilityPolicy {
8157 CapabilityPolicy {
8158 env_allow: names.iter().map(|name| (*name).to_string()).collect(),
8159 shell_strip_env: strip.iter().map(|name| (*name).to_string()).collect(),
8160 ..CapabilityPolicy::default()
8161 }
8162 }
8163
8164 #[test]
8167 fn a_credential_shaped_name_the_backstop_keeps_is_reported_as_reaching_the_guest() {
8168 let grants = secret_shaped_env_grants(
8169 &env_allow_policy(&["DATABASE_PASSWORD"], &[]),
8170 &LifecycleConfig::default(),
8171 );
8172
8173 assert_eq!(
8174 grants,
8175 vec![SecretShapedEnvGrant {
8176 name: "DATABASE_PASSWORD".to_string(),
8177 outlives_launcher: false,
8178 }]
8179 );
8180 let message = secret_shaped_env_grant_message(&grants[0]);
8181 assert!(message.contains("the capsule holds it"), "{message}");
8182 assert!(!message.contains("lifecycle.after_task"), "{message}");
8183 }
8184
8185 #[test]
8190 fn sleep_escalates_a_held_grant_even_without_a_queue() {
8191 for acceptance in [
8192 TaskAcceptance::Single,
8193 TaskAcceptance::Queue,
8194 TaskAcceptance::None,
8195 ] {
8196 let grants = secret_shaped_env_grants(
8197 &env_allow_policy(&["DATABASE_PASSWORD"], &[]),
8198 &lifecycle(acceptance.clone(), AfterTask::Sleep),
8199 );
8200 assert!(
8201 grants[0].outlives_launcher,
8202 "{acceptance:?} + sleep outlives the launching task"
8203 );
8204 let message = secret_shaped_env_grant_message(&grants[0]);
8205 assert!(message.contains("lifecycle.after_task: sleep"), "{message}");
8206 assert!(
8207 message.contains("with nothing left waiting on it"),
8208 "{message}"
8209 );
8210 }
8211 }
8212
8213 #[test]
8216 fn a_name_the_backstop_drops_produces_no_grant() {
8217 assert!(secret_shaped_env_grants(
8218 &env_allow_policy(&["GITHUB_TOKEN"], &[]),
8219 &lifecycle(TaskAcceptance::Queue, AfterTask::Sleep),
8220 )
8221 .is_empty());
8222 }
8223
8224 #[test]
8227 fn a_strip_matched_credential_shaped_name_produces_no_grant() {
8228 assert!(secret_shaped_env_grants(
8229 &env_allow_policy(&["MY_SERVICE_SECRET"], &["*_SERVICE_SECRET"]),
8230 &LifecycleConfig::default(),
8231 )
8232 .is_empty());
8233 }
8234
8235 #[test]
8238 fn segment_matched_credential_names_each_produce_a_grant() {
8239 let grants = secret_shaped_env_grants(
8240 &env_allow_policy(
8241 &["PRIVATE_KEY", "SSH_KEY", "CREDENTIALS", "SENTRY_DSN"],
8242 &[],
8243 ),
8244 &LifecycleConfig::default(),
8245 );
8246
8247 assert_eq!(
8248 grants
8249 .iter()
8250 .map(|grant| grant.name.as_str())
8251 .collect::<Vec<_>>(),
8252 vec!["PRIVATE_KEY", "SSH_KEY", "CREDENTIALS", "SENTRY_DSN"]
8253 );
8254 }
8255
8256 #[test]
8259 fn sleep_without_a_credential_shaped_name_decides_nothing() {
8260 assert!(secret_shaped_env_grants(
8261 &env_allow_policy(&["HOME", "TZ", "LANG", "BUILD_NUMBER"], &[]),
8262 &lifecycle(TaskAcceptance::Queue, AfterTask::Sleep),
8263 )
8264 .is_empty());
8265 assert!(secret_shaped_env_grants(
8266 &CapabilityPolicy::default(),
8267 &lifecycle(TaskAcceptance::Queue, AfterTask::Sleep),
8268 )
8269 .is_empty());
8270 }
8271
8272 #[test]
8275 fn every_distinct_credential_shaped_name_is_judged_once_in_declaration_order() {
8276 let grants = secret_shaped_env_grants(
8277 &env_allow_policy(
8278 &[
8279 "DATABASE_PASSWORD",
8280 "HOME",
8281 "GITHUB_TOKEN",
8282 "SIGNING_KEY",
8283 "DATABASE_PASSWORD",
8284 ],
8285 &[],
8286 ),
8287 &LifecycleConfig::default(),
8288 );
8289
8290 assert_eq!(
8291 grants
8292 .iter()
8293 .map(|grant| grant.name.as_str())
8294 .collect::<Vec<_>>(),
8295 vec!["DATABASE_PASSWORD", "SIGNING_KEY"]
8296 );
8297 }
8298
8299 #[test]
8302 fn stripped_env_allow_entries_are_distinct_ordered_and_attributed() {
8303 let entries = super::stripped_env_allow_entries(&env_allow_policy(
8304 &[
8305 "NPM_TOKEN",
8306 "TZ",
8307 "MY_SERVICE_SECRET",
8308 "NPM_TOKEN",
8309 "AWS_REGION",
8310 ],
8311 &["*_SERVICE_SECRET"],
8312 ));
8313
8314 assert_eq!(
8315 entries,
8316 vec![
8317 super::StrippedEnvAllowEntry {
8318 name: "NPM_TOKEN".to_string(),
8319 pattern: "NPM_TOKEN".to_string(),
8320 source: crate::shell::BackstopPatternSource::Builtin,
8321 },
8322 super::StrippedEnvAllowEntry {
8323 name: "MY_SERVICE_SECRET".to_string(),
8324 pattern: "*_SERVICE_SECRET".to_string(),
8325 source: crate::shell::BackstopPatternSource::StripEnv,
8326 },
8327 super::StrippedEnvAllowEntry {
8328 name: "AWS_REGION".to_string(),
8329 pattern: "AWS_*".to_string(),
8330 source: crate::shell::BackstopPatternSource::Builtin,
8331 },
8332 ]
8333 );
8334 }
8335
8336 #[test]
8337 fn env_allow_names_the_backstop_keeps_pass_the_check() {
8338 assert!(super::check_env_allow_reaches_guests(&env_allow_policy(
8339 &["TZ", "PRIVATE_KEY", "DATABASE_PASSWORD"],
8340 &[],
8341 ))
8342 .is_ok());
8343 assert!(super::check_env_allow_reaches_guests(&CapabilityPolicy::default()).is_ok());
8344 }
8345
8346 #[test]
8348 fn the_refusal_names_each_entry_its_pattern_and_the_patterns_source() {
8349 let error = super::check_env_allow_reaches_guests(&env_allow_policy(
8350 &["GITHUB_TOKEN", "MY_SERVICE_SECRET"],
8351 &["*_SERVICE_SECRET"],
8352 ))
8353 .unwrap_err();
8354 let message = error.to_string();
8355
8356 assert!(message.contains("capabilities.env.allow"), "{message}");
8357 assert!(
8358 message.contains(
8359 "'GITHUB_TOKEN' (credential backstop pattern 'GITHUB_TOKEN'), 'MY_SERVICE_SECRET' \
8360 (capabilities.shell.strip_env pattern '*_SERVICE_SECRET')"
8361 ),
8362 "{message}"
8363 );
8364 }
8365
8366 fn inference_without_prompt() -> murmur_artifact::InferenceConfig {
8369 murmur_artifact::InferenceConfig {
8370 transport: "http".to_string(),
8371 endpoint: Some("http://127.0.0.1:1".to_string()),
8372 model: "test-model".to_string(),
8373 api_key: None,
8374 driver: None,
8375 command: None,
8376 compaction: None,
8377 system_prompt: None,
8378 system_prompt_file: None,
8379 system_prompt_artifact: None,
8380 max_turns: 10,
8381 max_tokens: None,
8382 max_session_tokens: None,
8383 }
8384 }
8385
8386 #[test]
8387 fn resolve_system_prompt_returns_none_when_nothing_is_declared() {
8388 let tmp = TempDir::new().unwrap();
8389 let resolved =
8390 resolve_system_prompt(tmp.path(), tmp.path(), &inference_without_prompt()).unwrap();
8391 assert_eq!(resolved, None);
8392 }
8393
8394 #[test]
8395 fn resolve_system_prompt_returns_the_inline_prompt_verbatim() {
8396 let tmp = TempDir::new().unwrap();
8397 let inference = murmur_artifact::InferenceConfig {
8398 system_prompt: Some("Be terse.".to_string()),
8399 ..inference_without_prompt()
8400 };
8401 let resolved = resolve_system_prompt(tmp.path(), tmp.path(), &inference).unwrap();
8402 assert_eq!(resolved.as_deref(), Some("Be terse."));
8403 }
8404
8405 #[test]
8409 fn resolve_system_prompt_reads_the_prompt_file_verbatim_relative_to_the_manifest_dir() {
8410 let manifest_dir = TempDir::new().unwrap();
8411 let workdir = TempDir::new().unwrap();
8412 fs::write(manifest_dir.path().join("conventions.md"), " Be terse.\n").unwrap();
8413
8414 let inference = murmur_artifact::InferenceConfig {
8415 system_prompt_file: Some("conventions.md".to_string()),
8416 ..inference_without_prompt()
8417 };
8418 let resolved =
8419 resolve_system_prompt(manifest_dir.path(), workdir.path(), &inference).unwrap();
8420 assert_eq!(resolved.as_deref(), Some(" Be terse.\n"));
8421 }
8422
8423 #[test]
8424 fn resolve_system_prompt_errors_when_the_prompt_file_is_missing() {
8425 let tmp = TempDir::new().unwrap();
8426 let inference = murmur_artifact::InferenceConfig {
8427 system_prompt_file: Some("missing-conventions.md".to_string()),
8428 ..inference_without_prompt()
8429 };
8430 match resolve_system_prompt(tmp.path(), tmp.path(), &inference) {
8431 Err(RuntimeError::SystemPromptFileRead { path, .. }) => {
8432 assert!(path.ends_with("missing-conventions.md"), "got {path}");
8433 }
8434 other => panic!("expected SystemPromptFileRead, got {other:?}"),
8435 }
8436 }
8437
8438 #[test]
8442 fn resolve_system_prompt_reads_skill_md_from_the_staged_artifact() {
8443 let manifest_dir = TempDir::new().unwrap();
8444 let workdir = TempDir::new().unwrap();
8445 let skill_dir = workdir.path().join("tools").join("house-style");
8446 fs::create_dir_all(&skill_dir).unwrap();
8447 fs::write(skill_dir.join("skill.md"), "# House style\nBe terse.").unwrap();
8448
8449 let inference = murmur_artifact::InferenceConfig {
8450 system_prompt_artifact: Some("house-style".to_string()),
8451 ..inference_without_prompt()
8452 };
8453 let resolved =
8454 resolve_system_prompt(manifest_dir.path(), workdir.path(), &inference).unwrap();
8455 assert_eq!(resolved.as_deref(), Some("# House style\nBe terse."));
8456 }
8457
8458 #[test]
8459 fn resolve_system_prompt_errors_when_the_prompt_artifact_has_no_skill_md() {
8460 let tmp = TempDir::new().unwrap();
8461 let inference = murmur_artifact::InferenceConfig {
8462 system_prompt_artifact: Some("house-style".to_string()),
8463 ..inference_without_prompt()
8464 };
8465 match resolve_system_prompt(tmp.path(), tmp.path(), &inference) {
8466 Err(RuntimeError::SystemPromptArtifactRead { name, .. }) => {
8467 assert_eq!(name, "house-style");
8468 }
8469 other => panic!("expected SystemPromptArtifactRead, got {other:?}"),
8470 }
8471 }
8472
8473 #[test]
8477 fn resolve_system_prompt_ignores_cleared_declarations() {
8478 let tmp = TempDir::new().unwrap();
8479 let inference = murmur_artifact::InferenceConfig {
8480 system_prompt: Some("CLI prompt".to_string()),
8481 system_prompt_file: None,
8482 system_prompt_artifact: None,
8483 ..inference_without_prompt()
8484 };
8485 let resolved = resolve_system_prompt(tmp.path(), tmp.path(), &inference).unwrap();
8486 assert_eq!(resolved.as_deref(), Some("CLI prompt"));
8487 }
8488
8489 #[test]
8490 fn bash_and_network_together_trigger_warning() {
8491 let tmp = TempDir::new().unwrap();
8492 let policy = CapabilityPolicy {
8493 shell_allow: vec!["bash".to_string()],
8494 network_allow: vec!["https://api.example.com".to_string()],
8495 ..Default::default()
8496 };
8497 warn_if_bash_network_bypass(tmp.path(), &policy);
8498 let log = bootstrap_log_contents(tmp.path());
8499 assert!(log.contains("bash"), "log should mention bash: {log}");
8500 assert!(log.contains("network"), "log should mention network: {log}");
8501 assert!(
8502 log.contains(W_SEC_003),
8503 "log should carry its warning code: {log}"
8504 );
8505 assert!(
8506 log.contains(&security_warning_link(W_SEC_003)),
8507 "log should link to the diagnostics doc page: {log}"
8508 );
8509 }
8510
8511 fn compaction_config(
8512 system_prompt: Option<&str>,
8513 system_prompt_file: Option<&str>,
8514 ) -> murmur_artifact::CompactionConfig {
8515 murmur_artifact::CompactionConfig {
8516 threshold: None,
8517 model: Some("compaction-model".to_string()),
8518 system_prompt: system_prompt.map(str::to_string),
8519 system_prompt_file: system_prompt_file.map(str::to_string),
8520 dump_summaries: None,
8521 }
8522 }
8523
8524 #[test]
8527 fn compaction_system_prompt_file_resolves_relative_to_manifest_dir() {
8528 let manifest_dir = TempDir::new().unwrap();
8529 let body = "Summarize aggressively.\nKeep file paths.\n";
8530 fs::write(manifest_dir.path().join("compaction-instructions.md"), body).unwrap();
8531
8532 let resolved = resolve_compaction_system_prompt(
8533 manifest_dir.path(),
8534 Some(&compaction_config(None, Some("compaction-instructions.md"))),
8535 )
8536 .expect("file resolves");
8537
8538 assert_eq!(resolved, Some(body.to_string()));
8539 }
8540
8541 #[test]
8543 fn compaction_inline_system_prompt_resolves_without_reading_a_file() {
8544 let manifest_dir = TempDir::new().unwrap();
8545
8546 let resolved = resolve_compaction_system_prompt(
8547 manifest_dir.path(),
8548 Some(&compaction_config(Some("inline prompt"), None)),
8549 )
8550 .expect("inline prompt resolves");
8551
8552 assert_eq!(resolved, Some("inline prompt".to_string()));
8553 }
8554
8555 #[test]
8558 fn compaction_system_prompt_absent_resolves_to_none() {
8559 let manifest_dir = TempDir::new().unwrap();
8560
8561 assert_eq!(
8562 resolve_compaction_system_prompt(
8563 manifest_dir.path(),
8564 Some(&compaction_config(None, None))
8565 )
8566 .unwrap(),
8567 None
8568 );
8569 assert_eq!(
8570 resolve_compaction_system_prompt(manifest_dir.path(), None).unwrap(),
8571 None
8572 );
8573 }
8574
8575 #[test]
8579 fn compaction_system_prompt_file_missing_reports_compaction_variant() {
8580 let manifest_dir = TempDir::new().unwrap();
8581
8582 let err = resolve_compaction_system_prompt(
8583 manifest_dir.path(),
8584 Some(&compaction_config(None, Some("nope.md"))),
8585 )
8586 .expect_err("missing file must fail");
8587
8588 match &err {
8589 RuntimeError::CompactionSystemPromptFileRead { path, .. } => {
8590 assert!(
8591 path.ends_with("nope.md"),
8592 "error should name the resolved path, got {path}"
8593 );
8594 assert!(
8595 path.starts_with(&manifest_dir.path().display().to_string()),
8596 "path should be manifest-dir relative, got {path}"
8597 );
8598 }
8599 other => panic!("expected CompactionSystemPromptFileRead, got {other:?}"),
8600 }
8601 assert!(err
8602 .to_string()
8603 .contains("inference.compaction.system_prompt_file"));
8604 }
8605
8606 #[test]
8607 fn network_without_bash_does_not_warn() {
8608 let tmp = TempDir::new().unwrap();
8609 let policy = CapabilityPolicy {
8610 shell_allow: vec!["cargo".to_string(), "git".to_string()],
8611 network_allow: vec!["https://api.example.com".to_string()],
8612 ..Default::default()
8613 };
8614 warn_if_bash_network_bypass(tmp.path(), &policy);
8615 assert!(bootstrap_log_contents(tmp.path()).is_empty());
8616 }
8617
8618 #[test]
8619 fn bash_without_network_does_not_warn() {
8620 let tmp = TempDir::new().unwrap();
8621 let policy = CapabilityPolicy {
8622 shell_allow: vec!["bash".to_string()],
8623 network_allow: Vec::new(),
8624 ..Default::default()
8625 };
8626 warn_if_bash_network_bypass(tmp.path(), &policy);
8627 assert!(bootstrap_log_contents(tmp.path()).is_empty());
8628 }
8629
8630 #[test]
8631 fn neither_declared_does_not_warn() {
8632 let tmp = TempDir::new().unwrap();
8633 warn_if_bash_network_bypass(tmp.path(), &CapabilityPolicy::default());
8634 assert!(bootstrap_log_contents(tmp.path()).is_empty());
8635 }
8636
8637 #[test]
8638 fn non_bash_shell_interpreter_does_not_warn() {
8639 let tmp = TempDir::new().unwrap();
8640 let policy = CapabilityPolicy {
8641 shell_allow: vec!["sh".to_string()],
8642 network_allow: vec!["https://api.example.com".to_string()],
8643 ..Default::default()
8644 };
8645 warn_if_bash_network_bypass(tmp.path(), &policy);
8646 assert!(
8647 bootstrap_log_contents(tmp.path()).is_empty(),
8648 "exact match on \"bash\" literal must not fire for other shell interpreters like sh"
8649 );
8650 }
8651
8652 #[test]
8653 fn allowlist_blocks_unlisted_before_dispatch() {
8654 let allowlist = HashSet::from(["echo-tool".to_string()]);
8655 let mut dispatched = false;
8656
8657 let result = enforce_allowlist(&allowlist, "missing-tool", || {
8658 dispatched = true;
8659 Ok::<_, String>(())
8660 });
8661
8662 assert!(!dispatched);
8663 assert!(result.is_err());
8664 assert!(result
8665 .unwrap_err()
8666 .contains("not declared in manifest allowlist"));
8667 }
8668
8669 #[test]
8670 fn allowlist_dispatches_listed_tool() {
8671 let allowlist = HashSet::from(["echo-tool".to_string()]);
8672 let result = enforce_allowlist(&allowlist, "echo-tool", || Ok::<_, String>("ok")).unwrap();
8673 assert_eq!(result, "ok");
8674 }
8675
8676 fn iface_double(engine: &wasmtime::Engine, iface: &str) -> wasmtime::component::Component {
8681 let wat = format!(
8682 "(component\n\
8683 (instance $i)\n\
8684 (export \"{iface}\" (instance $i))\n\
8685 )"
8686 );
8687 let bytes = wat::parse_str(&wat).expect("component WAT parses");
8688 wasmtime::component::Component::new(engine, bytes).expect("component compiles")
8689 }
8690
8691 fn iface_test_engine() -> wasmtime::Engine {
8692 let mut config = wasmtime::Config::new();
8693 config.wasm_component_model(true);
8694 wasmtime::Engine::new(&config).expect("engine builds")
8695 }
8696
8697 fn instantiate_iface_double(
8698 engine: &wasmtime::Engine,
8699 iface: &str,
8700 ) -> (wasmtime::component::Instance, Store<()>) {
8701 let component = iface_double(engine, iface);
8702 let linker = wasmtime::component::Linker::new(engine);
8703 let mut store = Store::new(engine, ());
8704 let instance = linker
8705 .instantiate(&mut store, &component)
8706 .expect("component with no imports instantiates");
8707 (instance, store)
8708 }
8709
8710 #[test]
8713 fn resolve_versioned_iface_finds_versioned_name() {
8714 let engine = iface_test_engine();
8715 let (instance, mut store) = instantiate_iface_double(&engine, WIT_CAPSULE_IFACE_VERSIONED);
8716 let found = resolve_versioned_iface(&instance, &mut store, WIT_CAPSULE_IFACE_VERSIONED);
8717 assert!(
8718 found.is_some(),
8719 "a component exporting the versioned name must resolve"
8720 );
8721 }
8722
8723 #[test]
8727 fn resolve_versioned_iface_rejects_unversioned_only_name() {
8728 let engine = iface_test_engine();
8729 let (instance, mut store) = instantiate_iface_double(&engine, "murmur:tool/run");
8730 let found = resolve_versioned_iface(&instance, &mut store, WIT_TOOL_IFACE_VERSIONED);
8731 assert!(
8732 found.is_none(),
8733 "a component exporting only the legacy unversioned name must no longer resolve"
8734 );
8735 }
8736
8737 #[test]
8741 fn resolve_versioned_iface_returns_none_when_neither_name_matches() {
8742 let engine = iface_test_engine();
8743 let (instance, mut store) = instantiate_iface_double(&engine, "murmur:capsule/nonexistent");
8744 let found = resolve_versioned_iface(&instance, &mut store, WIT_CAPSULE_IFACE_VERSIONED);
8745 assert!(
8746 found.is_none(),
8747 "a component exporting neither probed name must not resolve"
8748 );
8749 }
8750
8751 #[test]
8752 fn generate_session_id_is_unique() {
8753 let first = generate_session_id();
8754 let second = generate_session_id();
8755
8756 assert_ne!(first, second);
8757 assert!(first.starts_with("ses_"), "session id must start with ses_");
8758 assert_eq!(
8759 first.len(),
8760 36,
8761 "session id must be 36 chars (ses_ + 32 hex)"
8762 );
8763 uuid::Uuid::parse_str(&first[4..]).expect("session id suffix should be a valid UUID");
8764 }
8765
8766 #[test]
8767 fn sha_verification_happens_before_component_compile() {
8768 struct FakeRegistry;
8769
8770 impl Registry for FakeRegistry {
8771 fn resolve(
8772 &self,
8773 name: &str,
8774 version: &str,
8775 ) -> Result<ResolvedArtifact, RegistryError> {
8776 Ok(ResolvedArtifact {
8777 meta: ArtifactMeta {
8778 name: name.to_string(),
8779 version: version.to_string(),
8780 runtime: RuntimeType::Wasm,
8781 artifact_runtime: "wasm".to_string(),
8782 platforms: Vec::new(),
8783 description: None,
8784 tags: Vec::new(),
8785 wit_contracts: None,
8786 },
8787 bytes: b"not-a-real-zip".to_vec().into(),
8788 sha256: "definitely-wrong".to_string(),
8789 platform_match: murmur_artifact::PlatformMatch::NotApplicable,
8790 })
8791 }
8792
8793 fn publish(
8794 &self,
8795 _meta: ArtifactMeta,
8796 _bytes: &[u8],
8797 ) -> Result<murmur_artifact::PublishResult, RegistryError> {
8798 unreachable!()
8799 }
8800
8801 fn list_index(&self) -> Result<Vec<ArtifactMeta>, RegistryError> {
8802 unreachable!()
8803 }
8804 }
8805
8806 let tempdir = tempfile::tempdir().unwrap();
8807 let request = StageRequest {
8808 credentials_file: None,
8809 manifest_dir: tempdir.path().to_path_buf(),
8810 capsule_name: "test".to_string(),
8811 capsule_version: String::new(),
8812 capsule_component_bytes: b"not-a-component".to_vec(),
8813 artifacts: vec![crate::types::ArtifactRequest {
8814 name: "echo-tool".to_string(),
8815 version: "0.0.1".to_string(),
8816 runtime: ArtifactRuntime::Tool,
8817 source: None,
8818 on_overflow: Default::default(),
8819 capabilities: None,
8820 config: None,
8821 }],
8822 allowlisted_tools: HashSet::from(["echo-tool".to_string()]),
8823 lock_expectations: None,
8824 capability_policy: CapabilityPolicy::default(),
8825 inference: None,
8826 system_prompt_overridden: false,
8827 context: None,
8828 context_id: None,
8829 resume: None,
8830 otel_endpoint: None,
8831 eval_config_json: None,
8832 case_id: None,
8833 dataset_id: None,
8834 lifecycle: None,
8835 lifecycle_override: None,
8836 trace: None,
8837 workdir: None,
8838 bind_addr: "127.0.0.1".to_string(),
8839 internal_port: None,
8840 declared_containment_floor: murmur_artifact::ContainmentClass::Advisory,
8841 exports: None,
8842 spawn_grant: None,
8843 machine_tokens_per_day: None,
8844 };
8845
8846 let err = match stage_session(Arc::new(FakeRegistry), request) {
8847 Ok(_) => panic!("expected stage_session to fail"),
8848 Err(err) => err,
8849 };
8850 assert!(matches!(
8851 err,
8852 RuntimeError::ArtifactIntegrityFailed { name, version }
8853 if name == "echo-tool" && version == "0.0.1"
8854 ));
8855 }
8856
8857 #[test]
8858 fn lock_expectation_mismatch_returns_integrity_failure() {
8859 struct FakeRegistry;
8860
8861 impl Registry for FakeRegistry {
8862 fn resolve(
8863 &self,
8864 name: &str,
8865 version: &str,
8866 ) -> Result<ResolvedArtifact, RegistryError> {
8867 Ok(ResolvedArtifact {
8868 meta: ArtifactMeta {
8869 name: name.to_string(),
8870 version: version.to_string(),
8871 runtime: RuntimeType::Wasm,
8872 artifact_runtime: "wasm".to_string(),
8873 platforms: Vec::new(),
8874 description: None,
8875 tags: Vec::new(),
8876 wit_contracts: None,
8877 },
8878 bytes: b"not-a-real-zip".to_vec().into(),
8879 sha256: murmur_artifact::sha256_hex(b"not-a-real-zip"),
8880 platform_match: murmur_artifact::PlatformMatch::NotApplicable,
8881 })
8882 }
8883
8884 fn publish(
8885 &self,
8886 _meta: ArtifactMeta,
8887 _bytes: &[u8],
8888 ) -> Result<murmur_artifact::PublishResult, RegistryError> {
8889 unreachable!()
8890 }
8891
8892 fn list_index(&self) -> Result<Vec<ArtifactMeta>, RegistryError> {
8893 unreachable!()
8894 }
8895 }
8896
8897 let tempdir = tempfile::tempdir().unwrap();
8898 let request = StageRequest {
8899 credentials_file: None,
8900 manifest_dir: tempdir.path().to_path_buf(),
8901 capsule_name: "test".to_string(),
8902 capsule_version: String::new(),
8903 capsule_component_bytes: Vec::new(),
8904 artifacts: vec![crate::types::ArtifactRequest {
8905 name: "echo-tool".to_string(),
8906 version: "0.0.1".to_string(),
8907 runtime: ArtifactRuntime::Tool,
8908 source: None,
8909 on_overflow: Default::default(),
8910 capabilities: None,
8911 config: None,
8912 }],
8913 allowlisted_tools: HashSet::from(["echo-tool".to_string()]),
8914 lock_expectations: Some(vec![crate::types::LockExpectation {
8915 name: "echo-tool".to_string(),
8916 resolved_version: "0.0.1".to_string(),
8917 sha256: "different".to_string(),
8918 }]),
8919 capability_policy: CapabilityPolicy::default(),
8920 inference: None,
8921 system_prompt_overridden: false,
8922 context: None,
8923 context_id: None,
8924 resume: None,
8925 otel_endpoint: None,
8926 eval_config_json: None,
8927 case_id: None,
8928 dataset_id: None,
8929 lifecycle: None,
8930 lifecycle_override: None,
8931 trace: None,
8932 workdir: None,
8933 bind_addr: "127.0.0.1".to_string(),
8934 internal_port: None,
8935 declared_containment_floor: murmur_artifact::ContainmentClass::Advisory,
8936 exports: None,
8937 spawn_grant: None,
8938 machine_tokens_per_day: None,
8939 };
8940
8941 let err = match stage_session(Arc::new(FakeRegistry), request) {
8942 Ok(_) => panic!("expected stage_session to fail"),
8943 Err(err) => err,
8944 };
8945 assert!(matches!(
8946 err,
8947 RuntimeError::ArtifactIntegrityFailed { name, version }
8948 if name == "echo-tool" && version == "0.0.1"
8949 ));
8950 }
8951
8952 #[test]
8953 fn lock_expectations_require_entry_for_each_manifest_artifact() {
8954 struct FakeRegistry;
8955
8956 impl Registry for FakeRegistry {
8957 fn resolve(
8958 &self,
8959 _name: &str,
8960 _version: &str,
8961 ) -> Result<ResolvedArtifact, RegistryError> {
8962 unreachable!("lock validation should fail before any registry resolve call")
8963 }
8964
8965 fn publish(
8966 &self,
8967 _meta: ArtifactMeta,
8968 _bytes: &[u8],
8969 ) -> Result<murmur_artifact::PublishResult, RegistryError> {
8970 unreachable!()
8971 }
8972
8973 fn list_index(&self) -> Result<Vec<ArtifactMeta>, RegistryError> {
8974 unreachable!()
8975 }
8976 }
8977
8978 let tempdir = tempfile::tempdir().unwrap();
8979 let request = StageRequest {
8980 credentials_file: None,
8981 manifest_dir: tempdir.path().to_path_buf(),
8982 capsule_name: "test".to_string(),
8983 capsule_version: String::new(),
8984 capsule_component_bytes: Vec::new(),
8985 artifacts: vec![crate::types::ArtifactRequest {
8986 name: "echo-tool".to_string(),
8987 version: "0.0.1".to_string(),
8988 runtime: ArtifactRuntime::Tool,
8989 source: None,
8990 on_overflow: Default::default(),
8991 capabilities: None,
8992 config: None,
8993 }],
8994 allowlisted_tools: HashSet::from(["echo-tool".to_string()]),
8995 lock_expectations: Some(vec![crate::types::LockExpectation {
8996 name: "different-tool".to_string(),
8997 resolved_version: "0.0.1".to_string(),
8998 sha256: "abc".to_string(),
8999 }]),
9000 capability_policy: CapabilityPolicy::default(),
9001 inference: None,
9002 system_prompt_overridden: false,
9003 context: None,
9004 context_id: None,
9005 resume: None,
9006 otel_endpoint: None,
9007 eval_config_json: None,
9008 case_id: None,
9009 dataset_id: None,
9010 lifecycle: None,
9011 lifecycle_override: None,
9012 trace: None,
9013 workdir: None,
9014 bind_addr: "127.0.0.1".to_string(),
9015 internal_port: None,
9016 declared_containment_floor: murmur_artifact::ContainmentClass::Advisory,
9017 exports: None,
9018 spawn_grant: None,
9019 machine_tokens_per_day: None,
9020 };
9021
9022 let err = match stage_session(Arc::new(FakeRegistry), request) {
9023 Ok(_) => panic!("expected stage_session to fail"),
9024 Err(err) => err,
9025 };
9026 assert!(matches!(
9027 err,
9028 RuntimeError::LockMissingEntry { name } if name == "echo-tool"
9029 ));
9030 }
9031
9032 #[test]
9033 fn lock_expectation_version_mismatch_is_reported() {
9034 struct FakeRegistry;
9035
9036 impl Registry for FakeRegistry {
9037 fn resolve(
9038 &self,
9039 _name: &str,
9040 _version: &str,
9041 ) -> Result<ResolvedArtifact, RegistryError> {
9042 unreachable!("version mismatch should fail before registry resolve")
9043 }
9044
9045 fn publish(
9046 &self,
9047 _meta: ArtifactMeta,
9048 _bytes: &[u8],
9049 ) -> Result<murmur_artifact::PublishResult, RegistryError> {
9050 unreachable!()
9051 }
9052
9053 fn list_index(&self) -> Result<Vec<ArtifactMeta>, RegistryError> {
9054 unreachable!()
9055 }
9056 }
9057
9058 let tempdir = tempfile::tempdir().unwrap();
9059 let request = StageRequest {
9060 credentials_file: None,
9061 manifest_dir: tempdir.path().to_path_buf(),
9062 capsule_name: "test".to_string(),
9063 capsule_version: String::new(),
9064 capsule_component_bytes: Vec::new(),
9065 artifacts: vec![crate::types::ArtifactRequest {
9066 name: "echo-tool".to_string(),
9067 version: "0.0.9".to_string(),
9068 runtime: ArtifactRuntime::Tool,
9069 source: None,
9070 on_overflow: Default::default(),
9071 capabilities: None,
9072 config: None,
9073 }],
9074 allowlisted_tools: HashSet::from(["echo-tool".to_string()]),
9075 lock_expectations: Some(vec![crate::types::LockExpectation {
9076 name: "echo-tool".to_string(),
9077 resolved_version: "0.0.1".to_string(),
9078 sha256: "abc".to_string(),
9079 }]),
9080 capability_policy: CapabilityPolicy::default(),
9081 inference: None,
9082 system_prompt_overridden: false,
9083 context: None,
9084 context_id: None,
9085 resume: None,
9086 otel_endpoint: None,
9087 eval_config_json: None,
9088 case_id: None,
9089 dataset_id: None,
9090 lifecycle: None,
9091 lifecycle_override: None,
9092 trace: None,
9093 workdir: None,
9094 bind_addr: "127.0.0.1".to_string(),
9095 internal_port: None,
9096 declared_containment_floor: murmur_artifact::ContainmentClass::Advisory,
9097 exports: None,
9098 spawn_grant: None,
9099 machine_tokens_per_day: None,
9100 };
9101
9102 let err = match stage_session(Arc::new(FakeRegistry), request) {
9103 Ok(_) => panic!("expected stage_session to fail"),
9104 Err(err) => err,
9105 };
9106 assert!(matches!(
9107 err,
9108 RuntimeError::LockVersionMismatch {
9109 name,
9110 requested,
9111 pinned,
9112 } if name == "echo-tool" && requested == "0.0.9" && pinned == "0.0.1"
9113 ));
9114 }
9115
9116 #[test]
9119 fn load_local_skill_md_reads_file_path() {
9120 let dir = tempfile::tempdir().unwrap();
9121 let skill = dir.path().join("skill.md");
9122 fs::write(&skill, b"# hi from file").unwrap();
9123 let bytes = load_local_skill_md(dir.path(), "skill.md").unwrap();
9124 assert_eq!(bytes, b"# hi from file");
9125 }
9126
9127 #[test]
9128 fn load_local_skill_md_finds_skill_md_in_directory_case_insensitive() {
9129 let manifest_dir = tempfile::tempdir().unwrap();
9130 let skill_dir = manifest_dir.path().join("skills").join("my-skill");
9131 fs::create_dir_all(&skill_dir).unwrap();
9132 fs::write(skill_dir.join("SKILL.MD"), b"# upper").unwrap();
9134 let bytes = load_local_skill_md(manifest_dir.path(), "skills/my-skill").unwrap();
9135 assert_eq!(bytes, b"# upper");
9136 }
9137
9138 #[test]
9139 fn load_local_skill_md_missing_path_errors_with_name() {
9140 let dir = tempfile::tempdir().unwrap();
9141 let err = load_local_skill_md(dir.path(), "does/not/exist").unwrap_err();
9142 match err {
9143 RuntimeError::SkillSourceNotFound { path } => {
9144 assert!(path.contains("does/not/exist"), "path was: {path}");
9145 }
9146 other => panic!("expected SkillSourceNotFound, got {other:?}"),
9147 }
9148 }
9149
9150 #[test]
9151 fn load_local_skill_md_directory_without_skill_md_errors() {
9152 let dir = tempfile::tempdir().unwrap();
9153 let empty = dir.path().join("empty");
9154 fs::create_dir_all(&empty).unwrap();
9155 let err = load_local_skill_md(dir.path(), "empty").unwrap_err();
9156 match err {
9157 RuntimeError::SkillSourceMissingSkillMd { path } => {
9158 assert!(path.contains("empty"), "path was: {path}");
9159 }
9160 other => panic!("expected SkillSourceMissingSkillMd, got {other:?}"),
9161 }
9162 }
9163
9164 #[test]
9165 fn load_local_skill_md_absolute_path_ignores_manifest_dir() {
9166 let manifest_dir = tempfile::tempdir().unwrap();
9167 let elsewhere = tempfile::tempdir().unwrap();
9168 let skill = elsewhere.path().join("skill.md");
9169 fs::write(&skill, b"# absolute").unwrap();
9170 let bytes = load_local_skill_md(manifest_dir.path(), &skill.to_string_lossy()).unwrap();
9171 assert_eq!(bytes, b"# absolute");
9172 }
9173
9174 #[test]
9177 fn stage_session_refuses_an_env_allow_entry_the_backstop_strips() {
9178 struct PanicRegistry;
9179 impl Registry for PanicRegistry {
9180 fn resolve(&self, _: &str, _: &str) -> Result<ResolvedArtifact, RegistryError> {
9181 panic!("the refusal must precede registry resolution");
9182 }
9183 fn publish(
9184 &self,
9185 _: ArtifactMeta,
9186 _: &[u8],
9187 ) -> Result<murmur_artifact::PublishResult, RegistryError> {
9188 panic!("the refusal must precede any registry call");
9189 }
9190 fn list_index(&self) -> Result<Vec<ArtifactMeta>, RegistryError> {
9191 panic!("the refusal must precede any registry call");
9192 }
9193 }
9194
9195 let project = tempfile::tempdir().unwrap();
9196 let request = StageRequest {
9197 credentials_file: None,
9198 manifest_dir: project.path().to_path_buf(),
9199 capsule_name: "test".to_string(),
9200 capsule_version: "0.0.1".to_string(),
9201 capsule_component_bytes: Vec::new(),
9202 artifacts: vec![crate::types::ArtifactRequest {
9203 name: "some-tool".to_string(),
9204 version: "0.1.0".to_string(),
9205 runtime: ArtifactRuntime::Tool,
9206 source: None,
9207 on_overflow: Default::default(),
9208 capabilities: None,
9209 config: None,
9210 }],
9211 allowlisted_tools: HashSet::new(),
9212 lock_expectations: None,
9213 capability_policy: env_allow_policy(&["GITHUB_TOKEN"], &[]),
9214 inference: None,
9215 system_prompt_overridden: false,
9216 context: None,
9217 context_id: None,
9218 resume: None,
9219 otel_endpoint: None,
9220 eval_config_json: None,
9221 case_id: None,
9222 dataset_id: None,
9223 lifecycle: None,
9224 lifecycle_override: None,
9225 trace: None,
9226 workdir: None,
9227 bind_addr: "127.0.0.1".to_string(),
9228 internal_port: None,
9229 declared_containment_floor: murmur_artifact::ContainmentClass::Advisory,
9230 exports: None,
9231 spawn_grant: None,
9232 machine_tokens_per_day: None,
9233 };
9234
9235 match stage_session(Arc::new(PanicRegistry), request) {
9236 Err(RuntimeError::EnvAllowStrippedByBackstop { entries }) => assert_eq!(
9237 entries,
9238 vec![super::StrippedEnvAllowEntry {
9239 name: "GITHUB_TOKEN".to_string(),
9240 pattern: "GITHUB_TOKEN".to_string(),
9241 source: crate::shell::BackstopPatternSource::Builtin,
9242 }]
9243 ),
9244 Err(other) => panic!("expected EnvAllowStrippedByBackstop, got {other}"),
9245 Ok(_) => panic!("staging must refuse a backstop-stripped env.allow entry"),
9246 }
9247 }
9248
9249 #[test]
9250 fn stage_session_installs_local_source_skill_without_registry() {
9251 use murmur_artifact::{InferenceConfig, InferenceDriver};
9252
9253 struct PanicRegistry;
9255 impl Registry for PanicRegistry {
9256 fn resolve(&self, _: &str, _: &str) -> Result<ResolvedArtifact, RegistryError> {
9257 panic!("registry must not be called for a local-source skill");
9258 }
9259 fn publish(
9260 &self,
9261 _: ArtifactMeta,
9262 _: &[u8],
9263 ) -> Result<murmur_artifact::PublishResult, RegistryError> {
9264 unreachable!()
9265 }
9266 fn list_index(&self) -> Result<Vec<ArtifactMeta>, RegistryError> {
9267 unreachable!()
9268 }
9269 }
9270
9271 let project = tempfile::tempdir().unwrap();
9272 let skill_dir = project.path().join("skills").join("my-skill");
9273 fs::create_dir_all(&skill_dir).unwrap();
9274 fs::write(skill_dir.join("skill.md"), b"# my local skill").unwrap();
9275
9276 let inference = InferenceConfig {
9277 transport: "http".into(),
9278 endpoint: Some("http://localhost".into()),
9279 model: "claude-3-haiku".into(),
9280 api_key: None,
9281 driver: Some(InferenceDriver {
9282 artifact: "test-driver".into(),
9283 config: None,
9284 }),
9285 command: None,
9286 compaction: None,
9287 system_prompt: None,
9288 system_prompt_file: None,
9289 system_prompt_artifact: None,
9290 max_turns: 10,
9291 max_tokens: None,
9292 max_session_tokens: None,
9293 };
9294
9295 let request = StageRequest {
9296 credentials_file: None,
9297 manifest_dir: project.path().to_path_buf(),
9298 capsule_name: "test".to_string(),
9299 capsule_version: "0.0.1".to_string(),
9300 capsule_component_bytes: Vec::new(),
9301 artifacts: vec![crate::types::ArtifactRequest {
9302 name: "my-skill".to_string(),
9303 version: "local".to_string(),
9304 runtime: ArtifactRuntime::Skill,
9305 source: Some("skills/my-skill".to_string()),
9306 on_overflow: Default::default(),
9307 capabilities: None,
9308 config: None,
9309 }],
9310 allowlisted_tools: HashSet::new(),
9311 lock_expectations: None,
9312 capability_policy: CapabilityPolicy::default(),
9313 inference: Some(inference),
9314 system_prompt_overridden: false,
9315 context: None,
9316 context_id: None,
9317 resume: None,
9318 otel_endpoint: None,
9319 eval_config_json: None,
9320 case_id: None,
9321 dataset_id: None,
9322 lifecycle: None,
9323 lifecycle_override: None,
9324 trace: None,
9325 workdir: None,
9326 bind_addr: "127.0.0.1".to_string(),
9327 internal_port: None,
9328 declared_containment_floor: murmur_artifact::ContainmentClass::Advisory,
9329 exports: None,
9330 spawn_grant: None,
9331 machine_tokens_per_day: None,
9332 };
9333
9334 let staged = stage_session(Arc::new(PanicRegistry), request).unwrap();
9335 let installed = staged
9336 .workdir
9337 .join("tools")
9338 .join("my-skill")
9339 .join("skill.md");
9340 assert!(
9341 installed.exists(),
9342 "skill.md not installed at {}",
9343 installed.display()
9344 );
9345 assert_eq!(fs::read(&installed).unwrap(), b"# my local skill");
9346 assert!(staged.resolved_lock_artifacts.is_empty());
9348 assert_eq!(staged.installed_artifacts.len(), 1);
9349 assert_eq!(staged.installed_artifacts[0].version, "local");
9350 let murmur_md = fs::read_to_string(staged.workdir.join("MURMUR.md")).unwrap();
9352 assert!(
9353 murmur_md.contains("**my-skill**"),
9354 "MURMUR.md missing skill listing:\n{murmur_md}"
9355 );
9356 assert!(
9357 murmur_md.contains("call by name to load guidance"),
9358 "MURMUR.md missing callable skill hint:\n{murmur_md}"
9359 );
9360 }
9361
9362 fn zip_with_files(files: &[(&str, &[u8])]) -> Vec<u8> {
9365 use std::io::Write as _;
9366 let mut cursor = std::io::Cursor::new(Vec::<u8>::new());
9367 {
9368 let mut zip = zip::ZipWriter::new(&mut cursor);
9369 let options = zip::write::SimpleFileOptions::default();
9370 for (name, bytes) in files {
9371 zip.start_file(*name, options).unwrap();
9372 zip.write_all(bytes).unwrap();
9373 }
9374 zip.finish().unwrap();
9375 }
9376 cursor.into_inner()
9377 }
9378
9379 fn build_test_state(
9380 registry: Arc<dyn Registry>,
9381 workdir: PathBuf,
9382 lock_path: PathBuf,
9383 ) -> CapsuleStoreState {
9384 let engine = build_engine().unwrap();
9385 let wasi = build_wasi_ctx(
9386 &workdir,
9387 None,
9388 None,
9389 None,
9390 &[],
9391 &CapabilityPolicy::default(),
9392 )
9393 .unwrap();
9394 CapsuleStoreState {
9395 limits: crate::limits::ExecutionLimits::default().limiter(),
9396 table: ResourceTable::new(),
9397 wasi,
9398 http: WasiHttpCtx::new(),
9399 http_hooks: NetworkPolicyHooks {
9400 network_allow_rules: Vec::new(),
9401 inference_gateway: None,
9402 },
9403 network_allow_rules: Vec::new(),
9404 peer_fetch_rules: Vec::new(),
9405 peer_plane: None,
9406 peer_own_audience: String::new(),
9407 peer_trace: None,
9408 plan_trace: None,
9409 plan_counter: AtomicU64::new(0),
9410 delegation: None,
9411 inference_env: Vec::new(),
9412 inference_gateway: None,
9413 spend: Arc::new(SpendMeter::unlimited()),
9414 engine,
9415 workdir: workdir.clone(),
9416 accessible_workdir: workdir,
9417 tool_components: HashMap::new(),
9418 artifact_grants: HashMap::new(),
9419 allowlisted_tools: HashSet::new(),
9420 installed_artifacts: Vec::new(),
9421 session_id: "ses_test".to_string(),
9422 pending_a2a_events: Vec::new(),
9423 capability_policy: CapabilityPolicy::default(),
9424 protected_paths: ProtectedPaths::default(),
9425 tool_annotations: ToolAnnotationMap::default(),
9426 shell_enforcement: sandbox::ShellEnforcement::environment_only(),
9427 current_traceparent: None,
9428 current_task_provenance: None,
9429 current_context_id: None,
9430 live_delegations: Arc::new(crate::cancel::LiveDelegations::new()),
9431 detached: None,
9432 shell_grace_secs: 0,
9433 a2a_task_registry: None,
9434 a2a_sse: None,
9435 a2a_task_id: None,
9436 input_timeout_secs: None,
9437 a2a_chunk_event_id: Arc::new(AtomicU64::new(0)),
9438 a2a_chunks_emitted: Arc::new(AtomicBool::new(false)),
9439 registry,
9440 lock_path,
9441 driver_continuation_id: None,
9442 driver_continuation_context_id: None,
9443 driver_continuation_acked_len: 0,
9444 }
9445 }
9446
9447 fn continuation_test_state() -> CapsuleStoreState {
9450 let dir = tempfile::tempdir().unwrap();
9451 let workdir = dir.path().to_path_buf();
9452 let lock_path = workdir.join("murmur.lock");
9453 build_test_state(
9455 Arc::new(FakeSkillRegistry::new(Vec::new())),
9456 workdir,
9457 lock_path,
9458 )
9459 }
9460
9461 #[test]
9462 fn continuation_default_is_none_and_active_query_returns_none() {
9463 let state = continuation_test_state();
9464 assert!(state.driver_continuation_id.is_none());
9465 assert!(state.active_continuation(Some("ctx-a")).is_none());
9466 assert!(state.active_continuation(None).is_none());
9467 }
9468
9469 #[test]
9470 fn continuation_record_then_active_requires_matching_context() {
9471 let mut state = continuation_test_state();
9473 state.record_continuation("cont-1".into(), Some("ctx-a".into()), 2);
9474
9475 assert_eq!(
9476 state.active_continuation(Some("ctx-a")),
9477 Some(("cont-1", 2)),
9478 "same context → continuation is active"
9479 );
9480 assert!(
9481 state.active_continuation(Some("ctx-b")).is_none(),
9482 "different context → continuation must not be reused"
9483 );
9484 assert!(
9485 state.active_continuation(None).is_none(),
9486 "absent context → continuation must not be reused"
9487 );
9488 }
9489
9490 #[test]
9491 fn continuation_clear_drops_all_bookkeeping() {
9492 let mut state = continuation_test_state();
9494 state.record_continuation("cont-1".into(), Some("ctx-a".into()), 5);
9495 state.clear_continuation();
9496 assert!(state.driver_continuation_id.is_none());
9497 assert_eq!(state.driver_continuation_acked_len, 0);
9498 assert!(state.active_continuation(Some("ctx-a")).is_none());
9499 }
9500
9501 #[test]
9502 fn advance_acked_len_only_affects_same_context_held_continuation() {
9503 let mut state = continuation_test_state();
9506 state.record_continuation("cont-1".into(), Some("ctx-a".into()), 1);
9507
9508 state.advance_continuation_acked_len(Some("ctx-b"), 9);
9509 assert_eq!(
9510 state.driver_continuation_acked_len, 1,
9511 "advancing under a different context must be a no-op"
9512 );
9513
9514 state.advance_continuation_acked_len(Some("ctx-a"), 2);
9515 assert_eq!(
9516 state.active_continuation(Some("ctx-a")),
9517 Some(("cont-1", 2))
9518 );
9519
9520 state.clear_continuation();
9521 state.advance_continuation_acked_len(Some("ctx-a"), 7);
9522 assert_eq!(
9523 state.driver_continuation_acked_len, 0,
9524 "advancing with no held continuation must be a no-op"
9525 );
9526 }
9527
9528 struct FakeSkillRegistry {
9529 bytes: Vec<u8>,
9530 sha256: String,
9531 }
9532
9533 impl FakeSkillRegistry {
9534 fn new(bytes: Vec<u8>) -> Self {
9535 let sha256 = murmur_artifact::sha256_hex(&bytes);
9536 Self { bytes, sha256 }
9537 }
9538 }
9539
9540 impl Registry for FakeSkillRegistry {
9541 fn resolve(&self, name: &str, version: &str) -> Result<ResolvedArtifact, RegistryError> {
9542 Ok(ResolvedArtifact {
9543 meta: ArtifactMeta {
9544 name: name.to_string(),
9545 version: version.to_string(),
9546 runtime: RuntimeType::Static,
9547 artifact_runtime: "skill".to_string(),
9548 platforms: Vec::new(),
9549 description: None,
9550 tags: Vec::new(),
9551 wit_contracts: None,
9552 },
9553 bytes: self.bytes.clone().into(),
9554 sha256: self.sha256.clone(),
9555 platform_match: murmur_artifact::PlatformMatch::NotApplicable,
9556 })
9557 }
9558
9559 fn publish(
9560 &self,
9561 _meta: ArtifactMeta,
9562 _bytes: &[u8],
9563 ) -> Result<murmur_artifact::PublishResult, RegistryError> {
9564 unreachable!()
9565 }
9566
9567 fn list_index(&self) -> Result<Vec<ArtifactMeta>, RegistryError> {
9568 unreachable!()
9569 }
9570 }
9571
9572 #[test]
9573 fn pull_happy_path_installs_artifact_and_updates_lock() {
9574 let artifact_bytes = zip_with_files(&[
9575 (
9576 PACKED_MANIFEST_ENTRY,
9577 b"name: my-skill\nversion: 1.0.0\nruntime: skill\n",
9578 ),
9579 ("skill.md", b"# guidance"),
9580 ]);
9581 let registry = Arc::new(FakeSkillRegistry::new(artifact_bytes));
9582 let expected_sha256 = registry.sha256.clone();
9583
9584 let project = tempfile::tempdir().unwrap();
9585 let workdir = project.path().join("workdir");
9586 fs::create_dir_all(&workdir).unwrap();
9587 let lock_path = project.path().join("murmur.lock");
9588
9589 let mut state = build_test_state(registry, workdir.clone(), lock_path.clone());
9590
9591 let summary = manage::Host::pull(&mut state, "my-skill".to_string(), "1.0.0".to_string())
9592 .expect("pull should succeed");
9593 assert_eq!(summary.name, "my-skill");
9594 assert_eq!(summary.version, "1.0.0");
9595
9596 let installed_skill_md = workdir.join("tools").join("my-skill").join("skill.md");
9597 assert!(installed_skill_md.exists());
9598 assert_eq!(fs::read(&installed_skill_md).unwrap(), b"# guidance");
9599
9600 assert!(state
9601 .installed_artifacts
9602 .iter()
9603 .any(|a| a.name == "my-skill" && a.version == "1.0.0"));
9604
9605 let lock = read_lockfile(&lock_path).expect("murmur.lock should have been written");
9606 let entry = lock
9607 .artifact_for("my-skill")
9608 .expect("lock entry for my-skill");
9609 assert_eq!(entry.resolved_version, "1.0.0");
9610 assert_eq!(entry.sha256.any.as_deref().unwrap(), expected_sha256);
9611 }
9612
9613 #[test]
9617 fn pull_does_not_refresh_the_held_tool_inventory() {
9618 let artifact_bytes = zip_with_files(&[
9619 (
9620 PACKED_MANIFEST_ENTRY,
9621 b"name: aaa-late-skill\nversion: 1.0.0\nruntime: skill\n",
9622 ),
9623 ("skill.md", b"# guidance"),
9624 ]);
9625 let registry = Arc::new(FakeSkillRegistry::new(artifact_bytes));
9626
9627 let project = tempfile::tempdir().unwrap();
9628 let workdir = project.path().join("workdir");
9629 let existing = workdir.join("tools").join("zzz-existing-tool");
9632 fs::create_dir_all(&existing).unwrap();
9633 fs::write(
9634 existing.join(PACKED_MANIFEST_ENTRY),
9635 "name: zzz-existing-tool\nversion: 1.0.0\nruntime: tool\n",
9636 )
9637 .unwrap();
9638 let lock_path = project.path().join("murmur.lock");
9639
9640 let mut state = build_test_state(registry, workdir.clone(), lock_path);
9641
9642 let snapshot = crate::agent::inventory::build_tool_inventory(&workdir, None);
9644 assert_eq!(snapshot.len(), 1);
9645
9646 manage::Host::pull(
9647 &mut state,
9648 "aaa-late-skill".to_string(),
9649 "1.0.0".to_string(),
9650 )
9651 .expect("pull should succeed");
9652
9653 assert_eq!(snapshot.len(), 1);
9655 assert_eq!(snapshot[0]["name"], "zzz-existing-tool");
9656
9657 let payload = crate::agent::build_driver_payload(
9659 "m",
9660 8192,
9661 &[serde_json::json!({"role": "user", "content": []})],
9662 &snapshot,
9663 "sys",
9664 None,
9665 Some("cap:1.0.0"),
9666 );
9667 assert_eq!(payload["tools"], serde_json::json!(snapshot));
9668
9669 let fresh = crate::agent::inventory::build_tool_inventory(&workdir, None);
9671 assert_eq!(fresh.len(), 2);
9672 assert_eq!(fresh[0]["name"], "aaa-late-skill");
9673 assert_eq!(fresh[1]["name"], "zzz-existing-tool");
9674 }
9675
9676 #[test]
9677 fn pull_rejects_tampered_bytes_and_writes_nothing() {
9678 struct TamperedRegistry;
9679 impl Registry for TamperedRegistry {
9680 fn resolve(
9681 &self,
9682 name: &str,
9683 version: &str,
9684 ) -> Result<ResolvedArtifact, RegistryError> {
9685 Ok(ResolvedArtifact {
9686 meta: ArtifactMeta {
9687 name: name.to_string(),
9688 version: version.to_string(),
9689 runtime: RuntimeType::Static,
9690 artifact_runtime: "skill".to_string(),
9691 platforms: Vec::new(),
9692 description: None,
9693 tags: Vec::new(),
9694 wit_contracts: None,
9695 },
9696 bytes: b"tampered-bytes".to_vec().into(),
9697 sha256: "not-the-real-hash".to_string(),
9698 platform_match: murmur_artifact::PlatformMatch::NotApplicable,
9699 })
9700 }
9701
9702 fn publish(
9703 &self,
9704 _meta: ArtifactMeta,
9705 _bytes: &[u8],
9706 ) -> Result<murmur_artifact::PublishResult, RegistryError> {
9707 unreachable!()
9708 }
9709
9710 fn list_index(&self) -> Result<Vec<ArtifactMeta>, RegistryError> {
9711 unreachable!()
9712 }
9713 }
9714
9715 let project = tempfile::tempdir().unwrap();
9716 let workdir = project.path().join("workdir");
9717 fs::create_dir_all(&workdir).unwrap();
9718 let lock_path = project.path().join("murmur.lock");
9719
9720 let mut state = build_test_state(
9721 Arc::new(TamperedRegistry),
9722 workdir.clone(),
9723 lock_path.clone(),
9724 );
9725
9726 let err = manage::Host::pull(&mut state, "evil-tool".to_string(), "1.0.0".to_string())
9727 .expect_err("tampered bytes must be rejected");
9728 assert!(err.contains("integrity"), "unexpected error message: {err}");
9729
9730 assert!(!workdir.join("tools").join("evil-tool").exists());
9731 assert!(!lock_path.exists());
9732 assert!(state.installed_artifacts.is_empty());
9733 }
9734
9735 #[test]
9736 fn pull_rejects_lock_conflict_and_writes_nothing() {
9737 let artifact_bytes = zip_with_files(&[
9738 (
9739 PACKED_MANIFEST_ENTRY,
9740 b"name: my-skill\nversion: 2.0.0\nruntime: skill\n",
9741 ),
9742 ("skill.md", b"# guidance v2"),
9743 ]);
9744 let registry = Arc::new(FakeSkillRegistry::new(artifact_bytes));
9745
9746 let project = tempfile::tempdir().unwrap();
9747 let workdir = project.path().join("workdir");
9748 fs::create_dir_all(&workdir).unwrap();
9749 let lock_path = project.path().join("murmur.lock");
9750
9751 write_lockfile_atomic(
9753 &lock_path,
9754 &MurmurLock {
9755 lock_version: LOCK_VERSION,
9756 artifacts: vec![LockedArtifact {
9757 name: "my-skill".to_string(),
9758 resolved_version: "1.0.0".to_string(),
9759 sha256: LockedSha256::any("pinned-hash-from-earlier-pull".to_string()),
9760 }],
9761 },
9762 )
9763 .unwrap();
9764
9765 let mut state = build_test_state(registry, workdir.clone(), lock_path.clone());
9766
9767 let err = manage::Host::pull(&mut state, "my-skill".to_string(), "2.0.0".to_string())
9768 .expect_err("lock conflict must be rejected");
9769 assert!(
9770 err.contains("murmur.lock conflict"),
9771 "unexpected error message: {err}"
9772 );
9773
9774 assert!(!workdir
9775 .join("tools")
9776 .join("my-skill")
9777 .join("skill.md")
9778 .exists());
9779 assert!(state.installed_artifacts.is_empty());
9780
9781 let lock = read_lockfile(&lock_path).unwrap();
9783 let entry = lock.artifact_for("my-skill").unwrap();
9784 assert_eq!(entry.resolved_version, "1.0.0");
9785 assert_eq!(
9786 entry.sha256.any.as_deref().unwrap(),
9787 "pinned-hash-from-earlier-pull"
9788 );
9789 }
9790
9791 fn write_env_echo_native_tool(dir: &Path, name: &str, env_vars: &[&str]) -> std::path::PathBuf {
9795 use std::os::unix::fs::PermissionsExt;
9796
9797 let script_path = dir.join(name);
9798 let echoes: String = env_vars
9799 .iter()
9800 .map(|var| format!(r#"echo -n "{var}=${var} ""#))
9801 .collect::<Vec<_>>()
9802 .join("\n");
9803 let script = format!(
9804 "#!/bin/sh\ncat >/dev/null\nprintf '{{\"status\":\"passed\",\"data\":\"'\n{echoes}\nprintf '\"}}'\n"
9805 );
9806 fs::write(&script_path, script).unwrap();
9807 fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)).unwrap();
9808 script_path
9809 }
9810
9811 #[test]
9819 fn a_plan_shell_step_resolves_as_the_equivalent_shell_tool_call_does() {
9820 let tmp = TempDir::new().unwrap();
9821 let policy = CapabilityPolicy {
9822 shell_allow: vec!["bash".to_string()],
9823 ..CapabilityPolicy::default()
9824 };
9825
9826 let direct = resolve_shell_call(
9827 "bash",
9828 &murmur::tool::run::ToolInput {
9829 data: Some(r#"{"command":"printf x > out.txt"}"#.to_string()),
9830 log_path: None,
9831 },
9832 tmp.path(),
9833 &policy,
9834 )
9835 .expect("a declared binary with a usable command resolves");
9836
9837 let step = GatedStepCall::Shell {
9838 binary: "bash".to_string(),
9839 command: "bash -c 'printf x > out.txt'".to_string(),
9840 argv: vec!["-c".to_string(), "printf x > out.txt".to_string()],
9841 }
9842 .resolve(tmp.path());
9843
9844 let ResolvedCall::Shell {
9845 binary,
9846 argv,
9847 script,
9848 ..
9849 } = step
9850 else {
9851 panic!("a shell step resolves to a shell call");
9852 };
9853 assert_eq!(binary, direct.binary);
9854 assert_eq!(argv, direct.argv);
9855 assert_eq!(script, direct.script);
9856 }
9857
9858 #[test]
9863 fn dispatch_shell_tool_reports_the_invoked_binary_separately_from_the_command() {
9864 let tmp = TempDir::new().unwrap();
9865 let policy = CapabilityPolicy {
9866 shell_allow: vec!["bash".to_string()],
9867 ..CapabilityPolicy::default()
9868 };
9869
9870 let outcome = dispatch_shell_tool(
9871 "bash",
9872 murmur::tool::run::ToolInput {
9873 data: Some(r#"{"command":"echo hi"}"#.to_string()),
9874 log_path: None,
9875 },
9876 tmp.path(),
9877 tmp.path(),
9878 &[],
9879 &policy,
9880 &sandbox::ShellEnforcement::environment_only(),
9881 None,
9882 );
9883
9884 let shell = outcome
9885 .shell
9886 .expect("a successful shell call reports itself");
9887 assert!(
9888 Path::new(&shell.binary).is_absolute() && shell.binary.ends_with("bash"),
9889 "binary must be the resolved path of what ran, got {:?}",
9890 shell.binary
9891 );
9892 assert_eq!(
9893 shell.command, "echo hi",
9894 "command must still carry only the argument list"
9895 );
9896 assert_eq!(shell.exit_code, 0);
9897 }
9898
9899 #[test]
9903 fn shell_tool_result_names_the_binary_a_non_interpreter_call_ran() {
9904 let tmp = TempDir::new().unwrap();
9905 let policy = CapabilityPolicy {
9906 shell_allow: vec!["ls".to_string()],
9907 ..CapabilityPolicy::default()
9908 };
9909
9910 let outcome = dispatch_shell_tool(
9911 "ls",
9912 murmur::tool::run::ToolInput {
9913 data: Some(r#"{"command":"-d ."}"#.to_string()),
9914 log_path: None,
9915 },
9916 tmp.path(),
9917 tmp.path(),
9918 &[],
9919 &policy,
9920 &sandbox::ShellEnforcement::environment_only(),
9921 None,
9922 );
9923
9924 let data = outcome.result.data.expect("a finished call renders text");
9925 let first = data.lines().next().expect("the text opens with the $ line");
9926 assert_eq!(first, "$ ls -d .");
9927 assert!(
9928 !first.starts_with("$ /"),
9929 "the resolved path must not reach the $ line, got {first:?}"
9930 );
9931 let printed_binary = first
9932 .strip_prefix("$ ")
9933 .and_then(|rest| rest.split_whitespace().next())
9934 .expect("the $ line names a binary");
9935 assert!(
9936 !printed_binary.contains('/'),
9937 "the $ line names the declared short name, got {printed_binary:?}"
9938 );
9939 }
9940
9941 #[test]
9945 fn shell_tool_result_leaves_an_interpreter_command_line_alone() {
9946 let tmp = TempDir::new().unwrap();
9947 let policy = CapabilityPolicy {
9948 shell_allow: vec!["bash".to_string()],
9949 ..CapabilityPolicy::default()
9950 };
9951
9952 let outcome = dispatch_shell_tool(
9953 "bash",
9954 murmur::tool::run::ToolInput {
9955 data: Some(r#"{"command":"echo hi"}"#.to_string()),
9956 log_path: None,
9957 },
9958 tmp.path(),
9959 tmp.path(),
9960 &[],
9961 &policy,
9962 &sandbox::ShellEnforcement::environment_only(),
9963 None,
9964 );
9965
9966 let data = outcome.result.data.expect("a finished call renders text");
9967 let first = data.lines().next().expect("the text opens with the $ line");
9968 assert_eq!(first, "$ echo hi");
9969 }
9970
9971 #[test]
9975 fn resolve_shell_call_and_dispatch_shell_tool_agree_on_argv() {
9976 let tmp = TempDir::new().unwrap();
9977 let policy = CapabilityPolicy {
9978 shell_allow: vec!["bash".to_string()],
9979 ..CapabilityPolicy::default()
9980 };
9981 let input = murmur::tool::run::ToolInput {
9982 data: Some(r#"{"command":"echo one 'two three'"}"#.to_string()),
9983 log_path: None,
9984 };
9985
9986 let resolved =
9987 resolve_shell_call("bash", &input, tmp.path(), &policy).expect("bash resolves");
9988 let outcome = dispatch_shell_tool(
9989 "bash",
9990 input,
9991 tmp.path(),
9992 tmp.path(),
9993 &[],
9994 &policy,
9995 &sandbox::ShellEnforcement::environment_only(),
9996 None,
9997 );
9998
9999 let shell = outcome
10000 .shell
10001 .expect("a successful shell call reports itself");
10002 assert_eq!(resolved.argv, shell.argv);
10003 assert_eq!(resolved.script, shell.script);
10004 assert_eq!(
10005 resolved.argv,
10006 vec!["-c".to_string(), "echo one 'two three'".to_string()],
10007 "an interpreter takes the whole command as one -c body"
10008 );
10009 }
10010
10011 #[test]
10013 fn resolve_shell_call_splits_a_non_interpreter_and_reports_no_script() {
10014 let tmp = TempDir::new().unwrap();
10015 let policy = CapabilityPolicy {
10016 shell_allow: vec!["curl".to_string()],
10017 ..CapabilityPolicy::default()
10018 };
10019 let resolved = resolve_shell_call(
10020 "curl",
10021 &murmur::tool::run::ToolInput {
10022 data: Some(r#"{"command":"-s http://example.com"}"#.to_string()),
10023 log_path: None,
10024 },
10025 tmp.path(),
10026 &policy,
10027 )
10028 .expect("curl resolves");
10029
10030 assert_eq!(resolved.argv, vec!["-s", "http://example.com"]);
10031 assert_eq!(resolved.script, None);
10032 }
10033
10034 #[test]
10038 fn resolve_shell_call_declines_an_undeclared_binary_and_unusable_input() {
10039 let tmp = TempDir::new().unwrap();
10040 let policy = CapabilityPolicy {
10041 shell_allow: vec!["bash".to_string()],
10042 ..CapabilityPolicy::default()
10043 };
10044 assert!(resolve_shell_call(
10045 "curl",
10046 &murmur::tool::run::ToolInput {
10047 data: Some(r#"{"command":"--version"}"#.to_string()),
10048 log_path: None,
10049 },
10050 tmp.path(),
10051 &policy,
10052 )
10053 .is_none());
10054 assert!(resolve_shell_call(
10055 "bash",
10056 &murmur::tool::run::ToolInput {
10057 data: Some(r#"{"nope":1}"#.to_string()),
10058 log_path: None,
10059 },
10060 tmp.path(),
10061 &policy,
10062 )
10063 .is_none());
10064 }
10065
10066 #[test]
10069 fn resolve_shell_call_and_dispatch_shell_tool_agree_on_a_recipe_invocation() {
10070 let tmp = TempDir::new().unwrap();
10071 std::fs::write(
10072 tmp.path().join("justfile"),
10073 "build:\n echo RECIPE-BODY-MARKER\n",
10074 )
10075 .unwrap();
10076 let policy = CapabilityPolicy {
10077 shell_allow: vec!["just".to_string()],
10078 ..CapabilityPolicy::default()
10079 };
10080 let input = murmur::tool::run::ToolInput {
10081 data: Some(r#"{"command":"build"}"#.to_string()),
10082 log_path: None,
10083 };
10084
10085 let resolved =
10086 resolve_shell_call("just", &input, tmp.path(), &policy).expect("just resolves");
10087 let outcome = dispatch_shell_tool(
10088 "just",
10089 input,
10090 tmp.path(),
10091 tmp.path(),
10092 &[],
10093 &policy,
10094 &sandbox::ShellEnforcement::environment_only(),
10095 None,
10096 );
10097
10098 assert_eq!(
10099 resolved.recipe.as_deref(),
10100 Some("echo RECIPE-BODY-MARKER"),
10101 "the recipe body is read out of the workdir"
10102 );
10103 assert_eq!(
10104 resolved.argv,
10105 vec!["build".to_string()],
10106 "resolving a recipe does not move the argv"
10107 );
10108 if let Some(shell) = outcome.shell {
10111 assert_eq!(resolved.argv, shell.argv);
10112 assert_eq!(resolved.script, shell.script);
10113 assert_eq!(resolved.recipe, shell.recipe);
10114 }
10115 }
10116
10117 #[cfg(target_os = "linux")]
10126 #[test]
10127 fn a_sealed_composed_root_failure_ends_the_session_not_just_the_tool_call() {
10128 if crate::network_namespace::skip_without_egress_namespace(
10129 "a_sealed_composed_root_failure_ends_the_session_not_just_the_tool_call",
10130 ) {
10131 return;
10132 }
10133 let tmp = TempDir::new().unwrap();
10134 let policy = CapabilityPolicy {
10135 shell_allow: vec!["bash".to_string()],
10136 ..CapabilityPolicy::default()
10137 };
10138
10139 let _guard = sandbox::ForceSealedRootFailureGuard::new();
10140 let outcome = dispatch_shell_tool(
10141 "bash",
10142 murmur::tool::run::ToolInput {
10143 data: Some(r#"{"command":"echo hi"}"#.to_string()),
10144 log_path: None,
10145 },
10146 tmp.path(),
10147 tmp.path(),
10148 &[],
10149 &policy,
10150 &sandbox::sealed_test_enforcement(),
10151 None,
10152 );
10153
10154 assert!(
10155 matches!(outcome.result.status, murmur::tool::run::Status::Error),
10156 "the failed call must still read as a failed call"
10157 );
10158 let data = outcome.result.data.as_deref().unwrap_or_default();
10159 assert!(
10160 data.contains("composed root"),
10161 "the tool result must still say what happened: {data}"
10162 );
10163 let fatal = outcome
10164 .fatal
10165 .expect("a composed-root failure must be carried out as session-fatal");
10166 assert!(
10167 matches!(fatal, RuntimeError::SealedRootConstructionFailed { .. }),
10168 "must be the variant murmur-cli maps to E-RUN-014: {fatal}"
10169 );
10170 }
10171
10172 #[test]
10175 fn an_ordinary_shell_failure_leaves_the_session_running() {
10176 let tmp = TempDir::new().unwrap();
10177 let outcome = dispatch_shell_tool(
10178 "bash",
10179 murmur::tool::run::ToolInput {
10180 data: Some(r#"{"command":"echo hi"}"#.to_string()),
10181 log_path: None,
10182 },
10183 tmp.path(),
10184 tmp.path(),
10185 &[],
10186 &CapabilityPolicy::default(),
10188 &sandbox::ShellEnforcement::environment_only(),
10189 None,
10190 );
10191
10192 assert!(matches!(
10193 outcome.result.status,
10194 murmur::tool::run::Status::Error
10195 ));
10196 assert!(
10197 outcome.fatal.is_none(),
10198 "a disallowed binary is the capsule's problem, not the session's"
10199 );
10200 }
10201
10202 fn tool_result_with(
10205 data: Option<&str>,
10206 summary: Option<&str>,
10207 ) -> murmur::tool::run::ToolResult {
10208 murmur::tool::run::ToolResult {
10209 status: murmur::tool::run::Status::Passed,
10210 summary: summary.map(str::to_string),
10211 data: data.map(str::to_string),
10212 data_path: None,
10213 truncated: false,
10214 metadata: Vec::new(),
10215 }
10216 }
10217
10218 #[test]
10221 fn fence_wraps_each_dispatch_result_shape_exactly_once() {
10222 let shapes = [
10223 tool_result_with(Some("stdout line"), Some("ran ok")),
10224 tool_result_with(Some("stdout line"), None),
10225 tool_result_with(None, Some("ran ok")),
10226 tool_result_with(None, None),
10227 ];
10228 for mut result in shapes {
10229 fence_tool_result("web-fetch", &mut result);
10230 let text = result.data.expect("the fenced text lands in data");
10231 assert_eq!(
10232 text.matches("<untrusted-content source=tool:web-fetch>")
10233 .count(),
10234 1,
10235 "exactly one opening marker: {text}"
10236 );
10237 assert_eq!(
10238 text.matches(crate::fence::FENCE_CLOSE).count(),
10239 1,
10240 "exactly one closing marker: {text}"
10241 );
10242 }
10243 }
10244
10245 #[test]
10248 fn fence_moves_a_summary_only_result_into_fenced_data() {
10249 let mut result = tool_result_with(None, Some("ran ok"));
10250 fence_tool_result("probe", &mut result);
10251 assert_eq!(
10252 result.data.as_deref(),
10253 Some("<untrusted-content source=tool:probe>\nran ok\n</untrusted-content>")
10254 );
10255 assert!(
10256 result.summary.is_none(),
10257 "no unfenced text may survive on the outcome"
10258 );
10259 }
10260
10261 #[test]
10264 fn fence_wraps_the_empty_dispatch_result_too() {
10265 let mut result = tool_result_with(None, None);
10266 fence_tool_result("probe", &mut result);
10267 assert_eq!(
10268 result.data.as_deref(),
10269 Some(
10270 "<untrusted-content source=tool:probe>\ntool returned no data\n</untrusted-content>"
10271 )
10272 );
10273 }
10274
10275 #[test]
10278 fn fence_leaves_the_declarative_tool_result_fields_alone() {
10279 let mut result = murmur::tool::run::ToolResult {
10280 status: murmur::tool::run::Status::Error,
10281 summary: Some("failed".to_string()),
10282 data: Some("boom".to_string()),
10283 data_path: Some("out/log.txt".to_string()),
10284 truncated: true,
10285 metadata: vec![("state_effect".to_string(), "read".to_string())],
10286 };
10287 fence_tool_result("probe", &mut result);
10288 assert!(matches!(result.status, murmur::tool::run::Status::Error));
10289 assert_eq!(result.data_path.as_deref(), Some("out/log.txt"));
10290 assert!(result.truncated);
10291 assert_eq!(result.metadata.len(), 1);
10292 }
10293
10294 #[test]
10300 fn shell_dispatch_writes_the_resolved_binary_into_trace_jsonl() {
10301 let tmp = TempDir::new().unwrap();
10302 let workdir = tmp.path().to_path_buf();
10303 let policy = CapabilityPolicy {
10304 shell_allow: vec!["bash".to_string()],
10305 ..CapabilityPolicy::default()
10306 };
10307
10308 let rt = tokio::runtime::Runtime::new().unwrap();
10309 let events: Vec<serde_json::Value> = rt.block_on(async {
10310 let outcome = dispatch_shell_tool(
10311 "bash",
10312 murmur::tool::run::ToolInput {
10313 data: Some(r#"{"command":"echo hi"}"#.to_string()),
10314 log_path: None,
10315 },
10316 &workdir,
10317 &workdir,
10318 &[],
10319 &policy,
10320 &sandbox::ShellEnforcement::environment_only(),
10321 None,
10322 );
10323 let shell = outcome.shell.expect("the shell call reports itself");
10324
10325 let mut trace = TraceWriter::open(
10326 &workdir,
10327 "ses_test".to_string(),
10328 "cap".to_string(),
10329 "0.1.0".to_string(),
10330 "test-model".to_string(),
10331 Vec::new(),
10332 crate::containment::scope_report_for_tier(
10333 &policy,
10334 murmur_artifact::ContainmentClass::Advisory,
10335 sandbox::EnforcementTier::EnvironmentOnly,
10336 None,
10337 None,
10338 None,
10339 Vec::new(),
10340 Vec::new(),
10341 Vec::new(),
10342 crate::cgroup::IoMaxReport::default(),
10343 ),
10344 murmur_artifact::TraceCapture::Meta,
10345 None,
10346 false,
10347 None,
10348 None,
10349 None,
10350 None,
10351 )
10352 .await
10353 .unwrap();
10354 trace
10355 .write_shell(
10356 1,
10357 shell.binary.clone(),
10358 shell.command.clone(),
10359 shell.exit_code,
10360 shell.stdout_bytes,
10361 shell.stderr_bytes,
10362 shell.duration_ms,
10363 shell.resource_limit.clone(),
10364 )
10365 .await
10366 .unwrap();
10367 trace.flush().await.unwrap();
10368
10369 fs::read_to_string(workdir.join("trace.jsonl"))
10370 .unwrap()
10371 .lines()
10372 .filter(|l| !l.is_empty())
10373 .map(|l| serde_json::from_str(l).unwrap())
10374 .collect()
10375 });
10376
10377 let shell_event = events
10378 .iter()
10379 .find(|e| e["event_type"] == "shell")
10380 .expect("trace.jsonl must carry a shell event");
10381 let binary = shell_event["binary"]
10382 .as_str()
10383 .expect("the shell event must carry a `binary` string");
10384 assert!(
10385 Path::new(binary).is_absolute() && binary.ends_with("bash"),
10386 "trace.jsonl's binary must be the resolved absolute path of what ran, got {binary:?}"
10387 );
10388 assert_eq!(shell_event["command"], "echo hi");
10389 }
10390
10391 #[test]
10392 fn dispatch_native_tool_gets_synthetic_home_matching_execute_shell() {
10393 let tmp = TempDir::new().unwrap();
10394 let binary = write_env_echo_native_tool(tmp.path(), "echo-home", &["HOME"]);
10395 let policy = CapabilityPolicy::default();
10396
10397 let result = dispatch_native_tool(
10398 "echo-home",
10399 murmur::tool::run::ToolInput {
10400 data: None,
10401 log_path: None,
10402 },
10403 &binary,
10404 tmp.path(),
10405 tmp.path(),
10406 &policy,
10407 &sandbox::ShellEnforcement::environment_only(),
10408 )
10409 .unwrap();
10410
10411 let expected_home = tmp.path().join(".capsule-home");
10412 let data = result.data.unwrap_or_default();
10413 assert!(
10414 data.contains(&format!("HOME={}", expected_home.display())),
10415 "expected synthetic HOME in native tool output, got: {data}"
10416 );
10417 assert!(expected_home.is_dir());
10418 }
10419
10420 #[test]
10421 fn dispatch_native_tool_strips_credential_shaped_var() {
10422 let tmp = TempDir::new().unwrap();
10423 let binary = write_env_echo_native_tool(tmp.path(), "echo-token", &["GITHUB_TOKEN"]);
10424 let policy = CapabilityPolicy::default();
10425
10426 std::env::set_var("GITHUB_TOKEN", "leaked-token");
10427 let result = dispatch_native_tool(
10428 "echo-token",
10429 murmur::tool::run::ToolInput {
10430 data: None,
10431 log_path: None,
10432 },
10433 &binary,
10434 tmp.path(),
10435 tmp.path(),
10436 &policy,
10437 &sandbox::ShellEnforcement::environment_only(),
10438 )
10439 .unwrap();
10440 std::env::remove_var("GITHUB_TOKEN");
10441
10442 let data = result.data.unwrap_or_default();
10443 assert!(
10444 !data.contains("leaked-token"),
10445 "GITHUB_TOKEN must not reach a native tool subprocess, got: {data}"
10446 );
10447 }
10448
10449 #[test]
10450 fn dispatch_native_tool_strips_wildcard_credential_pattern() {
10451 let tmp = TempDir::new().unwrap();
10452 let binary = write_env_echo_native_tool(tmp.path(), "echo-stripe", &["STRIPE_API_KEY"]);
10453 let policy = CapabilityPolicy::default();
10454
10455 std::env::set_var("STRIPE_API_KEY", "leaked-key");
10456 let result = dispatch_native_tool(
10457 "echo-stripe",
10458 murmur::tool::run::ToolInput {
10459 data: None,
10460 log_path: None,
10461 },
10462 &binary,
10463 tmp.path(),
10464 tmp.path(),
10465 &policy,
10466 &sandbox::ShellEnforcement::environment_only(),
10467 )
10468 .unwrap();
10469 std::env::remove_var("STRIPE_API_KEY");
10470
10471 let data = result.data.unwrap_or_default();
10472 assert!(
10473 !data.contains("leaked-key"),
10474 "*_API_KEY wildcard pattern must strip STRIPE_API_KEY from native tool subprocess, got: {data}"
10475 );
10476 }
10477
10478 #[test]
10479 fn dispatch_native_tool_keeps_safe_baseline_var() {
10480 let tmp = TempDir::new().unwrap();
10481 let binary = write_env_echo_native_tool(tmp.path(), "echo-cargo-home", &["CARGO_HOME"]);
10482 let policy = CapabilityPolicy::default();
10483
10484 std::env::set_var("CARGO_HOME", "/fake/cargo/home");
10485 let result = dispatch_native_tool(
10486 "echo-cargo-home",
10487 murmur::tool::run::ToolInput {
10488 data: None,
10489 log_path: None,
10490 },
10491 &binary,
10492 tmp.path(),
10493 tmp.path(),
10494 &policy,
10495 &sandbox::ShellEnforcement::environment_only(),
10496 )
10497 .unwrap();
10498 std::env::remove_var("CARGO_HOME");
10499
10500 let data = result.data.unwrap_or_default();
10501 assert!(
10502 data.contains("CARGO_HOME=/fake/cargo/home"),
10503 "safe baseline var CARGO_HOME must pass through to native tool subprocess, got: {data}"
10504 );
10505 }
10506
10507 #[test]
10508 fn dispatch_native_tool_composes_policy_strip_env() {
10509 let tmp = TempDir::new().unwrap();
10510 let binary =
10511 write_env_echo_native_tool(tmp.path(), "echo-mycompany", &["MYCOMPANY_SECRET"]);
10512 let policy = CapabilityPolicy {
10513 shell_strip_env: vec!["MYCOMPANY_*".to_string()],
10514 ..CapabilityPolicy::default()
10515 };
10516
10517 std::env::set_var("MYCOMPANY_SECRET", "leaked-secret");
10518 let result = dispatch_native_tool(
10519 "echo-mycompany",
10520 murmur::tool::run::ToolInput {
10521 data: None,
10522 log_path: None,
10523 },
10524 &binary,
10525 tmp.path(),
10526 tmp.path(),
10527 &policy,
10528 &sandbox::ShellEnforcement::environment_only(),
10529 )
10530 .unwrap();
10531 std::env::remove_var("MYCOMPANY_SECRET");
10532
10533 let data = result.data.unwrap_or_default();
10534 assert!(
10535 !data.contains("leaked-secret"),
10536 "policy.shell_strip_env pattern must compose for native tool subprocess, got: {data}"
10537 );
10538 }
10539
10540 fn narrowing_ceiling() -> Vec<NetworkAllowRule> {
10545 parse_network_allow_rules(&[
10546 "http://127.0.0.1:1".to_string(),
10547 "http://127.0.0.1:2".to_string(),
10548 ])
10549 .unwrap()
10550 }
10551
10552 fn grant_of(network: Option<Vec<&str>>, scope: Option<&str>) -> ToolCapabilityGrant {
10553 let caps = murmur_artifact::Capabilities {
10554 peer_fetch: None,
10555 network: network.map(|allow| murmur_artifact::NetworkCapabilities {
10556 allow: allow.into_iter().map(str::to_string).collect(),
10557 unix_sockets: false,
10558 }),
10559 filesystem: scope.map(|scope| murmur_artifact::FilesystemCapabilities {
10560 scope: Some(scope.to_string()),
10561 workdir_exec: false,
10562 read_only: Vec::new(),
10563 }),
10564 shell: None,
10565 spawn: None,
10566 env: None,
10567 limits: None,
10568 resources: None,
10569 state: None,
10570 task_io: None,
10571 conversation: None,
10572 plan: None,
10573 containment: None,
10574 };
10575 ToolCapabilityGrant::derive(Some(&caps), &narrowing_ceiling(), "test-capsule")
10576 .expect("grant is valid")
10577 }
10578
10579 fn send_through_tool_hooks(rules: &[NetworkAllowRule], uri: &str, use_tls: bool) -> bool {
10582 use http_body_util::{BodyExt, Empty};
10583
10584 let mut hooks = NetworkPolicyHooks {
10585 network_allow_rules: rules.to_vec(),
10586 inference_gateway: None,
10587 };
10588 let body = Empty::<bytes::Bytes>::new()
10589 .map_err(|err| match err {})
10590 .boxed_unsync();
10591 let request = hyper::Request::builder()
10592 .uri(uri)
10593 .body(body)
10594 .expect("request builds");
10595 let config = wasmtime_wasi_http::p2::types::OutgoingRequestConfig {
10596 use_tls,
10597 connect_timeout: std::time::Duration::from_millis(1),
10598 first_byte_timeout: std::time::Duration::from_millis(1),
10599 between_bytes_timeout: std::time::Duration::from_millis(1),
10600 };
10601
10602 let rt = tokio::runtime::Runtime::new().unwrap();
10603 rt.block_on(async { hooks.send_request(request, config).is_ok() })
10604 }
10605
10606 fn gateway_test_config() -> wasmtime_wasi_http::p2::types::OutgoingRequestConfig {
10607 wasmtime_wasi_http::p2::types::OutgoingRequestConfig {
10608 use_tls: false,
10609 connect_timeout: std::time::Duration::from_millis(1),
10610 first_byte_timeout: std::time::Duration::from_millis(1),
10611 between_bytes_timeout: std::time::Duration::from_millis(1),
10612 }
10613 }
10614
10615 #[test]
10619 fn gateway_is_driver_only() {
10620 use http_body_util::{BodyExt, Empty};
10621
10622 const KEY: &str = "sk-driver-only-marker";
10623 let gateway = Arc::new(
10624 InferenceGateway::new(
10625 "the-driver",
10626 "http://127.0.0.1:1",
10627 murmur_artifact::InferenceAuth {
10628 header: "x-api-key".to_string(),
10629 value: "{key}".to_string(),
10630 },
10631 Some(Arc::new(
10632 InferenceCredential::resolve(&ApiKeyReference::Literal(KEY.to_string()), None)
10633 .unwrap(),
10634 )),
10635 Arc::new(SpendMeter::unlimited()),
10636 )
10637 .unwrap(),
10638 );
10639 assert!(gateway_for_store(Some(&gateway), "the-driver").is_some());
10640 assert!(gateway_for_store(Some(&gateway), "other-tool").is_none());
10641
10642 let mut hooks = NetworkPolicyHooks {
10643 network_allow_rules: Vec::new(),
10644 inference_gateway: gateway_for_store(Some(&gateway), "other-tool"),
10645 };
10646 let request = hyper::Request::builder()
10647 .uri("http://127.0.0.1:9/")
10648 .body(
10649 Empty::<bytes::Bytes>::new()
10650 .map_err(|err| match err {})
10651 .boxed_unsync(),
10652 )
10653 .unwrap();
10654 let rt = tokio::runtime::Runtime::new().unwrap();
10655 let Err(denied) = rt.block_on(async { hooks.send_request(request, gateway_test_config()) })
10656 else {
10657 panic!("a non-driver store's request to the gateway authority is denied");
10658 };
10659 assert!(matches!(
10660 denied.downcast_ref(),
10661 Some(WasiHttpErrorCode::HttpRequestDenied)
10662 ));
10663 assert!(!format!("{denied:?}").contains(KEY));
10664 }
10665
10666 fn http_inference(endpoint: &str, api_key: Option<&str>) -> InferenceConfig {
10667 InferenceConfig {
10668 transport: "http".to_string(),
10669 endpoint: Some(endpoint.to_string()),
10670 model: "test-model".to_string(),
10671 api_key: api_key.map(|key| ApiKeyReference::Literal(key.to_string())),
10672 driver: Some(murmur_artifact::InferenceDriver {
10673 artifact: "the-driver".to_string(),
10674 config: None,
10675 }),
10676 command: None,
10677 compaction: None,
10678 system_prompt: None,
10679 system_prompt_file: None,
10680 system_prompt_artifact: None,
10681 max_turns: 10,
10682 max_tokens: None,
10683 max_session_tokens: None,
10684 }
10685 }
10686
10687 #[test]
10688 fn inference_env_pairs_never_carries_the_api_key() {
10689 const KEY: &str = "sk-env-pairs-marker";
10690 let inference = http_inference("https://api.moonshot.ai/v1", Some(KEY));
10691 let gateway = InferenceGateway::new(
10692 "the-driver",
10693 "https://api.moonshot.ai/v1",
10694 murmur_artifact::InferenceAuth {
10695 header: "Authorization".to_string(),
10696 value: "Bearer {key}".to_string(),
10697 },
10698 inference
10699 .api_key
10700 .as_ref()
10701 .map(|reference| Arc::new(InferenceCredential::resolve(reference, None).unwrap())),
10702 Arc::new(SpendMeter::unlimited()),
10703 )
10704 .unwrap();
10705 for pairs in [
10706 inference_env_pairs(&inference, Some(&gateway)),
10707 inference_env_pairs(&inference, None),
10708 ] {
10709 assert!(pairs
10710 .iter()
10711 .all(|(name, value)| name != "MURMUR_INFERENCE_API_KEY" && !value.contains(KEY)));
10712 }
10713 let pairs = inference_env_pairs(&inference, Some(&gateway));
10714 assert!(pairs.contains(&(
10715 "MURMUR_INFERENCE_ENDPOINT".to_string(),
10716 "http://127.0.0.1:9/v1".to_string()
10717 )));
10718 }
10719
10720 #[test]
10721 fn inference_endpoint_allow_entries_names_only_entries_matching_the_endpoint() {
10722 let allow = vec![
10723 "api.anthropic.com".to_string(),
10724 "https://api.anthropic.com".to_string(),
10725 "http://api.anthropic.com".to_string(),
10726 "example.com".to_string(),
10727 ];
10728 let inference = http_inference("https://api.anthropic.com", None);
10729 assert_eq!(
10730 inference_endpoint_allow_entries(&allow, Some(&inference)),
10731 vec!["api.anthropic.com", "https://api.anthropic.com"]
10732 );
10733 let process = InferenceConfig {
10734 transport: "process".to_string(),
10735 endpoint: None,
10736 ..inference
10737 };
10738 assert!(inference_endpoint_allow_entries(&allow, Some(&process)).is_empty());
10739 }
10740
10741 #[test]
10744 fn ungranted_tool_keeps_the_whole_ceiling() {
10745 let ceiling = narrowing_ceiling();
10746 let rules = effective_tool_network_rules(None, &ceiling);
10747
10748 assert!(send_through_tool_hooks(
10749 rules,
10750 "http://127.0.0.1:1/x",
10751 false
10752 ));
10753 assert!(send_through_tool_hooks(
10754 rules,
10755 "http://127.0.0.1:2/x",
10756 false
10757 ));
10758 }
10759
10760 #[test]
10763 fn narrowed_tool_reaches_only_its_declared_host() {
10764 let ceiling = narrowing_ceiling();
10765 let grant = grant_of(Some(vec!["http://127.0.0.1:1"]), None);
10766 let narrowed = effective_tool_network_rules(Some(&grant), &ceiling);
10767
10768 assert!(
10769 send_through_tool_hooks(narrowed, "http://127.0.0.1:1/x", false),
10770 "the declared host stays reachable"
10771 );
10772 assert!(
10773 !send_through_tool_hooks(narrowed, "http://127.0.0.1:2/x", false),
10774 "the rest of the ceiling is dropped for this artifact"
10775 );
10776 let sibling = effective_tool_network_rules(None, &ceiling);
10778 assert!(send_through_tool_hooks(
10779 sibling,
10780 "http://127.0.0.1:2/x",
10781 false
10782 ));
10783 }
10784
10785 #[test]
10788 fn out_of_ceiling_entry_is_dropped_and_reported() {
10789 let ceiling = narrowing_ceiling();
10790 let grant = grant_of(
10791 Some(vec!["http://127.0.0.1:1", "https://evil.example.com"]),
10792 None,
10793 );
10794 let narrowed = effective_tool_network_rules(Some(&grant), &ceiling);
10795
10796 assert!(!send_through_tool_hooks(
10797 narrowed,
10798 "https://evil.example.com/x",
10799 true
10800 ));
10801 assert_eq!(
10802 grant.dropped_network_entries,
10803 vec!["https://evil.example.com".to_string()]
10804 );
10805 }
10806
10807 #[test]
10810 fn tool_without_filesystem_scope_preopens_the_workdir_itself() {
10811 let root = TempDir::new().unwrap();
10812 let missing = root.path().join("does-not-exist");
10813
10814 assert!(
10815 build_wasi_ctx(
10816 &missing,
10817 None,
10818 None,
10819 None,
10820 &[],
10821 &CapabilityPolicy::default()
10822 )
10823 .is_err(),
10824 "an unscoped tool preopens the workdir, which must exist"
10825 );
10826 build_wasi_ctx(
10827 root.path(),
10828 None,
10829 None,
10830 None,
10831 &[],
10832 &CapabilityPolicy::default(),
10833 )
10834 .expect("an existing workdir preopens as before");
10835 assert!(
10836 !missing.exists(),
10837 "the unscoped path must not create anything"
10838 );
10839 }
10840
10841 #[test]
10844 fn tool_with_filesystem_scope_preopens_only_the_scoped_subtree() {
10845 let root = TempDir::new().unwrap();
10846 std::fs::write(root.path().join("secret.txt"), b"capsule state").unwrap();
10847
10848 build_wasi_ctx(
10849 root.path(),
10850 Some("cache"),
10851 None,
10852 None,
10853 &[],
10854 &CapabilityPolicy::default(),
10855 )
10856 .expect("a granted scope is created and preopened");
10857
10858 let scoped = root.path().join("cache");
10859 assert!(scoped.is_dir(), "the granted scope is created if missing");
10860 std::fs::write(scoped.join("entry.json"), b"{}").unwrap();
10861 assert!(scoped.join("entry.json").exists());
10862 assert!(
10863 root.path().join("secret.txt").exists(),
10864 "nothing outside the scope was touched"
10865 );
10866 }
10867
10868 #[test]
10871 fn unusable_filesystem_scope_is_a_hard_error() {
10872 let root = TempDir::new().unwrap();
10873 std::fs::write(root.path().join("cache"), b"not a directory").unwrap();
10875
10876 let policy = CapabilityPolicy::default();
10877 let err = match build_wasi_ctx(root.path(), Some("cache"), None, None, &[], &policy) {
10878 Ok(_) => panic!("an uncreatable scope must fail loudly"),
10879 Err(err) => err,
10880 };
10881 assert!(
10882 err.to_string().contains("cache"),
10883 "the error must name the scope, got: {err}"
10884 );
10885 }
10886
10887 #[test]
10891 fn real_dispatch_applies_the_filesystem_scope() {
10892 let engine = build_engine().unwrap();
10893 let workdir = TempDir::new().unwrap();
10894 let component = crate::inference_import::test_support::driver_double(
10895 &engine,
10896 0,
10897 r#"{"stop_reason":"end_turn","content":[{"type":"text","text":"ok"}]}"#,
10898 );
10899 let ceiling = narrowing_ceiling();
10900 let grant = grant_of(None, Some("tool-cache"));
10901
10902 let rt = tokio::runtime::Runtime::new().unwrap();
10903 let result = rt.block_on(async {
10904 invoke_tool_component(
10905 ToolInvokeEnv {
10906 engine: &engine,
10907 accessible_workdir: workdir.path(),
10908 inference_env: &[],
10909 capability_policy: &CapabilityPolicy::default(),
10910 network_allow_rules: &ceiling,
10911 artifact_grant: Some(&grant),
10912 inference_gateway: None,
10913 },
10914 ToolA2aWiring::silent(),
10915 "scoped-tool",
10916 &component,
10917 murmur::tool::run::ToolInput {
10918 data: Some("{}".to_string()),
10919 log_path: None,
10920 },
10921 )
10922 .await
10923 });
10924
10925 assert!(result.is_ok(), "the scoped tool still runs: {result:?}");
10926 assert!(
10927 workdir.path().join("tool-cache").is_dir(),
10928 "the real dispatch path preopened the granted scope"
10929 );
10930 }
10931
10932 #[test]
10935 fn real_dispatch_without_a_grant_is_unchanged() {
10936 let engine = build_engine().unwrap();
10937 let workdir = TempDir::new().unwrap();
10938 let component = crate::inference_import::test_support::driver_double(
10939 &engine,
10940 0,
10941 r#"{"stop_reason":"end_turn","content":[{"type":"text","text":"ok"}]}"#,
10942 );
10943 let ceiling = narrowing_ceiling();
10944
10945 let rt = tokio::runtime::Runtime::new().unwrap();
10946 let result = rt.block_on(async {
10947 invoke_tool_component(
10948 ToolInvokeEnv {
10949 engine: &engine,
10950 accessible_workdir: workdir.path(),
10951 inference_env: &[],
10952 capability_policy: &CapabilityPolicy::default(),
10953 network_allow_rules: &ceiling,
10954 artifact_grant: None,
10955 inference_gateway: None,
10956 },
10957 ToolA2aWiring::silent(),
10958 "plain-tool",
10959 &component,
10960 murmur::tool::run::ToolInput {
10961 data: Some("{}".to_string()),
10962 log_path: None,
10963 },
10964 )
10965 .await
10966 });
10967
10968 assert!(
10969 result.is_ok(),
10970 "an ungranted tool runs as before: {result:?}"
10971 );
10972 assert_eq!(
10973 std::fs::read_dir(workdir.path()).unwrap().count(),
10974 0,
10975 "no per-artifact subtree is created when nothing was granted"
10976 );
10977 }
10978
10979 #[test]
10982 fn staging_records_grants_only_for_declaring_artifacts() {
10983 let ceiling = narrowing_ceiling();
10984 let mut grants = HashMap::new();
10985
10986 let declared = ArtifactRequest {
10987 name: "scoped-tool".to_string(),
10988 version: "1.0.0".to_string(),
10989 runtime: ArtifactRuntime::Tool,
10990 source: None,
10991 on_overflow: Default::default(),
10992 config: None,
10993 capabilities: Some(murmur_artifact::Capabilities {
10994 peer_fetch: None,
10995 network: Some(murmur_artifact::NetworkCapabilities {
10996 allow: vec!["http://127.0.0.1:1".to_string()],
10997 unix_sockets: false,
10998 }),
10999 filesystem: None,
11000 shell: None,
11001 spawn: None,
11002 env: None,
11003 limits: None,
11004 resources: None,
11005 state: None,
11006 task_io: None,
11007 conversation: None,
11008 plan: None,
11009 containment: None,
11010 }),
11011 };
11012 let silent = ArtifactRequest {
11013 name: "plain-tool".to_string(),
11014 version: "1.0.0".to_string(),
11015 runtime: ArtifactRuntime::Tool,
11016 source: None,
11017 on_overflow: Default::default(),
11018 capabilities: None,
11019 config: None,
11020 };
11021
11022 stage_artifact_grant(&declared, &ceiling, "test-capsule", &mut grants).unwrap();
11023 stage_artifact_grant(&silent, &ceiling, "test-capsule", &mut grants).unwrap();
11024
11025 assert!(grants.contains_key("scoped-tool"));
11026 assert!(
11027 !grants.contains_key("plain-tool"),
11028 "an artifact with no capabilities block must stay absent so dispatch falls back \
11029 to the ceiling"
11030 );
11031 }
11032
11033 #[test]
11040 fn config_alone_stages_a_grant_that_changes_nothing_else() {
11041 let ceiling = narrowing_ceiling();
11042 let mut grants = HashMap::new();
11043
11044 let configured = ArtifactRequest {
11045 name: "config-echo".to_string(),
11046 version: "1.0.0".to_string(),
11047 runtime: ArtifactRuntime::Driver,
11048 source: None,
11049 on_overflow: Default::default(),
11050 capabilities: None,
11051 config: Some(serde_yaml::from_str("who: a\n").unwrap()),
11052 };
11053
11054 stage_artifact_grant(&configured, &ceiling, "test-capsule", &mut grants).unwrap();
11055
11056 let grant = grants
11057 .get("config-echo")
11058 .expect("config alone stages a grant");
11059 assert_eq!(grant.config_json.as_deref(), Some(r#"{"who":"a"}"#));
11060 assert_eq!(
11061 grant,
11062 &ToolCapabilityGrant {
11063 config_json: grant.config_json.clone(),
11064 ..ToolCapabilityGrant::default()
11065 },
11066 "declaring config must leave every other field at the inherit-everything default"
11067 );
11068 }
11069
11070 #[test]
11075 fn has_protected_paths_is_false_without_a_declaration_and_true_with_one() {
11076 let dir = tempfile::tempdir().unwrap();
11077 let workdir = dir.path().to_path_buf();
11078 let lock_path = workdir.join("murmur.lock");
11079 let mut state = build_test_state(
11080 Arc::new(FakeSkillRegistry::new(Vec::new())),
11081 workdir,
11082 lock_path,
11083 );
11084 assert!(!state.has_protected_paths());
11085 assert!(state
11086 .check_protected_paths(&ResolvedCall::Tool {
11087 tool_name: "writer".to_string(),
11088 input: r#"{"path":"tests/a","content":"x"}"#.to_string(),
11089 input_bytes: 34,
11090 })
11091 .is_none());
11092
11093 state.protected_paths = ProtectedPaths::from_declared(&["tests".to_string()]).unwrap();
11094 assert!(state.has_protected_paths());
11095 let refusal = state
11096 .check_protected_paths(&ResolvedCall::Tool {
11097 tool_name: "writer".to_string(),
11098 input: r#"{"path":"tests/a","content":"x"}"#.to_string(),
11099 input_bytes: 34,
11100 })
11101 .expect("a declared subtree is checked");
11102 assert_eq!(refusal.rule, "tests");
11103 assert_eq!(refusal.path, "tests/a");
11104 }
11105
11106 #[test]
11109 fn a_malformed_read_only_entry_refuses_before_a_session_exists() {
11110 for entry in ["/etc", "../outside", "tests/../../outside"] {
11111 let err = ProtectedPaths::from_declared(&[entry.to_string()])
11112 .expect_err("staging must refuse");
11113 assert!(
11114 matches!(&err, RuntimeError::InvalidReadOnlyPath { path, .. } if path == entry),
11115 "{entry}: {err}"
11116 );
11117 }
11118 }
11119
11120 #[test]
11123 fn staging_rejects_a_malformed_config_block() {
11124 let mut grants = HashMap::new();
11125 let artifact = ArtifactRequest {
11126 name: "config-echo".to_string(),
11127 version: "1.0.0".to_string(),
11128 runtime: ArtifactRuntime::Tool,
11129 source: None,
11130 on_overflow: Default::default(),
11131 capabilities: None,
11132 config: Some(serde_yaml::from_str("[a, b]").unwrap()),
11133 };
11134
11135 let err =
11136 stage_artifact_grant(&artifact, &narrowing_ceiling(), "test-capsule", &mut grants)
11137 .expect_err("a sequence must fail staging");
11138 assert!(matches!(err, RuntimeError::InvalidArtifactConfig { .. }));
11139 assert!(err.to_string().contains("config-echo"), "{err}");
11140 assert!(grants.is_empty());
11141 }
11142
11143 #[test]
11146 fn staging_rejects_an_escaping_filesystem_scope() {
11147 let mut grants = HashMap::new();
11148 let artifact = ArtifactRequest {
11149 name: "escaping-tool".to_string(),
11150 version: "1.0.0".to_string(),
11151 runtime: ArtifactRuntime::Tool,
11152 source: None,
11153 on_overflow: Default::default(),
11154 config: None,
11155 capabilities: Some(murmur_artifact::Capabilities {
11156 peer_fetch: None,
11157 network: None,
11158 filesystem: Some(murmur_artifact::FilesystemCapabilities {
11159 scope: Some("../escape".to_string()),
11160 workdir_exec: false,
11161 read_only: Vec::new(),
11162 }),
11163 shell: None,
11164 spawn: None,
11165 env: None,
11166 limits: None,
11167 resources: None,
11168 state: None,
11169 task_io: None,
11170 conversation: None,
11171 plan: None,
11172 containment: None,
11173 }),
11174 };
11175
11176 let err =
11177 stage_artifact_grant(&artifact, &narrowing_ceiling(), "test-capsule", &mut grants)
11178 .expect_err("an escaping scope must fail staging");
11179 assert!(matches!(err, RuntimeError::InvalidFilesystemScope { .. }));
11180 assert!(grants.is_empty());
11181 }
11182
11183 #[test]
11186 fn inert_sub_blocks_are_exactly_the_unconsumed_ones() {
11187 let caps = murmur_artifact::Capabilities {
11188 peer_fetch: None,
11189 network: Some(murmur_artifact::NetworkCapabilities {
11190 allow: Vec::new(),
11191 unix_sockets: false,
11192 }),
11193 filesystem: Some(murmur_artifact::FilesystemCapabilities {
11194 scope: None,
11195 workdir_exec: false,
11196 read_only: Vec::new(),
11197 }),
11198 shell: Some(murmur_artifact::ShellCapabilities {
11199 allow: vec!["bash".to_string()],
11200 strip_env: None,
11201 baseline_env: None,
11202 interpreter_runtime: Vec::new(),
11203 staged_runtime: Vec::new(),
11204 }),
11205 spawn: None,
11206 env: Some(murmur_artifact::EnvCapabilities { allow: Vec::new() }),
11207 limits: None,
11208 resources: None,
11209 state: Some(murmur_artifact::StateCapabilities { store: None }),
11213 task_io: None,
11214 conversation: None,
11215 plan: None,
11216 containment: Some(murmur_artifact::ContainmentClass::Sealed),
11217 };
11218
11219 assert_eq!(
11220 inert_capability_sub_blocks(Some(&caps)),
11221 vec!["shell", "env", "containment"]
11222 );
11223 assert!(inert_capability_sub_blocks(None).is_empty());
11224 }
11225
11226 fn write_fake_claude_cli(dir: &Path) -> PathBuf {
11232 let script = dir.join("claude");
11233 fs::write(
11234 &script,
11235 "#!/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",
11236 )
11237 .unwrap();
11238 #[cfg(unix)]
11239 {
11240 use std::os::unix::fs::PermissionsExt;
11241 let mut perms = fs::metadata(&script).unwrap().permissions();
11242 perms.set_mode(0o755);
11243 fs::set_permissions(&script, perms).unwrap();
11244 }
11245 script
11246 }
11247
11248 fn on_task_end_reopen_double(
11253 engine: &wasmtime::Engine,
11254 reopen_limit: u32,
11255 reason: &str,
11256 ) -> wasmtime::component::Component {
11257 let reason_len = reason.len();
11258 let stubs = [
11259 "on-session-start",
11260 "on-inference",
11261 "on-tool-call",
11262 "on-shell",
11263 "on-compaction",
11264 "on-session-end",
11265 ]
11266 .iter()
11267 .map(|n| format!(" (export \"{n}\" (func $noop))"))
11268 .collect::<Vec<_>>()
11269 .join("\n");
11270 let wat = format!(
11271 r#"(component
11272 (core module $m
11273 (memory (export "memory") 1)
11274 (global $count (mut i32) (i32.const 0))
11275 (data (i32.const 300) "{reason}")
11276 (func (export "realloc") (param i32 i32 i32 i32) (result i32) i32.const 512)
11277 (func (export "ontaskend") (param i32 i32 i32 i32) (result i32)
11278 (i32.store (i32.const 128) (i32.const 0))
11279 (if (result i32) (i32.lt_u (global.get $count) (i32.const {reopen_limit}))
11280 (then
11281 (global.set $count (i32.add (global.get $count) (i32.const 1)))
11282 (i32.store (i32.const 132) (i32.const 4))
11283 (i32.store (i32.const 136) (i32.const 300))
11284 (i32.store (i32.const 140) (i32.const {reason_len}))
11285 (i32.const 128))
11286 (else
11287 (i32.store (i32.const 132) (i32.const 0))
11288 (i32.const 128))))
11289 (func (export "noop"))
11290 )
11291 (core instance $i (instantiate $m))
11292 (alias core export $i "memory" (core memory $mem))
11293 (alias core export $i "realloc" (core func $realloc))
11294
11295 (type $message (record
11296 (field "role" string)
11297 (field "content" string)
11298 (field "id" (option string))
11299 (field "source-id" (option string))))
11300 (type $tool-manifest (record (field "binary-name" string) (field "content" string)))
11301 (type $hook-output (variant
11302 (case "none")
11303 (case "replace-context" (list $message))
11304 (case "write-manifests" (list $tool-manifest))
11305 (case "artifact" string)
11306 (case "reopen-task" string)
11307 (case "seed-context" (list $message))
11308 (case "deny" string)))
11309 (type $task-end-event (record
11310 (field "task-id" string)
11311 (field "exit-status" string)))
11312 (type $ft (func (param "event" $task-end-event) (result (result $hook-output (error string)))))
11313
11314 (func $te (type $ft)
11315 (canon lift (core func $i "ontaskend") (memory $mem) (realloc $realloc) string-encoding=utf8))
11316 (func $noop (canon lift (core func $i "noop")))
11317
11318 (instance $lc
11319 (export "message" (type $message))
11320 (export "tool-manifest" (type $tool-manifest))
11321 (export "hook-output" (type $hook-output))
11322 (export "task-end-event" (type $task-end-event))
11323 (export "on-task-end" (func $te))
11324{stubs}
11325 )
11326 (export "murmur:hook/lifecycle@0.8.0" (instance $lc))
11327)"#
11328 );
11329 let bytes = wat::parse_str(&wat).expect("on-task-end reopen double WAT parses");
11330 wasmtime::component::Component::new(engine, &bytes)
11331 .expect("on-task-end reopen double compiles")
11332 }
11333
11334 async fn run_reopen_scenario(
11338 reopen_limit: u32,
11339 max_task_reopens: u32,
11340 max_turns: u32,
11341 ) -> (Result<(), RuntimeError>, Vec<serde_json::Value>) {
11342 let dir = tempfile::tempdir().unwrap();
11343 let workdir = dir.path().to_path_buf();
11344 fs::create_dir_all(workdir.join("tools")).unwrap();
11345 fs::write(workdir.join("task.md"), "Original task: build the thing.").unwrap();
11346 let cli = write_fake_claude_cli(dir.path());
11347
11348 let inference = InferenceConfig {
11349 transport: "process".into(),
11350 endpoint: None,
11351 model: "test-model".into(),
11352 api_key: None,
11353 driver: None,
11354 command: Some(cli.to_string_lossy().to_string()),
11355 compaction: None,
11356 system_prompt: None,
11357 system_prompt_file: None,
11358 system_prompt_artifact: None,
11359 max_turns,
11360 max_tokens: None,
11361 max_session_tokens: None,
11362 };
11363
11364 let mut state = build_test_state(
11365 Arc::new(FakeSkillRegistry::new(Vec::new())),
11366 workdir.clone(),
11367 workdir.join("murmur.lock"),
11368 );
11369
11370 let mut trace = TraceWriter::open(
11371 &workdir,
11372 "ses_test".to_string(),
11373 "cap".to_string(),
11374 "0.1.0".to_string(),
11375 "test-model".to_string(),
11376 Vec::new(),
11377 crate::containment::scope_report_for_tier(
11378 &CapabilityPolicy::default(),
11379 murmur_artifact::ContainmentClass::Advisory,
11380 sandbox::EnforcementTier::EnvironmentOnly,
11381 None,
11382 None,
11383 None,
11384 Vec::new(),
11385 Vec::new(),
11386 Vec::new(),
11387 crate::cgroup::IoMaxReport::default(),
11388 ),
11389 murmur_artifact::TraceCapture::Meta,
11390 None,
11391 false,
11392 None,
11393 None,
11394 None,
11395 None,
11396 )
11397 .await
11398 .unwrap();
11399
11400 let mut otel = OtelEmitter::new(None, &workdir, "cap".to_string(), "0.1.0".to_string());
11401
11402 let staged_hook = StagedHookArtifact {
11403 name: "gatekeeper".to_string(),
11404 version: "0.0.1".to_string(),
11405 component: on_task_end_reopen_double(&state.engine, reopen_limit, "tests still fail"),
11406 config: murmur_artifact::HookConfig {
11407 binding: HookBinding::OnTaskEnd,
11408 execution_mode: murmur_artifact::HookExecutionMode::Blocking,
11409 commit_policy: murmur_artifact::HookCommitPolicy::ReopenTask,
11410 },
11411 grant: HookCapabilityGrant::default(),
11412 on_overflow: Default::default(),
11413 };
11414
11415 let mut hooks = HookRuntime::new(
11416 &state.engine,
11417 &workdir,
11418 &workdir,
11419 vec![staged_hook],
11420 SessionContextData {
11421 capsule_name: "cap".to_string(),
11422 capsule_version: "0.1.0".to_string(),
11423 session_id: "ses_test".to_string(),
11424 model: "test-model".to_string(),
11425 capabilities: Vec::new(),
11426 },
11427 HookEnvVars::default(),
11428 crate::limits::ExecutionLimits::default(),
11429 None,
11430 None,
11431 )
11432 .await
11433 .unwrap();
11434
11435 let run_config = agent::AgentRunConfig {
11436 context_window: 0,
11437 compaction_threshold: 0.98,
11438 compaction_model: None,
11439 compaction_system_prompt: None,
11440 compaction_dump_summaries: false,
11441 max_output_tokens: 1024,
11442 seed_budget: murmur_artifact::DEFAULT_SEED_BUDGET,
11443 seed_overflow_margin: murmur_artifact::DEFAULT_SEED_OVERFLOW_MARGIN,
11444 conversation_root: None,
11445 record_owner: None,
11446 resume: None,
11447 };
11448
11449 trace
11451 .write_task_start(
11452 "tsk_1",
11453 "ctx_1",
11454 "task_md",
11455 TaskProvenance::derive(TaskOrigin::User, None),
11456 None,
11457 8,
11458 )
11459 .await
11460 .unwrap();
11461
11462 let result = run_task_with_reopens(
11463 &mut state,
11464 &workdir,
11465 &inference,
11466 max_task_reopens,
11467 None,
11468 run_config,
11469 &mut hooks,
11470 &mut trace,
11471 &mut otel,
11472 None,
11473 None,
11474 &workdir,
11475 "cap",
11476 "0.1.0",
11477 ConversationMode::Stateless,
11478 Some("ctx_1".to_string()),
11479 "tsk_1",
11480 None,
11481 None,
11482 )
11483 .await;
11484
11485 trace.flush().await.unwrap();
11486 let content = fs::read_to_string(workdir.join("trace.jsonl")).unwrap();
11487 let events: Vec<serde_json::Value> = content
11488 .lines()
11489 .filter(|l| !l.is_empty())
11490 .map(|l| serde_json::from_str(l).unwrap())
11491 .collect();
11492 (result.map(|_| ()), events)
11493 }
11494
11495 use crate::task_io_import::test_support::{reader_double, REPORT_SEP};
11498
11499 fn write_counting_fake_claude_cli(dir: &Path) -> PathBuf {
11504 let script = dir.join("claude-counting");
11505 let counter = dir.join("attempt-counter");
11506 fs::write(
11507 &script,
11508 format!(
11509 "#!/bin/sh\ncat > /dev/null\nn=$(cat '{c}' 2>/dev/null || echo 0)\n\
11510 n=$((n+1))\necho \"$n\" > '{c}'\n\
11511 printf '%s\\n' \"{{\\\"type\\\":\\\"assistant\\\",\\\"message\\\":\
11512 {{\\\"content\\\":[{{\\\"type\\\":\\\"text\\\",\\\"text\\\":\
11513 \\\"RESULT-$n\\\"}}]}}}}\"\n\
11514 printf '%s\\n' \"{{\\\"type\\\":\\\"result\\\",\\\"subtype\\\":\
11515 \\\"success\\\",\\\"result\\\":\\\"RESULT-$n\\\"}}\"\n",
11516 c = counter.display()
11517 ),
11518 )
11519 .unwrap();
11520 #[cfg(unix)]
11521 {
11522 use std::os::unix::fs::PermissionsExt;
11523 let mut perms = fs::metadata(&script).unwrap().permissions();
11524 perms.set_mode(0o755);
11525 fs::set_permissions(&script, perms).unwrap();
11526 }
11527 script
11528 }
11529
11530 const TASK_SENTINEL: &str = "TASK-SENTINEL: build the thing.";
11533
11534 async fn run_task_io_scenario(
11541 transport: &str,
11542 task_io_read: bool,
11543 max_task_reopens: u32,
11544 ) -> (Result<(), RuntimeError>, Vec<serde_json::Value>) {
11545 let dir = tempfile::tempdir().unwrap();
11546 let workdir = dir.path().to_path_buf();
11547 fs::create_dir_all(workdir.join("tools")).unwrap();
11548 fs::write(workdir.join("task.md"), TASK_SENTINEL).unwrap();
11549
11550 let mut state = build_test_state(
11551 Arc::new(FakeSkillRegistry::new(Vec::new())),
11552 workdir.clone(),
11553 workdir.join("murmur.lock"),
11554 );
11555
11556 let inference = if transport == "process" {
11557 let cli = write_counting_fake_claude_cli(dir.path());
11558 InferenceConfig {
11559 transport: "process".into(),
11560 command: Some(cli.to_string_lossy().to_string()),
11561 driver: None,
11562 ..task_io_inference_config()
11563 }
11564 } else {
11565 fs::create_dir_all(workdir.join("tools").join("mock-driver")).unwrap();
11568 state.tool_components.insert(
11569 "mock-driver".to_string(),
11570 crate::inference_import::test_support::driver_double(
11571 &state.engine,
11572 0,
11573 r#"{"stop_reason":"end_turn","content":[{"type":"text","text":"RESULT-1"}]}"#,
11574 ),
11575 );
11576 InferenceConfig {
11577 transport: "http".into(),
11578 command: None,
11579 driver: Some(murmur_artifact::InferenceDriver {
11580 artifact: "mock-driver".to_string(),
11581 config: None,
11582 }),
11583 ..task_io_inference_config()
11584 }
11585 };
11586
11587 let mut trace = TraceWriter::open(
11588 &workdir,
11589 "ses_test".to_string(),
11590 "cap".to_string(),
11591 "0.1.0".to_string(),
11592 "test-model".to_string(),
11593 Vec::new(),
11594 crate::containment::scope_report_for_tier(
11595 &CapabilityPolicy::default(),
11596 murmur_artifact::ContainmentClass::Advisory,
11597 sandbox::EnforcementTier::EnvironmentOnly,
11598 None,
11599 None,
11600 None,
11601 Vec::new(),
11602 Vec::new(),
11603 Vec::new(),
11604 crate::cgroup::IoMaxReport::default(),
11605 ),
11606 murmur_artifact::TraceCapture::Meta,
11607 None,
11608 false,
11609 None,
11610 None,
11611 None,
11612 None,
11613 )
11614 .await
11615 .unwrap();
11616 let mut otel = OtelEmitter::new(None, &workdir, "cap".to_string(), "0.1.0".to_string());
11617
11618 let staged_hook = StagedHookArtifact {
11619 name: "gatekeeper".to_string(),
11620 version: "0.0.1".to_string(),
11621 component: reader_double(&state.engine),
11622 config: murmur_artifact::HookConfig {
11623 binding: HookBinding::OnTaskEnd,
11624 execution_mode: murmur_artifact::HookExecutionMode::Blocking,
11625 commit_policy: murmur_artifact::HookCommitPolicy::ReopenTask,
11626 },
11627 grant: crate::network_policy::HookCapabilityGrant {
11628 network_allow_rules: Vec::new(),
11629 filesystem_scope: None,
11630 task_io_read,
11631 conversation_read: false,
11632 state_store: None,
11633 state_dir: None,
11634 config_json: None,
11635 },
11636 on_overflow: Default::default(),
11637 };
11638
11639 let mut hooks = HookRuntime::new(
11640 &state.engine,
11641 &workdir,
11642 &workdir,
11643 vec![staged_hook],
11644 SessionContextData {
11645 capsule_name: "cap".to_string(),
11646 capsule_version: "0.1.0".to_string(),
11647 session_id: "ses_test".to_string(),
11648 model: "test-model".to_string(),
11649 capabilities: Vec::new(),
11650 },
11651 HookEnvVars::default(),
11652 crate::limits::ExecutionLimits::default(),
11653 None,
11654 None,
11655 )
11656 .await
11657 .unwrap();
11658
11659 let run_config = agent::AgentRunConfig {
11660 context_window: 0,
11661 compaction_threshold: 0.98,
11662 compaction_model: None,
11663 compaction_system_prompt: None,
11664 compaction_dump_summaries: false,
11665 max_output_tokens: 1024,
11666 seed_budget: murmur_artifact::DEFAULT_SEED_BUDGET,
11667 seed_overflow_margin: murmur_artifact::DEFAULT_SEED_OVERFLOW_MARGIN,
11668 conversation_root: None,
11669 record_owner: None,
11670 resume: None,
11671 };
11672
11673 trace
11674 .write_task_start(
11675 "tsk_1",
11676 "ctx_1",
11677 "task_md",
11678 TaskProvenance::derive(TaskOrigin::User, None),
11679 None,
11680 8,
11681 )
11682 .await
11683 .unwrap();
11684
11685 let result = run_task_with_reopens(
11686 &mut state,
11687 &workdir,
11688 &inference,
11689 max_task_reopens,
11690 None,
11691 run_config,
11692 &mut hooks,
11693 &mut trace,
11694 &mut otel,
11695 None,
11696 None,
11697 &workdir,
11698 "cap",
11699 "0.1.0",
11700 ConversationMode::Stateless,
11701 Some("ctx_1".to_string()),
11702 "tsk_1",
11703 None,
11704 None,
11705 )
11706 .await;
11707
11708 trace.flush().await.unwrap();
11709 let content = fs::read_to_string(workdir.join("trace.jsonl")).unwrap();
11710 let events: Vec<serde_json::Value> = content
11711 .lines()
11712 .filter(|l| !l.is_empty())
11713 .map(|l| serde_json::from_str(l).unwrap())
11714 .collect();
11715 (result.map(|_| ()), events)
11716 }
11717
11718 fn task_io_inference_config() -> InferenceConfig {
11720 InferenceConfig {
11721 transport: String::new(),
11722 endpoint: None,
11723 model: "test-model".into(),
11724 api_key: None,
11725 driver: None,
11726 command: None,
11727 compaction: None,
11728 system_prompt: None,
11729 system_prompt_file: None,
11730 system_prompt_artifact: None,
11731 max_turns: 10,
11732 max_tokens: None,
11733 max_session_tokens: None,
11734 }
11735 }
11736
11737 fn reopen_reports(events: &[serde_json::Value]) -> Vec<Vec<String>> {
11744 events
11745 .iter()
11746 .filter(|e| e["event_type"] == "task_reopened")
11747 .map(|e| {
11748 let mut fields: Vec<String> = e["reason"]
11749 .as_str()
11750 .unwrap()
11751 .rsplitn(5, REPORT_SEP)
11752 .map(str::to_string)
11753 .collect();
11754 fields.reverse();
11755 fields
11756 })
11757 .collect()
11758 }
11759
11760 #[tokio::test(flavor = "multi_thread")]
11764 async fn granted_hook_reads_task_and_result_over_the_process_transport() {
11765 let (_, events) = run_task_io_scenario("process", true, 1).await;
11766 assert_eq!(
11767 reopen_reports(&events),
11768 vec![vec![
11769 format!("A={TASK_SENTINEL}"),
11770 format!("O={TASK_SENTINEL}"),
11771 "R=RESULT-1".to_string(),
11772 format!("LI={}", TASK_SENTINEL.len()),
11773 "LO=8".to_string(),
11774 ]],
11775 "the hook holds no filesystem scope, so neither sentinel could have come from disk"
11776 );
11777 }
11778
11779 #[tokio::test(flavor = "multi_thread")]
11784 async fn granted_hook_reads_task_and_result_over_the_http_transport() {
11785 let (_, events) = run_task_io_scenario("http", true, 1).await;
11786 assert_eq!(
11787 reopen_reports(&events),
11788 vec![vec![
11789 format!("A={TASK_SENTINEL}"),
11790 format!("O={TASK_SENTINEL}"),
11791 "R=RESULT-1".to_string(),
11792 format!("LI={}", TASK_SENTINEL.len()),
11793 "LO=8".to_string(),
11794 ]]
11795 );
11796 }
11797
11798 #[tokio::test(flavor = "multi_thread")]
11802 async fn an_ungranted_hook_reads_nothing_end_to_end() {
11803 let (_, events) = run_task_io_scenario("process", false, 1).await;
11804 let reports = reopen_reports(&events);
11805 assert_eq!(
11806 reports,
11807 vec![vec!["A=!0", "O=!0", "R=!0", "LI=!0", "LO=!0"]],
11808 "every read is not-granted, and no length leaks either"
11809 );
11810 }
11811
11812 #[tokio::test(flavor = "multi_thread")]
11816 async fn reopened_attempts_see_their_own_input_and_their_own_output() {
11817 let (result, events) = run_task_io_scenario("process", true, 2).await;
11818 assert!(
11819 result.is_err(),
11820 "the reader double always reopens, so the budget runs out"
11821 );
11822 let reports = reopen_reports(&events);
11823 assert_eq!(reports.len(), 2, "max_task_reopens: 2 ⇒ two reopens");
11824
11825 for (index, report) in reports.iter().enumerate() {
11826 assert_eq!(
11827 report[1],
11828 format!("O={TASK_SENTINEL}"),
11829 "`original` never changes across attempts"
11830 );
11831 assert_eq!(
11832 report[2],
11833 format!("R=RESULT-{}", index + 1),
11834 "attempt {} must read its own result, never the previous attempt's",
11835 index + 1
11836 );
11837 }
11838
11839 assert_eq!(
11840 reports[0][0],
11841 format!("A={TASK_SENTINEL}"),
11842 "attempt 1 was handed the pristine task"
11843 );
11844 let attempt_two_input = &reports[1][0];
11845 assert!(
11846 attempt_two_input.starts_with(&format!("A={TASK_SENTINEL}"))
11847 && attempt_two_input.contains("# Reopen feedback"),
11848 "attempt 2's `as-given` is the pristine task plus the injected feedback, got: \
11849 {attempt_two_input}"
11850 );
11851 assert_eq!(
11852 reports[1][3],
11853 format!("LI={}", attempt_two_input.len() - "A=".len()),
11854 "input-len reports the byte length of the very text read back"
11855 );
11856 }
11857
11858 fn count_type(events: &[serde_json::Value], ty: &str) -> usize {
11859 events.iter().filter(|e| e["event_type"] == ty).count()
11860 }
11861
11862 #[tokio::test(flavor = "multi_thread")]
11866 async fn reopen_once_then_satisfied_end_to_end() {
11867 let (result, events) = run_reopen_scenario(1, 5, 10).await;
11868 assert!(
11869 result.is_ok(),
11870 "a satisfied hook ends the task Ok: {result:?}"
11871 );
11872 assert_eq!(
11873 count_type(&events, "inference"),
11874 2,
11875 "agent loop ran exactly twice"
11876 );
11877 assert_eq!(count_type(&events, "task_reopened"), 1);
11878 let re = events
11879 .iter()
11880 .find(|e| e["event_type"] == "task_reopened")
11881 .unwrap();
11882 assert_eq!(re["hook_name"], "gatekeeper");
11883 assert_eq!(re["reason"], "tests still fail");
11884 assert_eq!(re["reopen_number"], 1);
11885 let end = events
11886 .iter()
11887 .find(|e| e["event_type"] == "task_end")
11888 .unwrap();
11889 assert_eq!(end["reopen_count"], 1);
11890 assert_eq!(end["exit_status"], "ok");
11891 }
11892
11893 #[tokio::test(flavor = "multi_thread")]
11897 async fn reopen_budget_exhausted_end_to_end() {
11898 let (result, events) = run_reopen_scenario(99, 1, 10).await;
11899 assert!(
11900 result.is_err(),
11901 "an exhausted reopen budget is a task failure"
11902 );
11903 assert_eq!(count_type(&events, "inference"), 2, "1 original + 1 reopen");
11904 assert_eq!(count_type(&events, "task_reopened"), 1);
11905 let end = events
11906 .iter()
11907 .find(|e| e["event_type"] == "task_end")
11908 .unwrap();
11909 assert_eq!(end["reopen_count"], 1);
11910 assert_eq!(end["exit_status"], "reopen_budget_exhausted");
11911 }
11912
11913 #[tokio::test(flavor = "multi_thread")]
11918 async fn reopen_never_exceeds_max_turns_end_to_end() {
11919 let (result, events) = run_reopen_scenario(99, 5, 3).await;
11920 assert!(result.is_err());
11921 assert_eq!(
11922 count_type(&events, "inference"),
11923 3,
11924 "cumulative turns must never exceed max_turns"
11925 );
11926 assert_eq!(
11927 count_type(&events, "task_reopened"),
11928 2,
11929 "3 attempts ⇒ 2 reopens"
11930 );
11931 let end = events
11932 .iter()
11933 .find(|e| e["event_type"] == "task_end")
11934 .unwrap();
11935 assert_eq!(end["reopen_count"], 2);
11936 assert_eq!(end["exit_status"], "reopen_budget_exhausted");
11937 }
11938}