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