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