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 crate::value::intern_key("process"),
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 crate::value::intern_key("template"),
136 capability(
137 "Template rendering.",
138 &[op("render", "Render a template file.")],
139 ),
140 );
141 root.insert(
142 crate::value::intern_key("interaction"),
143 capability(
144 "User interaction.",
145 &[op("ask", "Ask the user a question.")],
146 ),
147 );
148 root.insert(
149 crate::value::intern_key("memory"),
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 crate::value::intern_key(capability_name),
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(arcstr::ArcStr::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(crate::value::intern_key(operation_name))
210 .or_insert_with(mocked_operation_entry);
211
212 entry.insert(
213 crate::value::intern_key("ops"),
214 VmValue::List(std::sync::Arc::new(ops)),
215 );
216 entry.insert(
217 crate::value::intern_key("operations"),
218 VmValue::dict(operations),
219 );
220 root.insert(
221 crate::value::intern_key(capability_name),
222 VmValue::dict(entry),
223 );
224}
225
226fn capability_manifest_with_mocks() -> VmValue {
227 let mut root = capability_manifest_map();
228 HOST_MOCKS.with(|mocks| {
229 for host_mock in mocks.borrow().iter() {
230 ensure_mocked_capability(&mut root, &host_mock.capability, &host_mock.operation);
231 }
232 });
233 VmValue::dict(root)
234}
235
236fn op(name: &str, description: &str) -> (String, VmValue) {
237 let mut entry = crate::value::DictMap::new();
238 entry.put_str("description", description);
239 (name.to_string(), VmValue::dict(entry))
240}
241
242fn capability(description: &str, ops: &[(String, VmValue)]) -> VmValue {
243 let mut entry = crate::value::DictMap::new();
244 entry.put_str("description", description);
245 entry.insert(
246 crate::value::intern_key("ops"),
247 VmValue::List(std::sync::Arc::new(
248 ops.iter()
249 .map(|(name, _)| VmValue::String(arcstr::ArcStr::from(name.as_str())))
250 .collect(),
251 )),
252 );
253 let mut op_dict = crate::value::DictMap::new();
254 for (name, op) in ops {
255 op_dict.insert(crate::value::intern_key(name), op.clone());
256 }
257 entry.insert(
258 crate::value::intern_key("operations"),
259 VmValue::dict(op_dict),
260 );
261 VmValue::dict(entry)
262}
263
264pub(crate) fn require_param(params: &crate::value::DictMap, key: &str) -> Result<String, VmError> {
265 params
266 .get(key)
267 .map(|v| v.display())
268 .filter(|v| !v.is_empty())
269 .ok_or_else(|| {
270 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
271 "host_call: missing required parameter '{key}'"
272 ))))
273 })
274}
275
276fn render_template(
277 path: &str,
278 bindings: Option<&crate::value::DictMap>,
279) -> Result<String, VmError> {
280 let asset = crate::stdlib::template::TemplateAsset::render_target(path).map_err(|msg| {
281 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
282 "host_call template.render: {msg}"
283 ))))
284 })?;
285 crate::stdlib::template::render_asset_result(&asset, bindings).map_err(VmError::from)
286}
287
288fn params_match(expected: Option<&crate::value::DictMap>, actual: &crate::value::DictMap) -> bool {
289 let Some(expected) = expected else {
290 return true;
291 };
292 expected.iter().all(|(key, value)| {
293 actual
294 .get(key)
295 .is_some_and(|candidate| values_equal(candidate, value))
296 })
297}
298
299fn parse_host_mock(args: &[VmValue]) -> Result<HostMock, VmError> {
300 let capability = args
301 .first()
302 .map(|value| value.display())
303 .unwrap_or_default();
304 let operation = args.get(1).map(|value| value.display()).unwrap_or_default();
305 if capability.is_empty() || operation.is_empty() {
306 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
307 "host_mock: capability and operation are required",
308 ))));
309 }
310
311 let mut params = args
312 .get(3)
313 .and_then(|value| value.as_dict())
314 .map(|dict| (*dict).clone());
315 let mut result = args.get(2).cloned().or(Some(VmValue::Nil));
316 let mut error = None;
317
318 if let Some(config) = args.get(2).and_then(|value| value.as_dict()) {
319 if config.contains_key("result")
320 || config.contains_key("params")
321 || config.contains_key("error")
322 {
323 params = config
324 .get("params")
325 .and_then(|value| value.as_dict())
326 .map(|dict| (*dict).clone());
327 result = config.get("result").cloned();
328 error = config
329 .get("error")
330 .map(|value| value.display())
331 .filter(|value| !value.is_empty());
332 }
333 }
334
335 Ok(HostMock {
336 capability,
337 operation,
338 params,
339 result,
340 error,
341 })
342}
343
344fn push_host_mock(host_mock: HostMock) {
345 HOST_MOCKS.with(|mocks| mocks.borrow_mut().push(host_mock));
346}
347
348fn mock_call_value(call: &HostMockCall) -> VmValue {
349 let mut item = crate::value::DictMap::new();
350 item.put_str("capability", call.capability.clone());
351 item.put_str("operation", call.operation.clone());
352 item.insert(
353 crate::value::intern_key("params"),
354 VmValue::dict(call.params.clone()),
355 );
356 VmValue::dict(item)
357}
358
359fn record_mock_call(capability: &str, operation: &str, params: &crate::value::DictMap) {
360 HOST_MOCK_CALLS.with(|calls| {
361 calls.borrow_mut().push(HostMockCall {
362 capability: capability.to_string(),
363 operation: operation.to_string(),
364 params: params.clone(),
365 });
366 });
367}
368
369pub(crate) fn dispatch_mock_host_call(
370 capability: &str,
371 operation: &str,
372 params: &crate::value::DictMap,
373) -> Option<Result<VmValue, VmError>> {
374 let matched = HOST_MOCKS.with(|mocks| {
375 mocks
376 .borrow()
377 .iter()
378 .rev()
379 .find(|host_mock| {
380 host_mock.capability == capability
381 && host_mock.operation == operation
382 && params_match(host_mock.params.as_ref(), params)
383 })
384 .cloned()
385 })?;
386
387 record_mock_call(capability, operation, params);
388 if let Some(error) = matched.error {
389 return Some(Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
390 error,
391 )))));
392 }
393 Some(Ok(matched.result.unwrap_or(VmValue::Nil)))
394}
395
396pub fn dispatch_mock_hostlib_call(
407 module: &str,
408 method: &str,
409 params: &crate::value::DictMap,
410) -> Option<Result<VmValue, VmError>> {
411 if let Some(mocked) = dispatch_mock_host_call(module, method, params) {
412 return Some(mocked);
413 }
414
415 if (module, method) == ("tools", "run_command") {
416 return dispatch_mock_host_call("process", "exec", params);
417 }
418
419 None
420}
421
422pub trait HostCallBridge: Send + Sync {
437 fn dispatch(
438 &self,
439 capability: &str,
440 operation: &str,
441 params: &crate::value::DictMap,
442 ) -> Result<Option<VmValue>, VmError>;
443
444 fn list_tools(&self) -> Result<Option<VmValue>, VmError> {
445 Ok(None)
446 }
447
448 fn call_tool(&self, _name: &str, _args: &VmValue) -> Result<Option<VmValue>, VmError> {
449 Ok(None)
450 }
451}
452
453thread_local! {
454 static HOST_CALL_BRIDGE: RefCell<Option<Arc<dyn HostCallBridge>>> = const { RefCell::new(None) };
455}
456
457pub fn set_host_call_bridge(bridge: Arc<dyn HostCallBridge>) {
462 HOST_CALL_BRIDGE.with(|b| *b.borrow_mut() = Some(bridge));
463}
464
465pub fn clear_host_call_bridge() {
467 HOST_CALL_BRIDGE.with(|b| *b.borrow_mut() = None);
468}
469
470pub fn dispatch_host_call_bridge(
480 capability: &str,
481 operation: &str,
482 params: &crate::value::DictMap,
483) -> Option<Result<VmValue, VmError>> {
484 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone())?;
485 match bridge.dispatch(capability, operation, params) {
486 Ok(Some(value)) => Some(Ok(value)),
487 Ok(None) => None,
488 Err(error) => Some(Err(error)),
489 }
490}
491
492fn empty_tool_list_value() -> VmValue {
493 VmValue::List(std::sync::Arc::new(Vec::new()))
494}
495
496fn current_vm_host_bridge(
497 ctx: Option<&AsyncBuiltinCtx>,
498) -> Option<std::sync::Arc<crate::bridge::HostBridge>> {
499 ctx.and_then(|ctx| ctx.child_vm().bridge.clone())
500}
501
502#[cfg(test)]
503async fn dispatch_host_tool_list() -> Result<VmValue, VmError> {
504 dispatch_host_tool_list_with_ctx(None).await
505}
506
507async fn dispatch_host_tool_list_with_ctx(
508 ctx: Option<&AsyncBuiltinCtx>,
509) -> Result<VmValue, VmError> {
510 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
511 if let Some(bridge) = bridge {
512 if let Some(value) = bridge.list_tools()? {
513 return Ok(value);
514 }
515 }
516
517 let Some(bridge) = current_vm_host_bridge(ctx) else {
518 return Ok(empty_tool_list_value());
519 };
520 let tools = bridge.list_host_tools().await?;
521 Ok(crate::bridge::json_result_to_vm_value(&JsonValue::Array(
522 tools.into_iter().collect(),
523 )))
524}
525
526pub(crate) async fn dispatch_host_tool_call(
527 name: &str,
528 args: &VmValue,
529) -> Result<VmValue, VmError> {
530 dispatch_host_tool_call_with_ctx(None, name, args).await
531}
532
533pub(crate) async fn dispatch_host_tool_call_with_ctx(
534 ctx: Option<&AsyncBuiltinCtx>,
535 name: &str,
536 args: &VmValue,
537) -> Result<VmValue, VmError> {
538 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
539 if let Some(bridge) = bridge {
540 if let Some(value) = bridge.call_tool(name, args)? {
541 return Ok(value);
542 }
543 }
544
545 let Some(bridge) = current_vm_host_bridge(ctx) else {
546 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
547 "host_tool_call: no host bridge is attached",
548 ))));
549 };
550
551 let result = bridge
552 .call(
553 "builtin_call",
554 serde_json::json!({
555 "name": name,
556 "args": [crate::llm::vm_value_to_json(args)],
557 }),
558 )
559 .await?;
560 Ok(crate::bridge::json_result_to_vm_value(&result))
561}
562
563pub(crate) async fn dispatch_host_operation(
564 capability: &str,
565 operation: &str,
566 params: &crate::value::DictMap,
567) -> Result<VmValue, VmError> {
568 dispatch_host_operation_with_ctx(None, capability, operation, params).await
569}
570
571pub(crate) async fn dispatch_host_operation_with_ctx(
572 ctx: Option<&AsyncBuiltinCtx>,
573 capability: &str,
574 operation: &str,
575 params: &crate::value::DictMap,
576) -> Result<VmValue, VmError> {
577 if let Some(mocked) = dispatch_mock_host_call(capability, operation, params) {
578 return mocked;
579 }
580
581 if (capability, operation) == ("process", "exec") {
582 let caller = serde_json::json!({
583 "surface": "host_call",
584 "capability": "process",
585 "operation": "exec",
586 "session_id": crate::llm::current_agent_session_id(),
587 });
588 return dispatch_process_exec_with_policy(ctx, params, caller).await;
589 }
590
591 if (capability, operation) == ("process", "spawn") {
598 let caller = serde_json::json!({
599 "surface": "host_call",
600 "capability": "process",
601 "operation": "spawn",
602 "session_id": crate::llm::current_agent_session_id(),
603 });
604 return dispatch_process_spawn_with_policy(ctx, params, caller).await;
605 }
606 if capability == "process" && matches!(operation, "poll" | "wait" | "kill" | "release") {
607 if let Some(result) = crate::stdlib::process_spawn::dispatch(operation, params).await {
608 return result;
609 }
610 }
611
612 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
613 if let Some(bridge) = bridge {
614 if let Some(value) = bridge.dispatch(capability, operation, params)? {
615 return Ok(value);
616 }
617 }
618
619 dispatch_builtin_host_operation(capability, operation, params).await
620}
621
622async fn dispatch_builtin_host_operation(
623 capability: &str,
624 operation: &str,
625 params: &crate::value::DictMap,
626) -> Result<VmValue, VmError> {
627 match (capability, operation) {
628 ("process", "list_shells") => Ok(crate::shells::list_shells_vm_value()),
629 ("process", "get_default_shell") => Ok(crate::shells::default_shell_vm_value()),
630 ("process", "set_default_shell") => crate::shells::set_default_shell_vm_value(params),
631 ("process", "shell_invocation") => crate::shells::shell_invocation_vm_value(params),
632 ("template", "render") => {
633 let path = require_param(params, "path")?;
634 let bindings = params.get("bindings").and_then(|v| v.as_dict());
635 Ok(VmValue::String(arcstr::ArcStr::from(render_template(
636 &path, bindings,
637 )?)))
638 }
639 ("interaction", "ask") => {
640 let question = require_param(params, "question")?;
641 use std::io::BufRead;
642 print!("{question}");
643 let _ = std::io::Write::flush(&mut std::io::stdout());
644 let mut input = String::new();
645 if std::io::stdin().lock().read_line(&mut input).is_ok() {
646 Ok(VmValue::String(arcstr::ArcStr::from(input.trim_end())))
647 } else {
648 Ok(VmValue::Nil)
649 }
650 }
651 ("project", "metadata_get") => crate::metadata::project_metadata_host_get(params),
652 ("project", "metadata_inspect") => crate::metadata::project_metadata_host_inspect(params),
653 ("project", "metadata_set") => crate::metadata::project_metadata_host_set(params),
654 ("project", "metadata_save") => crate::metadata::project_metadata_host_save(params),
655 ("project", "metadata_stale") => crate::metadata::project_metadata_host_stale(params),
656 ("project", "metadata_refresh_hashes") => {
657 crate::metadata::project_metadata_host_refresh_hashes(params)
658 }
659 ("runtime", "task") => Ok(VmValue::String(arcstr::ArcStr::from(
664 std::env::var("HARN_TASK").unwrap_or_default(),
665 ))),
666 ("runtime", "set_result") => {
667 Ok(VmValue::Nil)
670 }
671 ("workspace", "project_root") => {
672 let path = crate::stdlib::process::project_root_path()
677 .map(|root| root.display().to_string())
678 .or_else(|| std::env::var("HARN_PROJECT_ROOT").ok())
679 .unwrap_or_else(|| {
680 std::env::current_dir()
681 .map(|p| p.display().to_string())
682 .unwrap_or_default()
683 });
684 Ok(VmValue::String(arcstr::ArcStr::from(path)))
685 }
686 ("workspace", "cwd") => {
687 let path = std::env::current_dir()
688 .map(|p| p.display().to_string())
689 .unwrap_or_default();
690 Ok(VmValue::String(arcstr::ArcStr::from(path)))
691 }
692 _ => Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
693 format!("host_call: unsupported operation {capability}.{operation}"),
694 )))),
695 }
696}
697
698pub(crate) async fn dispatch_process_exec(
699 params: &crate::value::DictMap,
700 caller: serde_json::Value,
701) -> Result<VmValue, VmError> {
702 dispatch_process_exec_with_policy(None, params, caller).await
703}
704
705async fn dispatch_process_exec_with_policy(
706 ctx: Option<&AsyncBuiltinCtx>,
707 params: &crate::value::DictMap,
708 caller: serde_json::Value,
709) -> Result<VmValue, VmError> {
710 let (params, command_policy_context, command_policy_decisions) =
711 match crate::orchestration::run_command_policy_preflight_with_ctx(ctx, params, caller)
712 .await?
713 {
714 crate::orchestration::CommandPolicyPreflight::Proceed {
715 params,
716 context,
717 decisions,
718 } => (params, context, decisions),
719 crate::orchestration::CommandPolicyPreflight::Blocked {
720 status,
721 message,
722 context,
723 decisions,
724 } => {
725 return Ok(crate::orchestration::blocked_command_response(
726 params, status, &message, context, decisions,
727 ));
728 }
729 };
730
731 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
732 if let Some(bridge) = bridge {
733 if let Some(value) = bridge.dispatch("process", "exec", ¶ms)? {
734 return crate::orchestration::run_command_policy_postflight_with_ctx(
735 ctx,
736 ¶ms,
737 value,
738 command_policy_context,
739 command_policy_decisions,
740 )
741 .await;
742 }
743 }
744
745 dispatch_process_exec_after_policy(
746 ctx,
747 ¶ms,
748 command_policy_context,
749 command_policy_decisions,
750 )
751 .await
752}
753
754async fn dispatch_process_spawn_with_policy(
761 ctx: Option<&AsyncBuiltinCtx>,
762 params: &crate::value::DictMap,
763 caller: serde_json::Value,
764) -> Result<VmValue, VmError> {
765 let params =
766 match crate::orchestration::run_command_policy_preflight_with_ctx(ctx, params, caller)
767 .await?
768 {
769 crate::orchestration::CommandPolicyPreflight::Proceed { params, .. } => params,
770 crate::orchestration::CommandPolicyPreflight::Blocked {
771 status,
772 message,
773 context,
774 decisions,
775 } => {
776 return Ok(crate::orchestration::blocked_command_response(
777 params, status, &message, context, decisions,
778 ));
779 }
780 };
781
782 match crate::stdlib::process_spawn::dispatch("spawn", ¶ms).await {
783 Some(result) => result,
784 None => Err(VmError::Runtime(
785 "host_call process.spawn: dispatch returned None".to_string(),
786 )),
787 }
788}
789
790async fn dispatch_process_exec_after_policy(
791 ctx: Option<&AsyncBuiltinCtx>,
792 params: &crate::value::DictMap,
793 command_policy_context: JsonValue,
794 command_policy_decisions: Vec<crate::orchestration::CommandPolicyDecision>,
795) -> Result<VmValue, VmError> {
796 let timeout_ms = optional_i64(params, "timeout")
797 .or_else(|| optional_i64(params, "timeout_ms"))
798 .filter(|value| *value > 0)
799 .map(|value| value as u64);
800 let profile_guard = match optional_string(params, "sandbox_profile") {
806 Some(value) => Some(push_sandbox_profile_override(&value)?),
807 None => None,
808 };
809 let mut cmd = build_sandboxed_command(params, "process.exec")?;
810 cmd.stdin(std::process::Stdio::null())
811 .stdout(std::process::Stdio::piped())
812 .stderr(std::process::Stdio::piped())
813 .kill_on_drop(true);
814 let started_at = audited_utc_now_rfc3339("host_call/process.exec.started_at");
815 let started = crate::clock_mock::leak_audit::instant_now("host_call/process.exec.started");
816 let child = cmd
817 .spawn()
818 .map_err(|e| VmError::Runtime(format!("host_call process.exec: {e}")))?;
819 drop(profile_guard);
820 let pid = child.id();
821 let timed_out;
822 let output_result = if let Some(timeout_ms) = timeout_ms {
823 match tokio::time::timeout(
824 std::time::Duration::from_millis(timeout_ms),
825 child.wait_with_output(),
826 )
827 .await
828 {
829 Ok(result) => {
830 timed_out = false;
831 result
832 }
833 Err(_) => {
834 let response = process_exec_response(ProcessExecResponse {
835 pid,
836 started_at,
837 started,
838 stdout: "",
839 stderr: "",
840 exit_code: -1,
841 status: "timed_out",
842 success: false,
843 timed_out: true,
844 });
845 return crate::orchestration::run_command_policy_postflight_with_ctx(
846 ctx,
847 params,
848 response,
849 command_policy_context,
850 command_policy_decisions,
851 )
852 .await;
853 }
854 }
855 } else {
856 timed_out = false;
857 child.wait_with_output().await
858 };
859 let output =
860 output_result.map_err(|e| VmError::Runtime(format!("host_call process.exec: {e}")))?;
861 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
862 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
863 let exit_code = output.status.code().unwrap_or(-1);
864 let response = process_exec_response(ProcessExecResponse {
865 pid,
866 started_at,
867 started,
868 stdout: &stdout,
869 stderr: &stderr,
870 exit_code,
871 status: if timed_out { "timed_out" } else { "completed" },
872 success: output.status.success(),
873 timed_out,
874 });
875 crate::orchestration::run_command_policy_postflight_with_ctx(
876 ctx,
877 params,
878 response,
879 command_policy_context,
880 command_policy_decisions,
881 )
882 .await
883}
884
885pub(crate) fn build_sandboxed_command(
897 params: &crate::value::DictMap,
898 label: &str,
899) -> Result<tokio::process::Command, VmError> {
900 let (program, args) = process_exec_argv(params)?;
901 let mut cmd = crate::process_sandbox::tokio_command_for(&program, &args)
902 .map_err(|e| VmError::Runtime(format!("host_call {label} sandbox setup: {e}")))?;
903 if let Some(cwd) = optional_string(params, "cwd") {
904 let cwd = resolve_process_exec_cwd(&cwd);
905 crate::process_sandbox::enforce_process_cwd(&cwd)
906 .map_err(|e| VmError::Runtime(format!("host_call {label} cwd: {e}")))?;
907 cmd.current_dir(cwd);
908 }
909 let mut caller_env_keys: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
912 if let Some(env) = optional_string_dict(params, "env")? {
913 let env_mode = optional_string(params, "env_mode");
922 match env_mode.as_deref().unwrap_or("merge") {
923 "replace" => {
924 cmd.env_clear();
925 }
926 "merge" => {}
927 other => {
928 return Err(VmError::Runtime(format!(
929 "host_call {label}: unknown env_mode {other:?}; expected \"merge\" or \"replace\""
930 )));
931 }
932 }
933 for (key, value) in env {
934 caller_env_keys.insert(key.clone());
935 cmd.env(key, value);
936 }
937 }
938 if let Some(env_remove) = optional_string_list(params, "env_remove") {
944 for key in env_remove {
945 caller_env_keys.insert(key.clone());
946 cmd.env_remove(key);
947 }
948 }
949 for (key, value) in crate::process_sandbox::active_workspace_tmpdir_env() {
958 if caller_env_keys.contains(&key) {
959 continue;
960 }
961 cmd.env(key, value);
962 }
963 if !caller_env_keys.contains(crate::process_sandbox::MESSAGE_LOCALE_OVERRIDE_ENV) {
971 cmd.env_remove(crate::process_sandbox::MESSAGE_LOCALE_OVERRIDE_ENV);
972 }
973 for (key, value) in crate::process_sandbox::deterministic_message_locale_env() {
974 if caller_env_keys.contains(&key) {
975 continue;
976 }
977 cmd.env(key, value);
978 }
979 Ok(cmd)
980}
981
982struct ProcessExecResponse<'a> {
983 pid: Option<u32>,
984 started_at: String,
985 started: Instant,
986 stdout: &'a str,
987 stderr: &'a str,
988 exit_code: i32,
989 status: &'a str,
990 success: bool,
991 timed_out: bool,
992}
993
994fn process_exec_response(response: ProcessExecResponse<'_>) -> VmValue {
995 let combined = format!("{}{}", response.stdout, response.stderr);
996 let mut result = crate::value::DictMap::new();
997 result.put_str(
998 "command_id",
999 format!(
1000 "cmd_{}_{}",
1001 std::process::id(),
1002 response.started.elapsed().as_nanos()
1003 ),
1004 );
1005 result.put_str("status", response.status);
1006 result.insert(
1007 crate::value::intern_key("pid"),
1008 response
1009 .pid
1010 .map(|pid| VmValue::Int(pid as i64))
1011 .unwrap_or(VmValue::Nil),
1012 );
1013 result.insert(
1014 crate::value::intern_key("process_group_id"),
1015 response
1016 .pid
1017 .map(|pid| VmValue::Int(pid as i64))
1018 .unwrap_or(VmValue::Nil),
1019 );
1020 result.insert(crate::value::intern_key("handle_id"), VmValue::Nil);
1021 result.put_str("started_at", response.started_at);
1022 result.put_str(
1023 "ended_at",
1024 audited_utc_now_rfc3339("host_call/process.exec.ended_at"),
1025 );
1026 result.insert(
1027 crate::value::intern_key("duration_ms"),
1028 VmValue::Int(response.started.elapsed().as_millis() as i64),
1029 );
1030 result.insert(
1031 crate::value::intern_key("exit_code"),
1032 VmValue::Int(response.exit_code as i64),
1033 );
1034 result.insert(crate::value::intern_key("signal"), VmValue::Nil);
1035 result.insert(
1036 crate::value::intern_key("timed_out"),
1037 VmValue::Bool(response.timed_out),
1038 );
1039 result.put_str("stdout", response.stdout);
1040 result.put_str("stderr", response.stderr);
1041 result.put_str("combined", combined);
1042 result.insert(
1043 crate::value::intern_key("exit_status"),
1044 VmValue::Int(response.exit_code as i64),
1045 );
1046 result.insert(
1047 crate::value::intern_key("legacy_status"),
1048 VmValue::Int(response.exit_code as i64),
1049 );
1050 result.insert(
1051 crate::value::intern_key("success"),
1052 VmValue::Bool(response.success),
1053 );
1054 VmValue::dict(result)
1055}
1056
1057fn resolve_process_exec_cwd(cwd: &str) -> std::path::PathBuf {
1058 crate::stdlib::process::resolve_source_relative_path(cwd)
1059}
1060
1061fn process_exec_argv(params: &crate::value::DictMap) -> Result<(String, Vec<String>), VmError> {
1062 match optional_string(params, "mode")
1063 .as_deref()
1064 .unwrap_or("shell")
1065 {
1066 "argv" => {
1067 let argv = optional_string_list(params, "argv").ok_or_else(|| {
1068 VmError::Runtime("host_call process.exec missing argv".to_string())
1069 })?;
1070 split_argv(argv)
1071 }
1072 "shell" => {
1073 let command = require_param(params, "command")?;
1074 let mut invocation_params = params.clone();
1075 invocation_params.put_str("command", command);
1076 let invocation =
1077 crate::shells::resolve_invocation_from_vm_params(&invocation_params)
1078 .map_err(|err| VmError::Runtime(format!("host_call process.exec: {err}")))?;
1079 Ok((invocation.program, invocation.args))
1080 }
1081 other => Err(VmError::Runtime(format!(
1082 "host_call process.exec unsupported mode {other:?}"
1083 ))),
1084 }
1085}
1086
1087fn split_argv(mut argv: Vec<String>) -> Result<(String, Vec<String>), VmError> {
1088 if argv.is_empty() {
1089 return Err(VmError::Runtime(
1090 "host_call process.exec argv must not be empty".to_string(),
1091 ));
1092 }
1093 let program = argv.remove(0);
1094 if program.is_empty() {
1095 return Err(VmError::Runtime(
1096 "host_call process.exec argv[0] must not be empty".to_string(),
1097 ));
1098 }
1099 Ok((program, argv))
1100}
1101
1102pub(crate) fn push_sandbox_profile_override(value: &str) -> Result<SandboxProfileGuard, VmError> {
1108 let profile = crate::orchestration::SandboxProfile::parse(value).ok_or_else(|| {
1109 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
1110 "host_call process.exec: unknown sandbox_profile {value:?}; expected one of \"unrestricted\", \"worktree\", \"os_hardened\", \"wasi\""
1111 ))))
1112 })?;
1113 let mut policy = crate::orchestration::current_execution_policy().unwrap_or_default();
1114 policy.sandbox_profile = profile;
1115 crate::orchestration::push_execution_policy(policy);
1116 Ok(SandboxProfileGuard {
1117 _private: std::marker::PhantomData,
1118 })
1119}
1120
1121pub(crate) struct SandboxProfileGuard {
1122 _private: std::marker::PhantomData<*const ()>,
1123}
1124
1125impl Drop for SandboxProfileGuard {
1126 fn drop(&mut self) {
1127 crate::orchestration::pop_execution_policy();
1128 }
1129}
1130
1131pub(crate) fn optional_i64(params: &crate::value::DictMap, key: &str) -> Option<i64> {
1132 match params.get(key) {
1133 Some(VmValue::Int(value)) => Some(*value),
1134 Some(VmValue::Float(value)) if value.fract() == 0.0 => Some(*value as i64),
1135 _ => None,
1136 }
1137}
1138
1139pub(crate) fn optional_string(params: &crate::value::DictMap, key: &str) -> Option<String> {
1140 params.get(key).and_then(vm_string).map(ToString::to_string)
1141}
1142
1143fn optional_string_list(params: &crate::value::DictMap, key: &str) -> Option<Vec<String>> {
1144 let VmValue::List(values) = params.get(key)? else {
1145 return None;
1146 };
1147 values
1148 .iter()
1149 .map(|value| vm_string(value).map(ToString::to_string))
1150 .collect()
1151}
1152
1153fn optional_string_dict(
1154 params: &crate::value::DictMap,
1155 key: &str,
1156) -> Result<Option<BTreeMap<String, String>>, VmError> {
1157 let Some(value) = params.get(key) else {
1158 return Ok(None);
1159 };
1160 let Some(dict) = value.as_dict() else {
1161 return Err(VmError::Runtime(format!(
1162 "host_call process.exec {key} must be a dict"
1163 )));
1164 };
1165 let mut out = std::collections::BTreeMap::new();
1166 for (key, value) in dict.iter() {
1167 let Some(value) = vm_string(value) else {
1168 return Err(VmError::Runtime(format!(
1169 "host_call process.exec env value for {key:?} must be a string"
1170 )));
1171 };
1172 out.insert(key.to_string(), value.to_string());
1173 }
1174 Ok(Some(out))
1175}
1176
1177fn vm_string(value: &VmValue) -> Option<&str> {
1178 match value {
1179 VmValue::String(value) => Some(value.as_ref()),
1180 _ => None,
1181 }
1182}
1183
1184pub(crate) fn register_host_builtins(vm: &mut Vm) {
1185 for def in MODULE_BUILTINS {
1186 vm.register_builtin_def(def);
1187 }
1188}
1189
1190pub(crate) fn register_missing_host_builtins(vm: &mut Vm) {
1191 for def in MODULE_BUILTINS {
1192 if vm.builtin_metadata_for(def.sig.name).is_none() {
1193 vm.register_builtin_def(def);
1194 }
1195 }
1196}
1197
1198#[harn_builtin(
1199 sig = "host_mock(capability: string, op: string, response_or_config?: any, params?: dict) -> nil",
1200 category = "host"
1201)]
1202fn host_mock_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1203 let host_mock = parse_host_mock(args)?;
1204 push_host_mock(host_mock);
1205 Ok(VmValue::Nil)
1206}
1207
1208#[harn_builtin(sig = "host_mock_clear() -> nil", category = "host")]
1209fn host_mock_clear_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1210 reset_host_state();
1211 Ok(VmValue::Nil)
1212}
1213
1214#[harn_builtin(sig = "host_mock_calls() -> list", category = "host")]
1215fn host_mock_calls_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1216 let calls = HOST_MOCK_CALLS.with(|calls| {
1217 calls
1218 .borrow()
1219 .iter()
1220 .map(mock_call_value)
1221 .collect::<Vec<_>>()
1222 });
1223 Ok(VmValue::List(std::sync::Arc::new(calls)))
1224}
1225
1226#[harn_builtin(sig = "host_mock_push_scope() -> nil", category = "host")]
1227fn host_mock_push_scope_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1228 push_host_mock_scope();
1229 Ok(VmValue::Nil)
1230}
1231
1232#[harn_builtin(sig = "host_mock_pop_scope() -> nil", category = "host")]
1233fn host_mock_pop_scope_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1234 if !pop_host_mock_scope() {
1235 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1236 "host_mock_pop_scope: no scope to pop",
1237 ))));
1238 }
1239 Ok(VmValue::Nil)
1240}
1241
1242#[harn_builtin(sig = "host_capabilities() -> dict", category = "host")]
1243fn host_capabilities_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1244 Ok(capability_manifest_with_mocks())
1245}
1246
1247#[harn_builtin(
1248 sig = "host_has(capability: string, op?: string) -> bool",
1249 category = "host"
1250)]
1251fn host_has_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1252 let capability = args.first().map(|a| a.display()).unwrap_or_default();
1253 let operation = args.get(1).map(|a| a.display());
1254 let manifest = capability_manifest_with_mocks();
1255 let has = manifest
1256 .as_dict()
1257 .and_then(|d| d.get(capability.as_str()))
1258 .and_then(|v| v.as_dict())
1259 .is_some_and(|cap| {
1260 if let Some(operation) = operation {
1261 cap.get("ops")
1262 .and_then(|v| match v {
1263 VmValue::List(list) => {
1264 Some(list.iter().any(|item| item.display() == operation))
1265 }
1266 _ => None,
1267 })
1268 .unwrap_or(false)
1269 } else {
1270 true
1271 }
1272 });
1273 Ok(VmValue::Bool(has))
1274}
1275
1276#[harn_builtin(
1277 sig = "host_call(name: string, args?: dict) -> any",
1278 kind = "async",
1279 category = "host"
1280)]
1281async fn host_call_builtin(
1282 ctx: crate::vm::AsyncBuiltinCtx,
1283 args: Vec<VmValue>,
1284) -> Result<VmValue, VmError> {
1285 let name = args.first().map(|a| a.display()).unwrap_or_default();
1286 let params = args
1287 .get(1)
1288 .and_then(|a| a.as_dict())
1289 .cloned()
1290 .unwrap_or_default();
1291 let Some((capability, operation)) = name.split_once('.') else {
1292 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1293 format!("host_call: unsupported operation name '{name}'"),
1294 ))));
1295 };
1296 dispatch_host_operation_with_ctx(Some(&ctx), capability, operation, ¶ms).await
1297}
1298
1299#[harn_builtin(sig = "host_tool_list() -> list", kind = "async", category = "host")]
1300async fn host_tool_list_builtin(
1301 ctx: crate::vm::AsyncBuiltinCtx,
1302 _args: Vec<VmValue>,
1303) -> Result<VmValue, VmError> {
1304 dispatch_host_tool_list_with_ctx(Some(&ctx)).await
1305}
1306
1307#[harn_builtin(
1308 sig = "host_tool_call(name: string, args?: any) -> any",
1309 kind = "async",
1310 category = "host"
1311)]
1312async fn host_tool_call_builtin(
1313 ctx: crate::vm::AsyncBuiltinCtx,
1314 args: Vec<VmValue>,
1315) -> Result<VmValue, VmError> {
1316 let name = args.first().map(|a| a.display()).unwrap_or_default();
1317 if name.is_empty() {
1318 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1319 "host_tool_call: tool name is required",
1320 ))));
1321 }
1322 let call_args = args.get(1).cloned().unwrap_or(VmValue::Nil);
1323 dispatch_host_tool_call_with_ctx(Some(&ctx), &name, &call_args).await
1324}
1325
1326#[cfg(test)]
1327mod tests {
1328 use super::{
1329 build_sandboxed_command, capability_manifest_with_mocks, clear_host_call_bridge,
1330 dispatch_host_operation, dispatch_host_tool_call, dispatch_host_tool_list,
1331 dispatch_mock_host_call, dispatch_mock_hostlib_call, push_host_mock, reset_host_state,
1332 resolve_process_exec_cwd, set_host_call_bridge, HostCallBridge, HostMock,
1333 };
1334 use crate::value::VmDictExt;
1335
1336 use std::sync::{
1337 atomic::{AtomicUsize, Ordering},
1338 Arc,
1339 };
1340
1341 use crate::value::{VmError, VmValue};
1342
1343 fn command_env(
1347 cmd: &tokio::process::Command,
1348 ) -> std::collections::BTreeMap<String, Option<String>> {
1349 cmd.as_std()
1350 .get_envs()
1351 .map(|(k, v)| {
1352 (
1353 k.to_string_lossy().into_owned(),
1354 v.map(|value| value.to_string_lossy().into_owned()),
1355 )
1356 })
1357 .collect()
1358 }
1359
1360 #[test]
1361 fn build_sandboxed_command_forces_deterministic_message_locale() {
1362 let mut params = crate::value::DictMap::new();
1372 params.put_str("mode", "argv");
1373 params.put(
1374 "argv",
1375 VmValue::List(Arc::new(vec![VmValue::string("/bin/true")])),
1376 );
1377 params.put_str("env_mode", "merge");
1378 let mut caller_env = crate::value::DictMap::new();
1379 caller_env.put_str("CARGO_TARGET_DIR", "/tmp/target");
1381 params.put("env", VmValue::dict_map(caller_env));
1382
1383 let cmd = build_sandboxed_command(¶ms, "process.exec").expect("build command");
1384 let env = command_env(&cmd);
1385
1386 assert_eq!(
1387 env.get("LC_ALL"),
1388 Some(&None),
1389 "the builder must remove LC_ALL from the child so an inherited shell \
1390 value cannot override the forced LC_MESSAGES"
1391 );
1392 assert_eq!(
1393 env.get("LC_MESSAGES"),
1394 Some(&Some("C".to_string())),
1395 "LC_MESSAGES must be pinned to C for untranslated (English) tool output"
1396 );
1397 assert_eq!(
1398 env.get("DOTNET_CLI_UI_LANGUAGE"),
1399 Some(&Some("en".to_string())),
1400 ".NET ignores LC_* and needs its own UI-language override"
1401 );
1402 }
1403
1404 #[test]
1405 fn build_sandboxed_command_respects_a_caller_pinned_locale() {
1406 let mut params = crate::value::DictMap::new();
1409 params.put_str("mode", "argv");
1410 params.put(
1411 "argv",
1412 VmValue::List(Arc::new(vec![VmValue::string("/bin/true")])),
1413 );
1414 params.put_str("env_mode", "merge");
1415 let mut caller_env = crate::value::DictMap::new();
1416 caller_env.put_str("LC_ALL", "fr_FR.UTF-8");
1417 caller_env.put_str("LC_MESSAGES", "fr_FR.UTF-8");
1418 params.put("env", VmValue::dict_map(caller_env));
1419
1420 let cmd = build_sandboxed_command(¶ms, "process.exec").expect("build command");
1421 let env = command_env(&cmd);
1422
1423 assert_eq!(
1424 env.get("LC_ALL"),
1425 Some(&Some("fr_FR.UTF-8".to_string())),
1426 "a caller that pins LC_ALL keeps it — the overlay must not strip an explicit value"
1427 );
1428 assert_eq!(
1429 env.get("LC_MESSAGES"),
1430 Some(&Some("fr_FR.UTF-8".to_string())),
1431 "a caller-pinned LC_MESSAGES wins over the C overlay"
1432 );
1433 }
1434
1435 #[test]
1436 fn process_exec_relative_cwd_resolves_against_execution_root() {
1437 let dir = tempfile::tempdir().expect("tempdir");
1438 crate::stdlib::process::set_thread_execution_context(Some(
1439 crate::orchestration::RunExecutionRecord {
1440 cwd: Some(dir.path().to_string_lossy().into_owned()),
1441 project_root: None,
1442 source_dir: Some(dir.path().join("src").to_string_lossy().into_owned()),
1443 env: std::collections::BTreeMap::new(),
1444 adapter: None,
1445 repo_path: None,
1446 worktree_path: None,
1447 branch: None,
1448 base_ref: None,
1449 cleanup: None,
1450 },
1451 ));
1452
1453 assert_eq!(
1454 resolve_process_exec_cwd("subdir"),
1455 dir.path().join("subdir")
1456 );
1457
1458 crate::stdlib::process::set_thread_execution_context(None);
1459 }
1460
1461 #[test]
1462 fn workspace_project_root_fallback_prefers_execution_context_project_root() {
1463 run_host_async_test(|| async {
1464 let project = tempfile::tempdir().expect("project root");
1465 let cwd = tempfile::tempdir().expect("cwd");
1466 crate::stdlib::process::set_thread_execution_context(Some(
1467 crate::orchestration::RunExecutionRecord {
1468 cwd: Some(cwd.path().to_string_lossy().into_owned()),
1469 project_root: Some(project.path().to_string_lossy().into_owned()),
1470 source_dir: None,
1471 env: std::collections::BTreeMap::new(),
1472 adapter: None,
1473 repo_path: None,
1474 worktree_path: None,
1475 branch: None,
1476 base_ref: None,
1477 cleanup: None,
1478 },
1479 ));
1480
1481 let result =
1482 dispatch_host_operation("workspace", "project_root", &crate::value::DictMap::new())
1483 .await
1484 .expect("workspace.project_root result");
1485
1486 crate::stdlib::process::set_thread_execution_context(None);
1487 assert_eq!(result.display(), project.path().display().to_string());
1488 });
1489 }
1490
1491 #[test]
1492 fn manifest_includes_operation_metadata() {
1493 let manifest = capability_manifest_with_mocks();
1494 let process = manifest
1495 .as_dict()
1496 .and_then(|d| d.get("process"))
1497 .and_then(|v| v.as_dict())
1498 .expect("process capability");
1499 assert!(process.get("description").is_some());
1500 let operations = process
1501 .get("operations")
1502 .and_then(|v| v.as_dict())
1503 .expect("operations dict");
1504 assert!(operations.get("exec").is_some());
1505 }
1506
1507 #[test]
1508 fn mocked_capabilities_appear_in_manifest() {
1509 reset_host_state();
1510 push_host_mock(HostMock {
1511 capability: "project".to_string(),
1512 operation: "metadata_get".to_string(),
1513 params: None,
1514 result: Some(VmValue::dict(crate::value::DictMap::new())),
1515 error: None,
1516 });
1517 let manifest = capability_manifest_with_mocks();
1518 let project = manifest
1519 .as_dict()
1520 .and_then(|d| d.get("project"))
1521 .and_then(|v| v.as_dict())
1522 .expect("project capability");
1523 let operations = project
1524 .get("operations")
1525 .and_then(|v| v.as_dict())
1526 .expect("operations dict");
1527 assert!(operations.get("metadata_get").is_some());
1528 reset_host_state();
1529 }
1530
1531 #[test]
1532 fn mock_host_call_matches_partial_params_and_overrides_order() {
1533 reset_host_state();
1534 let mut exact_params = crate::value::DictMap::new();
1535 exact_params.put_str("namespace", "facts");
1536 push_host_mock(HostMock {
1537 capability: "project".to_string(),
1538 operation: "metadata_get".to_string(),
1539 params: None,
1540 result: Some(VmValue::String(arcstr::ArcStr::from("fallback"))),
1541 error: None,
1542 });
1543 push_host_mock(HostMock {
1544 capability: "project".to_string(),
1545 operation: "metadata_get".to_string(),
1546 params: Some(exact_params),
1547 result: Some(VmValue::String(arcstr::ArcStr::from("facts"))),
1548 error: None,
1549 });
1550
1551 let mut call_params = crate::value::DictMap::new();
1552 call_params.put_str("dir", "pkg");
1553 call_params.put_str("namespace", "facts");
1554 let exact = dispatch_mock_host_call("project", "metadata_get", &call_params)
1555 .expect("expected exact mock")
1556 .expect("exact mock should succeed");
1557 assert_eq!(exact.display(), "facts");
1558
1559 call_params.put_str("namespace", "classification");
1560 let fallback = dispatch_mock_host_call("project", "metadata_get", &call_params)
1561 .expect("expected fallback mock")
1562 .expect("fallback mock should succeed");
1563 assert_eq!(fallback.display(), "fallback");
1564 reset_host_state();
1565 }
1566
1567 #[test]
1568 fn mock_host_call_can_throw_errors() {
1569 reset_host_state();
1570 push_host_mock(HostMock {
1571 capability: "project".to_string(),
1572 operation: "metadata_get".to_string(),
1573 params: None,
1574 result: None,
1575 error: Some("boom".to_string()),
1576 });
1577 let params = crate::value::DictMap::new();
1578 let result = dispatch_mock_host_call("project", "metadata_get", ¶ms)
1579 .expect("expected mock result");
1580 match result {
1581 Err(VmError::Thrown(VmValue::String(message))) => assert_eq!(message.as_str(), "boom"),
1582 other => panic!("unexpected result: {other:?}"),
1583 }
1584 reset_host_state();
1585 }
1586
1587 #[test]
1588 fn hostlib_mock_dispatch_matches_module_method_and_params() {
1589 reset_host_state();
1590 let mut mock_params = crate::value::DictMap::new();
1591 mock_params.put(
1592 "argv",
1593 VmValue::List(Arc::new(vec![VmValue::string("echo")])),
1594 );
1595 push_host_mock(HostMock {
1596 capability: "tools".to_string(),
1597 operation: "run_command".to_string(),
1598 params: Some(mock_params),
1599 result: Some(VmValue::String(arcstr::ArcStr::from("direct"))),
1600 error: None,
1601 });
1602
1603 let mut call_params = crate::value::DictMap::new();
1604 call_params.put(
1605 "argv",
1606 VmValue::List(Arc::new(vec![VmValue::string("echo")])),
1607 );
1608 call_params.put_str("cwd", "/tmp/not-used");
1609 let value = dispatch_mock_hostlib_call("tools", "run_command", &call_params)
1610 .expect("expected hostlib mock")
1611 .expect("hostlib mock should succeed");
1612 assert_eq!(value.display(), "direct");
1613 reset_host_state();
1614 }
1615
1616 #[test]
1617 fn hostlib_run_command_falls_back_to_process_exec_mocks() {
1618 reset_host_state();
1619 let mut mock_params = crate::value::DictMap::new();
1620 mock_params.put(
1621 "argv",
1622 VmValue::List(Arc::new(vec![
1623 VmValue::string("cargo"),
1624 VmValue::string("test"),
1625 ])),
1626 );
1627 push_host_mock(HostMock {
1628 capability: "process".to_string(),
1629 operation: "exec".to_string(),
1630 params: Some(mock_params),
1631 result: Some(VmValue::String(arcstr::ArcStr::from("legacy"))),
1632 error: None,
1633 });
1634
1635 let mut call_params = crate::value::DictMap::new();
1636 call_params.put(
1637 "argv",
1638 VmValue::List(Arc::new(vec![
1639 VmValue::string("cargo"),
1640 VmValue::string("test"),
1641 ])),
1642 );
1643 call_params.put_str("cwd", "/tmp/not-used");
1644 let value = dispatch_mock_hostlib_call("tools", "run_command", &call_params)
1645 .expect("expected legacy process.exec mock")
1646 .expect("legacy mock should succeed");
1647 assert_eq!(value.display(), "legacy");
1648 reset_host_state();
1649 }
1650
1651 #[test]
1652 fn hostlib_run_command_prefers_exact_mock_over_process_exec_alias() {
1653 reset_host_state();
1654 let mut params = crate::value::DictMap::new();
1655 params.put(
1656 "argv",
1657 VmValue::List(Arc::new(vec![
1658 VmValue::string("npm"),
1659 VmValue::string("test"),
1660 ])),
1661 );
1662 push_host_mock(HostMock {
1663 capability: "process".to_string(),
1664 operation: "exec".to_string(),
1665 params: Some(params.clone()),
1666 result: Some(VmValue::String(arcstr::ArcStr::from("legacy"))),
1667 error: None,
1668 });
1669 push_host_mock(HostMock {
1670 capability: "tools".to_string(),
1671 operation: "run_command".to_string(),
1672 params: Some(params.clone()),
1673 result: Some(VmValue::String(arcstr::ArcStr::from("direct"))),
1674 error: None,
1675 });
1676
1677 let value = dispatch_mock_hostlib_call("tools", "run_command", ¶ms)
1678 .expect("expected exact hostlib mock")
1679 .expect("exact mock should succeed");
1680 assert_eq!(value.display(), "direct");
1681 reset_host_state();
1682 }
1683
1684 #[derive(Default)]
1685 struct TestHostToolBridge;
1686
1687 impl HostCallBridge for TestHostToolBridge {
1688 fn dispatch(
1689 &self,
1690 _capability: &str,
1691 _operation: &str,
1692 _params: &crate::value::DictMap,
1693 ) -> Result<Option<VmValue>, VmError> {
1694 Ok(None)
1695 }
1696
1697 fn list_tools(&self) -> Result<Option<VmValue>, VmError> {
1698 let tool = VmValue::dict(crate::value::DictMap::from_iter([
1699 (
1700 crate::value::intern_key("name"),
1701 VmValue::String(arcstr::ArcStr::from("Read".to_string())),
1702 ),
1703 (
1704 crate::value::intern_key("description"),
1705 VmValue::String(arcstr::ArcStr::from(
1706 "Read a file from the host".to_string(),
1707 )),
1708 ),
1709 (
1710 crate::value::intern_key("schema"),
1711 VmValue::dict(crate::value::DictMap::from_iter([(
1712 crate::value::intern_key("type"),
1713 VmValue::String(arcstr::ArcStr::from("object".to_string())),
1714 )])),
1715 ),
1716 (crate::value::intern_key("deprecated"), VmValue::Bool(false)),
1717 ]));
1718 Ok(Some(VmValue::List(std::sync::Arc::new(vec![tool]))))
1719 }
1720
1721 fn call_tool(&self, name: &str, args: &VmValue) -> Result<Option<VmValue>, VmError> {
1722 if name != "Read" {
1723 return Ok(None);
1724 }
1725 let path = args
1726 .as_dict()
1727 .and_then(|dict| dict.get("path"))
1728 .map(|value| value.display())
1729 .unwrap_or_default();
1730 Ok(Some(VmValue::String(arcstr::ArcStr::from(format!(
1731 "read:{path}"
1732 )))))
1733 }
1734 }
1735
1736 struct CountingProcessExecBridge {
1737 calls: Arc<AtomicUsize>,
1738 }
1739
1740 impl HostCallBridge for CountingProcessExecBridge {
1741 fn dispatch(
1742 &self,
1743 capability: &str,
1744 operation: &str,
1745 _params: &crate::value::DictMap,
1746 ) -> Result<Option<VmValue>, VmError> {
1747 if (capability, operation) != ("process", "exec") {
1748 return Ok(None);
1749 }
1750 self.calls.fetch_add(1, Ordering::SeqCst);
1751 Ok(Some(VmValue::dict(crate::value::DictMap::from_iter([
1752 (
1753 crate::value::intern_key("status"),
1754 VmValue::String(arcstr::ArcStr::from("completed".to_string())),
1755 ),
1756 (crate::value::intern_key("exit_code"), VmValue::Int(0)),
1757 (crate::value::intern_key("success"), VmValue::Bool(true)),
1758 ]))))
1759 }
1760 }
1761
1762 fn run_host_async_test<F, Fut>(test: F)
1763 where
1764 F: FnOnce() -> Fut,
1765 Fut: std::future::Future<Output = ()>,
1766 {
1767 let rt = tokio::runtime::Builder::new_current_thread()
1768 .enable_all()
1769 .build()
1770 .expect("runtime");
1771 rt.block_on(async {
1772 let local = tokio::task::LocalSet::new();
1773 local.run_until(test()).await;
1774 });
1775 }
1776
1777 #[test]
1778 fn host_tool_list_uses_installed_host_call_bridge() {
1779 run_host_async_test(|| async {
1780 reset_host_state();
1781 set_host_call_bridge(Arc::new(TestHostToolBridge));
1782 let tools = dispatch_host_tool_list().await.expect("tool list");
1783 clear_host_call_bridge();
1784
1785 let VmValue::List(items) = tools else {
1786 panic!("expected tool list");
1787 };
1788 assert_eq!(items.len(), 1);
1789 let tool = items[0].as_dict().expect("tool dict");
1790 assert_eq!(tool.get("name").unwrap().display(), "Read");
1791 assert_eq!(tool.get("deprecated").unwrap().display(), "false");
1792 });
1793 }
1794
1795 #[test]
1796 fn host_tool_call_uses_installed_host_call_bridge() {
1797 run_host_async_test(|| async {
1798 set_host_call_bridge(Arc::new(TestHostToolBridge));
1799 let args = VmValue::dict(crate::value::DictMap::from_iter([(
1800 crate::value::intern_key("path"),
1801 VmValue::String(arcstr::ArcStr::from("README.md".to_string())),
1802 )]));
1803 let value = dispatch_host_tool_call("Read", &args)
1804 .await
1805 .expect("tool call");
1806 clear_host_call_bridge();
1807 assert_eq!(value.display(), "read:README.md");
1808 });
1809 }
1810
1811 #[test]
1812 fn process_exec_bridge_is_gated_by_command_policy() {
1813 run_host_async_test(|| async {
1814 crate::orchestration::clear_command_policies();
1815 let calls = Arc::new(AtomicUsize::new(0));
1816 set_host_call_bridge(Arc::new(CountingProcessExecBridge {
1817 calls: calls.clone(),
1818 }));
1819 crate::orchestration::push_command_policy(crate::orchestration::CommandPolicy {
1820 tools: vec!["run".to_string()],
1821 workspace_roots: Vec::new(),
1822 default_shell_mode: "shell".to_string(),
1823 deny_patterns: vec!["cat *".to_string()],
1824 require_approval: Default::default(),
1825 deny_labels: Default::default(),
1826 pre: None,
1827 post: None,
1828 consent: None,
1829 allow_recursive: false,
1830 });
1831
1832 let result = dispatch_host_operation(
1833 "process",
1834 "exec",
1835 &crate::value::DictMap::from_iter([
1836 (
1837 crate::value::intern_key("mode"),
1838 VmValue::String(arcstr::ArcStr::from("shell")),
1839 ),
1840 (
1841 crate::value::intern_key("command"),
1842 VmValue::String(arcstr::ArcStr::from("cat Cargo.toml")),
1843 ),
1844 ]),
1845 )
1846 .await
1847 .expect("process.exec result");
1848
1849 crate::orchestration::clear_command_policies();
1850 clear_host_call_bridge();
1851
1852 assert_eq!(
1853 calls.load(Ordering::SeqCst),
1854 0,
1855 "blocked command must not reach host bridge"
1856 );
1857 let result = result.as_dict().expect("blocked result dict");
1858 assert_eq!(result.get("status").unwrap().display(), "blocked");
1859 assert!(
1860 result
1861 .get("reason")
1862 .map(VmValue::display)
1863 .unwrap_or_default()
1864 .contains("cat *"),
1865 "blocked result should name the matched policy pattern"
1866 );
1867 });
1868 }
1869
1870 #[cfg(unix)]
1871 async fn process_exec_env_probe(env: VmValue, env_mode: Option<&str>) -> (String, String) {
1872 std::env::set_var("PARENT_VAR", "inherited");
1877 let mut params = crate::value::DictMap::from_iter([
1878 (
1879 crate::value::intern_key("mode"),
1880 VmValue::String(arcstr::ArcStr::from("argv")),
1881 ),
1882 (
1883 crate::value::intern_key("argv"),
1884 VmValue::List(std::sync::Arc::new(vec![
1885 VmValue::String(arcstr::ArcStr::from("/bin/sh")),
1888 VmValue::String(arcstr::ArcStr::from("-c")),
1889 VmValue::String(arcstr::ArcStr::from(
1890 "printf '%s|%s' \"$PARENT_VAR\" \"$CHILD_VAR\"",
1891 )),
1892 ])),
1893 ),
1894 (crate::value::intern_key("env"), env),
1895 ]);
1896 if let Some(mode) = env_mode {
1897 params.put_str("env_mode", mode);
1898 }
1899 let result = super::dispatch_process_exec(¶ms, serde_json::Value::Null)
1900 .await
1901 .expect("process.exec result");
1902 let dict = result.as_dict().expect("result dict");
1903 let stdout = dict.get("stdout").map(VmValue::display).unwrap_or_default();
1904 std::env::remove_var("PARENT_VAR");
1905 let (parent, child) = stdout.split_once('|').unwrap_or((&stdout, ""));
1906 (parent.to_string(), child.to_string())
1907 }
1908
1909 #[cfg(unix)]
1910 #[test]
1911 fn process_exec_env_default_merges_with_parent() {
1912 run_host_async_test(|| async {
1913 let child_env = VmValue::dict(crate::value::DictMap::from_iter([(
1916 crate::value::intern_key("CHILD_VAR"),
1917 VmValue::String(arcstr::ArcStr::from("provided")),
1918 )]));
1919 let (parent, child) = process_exec_env_probe(child_env, None).await;
1920 assert_eq!(
1921 parent, "inherited",
1922 "default env_mode must inherit parent env"
1923 );
1924 assert_eq!(
1925 child, "provided",
1926 "default env_mode must apply provided keys"
1927 );
1928 });
1929 }
1930
1931 #[cfg(unix)]
1932 #[test]
1933 fn process_exec_env_mode_replace_clears_parent() {
1934 run_host_async_test(|| async {
1935 let child_env = VmValue::dict(crate::value::DictMap::from_iter([(
1939 crate::value::intern_key("CHILD_VAR"),
1940 VmValue::String(arcstr::ArcStr::from("provided")),
1941 )]));
1942 let (parent, child) = process_exec_env_probe(child_env, Some("replace")).await;
1943 assert_eq!(parent, "", "explicit replace must clear parent env");
1944 assert_eq!(
1945 child, "provided",
1946 "explicit replace must keep provided keys"
1947 );
1948 });
1949 }
1950
1951 #[cfg(unix)]
1952 #[test]
1953 fn process_exec_env_mode_unknown_is_rejected() {
1954 run_host_async_test(|| async {
1955 let params = crate::value::DictMap::from_iter([
1956 (
1957 crate::value::intern_key("mode"),
1958 VmValue::String(arcstr::ArcStr::from("argv")),
1959 ),
1960 (
1961 crate::value::intern_key("argv"),
1962 VmValue::List(std::sync::Arc::new(vec![VmValue::String(
1963 arcstr::ArcStr::from("true"),
1964 )])),
1965 ),
1966 (
1967 crate::value::intern_key("env"),
1968 VmValue::dict(crate::value::DictMap::from_iter([(
1969 crate::value::intern_key("CHILD_VAR"),
1970 VmValue::String(arcstr::ArcStr::from("x")),
1971 )])),
1972 ),
1973 (
1974 crate::value::intern_key("env_mode"),
1975 VmValue::String(arcstr::ArcStr::from("bogus")),
1976 ),
1977 ]);
1978 let err = super::dispatch_process_exec(¶ms, serde_json::Value::Null)
1979 .await
1980 .expect_err("unknown env_mode must error");
1981 assert!(
1982 format!("{err:?}").contains("env_mode"),
1983 "error should name env_mode, got {err:?}"
1984 );
1985 });
1986 }
1987
1988 #[cfg(unix)]
1994 async fn process_exec_tmpdir_probe(
1995 workspace: &std::path::Path,
1996 caller_env: Option<VmValue>,
1997 ) -> String {
1998 let mut env_pairs = vec![(
1999 crate::value::intern_key("mode"),
2000 VmValue::String(arcstr::ArcStr::from("argv")),
2001 )];
2002 env_pairs.push((
2003 crate::value::intern_key("argv"),
2004 VmValue::List(std::sync::Arc::new(vec![
2005 VmValue::String(arcstr::ArcStr::from("/bin/sh")),
2006 VmValue::String(arcstr::ArcStr::from("-c")),
2007 VmValue::String(arcstr::ArcStr::from("printf '%s' \"$TMPDIR\"")),
2008 ])),
2009 ));
2010 if let Some(env) = caller_env {
2011 env_pairs.push((crate::value::intern_key("env"), env));
2012 }
2013 let params = crate::value::DictMap::from_iter(env_pairs);
2014
2015 crate::orchestration::push_execution_policy(crate::orchestration::CapabilityPolicy {
2016 sandbox_profile: crate::orchestration::SandboxProfile::Worktree,
2017 workspace_roots: vec![workspace.to_string_lossy().into_owned()],
2018 ..crate::orchestration::CapabilityPolicy::default()
2022 });
2023 std::env::set_var("HARN_HANDLER_SANDBOX", "off");
2024 let result = super::dispatch_process_exec(¶ms, serde_json::Value::Null)
2025 .await
2026 .expect("process.exec result");
2027 std::env::remove_var("HARN_HANDLER_SANDBOX");
2028 crate::orchestration::pop_execution_policy();
2029 result
2030 .as_dict()
2031 .and_then(|d| d.get("stdout"))
2032 .map(VmValue::display)
2033 .unwrap_or_default()
2034 }
2035
2036 #[cfg(unix)]
2037 #[test]
2038 fn process_exec_injects_workspace_local_tmpdir() {
2039 run_host_async_test(|| async {
2040 let workspace = tempfile::tempdir().expect("workspace");
2041 let tmpdir = process_exec_tmpdir_probe(workspace.path(), None).await;
2042
2043 assert!(
2044 !tmpdir.is_empty(),
2045 "sandboxed child must receive a non-empty TMPDIR"
2046 );
2047 let tmpdir_path = std::path::PathBuf::from(&tmpdir);
2048 let canonical_tmpdir = std::fs::canonicalize(&tmpdir_path)
2049 .expect("workspace-local TMPDIR should canonicalize");
2050 let canonical_workspace =
2051 std::fs::canonicalize(workspace.path()).expect("workspace should canonicalize");
2052 assert!(
2053 canonical_tmpdir.starts_with(&canonical_workspace),
2054 "child TMPDIR {tmpdir:?} must live inside the workspace {:?}",
2055 workspace.path()
2056 );
2057 assert!(
2058 tmpdir_path.ends_with(".harn-tmp"),
2059 "child TMPDIR {tmpdir:?} must be the workspace-local .harn-tmp dir"
2060 );
2061 assert!(
2062 tmpdir_path.is_dir(),
2063 "the workspace-local TMPDIR must have been created on disk"
2064 );
2065 });
2066 }
2067
2068 #[cfg(unix)]
2069 #[test]
2070 fn process_exec_respects_caller_pinned_tmpdir() {
2071 run_host_async_test(|| async {
2072 let workspace = tempfile::tempdir().expect("workspace");
2073 let caller_tmp = workspace.path().join("caller-chosen");
2074 std::fs::create_dir_all(&caller_tmp).unwrap();
2075 let caller_env = VmValue::dict(crate::value::DictMap::from_iter([(
2076 crate::value::intern_key("TMPDIR"),
2077 VmValue::String(arcstr::ArcStr::from(
2078 caller_tmp.to_string_lossy().into_owned(),
2079 )),
2080 )]));
2081
2082 let tmpdir = process_exec_tmpdir_probe(workspace.path(), Some(caller_env)).await;
2083
2084 assert_eq!(
2085 std::path::PathBuf::from(&tmpdir),
2086 caller_tmp,
2087 "an explicit caller TMPDIR must override the workspace-local default"
2088 );
2089 });
2090 }
2091
2092 #[test]
2093 fn host_tool_list_is_empty_without_bridge() {
2094 run_host_async_test(|| async {
2095 clear_host_call_bridge();
2096 let tools = dispatch_host_tool_list().await.expect("tool list");
2097 let VmValue::List(items) = tools else {
2098 panic!("expected tool list");
2099 };
2100 assert!(items.is_empty());
2101 });
2102 }
2103}