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 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 shell selection and 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.insert(
169 "memory".to_string(),
170 capability(
171 "Vector-aware memory: host-provided embeddings.",
172 &[op(
173 "embed",
174 "Embed text for semantic recall. Params: {text, model_hint?}. \
175 Returns {vector: list<float>, model: string, dim: int}.",
176 )],
177 ),
178 );
179 root
180}
181
182fn mocked_operation_entry() -> VmValue {
183 op(
184 "mocked",
185 "Mocked host operation registered at runtime for tests.",
186 )
187 .1
188}
189
190fn ensure_mocked_capability(
191 root: &mut BTreeMap<String, VmValue>,
192 capability_name: &str,
193 operation_name: &str,
194) {
195 let Some(existing) = root.get(capability_name).cloned() else {
196 root.insert(
197 capability_name.to_string(),
198 capability(
199 "Mocked host capability registered at runtime for tests.",
200 &[(operation_name.to_string(), mocked_operation_entry())],
201 ),
202 );
203 return;
204 };
205
206 let Some(existing_dict) = existing.as_dict() else {
207 return;
208 };
209 let mut entry = (*existing_dict).clone();
210 let mut ops = entry
211 .get("ops")
212 .and_then(|value| match value {
213 VmValue::List(list) => Some((**list).clone()),
214 _ => None,
215 })
216 .unwrap_or_default();
217 if !ops.iter().any(|value| value.display() == operation_name) {
218 ops.push(VmValue::String(Rc::from(operation_name.to_string())));
219 }
220
221 let mut operations = entry
222 .get("operations")
223 .and_then(|value| value.as_dict())
224 .map(|dict| (*dict).clone())
225 .unwrap_or_default();
226 operations
227 .entry(operation_name.to_string())
228 .or_insert_with(mocked_operation_entry);
229
230 entry.insert("ops".to_string(), VmValue::List(Rc::new(ops)));
231 entry.insert("operations".to_string(), VmValue::Dict(Rc::new(operations)));
232 root.insert(capability_name.to_string(), VmValue::Dict(Rc::new(entry)));
233}
234
235fn capability_manifest_with_mocks() -> VmValue {
236 let mut root = capability_manifest_map();
237 HOST_MOCKS.with(|mocks| {
238 for host_mock in mocks.borrow().iter() {
239 ensure_mocked_capability(&mut root, &host_mock.capability, &host_mock.operation);
240 }
241 });
242 VmValue::Dict(Rc::new(root))
243}
244
245fn op(name: &str, description: &str) -> (String, VmValue) {
246 let mut entry = BTreeMap::new();
247 entry.insert(
248 "description".to_string(),
249 VmValue::String(Rc::from(description)),
250 );
251 (name.to_string(), VmValue::Dict(Rc::new(entry)))
252}
253
254fn capability(description: &str, ops: &[(String, VmValue)]) -> VmValue {
255 let mut entry = BTreeMap::new();
256 entry.insert(
257 "description".to_string(),
258 VmValue::String(Rc::from(description)),
259 );
260 entry.insert(
261 "ops".to_string(),
262 VmValue::List(Rc::new(
263 ops.iter()
264 .map(|(name, _)| VmValue::String(Rc::from(name.as_str())))
265 .collect(),
266 )),
267 );
268 let mut op_dict = BTreeMap::new();
269 for (name, op) in ops {
270 op_dict.insert(name.clone(), op.clone());
271 }
272 entry.insert("operations".to_string(), VmValue::Dict(Rc::new(op_dict)));
273 VmValue::Dict(Rc::new(entry))
274}
275
276fn require_param(params: &BTreeMap<String, VmValue>, key: &str) -> Result<String, VmError> {
277 params
278 .get(key)
279 .map(|v| v.display())
280 .filter(|v| !v.is_empty())
281 .ok_or_else(|| {
282 VmError::Thrown(VmValue::String(Rc::from(format!(
283 "host_call: missing required parameter '{key}'"
284 ))))
285 })
286}
287
288fn render_template(
289 path: &str,
290 bindings: Option<&BTreeMap<String, VmValue>>,
291) -> Result<String, VmError> {
292 let asset = crate::stdlib::template::TemplateAsset::render_target(path).map_err(|msg| {
293 VmError::Thrown(VmValue::String(Rc::from(format!(
294 "host_call template.render: {msg}"
295 ))))
296 })?;
297 crate::stdlib::template::render_asset_result(&asset, bindings).map_err(VmError::from)
298}
299
300fn params_match(
301 expected: Option<&BTreeMap<String, VmValue>>,
302 actual: &BTreeMap<String, VmValue>,
303) -> bool {
304 let Some(expected) = expected else {
305 return true;
306 };
307 expected.iter().all(|(key, value)| {
308 actual
309 .get(key)
310 .is_some_and(|candidate| values_equal(candidate, value))
311 })
312}
313
314fn parse_host_mock(args: &[VmValue]) -> Result<HostMock, VmError> {
315 let capability = args
316 .first()
317 .map(|value| value.display())
318 .unwrap_or_default();
319 let operation = args.get(1).map(|value| value.display()).unwrap_or_default();
320 if capability.is_empty() || operation.is_empty() {
321 return Err(VmError::Thrown(VmValue::String(Rc::from(
322 "host_mock: capability and operation are required",
323 ))));
324 }
325
326 let mut params = args
327 .get(3)
328 .and_then(|value| value.as_dict())
329 .map(|dict| (*dict).clone());
330 let mut result = args.get(2).cloned().or(Some(VmValue::Nil));
331 let mut error = None;
332
333 if let Some(config) = args.get(2).and_then(|value| value.as_dict()) {
334 if config.contains_key("result")
335 || config.contains_key("params")
336 || config.contains_key("error")
337 {
338 params = config
339 .get("params")
340 .and_then(|value| value.as_dict())
341 .map(|dict| (*dict).clone());
342 result = config.get("result").cloned();
343 error = config
344 .get("error")
345 .map(|value| value.display())
346 .filter(|value| !value.is_empty());
347 }
348 }
349
350 Ok(HostMock {
351 capability,
352 operation,
353 params,
354 result,
355 error,
356 })
357}
358
359fn push_host_mock(host_mock: HostMock) {
360 HOST_MOCKS.with(|mocks| mocks.borrow_mut().push(host_mock));
361}
362
363fn mock_call_value(call: &HostMockCall) -> VmValue {
364 let mut item = BTreeMap::new();
365 item.insert(
366 "capability".to_string(),
367 VmValue::String(Rc::from(call.capability.clone())),
368 );
369 item.insert(
370 "operation".to_string(),
371 VmValue::String(Rc::from(call.operation.clone())),
372 );
373 item.insert(
374 "params".to_string(),
375 VmValue::Dict(Rc::new(call.params.clone())),
376 );
377 VmValue::Dict(Rc::new(item))
378}
379
380fn record_mock_call(capability: &str, operation: &str, params: &BTreeMap<String, VmValue>) {
381 HOST_MOCK_CALLS.with(|calls| {
382 calls.borrow_mut().push(HostMockCall {
383 capability: capability.to_string(),
384 operation: operation.to_string(),
385 params: params.clone(),
386 });
387 });
388}
389
390pub(crate) fn dispatch_mock_host_call(
391 capability: &str,
392 operation: &str,
393 params: &BTreeMap<String, VmValue>,
394) -> Option<Result<VmValue, VmError>> {
395 let matched = HOST_MOCKS.with(|mocks| {
396 mocks
397 .borrow()
398 .iter()
399 .rev()
400 .find(|host_mock| {
401 host_mock.capability == capability
402 && host_mock.operation == operation
403 && params_match(host_mock.params.as_ref(), params)
404 })
405 .cloned()
406 })?;
407
408 record_mock_call(capability, operation, params);
409 if let Some(error) = matched.error {
410 return Some(Err(VmError::Thrown(VmValue::String(Rc::from(error)))));
411 }
412 Some(Ok(matched.result.unwrap_or(VmValue::Nil)))
413}
414
415pub trait HostCallBridge {
430 fn dispatch(
431 &self,
432 capability: &str,
433 operation: &str,
434 params: &BTreeMap<String, VmValue>,
435 ) -> Result<Option<VmValue>, VmError>;
436
437 fn list_tools(&self) -> Result<Option<VmValue>, VmError> {
438 Ok(None)
439 }
440
441 fn call_tool(&self, _name: &str, _args: &VmValue) -> Result<Option<VmValue>, VmError> {
442 Ok(None)
443 }
444}
445
446thread_local! {
447 static HOST_CALL_BRIDGE: RefCell<Option<Rc<dyn HostCallBridge>>> = const { RefCell::new(None) };
448}
449
450pub fn set_host_call_bridge(bridge: Rc<dyn HostCallBridge>) {
455 HOST_CALL_BRIDGE.with(|b| *b.borrow_mut() = Some(bridge));
456}
457
458pub fn clear_host_call_bridge() {
460 HOST_CALL_BRIDGE.with(|b| *b.borrow_mut() = None);
461}
462
463pub fn dispatch_host_call_bridge(
473 capability: &str,
474 operation: &str,
475 params: &BTreeMap<String, VmValue>,
476) -> Option<Result<VmValue, VmError>> {
477 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone())?;
478 match bridge.dispatch(capability, operation, params) {
479 Ok(Some(value)) => Some(Ok(value)),
480 Ok(None) => None,
481 Err(error) => Some(Err(error)),
482 }
483}
484
485fn empty_tool_list_value() -> VmValue {
486 VmValue::List(Rc::new(Vec::new()))
487}
488
489fn current_vm_host_bridge() -> Option<Rc<crate::bridge::HostBridge>> {
490 clone_async_builtin_child_vm().and_then(|vm| vm.bridge.clone())
491}
492
493async fn dispatch_host_tool_list() -> Result<VmValue, VmError> {
494 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
495 if let Some(bridge) = bridge {
496 if let Some(value) = bridge.list_tools()? {
497 return Ok(value);
498 }
499 }
500
501 let Some(bridge) = current_vm_host_bridge() else {
502 return Ok(empty_tool_list_value());
503 };
504 let tools = bridge.list_host_tools().await?;
505 Ok(crate::bridge::json_result_to_vm_value(&JsonValue::Array(
506 tools.into_iter().collect(),
507 )))
508}
509
510pub(crate) async fn dispatch_host_tool_call(
511 name: &str,
512 args: &VmValue,
513) -> Result<VmValue, VmError> {
514 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
515 if let Some(bridge) = bridge {
516 if let Some(value) = bridge.call_tool(name, args)? {
517 return Ok(value);
518 }
519 }
520
521 let Some(bridge) = current_vm_host_bridge() else {
522 return Err(VmError::Thrown(VmValue::String(Rc::from(
523 "host_tool_call: no host bridge is attached",
524 ))));
525 };
526
527 let result = bridge
528 .call(
529 "builtin_call",
530 serde_json::json!({
531 "name": name,
532 "args": [crate::llm::vm_value_to_json(args)],
533 }),
534 )
535 .await?;
536 Ok(crate::bridge::json_result_to_vm_value(&result))
537}
538
539pub(crate) async fn dispatch_host_operation(
540 capability: &str,
541 operation: &str,
542 params: &BTreeMap<String, VmValue>,
543) -> Result<VmValue, VmError> {
544 if let Some(mocked) = dispatch_mock_host_call(capability, operation, params) {
545 return mocked;
546 }
547
548 if (capability, operation) == ("process", "exec") {
549 let caller = serde_json::json!({
550 "surface": "host_call",
551 "capability": "process",
552 "operation": "exec",
553 "session_id": crate::llm::current_agent_session_id(),
554 });
555 return dispatch_process_exec_with_policy(params, caller).await;
556 }
557
558 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
559 if let Some(bridge) = bridge {
560 if let Some(value) = bridge.dispatch(capability, operation, params)? {
561 return Ok(value);
562 }
563 }
564
565 dispatch_builtin_host_operation(capability, operation, params).await
566}
567
568async fn dispatch_builtin_host_operation(
569 capability: &str,
570 operation: &str,
571 params: &BTreeMap<String, VmValue>,
572) -> Result<VmValue, VmError> {
573 match (capability, operation) {
574 ("process", "list_shells") => Ok(crate::shells::list_shells_vm_value()),
575 ("process", "get_default_shell") => Ok(crate::shells::default_shell_vm_value()),
576 ("process", "set_default_shell") => crate::shells::set_default_shell_vm_value(params),
577 ("process", "shell_invocation") => crate::shells::shell_invocation_vm_value(params),
578 ("template", "render") => {
579 let path = require_param(params, "path")?;
580 let bindings = params.get("bindings").and_then(|v| v.as_dict());
581 Ok(VmValue::String(Rc::from(render_template(&path, bindings)?)))
582 }
583 ("interaction", "ask") => {
584 let question = require_param(params, "question")?;
585 use std::io::BufRead;
586 print!("{question}");
587 let _ = std::io::Write::flush(&mut std::io::stdout());
588 let mut input = String::new();
589 if std::io::stdin().lock().read_line(&mut input).is_ok() {
590 Ok(VmValue::String(Rc::from(input.trim_end())))
591 } else {
592 Ok(VmValue::Nil)
593 }
594 }
595 ("runtime", "task") => Ok(VmValue::String(Rc::from(
600 std::env::var("HARN_TASK").unwrap_or_default(),
601 ))),
602 ("runtime", "set_result") => {
603 Ok(VmValue::Nil)
606 }
607 ("workspace", "project_root") => {
608 let path = std::env::var("HARN_PROJECT_ROOT").unwrap_or_else(|_| {
612 std::env::current_dir()
613 .map(|p| p.display().to_string())
614 .unwrap_or_default()
615 });
616 Ok(VmValue::String(Rc::from(path)))
617 }
618 ("workspace", "cwd") => {
619 let path = std::env::current_dir()
620 .map(|p| p.display().to_string())
621 .unwrap_or_default();
622 Ok(VmValue::String(Rc::from(path)))
623 }
624 _ => Err(VmError::Thrown(VmValue::String(Rc::from(format!(
625 "host_call: unsupported operation {capability}.{operation}"
626 ))))),
627 }
628}
629
630pub(crate) async fn dispatch_process_exec(
631 params: &BTreeMap<String, VmValue>,
632 caller: serde_json::Value,
633) -> Result<VmValue, VmError> {
634 dispatch_process_exec_with_policy(params, caller).await
635}
636
637async fn dispatch_process_exec_with_policy(
638 params: &BTreeMap<String, VmValue>,
639 caller: serde_json::Value,
640) -> Result<VmValue, VmError> {
641 let (params, command_policy_context, command_policy_decisions) =
642 match crate::orchestration::run_command_policy_preflight(params, caller).await? {
643 crate::orchestration::CommandPolicyPreflight::Proceed {
644 params,
645 context,
646 decisions,
647 } => (params, context, decisions),
648 crate::orchestration::CommandPolicyPreflight::Blocked {
649 status,
650 message,
651 context,
652 decisions,
653 } => {
654 return Ok(crate::orchestration::blocked_command_response(
655 params, status, &message, context, decisions,
656 ));
657 }
658 };
659
660 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
661 if let Some(bridge) = bridge {
662 if let Some(value) = bridge.dispatch("process", "exec", ¶ms)? {
663 return crate::orchestration::run_command_policy_postflight(
664 ¶ms,
665 value,
666 command_policy_context,
667 command_policy_decisions,
668 )
669 .await;
670 }
671 }
672
673 dispatch_process_exec_after_policy(¶ms, command_policy_context, command_policy_decisions)
674 .await
675}
676
677async fn dispatch_process_exec_after_policy(
678 params: &BTreeMap<String, VmValue>,
679 command_policy_context: JsonValue,
680 command_policy_decisions: Vec<crate::orchestration::CommandPolicyDecision>,
681) -> Result<VmValue, VmError> {
682 let (program, args) = process_exec_argv(params)?;
683 let timeout_ms = optional_i64(params, "timeout")
684 .or_else(|| optional_i64(params, "timeout_ms"))
685 .filter(|value| *value > 0)
686 .map(|value| value as u64);
687 let _profile_guard = match optional_string(params, "sandbox_profile") {
693 Some(value) => Some(push_sandbox_profile_override(&value)?),
694 None => None,
695 };
696 let mut cmd = crate::process_sandbox::tokio_command_for(&program, &args)
697 .map_err(|e| VmError::Runtime(format!("host_call process.exec sandbox setup: {e}")))?;
698 if let Some(cwd) = optional_string(params, "cwd") {
699 let cwd = resolve_process_exec_cwd(&cwd);
700 crate::process_sandbox::enforce_process_cwd(&cwd)
701 .map_err(|e| VmError::Runtime(format!("host_call process.exec cwd: {e}")))?;
702 cmd.current_dir(cwd);
703 }
704 if let Some(env) = optional_string_dict(params, "env")? {
705 let env_mode = optional_string(params, "env_mode");
706 if env_mode.as_deref().unwrap_or("replace") == "replace" {
707 cmd.env_clear();
708 }
709 for (key, value) in env {
710 cmd.env(key, value);
711 }
712 }
713 if let Some(env_remove) = optional_string_list(params, "env_remove") {
719 for key in env_remove {
720 cmd.env_remove(key);
721 }
722 }
723 cmd.stdin(std::process::Stdio::null())
724 .stdout(std::process::Stdio::piped())
725 .stderr(std::process::Stdio::piped())
726 .kill_on_drop(true);
727 let started_at = audited_utc_now_rfc3339("host_call/process.exec.started_at");
728 let started = crate::clock_mock::leak_audit::instant_now("host_call/process.exec.started");
729 let child = cmd
730 .spawn()
731 .map_err(|e| VmError::Runtime(format!("host_call process.exec: {e}")))?;
732 let pid = child.id();
733 let timed_out;
734 let output_result = if let Some(timeout_ms) = timeout_ms {
735 match tokio::time::timeout(
736 std::time::Duration::from_millis(timeout_ms),
737 child.wait_with_output(),
738 )
739 .await
740 {
741 Ok(result) => {
742 timed_out = false;
743 result
744 }
745 Err(_) => {
746 let response = process_exec_response(ProcessExecResponse {
747 pid,
748 started_at,
749 started,
750 stdout: "",
751 stderr: "",
752 exit_code: -1,
753 status: "timed_out",
754 success: false,
755 timed_out: true,
756 });
757 return crate::orchestration::run_command_policy_postflight(
758 params,
759 response,
760 command_policy_context,
761 command_policy_decisions,
762 )
763 .await;
764 }
765 }
766 } else {
767 timed_out = false;
768 child.wait_with_output().await
769 };
770 let output =
771 output_result.map_err(|e| VmError::Runtime(format!("host_call process.exec: {e}")))?;
772 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
773 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
774 let exit_code = output.status.code().unwrap_or(-1);
775 let response = process_exec_response(ProcessExecResponse {
776 pid,
777 started_at,
778 started,
779 stdout: &stdout,
780 stderr: &stderr,
781 exit_code,
782 status: if timed_out { "timed_out" } else { "completed" },
783 success: output.status.success(),
784 timed_out,
785 });
786 crate::orchestration::run_command_policy_postflight(
787 params,
788 response,
789 command_policy_context,
790 command_policy_decisions,
791 )
792 .await
793}
794
795struct ProcessExecResponse<'a> {
796 pid: Option<u32>,
797 started_at: String,
798 started: Instant,
799 stdout: &'a str,
800 stderr: &'a str,
801 exit_code: i32,
802 status: &'a str,
803 success: bool,
804 timed_out: bool,
805}
806
807fn process_exec_response(response: ProcessExecResponse<'_>) -> VmValue {
808 let combined = format!("{}{}", response.stdout, response.stderr);
809 let mut result = BTreeMap::new();
810 result.insert(
811 "command_id".to_string(),
812 VmValue::String(Rc::from(format!(
813 "cmd_{}_{}",
814 std::process::id(),
815 response.started.elapsed().as_nanos()
816 ))),
817 );
818 result.insert(
819 "status".to_string(),
820 VmValue::String(Rc::from(response.status)),
821 );
822 result.insert(
823 "pid".to_string(),
824 response
825 .pid
826 .map(|pid| VmValue::Int(pid as i64))
827 .unwrap_or(VmValue::Nil),
828 );
829 result.insert(
830 "process_group_id".to_string(),
831 response
832 .pid
833 .map(|pid| VmValue::Int(pid as i64))
834 .unwrap_or(VmValue::Nil),
835 );
836 result.insert("handle_id".to_string(), VmValue::Nil);
837 result.insert(
838 "started_at".to_string(),
839 VmValue::String(Rc::from(response.started_at)),
840 );
841 result.insert(
842 "ended_at".to_string(),
843 VmValue::String(Rc::from(audited_utc_now_rfc3339(
844 "host_call/process.exec.ended_at",
845 ))),
846 );
847 result.insert(
848 "duration_ms".to_string(),
849 VmValue::Int(response.started.elapsed().as_millis() as i64),
850 );
851 result.insert(
852 "exit_code".to_string(),
853 VmValue::Int(response.exit_code as i64),
854 );
855 result.insert("signal".to_string(), VmValue::Nil);
856 result.insert("timed_out".to_string(), VmValue::Bool(response.timed_out));
857 result.insert(
858 "stdout".to_string(),
859 VmValue::String(Rc::from(response.stdout.to_string())),
860 );
861 result.insert(
862 "stderr".to_string(),
863 VmValue::String(Rc::from(response.stderr.to_string())),
864 );
865 result.insert("combined".to_string(), VmValue::String(Rc::from(combined)));
866 result.insert(
867 "exit_status".to_string(),
868 VmValue::Int(response.exit_code as i64),
869 );
870 result.insert(
871 "legacy_status".to_string(),
872 VmValue::Int(response.exit_code as i64),
873 );
874 result.insert("success".to_string(), VmValue::Bool(response.success));
875 VmValue::Dict(Rc::new(result))
876}
877
878fn resolve_process_exec_cwd(cwd: &str) -> std::path::PathBuf {
879 crate::stdlib::process::resolve_source_relative_path(cwd)
880}
881
882fn process_exec_argv(params: &BTreeMap<String, VmValue>) -> Result<(String, Vec<String>), VmError> {
883 match optional_string(params, "mode")
884 .as_deref()
885 .unwrap_or("shell")
886 {
887 "argv" => {
888 let argv = optional_string_list(params, "argv").ok_or_else(|| {
889 VmError::Runtime("host_call process.exec missing argv".to_string())
890 })?;
891 split_argv(argv)
892 }
893 "shell" => {
894 let command = require_param(params, "command")?;
895 let mut invocation_params = params.clone();
896 invocation_params.insert("command".to_string(), VmValue::String(Rc::from(command)));
897 let invocation =
898 crate::shells::resolve_invocation_from_vm_params(&invocation_params)
899 .map_err(|err| VmError::Runtime(format!("host_call process.exec: {err}")))?;
900 Ok((invocation.program, invocation.args))
901 }
902 other => Err(VmError::Runtime(format!(
903 "host_call process.exec unsupported mode {other:?}"
904 ))),
905 }
906}
907
908fn split_argv(mut argv: Vec<String>) -> Result<(String, Vec<String>), VmError> {
909 if argv.is_empty() {
910 return Err(VmError::Runtime(
911 "host_call process.exec argv must not be empty".to_string(),
912 ));
913 }
914 let program = argv.remove(0);
915 if program.is_empty() {
916 return Err(VmError::Runtime(
917 "host_call process.exec argv[0] must not be empty".to_string(),
918 ));
919 }
920 Ok((program, argv))
921}
922
923fn push_sandbox_profile_override(value: &str) -> Result<SandboxProfileGuard, VmError> {
929 let profile = crate::orchestration::SandboxProfile::parse(value).ok_or_else(|| {
930 VmError::Thrown(VmValue::String(Rc::from(format!(
931 "host_call process.exec: unknown sandbox_profile {value:?}; expected one of \"unrestricted\", \"worktree\", \"os_hardened\", \"wasi\""
932 ))))
933 })?;
934 let mut policy = crate::orchestration::current_execution_policy().unwrap_or_default();
935 policy.sandbox_profile = profile;
936 crate::orchestration::push_execution_policy(policy);
937 Ok(SandboxProfileGuard {
938 _private: std::marker::PhantomData,
939 })
940}
941
942struct SandboxProfileGuard {
943 _private: std::marker::PhantomData<*const ()>,
944}
945
946impl Drop for SandboxProfileGuard {
947 fn drop(&mut self) {
948 crate::orchestration::pop_execution_policy();
949 }
950}
951
952fn optional_i64(params: &BTreeMap<String, VmValue>, key: &str) -> Option<i64> {
953 match params.get(key) {
954 Some(VmValue::Int(value)) => Some(*value),
955 Some(VmValue::Float(value)) if value.fract() == 0.0 => Some(*value as i64),
956 _ => None,
957 }
958}
959
960fn optional_string(params: &BTreeMap<String, VmValue>, key: &str) -> Option<String> {
961 params.get(key).and_then(vm_string).map(ToString::to_string)
962}
963
964fn optional_string_list(params: &BTreeMap<String, VmValue>, key: &str) -> Option<Vec<String>> {
965 let VmValue::List(values) = params.get(key)? else {
966 return None;
967 };
968 values
969 .iter()
970 .map(|value| vm_string(value).map(ToString::to_string))
971 .collect()
972}
973
974fn optional_string_dict(
975 params: &BTreeMap<String, VmValue>,
976 key: &str,
977) -> Result<Option<BTreeMap<String, String>>, VmError> {
978 let Some(value) = params.get(key) else {
979 return Ok(None);
980 };
981 let Some(dict) = value.as_dict() else {
982 return Err(VmError::Runtime(format!(
983 "host_call process.exec {key} must be a dict"
984 )));
985 };
986 let mut out = BTreeMap::new();
987 for (key, value) in dict.iter() {
988 let Some(value) = vm_string(value) else {
989 return Err(VmError::Runtime(format!(
990 "host_call process.exec env value for {key:?} must be a string"
991 )));
992 };
993 out.insert(key.clone(), value.to_string());
994 }
995 Ok(Some(out))
996}
997
998fn vm_string(value: &VmValue) -> Option<&str> {
999 match value {
1000 VmValue::String(value) => Some(value.as_ref()),
1001 _ => None,
1002 }
1003}
1004
1005pub(crate) fn register_host_builtins(vm: &mut Vm) {
1006 register_builtin_group(vm, HOST_PRIMITIVES);
1007}
1008
1009fn host_mock_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1010 let host_mock = parse_host_mock(args)?;
1011 push_host_mock(host_mock);
1012 Ok(VmValue::Nil)
1013}
1014
1015fn host_mock_clear_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1016 reset_host_state();
1017 Ok(VmValue::Nil)
1018}
1019
1020fn host_mock_calls_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1021 let calls = HOST_MOCK_CALLS.with(|calls| {
1022 calls
1023 .borrow()
1024 .iter()
1025 .map(mock_call_value)
1026 .collect::<Vec<_>>()
1027 });
1028 Ok(VmValue::List(Rc::new(calls)))
1029}
1030
1031fn host_mock_push_scope_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1032 push_host_mock_scope();
1033 Ok(VmValue::Nil)
1034}
1035
1036fn host_mock_pop_scope_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1037 if !pop_host_mock_scope() {
1038 return Err(VmError::Thrown(VmValue::String(Rc::from(
1039 "host_mock_pop_scope: no scope to pop",
1040 ))));
1041 }
1042 Ok(VmValue::Nil)
1043}
1044
1045fn host_capabilities_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1046 Ok(capability_manifest_with_mocks())
1047}
1048
1049fn host_has_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1050 let capability = args.first().map(|a| a.display()).unwrap_or_default();
1051 let operation = args.get(1).map(|a| a.display());
1052 let manifest = capability_manifest_with_mocks();
1053 let has = manifest
1054 .as_dict()
1055 .and_then(|d| d.get(&capability))
1056 .and_then(|v| v.as_dict())
1057 .is_some_and(|cap| {
1058 if let Some(operation) = operation {
1059 cap.get("ops")
1060 .and_then(|v| match v {
1061 VmValue::List(list) => {
1062 Some(list.iter().any(|item| item.display() == operation))
1063 }
1064 _ => None,
1065 })
1066 .unwrap_or(false)
1067 } else {
1068 true
1069 }
1070 });
1071 Ok(VmValue::Bool(has))
1072}
1073
1074async fn host_call_builtin(args: Vec<VmValue>) -> Result<VmValue, VmError> {
1075 let name = args.first().map(|a| a.display()).unwrap_or_default();
1076 let params = args
1077 .get(1)
1078 .and_then(|a| a.as_dict())
1079 .cloned()
1080 .unwrap_or_default();
1081 let Some((capability, operation)) = name.split_once('.') else {
1082 return Err(VmError::Thrown(VmValue::String(Rc::from(format!(
1083 "host_call: unsupported operation name '{name}'"
1084 )))));
1085 };
1086 dispatch_host_operation(capability, operation, ¶ms).await
1087}
1088
1089async fn host_tool_list_builtin(_args: Vec<VmValue>) -> Result<VmValue, VmError> {
1090 dispatch_host_tool_list().await
1091}
1092
1093async fn host_tool_call_builtin(args: Vec<VmValue>) -> Result<VmValue, VmError> {
1094 let name = args.first().map(|a| a.display()).unwrap_or_default();
1095 if name.is_empty() {
1096 return Err(VmError::Thrown(VmValue::String(Rc::from(
1097 "host_tool_call: tool name is required",
1098 ))));
1099 }
1100 let call_args = args.get(1).cloned().unwrap_or(VmValue::Nil);
1101 dispatch_host_tool_call(&name, &call_args).await
1102}
1103
1104#[cfg(test)]
1105mod tests {
1106 use super::{
1107 capability_manifest_with_mocks, clear_host_call_bridge, dispatch_host_operation,
1108 dispatch_host_tool_call, dispatch_host_tool_list, dispatch_mock_host_call, push_host_mock,
1109 reset_host_state, resolve_process_exec_cwd, set_host_call_bridge, HostCallBridge, HostMock,
1110 };
1111 use std::cell::Cell;
1112 use std::collections::BTreeMap;
1113 use std::rc::Rc;
1114
1115 use crate::value::{VmError, VmValue};
1116
1117 #[test]
1118 fn process_exec_relative_cwd_resolves_against_execution_root() {
1119 let dir = tempfile::tempdir().expect("tempdir");
1120 crate::stdlib::process::set_thread_execution_context(Some(
1121 crate::orchestration::RunExecutionRecord {
1122 cwd: Some(dir.path().to_string_lossy().into_owned()),
1123 source_dir: Some(dir.path().join("src").to_string_lossy().into_owned()),
1124 env: BTreeMap::new(),
1125 adapter: None,
1126 repo_path: None,
1127 worktree_path: None,
1128 branch: None,
1129 base_ref: None,
1130 cleanup: None,
1131 },
1132 ));
1133
1134 assert_eq!(
1135 resolve_process_exec_cwd("subdir"),
1136 dir.path().join("subdir")
1137 );
1138
1139 crate::stdlib::process::set_thread_execution_context(None);
1140 }
1141
1142 #[test]
1143 fn manifest_includes_operation_metadata() {
1144 let manifest = capability_manifest_with_mocks();
1145 let process = manifest
1146 .as_dict()
1147 .and_then(|d| d.get("process"))
1148 .and_then(|v| v.as_dict())
1149 .expect("process capability");
1150 assert!(process.get("description").is_some());
1151 let operations = process
1152 .get("operations")
1153 .and_then(|v| v.as_dict())
1154 .expect("operations dict");
1155 assert!(operations.get("exec").is_some());
1156 }
1157
1158 #[test]
1159 fn mocked_capabilities_appear_in_manifest() {
1160 reset_host_state();
1161 push_host_mock(HostMock {
1162 capability: "project".to_string(),
1163 operation: "metadata_get".to_string(),
1164 params: None,
1165 result: Some(VmValue::Dict(Rc::new(BTreeMap::new()))),
1166 error: None,
1167 });
1168 let manifest = capability_manifest_with_mocks();
1169 let project = manifest
1170 .as_dict()
1171 .and_then(|d| d.get("project"))
1172 .and_then(|v| v.as_dict())
1173 .expect("project capability");
1174 let operations = project
1175 .get("operations")
1176 .and_then(|v| v.as_dict())
1177 .expect("operations dict");
1178 assert!(operations.get("metadata_get").is_some());
1179 reset_host_state();
1180 }
1181
1182 #[test]
1183 fn mock_host_call_matches_partial_params_and_overrides_order() {
1184 reset_host_state();
1185 let mut exact_params = BTreeMap::new();
1186 exact_params.insert("namespace".to_string(), VmValue::String(Rc::from("facts")));
1187 push_host_mock(HostMock {
1188 capability: "project".to_string(),
1189 operation: "metadata_get".to_string(),
1190 params: None,
1191 result: Some(VmValue::String(Rc::from("fallback"))),
1192 error: None,
1193 });
1194 push_host_mock(HostMock {
1195 capability: "project".to_string(),
1196 operation: "metadata_get".to_string(),
1197 params: Some(exact_params),
1198 result: Some(VmValue::String(Rc::from("facts"))),
1199 error: None,
1200 });
1201
1202 let mut call_params = BTreeMap::new();
1203 call_params.insert("dir".to_string(), VmValue::String(Rc::from("pkg")));
1204 call_params.insert("namespace".to_string(), VmValue::String(Rc::from("facts")));
1205 let exact = dispatch_mock_host_call("project", "metadata_get", &call_params)
1206 .expect("expected exact mock")
1207 .expect("exact mock should succeed");
1208 assert_eq!(exact.display(), "facts");
1209
1210 call_params.insert(
1211 "namespace".to_string(),
1212 VmValue::String(Rc::from("classification")),
1213 );
1214 let fallback = dispatch_mock_host_call("project", "metadata_get", &call_params)
1215 .expect("expected fallback mock")
1216 .expect("fallback mock should succeed");
1217 assert_eq!(fallback.display(), "fallback");
1218 reset_host_state();
1219 }
1220
1221 #[test]
1222 fn mock_host_call_can_throw_errors() {
1223 reset_host_state();
1224 push_host_mock(HostMock {
1225 capability: "project".to_string(),
1226 operation: "metadata_get".to_string(),
1227 params: None,
1228 result: None,
1229 error: Some("boom".to_string()),
1230 });
1231 let params = BTreeMap::new();
1232 let result = dispatch_mock_host_call("project", "metadata_get", ¶ms)
1233 .expect("expected mock result");
1234 match result {
1235 Err(VmError::Thrown(VmValue::String(message))) => assert_eq!(message.as_ref(), "boom"),
1236 other => panic!("unexpected result: {other:?}"),
1237 }
1238 reset_host_state();
1239 }
1240
1241 #[derive(Default)]
1242 struct TestHostToolBridge;
1243
1244 impl HostCallBridge for TestHostToolBridge {
1245 fn dispatch(
1246 &self,
1247 _capability: &str,
1248 _operation: &str,
1249 _params: &BTreeMap<String, VmValue>,
1250 ) -> Result<Option<VmValue>, VmError> {
1251 Ok(None)
1252 }
1253
1254 fn list_tools(&self) -> Result<Option<VmValue>, VmError> {
1255 let tool = VmValue::Dict(Rc::new(BTreeMap::from([
1256 (
1257 "name".to_string(),
1258 VmValue::String(Rc::from("Read".to_string())),
1259 ),
1260 (
1261 "description".to_string(),
1262 VmValue::String(Rc::from("Read a file from the host".to_string())),
1263 ),
1264 (
1265 "schema".to_string(),
1266 VmValue::Dict(Rc::new(BTreeMap::from([(
1267 "type".to_string(),
1268 VmValue::String(Rc::from("object".to_string())),
1269 )]))),
1270 ),
1271 ("deprecated".to_string(), VmValue::Bool(false)),
1272 ])));
1273 Ok(Some(VmValue::List(Rc::new(vec![tool]))))
1274 }
1275
1276 fn call_tool(&self, name: &str, args: &VmValue) -> Result<Option<VmValue>, VmError> {
1277 if name != "Read" {
1278 return Ok(None);
1279 }
1280 let path = args
1281 .as_dict()
1282 .and_then(|dict| dict.get("path"))
1283 .map(|value| value.display())
1284 .unwrap_or_default();
1285 Ok(Some(VmValue::String(Rc::from(format!("read:{path}")))))
1286 }
1287 }
1288
1289 struct CountingProcessExecBridge {
1290 calls: Rc<Cell<usize>>,
1291 }
1292
1293 impl HostCallBridge for CountingProcessExecBridge {
1294 fn dispatch(
1295 &self,
1296 capability: &str,
1297 operation: &str,
1298 _params: &BTreeMap<String, VmValue>,
1299 ) -> Result<Option<VmValue>, VmError> {
1300 if (capability, operation) != ("process", "exec") {
1301 return Ok(None);
1302 }
1303 self.calls.set(self.calls.get() + 1);
1304 Ok(Some(VmValue::Dict(Rc::new(BTreeMap::from([
1305 (
1306 "status".to_string(),
1307 VmValue::String(Rc::from("completed".to_string())),
1308 ),
1309 ("exit_code".to_string(), VmValue::Int(0)),
1310 ("success".to_string(), VmValue::Bool(true)),
1311 ])))))
1312 }
1313 }
1314
1315 fn run_host_async_test<F, Fut>(test: F)
1316 where
1317 F: FnOnce() -> Fut,
1318 Fut: std::future::Future<Output = ()>,
1319 {
1320 let rt = tokio::runtime::Builder::new_current_thread()
1321 .enable_all()
1322 .build()
1323 .expect("runtime");
1324 rt.block_on(async {
1325 let local = tokio::task::LocalSet::new();
1326 local.run_until(test()).await;
1327 });
1328 }
1329
1330 #[test]
1331 fn host_tool_list_uses_installed_host_call_bridge() {
1332 run_host_async_test(|| async {
1333 reset_host_state();
1334 set_host_call_bridge(Rc::new(TestHostToolBridge));
1335 let tools = dispatch_host_tool_list().await.expect("tool list");
1336 clear_host_call_bridge();
1337
1338 let VmValue::List(items) = tools else {
1339 panic!("expected tool list");
1340 };
1341 assert_eq!(items.len(), 1);
1342 let tool = items[0].as_dict().expect("tool dict");
1343 assert_eq!(tool.get("name").unwrap().display(), "Read");
1344 assert_eq!(tool.get("deprecated").unwrap().display(), "false");
1345 });
1346 }
1347
1348 #[test]
1349 fn host_tool_call_uses_installed_host_call_bridge() {
1350 run_host_async_test(|| async {
1351 set_host_call_bridge(Rc::new(TestHostToolBridge));
1352 let args = VmValue::Dict(Rc::new(BTreeMap::from([(
1353 "path".to_string(),
1354 VmValue::String(Rc::from("README.md".to_string())),
1355 )])));
1356 let value = dispatch_host_tool_call("Read", &args)
1357 .await
1358 .expect("tool call");
1359 clear_host_call_bridge();
1360 assert_eq!(value.display(), "read:README.md");
1361 });
1362 }
1363
1364 #[test]
1365 fn process_exec_bridge_is_gated_by_command_policy() {
1366 run_host_async_test(|| async {
1367 crate::orchestration::clear_command_policies();
1368 let calls = Rc::new(Cell::new(0));
1369 set_host_call_bridge(Rc::new(CountingProcessExecBridge {
1370 calls: calls.clone(),
1371 }));
1372 crate::orchestration::push_command_policy(crate::orchestration::CommandPolicy {
1373 tools: vec!["run".to_string()],
1374 workspace_roots: Vec::new(),
1375 default_shell_mode: "shell".to_string(),
1376 deny_patterns: vec!["cat *".to_string()],
1377 require_approval: Default::default(),
1378 pre: None,
1379 post: None,
1380 allow_recursive: false,
1381 });
1382
1383 let result = dispatch_host_operation(
1384 "process",
1385 "exec",
1386 &BTreeMap::from([
1387 ("mode".to_string(), VmValue::String(Rc::from("shell"))),
1388 (
1389 "command".to_string(),
1390 VmValue::String(Rc::from("cat Cargo.toml")),
1391 ),
1392 ]),
1393 )
1394 .await
1395 .expect("process.exec result");
1396
1397 crate::orchestration::clear_command_policies();
1398 clear_host_call_bridge();
1399
1400 assert_eq!(calls.get(), 0, "blocked command must not reach host bridge");
1401 let result = result.as_dict().expect("blocked result dict");
1402 assert_eq!(result.get("status").unwrap().display(), "blocked");
1403 assert!(
1404 result
1405 .get("reason")
1406 .map(VmValue::display)
1407 .unwrap_or_default()
1408 .contains("cat *"),
1409 "blocked result should name the matched policy pattern"
1410 );
1411 });
1412 }
1413
1414 #[test]
1415 fn host_tool_list_is_empty_without_bridge() {
1416 run_host_async_test(|| async {
1417 clear_host_call_bridge();
1418 let tools = dispatch_host_tool_list().await.expect("tool list");
1419 let VmValue::List(items) = tools else {
1420 panic!("expected tool list");
1421 };
1422 assert!(items.is_empty());
1423 });
1424 }
1425}