1use crate::value::VmDictExt;
2use std::cell::RefCell;
3use std::collections::BTreeMap;
4use std::sync::Arc;
5use std::time::Instant;
6
7use serde_json::Value as JsonValue;
8
9use crate::stdlib::macros::{harn_builtin, VmBuiltinDef};
10use crate::value::{values_equal, VmError, VmValue};
11use crate::vm::{AsyncBuiltinCtx, Vm};
12
13pub(crate) fn 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<crate::value::DictMap>,
40 result: Option<VmValue>,
41 error: Option<String>,
42}
43
44#[derive(Clone)]
45struct HostMockCall {
46 capability: String,
47 operation: String,
48 params: crate::value::DictMap,
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() -> crate::value::DictMap {
91 let mut root = crate::value::DictMap::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(
99 "spawn",
100 "Spawn a process non-blocking; returns a handle immediately for poll/wait/kill.",
101 ),
102 op(
103 "poll",
104 "Non-blocking snapshot of a spawned process: status, captured stdout/stderr.",
105 ),
106 op(
107 "wait",
108 "Await a spawned process to completion (optional timeout_ms); returns final result.",
109 ),
110 op(
111 "kill",
112 "Terminate a spawned process by handle and await the status transition.",
113 ),
114 op(
115 "release",
116 "Release a spawned-process handle and free its retained output.",
117 ),
118 op("list_shells", "List shells discovered by the host/session."),
119 op(
120 "get_default_shell",
121 "Return the selected default shell for this host/session.",
122 ),
123 op(
124 "set_default_shell",
125 "Select the default shell for this host/session.",
126 ),
127 op(
128 "shell_invocation",
129 "Resolve shell selection and login/interactive flags into argv.",
130 ),
131 ],
132 ),
133 );
134 root.insert(
135 "template".to_string(),
136 capability(
137 "Template rendering.",
138 &[op("render", "Render a template file.")],
139 ),
140 );
141 root.insert(
142 "interaction".to_string(),
143 capability(
144 "User interaction.",
145 &[op("ask", "Ask the user a question.")],
146 ),
147 );
148 root.insert(
149 "memory".to_string(),
150 capability(
151 "Vector-aware memory: host-provided embeddings.",
152 &[op(
153 "embed",
154 "Embed text for semantic recall. Params: {text, model_hint?}. \
155 Returns {vector: list<float>, model: string, dim: int}.",
156 )],
157 ),
158 );
159 root
160}
161
162fn mocked_operation_entry() -> VmValue {
163 op(
164 "mocked",
165 "Mocked host operation registered at runtime for tests.",
166 )
167 .1
168}
169
170fn ensure_mocked_capability(
171 root: &mut crate::value::DictMap,
172 capability_name: &str,
173 operation_name: &str,
174) {
175 let Some(existing) = root.get(capability_name).cloned() else {
176 root.insert(
177 capability_name.to_string(),
178 capability(
179 "Mocked host capability registered at runtime for tests.",
180 &[(operation_name.to_string(), mocked_operation_entry())],
181 ),
182 );
183 return;
184 };
185
186 let Some(existing_dict) = existing.as_dict() else {
187 return;
188 };
189 let mut entry = (*existing_dict).clone();
190 let mut ops = entry
191 .get("ops")
192 .and_then(|value| match value {
193 VmValue::List(list) => Some((**list).clone()),
194 _ => None,
195 })
196 .unwrap_or_default();
197 if !ops.iter().any(|value| value.display() == operation_name) {
198 ops.push(VmValue::String(std::sync::Arc::from(
199 operation_name.to_string(),
200 )));
201 }
202
203 let mut operations = entry
204 .get("operations")
205 .and_then(|value| value.as_dict())
206 .map(|dict| (*dict).clone())
207 .unwrap_or_default();
208 operations
209 .entry(operation_name.to_string())
210 .or_insert_with(mocked_operation_entry);
211
212 entry.insert("ops".to_string(), VmValue::List(std::sync::Arc::new(ops)));
213 entry.insert("operations".to_string(), VmValue::dict(operations));
214 root.insert(capability_name.to_string(), VmValue::dict(entry));
215}
216
217fn capability_manifest_with_mocks() -> VmValue {
218 let mut root = capability_manifest_map();
219 HOST_MOCKS.with(|mocks| {
220 for host_mock in mocks.borrow().iter() {
221 ensure_mocked_capability(&mut root, &host_mock.capability, &host_mock.operation);
222 }
223 });
224 VmValue::dict(root)
225}
226
227fn op(name: &str, description: &str) -> (String, VmValue) {
228 let mut entry = crate::value::DictMap::new();
229 entry.put_str("description", description);
230 (name.to_string(), VmValue::dict(entry))
231}
232
233fn capability(description: &str, ops: &[(String, VmValue)]) -> VmValue {
234 let mut entry = crate::value::DictMap::new();
235 entry.put_str("description", description);
236 entry.insert(
237 "ops".to_string(),
238 VmValue::List(std::sync::Arc::new(
239 ops.iter()
240 .map(|(name, _)| VmValue::String(std::sync::Arc::from(name.as_str())))
241 .collect(),
242 )),
243 );
244 let mut op_dict = crate::value::DictMap::new();
245 for (name, op) in ops {
246 op_dict.insert(name.clone(), op.clone());
247 }
248 entry.insert("operations".to_string(), VmValue::dict(op_dict));
249 VmValue::dict(entry)
250}
251
252pub(crate) fn require_param(params: &crate::value::DictMap, key: &str) -> Result<String, VmError> {
253 params
254 .get(key)
255 .map(|v| v.display())
256 .filter(|v| !v.is_empty())
257 .ok_or_else(|| {
258 VmError::Thrown(VmValue::String(std::sync::Arc::from(format!(
259 "host_call: missing required parameter '{key}'"
260 ))))
261 })
262}
263
264fn render_template(
265 path: &str,
266 bindings: Option<&crate::value::DictMap>,
267) -> Result<String, VmError> {
268 let asset = crate::stdlib::template::TemplateAsset::render_target(path).map_err(|msg| {
269 VmError::Thrown(VmValue::String(std::sync::Arc::from(format!(
270 "host_call template.render: {msg}"
271 ))))
272 })?;
273 crate::stdlib::template::render_asset_result(&asset, bindings).map_err(VmError::from)
274}
275
276fn params_match(expected: Option<&crate::value::DictMap>, actual: &crate::value::DictMap) -> bool {
277 let Some(expected) = expected else {
278 return true;
279 };
280 expected.iter().all(|(key, value)| {
281 actual
282 .get(key)
283 .is_some_and(|candidate| values_equal(candidate, value))
284 })
285}
286
287fn parse_host_mock(args: &[VmValue]) -> Result<HostMock, VmError> {
288 let capability = args
289 .first()
290 .map(|value| value.display())
291 .unwrap_or_default();
292 let operation = args.get(1).map(|value| value.display()).unwrap_or_default();
293 if capability.is_empty() || operation.is_empty() {
294 return Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
295 "host_mock: capability and operation are required",
296 ))));
297 }
298
299 let mut params = args
300 .get(3)
301 .and_then(|value| value.as_dict())
302 .map(|dict| (*dict).clone());
303 let mut result = args.get(2).cloned().or(Some(VmValue::Nil));
304 let mut error = None;
305
306 if let Some(config) = args.get(2).and_then(|value| value.as_dict()) {
307 if config.contains_key("result")
308 || config.contains_key("params")
309 || config.contains_key("error")
310 {
311 params = config
312 .get("params")
313 .and_then(|value| value.as_dict())
314 .map(|dict| (*dict).clone());
315 result = config.get("result").cloned();
316 error = config
317 .get("error")
318 .map(|value| value.display())
319 .filter(|value| !value.is_empty());
320 }
321 }
322
323 Ok(HostMock {
324 capability,
325 operation,
326 params,
327 result,
328 error,
329 })
330}
331
332fn push_host_mock(host_mock: HostMock) {
333 HOST_MOCKS.with(|mocks| mocks.borrow_mut().push(host_mock));
334}
335
336fn mock_call_value(call: &HostMockCall) -> VmValue {
337 let mut item = crate::value::DictMap::new();
338 item.put_str("capability", call.capability.clone());
339 item.put_str("operation", call.operation.clone());
340 item.insert("params".to_string(), VmValue::dict(call.params.clone()));
341 VmValue::dict(item)
342}
343
344fn record_mock_call(capability: &str, operation: &str, params: &crate::value::DictMap) {
345 HOST_MOCK_CALLS.with(|calls| {
346 calls.borrow_mut().push(HostMockCall {
347 capability: capability.to_string(),
348 operation: operation.to_string(),
349 params: params.clone(),
350 });
351 });
352}
353
354pub(crate) fn dispatch_mock_host_call(
355 capability: &str,
356 operation: &str,
357 params: &crate::value::DictMap,
358) -> Option<Result<VmValue, VmError>> {
359 let matched = HOST_MOCKS.with(|mocks| {
360 mocks
361 .borrow()
362 .iter()
363 .rev()
364 .find(|host_mock| {
365 host_mock.capability == capability
366 && host_mock.operation == operation
367 && params_match(host_mock.params.as_ref(), params)
368 })
369 .cloned()
370 })?;
371
372 record_mock_call(capability, operation, params);
373 if let Some(error) = matched.error {
374 return Some(Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
375 error,
376 )))));
377 }
378 Some(Ok(matched.result.unwrap_or(VmValue::Nil)))
379}
380
381pub trait HostCallBridge: Send + Sync {
396 fn dispatch(
397 &self,
398 capability: &str,
399 operation: &str,
400 params: &crate::value::DictMap,
401 ) -> Result<Option<VmValue>, VmError>;
402
403 fn list_tools(&self) -> Result<Option<VmValue>, VmError> {
404 Ok(None)
405 }
406
407 fn call_tool(&self, _name: &str, _args: &VmValue) -> Result<Option<VmValue>, VmError> {
408 Ok(None)
409 }
410}
411
412thread_local! {
413 static HOST_CALL_BRIDGE: RefCell<Option<Arc<dyn HostCallBridge>>> = const { RefCell::new(None) };
414}
415
416pub fn set_host_call_bridge(bridge: Arc<dyn HostCallBridge>) {
421 HOST_CALL_BRIDGE.with(|b| *b.borrow_mut() = Some(bridge));
422}
423
424pub fn clear_host_call_bridge() {
426 HOST_CALL_BRIDGE.with(|b| *b.borrow_mut() = None);
427}
428
429pub fn dispatch_host_call_bridge(
439 capability: &str,
440 operation: &str,
441 params: &crate::value::DictMap,
442) -> Option<Result<VmValue, VmError>> {
443 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone())?;
444 match bridge.dispatch(capability, operation, params) {
445 Ok(Some(value)) => Some(Ok(value)),
446 Ok(None) => None,
447 Err(error) => Some(Err(error)),
448 }
449}
450
451fn empty_tool_list_value() -> VmValue {
452 VmValue::List(std::sync::Arc::new(Vec::new()))
453}
454
455fn current_vm_host_bridge(
456 ctx: Option<&AsyncBuiltinCtx>,
457) -> Option<std::sync::Arc<crate::bridge::HostBridge>> {
458 ctx.and_then(|ctx| ctx.child_vm().bridge.clone())
459}
460
461#[cfg(test)]
462async fn dispatch_host_tool_list() -> Result<VmValue, VmError> {
463 dispatch_host_tool_list_with_ctx(None).await
464}
465
466async fn dispatch_host_tool_list_with_ctx(
467 ctx: Option<&AsyncBuiltinCtx>,
468) -> Result<VmValue, VmError> {
469 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
470 if let Some(bridge) = bridge {
471 if let Some(value) = bridge.list_tools()? {
472 return Ok(value);
473 }
474 }
475
476 let Some(bridge) = current_vm_host_bridge(ctx) else {
477 return Ok(empty_tool_list_value());
478 };
479 let tools = bridge.list_host_tools().await?;
480 Ok(crate::bridge::json_result_to_vm_value(&JsonValue::Array(
481 tools.into_iter().collect(),
482 )))
483}
484
485pub(crate) async fn dispatch_host_tool_call(
486 name: &str,
487 args: &VmValue,
488) -> Result<VmValue, VmError> {
489 dispatch_host_tool_call_with_ctx(None, name, args).await
490}
491
492pub(crate) async fn dispatch_host_tool_call_with_ctx(
493 ctx: Option<&AsyncBuiltinCtx>,
494 name: &str,
495 args: &VmValue,
496) -> Result<VmValue, VmError> {
497 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
498 if let Some(bridge) = bridge {
499 if let Some(value) = bridge.call_tool(name, args)? {
500 return Ok(value);
501 }
502 }
503
504 let Some(bridge) = current_vm_host_bridge(ctx) else {
505 return Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
506 "host_tool_call: no host bridge is attached",
507 ))));
508 };
509
510 let result = bridge
511 .call(
512 "builtin_call",
513 serde_json::json!({
514 "name": name,
515 "args": [crate::llm::vm_value_to_json(args)],
516 }),
517 )
518 .await?;
519 Ok(crate::bridge::json_result_to_vm_value(&result))
520}
521
522pub(crate) async fn dispatch_host_operation(
523 capability: &str,
524 operation: &str,
525 params: &crate::value::DictMap,
526) -> Result<VmValue, VmError> {
527 dispatch_host_operation_with_ctx(None, capability, operation, params).await
528}
529
530pub(crate) async fn dispatch_host_operation_with_ctx(
531 ctx: Option<&AsyncBuiltinCtx>,
532 capability: &str,
533 operation: &str,
534 params: &crate::value::DictMap,
535) -> Result<VmValue, VmError> {
536 if let Some(mocked) = dispatch_mock_host_call(capability, operation, params) {
537 return mocked;
538 }
539
540 if (capability, operation) == ("process", "exec") {
541 let caller = serde_json::json!({
542 "surface": "host_call",
543 "capability": "process",
544 "operation": "exec",
545 "session_id": crate::llm::current_agent_session_id(),
546 });
547 return dispatch_process_exec_with_policy(ctx, params, caller).await;
548 }
549
550 if (capability, operation) == ("process", "spawn") {
557 let caller = serde_json::json!({
558 "surface": "host_call",
559 "capability": "process",
560 "operation": "spawn",
561 "session_id": crate::llm::current_agent_session_id(),
562 });
563 return dispatch_process_spawn_with_policy(ctx, params, caller).await;
564 }
565 if capability == "process" && matches!(operation, "poll" | "wait" | "kill" | "release") {
566 if let Some(result) = crate::stdlib::process_spawn::dispatch(operation, params).await {
567 return result;
568 }
569 }
570
571 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
572 if let Some(bridge) = bridge {
573 if let Some(value) = bridge.dispatch(capability, operation, params)? {
574 return Ok(value);
575 }
576 }
577
578 dispatch_builtin_host_operation(capability, operation, params).await
579}
580
581async fn dispatch_builtin_host_operation(
582 capability: &str,
583 operation: &str,
584 params: &crate::value::DictMap,
585) -> Result<VmValue, VmError> {
586 match (capability, operation) {
587 ("process", "list_shells") => Ok(crate::shells::list_shells_vm_value()),
588 ("process", "get_default_shell") => Ok(crate::shells::default_shell_vm_value()),
589 ("process", "set_default_shell") => crate::shells::set_default_shell_vm_value(params),
590 ("process", "shell_invocation") => crate::shells::shell_invocation_vm_value(params),
591 ("template", "render") => {
592 let path = require_param(params, "path")?;
593 let bindings = params.get("bindings").and_then(|v| v.as_dict());
594 Ok(VmValue::String(std::sync::Arc::from(render_template(
595 &path, bindings,
596 )?)))
597 }
598 ("interaction", "ask") => {
599 let question = require_param(params, "question")?;
600 use std::io::BufRead;
601 print!("{question}");
602 let _ = std::io::Write::flush(&mut std::io::stdout());
603 let mut input = String::new();
604 if std::io::stdin().lock().read_line(&mut input).is_ok() {
605 Ok(VmValue::String(std::sync::Arc::from(input.trim_end())))
606 } else {
607 Ok(VmValue::Nil)
608 }
609 }
610 ("runtime", "task") => Ok(VmValue::String(std::sync::Arc::from(
615 std::env::var("HARN_TASK").unwrap_or_default(),
616 ))),
617 ("runtime", "set_result") => {
618 Ok(VmValue::Nil)
621 }
622 ("workspace", "project_root") => {
623 let path = std::env::var("HARN_PROJECT_ROOT").unwrap_or_else(|_| {
627 std::env::current_dir()
628 .map(|p| p.display().to_string())
629 .unwrap_or_default()
630 });
631 Ok(VmValue::String(std::sync::Arc::from(path)))
632 }
633 ("workspace", "cwd") => {
634 let path = std::env::current_dir()
635 .map(|p| p.display().to_string())
636 .unwrap_or_default();
637 Ok(VmValue::String(std::sync::Arc::from(path)))
638 }
639 _ => Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
640 format!("host_call: unsupported operation {capability}.{operation}"),
641 )))),
642 }
643}
644
645pub(crate) async fn dispatch_process_exec(
646 params: &crate::value::DictMap,
647 caller: serde_json::Value,
648) -> Result<VmValue, VmError> {
649 dispatch_process_exec_with_policy(None, params, caller).await
650}
651
652async fn dispatch_process_exec_with_policy(
653 ctx: Option<&AsyncBuiltinCtx>,
654 params: &crate::value::DictMap,
655 caller: serde_json::Value,
656) -> Result<VmValue, VmError> {
657 let (params, command_policy_context, command_policy_decisions) =
658 match crate::orchestration::run_command_policy_preflight_with_ctx(ctx, params, caller)
659 .await?
660 {
661 crate::orchestration::CommandPolicyPreflight::Proceed {
662 params,
663 context,
664 decisions,
665 } => (params, context, decisions),
666 crate::orchestration::CommandPolicyPreflight::Blocked {
667 status,
668 message,
669 context,
670 decisions,
671 } => {
672 return Ok(crate::orchestration::blocked_command_response(
673 params, status, &message, context, decisions,
674 ));
675 }
676 };
677
678 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
679 if let Some(bridge) = bridge {
680 if let Some(value) = bridge.dispatch("process", "exec", ¶ms)? {
681 return crate::orchestration::run_command_policy_postflight_with_ctx(
682 ctx,
683 ¶ms,
684 value,
685 command_policy_context,
686 command_policy_decisions,
687 )
688 .await;
689 }
690 }
691
692 dispatch_process_exec_after_policy(
693 ctx,
694 ¶ms,
695 command_policy_context,
696 command_policy_decisions,
697 )
698 .await
699}
700
701async fn dispatch_process_spawn_with_policy(
708 ctx: Option<&AsyncBuiltinCtx>,
709 params: &crate::value::DictMap,
710 caller: serde_json::Value,
711) -> Result<VmValue, VmError> {
712 let params =
713 match crate::orchestration::run_command_policy_preflight_with_ctx(ctx, params, caller)
714 .await?
715 {
716 crate::orchestration::CommandPolicyPreflight::Proceed { params, .. } => params,
717 crate::orchestration::CommandPolicyPreflight::Blocked {
718 status,
719 message,
720 context,
721 decisions,
722 } => {
723 return Ok(crate::orchestration::blocked_command_response(
724 params, status, &message, context, decisions,
725 ));
726 }
727 };
728
729 match crate::stdlib::process_spawn::dispatch("spawn", ¶ms).await {
730 Some(result) => result,
731 None => Err(VmError::Runtime(
732 "host_call process.spawn: dispatch returned None".to_string(),
733 )),
734 }
735}
736
737async fn dispatch_process_exec_after_policy(
738 ctx: Option<&AsyncBuiltinCtx>,
739 params: &crate::value::DictMap,
740 command_policy_context: JsonValue,
741 command_policy_decisions: Vec<crate::orchestration::CommandPolicyDecision>,
742) -> Result<VmValue, VmError> {
743 let timeout_ms = optional_i64(params, "timeout")
744 .or_else(|| optional_i64(params, "timeout_ms"))
745 .filter(|value| *value > 0)
746 .map(|value| value as u64);
747 let profile_guard = match optional_string(params, "sandbox_profile") {
753 Some(value) => Some(push_sandbox_profile_override(&value)?),
754 None => None,
755 };
756 let mut cmd = build_sandboxed_command(params, "process.exec")?;
757 cmd.stdin(std::process::Stdio::null())
758 .stdout(std::process::Stdio::piped())
759 .stderr(std::process::Stdio::piped())
760 .kill_on_drop(true);
761 let started_at = audited_utc_now_rfc3339("host_call/process.exec.started_at");
762 let started = crate::clock_mock::leak_audit::instant_now("host_call/process.exec.started");
763 let child = cmd
764 .spawn()
765 .map_err(|e| VmError::Runtime(format!("host_call process.exec: {e}")))?;
766 drop(profile_guard);
767 let pid = child.id();
768 let timed_out;
769 let output_result = if let Some(timeout_ms) = timeout_ms {
770 match tokio::time::timeout(
771 std::time::Duration::from_millis(timeout_ms),
772 child.wait_with_output(),
773 )
774 .await
775 {
776 Ok(result) => {
777 timed_out = false;
778 result
779 }
780 Err(_) => {
781 let response = process_exec_response(ProcessExecResponse {
782 pid,
783 started_at,
784 started,
785 stdout: "",
786 stderr: "",
787 exit_code: -1,
788 status: "timed_out",
789 success: false,
790 timed_out: true,
791 });
792 return crate::orchestration::run_command_policy_postflight_with_ctx(
793 ctx,
794 params,
795 response,
796 command_policy_context,
797 command_policy_decisions,
798 )
799 .await;
800 }
801 }
802 } else {
803 timed_out = false;
804 child.wait_with_output().await
805 };
806 let output =
807 output_result.map_err(|e| VmError::Runtime(format!("host_call process.exec: {e}")))?;
808 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
809 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
810 let exit_code = output.status.code().unwrap_or(-1);
811 let response = process_exec_response(ProcessExecResponse {
812 pid,
813 started_at,
814 started,
815 stdout: &stdout,
816 stderr: &stderr,
817 exit_code,
818 status: if timed_out { "timed_out" } else { "completed" },
819 success: output.status.success(),
820 timed_out,
821 });
822 crate::orchestration::run_command_policy_postflight_with_ctx(
823 ctx,
824 params,
825 response,
826 command_policy_context,
827 command_policy_decisions,
828 )
829 .await
830}
831
832pub(crate) fn build_sandboxed_command(
844 params: &crate::value::DictMap,
845 label: &str,
846) -> Result<tokio::process::Command, VmError> {
847 let (program, args) = process_exec_argv(params)?;
848 let mut cmd = crate::process_sandbox::tokio_command_for(&program, &args)
849 .map_err(|e| VmError::Runtime(format!("host_call {label} sandbox setup: {e}")))?;
850 if let Some(cwd) = optional_string(params, "cwd") {
851 let cwd = resolve_process_exec_cwd(&cwd);
852 crate::process_sandbox::enforce_process_cwd(&cwd)
853 .map_err(|e| VmError::Runtime(format!("host_call {label} cwd: {e}")))?;
854 cmd.current_dir(cwd);
855 }
856 if let Some(env) = optional_string_dict(params, "env")? {
857 let env_mode = optional_string(params, "env_mode");
866 match env_mode.as_deref().unwrap_or("merge") {
867 "replace" => {
868 cmd.env_clear();
869 }
870 "merge" => {}
871 other => {
872 return Err(VmError::Runtime(format!(
873 "host_call {label}: unknown env_mode {other:?}; expected \"merge\" or \"replace\""
874 )));
875 }
876 }
877 for (key, value) in env {
878 cmd.env(key, value);
879 }
880 }
881 if let Some(env_remove) = optional_string_list(params, "env_remove") {
887 for key in env_remove {
888 cmd.env_remove(key);
889 }
890 }
891 Ok(cmd)
892}
893
894struct ProcessExecResponse<'a> {
895 pid: Option<u32>,
896 started_at: String,
897 started: Instant,
898 stdout: &'a str,
899 stderr: &'a str,
900 exit_code: i32,
901 status: &'a str,
902 success: bool,
903 timed_out: bool,
904}
905
906fn process_exec_response(response: ProcessExecResponse<'_>) -> VmValue {
907 let combined = format!("{}{}", response.stdout, response.stderr);
908 let mut result = crate::value::DictMap::new();
909 result.put_str(
910 "command_id",
911 format!(
912 "cmd_{}_{}",
913 std::process::id(),
914 response.started.elapsed().as_nanos()
915 ),
916 );
917 result.put_str("status", response.status);
918 result.insert(
919 "pid".to_string(),
920 response
921 .pid
922 .map(|pid| VmValue::Int(pid as i64))
923 .unwrap_or(VmValue::Nil),
924 );
925 result.insert(
926 "process_group_id".to_string(),
927 response
928 .pid
929 .map(|pid| VmValue::Int(pid as i64))
930 .unwrap_or(VmValue::Nil),
931 );
932 result.insert("handle_id".to_string(), VmValue::Nil);
933 result.put_str("started_at", response.started_at);
934 result.put_str(
935 "ended_at",
936 audited_utc_now_rfc3339("host_call/process.exec.ended_at"),
937 );
938 result.insert(
939 "duration_ms".to_string(),
940 VmValue::Int(response.started.elapsed().as_millis() as i64),
941 );
942 result.insert(
943 "exit_code".to_string(),
944 VmValue::Int(response.exit_code as i64),
945 );
946 result.insert("signal".to_string(), VmValue::Nil);
947 result.insert("timed_out".to_string(), VmValue::Bool(response.timed_out));
948 result.put_str("stdout", response.stdout);
949 result.put_str("stderr", response.stderr);
950 result.put_str("combined", combined);
951 result.insert(
952 "exit_status".to_string(),
953 VmValue::Int(response.exit_code as i64),
954 );
955 result.insert(
956 "legacy_status".to_string(),
957 VmValue::Int(response.exit_code as i64),
958 );
959 result.insert("success".to_string(), VmValue::Bool(response.success));
960 VmValue::dict(result)
961}
962
963fn resolve_process_exec_cwd(cwd: &str) -> std::path::PathBuf {
964 crate::stdlib::process::resolve_source_relative_path(cwd)
965}
966
967fn process_exec_argv(params: &crate::value::DictMap) -> Result<(String, Vec<String>), VmError> {
968 match optional_string(params, "mode")
969 .as_deref()
970 .unwrap_or("shell")
971 {
972 "argv" => {
973 let argv = optional_string_list(params, "argv").ok_or_else(|| {
974 VmError::Runtime("host_call process.exec missing argv".to_string())
975 })?;
976 split_argv(argv)
977 }
978 "shell" => {
979 let command = require_param(params, "command")?;
980 let mut invocation_params = params.clone();
981 invocation_params.put_str("command", command);
982 let invocation =
983 crate::shells::resolve_invocation_from_vm_params(&invocation_params)
984 .map_err(|err| VmError::Runtime(format!("host_call process.exec: {err}")))?;
985 Ok((invocation.program, invocation.args))
986 }
987 other => Err(VmError::Runtime(format!(
988 "host_call process.exec unsupported mode {other:?}"
989 ))),
990 }
991}
992
993fn split_argv(mut argv: Vec<String>) -> Result<(String, Vec<String>), VmError> {
994 if argv.is_empty() {
995 return Err(VmError::Runtime(
996 "host_call process.exec argv must not be empty".to_string(),
997 ));
998 }
999 let program = argv.remove(0);
1000 if program.is_empty() {
1001 return Err(VmError::Runtime(
1002 "host_call process.exec argv[0] must not be empty".to_string(),
1003 ));
1004 }
1005 Ok((program, argv))
1006}
1007
1008pub(crate) fn push_sandbox_profile_override(value: &str) -> Result<SandboxProfileGuard, VmError> {
1014 let profile = crate::orchestration::SandboxProfile::parse(value).ok_or_else(|| {
1015 VmError::Thrown(VmValue::String(std::sync::Arc::from(format!(
1016 "host_call process.exec: unknown sandbox_profile {value:?}; expected one of \"unrestricted\", \"worktree\", \"os_hardened\", \"wasi\""
1017 ))))
1018 })?;
1019 let mut policy = crate::orchestration::current_execution_policy().unwrap_or_default();
1020 policy.sandbox_profile = profile;
1021 crate::orchestration::push_execution_policy(policy);
1022 Ok(SandboxProfileGuard {
1023 _private: std::marker::PhantomData,
1024 })
1025}
1026
1027pub(crate) struct SandboxProfileGuard {
1028 _private: std::marker::PhantomData<*const ()>,
1029}
1030
1031impl Drop for SandboxProfileGuard {
1032 fn drop(&mut self) {
1033 crate::orchestration::pop_execution_policy();
1034 }
1035}
1036
1037pub(crate) fn optional_i64(params: &crate::value::DictMap, key: &str) -> Option<i64> {
1038 match params.get(key) {
1039 Some(VmValue::Int(value)) => Some(*value),
1040 Some(VmValue::Float(value)) if value.fract() == 0.0 => Some(*value as i64),
1041 _ => None,
1042 }
1043}
1044
1045pub(crate) fn optional_string(params: &crate::value::DictMap, key: &str) -> Option<String> {
1046 params.get(key).and_then(vm_string).map(ToString::to_string)
1047}
1048
1049fn optional_string_list(params: &crate::value::DictMap, key: &str) -> Option<Vec<String>> {
1050 let VmValue::List(values) = params.get(key)? else {
1051 return None;
1052 };
1053 values
1054 .iter()
1055 .map(|value| vm_string(value).map(ToString::to_string))
1056 .collect()
1057}
1058
1059fn optional_string_dict(
1060 params: &crate::value::DictMap,
1061 key: &str,
1062) -> Result<Option<BTreeMap<String, String>>, VmError> {
1063 let Some(value) = params.get(key) else {
1064 return Ok(None);
1065 };
1066 let Some(dict) = value.as_dict() else {
1067 return Err(VmError::Runtime(format!(
1068 "host_call process.exec {key} must be a dict"
1069 )));
1070 };
1071 let mut out = std::collections::BTreeMap::new();
1072 for (key, value) in dict.iter() {
1073 let Some(value) = vm_string(value) else {
1074 return Err(VmError::Runtime(format!(
1075 "host_call process.exec env value for {key:?} must be a string"
1076 )));
1077 };
1078 out.insert(key.clone(), value.to_string());
1079 }
1080 Ok(Some(out))
1081}
1082
1083fn vm_string(value: &VmValue) -> Option<&str> {
1084 match value {
1085 VmValue::String(value) => Some(value.as_ref()),
1086 _ => None,
1087 }
1088}
1089
1090pub(crate) fn register_host_builtins(vm: &mut Vm) {
1091 for def in MODULE_BUILTINS {
1092 vm.register_builtin_def(def);
1093 }
1094}
1095
1096#[harn_builtin(
1097 sig = "host_mock(capability: string, op: string, response_or_config?: any, params?: dict) -> nil",
1098 category = "host"
1099)]
1100fn host_mock_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1101 let host_mock = parse_host_mock(args)?;
1102 push_host_mock(host_mock);
1103 Ok(VmValue::Nil)
1104}
1105
1106#[harn_builtin(sig = "host_mock_clear() -> nil", category = "host")]
1107fn host_mock_clear_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1108 reset_host_state();
1109 Ok(VmValue::Nil)
1110}
1111
1112#[harn_builtin(sig = "host_mock_calls() -> list", category = "host")]
1113fn host_mock_calls_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1114 let calls = HOST_MOCK_CALLS.with(|calls| {
1115 calls
1116 .borrow()
1117 .iter()
1118 .map(mock_call_value)
1119 .collect::<Vec<_>>()
1120 });
1121 Ok(VmValue::List(std::sync::Arc::new(calls)))
1122}
1123
1124#[harn_builtin(sig = "host_mock_push_scope() -> nil", category = "host")]
1125fn host_mock_push_scope_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1126 push_host_mock_scope();
1127 Ok(VmValue::Nil)
1128}
1129
1130#[harn_builtin(sig = "host_mock_pop_scope() -> nil", category = "host")]
1131fn host_mock_pop_scope_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1132 if !pop_host_mock_scope() {
1133 return Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
1134 "host_mock_pop_scope: no scope to pop",
1135 ))));
1136 }
1137 Ok(VmValue::Nil)
1138}
1139
1140#[harn_builtin(sig = "host_capabilities() -> dict", category = "host")]
1141fn host_capabilities_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1142 Ok(capability_manifest_with_mocks())
1143}
1144
1145#[harn_builtin(
1146 sig = "host_has(capability: string, op?: string) -> bool",
1147 category = "host"
1148)]
1149fn host_has_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1150 let capability = args.first().map(|a| a.display()).unwrap_or_default();
1151 let operation = args.get(1).map(|a| a.display());
1152 let manifest = capability_manifest_with_mocks();
1153 let has = manifest
1154 .as_dict()
1155 .and_then(|d| d.get(&capability))
1156 .and_then(|v| v.as_dict())
1157 .is_some_and(|cap| {
1158 if let Some(operation) = operation {
1159 cap.get("ops")
1160 .and_then(|v| match v {
1161 VmValue::List(list) => {
1162 Some(list.iter().any(|item| item.display() == operation))
1163 }
1164 _ => None,
1165 })
1166 .unwrap_or(false)
1167 } else {
1168 true
1169 }
1170 });
1171 Ok(VmValue::Bool(has))
1172}
1173
1174#[harn_builtin(
1175 sig = "host_call(name: string, args?: dict) -> any",
1176 kind = "async",
1177 category = "host"
1178)]
1179async fn host_call_builtin(
1180 ctx: crate::vm::AsyncBuiltinCtx,
1181 args: Vec<VmValue>,
1182) -> Result<VmValue, VmError> {
1183 let name = args.first().map(|a| a.display()).unwrap_or_default();
1184 let params = args
1185 .get(1)
1186 .and_then(|a| a.as_dict())
1187 .cloned()
1188 .unwrap_or_default();
1189 let Some((capability, operation)) = name.split_once('.') else {
1190 return Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
1191 format!("host_call: unsupported operation name '{name}'"),
1192 ))));
1193 };
1194 dispatch_host_operation_with_ctx(Some(&ctx), capability, operation, ¶ms).await
1195}
1196
1197#[harn_builtin(sig = "host_tool_list() -> list", kind = "async", category = "host")]
1198async fn host_tool_list_builtin(
1199 ctx: crate::vm::AsyncBuiltinCtx,
1200 _args: Vec<VmValue>,
1201) -> Result<VmValue, VmError> {
1202 dispatch_host_tool_list_with_ctx(Some(&ctx)).await
1203}
1204
1205#[harn_builtin(
1206 sig = "host_tool_call(name: string, args?: any) -> any",
1207 kind = "async",
1208 category = "host"
1209)]
1210async fn host_tool_call_builtin(
1211 ctx: crate::vm::AsyncBuiltinCtx,
1212 args: Vec<VmValue>,
1213) -> Result<VmValue, VmError> {
1214 let name = args.first().map(|a| a.display()).unwrap_or_default();
1215 if name.is_empty() {
1216 return Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
1217 "host_tool_call: tool name is required",
1218 ))));
1219 }
1220 let call_args = args.get(1).cloned().unwrap_or(VmValue::Nil);
1221 dispatch_host_tool_call_with_ctx(Some(&ctx), &name, &call_args).await
1222}
1223
1224#[cfg(test)]
1225mod tests {
1226 use super::{
1227 capability_manifest_with_mocks, clear_host_call_bridge, dispatch_host_operation,
1228 dispatch_host_tool_call, dispatch_host_tool_list, dispatch_mock_host_call, push_host_mock,
1229 reset_host_state, resolve_process_exec_cwd, set_host_call_bridge, HostCallBridge, HostMock,
1230 };
1231 use crate::value::VmDictExt;
1232
1233 use std::sync::{
1234 atomic::{AtomicUsize, Ordering},
1235 Arc,
1236 };
1237
1238 use crate::value::{VmError, VmValue};
1239
1240 #[test]
1241 fn process_exec_relative_cwd_resolves_against_execution_root() {
1242 let dir = tempfile::tempdir().expect("tempdir");
1243 crate::stdlib::process::set_thread_execution_context(Some(
1244 crate::orchestration::RunExecutionRecord {
1245 cwd: Some(dir.path().to_string_lossy().into_owned()),
1246 source_dir: Some(dir.path().join("src").to_string_lossy().into_owned()),
1247 env: std::collections::BTreeMap::new(),
1248 adapter: None,
1249 repo_path: None,
1250 worktree_path: None,
1251 branch: None,
1252 base_ref: None,
1253 cleanup: None,
1254 },
1255 ));
1256
1257 assert_eq!(
1258 resolve_process_exec_cwd("subdir"),
1259 dir.path().join("subdir")
1260 );
1261
1262 crate::stdlib::process::set_thread_execution_context(None);
1263 }
1264
1265 #[test]
1266 fn manifest_includes_operation_metadata() {
1267 let manifest = capability_manifest_with_mocks();
1268 let process = manifest
1269 .as_dict()
1270 .and_then(|d| d.get("process"))
1271 .and_then(|v| v.as_dict())
1272 .expect("process capability");
1273 assert!(process.get("description").is_some());
1274 let operations = process
1275 .get("operations")
1276 .and_then(|v| v.as_dict())
1277 .expect("operations dict");
1278 assert!(operations.get("exec").is_some());
1279 }
1280
1281 #[test]
1282 fn mocked_capabilities_appear_in_manifest() {
1283 reset_host_state();
1284 push_host_mock(HostMock {
1285 capability: "project".to_string(),
1286 operation: "metadata_get".to_string(),
1287 params: None,
1288 result: Some(VmValue::dict(crate::value::DictMap::new())),
1289 error: None,
1290 });
1291 let manifest = capability_manifest_with_mocks();
1292 let project = manifest
1293 .as_dict()
1294 .and_then(|d| d.get("project"))
1295 .and_then(|v| v.as_dict())
1296 .expect("project capability");
1297 let operations = project
1298 .get("operations")
1299 .and_then(|v| v.as_dict())
1300 .expect("operations dict");
1301 assert!(operations.get("metadata_get").is_some());
1302 reset_host_state();
1303 }
1304
1305 #[test]
1306 fn mock_host_call_matches_partial_params_and_overrides_order() {
1307 reset_host_state();
1308 let mut exact_params = crate::value::DictMap::new();
1309 exact_params.put_str("namespace", "facts");
1310 push_host_mock(HostMock {
1311 capability: "project".to_string(),
1312 operation: "metadata_get".to_string(),
1313 params: None,
1314 result: Some(VmValue::String(std::sync::Arc::from("fallback"))),
1315 error: None,
1316 });
1317 push_host_mock(HostMock {
1318 capability: "project".to_string(),
1319 operation: "metadata_get".to_string(),
1320 params: Some(exact_params),
1321 result: Some(VmValue::String(std::sync::Arc::from("facts"))),
1322 error: None,
1323 });
1324
1325 let mut call_params = crate::value::DictMap::new();
1326 call_params.put_str("dir", "pkg");
1327 call_params.put_str("namespace", "facts");
1328 let exact = dispatch_mock_host_call("project", "metadata_get", &call_params)
1329 .expect("expected exact mock")
1330 .expect("exact mock should succeed");
1331 assert_eq!(exact.display(), "facts");
1332
1333 call_params.put_str("namespace", "classification");
1334 let fallback = dispatch_mock_host_call("project", "metadata_get", &call_params)
1335 .expect("expected fallback mock")
1336 .expect("fallback mock should succeed");
1337 assert_eq!(fallback.display(), "fallback");
1338 reset_host_state();
1339 }
1340
1341 #[test]
1342 fn mock_host_call_can_throw_errors() {
1343 reset_host_state();
1344 push_host_mock(HostMock {
1345 capability: "project".to_string(),
1346 operation: "metadata_get".to_string(),
1347 params: None,
1348 result: None,
1349 error: Some("boom".to_string()),
1350 });
1351 let params = crate::value::DictMap::new();
1352 let result = dispatch_mock_host_call("project", "metadata_get", ¶ms)
1353 .expect("expected mock result");
1354 match result {
1355 Err(VmError::Thrown(VmValue::String(message))) => assert_eq!(message.as_ref(), "boom"),
1356 other => panic!("unexpected result: {other:?}"),
1357 }
1358 reset_host_state();
1359 }
1360
1361 #[derive(Default)]
1362 struct TestHostToolBridge;
1363
1364 impl HostCallBridge for TestHostToolBridge {
1365 fn dispatch(
1366 &self,
1367 _capability: &str,
1368 _operation: &str,
1369 _params: &crate::value::DictMap,
1370 ) -> Result<Option<VmValue>, VmError> {
1371 Ok(None)
1372 }
1373
1374 fn list_tools(&self) -> Result<Option<VmValue>, VmError> {
1375 let tool = VmValue::dict(crate::value::DictMap::from_iter([
1376 (
1377 "name".to_string(),
1378 VmValue::String(std::sync::Arc::from("Read".to_string())),
1379 ),
1380 (
1381 "description".to_string(),
1382 VmValue::String(std::sync::Arc::from(
1383 "Read a file from the host".to_string(),
1384 )),
1385 ),
1386 (
1387 "schema".to_string(),
1388 VmValue::dict(crate::value::DictMap::from_iter([(
1389 "type".to_string(),
1390 VmValue::String(std::sync::Arc::from("object".to_string())),
1391 )])),
1392 ),
1393 ("deprecated".to_string(), VmValue::Bool(false)),
1394 ]));
1395 Ok(Some(VmValue::List(std::sync::Arc::new(vec![tool]))))
1396 }
1397
1398 fn call_tool(&self, name: &str, args: &VmValue) -> Result<Option<VmValue>, VmError> {
1399 if name != "Read" {
1400 return Ok(None);
1401 }
1402 let path = args
1403 .as_dict()
1404 .and_then(|dict| dict.get("path"))
1405 .map(|value| value.display())
1406 .unwrap_or_default();
1407 Ok(Some(VmValue::String(std::sync::Arc::from(format!(
1408 "read:{path}"
1409 )))))
1410 }
1411 }
1412
1413 struct CountingProcessExecBridge {
1414 calls: Arc<AtomicUsize>,
1415 }
1416
1417 impl HostCallBridge for CountingProcessExecBridge {
1418 fn dispatch(
1419 &self,
1420 capability: &str,
1421 operation: &str,
1422 _params: &crate::value::DictMap,
1423 ) -> Result<Option<VmValue>, VmError> {
1424 if (capability, operation) != ("process", "exec") {
1425 return Ok(None);
1426 }
1427 self.calls.fetch_add(1, Ordering::SeqCst);
1428 Ok(Some(VmValue::dict(crate::value::DictMap::from_iter([
1429 (
1430 "status".to_string(),
1431 VmValue::String(std::sync::Arc::from("completed".to_string())),
1432 ),
1433 ("exit_code".to_string(), VmValue::Int(0)),
1434 ("success".to_string(), VmValue::Bool(true)),
1435 ]))))
1436 }
1437 }
1438
1439 fn run_host_async_test<F, Fut>(test: F)
1440 where
1441 F: FnOnce() -> Fut,
1442 Fut: std::future::Future<Output = ()>,
1443 {
1444 let rt = tokio::runtime::Builder::new_current_thread()
1445 .enable_all()
1446 .build()
1447 .expect("runtime");
1448 rt.block_on(async {
1449 let local = tokio::task::LocalSet::new();
1450 local.run_until(test()).await;
1451 });
1452 }
1453
1454 #[test]
1455 fn host_tool_list_uses_installed_host_call_bridge() {
1456 run_host_async_test(|| async {
1457 reset_host_state();
1458 set_host_call_bridge(Arc::new(TestHostToolBridge));
1459 let tools = dispatch_host_tool_list().await.expect("tool list");
1460 clear_host_call_bridge();
1461
1462 let VmValue::List(items) = tools else {
1463 panic!("expected tool list");
1464 };
1465 assert_eq!(items.len(), 1);
1466 let tool = items[0].as_dict().expect("tool dict");
1467 assert_eq!(tool.get("name").unwrap().display(), "Read");
1468 assert_eq!(tool.get("deprecated").unwrap().display(), "false");
1469 });
1470 }
1471
1472 #[test]
1473 fn host_tool_call_uses_installed_host_call_bridge() {
1474 run_host_async_test(|| async {
1475 set_host_call_bridge(Arc::new(TestHostToolBridge));
1476 let args = VmValue::dict(crate::value::DictMap::from_iter([(
1477 "path".to_string(),
1478 VmValue::String(std::sync::Arc::from("README.md".to_string())),
1479 )]));
1480 let value = dispatch_host_tool_call("Read", &args)
1481 .await
1482 .expect("tool call");
1483 clear_host_call_bridge();
1484 assert_eq!(value.display(), "read:README.md");
1485 });
1486 }
1487
1488 #[test]
1489 fn process_exec_bridge_is_gated_by_command_policy() {
1490 run_host_async_test(|| async {
1491 crate::orchestration::clear_command_policies();
1492 let calls = Arc::new(AtomicUsize::new(0));
1493 set_host_call_bridge(Arc::new(CountingProcessExecBridge {
1494 calls: calls.clone(),
1495 }));
1496 crate::orchestration::push_command_policy(crate::orchestration::CommandPolicy {
1497 tools: vec!["run".to_string()],
1498 workspace_roots: Vec::new(),
1499 default_shell_mode: "shell".to_string(),
1500 deny_patterns: vec!["cat *".to_string()],
1501 require_approval: Default::default(),
1502 pre: None,
1503 post: None,
1504 allow_recursive: false,
1505 });
1506
1507 let result = dispatch_host_operation(
1508 "process",
1509 "exec",
1510 &crate::value::DictMap::from_iter([
1511 (
1512 "mode".to_string(),
1513 VmValue::String(std::sync::Arc::from("shell")),
1514 ),
1515 (
1516 "command".to_string(),
1517 VmValue::String(std::sync::Arc::from("cat Cargo.toml")),
1518 ),
1519 ]),
1520 )
1521 .await
1522 .expect("process.exec result");
1523
1524 crate::orchestration::clear_command_policies();
1525 clear_host_call_bridge();
1526
1527 assert_eq!(
1528 calls.load(Ordering::SeqCst),
1529 0,
1530 "blocked command must not reach host bridge"
1531 );
1532 let result = result.as_dict().expect("blocked result dict");
1533 assert_eq!(result.get("status").unwrap().display(), "blocked");
1534 assert!(
1535 result
1536 .get("reason")
1537 .map(VmValue::display)
1538 .unwrap_or_default()
1539 .contains("cat *"),
1540 "blocked result should name the matched policy pattern"
1541 );
1542 });
1543 }
1544
1545 #[cfg(unix)]
1546 async fn process_exec_env_probe(env: VmValue, env_mode: Option<&str>) -> (String, String) {
1547 std::env::set_var("PARENT_VAR", "inherited");
1552 let mut params = crate::value::DictMap::from_iter([
1553 (
1554 "mode".to_string(),
1555 VmValue::String(std::sync::Arc::from("argv")),
1556 ),
1557 (
1558 "argv".to_string(),
1559 VmValue::List(std::sync::Arc::new(vec![
1560 VmValue::String(std::sync::Arc::from("/bin/sh")),
1563 VmValue::String(std::sync::Arc::from("-c")),
1564 VmValue::String(std::sync::Arc::from(
1565 "printf '%s|%s' \"$PARENT_VAR\" \"$CHILD_VAR\"",
1566 )),
1567 ])),
1568 ),
1569 ("env".to_string(), env),
1570 ]);
1571 if let Some(mode) = env_mode {
1572 params.put_str("env_mode", mode);
1573 }
1574 let result = super::dispatch_process_exec(¶ms, serde_json::Value::Null)
1575 .await
1576 .expect("process.exec result");
1577 let dict = result.as_dict().expect("result dict");
1578 let stdout = dict.get("stdout").map(VmValue::display).unwrap_or_default();
1579 std::env::remove_var("PARENT_VAR");
1580 let (parent, child) = stdout.split_once('|').unwrap_or((&stdout, ""));
1581 (parent.to_string(), child.to_string())
1582 }
1583
1584 #[cfg(unix)]
1585 #[test]
1586 fn process_exec_env_default_merges_with_parent() {
1587 run_host_async_test(|| async {
1588 let child_env = VmValue::dict(crate::value::DictMap::from_iter([(
1591 "CHILD_VAR".to_string(),
1592 VmValue::String(std::sync::Arc::from("provided")),
1593 )]));
1594 let (parent, child) = process_exec_env_probe(child_env, None).await;
1595 assert_eq!(
1596 parent, "inherited",
1597 "default env_mode must inherit parent env"
1598 );
1599 assert_eq!(
1600 child, "provided",
1601 "default env_mode must apply provided keys"
1602 );
1603 });
1604 }
1605
1606 #[cfg(unix)]
1607 #[test]
1608 fn process_exec_env_mode_replace_clears_parent() {
1609 run_host_async_test(|| async {
1610 let child_env = VmValue::dict(crate::value::DictMap::from_iter([(
1614 "CHILD_VAR".to_string(),
1615 VmValue::String(std::sync::Arc::from("provided")),
1616 )]));
1617 let (parent, child) = process_exec_env_probe(child_env, Some("replace")).await;
1618 assert_eq!(parent, "", "explicit replace must clear parent env");
1619 assert_eq!(
1620 child, "provided",
1621 "explicit replace must keep provided keys"
1622 );
1623 });
1624 }
1625
1626 #[cfg(unix)]
1627 #[test]
1628 fn process_exec_env_mode_unknown_is_rejected() {
1629 run_host_async_test(|| async {
1630 let params = crate::value::DictMap::from_iter([
1631 (
1632 "mode".to_string(),
1633 VmValue::String(std::sync::Arc::from("argv")),
1634 ),
1635 (
1636 "argv".to_string(),
1637 VmValue::List(std::sync::Arc::new(vec![VmValue::String(
1638 std::sync::Arc::from("true"),
1639 )])),
1640 ),
1641 (
1642 "env".to_string(),
1643 VmValue::dict(crate::value::DictMap::from_iter([(
1644 "CHILD_VAR".to_string(),
1645 VmValue::String(std::sync::Arc::from("x")),
1646 )])),
1647 ),
1648 (
1649 "env_mode".to_string(),
1650 VmValue::String(std::sync::Arc::from("bogus")),
1651 ),
1652 ]);
1653 let err = super::dispatch_process_exec(¶ms, serde_json::Value::Null)
1654 .await
1655 .expect_err("unknown env_mode must error");
1656 assert!(
1657 format!("{err:?}").contains("env_mode"),
1658 "error should name env_mode, got {err:?}"
1659 );
1660 });
1661 }
1662
1663 #[test]
1664 fn host_tool_list_is_empty_without_bridge() {
1665 run_host_async_test(|| async {
1666 clear_host_call_bridge();
1667 let tools = dispatch_host_tool_list().await.expect("tool list");
1668 let VmValue::List(items) = tools else {
1669 panic!("expected tool list");
1670 };
1671 assert!(items.is_empty());
1672 });
1673 }
1674}