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::value::{values_equal, VmError, VmValue};
9use crate::vm::clone_async_builtin_child_vm;
10use crate::vm::Vm;
11
12#[derive(Clone)]
13struct HostMock {
14 capability: String,
15 operation: String,
16 params: Option<BTreeMap<String, VmValue>>,
17 result: Option<VmValue>,
18 error: Option<String>,
19}
20
21#[derive(Clone)]
22struct HostMockCall {
23 capability: String,
24 operation: String,
25 params: BTreeMap<String, VmValue>,
26}
27
28thread_local! {
29 static HOST_MOCKS: RefCell<Vec<HostMock>> = const { RefCell::new(Vec::new()) };
30 static HOST_MOCK_CALLS: RefCell<Vec<HostMockCall>> = const { RefCell::new(Vec::new()) };
31 static HOST_MOCK_SCOPES: RefCell<Vec<(Vec<HostMock>, Vec<HostMockCall>)>> =
32 const { RefCell::new(Vec::new()) };
33}
34
35pub(crate) fn reset_host_state() {
36 HOST_MOCKS.with(|mocks| mocks.borrow_mut().clear());
37 HOST_MOCK_CALLS.with(|calls| calls.borrow_mut().clear());
38 HOST_MOCK_SCOPES.with(|scopes| scopes.borrow_mut().clear());
39}
40
41fn push_host_mock_scope() {
46 let mocks = HOST_MOCKS.with(|v| std::mem::take(&mut *v.borrow_mut()));
47 let calls = HOST_MOCK_CALLS.with(|v| std::mem::take(&mut *v.borrow_mut()));
48 HOST_MOCK_SCOPES.with(|v| v.borrow_mut().push((mocks, calls)));
49}
50
51fn pop_host_mock_scope() -> bool {
56 let entry = HOST_MOCK_SCOPES.with(|v| v.borrow_mut().pop());
57 match entry {
58 Some((mocks, calls)) => {
59 HOST_MOCKS.with(|v| *v.borrow_mut() = mocks);
60 HOST_MOCK_CALLS.with(|v| *v.borrow_mut() = calls);
61 true
62 }
63 None => false,
64 }
65}
66
67fn capability_manifest_map() -> BTreeMap<String, VmValue> {
68 let mut root = BTreeMap::new();
69 root.insert(
70 "process".to_string(),
71 capability(
72 "Process execution.",
73 &[
74 op("exec", "Execute a process in argv or explicit shell mode."),
75 op("list_shells", "List shells discovered by the host/session."),
76 op(
77 "get_default_shell",
78 "Return the selected default shell for this host/session.",
79 ),
80 op(
81 "set_default_shell",
82 "Select the default shell for this host/session.",
83 ),
84 op(
85 "shell_invocation",
86 "Resolve a shell id/object plus login/interactive flags into argv.",
87 ),
88 ],
89 ),
90 );
91 root.insert(
92 "template".to_string(),
93 capability(
94 "Template rendering.",
95 &[op("render", "Render a template file.")],
96 ),
97 );
98 root.insert(
99 "interaction".to_string(),
100 capability(
101 "User interaction.",
102 &[op("ask", "Ask the user a question.")],
103 ),
104 );
105 root
106}
107
108fn mocked_operation_entry() -> VmValue {
109 op(
110 "mocked",
111 "Mocked host operation registered at runtime for tests.",
112 )
113 .1
114}
115
116fn ensure_mocked_capability(
117 root: &mut BTreeMap<String, VmValue>,
118 capability_name: &str,
119 operation_name: &str,
120) {
121 let Some(existing) = root.get(capability_name).cloned() else {
122 root.insert(
123 capability_name.to_string(),
124 capability(
125 "Mocked host capability registered at runtime for tests.",
126 &[(operation_name.to_string(), mocked_operation_entry())],
127 ),
128 );
129 return;
130 };
131
132 let Some(existing_dict) = existing.as_dict() else {
133 return;
134 };
135 let mut entry = (*existing_dict).clone();
136 let mut ops = entry
137 .get("ops")
138 .and_then(|value| match value {
139 VmValue::List(list) => Some((**list).clone()),
140 _ => None,
141 })
142 .unwrap_or_default();
143 if !ops.iter().any(|value| value.display() == operation_name) {
144 ops.push(VmValue::String(Rc::from(operation_name.to_string())));
145 }
146
147 let mut operations = entry
148 .get("operations")
149 .and_then(|value| value.as_dict())
150 .map(|dict| (*dict).clone())
151 .unwrap_or_default();
152 operations
153 .entry(operation_name.to_string())
154 .or_insert_with(mocked_operation_entry);
155
156 entry.insert("ops".to_string(), VmValue::List(Rc::new(ops)));
157 entry.insert("operations".to_string(), VmValue::Dict(Rc::new(operations)));
158 root.insert(capability_name.to_string(), VmValue::Dict(Rc::new(entry)));
159}
160
161fn capability_manifest_with_mocks() -> VmValue {
162 let mut root = capability_manifest_map();
163 HOST_MOCKS.with(|mocks| {
164 for host_mock in mocks.borrow().iter() {
165 ensure_mocked_capability(&mut root, &host_mock.capability, &host_mock.operation);
166 }
167 });
168 VmValue::Dict(Rc::new(root))
169}
170
171fn op(name: &str, description: &str) -> (String, VmValue) {
172 let mut entry = BTreeMap::new();
173 entry.insert(
174 "description".to_string(),
175 VmValue::String(Rc::from(description)),
176 );
177 (name.to_string(), VmValue::Dict(Rc::new(entry)))
178}
179
180fn capability(description: &str, ops: &[(String, VmValue)]) -> VmValue {
181 let mut entry = BTreeMap::new();
182 entry.insert(
183 "description".to_string(),
184 VmValue::String(Rc::from(description)),
185 );
186 entry.insert(
187 "ops".to_string(),
188 VmValue::List(Rc::new(
189 ops.iter()
190 .map(|(name, _)| VmValue::String(Rc::from(name.as_str())))
191 .collect(),
192 )),
193 );
194 let mut op_dict = BTreeMap::new();
195 for (name, op) in ops {
196 op_dict.insert(name.clone(), op.clone());
197 }
198 entry.insert("operations".to_string(), VmValue::Dict(Rc::new(op_dict)));
199 VmValue::Dict(Rc::new(entry))
200}
201
202fn require_param(params: &BTreeMap<String, VmValue>, key: &str) -> Result<String, VmError> {
203 params
204 .get(key)
205 .map(|v| v.display())
206 .filter(|v| !v.is_empty())
207 .ok_or_else(|| {
208 VmError::Thrown(VmValue::String(Rc::from(format!(
209 "host_call: missing required parameter '{key}'"
210 ))))
211 })
212}
213
214fn render_template(
215 path: &str,
216 bindings: Option<&BTreeMap<String, VmValue>>,
217) -> Result<String, VmError> {
218 let resolved = crate::stdlib::asset_paths::resolve_or_source_relative(path, None)
219 .map_err(|msg| VmError::Thrown(VmValue::String(Rc::from(msg))))?;
220 let template = std::fs::read_to_string(&resolved).map_err(|e| {
221 VmError::Thrown(VmValue::String(Rc::from(format!(
222 "host_call template.render: failed to read template {}: {e}",
223 resolved.display()
224 ))))
225 })?;
226 let base = resolved.parent();
227 crate::stdlib::template::render_template_result(&template, bindings, base, Some(&resolved))
228 .map_err(VmError::from)
229}
230
231fn params_match(
232 expected: Option<&BTreeMap<String, VmValue>>,
233 actual: &BTreeMap<String, VmValue>,
234) -> bool {
235 let Some(expected) = expected else {
236 return true;
237 };
238 expected.iter().all(|(key, value)| {
239 actual
240 .get(key)
241 .is_some_and(|candidate| values_equal(candidate, value))
242 })
243}
244
245fn parse_host_mock(args: &[VmValue]) -> Result<HostMock, VmError> {
246 let capability = args
247 .first()
248 .map(|value| value.display())
249 .unwrap_or_default();
250 let operation = args.get(1).map(|value| value.display()).unwrap_or_default();
251 if capability.is_empty() || operation.is_empty() {
252 return Err(VmError::Thrown(VmValue::String(Rc::from(
253 "host_mock: capability and operation are required",
254 ))));
255 }
256
257 let mut params = args
258 .get(3)
259 .and_then(|value| value.as_dict())
260 .map(|dict| (*dict).clone());
261 let mut result = args.get(2).cloned().or(Some(VmValue::Nil));
262 let mut error = None;
263
264 if let Some(config) = args.get(2).and_then(|value| value.as_dict()) {
265 if config.contains_key("result")
266 || config.contains_key("params")
267 || config.contains_key("error")
268 {
269 params = config
270 .get("params")
271 .and_then(|value| value.as_dict())
272 .map(|dict| (*dict).clone());
273 result = config.get("result").cloned();
274 error = config
275 .get("error")
276 .map(|value| value.display())
277 .filter(|value| !value.is_empty());
278 }
279 }
280
281 Ok(HostMock {
282 capability,
283 operation,
284 params,
285 result,
286 error,
287 })
288}
289
290fn push_host_mock(host_mock: HostMock) {
291 HOST_MOCKS.with(|mocks| mocks.borrow_mut().push(host_mock));
292}
293
294fn mock_call_value(call: &HostMockCall) -> VmValue {
295 let mut item = BTreeMap::new();
296 item.insert(
297 "capability".to_string(),
298 VmValue::String(Rc::from(call.capability.clone())),
299 );
300 item.insert(
301 "operation".to_string(),
302 VmValue::String(Rc::from(call.operation.clone())),
303 );
304 item.insert(
305 "params".to_string(),
306 VmValue::Dict(Rc::new(call.params.clone())),
307 );
308 VmValue::Dict(Rc::new(item))
309}
310
311fn record_mock_call(capability: &str, operation: &str, params: &BTreeMap<String, VmValue>) {
312 HOST_MOCK_CALLS.with(|calls| {
313 calls.borrow_mut().push(HostMockCall {
314 capability: capability.to_string(),
315 operation: operation.to_string(),
316 params: params.clone(),
317 });
318 });
319}
320
321pub(crate) fn dispatch_mock_host_call(
322 capability: &str,
323 operation: &str,
324 params: &BTreeMap<String, VmValue>,
325) -> Option<Result<VmValue, VmError>> {
326 let matched = HOST_MOCKS.with(|mocks| {
327 mocks
328 .borrow()
329 .iter()
330 .rev()
331 .find(|host_mock| {
332 host_mock.capability == capability
333 && host_mock.operation == operation
334 && params_match(host_mock.params.as_ref(), params)
335 })
336 .cloned()
337 })?;
338
339 record_mock_call(capability, operation, params);
340 if let Some(error) = matched.error {
341 return Some(Err(VmError::Thrown(VmValue::String(Rc::from(error)))));
342 }
343 Some(Ok(matched.result.unwrap_or(VmValue::Nil)))
344}
345
346pub trait HostCallBridge {
361 fn dispatch(
362 &self,
363 capability: &str,
364 operation: &str,
365 params: &BTreeMap<String, VmValue>,
366 ) -> Result<Option<VmValue>, VmError>;
367
368 fn list_tools(&self) -> Result<Option<VmValue>, VmError> {
369 Ok(None)
370 }
371
372 fn call_tool(&self, _name: &str, _args: &VmValue) -> Result<Option<VmValue>, VmError> {
373 Ok(None)
374 }
375}
376
377thread_local! {
378 static HOST_CALL_BRIDGE: RefCell<Option<Rc<dyn HostCallBridge>>> = const { RefCell::new(None) };
379}
380
381pub fn set_host_call_bridge(bridge: Rc<dyn HostCallBridge>) {
386 HOST_CALL_BRIDGE.with(|b| *b.borrow_mut() = Some(bridge));
387}
388
389pub fn clear_host_call_bridge() {
391 HOST_CALL_BRIDGE.with(|b| *b.borrow_mut() = None);
392}
393
394fn empty_tool_list_value() -> VmValue {
395 VmValue::List(Rc::new(Vec::new()))
396}
397
398fn current_vm_host_bridge() -> Option<Rc<crate::bridge::HostBridge>> {
399 clone_async_builtin_child_vm().and_then(|vm| vm.bridge.clone())
400}
401
402async fn dispatch_host_tool_list() -> Result<VmValue, VmError> {
403 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
404 if let Some(bridge) = bridge {
405 if let Some(value) = bridge.list_tools()? {
406 return Ok(value);
407 }
408 }
409
410 let Some(bridge) = current_vm_host_bridge() else {
411 return Ok(empty_tool_list_value());
412 };
413 let tools = bridge.list_host_tools().await?;
414 Ok(crate::bridge::json_result_to_vm_value(&JsonValue::Array(
415 tools.into_iter().collect(),
416 )))
417}
418
419pub(crate) async fn dispatch_host_tool_call(
420 name: &str,
421 args: &VmValue,
422) -> Result<VmValue, VmError> {
423 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
424 if let Some(bridge) = bridge {
425 if let Some(value) = bridge.call_tool(name, args)? {
426 return Ok(value);
427 }
428 }
429
430 let Some(bridge) = current_vm_host_bridge() else {
431 return Err(VmError::Thrown(VmValue::String(Rc::from(
432 "host_tool_call: no host bridge is attached",
433 ))));
434 };
435
436 let result = bridge
437 .call(
438 "builtin_call",
439 serde_json::json!({
440 "name": name,
441 "args": [crate::llm::vm_value_to_json(args)],
442 }),
443 )
444 .await?;
445 Ok(crate::bridge::json_result_to_vm_value(&result))
446}
447
448async fn dispatch_host_operation(
449 capability: &str,
450 operation: &str,
451 params: &BTreeMap<String, VmValue>,
452) -> Result<VmValue, VmError> {
453 if let Some(mocked) = dispatch_mock_host_call(capability, operation, params) {
454 return mocked;
455 }
456
457 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
458 if let Some(bridge) = bridge {
459 if let Some(value) = bridge.dispatch(capability, operation, params)? {
460 return Ok(value);
461 }
462 }
463
464 match (capability, operation) {
465 ("process", "exec") => {
466 let caller = serde_json::json!({
467 "surface": "host_call",
468 "capability": "process",
469 "operation": "exec",
470 "session_id": crate::llm::current_agent_session_id(),
471 });
472 let (params, command_policy_context, command_policy_decisions) =
473 match crate::orchestration::run_command_policy_preflight(params, caller).await? {
474 crate::orchestration::CommandPolicyPreflight::Proceed {
475 params,
476 context,
477 decisions,
478 } => (params, context, decisions),
479 crate::orchestration::CommandPolicyPreflight::Blocked {
480 status,
481 message,
482 context,
483 decisions,
484 } => {
485 return Ok(crate::orchestration::blocked_command_response(
486 params, status, &message, context, decisions,
487 ));
488 }
489 };
490 let (program, args) = process_exec_argv(¶ms)?;
491 let timeout_ms = optional_i64(¶ms, "timeout")
492 .or_else(|| optional_i64(¶ms, "timeout_ms"))
493 .filter(|value| *value > 0)
494 .map(|value| value as u64);
495 let mut cmd =
496 crate::process_sandbox::tokio_command_for(&program, &args).map_err(|e| {
497 VmError::Runtime(format!("host_call process.exec sandbox setup: {e}"))
498 })?;
499 if let Some(cwd) = optional_string(¶ms, "cwd") {
500 crate::process_sandbox::enforce_process_cwd(std::path::Path::new(&cwd))
501 .map_err(|e| VmError::Runtime(format!("host_call process.exec cwd: {e}")))?;
502 cmd.current_dir(cwd);
503 }
504 if let Some(env) = optional_string_dict(¶ms, "env")? {
505 let env_mode = optional_string(¶ms, "env_mode");
506 if env_mode.as_deref().unwrap_or("replace") == "replace" {
507 cmd.env_clear();
508 }
509 for (key, value) in env {
510 cmd.env(key, value);
511 }
512 }
513 cmd.stdin(std::process::Stdio::null())
514 .stdout(std::process::Stdio::piped())
515 .stderr(std::process::Stdio::piped())
516 .kill_on_drop(true);
517 let started_at = chrono::Utc::now().to_rfc3339();
518 let started = Instant::now();
519 let child = cmd
520 .spawn()
521 .map_err(|e| VmError::Runtime(format!("host_call process.exec: {e}")))?;
522 let pid = child.id();
523 let timed_out;
524 let output_result = if let Some(timeout_ms) = timeout_ms {
525 match tokio::time::timeout(
526 std::time::Duration::from_millis(timeout_ms),
527 child.wait_with_output(),
528 )
529 .await
530 {
531 Ok(result) => {
532 timed_out = false;
533 result
534 }
535 Err(_) => {
536 let response = process_exec_response(ProcessExecResponse {
537 pid,
538 started_at,
539 started,
540 stdout: "",
541 stderr: "",
542 exit_code: -1,
543 status: "timed_out",
544 success: false,
545 timed_out: true,
546 });
547 return crate::orchestration::run_command_policy_postflight(
548 ¶ms,
549 response,
550 command_policy_context,
551 command_policy_decisions,
552 )
553 .await;
554 }
555 }
556 } else {
557 timed_out = false;
558 child.wait_with_output().await
559 };
560 let output = output_result
561 .map_err(|e| VmError::Runtime(format!("host_call process.exec: {e}")))?;
562 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
563 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
564 let exit_code = output.status.code().unwrap_or(-1);
565 let response = process_exec_response(ProcessExecResponse {
566 pid,
567 started_at,
568 started,
569 stdout: &stdout,
570 stderr: &stderr,
571 exit_code,
572 status: if timed_out { "timed_out" } else { "completed" },
573 success: output.status.success(),
574 timed_out,
575 });
576 crate::orchestration::run_command_policy_postflight(
577 ¶ms,
578 response,
579 command_policy_context,
580 command_policy_decisions,
581 )
582 .await
583 }
584 ("process", "list_shells") => Ok(crate::shells::list_shells_vm_value()),
585 ("process", "get_default_shell") => Ok(crate::shells::default_shell_vm_value()),
586 ("process", "set_default_shell") => crate::shells::set_default_shell_vm_value(params),
587 ("process", "shell_invocation") => crate::shells::shell_invocation_vm_value(params),
588 ("template", "render") => {
589 let path = require_param(params, "path")?;
590 let bindings = params.get("bindings").and_then(|v| v.as_dict());
591 Ok(VmValue::String(Rc::from(render_template(&path, bindings)?)))
592 }
593 ("interaction", "ask") => {
594 let question = require_param(params, "question")?;
595 use std::io::BufRead;
596 print!("{question}");
597 let _ = std::io::Write::flush(&mut std::io::stdout());
598 let mut input = String::new();
599 if std::io::stdin().lock().read_line(&mut input).is_ok() {
600 Ok(VmValue::String(Rc::from(input.trim_end())))
601 } else {
602 Ok(VmValue::Nil)
603 }
604 }
605 ("runtime", "task") => Ok(VmValue::String(Rc::from(
610 std::env::var("HARN_TASK").unwrap_or_default(),
611 ))),
612 ("runtime", "set_result") => {
613 Ok(VmValue::Nil)
616 }
617 ("workspace", "project_root") => {
618 let path = std::env::var("HARN_PROJECT_ROOT").unwrap_or_else(|_| {
622 std::env::current_dir()
623 .map(|p| p.display().to_string())
624 .unwrap_or_default()
625 });
626 Ok(VmValue::String(Rc::from(path)))
627 }
628 ("workspace", "cwd") => {
629 let path = std::env::current_dir()
630 .map(|p| p.display().to_string())
631 .unwrap_or_default();
632 Ok(VmValue::String(Rc::from(path)))
633 }
634 _ => Err(VmError::Thrown(VmValue::String(Rc::from(format!(
635 "host_call: unsupported operation {capability}.{operation}"
636 ))))),
637 }
638}
639
640struct ProcessExecResponse<'a> {
641 pid: Option<u32>,
642 started_at: String,
643 started: Instant,
644 stdout: &'a str,
645 stderr: &'a str,
646 exit_code: i32,
647 status: &'a str,
648 success: bool,
649 timed_out: bool,
650}
651
652fn process_exec_response(response: ProcessExecResponse<'_>) -> VmValue {
653 let combined = format!("{}{}", response.stdout, response.stderr);
654 let mut result = BTreeMap::new();
655 result.insert(
656 "command_id".to_string(),
657 VmValue::String(Rc::from(format!(
658 "cmd_{}_{}",
659 std::process::id(),
660 response.started.elapsed().as_nanos()
661 ))),
662 );
663 result.insert(
664 "status".to_string(),
665 VmValue::String(Rc::from(response.status)),
666 );
667 result.insert(
668 "pid".to_string(),
669 response
670 .pid
671 .map(|pid| VmValue::Int(pid as i64))
672 .unwrap_or(VmValue::Nil),
673 );
674 result.insert(
675 "process_group_id".to_string(),
676 response
677 .pid
678 .map(|pid| VmValue::Int(pid as i64))
679 .unwrap_or(VmValue::Nil),
680 );
681 result.insert("handle_id".to_string(), VmValue::Nil);
682 result.insert(
683 "started_at".to_string(),
684 VmValue::String(Rc::from(response.started_at)),
685 );
686 result.insert(
687 "ended_at".to_string(),
688 VmValue::String(Rc::from(chrono::Utc::now().to_rfc3339())),
689 );
690 result.insert(
691 "duration_ms".to_string(),
692 VmValue::Int(response.started.elapsed().as_millis() as i64),
693 );
694 result.insert(
695 "exit_code".to_string(),
696 VmValue::Int(response.exit_code as i64),
697 );
698 result.insert("signal".to_string(), VmValue::Nil);
699 result.insert("timed_out".to_string(), VmValue::Bool(response.timed_out));
700 result.insert(
701 "stdout".to_string(),
702 VmValue::String(Rc::from(response.stdout.to_string())),
703 );
704 result.insert(
705 "stderr".to_string(),
706 VmValue::String(Rc::from(response.stderr.to_string())),
707 );
708 result.insert("combined".to_string(), VmValue::String(Rc::from(combined)));
709 result.insert(
710 "exit_status".to_string(),
711 VmValue::Int(response.exit_code as i64),
712 );
713 result.insert(
714 "legacy_status".to_string(),
715 VmValue::Int(response.exit_code as i64),
716 );
717 result.insert("success".to_string(), VmValue::Bool(response.success));
718 VmValue::Dict(Rc::new(result))
719}
720
721fn process_exec_argv(params: &BTreeMap<String, VmValue>) -> Result<(String, Vec<String>), VmError> {
722 match optional_string(params, "mode")
723 .as_deref()
724 .unwrap_or("shell")
725 {
726 "argv" => {
727 let argv = optional_string_list(params, "argv").ok_or_else(|| {
728 VmError::Runtime("host_call process.exec missing argv".to_string())
729 })?;
730 split_argv(argv)
731 }
732 "shell" => {
733 let command = require_param(params, "command")?;
734 let mut invocation_params = params.clone();
735 invocation_params.insert("command".to_string(), VmValue::String(Rc::from(command)));
736 let invocation =
737 crate::shells::resolve_invocation_from_vm_params(&invocation_params)
738 .map_err(|err| VmError::Runtime(format!("host_call process.exec: {err}")))?;
739 Ok((invocation.program, invocation.args))
740 }
741 other => Err(VmError::Runtime(format!(
742 "host_call process.exec unsupported mode {other:?}"
743 ))),
744 }
745}
746
747fn split_argv(mut argv: Vec<String>) -> Result<(String, Vec<String>), VmError> {
748 if argv.is_empty() {
749 return Err(VmError::Runtime(
750 "host_call process.exec argv must not be empty".to_string(),
751 ));
752 }
753 let program = argv.remove(0);
754 if program.is_empty() {
755 return Err(VmError::Runtime(
756 "host_call process.exec argv[0] must not be empty".to_string(),
757 ));
758 }
759 Ok((program, argv))
760}
761
762fn optional_i64(params: &BTreeMap<String, VmValue>, key: &str) -> Option<i64> {
763 match params.get(key) {
764 Some(VmValue::Int(value)) => Some(*value),
765 Some(VmValue::Float(value)) if value.fract() == 0.0 => Some(*value as i64),
766 _ => None,
767 }
768}
769
770fn optional_string(params: &BTreeMap<String, VmValue>, key: &str) -> Option<String> {
771 params.get(key).and_then(vm_string).map(ToString::to_string)
772}
773
774fn optional_string_list(params: &BTreeMap<String, VmValue>, key: &str) -> Option<Vec<String>> {
775 let VmValue::List(values) = params.get(key)? else {
776 return None;
777 };
778 values
779 .iter()
780 .map(|value| vm_string(value).map(ToString::to_string))
781 .collect()
782}
783
784fn optional_string_dict(
785 params: &BTreeMap<String, VmValue>,
786 key: &str,
787) -> Result<Option<BTreeMap<String, String>>, VmError> {
788 let Some(value) = params.get(key) else {
789 return Ok(None);
790 };
791 let Some(dict) = value.as_dict() else {
792 return Err(VmError::Runtime(format!(
793 "host_call process.exec {key} must be a dict"
794 )));
795 };
796 let mut out = BTreeMap::new();
797 for (key, value) in dict.iter() {
798 let Some(value) = vm_string(value) else {
799 return Err(VmError::Runtime(format!(
800 "host_call process.exec env value for {key:?} must be a string"
801 )));
802 };
803 out.insert(key.clone(), value.to_string());
804 }
805 Ok(Some(out))
806}
807
808fn vm_string(value: &VmValue) -> Option<&str> {
809 match value {
810 VmValue::String(value) => Some(value.as_ref()),
811 _ => None,
812 }
813}
814
815pub(crate) fn register_host_builtins(vm: &mut Vm) {
816 vm.register_builtin("host_mock", |args, _out| {
817 let host_mock = parse_host_mock(args)?;
818 push_host_mock(host_mock);
819 Ok(VmValue::Nil)
820 });
821
822 vm.register_builtin("host_mock_clear", |_args, _out| {
823 reset_host_state();
824 Ok(VmValue::Nil)
825 });
826
827 vm.register_builtin("host_mock_calls", |_args, _out| {
828 let calls = HOST_MOCK_CALLS.with(|calls| {
829 calls
830 .borrow()
831 .iter()
832 .map(mock_call_value)
833 .collect::<Vec<_>>()
834 });
835 Ok(VmValue::List(Rc::new(calls)))
836 });
837
838 vm.register_builtin("host_mock_push_scope", |_args, _out| {
839 push_host_mock_scope();
840 Ok(VmValue::Nil)
841 });
842
843 vm.register_builtin("host_mock_pop_scope", |_args, _out| {
844 if !pop_host_mock_scope() {
845 return Err(VmError::Thrown(VmValue::String(Rc::from(
846 "host_mock_pop_scope: no scope to pop",
847 ))));
848 }
849 Ok(VmValue::Nil)
850 });
851
852 vm.register_builtin("host_capabilities", |_args, _out| {
853 Ok(capability_manifest_with_mocks())
854 });
855
856 vm.register_builtin("host_has", |args, _out| {
857 let capability = args.first().map(|a| a.display()).unwrap_or_default();
858 let operation = args.get(1).map(|a| a.display());
859 let manifest = capability_manifest_with_mocks();
860 let has = manifest
861 .as_dict()
862 .and_then(|d| d.get(&capability))
863 .and_then(|v| v.as_dict())
864 .is_some_and(|cap| {
865 if let Some(operation) = operation {
866 cap.get("ops")
867 .and_then(|v| match v {
868 VmValue::List(list) => {
869 Some(list.iter().any(|item| item.display() == operation))
870 }
871 _ => None,
872 })
873 .unwrap_or(false)
874 } else {
875 true
876 }
877 });
878 Ok(VmValue::Bool(has))
879 });
880
881 vm.register_async_builtin("host_call", |args| async move {
882 let name = args.first().map(|a| a.display()).unwrap_or_default();
883 let params = args
884 .get(1)
885 .and_then(|a| a.as_dict())
886 .cloned()
887 .unwrap_or_default();
888 let Some((capability, operation)) = name.split_once('.') else {
889 return Err(VmError::Thrown(VmValue::String(Rc::from(format!(
890 "host_call: unsupported operation name '{name}'"
891 )))));
892 };
893 dispatch_host_operation(capability, operation, ¶ms).await
894 });
895
896 vm.register_async_builtin("host_tool_list", |_args| async move {
897 dispatch_host_tool_list().await
898 });
899
900 vm.register_async_builtin("host_tool_call", |args| async move {
901 let name = args.first().map(|a| a.display()).unwrap_or_default();
902 if name.is_empty() {
903 return Err(VmError::Thrown(VmValue::String(Rc::from(
904 "host_tool_call: tool name is required",
905 ))));
906 }
907 let call_args = args.get(1).cloned().unwrap_or(VmValue::Nil);
908 dispatch_host_tool_call(&name, &call_args).await
909 });
910}
911
912#[cfg(test)]
913mod tests {
914 use super::{
915 capability_manifest_with_mocks, clear_host_call_bridge, dispatch_host_tool_call,
916 dispatch_host_tool_list, dispatch_mock_host_call, push_host_mock, reset_host_state,
917 set_host_call_bridge, HostCallBridge, HostMock,
918 };
919 use std::collections::BTreeMap;
920 use std::rc::Rc;
921
922 use crate::value::{VmError, VmValue};
923
924 #[test]
925 fn manifest_includes_operation_metadata() {
926 let manifest = capability_manifest_with_mocks();
927 let process = manifest
928 .as_dict()
929 .and_then(|d| d.get("process"))
930 .and_then(|v| v.as_dict())
931 .expect("process capability");
932 assert!(process.get("description").is_some());
933 let operations = process
934 .get("operations")
935 .and_then(|v| v.as_dict())
936 .expect("operations dict");
937 assert!(operations.get("exec").is_some());
938 }
939
940 #[test]
941 fn mocked_capabilities_appear_in_manifest() {
942 reset_host_state();
943 push_host_mock(HostMock {
944 capability: "project".to_string(),
945 operation: "metadata_get".to_string(),
946 params: None,
947 result: Some(VmValue::Dict(Rc::new(BTreeMap::new()))),
948 error: None,
949 });
950 let manifest = capability_manifest_with_mocks();
951 let project = manifest
952 .as_dict()
953 .and_then(|d| d.get("project"))
954 .and_then(|v| v.as_dict())
955 .expect("project capability");
956 let operations = project
957 .get("operations")
958 .and_then(|v| v.as_dict())
959 .expect("operations dict");
960 assert!(operations.get("metadata_get").is_some());
961 reset_host_state();
962 }
963
964 #[test]
965 fn mock_host_call_matches_partial_params_and_overrides_order() {
966 reset_host_state();
967 let mut exact_params = BTreeMap::new();
968 exact_params.insert("namespace".to_string(), VmValue::String(Rc::from("facts")));
969 push_host_mock(HostMock {
970 capability: "project".to_string(),
971 operation: "metadata_get".to_string(),
972 params: None,
973 result: Some(VmValue::String(Rc::from("fallback"))),
974 error: None,
975 });
976 push_host_mock(HostMock {
977 capability: "project".to_string(),
978 operation: "metadata_get".to_string(),
979 params: Some(exact_params),
980 result: Some(VmValue::String(Rc::from("facts"))),
981 error: None,
982 });
983
984 let mut call_params = BTreeMap::new();
985 call_params.insert("dir".to_string(), VmValue::String(Rc::from("pkg")));
986 call_params.insert("namespace".to_string(), VmValue::String(Rc::from("facts")));
987 let exact = dispatch_mock_host_call("project", "metadata_get", &call_params)
988 .expect("expected exact mock")
989 .expect("exact mock should succeed");
990 assert_eq!(exact.display(), "facts");
991
992 call_params.insert(
993 "namespace".to_string(),
994 VmValue::String(Rc::from("classification")),
995 );
996 let fallback = dispatch_mock_host_call("project", "metadata_get", &call_params)
997 .expect("expected fallback mock")
998 .expect("fallback mock should succeed");
999 assert_eq!(fallback.display(), "fallback");
1000 reset_host_state();
1001 }
1002
1003 #[test]
1004 fn mock_host_call_can_throw_errors() {
1005 reset_host_state();
1006 push_host_mock(HostMock {
1007 capability: "project".to_string(),
1008 operation: "metadata_get".to_string(),
1009 params: None,
1010 result: None,
1011 error: Some("boom".to_string()),
1012 });
1013 let params = BTreeMap::new();
1014 let result = dispatch_mock_host_call("project", "metadata_get", ¶ms)
1015 .expect("expected mock result");
1016 match result {
1017 Err(VmError::Thrown(VmValue::String(message))) => assert_eq!(message.as_ref(), "boom"),
1018 other => panic!("unexpected result: {other:?}"),
1019 }
1020 reset_host_state();
1021 }
1022
1023 #[derive(Default)]
1024 struct TestHostToolBridge;
1025
1026 impl HostCallBridge for TestHostToolBridge {
1027 fn dispatch(
1028 &self,
1029 _capability: &str,
1030 _operation: &str,
1031 _params: &BTreeMap<String, VmValue>,
1032 ) -> Result<Option<VmValue>, VmError> {
1033 Ok(None)
1034 }
1035
1036 fn list_tools(&self) -> Result<Option<VmValue>, VmError> {
1037 let tool = VmValue::Dict(Rc::new(BTreeMap::from([
1038 (
1039 "name".to_string(),
1040 VmValue::String(Rc::from("Read".to_string())),
1041 ),
1042 (
1043 "description".to_string(),
1044 VmValue::String(Rc::from("Read a file from the host".to_string())),
1045 ),
1046 (
1047 "schema".to_string(),
1048 VmValue::Dict(Rc::new(BTreeMap::from([(
1049 "type".to_string(),
1050 VmValue::String(Rc::from("object".to_string())),
1051 )]))),
1052 ),
1053 ("deprecated".to_string(), VmValue::Bool(false)),
1054 ])));
1055 Ok(Some(VmValue::List(Rc::new(vec![tool]))))
1056 }
1057
1058 fn call_tool(&self, name: &str, args: &VmValue) -> Result<Option<VmValue>, VmError> {
1059 if name != "Read" {
1060 return Ok(None);
1061 }
1062 let path = args
1063 .as_dict()
1064 .and_then(|dict| dict.get("path"))
1065 .map(|value| value.display())
1066 .unwrap_or_default();
1067 Ok(Some(VmValue::String(Rc::from(format!("read:{path}")))))
1068 }
1069 }
1070
1071 fn run_host_async_test<F, Fut>(test: F)
1072 where
1073 F: FnOnce() -> Fut,
1074 Fut: std::future::Future<Output = ()>,
1075 {
1076 let rt = tokio::runtime::Builder::new_current_thread()
1077 .enable_all()
1078 .build()
1079 .expect("runtime");
1080 rt.block_on(async {
1081 let local = tokio::task::LocalSet::new();
1082 local.run_until(test()).await;
1083 });
1084 }
1085
1086 #[test]
1087 fn host_tool_list_uses_installed_host_call_bridge() {
1088 run_host_async_test(|| async {
1089 reset_host_state();
1090 set_host_call_bridge(Rc::new(TestHostToolBridge));
1091 let tools = dispatch_host_tool_list().await.expect("tool list");
1092 clear_host_call_bridge();
1093
1094 let VmValue::List(items) = tools else {
1095 panic!("expected tool list");
1096 };
1097 assert_eq!(items.len(), 1);
1098 let tool = items[0].as_dict().expect("tool dict");
1099 assert_eq!(tool.get("name").unwrap().display(), "Read");
1100 assert_eq!(tool.get("deprecated").unwrap().display(), "false");
1101 });
1102 }
1103
1104 #[test]
1105 fn host_tool_call_uses_installed_host_call_bridge() {
1106 run_host_async_test(|| async {
1107 set_host_call_bridge(Rc::new(TestHostToolBridge));
1108 let args = VmValue::Dict(Rc::new(BTreeMap::from([(
1109 "path".to_string(),
1110 VmValue::String(Rc::from("README.md".to_string())),
1111 )])));
1112 let value = dispatch_host_tool_call("Read", &args)
1113 .await
1114 .expect("tool call");
1115 clear_host_call_bridge();
1116 assert_eq!(value.display(), "read:README.md");
1117 });
1118 }
1119
1120 #[test]
1121 fn host_tool_list_is_empty_without_bridge() {
1122 run_host_async_test(|| async {
1123 clear_host_call_bridge();
1124 let tools = dispatch_host_tool_list().await.expect("tool list");
1125 let VmValue::List(items) = tools else {
1126 panic!("expected tool list");
1127 };
1128 assert!(items.is_empty());
1129 });
1130 }
1131}