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