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
419async fn dispatch_host_tool_call(name: &str, args: &VmValue) -> Result<VmValue, VmError> {
420 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
421 if let Some(bridge) = bridge {
422 if let Some(value) = bridge.call_tool(name, args)? {
423 return Ok(value);
424 }
425 }
426
427 let Some(bridge) = current_vm_host_bridge() else {
428 return Err(VmError::Thrown(VmValue::String(Rc::from(
429 "host_tool_call: no host bridge is attached",
430 ))));
431 };
432
433 let result = bridge
434 .call(
435 "builtin_call",
436 serde_json::json!({
437 "name": name,
438 "args": [crate::llm::vm_value_to_json(args)],
439 }),
440 )
441 .await?;
442 Ok(crate::bridge::json_result_to_vm_value(&result))
443}
444
445async fn dispatch_host_operation(
446 capability: &str,
447 operation: &str,
448 params: &BTreeMap<String, VmValue>,
449) -> Result<VmValue, VmError> {
450 if let Some(mocked) = dispatch_mock_host_call(capability, operation, params) {
451 return mocked;
452 }
453
454 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
455 if let Some(bridge) = bridge {
456 if let Some(value) = bridge.dispatch(capability, operation, params)? {
457 return Ok(value);
458 }
459 }
460
461 match (capability, operation) {
462 ("process", "exec") => {
463 let caller = serde_json::json!({
464 "surface": "host_call",
465 "capability": "process",
466 "operation": "exec",
467 "session_id": crate::llm::current_agent_session_id(),
468 });
469 let (params, command_policy_context, command_policy_decisions) =
470 match crate::orchestration::run_command_policy_preflight(params, caller).await? {
471 crate::orchestration::CommandPolicyPreflight::Proceed {
472 params,
473 context,
474 decisions,
475 } => (params, context, decisions),
476 crate::orchestration::CommandPolicyPreflight::Blocked {
477 status,
478 message,
479 context,
480 decisions,
481 } => {
482 return Ok(crate::orchestration::blocked_command_response(
483 params, status, &message, context, decisions,
484 ));
485 }
486 };
487 let (program, args) = process_exec_argv(¶ms)?;
488 let timeout_ms = optional_i64(¶ms, "timeout")
489 .or_else(|| optional_i64(¶ms, "timeout_ms"))
490 .filter(|value| *value > 0)
491 .map(|value| value as u64);
492 let mut cmd =
493 crate::process_sandbox::tokio_command_for(&program, &args).map_err(|e| {
494 VmError::Runtime(format!("host_call process.exec sandbox setup: {e}"))
495 })?;
496 if let Some(cwd) = optional_string(¶ms, "cwd") {
497 crate::process_sandbox::enforce_process_cwd(std::path::Path::new(&cwd))
498 .map_err(|e| VmError::Runtime(format!("host_call process.exec cwd: {e}")))?;
499 cmd.current_dir(cwd);
500 }
501 if let Some(env) = optional_string_dict(¶ms, "env")? {
502 let env_mode = optional_string(¶ms, "env_mode");
503 if env_mode.as_deref().unwrap_or("replace") == "replace" {
504 cmd.env_clear();
505 }
506 for (key, value) in env {
507 cmd.env(key, value);
508 }
509 }
510 cmd.stdin(std::process::Stdio::null())
511 .stdout(std::process::Stdio::piped())
512 .stderr(std::process::Stdio::piped())
513 .kill_on_drop(true);
514 let started_at = chrono::Utc::now().to_rfc3339();
515 let started = Instant::now();
516 let child = cmd
517 .spawn()
518 .map_err(|e| VmError::Runtime(format!("host_call process.exec: {e}")))?;
519 let pid = child.id();
520 let timed_out;
521 let output_result = if let Some(timeout_ms) = timeout_ms {
522 match tokio::time::timeout(
523 std::time::Duration::from_millis(timeout_ms),
524 child.wait_with_output(),
525 )
526 .await
527 {
528 Ok(result) => {
529 timed_out = false;
530 result
531 }
532 Err(_) => {
533 let response = process_exec_response(ProcessExecResponse {
534 pid,
535 started_at,
536 started,
537 stdout: "",
538 stderr: "",
539 exit_code: -1,
540 status: "timed_out",
541 success: false,
542 timed_out: true,
543 });
544 return crate::orchestration::run_command_policy_postflight(
545 ¶ms,
546 response,
547 command_policy_context,
548 command_policy_decisions,
549 )
550 .await;
551 }
552 }
553 } else {
554 timed_out = false;
555 child.wait_with_output().await
556 };
557 let output = output_result
558 .map_err(|e| VmError::Runtime(format!("host_call process.exec: {e}")))?;
559 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
560 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
561 let exit_code = output.status.code().unwrap_or(-1);
562 let response = process_exec_response(ProcessExecResponse {
563 pid,
564 started_at,
565 started,
566 stdout: &stdout,
567 stderr: &stderr,
568 exit_code,
569 status: if timed_out { "timed_out" } else { "completed" },
570 success: output.status.success(),
571 timed_out,
572 });
573 crate::orchestration::run_command_policy_postflight(
574 ¶ms,
575 response,
576 command_policy_context,
577 command_policy_decisions,
578 )
579 .await
580 }
581 ("process", "list_shells") => Ok(crate::shells::list_shells_vm_value()),
582 ("process", "get_default_shell") => Ok(crate::shells::default_shell_vm_value()),
583 ("process", "set_default_shell") => crate::shells::set_default_shell_vm_value(params),
584 ("process", "shell_invocation") => crate::shells::shell_invocation_vm_value(params),
585 ("template", "render") => {
586 let path = require_param(params, "path")?;
587 let bindings = params.get("bindings").and_then(|v| v.as_dict());
588 Ok(VmValue::String(Rc::from(render_template(&path, bindings)?)))
589 }
590 ("interaction", "ask") => {
591 let question = require_param(params, "question")?;
592 use std::io::BufRead;
593 print!("{question}");
594 let _ = std::io::Write::flush(&mut std::io::stdout());
595 let mut input = String::new();
596 if std::io::stdin().lock().read_line(&mut input).is_ok() {
597 Ok(VmValue::String(Rc::from(input.trim_end())))
598 } else {
599 Ok(VmValue::Nil)
600 }
601 }
602 ("runtime", "task") => Ok(VmValue::String(Rc::from(
607 std::env::var("HARN_TASK").unwrap_or_default(),
608 ))),
609 ("runtime", "set_result") => {
610 Ok(VmValue::Nil)
613 }
614 ("workspace", "project_root") => {
615 let path = std::env::var("HARN_PROJECT_ROOT").unwrap_or_else(|_| {
619 std::env::current_dir()
620 .map(|p| p.display().to_string())
621 .unwrap_or_default()
622 });
623 Ok(VmValue::String(Rc::from(path)))
624 }
625 ("workspace", "cwd") => {
626 let path = std::env::current_dir()
627 .map(|p| p.display().to_string())
628 .unwrap_or_default();
629 Ok(VmValue::String(Rc::from(path)))
630 }
631 _ => Err(VmError::Thrown(VmValue::String(Rc::from(format!(
632 "host_call: unsupported operation {capability}.{operation}"
633 ))))),
634 }
635}
636
637struct ProcessExecResponse<'a> {
638 pid: Option<u32>,
639 started_at: String,
640 started: Instant,
641 stdout: &'a str,
642 stderr: &'a str,
643 exit_code: i32,
644 status: &'a str,
645 success: bool,
646 timed_out: bool,
647}
648
649fn process_exec_response(response: ProcessExecResponse<'_>) -> VmValue {
650 let combined = format!("{}{}", response.stdout, response.stderr);
651 let mut result = BTreeMap::new();
652 result.insert(
653 "command_id".to_string(),
654 VmValue::String(Rc::from(format!(
655 "cmd_{}_{}",
656 std::process::id(),
657 response.started.elapsed().as_nanos()
658 ))),
659 );
660 result.insert(
661 "status".to_string(),
662 VmValue::String(Rc::from(response.status)),
663 );
664 result.insert(
665 "pid".to_string(),
666 response
667 .pid
668 .map(|pid| VmValue::Int(pid as i64))
669 .unwrap_or(VmValue::Nil),
670 );
671 result.insert(
672 "process_group_id".to_string(),
673 response
674 .pid
675 .map(|pid| VmValue::Int(pid as i64))
676 .unwrap_or(VmValue::Nil),
677 );
678 result.insert("handle_id".to_string(), VmValue::Nil);
679 result.insert(
680 "started_at".to_string(),
681 VmValue::String(Rc::from(response.started_at)),
682 );
683 result.insert(
684 "ended_at".to_string(),
685 VmValue::String(Rc::from(chrono::Utc::now().to_rfc3339())),
686 );
687 result.insert(
688 "duration_ms".to_string(),
689 VmValue::Int(response.started.elapsed().as_millis() as i64),
690 );
691 result.insert(
692 "exit_code".to_string(),
693 VmValue::Int(response.exit_code as i64),
694 );
695 result.insert("signal".to_string(), VmValue::Nil);
696 result.insert("timed_out".to_string(), VmValue::Bool(response.timed_out));
697 result.insert(
698 "stdout".to_string(),
699 VmValue::String(Rc::from(response.stdout.to_string())),
700 );
701 result.insert(
702 "stderr".to_string(),
703 VmValue::String(Rc::from(response.stderr.to_string())),
704 );
705 result.insert("combined".to_string(), VmValue::String(Rc::from(combined)));
706 result.insert(
707 "exit_status".to_string(),
708 VmValue::Int(response.exit_code as i64),
709 );
710 result.insert(
711 "legacy_status".to_string(),
712 VmValue::Int(response.exit_code as i64),
713 );
714 result.insert("success".to_string(), VmValue::Bool(response.success));
715 VmValue::Dict(Rc::new(result))
716}
717
718fn process_exec_argv(params: &BTreeMap<String, VmValue>) -> Result<(String, Vec<String>), VmError> {
719 match optional_string(params, "mode")
720 .as_deref()
721 .unwrap_or("shell")
722 {
723 "argv" => {
724 let argv = optional_string_list(params, "argv").ok_or_else(|| {
725 VmError::Runtime("host_call process.exec missing argv".to_string())
726 })?;
727 split_argv(argv)
728 }
729 "shell" => {
730 let command = require_param(params, "command")?;
731 let mut invocation_params = params.clone();
732 invocation_params.insert("command".to_string(), VmValue::String(Rc::from(command)));
733 let invocation =
734 crate::shells::resolve_invocation_from_vm_params(&invocation_params)
735 .map_err(|err| VmError::Runtime(format!("host_call process.exec: {err}")))?;
736 Ok((invocation.program, invocation.args))
737 }
738 other => Err(VmError::Runtime(format!(
739 "host_call process.exec unsupported mode {other:?}"
740 ))),
741 }
742}
743
744fn split_argv(mut argv: Vec<String>) -> Result<(String, Vec<String>), VmError> {
745 if argv.is_empty() {
746 return Err(VmError::Runtime(
747 "host_call process.exec argv must not be empty".to_string(),
748 ));
749 }
750 let program = argv.remove(0);
751 if program.is_empty() {
752 return Err(VmError::Runtime(
753 "host_call process.exec argv[0] must not be empty".to_string(),
754 ));
755 }
756 Ok((program, argv))
757}
758
759fn optional_i64(params: &BTreeMap<String, VmValue>, key: &str) -> Option<i64> {
760 match params.get(key) {
761 Some(VmValue::Int(value)) => Some(*value),
762 Some(VmValue::Float(value)) if value.fract() == 0.0 => Some(*value as i64),
763 _ => None,
764 }
765}
766
767fn optional_string(params: &BTreeMap<String, VmValue>, key: &str) -> Option<String> {
768 params.get(key).and_then(vm_string).map(ToString::to_string)
769}
770
771fn optional_string_list(params: &BTreeMap<String, VmValue>, key: &str) -> Option<Vec<String>> {
772 let VmValue::List(values) = params.get(key)? else {
773 return None;
774 };
775 values
776 .iter()
777 .map(|value| vm_string(value).map(ToString::to_string))
778 .collect()
779}
780
781fn optional_string_dict(
782 params: &BTreeMap<String, VmValue>,
783 key: &str,
784) -> Result<Option<BTreeMap<String, String>>, VmError> {
785 let Some(value) = params.get(key) else {
786 return Ok(None);
787 };
788 let Some(dict) = value.as_dict() else {
789 return Err(VmError::Runtime(format!(
790 "host_call process.exec {key} must be a dict"
791 )));
792 };
793 let mut out = BTreeMap::new();
794 for (key, value) in dict.iter() {
795 let Some(value) = vm_string(value) else {
796 return Err(VmError::Runtime(format!(
797 "host_call process.exec env value for {key:?} must be a string"
798 )));
799 };
800 out.insert(key.clone(), value.to_string());
801 }
802 Ok(Some(out))
803}
804
805fn vm_string(value: &VmValue) -> Option<&str> {
806 match value {
807 VmValue::String(value) => Some(value.as_ref()),
808 _ => None,
809 }
810}
811
812pub(crate) fn register_host_builtins(vm: &mut Vm) {
813 vm.register_builtin("host_mock", |args, _out| {
814 let host_mock = parse_host_mock(args)?;
815 push_host_mock(host_mock);
816 Ok(VmValue::Nil)
817 });
818
819 vm.register_builtin("host_mock_clear", |_args, _out| {
820 reset_host_state();
821 Ok(VmValue::Nil)
822 });
823
824 vm.register_builtin("host_mock_calls", |_args, _out| {
825 let calls = HOST_MOCK_CALLS.with(|calls| {
826 calls
827 .borrow()
828 .iter()
829 .map(mock_call_value)
830 .collect::<Vec<_>>()
831 });
832 Ok(VmValue::List(Rc::new(calls)))
833 });
834
835 vm.register_builtin("host_mock_push_scope", |_args, _out| {
836 push_host_mock_scope();
837 Ok(VmValue::Nil)
838 });
839
840 vm.register_builtin("host_mock_pop_scope", |_args, _out| {
841 if !pop_host_mock_scope() {
842 return Err(VmError::Thrown(VmValue::String(Rc::from(
843 "host_mock_pop_scope: no scope to pop",
844 ))));
845 }
846 Ok(VmValue::Nil)
847 });
848
849 vm.register_builtin("host_capabilities", |_args, _out| {
850 Ok(capability_manifest_with_mocks())
851 });
852
853 vm.register_builtin("host_has", |args, _out| {
854 let capability = args.first().map(|a| a.display()).unwrap_or_default();
855 let operation = args.get(1).map(|a| a.display());
856 let manifest = capability_manifest_with_mocks();
857 let has = manifest
858 .as_dict()
859 .and_then(|d| d.get(&capability))
860 .and_then(|v| v.as_dict())
861 .is_some_and(|cap| {
862 if let Some(operation) = operation {
863 cap.get("ops")
864 .and_then(|v| match v {
865 VmValue::List(list) => {
866 Some(list.iter().any(|item| item.display() == operation))
867 }
868 _ => None,
869 })
870 .unwrap_or(false)
871 } else {
872 true
873 }
874 });
875 Ok(VmValue::Bool(has))
876 });
877
878 vm.register_async_builtin("host_call", |args| async move {
879 let name = args.first().map(|a| a.display()).unwrap_or_default();
880 let params = args
881 .get(1)
882 .and_then(|a| a.as_dict())
883 .cloned()
884 .unwrap_or_default();
885 let Some((capability, operation)) = name.split_once('.') else {
886 return Err(VmError::Thrown(VmValue::String(Rc::from(format!(
887 "host_call: unsupported operation name '{name}'"
888 )))));
889 };
890 dispatch_host_operation(capability, operation, ¶ms).await
891 });
892
893 vm.register_async_builtin("host_tool_list", |_args| async move {
894 dispatch_host_tool_list().await
895 });
896
897 vm.register_async_builtin("host_tool_call", |args| async move {
898 let name = args.first().map(|a| a.display()).unwrap_or_default();
899 if name.is_empty() {
900 return Err(VmError::Thrown(VmValue::String(Rc::from(
901 "host_tool_call: tool name is required",
902 ))));
903 }
904 let call_args = args.get(1).cloned().unwrap_or(VmValue::Nil);
905 dispatch_host_tool_call(&name, &call_args).await
906 });
907}
908
909#[cfg(test)]
910mod tests {
911 use super::{
912 capability_manifest_with_mocks, clear_host_call_bridge, dispatch_host_tool_call,
913 dispatch_host_tool_list, dispatch_mock_host_call, push_host_mock, reset_host_state,
914 set_host_call_bridge, HostCallBridge, HostMock,
915 };
916 use std::collections::BTreeMap;
917 use std::rc::Rc;
918
919 use crate::value::{VmError, VmValue};
920
921 #[test]
922 fn manifest_includes_operation_metadata() {
923 let manifest = capability_manifest_with_mocks();
924 let process = manifest
925 .as_dict()
926 .and_then(|d| d.get("process"))
927 .and_then(|v| v.as_dict())
928 .expect("process capability");
929 assert!(process.get("description").is_some());
930 let operations = process
931 .get("operations")
932 .and_then(|v| v.as_dict())
933 .expect("operations dict");
934 assert!(operations.get("exec").is_some());
935 }
936
937 #[test]
938 fn mocked_capabilities_appear_in_manifest() {
939 reset_host_state();
940 push_host_mock(HostMock {
941 capability: "project".to_string(),
942 operation: "metadata_get".to_string(),
943 params: None,
944 result: Some(VmValue::Dict(Rc::new(BTreeMap::new()))),
945 error: None,
946 });
947 let manifest = capability_manifest_with_mocks();
948 let project = manifest
949 .as_dict()
950 .and_then(|d| d.get("project"))
951 .and_then(|v| v.as_dict())
952 .expect("project capability");
953 let operations = project
954 .get("operations")
955 .and_then(|v| v.as_dict())
956 .expect("operations dict");
957 assert!(operations.get("metadata_get").is_some());
958 reset_host_state();
959 }
960
961 #[test]
962 fn mock_host_call_matches_partial_params_and_overrides_order() {
963 reset_host_state();
964 let mut exact_params = BTreeMap::new();
965 exact_params.insert("namespace".to_string(), VmValue::String(Rc::from("facts")));
966 push_host_mock(HostMock {
967 capability: "project".to_string(),
968 operation: "metadata_get".to_string(),
969 params: None,
970 result: Some(VmValue::String(Rc::from("fallback"))),
971 error: None,
972 });
973 push_host_mock(HostMock {
974 capability: "project".to_string(),
975 operation: "metadata_get".to_string(),
976 params: Some(exact_params),
977 result: Some(VmValue::String(Rc::from("facts"))),
978 error: None,
979 });
980
981 let mut call_params = BTreeMap::new();
982 call_params.insert("dir".to_string(), VmValue::String(Rc::from("pkg")));
983 call_params.insert("namespace".to_string(), VmValue::String(Rc::from("facts")));
984 let exact = dispatch_mock_host_call("project", "metadata_get", &call_params)
985 .expect("expected exact mock")
986 .expect("exact mock should succeed");
987 assert_eq!(exact.display(), "facts");
988
989 call_params.insert(
990 "namespace".to_string(),
991 VmValue::String(Rc::from("classification")),
992 );
993 let fallback = dispatch_mock_host_call("project", "metadata_get", &call_params)
994 .expect("expected fallback mock")
995 .expect("fallback mock should succeed");
996 assert_eq!(fallback.display(), "fallback");
997 reset_host_state();
998 }
999
1000 #[test]
1001 fn mock_host_call_can_throw_errors() {
1002 reset_host_state();
1003 push_host_mock(HostMock {
1004 capability: "project".to_string(),
1005 operation: "metadata_get".to_string(),
1006 params: None,
1007 result: None,
1008 error: Some("boom".to_string()),
1009 });
1010 let params = BTreeMap::new();
1011 let result = dispatch_mock_host_call("project", "metadata_get", ¶ms)
1012 .expect("expected mock result");
1013 match result {
1014 Err(VmError::Thrown(VmValue::String(message))) => assert_eq!(message.as_ref(), "boom"),
1015 other => panic!("unexpected result: {other:?}"),
1016 }
1017 reset_host_state();
1018 }
1019
1020 #[derive(Default)]
1021 struct TestHostToolBridge;
1022
1023 impl HostCallBridge for TestHostToolBridge {
1024 fn dispatch(
1025 &self,
1026 _capability: &str,
1027 _operation: &str,
1028 _params: &BTreeMap<String, VmValue>,
1029 ) -> Result<Option<VmValue>, VmError> {
1030 Ok(None)
1031 }
1032
1033 fn list_tools(&self) -> Result<Option<VmValue>, VmError> {
1034 let tool = VmValue::Dict(Rc::new(BTreeMap::from([
1035 (
1036 "name".to_string(),
1037 VmValue::String(Rc::from("Read".to_string())),
1038 ),
1039 (
1040 "description".to_string(),
1041 VmValue::String(Rc::from("Read a file from the host".to_string())),
1042 ),
1043 (
1044 "schema".to_string(),
1045 VmValue::Dict(Rc::new(BTreeMap::from([(
1046 "type".to_string(),
1047 VmValue::String(Rc::from("object".to_string())),
1048 )]))),
1049 ),
1050 ("deprecated".to_string(), VmValue::Bool(false)),
1051 ])));
1052 Ok(Some(VmValue::List(Rc::new(vec![tool]))))
1053 }
1054
1055 fn call_tool(&self, name: &str, args: &VmValue) -> Result<Option<VmValue>, VmError> {
1056 if name != "Read" {
1057 return Ok(None);
1058 }
1059 let path = args
1060 .as_dict()
1061 .and_then(|dict| dict.get("path"))
1062 .map(|value| value.display())
1063 .unwrap_or_default();
1064 Ok(Some(VmValue::String(Rc::from(format!("read:{path}")))))
1065 }
1066 }
1067
1068 fn run_host_async_test<F, Fut>(test: F)
1069 where
1070 F: FnOnce() -> Fut,
1071 Fut: std::future::Future<Output = ()>,
1072 {
1073 let rt = tokio::runtime::Builder::new_current_thread()
1074 .enable_all()
1075 .build()
1076 .expect("runtime");
1077 rt.block_on(async {
1078 let local = tokio::task::LocalSet::new();
1079 local.run_until(test()).await;
1080 });
1081 }
1082
1083 #[test]
1084 fn host_tool_list_uses_installed_host_call_bridge() {
1085 run_host_async_test(|| async {
1086 reset_host_state();
1087 set_host_call_bridge(Rc::new(TestHostToolBridge));
1088 let tools = dispatch_host_tool_list().await.expect("tool list");
1089 clear_host_call_bridge();
1090
1091 let VmValue::List(items) = tools else {
1092 panic!("expected tool list");
1093 };
1094 assert_eq!(items.len(), 1);
1095 let tool = items[0].as_dict().expect("tool dict");
1096 assert_eq!(tool.get("name").unwrap().display(), "Read");
1097 assert_eq!(tool.get("deprecated").unwrap().display(), "false");
1098 });
1099 }
1100
1101 #[test]
1102 fn host_tool_call_uses_installed_host_call_bridge() {
1103 run_host_async_test(|| async {
1104 set_host_call_bridge(Rc::new(TestHostToolBridge));
1105 let args = VmValue::Dict(Rc::new(BTreeMap::from([(
1106 "path".to_string(),
1107 VmValue::String(Rc::from("README.md".to_string())),
1108 )])));
1109 let value = dispatch_host_tool_call("Read", &args)
1110 .await
1111 .expect("tool call");
1112 clear_host_call_bridge();
1113 assert_eq!(value.display(), "read:README.md");
1114 });
1115 }
1116
1117 #[test]
1118 fn host_tool_list_is_empty_without_bridge() {
1119 run_host_async_test(|| async {
1120 clear_host_call_bridge();
1121 let tools = dispatch_host_tool_list().await.expect("tool list");
1122 let VmValue::List(items) = tools else {
1123 panic!("expected tool list");
1124 };
1125 assert!(items.is_empty());
1126 });
1127 }
1128}