1use std::cell::RefCell;
2use std::collections::BTreeMap;
3use std::rc::Rc;
4use std::time::Instant;
5
6use serde_json::Value as JsonValue;
7
8use crate::stdlib::registration::{
9 async_builtin, register_builtin_group, AsyncBuiltin, BuiltinGroup, SyncBuiltin,
10};
11use crate::value::{values_equal, VmError, VmValue};
12use crate::vm::clone_async_builtin_child_vm;
13use crate::vm::{Vm, VmBuiltinArity};
14
15fn audited_utc_now_rfc3339(capability_id: &'static str) -> String {
19 let dt: chrono::DateTime<chrono::Utc> =
20 crate::clock_mock::leak_audit::wall_now(capability_id).into();
21 dt.to_rfc3339()
22}
23
24const HOST_SYNC_PRIMITIVES: &[SyncBuiltin] = &[
25 SyncBuiltin::new("host_mock", host_mock_builtin)
26 .signature("host_mock(capability, op, response_or_config, params?)")
27 .arity(VmBuiltinArity::Range { min: 3, max: 4 })
28 .doc("Register a typed host mock for tests."),
29 SyncBuiltin::new("host_mock_clear", host_mock_clear_builtin)
30 .signature("host_mock_clear()")
31 .arity(VmBuiltinArity::Exact(0))
32 .doc("Clear typed host mocks and recorded calls."),
33 SyncBuiltin::new("host_mock_calls", host_mock_calls_builtin)
34 .signature("host_mock_calls()")
35 .arity(VmBuiltinArity::Exact(0))
36 .doc("Return typed host mock invocations."),
37 SyncBuiltin::new("host_mock_push_scope", host_mock_push_scope_builtin)
38 .signature("host_mock_push_scope()")
39 .arity(VmBuiltinArity::Exact(0))
40 .doc("Push an isolated host mock scope."),
41 SyncBuiltin::new("host_mock_pop_scope", host_mock_pop_scope_builtin)
42 .signature("host_mock_pop_scope()")
43 .arity(VmBuiltinArity::Exact(0))
44 .doc("Pop the current isolated host mock scope."),
45 SyncBuiltin::new("host_capabilities", host_capabilities_builtin)
46 .signature("host_capabilities()")
47 .arity(VmBuiltinArity::Exact(0))
48 .doc("Return the typed host capability manifest."),
49 SyncBuiltin::new("host_has", host_has_builtin)
50 .signature("host_has(capability, op?)")
51 .arity(VmBuiltinArity::Range { min: 1, max: 2 })
52 .doc("Return whether a host capability or operation is available."),
53];
54
55const HOST_ASYNC_PRIMITIVES: &[AsyncBuiltin] = &[
56 async_builtin!("host_call", host_call_builtin)
57 .signature("host_call(name, args)")
58 .arity(VmBuiltinArity::Range { min: 1, max: 2 })
59 .doc("Invoke a host capability operation by capability.operation name."),
60 async_builtin!("host_tool_list", host_tool_list_builtin)
61 .signature("host_tool_list()")
62 .arity(VmBuiltinArity::Exact(0))
63 .doc("List host tools exposed by the active bridge."),
64 async_builtin!("host_tool_call", host_tool_call_builtin)
65 .signature("host_tool_call(name, args?)")
66 .arity(VmBuiltinArity::Range { min: 1, max: 2 })
67 .doc("Call a host tool exposed by the active bridge."),
68];
69
70const HOST_PRIMITIVES: BuiltinGroup<'static> = BuiltinGroup::new()
71 .category("host")
72 .sync(HOST_SYNC_PRIMITIVES)
73 .async_(HOST_ASYNC_PRIMITIVES);
74
75#[derive(Clone)]
76struct HostMock {
77 capability: String,
78 operation: String,
79 params: Option<BTreeMap<String, VmValue>>,
80 result: Option<VmValue>,
81 error: Option<String>,
82}
83
84#[derive(Clone)]
85struct HostMockCall {
86 capability: String,
87 operation: String,
88 params: BTreeMap<String, VmValue>,
89}
90
91thread_local! {
92 static HOST_MOCKS: RefCell<Vec<HostMock>> = const { RefCell::new(Vec::new()) };
93 static HOST_MOCK_CALLS: RefCell<Vec<HostMockCall>> = const { RefCell::new(Vec::new()) };
94 static HOST_MOCK_SCOPES: RefCell<Vec<(Vec<HostMock>, Vec<HostMockCall>)>> =
95 const { RefCell::new(Vec::new()) };
96}
97
98pub(crate) fn reset_host_state() {
99 HOST_MOCKS.with(|mocks| mocks.borrow_mut().clear());
100 HOST_MOCK_CALLS.with(|calls| calls.borrow_mut().clear());
101 HOST_MOCK_SCOPES.with(|scopes| scopes.borrow_mut().clear());
102}
103
104fn push_host_mock_scope() {
109 let mocks = HOST_MOCKS.with(|v| std::mem::take(&mut *v.borrow_mut()));
110 let calls = HOST_MOCK_CALLS.with(|v| std::mem::take(&mut *v.borrow_mut()));
111 HOST_MOCK_SCOPES.with(|v| v.borrow_mut().push((mocks, calls)));
112}
113
114fn pop_host_mock_scope() -> bool {
119 let entry = HOST_MOCK_SCOPES.with(|v| v.borrow_mut().pop());
120 match entry {
121 Some((mocks, calls)) => {
122 HOST_MOCKS.with(|v| *v.borrow_mut() = mocks);
123 HOST_MOCK_CALLS.with(|v| *v.borrow_mut() = calls);
124 true
125 }
126 None => false,
127 }
128}
129
130fn capability_manifest_map() -> BTreeMap<String, VmValue> {
131 let mut root = BTreeMap::new();
132 root.insert(
133 "process".to_string(),
134 capability(
135 "Process execution.",
136 &[
137 op("exec", "Execute a process in argv or explicit shell mode."),
138 op("list_shells", "List shells discovered by the host/session."),
139 op(
140 "get_default_shell",
141 "Return the selected default shell for this host/session.",
142 ),
143 op(
144 "set_default_shell",
145 "Select the default shell for this host/session.",
146 ),
147 op(
148 "shell_invocation",
149 "Resolve a shell id/object plus login/interactive flags into argv.",
150 ),
151 ],
152 ),
153 );
154 root.insert(
155 "template".to_string(),
156 capability(
157 "Template rendering.",
158 &[op("render", "Render a template file.")],
159 ),
160 );
161 root.insert(
162 "interaction".to_string(),
163 capability(
164 "User interaction.",
165 &[op("ask", "Ask the user a question.")],
166 ),
167 );
168 root
169}
170
171fn mocked_operation_entry() -> VmValue {
172 op(
173 "mocked",
174 "Mocked host operation registered at runtime for tests.",
175 )
176 .1
177}
178
179fn ensure_mocked_capability(
180 root: &mut BTreeMap<String, VmValue>,
181 capability_name: &str,
182 operation_name: &str,
183) {
184 let Some(existing) = root.get(capability_name).cloned() else {
185 root.insert(
186 capability_name.to_string(),
187 capability(
188 "Mocked host capability registered at runtime for tests.",
189 &[(operation_name.to_string(), mocked_operation_entry())],
190 ),
191 );
192 return;
193 };
194
195 let Some(existing_dict) = existing.as_dict() else {
196 return;
197 };
198 let mut entry = (*existing_dict).clone();
199 let mut ops = entry
200 .get("ops")
201 .and_then(|value| match value {
202 VmValue::List(list) => Some((**list).clone()),
203 _ => None,
204 })
205 .unwrap_or_default();
206 if !ops.iter().any(|value| value.display() == operation_name) {
207 ops.push(VmValue::String(Rc::from(operation_name.to_string())));
208 }
209
210 let mut operations = entry
211 .get("operations")
212 .and_then(|value| value.as_dict())
213 .map(|dict| (*dict).clone())
214 .unwrap_or_default();
215 operations
216 .entry(operation_name.to_string())
217 .or_insert_with(mocked_operation_entry);
218
219 entry.insert("ops".to_string(), VmValue::List(Rc::new(ops)));
220 entry.insert("operations".to_string(), VmValue::Dict(Rc::new(operations)));
221 root.insert(capability_name.to_string(), VmValue::Dict(Rc::new(entry)));
222}
223
224fn capability_manifest_with_mocks() -> VmValue {
225 let mut root = capability_manifest_map();
226 HOST_MOCKS.with(|mocks| {
227 for host_mock in mocks.borrow().iter() {
228 ensure_mocked_capability(&mut root, &host_mock.capability, &host_mock.operation);
229 }
230 });
231 VmValue::Dict(Rc::new(root))
232}
233
234fn op(name: &str, description: &str) -> (String, VmValue) {
235 let mut entry = BTreeMap::new();
236 entry.insert(
237 "description".to_string(),
238 VmValue::String(Rc::from(description)),
239 );
240 (name.to_string(), VmValue::Dict(Rc::new(entry)))
241}
242
243fn capability(description: &str, ops: &[(String, VmValue)]) -> VmValue {
244 let mut entry = BTreeMap::new();
245 entry.insert(
246 "description".to_string(),
247 VmValue::String(Rc::from(description)),
248 );
249 entry.insert(
250 "ops".to_string(),
251 VmValue::List(Rc::new(
252 ops.iter()
253 .map(|(name, _)| VmValue::String(Rc::from(name.as_str())))
254 .collect(),
255 )),
256 );
257 let mut op_dict = BTreeMap::new();
258 for (name, op) in ops {
259 op_dict.insert(name.clone(), op.clone());
260 }
261 entry.insert("operations".to_string(), VmValue::Dict(Rc::new(op_dict)));
262 VmValue::Dict(Rc::new(entry))
263}
264
265fn require_param(params: &BTreeMap<String, VmValue>, key: &str) -> Result<String, VmError> {
266 params
267 .get(key)
268 .map(|v| v.display())
269 .filter(|v| !v.is_empty())
270 .ok_or_else(|| {
271 VmError::Thrown(VmValue::String(Rc::from(format!(
272 "host_call: missing required parameter '{key}'"
273 ))))
274 })
275}
276
277fn render_template(
278 path: &str,
279 bindings: Option<&BTreeMap<String, VmValue>>,
280) -> Result<String, VmError> {
281 let asset = crate::stdlib::template::TemplateAsset::render_target(path).map_err(|msg| {
282 VmError::Thrown(VmValue::String(Rc::from(format!(
283 "host_call template.render: {msg}"
284 ))))
285 })?;
286 crate::stdlib::template::render_asset_result(&asset, bindings).map_err(VmError::from)
287}
288
289fn params_match(
290 expected: Option<&BTreeMap<String, VmValue>>,
291 actual: &BTreeMap<String, VmValue>,
292) -> bool {
293 let Some(expected) = expected else {
294 return true;
295 };
296 expected.iter().all(|(key, value)| {
297 actual
298 .get(key)
299 .is_some_and(|candidate| values_equal(candidate, value))
300 })
301}
302
303fn parse_host_mock(args: &[VmValue]) -> Result<HostMock, VmError> {
304 let capability = args
305 .first()
306 .map(|value| value.display())
307 .unwrap_or_default();
308 let operation = args.get(1).map(|value| value.display()).unwrap_or_default();
309 if capability.is_empty() || operation.is_empty() {
310 return Err(VmError::Thrown(VmValue::String(Rc::from(
311 "host_mock: capability and operation are required",
312 ))));
313 }
314
315 let mut params = args
316 .get(3)
317 .and_then(|value| value.as_dict())
318 .map(|dict| (*dict).clone());
319 let mut result = args.get(2).cloned().or(Some(VmValue::Nil));
320 let mut error = None;
321
322 if let Some(config) = args.get(2).and_then(|value| value.as_dict()) {
323 if config.contains_key("result")
324 || config.contains_key("params")
325 || config.contains_key("error")
326 {
327 params = config
328 .get("params")
329 .and_then(|value| value.as_dict())
330 .map(|dict| (*dict).clone());
331 result = config.get("result").cloned();
332 error = config
333 .get("error")
334 .map(|value| value.display())
335 .filter(|value| !value.is_empty());
336 }
337 }
338
339 Ok(HostMock {
340 capability,
341 operation,
342 params,
343 result,
344 error,
345 })
346}
347
348fn push_host_mock(host_mock: HostMock) {
349 HOST_MOCKS.with(|mocks| mocks.borrow_mut().push(host_mock));
350}
351
352fn mock_call_value(call: &HostMockCall) -> VmValue {
353 let mut item = BTreeMap::new();
354 item.insert(
355 "capability".to_string(),
356 VmValue::String(Rc::from(call.capability.clone())),
357 );
358 item.insert(
359 "operation".to_string(),
360 VmValue::String(Rc::from(call.operation.clone())),
361 );
362 item.insert(
363 "params".to_string(),
364 VmValue::Dict(Rc::new(call.params.clone())),
365 );
366 VmValue::Dict(Rc::new(item))
367}
368
369fn record_mock_call(capability: &str, operation: &str, params: &BTreeMap<String, VmValue>) {
370 HOST_MOCK_CALLS.with(|calls| {
371 calls.borrow_mut().push(HostMockCall {
372 capability: capability.to_string(),
373 operation: operation.to_string(),
374 params: params.clone(),
375 });
376 });
377}
378
379pub(crate) fn dispatch_mock_host_call(
380 capability: &str,
381 operation: &str,
382 params: &BTreeMap<String, VmValue>,
383) -> Option<Result<VmValue, VmError>> {
384 let matched = HOST_MOCKS.with(|mocks| {
385 mocks
386 .borrow()
387 .iter()
388 .rev()
389 .find(|host_mock| {
390 host_mock.capability == capability
391 && host_mock.operation == operation
392 && params_match(host_mock.params.as_ref(), params)
393 })
394 .cloned()
395 })?;
396
397 record_mock_call(capability, operation, params);
398 if let Some(error) = matched.error {
399 return Some(Err(VmError::Thrown(VmValue::String(Rc::from(error)))));
400 }
401 Some(Ok(matched.result.unwrap_or(VmValue::Nil)))
402}
403
404pub trait HostCallBridge {
419 fn dispatch(
420 &self,
421 capability: &str,
422 operation: &str,
423 params: &BTreeMap<String, VmValue>,
424 ) -> Result<Option<VmValue>, VmError>;
425
426 fn list_tools(&self) -> Result<Option<VmValue>, VmError> {
427 Ok(None)
428 }
429
430 fn call_tool(&self, _name: &str, _args: &VmValue) -> Result<Option<VmValue>, VmError> {
431 Ok(None)
432 }
433}
434
435thread_local! {
436 static HOST_CALL_BRIDGE: RefCell<Option<Rc<dyn HostCallBridge>>> = const { RefCell::new(None) };
437}
438
439pub fn set_host_call_bridge(bridge: Rc<dyn HostCallBridge>) {
444 HOST_CALL_BRIDGE.with(|b| *b.borrow_mut() = Some(bridge));
445}
446
447pub fn clear_host_call_bridge() {
449 HOST_CALL_BRIDGE.with(|b| *b.borrow_mut() = None);
450}
451
452pub fn dispatch_host_call_bridge(
462 capability: &str,
463 operation: &str,
464 params: &BTreeMap<String, VmValue>,
465) -> Option<Result<VmValue, VmError>> {
466 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone())?;
467 match bridge.dispatch(capability, operation, params) {
468 Ok(Some(value)) => Some(Ok(value)),
469 Ok(None) => None,
470 Err(error) => Some(Err(error)),
471 }
472}
473
474fn empty_tool_list_value() -> VmValue {
475 VmValue::List(Rc::new(Vec::new()))
476}
477
478fn current_vm_host_bridge() -> Option<Rc<crate::bridge::HostBridge>> {
479 clone_async_builtin_child_vm().and_then(|vm| vm.bridge.clone())
480}
481
482async fn dispatch_host_tool_list() -> Result<VmValue, VmError> {
483 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
484 if let Some(bridge) = bridge {
485 if let Some(value) = bridge.list_tools()? {
486 return Ok(value);
487 }
488 }
489
490 let Some(bridge) = current_vm_host_bridge() else {
491 return Ok(empty_tool_list_value());
492 };
493 let tools = bridge.list_host_tools().await?;
494 Ok(crate::bridge::json_result_to_vm_value(&JsonValue::Array(
495 tools.into_iter().collect(),
496 )))
497}
498
499pub(crate) async fn dispatch_host_tool_call(
500 name: &str,
501 args: &VmValue,
502) -> Result<VmValue, VmError> {
503 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
504 if let Some(bridge) = bridge {
505 if let Some(value) = bridge.call_tool(name, args)? {
506 return Ok(value);
507 }
508 }
509
510 let Some(bridge) = current_vm_host_bridge() else {
511 return Err(VmError::Thrown(VmValue::String(Rc::from(
512 "host_tool_call: no host bridge is attached",
513 ))));
514 };
515
516 let result = bridge
517 .call(
518 "builtin_call",
519 serde_json::json!({
520 "name": name,
521 "args": [crate::llm::vm_value_to_json(args)],
522 }),
523 )
524 .await?;
525 Ok(crate::bridge::json_result_to_vm_value(&result))
526}
527
528pub(crate) async fn dispatch_host_operation(
529 capability: &str,
530 operation: &str,
531 params: &BTreeMap<String, VmValue>,
532) -> Result<VmValue, VmError> {
533 if let Some(mocked) = dispatch_mock_host_call(capability, operation, params) {
534 return mocked;
535 }
536
537 if (capability, operation) == ("process", "exec") {
538 let caller = serde_json::json!({
539 "surface": "host_call",
540 "capability": "process",
541 "operation": "exec",
542 "session_id": crate::llm::current_agent_session_id(),
543 });
544 return dispatch_process_exec_with_policy(params, caller).await;
545 }
546
547 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
548 if let Some(bridge) = bridge {
549 if let Some(value) = bridge.dispatch(capability, operation, params)? {
550 return Ok(value);
551 }
552 }
553
554 dispatch_builtin_host_operation(capability, operation, params).await
555}
556
557async fn dispatch_builtin_host_operation(
558 capability: &str,
559 operation: &str,
560 params: &BTreeMap<String, VmValue>,
561) -> Result<VmValue, VmError> {
562 match (capability, operation) {
563 ("process", "list_shells") => Ok(crate::shells::list_shells_vm_value()),
564 ("process", "get_default_shell") => Ok(crate::shells::default_shell_vm_value()),
565 ("process", "set_default_shell") => crate::shells::set_default_shell_vm_value(params),
566 ("process", "shell_invocation") => crate::shells::shell_invocation_vm_value(params),
567 ("template", "render") => {
568 let path = require_param(params, "path")?;
569 let bindings = params.get("bindings").and_then(|v| v.as_dict());
570 Ok(VmValue::String(Rc::from(render_template(&path, bindings)?)))
571 }
572 ("interaction", "ask") => {
573 let question = require_param(params, "question")?;
574 use std::io::BufRead;
575 print!("{question}");
576 let _ = std::io::Write::flush(&mut std::io::stdout());
577 let mut input = String::new();
578 if std::io::stdin().lock().read_line(&mut input).is_ok() {
579 Ok(VmValue::String(Rc::from(input.trim_end())))
580 } else {
581 Ok(VmValue::Nil)
582 }
583 }
584 ("runtime", "task") => Ok(VmValue::String(Rc::from(
589 std::env::var("HARN_TASK").unwrap_or_default(),
590 ))),
591 ("runtime", "set_result") => {
592 Ok(VmValue::Nil)
595 }
596 ("workspace", "project_root") => {
597 let path = std::env::var("HARN_PROJECT_ROOT").unwrap_or_else(|_| {
601 std::env::current_dir()
602 .map(|p| p.display().to_string())
603 .unwrap_or_default()
604 });
605 Ok(VmValue::String(Rc::from(path)))
606 }
607 ("workspace", "cwd") => {
608 let path = std::env::current_dir()
609 .map(|p| p.display().to_string())
610 .unwrap_or_default();
611 Ok(VmValue::String(Rc::from(path)))
612 }
613 _ => Err(VmError::Thrown(VmValue::String(Rc::from(format!(
614 "host_call: unsupported operation {capability}.{operation}"
615 ))))),
616 }
617}
618
619pub(crate) async fn dispatch_process_exec(
620 params: &BTreeMap<String, VmValue>,
621 caller: serde_json::Value,
622) -> Result<VmValue, VmError> {
623 dispatch_process_exec_with_policy(params, caller).await
624}
625
626async fn dispatch_process_exec_with_policy(
627 params: &BTreeMap<String, VmValue>,
628 caller: serde_json::Value,
629) -> Result<VmValue, VmError> {
630 let (params, command_policy_context, command_policy_decisions) =
631 match crate::orchestration::run_command_policy_preflight(params, caller).await? {
632 crate::orchestration::CommandPolicyPreflight::Proceed {
633 params,
634 context,
635 decisions,
636 } => (params, context, decisions),
637 crate::orchestration::CommandPolicyPreflight::Blocked {
638 status,
639 message,
640 context,
641 decisions,
642 } => {
643 return Ok(crate::orchestration::blocked_command_response(
644 params, status, &message, context, decisions,
645 ));
646 }
647 };
648
649 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
650 if let Some(bridge) = bridge {
651 if let Some(value) = bridge.dispatch("process", "exec", ¶ms)? {
652 return crate::orchestration::run_command_policy_postflight(
653 ¶ms,
654 value,
655 command_policy_context,
656 command_policy_decisions,
657 )
658 .await;
659 }
660 }
661
662 dispatch_process_exec_after_policy(¶ms, command_policy_context, command_policy_decisions)
663 .await
664}
665
666async fn dispatch_process_exec_after_policy(
667 params: &BTreeMap<String, VmValue>,
668 command_policy_context: JsonValue,
669 command_policy_decisions: Vec<crate::orchestration::CommandPolicyDecision>,
670) -> Result<VmValue, VmError> {
671 let (program, args) = process_exec_argv(params)?;
672 let timeout_ms = optional_i64(params, "timeout")
673 .or_else(|| optional_i64(params, "timeout_ms"))
674 .filter(|value| *value > 0)
675 .map(|value| value as u64);
676 let mut cmd = crate::process_sandbox::tokio_command_for(&program, &args)
677 .map_err(|e| VmError::Runtime(format!("host_call process.exec sandbox setup: {e}")))?;
678 if let Some(cwd) = optional_string(params, "cwd") {
679 crate::process_sandbox::enforce_process_cwd(std::path::Path::new(&cwd))
680 .map_err(|e| VmError::Runtime(format!("host_call process.exec cwd: {e}")))?;
681 cmd.current_dir(cwd);
682 }
683 if let Some(env) = optional_string_dict(params, "env")? {
684 let env_mode = optional_string(params, "env_mode");
685 if env_mode.as_deref().unwrap_or("replace") == "replace" {
686 cmd.env_clear();
687 }
688 for (key, value) in env {
689 cmd.env(key, value);
690 }
691 }
692 if let Some(env_remove) = optional_string_list(params, "env_remove") {
698 for key in env_remove {
699 cmd.env_remove(key);
700 }
701 }
702 cmd.stdin(std::process::Stdio::null())
703 .stdout(std::process::Stdio::piped())
704 .stderr(std::process::Stdio::piped())
705 .kill_on_drop(true);
706 let started_at = audited_utc_now_rfc3339("host_call/process.exec.started_at");
707 let started = crate::clock_mock::leak_audit::instant_now("host_call/process.exec.started");
708 let child = cmd
709 .spawn()
710 .map_err(|e| VmError::Runtime(format!("host_call process.exec: {e}")))?;
711 let pid = child.id();
712 let timed_out;
713 let output_result = if let Some(timeout_ms) = timeout_ms {
714 match tokio::time::timeout(
715 std::time::Duration::from_millis(timeout_ms),
716 child.wait_with_output(),
717 )
718 .await
719 {
720 Ok(result) => {
721 timed_out = false;
722 result
723 }
724 Err(_) => {
725 let response = process_exec_response(ProcessExecResponse {
726 pid,
727 started_at,
728 started,
729 stdout: "",
730 stderr: "",
731 exit_code: -1,
732 status: "timed_out",
733 success: false,
734 timed_out: true,
735 });
736 return crate::orchestration::run_command_policy_postflight(
737 params,
738 response,
739 command_policy_context,
740 command_policy_decisions,
741 )
742 .await;
743 }
744 }
745 } else {
746 timed_out = false;
747 child.wait_with_output().await
748 };
749 let output =
750 output_result.map_err(|e| VmError::Runtime(format!("host_call process.exec: {e}")))?;
751 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
752 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
753 let exit_code = output.status.code().unwrap_or(-1);
754 let response = process_exec_response(ProcessExecResponse {
755 pid,
756 started_at,
757 started,
758 stdout: &stdout,
759 stderr: &stderr,
760 exit_code,
761 status: if timed_out { "timed_out" } else { "completed" },
762 success: output.status.success(),
763 timed_out,
764 });
765 crate::orchestration::run_command_policy_postflight(
766 params,
767 response,
768 command_policy_context,
769 command_policy_decisions,
770 )
771 .await
772}
773
774struct ProcessExecResponse<'a> {
775 pid: Option<u32>,
776 started_at: String,
777 started: Instant,
778 stdout: &'a str,
779 stderr: &'a str,
780 exit_code: i32,
781 status: &'a str,
782 success: bool,
783 timed_out: bool,
784}
785
786fn process_exec_response(response: ProcessExecResponse<'_>) -> VmValue {
787 let combined = format!("{}{}", response.stdout, response.stderr);
788 let mut result = BTreeMap::new();
789 result.insert(
790 "command_id".to_string(),
791 VmValue::String(Rc::from(format!(
792 "cmd_{}_{}",
793 std::process::id(),
794 response.started.elapsed().as_nanos()
795 ))),
796 );
797 result.insert(
798 "status".to_string(),
799 VmValue::String(Rc::from(response.status)),
800 );
801 result.insert(
802 "pid".to_string(),
803 response
804 .pid
805 .map(|pid| VmValue::Int(pid as i64))
806 .unwrap_or(VmValue::Nil),
807 );
808 result.insert(
809 "process_group_id".to_string(),
810 response
811 .pid
812 .map(|pid| VmValue::Int(pid as i64))
813 .unwrap_or(VmValue::Nil),
814 );
815 result.insert("handle_id".to_string(), VmValue::Nil);
816 result.insert(
817 "started_at".to_string(),
818 VmValue::String(Rc::from(response.started_at)),
819 );
820 result.insert(
821 "ended_at".to_string(),
822 VmValue::String(Rc::from(audited_utc_now_rfc3339(
823 "host_call/process.exec.ended_at",
824 ))),
825 );
826 result.insert(
827 "duration_ms".to_string(),
828 VmValue::Int(response.started.elapsed().as_millis() as i64),
829 );
830 result.insert(
831 "exit_code".to_string(),
832 VmValue::Int(response.exit_code as i64),
833 );
834 result.insert("signal".to_string(), VmValue::Nil);
835 result.insert("timed_out".to_string(), VmValue::Bool(response.timed_out));
836 result.insert(
837 "stdout".to_string(),
838 VmValue::String(Rc::from(response.stdout.to_string())),
839 );
840 result.insert(
841 "stderr".to_string(),
842 VmValue::String(Rc::from(response.stderr.to_string())),
843 );
844 result.insert("combined".to_string(), VmValue::String(Rc::from(combined)));
845 result.insert(
846 "exit_status".to_string(),
847 VmValue::Int(response.exit_code as i64),
848 );
849 result.insert(
850 "legacy_status".to_string(),
851 VmValue::Int(response.exit_code as i64),
852 );
853 result.insert("success".to_string(), VmValue::Bool(response.success));
854 VmValue::Dict(Rc::new(result))
855}
856
857fn process_exec_argv(params: &BTreeMap<String, VmValue>) -> Result<(String, Vec<String>), VmError> {
858 match optional_string(params, "mode")
859 .as_deref()
860 .unwrap_or("shell")
861 {
862 "argv" => {
863 let argv = optional_string_list(params, "argv").ok_or_else(|| {
864 VmError::Runtime("host_call process.exec missing argv".to_string())
865 })?;
866 split_argv(argv)
867 }
868 "shell" => {
869 let command = require_param(params, "command")?;
870 let mut invocation_params = params.clone();
871 invocation_params.insert("command".to_string(), VmValue::String(Rc::from(command)));
872 let invocation =
873 crate::shells::resolve_invocation_from_vm_params(&invocation_params)
874 .map_err(|err| VmError::Runtime(format!("host_call process.exec: {err}")))?;
875 Ok((invocation.program, invocation.args))
876 }
877 other => Err(VmError::Runtime(format!(
878 "host_call process.exec unsupported mode {other:?}"
879 ))),
880 }
881}
882
883fn split_argv(mut argv: Vec<String>) -> Result<(String, Vec<String>), VmError> {
884 if argv.is_empty() {
885 return Err(VmError::Runtime(
886 "host_call process.exec argv must not be empty".to_string(),
887 ));
888 }
889 let program = argv.remove(0);
890 if program.is_empty() {
891 return Err(VmError::Runtime(
892 "host_call process.exec argv[0] must not be empty".to_string(),
893 ));
894 }
895 Ok((program, argv))
896}
897
898fn optional_i64(params: &BTreeMap<String, VmValue>, key: &str) -> Option<i64> {
899 match params.get(key) {
900 Some(VmValue::Int(value)) => Some(*value),
901 Some(VmValue::Float(value)) if value.fract() == 0.0 => Some(*value as i64),
902 _ => None,
903 }
904}
905
906fn optional_string(params: &BTreeMap<String, VmValue>, key: &str) -> Option<String> {
907 params.get(key).and_then(vm_string).map(ToString::to_string)
908}
909
910fn optional_string_list(params: &BTreeMap<String, VmValue>, key: &str) -> Option<Vec<String>> {
911 let VmValue::List(values) = params.get(key)? else {
912 return None;
913 };
914 values
915 .iter()
916 .map(|value| vm_string(value).map(ToString::to_string))
917 .collect()
918}
919
920fn optional_string_dict(
921 params: &BTreeMap<String, VmValue>,
922 key: &str,
923) -> Result<Option<BTreeMap<String, String>>, VmError> {
924 let Some(value) = params.get(key) else {
925 return Ok(None);
926 };
927 let Some(dict) = value.as_dict() else {
928 return Err(VmError::Runtime(format!(
929 "host_call process.exec {key} must be a dict"
930 )));
931 };
932 let mut out = BTreeMap::new();
933 for (key, value) in dict.iter() {
934 let Some(value) = vm_string(value) else {
935 return Err(VmError::Runtime(format!(
936 "host_call process.exec env value for {key:?} must be a string"
937 )));
938 };
939 out.insert(key.clone(), value.to_string());
940 }
941 Ok(Some(out))
942}
943
944fn vm_string(value: &VmValue) -> Option<&str> {
945 match value {
946 VmValue::String(value) => Some(value.as_ref()),
947 _ => None,
948 }
949}
950
951pub(crate) fn register_host_builtins(vm: &mut Vm) {
952 register_builtin_group(vm, HOST_PRIMITIVES);
953}
954
955fn host_mock_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
956 let host_mock = parse_host_mock(args)?;
957 push_host_mock(host_mock);
958 Ok(VmValue::Nil)
959}
960
961fn host_mock_clear_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
962 reset_host_state();
963 Ok(VmValue::Nil)
964}
965
966fn host_mock_calls_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
967 let calls = HOST_MOCK_CALLS.with(|calls| {
968 calls
969 .borrow()
970 .iter()
971 .map(mock_call_value)
972 .collect::<Vec<_>>()
973 });
974 Ok(VmValue::List(Rc::new(calls)))
975}
976
977fn host_mock_push_scope_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
978 push_host_mock_scope();
979 Ok(VmValue::Nil)
980}
981
982fn host_mock_pop_scope_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
983 if !pop_host_mock_scope() {
984 return Err(VmError::Thrown(VmValue::String(Rc::from(
985 "host_mock_pop_scope: no scope to pop",
986 ))));
987 }
988 Ok(VmValue::Nil)
989}
990
991fn host_capabilities_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
992 Ok(capability_manifest_with_mocks())
993}
994
995fn host_has_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
996 let capability = args.first().map(|a| a.display()).unwrap_or_default();
997 let operation = args.get(1).map(|a| a.display());
998 let manifest = capability_manifest_with_mocks();
999 let has = manifest
1000 .as_dict()
1001 .and_then(|d| d.get(&capability))
1002 .and_then(|v| v.as_dict())
1003 .is_some_and(|cap| {
1004 if let Some(operation) = operation {
1005 cap.get("ops")
1006 .and_then(|v| match v {
1007 VmValue::List(list) => {
1008 Some(list.iter().any(|item| item.display() == operation))
1009 }
1010 _ => None,
1011 })
1012 .unwrap_or(false)
1013 } else {
1014 true
1015 }
1016 });
1017 Ok(VmValue::Bool(has))
1018}
1019
1020async fn host_call_builtin(args: Vec<VmValue>) -> Result<VmValue, VmError> {
1021 let name = args.first().map(|a| a.display()).unwrap_or_default();
1022 let params = args
1023 .get(1)
1024 .and_then(|a| a.as_dict())
1025 .cloned()
1026 .unwrap_or_default();
1027 let Some((capability, operation)) = name.split_once('.') else {
1028 return Err(VmError::Thrown(VmValue::String(Rc::from(format!(
1029 "host_call: unsupported operation name '{name}'"
1030 )))));
1031 };
1032 dispatch_host_operation(capability, operation, ¶ms).await
1033}
1034
1035async fn host_tool_list_builtin(_args: Vec<VmValue>) -> Result<VmValue, VmError> {
1036 dispatch_host_tool_list().await
1037}
1038
1039async fn host_tool_call_builtin(args: Vec<VmValue>) -> Result<VmValue, VmError> {
1040 let name = args.first().map(|a| a.display()).unwrap_or_default();
1041 if name.is_empty() {
1042 return Err(VmError::Thrown(VmValue::String(Rc::from(
1043 "host_tool_call: tool name is required",
1044 ))));
1045 }
1046 let call_args = args.get(1).cloned().unwrap_or(VmValue::Nil);
1047 dispatch_host_tool_call(&name, &call_args).await
1048}
1049
1050#[cfg(test)]
1051mod tests {
1052 use super::{
1053 capability_manifest_with_mocks, clear_host_call_bridge, dispatch_host_operation,
1054 dispatch_host_tool_call, dispatch_host_tool_list, dispatch_mock_host_call, push_host_mock,
1055 reset_host_state, set_host_call_bridge, HostCallBridge, HostMock,
1056 };
1057 use std::cell::Cell;
1058 use std::collections::BTreeMap;
1059 use std::rc::Rc;
1060
1061 use crate::value::{VmError, VmValue};
1062
1063 #[test]
1064 fn manifest_includes_operation_metadata() {
1065 let manifest = capability_manifest_with_mocks();
1066 let process = manifest
1067 .as_dict()
1068 .and_then(|d| d.get("process"))
1069 .and_then(|v| v.as_dict())
1070 .expect("process capability");
1071 assert!(process.get("description").is_some());
1072 let operations = process
1073 .get("operations")
1074 .and_then(|v| v.as_dict())
1075 .expect("operations dict");
1076 assert!(operations.get("exec").is_some());
1077 }
1078
1079 #[test]
1080 fn mocked_capabilities_appear_in_manifest() {
1081 reset_host_state();
1082 push_host_mock(HostMock {
1083 capability: "project".to_string(),
1084 operation: "metadata_get".to_string(),
1085 params: None,
1086 result: Some(VmValue::Dict(Rc::new(BTreeMap::new()))),
1087 error: None,
1088 });
1089 let manifest = capability_manifest_with_mocks();
1090 let project = manifest
1091 .as_dict()
1092 .and_then(|d| d.get("project"))
1093 .and_then(|v| v.as_dict())
1094 .expect("project capability");
1095 let operations = project
1096 .get("operations")
1097 .and_then(|v| v.as_dict())
1098 .expect("operations dict");
1099 assert!(operations.get("metadata_get").is_some());
1100 reset_host_state();
1101 }
1102
1103 #[test]
1104 fn mock_host_call_matches_partial_params_and_overrides_order() {
1105 reset_host_state();
1106 let mut exact_params = BTreeMap::new();
1107 exact_params.insert("namespace".to_string(), VmValue::String(Rc::from("facts")));
1108 push_host_mock(HostMock {
1109 capability: "project".to_string(),
1110 operation: "metadata_get".to_string(),
1111 params: None,
1112 result: Some(VmValue::String(Rc::from("fallback"))),
1113 error: None,
1114 });
1115 push_host_mock(HostMock {
1116 capability: "project".to_string(),
1117 operation: "metadata_get".to_string(),
1118 params: Some(exact_params),
1119 result: Some(VmValue::String(Rc::from("facts"))),
1120 error: None,
1121 });
1122
1123 let mut call_params = BTreeMap::new();
1124 call_params.insert("dir".to_string(), VmValue::String(Rc::from("pkg")));
1125 call_params.insert("namespace".to_string(), VmValue::String(Rc::from("facts")));
1126 let exact = dispatch_mock_host_call("project", "metadata_get", &call_params)
1127 .expect("expected exact mock")
1128 .expect("exact mock should succeed");
1129 assert_eq!(exact.display(), "facts");
1130
1131 call_params.insert(
1132 "namespace".to_string(),
1133 VmValue::String(Rc::from("classification")),
1134 );
1135 let fallback = dispatch_mock_host_call("project", "metadata_get", &call_params)
1136 .expect("expected fallback mock")
1137 .expect("fallback mock should succeed");
1138 assert_eq!(fallback.display(), "fallback");
1139 reset_host_state();
1140 }
1141
1142 #[test]
1143 fn mock_host_call_can_throw_errors() {
1144 reset_host_state();
1145 push_host_mock(HostMock {
1146 capability: "project".to_string(),
1147 operation: "metadata_get".to_string(),
1148 params: None,
1149 result: None,
1150 error: Some("boom".to_string()),
1151 });
1152 let params = BTreeMap::new();
1153 let result = dispatch_mock_host_call("project", "metadata_get", ¶ms)
1154 .expect("expected mock result");
1155 match result {
1156 Err(VmError::Thrown(VmValue::String(message))) => assert_eq!(message.as_ref(), "boom"),
1157 other => panic!("unexpected result: {other:?}"),
1158 }
1159 reset_host_state();
1160 }
1161
1162 #[derive(Default)]
1163 struct TestHostToolBridge;
1164
1165 impl HostCallBridge for TestHostToolBridge {
1166 fn dispatch(
1167 &self,
1168 _capability: &str,
1169 _operation: &str,
1170 _params: &BTreeMap<String, VmValue>,
1171 ) -> Result<Option<VmValue>, VmError> {
1172 Ok(None)
1173 }
1174
1175 fn list_tools(&self) -> Result<Option<VmValue>, VmError> {
1176 let tool = VmValue::Dict(Rc::new(BTreeMap::from([
1177 (
1178 "name".to_string(),
1179 VmValue::String(Rc::from("Read".to_string())),
1180 ),
1181 (
1182 "description".to_string(),
1183 VmValue::String(Rc::from("Read a file from the host".to_string())),
1184 ),
1185 (
1186 "schema".to_string(),
1187 VmValue::Dict(Rc::new(BTreeMap::from([(
1188 "type".to_string(),
1189 VmValue::String(Rc::from("object".to_string())),
1190 )]))),
1191 ),
1192 ("deprecated".to_string(), VmValue::Bool(false)),
1193 ])));
1194 Ok(Some(VmValue::List(Rc::new(vec![tool]))))
1195 }
1196
1197 fn call_tool(&self, name: &str, args: &VmValue) -> Result<Option<VmValue>, VmError> {
1198 if name != "Read" {
1199 return Ok(None);
1200 }
1201 let path = args
1202 .as_dict()
1203 .and_then(|dict| dict.get("path"))
1204 .map(|value| value.display())
1205 .unwrap_or_default();
1206 Ok(Some(VmValue::String(Rc::from(format!("read:{path}")))))
1207 }
1208 }
1209
1210 struct CountingProcessExecBridge {
1211 calls: Rc<Cell<usize>>,
1212 }
1213
1214 impl HostCallBridge for CountingProcessExecBridge {
1215 fn dispatch(
1216 &self,
1217 capability: &str,
1218 operation: &str,
1219 _params: &BTreeMap<String, VmValue>,
1220 ) -> Result<Option<VmValue>, VmError> {
1221 if (capability, operation) != ("process", "exec") {
1222 return Ok(None);
1223 }
1224 self.calls.set(self.calls.get() + 1);
1225 Ok(Some(VmValue::Dict(Rc::new(BTreeMap::from([
1226 (
1227 "status".to_string(),
1228 VmValue::String(Rc::from("completed".to_string())),
1229 ),
1230 ("exit_code".to_string(), VmValue::Int(0)),
1231 ("success".to_string(), VmValue::Bool(true)),
1232 ])))))
1233 }
1234 }
1235
1236 fn run_host_async_test<F, Fut>(test: F)
1237 where
1238 F: FnOnce() -> Fut,
1239 Fut: std::future::Future<Output = ()>,
1240 {
1241 let rt = tokio::runtime::Builder::new_current_thread()
1242 .enable_all()
1243 .build()
1244 .expect("runtime");
1245 rt.block_on(async {
1246 let local = tokio::task::LocalSet::new();
1247 local.run_until(test()).await;
1248 });
1249 }
1250
1251 #[test]
1252 fn host_tool_list_uses_installed_host_call_bridge() {
1253 run_host_async_test(|| async {
1254 reset_host_state();
1255 set_host_call_bridge(Rc::new(TestHostToolBridge));
1256 let tools = dispatch_host_tool_list().await.expect("tool list");
1257 clear_host_call_bridge();
1258
1259 let VmValue::List(items) = tools else {
1260 panic!("expected tool list");
1261 };
1262 assert_eq!(items.len(), 1);
1263 let tool = items[0].as_dict().expect("tool dict");
1264 assert_eq!(tool.get("name").unwrap().display(), "Read");
1265 assert_eq!(tool.get("deprecated").unwrap().display(), "false");
1266 });
1267 }
1268
1269 #[test]
1270 fn host_tool_call_uses_installed_host_call_bridge() {
1271 run_host_async_test(|| async {
1272 set_host_call_bridge(Rc::new(TestHostToolBridge));
1273 let args = VmValue::Dict(Rc::new(BTreeMap::from([(
1274 "path".to_string(),
1275 VmValue::String(Rc::from("README.md".to_string())),
1276 )])));
1277 let value = dispatch_host_tool_call("Read", &args)
1278 .await
1279 .expect("tool call");
1280 clear_host_call_bridge();
1281 assert_eq!(value.display(), "read:README.md");
1282 });
1283 }
1284
1285 #[test]
1286 fn process_exec_bridge_is_gated_by_command_policy() {
1287 run_host_async_test(|| async {
1288 crate::orchestration::clear_command_policies();
1289 let calls = Rc::new(Cell::new(0));
1290 set_host_call_bridge(Rc::new(CountingProcessExecBridge {
1291 calls: calls.clone(),
1292 }));
1293 crate::orchestration::push_command_policy(crate::orchestration::CommandPolicy {
1294 tools: vec!["run".to_string()],
1295 workspace_roots: Vec::new(),
1296 default_shell_mode: "shell".to_string(),
1297 deny_patterns: vec!["cat *".to_string()],
1298 require_approval: Default::default(),
1299 pre: None,
1300 post: None,
1301 allow_recursive: false,
1302 });
1303
1304 let result = dispatch_host_operation(
1305 "process",
1306 "exec",
1307 &BTreeMap::from([
1308 ("mode".to_string(), VmValue::String(Rc::from("shell"))),
1309 (
1310 "command".to_string(),
1311 VmValue::String(Rc::from("cat Cargo.toml")),
1312 ),
1313 ]),
1314 )
1315 .await
1316 .expect("process.exec result");
1317
1318 crate::orchestration::clear_command_policies();
1319 clear_host_call_bridge();
1320
1321 assert_eq!(calls.get(), 0, "blocked command must not reach host bridge");
1322 let result = result.as_dict().expect("blocked result dict");
1323 assert_eq!(result.get("status").unwrap().display(), "blocked");
1324 assert!(
1325 result
1326 .get("reason")
1327 .map(VmValue::display)
1328 .unwrap_or_default()
1329 .contains("cat *"),
1330 "blocked result should name the matched policy pattern"
1331 );
1332 });
1333 }
1334
1335 #[test]
1336 fn host_tool_list_is_empty_without_bridge() {
1337 run_host_async_test(|| async {
1338 clear_host_call_bridge();
1339 let tools = dispatch_host_tool_list().await.expect("tool list");
1340 let VmValue::List(items) = tools else {
1341 panic!("expected tool list");
1342 };
1343 assert!(items.is_empty());
1344 });
1345 }
1346}