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