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 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
529 if let Some(bridge) = bridge {
530 if let Some(value) = bridge.dispatch(capability, operation, params)? {
531 return Ok(value);
532 }
533 }
534
535 match (capability, operation) {
536 ("process", "exec") => {
537 let caller = serde_json::json!({
538 "surface": "host_call",
539 "capability": "process",
540 "operation": "exec",
541 "session_id": crate::llm::current_agent_session_id(),
542 });
543 dispatch_process_exec(params, caller).await
544 }
545 ("process", "list_shells") => Ok(crate::shells::list_shells_vm_value()),
546 ("process", "get_default_shell") => Ok(crate::shells::default_shell_vm_value()),
547 ("process", "set_default_shell") => crate::shells::set_default_shell_vm_value(params),
548 ("process", "shell_invocation") => crate::shells::shell_invocation_vm_value(params),
549 ("template", "render") => {
550 let path = require_param(params, "path")?;
551 let bindings = params.get("bindings").and_then(|v| v.as_dict());
552 Ok(VmValue::String(Rc::from(render_template(&path, bindings)?)))
553 }
554 ("interaction", "ask") => {
555 let question = require_param(params, "question")?;
556 use std::io::BufRead;
557 print!("{question}");
558 let _ = std::io::Write::flush(&mut std::io::stdout());
559 let mut input = String::new();
560 if std::io::stdin().lock().read_line(&mut input).is_ok() {
561 Ok(VmValue::String(Rc::from(input.trim_end())))
562 } else {
563 Ok(VmValue::Nil)
564 }
565 }
566 ("runtime", "task") => Ok(VmValue::String(Rc::from(
571 std::env::var("HARN_TASK").unwrap_or_default(),
572 ))),
573 ("runtime", "set_result") => {
574 Ok(VmValue::Nil)
577 }
578 ("workspace", "project_root") => {
579 let path = std::env::var("HARN_PROJECT_ROOT").unwrap_or_else(|_| {
583 std::env::current_dir()
584 .map(|p| p.display().to_string())
585 .unwrap_or_default()
586 });
587 Ok(VmValue::String(Rc::from(path)))
588 }
589 ("workspace", "cwd") => {
590 let path = std::env::current_dir()
591 .map(|p| p.display().to_string())
592 .unwrap_or_default();
593 Ok(VmValue::String(Rc::from(path)))
594 }
595 _ => Err(VmError::Thrown(VmValue::String(Rc::from(format!(
596 "host_call: unsupported operation {capability}.{operation}"
597 ))))),
598 }
599}
600
601pub(crate) async fn dispatch_process_exec(
602 params: &BTreeMap<String, VmValue>,
603 caller: serde_json::Value,
604) -> Result<VmValue, VmError> {
605 let (params, command_policy_context, command_policy_decisions) =
606 match crate::orchestration::run_command_policy_preflight(params, caller).await? {
607 crate::orchestration::CommandPolicyPreflight::Proceed {
608 params,
609 context,
610 decisions,
611 } => (params, context, decisions),
612 crate::orchestration::CommandPolicyPreflight::Blocked {
613 status,
614 message,
615 context,
616 decisions,
617 } => {
618 return Ok(crate::orchestration::blocked_command_response(
619 params, status, &message, context, decisions,
620 ));
621 }
622 };
623 let (program, args) = process_exec_argv(¶ms)?;
624 let timeout_ms = optional_i64(¶ms, "timeout")
625 .or_else(|| optional_i64(¶ms, "timeout_ms"))
626 .filter(|value| *value > 0)
627 .map(|value| value as u64);
628 let mut cmd = crate::process_sandbox::tokio_command_for(&program, &args)
629 .map_err(|e| VmError::Runtime(format!("host_call process.exec sandbox setup: {e}")))?;
630 if let Some(cwd) = optional_string(¶ms, "cwd") {
631 crate::process_sandbox::enforce_process_cwd(std::path::Path::new(&cwd))
632 .map_err(|e| VmError::Runtime(format!("host_call process.exec cwd: {e}")))?;
633 cmd.current_dir(cwd);
634 }
635 if let Some(env) = optional_string_dict(¶ms, "env")? {
636 let env_mode = optional_string(¶ms, "env_mode");
637 if env_mode.as_deref().unwrap_or("replace") == "replace" {
638 cmd.env_clear();
639 }
640 for (key, value) in env {
641 cmd.env(key, value);
642 }
643 }
644 if let Some(env_remove) = optional_string_list(¶ms, "env_remove") {
650 for key in env_remove {
651 cmd.env_remove(key);
652 }
653 }
654 cmd.stdin(std::process::Stdio::null())
655 .stdout(std::process::Stdio::piped())
656 .stderr(std::process::Stdio::piped())
657 .kill_on_drop(true);
658 let started_at = chrono::Utc::now().to_rfc3339();
659 let started = Instant::now();
660 let child = cmd
661 .spawn()
662 .map_err(|e| VmError::Runtime(format!("host_call process.exec: {e}")))?;
663 let pid = child.id();
664 let timed_out;
665 let output_result = if let Some(timeout_ms) = timeout_ms {
666 match tokio::time::timeout(
667 std::time::Duration::from_millis(timeout_ms),
668 child.wait_with_output(),
669 )
670 .await
671 {
672 Ok(result) => {
673 timed_out = false;
674 result
675 }
676 Err(_) => {
677 let response = process_exec_response(ProcessExecResponse {
678 pid,
679 started_at,
680 started,
681 stdout: "",
682 stderr: "",
683 exit_code: -1,
684 status: "timed_out",
685 success: false,
686 timed_out: true,
687 });
688 return crate::orchestration::run_command_policy_postflight(
689 ¶ms,
690 response,
691 command_policy_context,
692 command_policy_decisions,
693 )
694 .await;
695 }
696 }
697 } else {
698 timed_out = false;
699 child.wait_with_output().await
700 };
701 let output =
702 output_result.map_err(|e| VmError::Runtime(format!("host_call process.exec: {e}")))?;
703 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
704 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
705 let exit_code = output.status.code().unwrap_or(-1);
706 let response = process_exec_response(ProcessExecResponse {
707 pid,
708 started_at,
709 started,
710 stdout: &stdout,
711 stderr: &stderr,
712 exit_code,
713 status: if timed_out { "timed_out" } else { "completed" },
714 success: output.status.success(),
715 timed_out,
716 });
717 crate::orchestration::run_command_policy_postflight(
718 ¶ms,
719 response,
720 command_policy_context,
721 command_policy_decisions,
722 )
723 .await
724}
725
726struct ProcessExecResponse<'a> {
727 pid: Option<u32>,
728 started_at: String,
729 started: Instant,
730 stdout: &'a str,
731 stderr: &'a str,
732 exit_code: i32,
733 status: &'a str,
734 success: bool,
735 timed_out: bool,
736}
737
738fn process_exec_response(response: ProcessExecResponse<'_>) -> VmValue {
739 let combined = format!("{}{}", response.stdout, response.stderr);
740 let mut result = BTreeMap::new();
741 result.insert(
742 "command_id".to_string(),
743 VmValue::String(Rc::from(format!(
744 "cmd_{}_{}",
745 std::process::id(),
746 response.started.elapsed().as_nanos()
747 ))),
748 );
749 result.insert(
750 "status".to_string(),
751 VmValue::String(Rc::from(response.status)),
752 );
753 result.insert(
754 "pid".to_string(),
755 response
756 .pid
757 .map(|pid| VmValue::Int(pid as i64))
758 .unwrap_or(VmValue::Nil),
759 );
760 result.insert(
761 "process_group_id".to_string(),
762 response
763 .pid
764 .map(|pid| VmValue::Int(pid as i64))
765 .unwrap_or(VmValue::Nil),
766 );
767 result.insert("handle_id".to_string(), VmValue::Nil);
768 result.insert(
769 "started_at".to_string(),
770 VmValue::String(Rc::from(response.started_at)),
771 );
772 result.insert(
773 "ended_at".to_string(),
774 VmValue::String(Rc::from(chrono::Utc::now().to_rfc3339())),
775 );
776 result.insert(
777 "duration_ms".to_string(),
778 VmValue::Int(response.started.elapsed().as_millis() as i64),
779 );
780 result.insert(
781 "exit_code".to_string(),
782 VmValue::Int(response.exit_code as i64),
783 );
784 result.insert("signal".to_string(), VmValue::Nil);
785 result.insert("timed_out".to_string(), VmValue::Bool(response.timed_out));
786 result.insert(
787 "stdout".to_string(),
788 VmValue::String(Rc::from(response.stdout.to_string())),
789 );
790 result.insert(
791 "stderr".to_string(),
792 VmValue::String(Rc::from(response.stderr.to_string())),
793 );
794 result.insert("combined".to_string(), VmValue::String(Rc::from(combined)));
795 result.insert(
796 "exit_status".to_string(),
797 VmValue::Int(response.exit_code as i64),
798 );
799 result.insert(
800 "legacy_status".to_string(),
801 VmValue::Int(response.exit_code as i64),
802 );
803 result.insert("success".to_string(), VmValue::Bool(response.success));
804 VmValue::Dict(Rc::new(result))
805}
806
807fn process_exec_argv(params: &BTreeMap<String, VmValue>) -> Result<(String, Vec<String>), VmError> {
808 match optional_string(params, "mode")
809 .as_deref()
810 .unwrap_or("shell")
811 {
812 "argv" => {
813 let argv = optional_string_list(params, "argv").ok_or_else(|| {
814 VmError::Runtime("host_call process.exec missing argv".to_string())
815 })?;
816 split_argv(argv)
817 }
818 "shell" => {
819 let command = require_param(params, "command")?;
820 let mut invocation_params = params.clone();
821 invocation_params.insert("command".to_string(), VmValue::String(Rc::from(command)));
822 let invocation =
823 crate::shells::resolve_invocation_from_vm_params(&invocation_params)
824 .map_err(|err| VmError::Runtime(format!("host_call process.exec: {err}")))?;
825 Ok((invocation.program, invocation.args))
826 }
827 other => Err(VmError::Runtime(format!(
828 "host_call process.exec unsupported mode {other:?}"
829 ))),
830 }
831}
832
833fn split_argv(mut argv: Vec<String>) -> Result<(String, Vec<String>), VmError> {
834 if argv.is_empty() {
835 return Err(VmError::Runtime(
836 "host_call process.exec argv must not be empty".to_string(),
837 ));
838 }
839 let program = argv.remove(0);
840 if program.is_empty() {
841 return Err(VmError::Runtime(
842 "host_call process.exec argv[0] must not be empty".to_string(),
843 ));
844 }
845 Ok((program, argv))
846}
847
848fn optional_i64(params: &BTreeMap<String, VmValue>, key: &str) -> Option<i64> {
849 match params.get(key) {
850 Some(VmValue::Int(value)) => Some(*value),
851 Some(VmValue::Float(value)) if value.fract() == 0.0 => Some(*value as i64),
852 _ => None,
853 }
854}
855
856fn optional_string(params: &BTreeMap<String, VmValue>, key: &str) -> Option<String> {
857 params.get(key).and_then(vm_string).map(ToString::to_string)
858}
859
860fn optional_string_list(params: &BTreeMap<String, VmValue>, key: &str) -> Option<Vec<String>> {
861 let VmValue::List(values) = params.get(key)? else {
862 return None;
863 };
864 values
865 .iter()
866 .map(|value| vm_string(value).map(ToString::to_string))
867 .collect()
868}
869
870fn optional_string_dict(
871 params: &BTreeMap<String, VmValue>,
872 key: &str,
873) -> Result<Option<BTreeMap<String, String>>, VmError> {
874 let Some(value) = params.get(key) else {
875 return Ok(None);
876 };
877 let Some(dict) = value.as_dict() else {
878 return Err(VmError::Runtime(format!(
879 "host_call process.exec {key} must be a dict"
880 )));
881 };
882 let mut out = BTreeMap::new();
883 for (key, value) in dict.iter() {
884 let Some(value) = vm_string(value) else {
885 return Err(VmError::Runtime(format!(
886 "host_call process.exec env value for {key:?} must be a string"
887 )));
888 };
889 out.insert(key.clone(), value.to_string());
890 }
891 Ok(Some(out))
892}
893
894fn vm_string(value: &VmValue) -> Option<&str> {
895 match value {
896 VmValue::String(value) => Some(value.as_ref()),
897 _ => None,
898 }
899}
900
901pub(crate) fn register_host_builtins(vm: &mut Vm) {
902 register_builtin_group(vm, HOST_PRIMITIVES);
903}
904
905fn host_mock_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
906 let host_mock = parse_host_mock(args)?;
907 push_host_mock(host_mock);
908 Ok(VmValue::Nil)
909}
910
911fn host_mock_clear_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
912 reset_host_state();
913 Ok(VmValue::Nil)
914}
915
916fn host_mock_calls_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
917 let calls = HOST_MOCK_CALLS.with(|calls| {
918 calls
919 .borrow()
920 .iter()
921 .map(mock_call_value)
922 .collect::<Vec<_>>()
923 });
924 Ok(VmValue::List(Rc::new(calls)))
925}
926
927fn host_mock_push_scope_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
928 push_host_mock_scope();
929 Ok(VmValue::Nil)
930}
931
932fn host_mock_pop_scope_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
933 if !pop_host_mock_scope() {
934 return Err(VmError::Thrown(VmValue::String(Rc::from(
935 "host_mock_pop_scope: no scope to pop",
936 ))));
937 }
938 Ok(VmValue::Nil)
939}
940
941fn host_capabilities_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
942 Ok(capability_manifest_with_mocks())
943}
944
945fn host_has_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
946 let capability = args.first().map(|a| a.display()).unwrap_or_default();
947 let operation = args.get(1).map(|a| a.display());
948 let manifest = capability_manifest_with_mocks();
949 let has = manifest
950 .as_dict()
951 .and_then(|d| d.get(&capability))
952 .and_then(|v| v.as_dict())
953 .is_some_and(|cap| {
954 if let Some(operation) = operation {
955 cap.get("ops")
956 .and_then(|v| match v {
957 VmValue::List(list) => {
958 Some(list.iter().any(|item| item.display() == operation))
959 }
960 _ => None,
961 })
962 .unwrap_or(false)
963 } else {
964 true
965 }
966 });
967 Ok(VmValue::Bool(has))
968}
969
970async fn host_call_builtin(args: Vec<VmValue>) -> Result<VmValue, VmError> {
971 let name = args.first().map(|a| a.display()).unwrap_or_default();
972 let params = args
973 .get(1)
974 .and_then(|a| a.as_dict())
975 .cloned()
976 .unwrap_or_default();
977 let Some((capability, operation)) = name.split_once('.') else {
978 return Err(VmError::Thrown(VmValue::String(Rc::from(format!(
979 "host_call: unsupported operation name '{name}'"
980 )))));
981 };
982 dispatch_host_operation(capability, operation, ¶ms).await
983}
984
985async fn host_tool_list_builtin(_args: Vec<VmValue>) -> Result<VmValue, VmError> {
986 dispatch_host_tool_list().await
987}
988
989async fn host_tool_call_builtin(args: Vec<VmValue>) -> Result<VmValue, VmError> {
990 let name = args.first().map(|a| a.display()).unwrap_or_default();
991 if name.is_empty() {
992 return Err(VmError::Thrown(VmValue::String(Rc::from(
993 "host_tool_call: tool name is required",
994 ))));
995 }
996 let call_args = args.get(1).cloned().unwrap_or(VmValue::Nil);
997 dispatch_host_tool_call(&name, &call_args).await
998}
999
1000#[cfg(test)]
1001mod tests {
1002 use super::{
1003 capability_manifest_with_mocks, clear_host_call_bridge, dispatch_host_tool_call,
1004 dispatch_host_tool_list, dispatch_mock_host_call, push_host_mock, reset_host_state,
1005 set_host_call_bridge, HostCallBridge, HostMock,
1006 };
1007 use std::collections::BTreeMap;
1008 use std::rc::Rc;
1009
1010 use crate::value::{VmError, VmValue};
1011
1012 #[test]
1013 fn manifest_includes_operation_metadata() {
1014 let manifest = capability_manifest_with_mocks();
1015 let process = manifest
1016 .as_dict()
1017 .and_then(|d| d.get("process"))
1018 .and_then(|v| v.as_dict())
1019 .expect("process capability");
1020 assert!(process.get("description").is_some());
1021 let operations = process
1022 .get("operations")
1023 .and_then(|v| v.as_dict())
1024 .expect("operations dict");
1025 assert!(operations.get("exec").is_some());
1026 }
1027
1028 #[test]
1029 fn mocked_capabilities_appear_in_manifest() {
1030 reset_host_state();
1031 push_host_mock(HostMock {
1032 capability: "project".to_string(),
1033 operation: "metadata_get".to_string(),
1034 params: None,
1035 result: Some(VmValue::Dict(Rc::new(BTreeMap::new()))),
1036 error: None,
1037 });
1038 let manifest = capability_manifest_with_mocks();
1039 let project = manifest
1040 .as_dict()
1041 .and_then(|d| d.get("project"))
1042 .and_then(|v| v.as_dict())
1043 .expect("project capability");
1044 let operations = project
1045 .get("operations")
1046 .and_then(|v| v.as_dict())
1047 .expect("operations dict");
1048 assert!(operations.get("metadata_get").is_some());
1049 reset_host_state();
1050 }
1051
1052 #[test]
1053 fn mock_host_call_matches_partial_params_and_overrides_order() {
1054 reset_host_state();
1055 let mut exact_params = BTreeMap::new();
1056 exact_params.insert("namespace".to_string(), VmValue::String(Rc::from("facts")));
1057 push_host_mock(HostMock {
1058 capability: "project".to_string(),
1059 operation: "metadata_get".to_string(),
1060 params: None,
1061 result: Some(VmValue::String(Rc::from("fallback"))),
1062 error: None,
1063 });
1064 push_host_mock(HostMock {
1065 capability: "project".to_string(),
1066 operation: "metadata_get".to_string(),
1067 params: Some(exact_params),
1068 result: Some(VmValue::String(Rc::from("facts"))),
1069 error: None,
1070 });
1071
1072 let mut call_params = BTreeMap::new();
1073 call_params.insert("dir".to_string(), VmValue::String(Rc::from("pkg")));
1074 call_params.insert("namespace".to_string(), VmValue::String(Rc::from("facts")));
1075 let exact = dispatch_mock_host_call("project", "metadata_get", &call_params)
1076 .expect("expected exact mock")
1077 .expect("exact mock should succeed");
1078 assert_eq!(exact.display(), "facts");
1079
1080 call_params.insert(
1081 "namespace".to_string(),
1082 VmValue::String(Rc::from("classification")),
1083 );
1084 let fallback = dispatch_mock_host_call("project", "metadata_get", &call_params)
1085 .expect("expected fallback mock")
1086 .expect("fallback mock should succeed");
1087 assert_eq!(fallback.display(), "fallback");
1088 reset_host_state();
1089 }
1090
1091 #[test]
1092 fn mock_host_call_can_throw_errors() {
1093 reset_host_state();
1094 push_host_mock(HostMock {
1095 capability: "project".to_string(),
1096 operation: "metadata_get".to_string(),
1097 params: None,
1098 result: None,
1099 error: Some("boom".to_string()),
1100 });
1101 let params = BTreeMap::new();
1102 let result = dispatch_mock_host_call("project", "metadata_get", ¶ms)
1103 .expect("expected mock result");
1104 match result {
1105 Err(VmError::Thrown(VmValue::String(message))) => assert_eq!(message.as_ref(), "boom"),
1106 other => panic!("unexpected result: {other:?}"),
1107 }
1108 reset_host_state();
1109 }
1110
1111 #[derive(Default)]
1112 struct TestHostToolBridge;
1113
1114 impl HostCallBridge for TestHostToolBridge {
1115 fn dispatch(
1116 &self,
1117 _capability: &str,
1118 _operation: &str,
1119 _params: &BTreeMap<String, VmValue>,
1120 ) -> Result<Option<VmValue>, VmError> {
1121 Ok(None)
1122 }
1123
1124 fn list_tools(&self) -> Result<Option<VmValue>, VmError> {
1125 let tool = VmValue::Dict(Rc::new(BTreeMap::from([
1126 (
1127 "name".to_string(),
1128 VmValue::String(Rc::from("Read".to_string())),
1129 ),
1130 (
1131 "description".to_string(),
1132 VmValue::String(Rc::from("Read a file from the host".to_string())),
1133 ),
1134 (
1135 "schema".to_string(),
1136 VmValue::Dict(Rc::new(BTreeMap::from([(
1137 "type".to_string(),
1138 VmValue::String(Rc::from("object".to_string())),
1139 )]))),
1140 ),
1141 ("deprecated".to_string(), VmValue::Bool(false)),
1142 ])));
1143 Ok(Some(VmValue::List(Rc::new(vec![tool]))))
1144 }
1145
1146 fn call_tool(&self, name: &str, args: &VmValue) -> Result<Option<VmValue>, VmError> {
1147 if name != "Read" {
1148 return Ok(None);
1149 }
1150 let path = args
1151 .as_dict()
1152 .and_then(|dict| dict.get("path"))
1153 .map(|value| value.display())
1154 .unwrap_or_default();
1155 Ok(Some(VmValue::String(Rc::from(format!("read:{path}")))))
1156 }
1157 }
1158
1159 fn run_host_async_test<F, Fut>(test: F)
1160 where
1161 F: FnOnce() -> Fut,
1162 Fut: std::future::Future<Output = ()>,
1163 {
1164 let rt = tokio::runtime::Builder::new_current_thread()
1165 .enable_all()
1166 .build()
1167 .expect("runtime");
1168 rt.block_on(async {
1169 let local = tokio::task::LocalSet::new();
1170 local.run_until(test()).await;
1171 });
1172 }
1173
1174 #[test]
1175 fn host_tool_list_uses_installed_host_call_bridge() {
1176 run_host_async_test(|| async {
1177 reset_host_state();
1178 set_host_call_bridge(Rc::new(TestHostToolBridge));
1179 let tools = dispatch_host_tool_list().await.expect("tool list");
1180 clear_host_call_bridge();
1181
1182 let VmValue::List(items) = tools else {
1183 panic!("expected tool list");
1184 };
1185 assert_eq!(items.len(), 1);
1186 let tool = items[0].as_dict().expect("tool dict");
1187 assert_eq!(tool.get("name").unwrap().display(), "Read");
1188 assert_eq!(tool.get("deprecated").unwrap().display(), "false");
1189 });
1190 }
1191
1192 #[test]
1193 fn host_tool_call_uses_installed_host_call_bridge() {
1194 run_host_async_test(|| async {
1195 set_host_call_bridge(Rc::new(TestHostToolBridge));
1196 let args = VmValue::Dict(Rc::new(BTreeMap::from([(
1197 "path".to_string(),
1198 VmValue::String(Rc::from("README.md".to_string())),
1199 )])));
1200 let value = dispatch_host_tool_call("Read", &args)
1201 .await
1202 .expect("tool call");
1203 clear_host_call_bridge();
1204 assert_eq!(value.display(), "read:README.md");
1205 });
1206 }
1207
1208 #[test]
1209 fn host_tool_list_is_empty_without_bridge() {
1210 run_host_async_test(|| async {
1211 clear_host_call_bridge();
1212 let tools = dispatch_host_tool_list().await.expect("tool list");
1213 let VmValue::List(items) = tools else {
1214 panic!("expected tool list");
1215 };
1216 assert!(items.is_empty());
1217 });
1218 }
1219}