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;
8use tokio::io::AsyncReadExt;
9
10use crate::stdlib::macros::{harn_builtin, VmBuiltinDef};
11use crate::value::{values_equal, VmError, VmValue};
12use crate::vm::{AsyncBuiltinCtx, Vm};
13
14pub(crate) fn audited_utc_now_rfc3339(capability_id: &'static str) -> String {
18 let dt: chrono::DateTime<chrono::Utc> =
19 crate::clock_mock::leak_audit::wall_now(capability_id).into();
20 dt.to_rfc3339()
21}
22
23pub(crate) const MODULE_BUILTINS: &[&VmBuiltinDef] = &[
24 &HOST_MOCK_BUILTIN_DEF,
25 &HOST_MOCK_CLEAR_BUILTIN_DEF,
26 &HOST_MOCK_CALLS_BUILTIN_DEF,
27 &HOST_MOCK_PUSH_SCOPE_BUILTIN_DEF,
28 &HOST_MOCK_POP_SCOPE_BUILTIN_DEF,
29 &HOST_CAPABILITIES_BUILTIN_DEF,
30 &HOST_HAS_BUILTIN_DEF,
31 &HOST_CALL_BUILTIN_DEF,
32 &HOST_TOOL_LIST_BUILTIN_DEF,
33 &HOST_TOOL_CALL_BUILTIN_DEF,
34];
35
36#[derive(Clone)]
37struct HostMock {
38 capability: String,
39 operation: String,
40 params: Option<crate::value::DictMap>,
41 result: Option<VmValue>,
42 error: Option<String>,
43}
44
45#[derive(Clone)]
46struct HostMockCall {
47 capability: String,
48 operation: String,
49 params: crate::value::DictMap,
50}
51
52thread_local! {
53 static HOST_MOCKS: RefCell<Vec<HostMock>> = const { RefCell::new(Vec::new()) };
54 static HOST_MOCK_CALLS: RefCell<Vec<HostMockCall>> = const { RefCell::new(Vec::new()) };
55 static HOST_MOCK_SCOPES: RefCell<Vec<(Vec<HostMock>, Vec<HostMockCall>)>> =
56 const { RefCell::new(Vec::new()) };
57}
58
59pub(crate) fn reset_host_state() {
60 HOST_MOCKS.with(|mocks| mocks.borrow_mut().clear());
61 HOST_MOCK_CALLS.with(|calls| calls.borrow_mut().clear());
62 HOST_MOCK_SCOPES.with(|scopes| scopes.borrow_mut().clear());
63}
64
65fn push_host_mock_scope() {
70 let mocks = HOST_MOCKS.with(|v| std::mem::take(&mut *v.borrow_mut()));
71 let calls = HOST_MOCK_CALLS.with(|v| std::mem::take(&mut *v.borrow_mut()));
72 HOST_MOCK_SCOPES.with(|v| v.borrow_mut().push((mocks, calls)));
73}
74
75fn pop_host_mock_scope() -> bool {
80 let entry = HOST_MOCK_SCOPES.with(|v| v.borrow_mut().pop());
81 match entry {
82 Some((mocks, calls)) => {
83 HOST_MOCKS.with(|v| *v.borrow_mut() = mocks);
84 HOST_MOCK_CALLS.with(|v| *v.borrow_mut() = calls);
85 true
86 }
87 None => false,
88 }
89}
90
91fn async_builtin_cancel_token(
92 ctx: Option<&AsyncBuiltinCtx>,
93) -> Option<std::sync::Arc<std::sync::atomic::AtomicBool>> {
94 ctx.and_then(|ctx| ctx.child_vm().cancel_token.clone())
95}
96
97fn capability_manifest_map() -> crate::value::DictMap {
98 let mut root = crate::value::DictMap::new();
99 root.insert(
100 crate::value::intern_key("process"),
101 capability(
102 "Process execution.",
103 &[
104 op("exec", "Execute a process in argv or shell mode."),
105 op(
106 "spawn",
107 "Spawn a process non-blocking; returns a handle immediately for poll/wait/kill.",
108 ),
109 op(
110 "poll",
111 "Non-blocking snapshot of a spawned process: status, captured stdout/stderr.",
112 ),
113 op(
114 "wait",
115 "Await a spawned process to completion (optional timeout_ms); returns final result.",
116 ),
117 op(
118 "kill",
119 "Terminate a spawned process by handle and await the status transition.",
120 ),
121 op(
122 "release",
123 "Release a spawned-process handle and free its retained output.",
124 ),
125 op("list_shells", "List shells discovered by the host/session."),
126 op(
127 "get_default_shell",
128 "Return the selected default shell for this host/session.",
129 ),
130 op(
131 "set_default_shell",
132 "Select the default shell for this host/session.",
133 ),
134 op(
135 "shell_invocation",
136 "Resolve shell selection and login/interactive flags into argv.",
137 ),
138 ],
139 ),
140 );
141 root.insert(
142 crate::value::intern_key("template"),
143 capability(
144 "Template rendering.",
145 &[op("render", "Render a template file.")],
146 ),
147 );
148 root.insert(
149 crate::value::intern_key("interaction"),
150 capability(
151 "User interaction.",
152 &[op("ask", "Ask the user a question.")],
153 ),
154 );
155 root.insert(
156 crate::value::intern_key("memory"),
157 capability(
158 "Vector-aware memory: host-provided embeddings.",
159 &[op(
160 "embed",
161 "Embed text for semantic recall. Params: {text, model_hint?}. \
162 Returns {vector: list<float>, model: string, dim: int}.",
163 )],
164 ),
165 );
166 root
167}
168
169fn mocked_operation_entry() -> VmValue {
170 op(
171 "mocked",
172 "Mocked host operation registered at runtime for tests.",
173 )
174 .1
175}
176
177fn ensure_mocked_capability(
178 root: &mut crate::value::DictMap,
179 capability_name: &str,
180 operation_name: &str,
181) {
182 let Some(existing) = root.get(capability_name).cloned() else {
183 root.insert(
184 crate::value::intern_key(capability_name),
185 capability(
186 "Mocked host capability registered at runtime for tests.",
187 &[(operation_name.to_string(), mocked_operation_entry())],
188 ),
189 );
190 return;
191 };
192
193 let Some(existing_dict) = existing.as_dict() else {
194 return;
195 };
196 let mut entry = (*existing_dict).clone();
197 let mut ops = entry
198 .get("ops")
199 .and_then(|value| match value {
200 VmValue::List(list) => Some((**list).clone()),
201 _ => None,
202 })
203 .unwrap_or_default();
204 if !ops.iter().any(|value| value.display() == operation_name) {
205 ops.push(VmValue::String(arcstr::ArcStr::from(
206 operation_name.to_string(),
207 )));
208 }
209
210 let mut operations = entry
211 .get("operations")
212 .and_then(|value| value.as_dict())
213 .map(|dict| (*dict).clone())
214 .unwrap_or_default();
215 operations
216 .entry(crate::value::intern_key(operation_name))
217 .or_insert_with(mocked_operation_entry);
218
219 entry.insert(
220 crate::value::intern_key("ops"),
221 VmValue::List(std::sync::Arc::new(ops)),
222 );
223 entry.insert(
224 crate::value::intern_key("operations"),
225 VmValue::dict(operations),
226 );
227 root.insert(
228 crate::value::intern_key(capability_name),
229 VmValue::dict(entry),
230 );
231}
232
233fn capability_manifest_with_mocks() -> VmValue {
234 let mut root = capability_manifest_map();
235 HOST_MOCKS.with(|mocks| {
236 for host_mock in mocks.borrow().iter() {
237 ensure_mocked_capability(&mut root, &host_mock.capability, &host_mock.operation);
238 }
239 });
240 VmValue::dict(root)
241}
242
243fn op(name: &str, description: &str) -> (String, VmValue) {
244 let mut entry = crate::value::DictMap::new();
245 entry.put_str("description", description);
246 (name.to_string(), VmValue::dict(entry))
247}
248
249fn capability(description: &str, ops: &[(String, VmValue)]) -> VmValue {
250 let mut entry = crate::value::DictMap::new();
251 entry.put_str("description", description);
252 entry.insert(
253 crate::value::intern_key("ops"),
254 VmValue::List(std::sync::Arc::new(
255 ops.iter()
256 .map(|(name, _)| VmValue::String(arcstr::ArcStr::from(name.as_str())))
257 .collect(),
258 )),
259 );
260 let mut op_dict = crate::value::DictMap::new();
261 for (name, op) in ops {
262 op_dict.insert(crate::value::intern_key(name), op.clone());
263 }
264 entry.insert(
265 crate::value::intern_key("operations"),
266 VmValue::dict(op_dict),
267 );
268 VmValue::dict(entry)
269}
270
271pub(crate) fn require_param(params: &crate::value::DictMap, key: &str) -> Result<String, VmError> {
272 params
273 .get(key)
274 .map(|v| v.display())
275 .filter(|v| !v.is_empty())
276 .ok_or_else(|| {
277 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
278 "host_call: missing required parameter '{key}'"
279 ))))
280 })
281}
282
283fn render_template(
284 path: &str,
285 bindings: Option<&crate::value::DictMap>,
286) -> Result<String, VmError> {
287 let asset = crate::stdlib::template::TemplateAsset::render_target(path).map_err(|msg| {
288 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
289 "host_call template.render: {msg}"
290 ))))
291 })?;
292 crate::stdlib::template::render_asset_result(&asset, bindings).map_err(VmError::from)
293}
294
295fn params_match(expected: Option<&crate::value::DictMap>, actual: &crate::value::DictMap) -> bool {
296 let Some(expected) = expected else {
297 return true;
298 };
299 expected.iter().all(|(key, value)| {
300 actual
301 .get(key)
302 .is_some_and(|candidate| values_equal(candidate, value))
303 })
304}
305
306fn parse_host_mock(args: &[VmValue]) -> Result<HostMock, VmError> {
307 let capability = args
308 .first()
309 .map(|value| value.display())
310 .unwrap_or_default();
311 let operation = args.get(1).map(|value| value.display()).unwrap_or_default();
312 if capability.is_empty() || operation.is_empty() {
313 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
314 "host_mock: capability and operation are required",
315 ))));
316 }
317
318 let mut params = args
319 .get(3)
320 .and_then(|value| value.as_dict())
321 .map(|dict| (*dict).clone());
322 let mut result = args.get(2).cloned().or(Some(VmValue::Nil));
323 let mut error = None;
324
325 if let Some(config) = args.get(2).and_then(|value| value.as_dict()) {
326 if config.contains_key("result")
327 || config.contains_key("params")
328 || config.contains_key("error")
329 {
330 params = config
331 .get("params")
332 .and_then(|value| value.as_dict())
333 .map(|dict| (*dict).clone());
334 result = config.get("result").cloned();
335 error = config
336 .get("error")
337 .map(|value| value.display())
338 .filter(|value| !value.is_empty());
339 }
340 }
341
342 Ok(HostMock {
343 capability,
344 operation,
345 params,
346 result,
347 error,
348 })
349}
350
351fn push_host_mock(host_mock: HostMock) {
352 HOST_MOCKS.with(|mocks| mocks.borrow_mut().push(host_mock));
353}
354
355fn mock_call_value(call: &HostMockCall) -> VmValue {
356 let mut item = crate::value::DictMap::new();
357 item.put_str("capability", call.capability.clone());
358 item.put_str("operation", call.operation.clone());
359 item.insert(
360 crate::value::intern_key("params"),
361 VmValue::dict(call.params.clone()),
362 );
363 VmValue::dict(item)
364}
365
366fn record_mock_call(capability: &str, operation: &str, params: &crate::value::DictMap) {
367 HOST_MOCK_CALLS.with(|calls| {
368 calls.borrow_mut().push(HostMockCall {
369 capability: capability.to_string(),
370 operation: operation.to_string(),
371 params: params.clone(),
372 });
373 });
374}
375
376pub(crate) fn dispatch_mock_host_call(
377 capability: &str,
378 operation: &str,
379 params: &crate::value::DictMap,
380) -> Option<Result<VmValue, VmError>> {
381 let matched = HOST_MOCKS.with(|mocks| {
382 mocks
383 .borrow()
384 .iter()
385 .rev()
386 .find(|host_mock| {
387 host_mock.capability == capability
388 && host_mock.operation == operation
389 && params_match(host_mock.params.as_ref(), params)
390 })
391 .cloned()
392 })?;
393
394 record_mock_call(capability, operation, params);
395 if let Some(error) = matched.error {
396 return Some(Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
397 error,
398 )))));
399 }
400 Some(Ok(matched.result.unwrap_or(VmValue::Nil)))
401}
402
403pub fn dispatch_mock_hostlib_call(
414 module: &str,
415 method: &str,
416 params: &crate::value::DictMap,
417) -> Option<Result<VmValue, VmError>> {
418 if let Some(mocked) = dispatch_mock_host_call(module, method, params) {
419 return Some(mocked);
420 }
421
422 if (module, method) == ("tools", "run_command") {
423 return dispatch_mock_host_call("process", "exec", params);
424 }
425
426 None
427}
428
429pub trait HostCallBridge: Send + Sync {
444 fn dispatch(
445 &self,
446 capability: &str,
447 operation: &str,
448 params: &crate::value::DictMap,
449 ) -> Result<Option<VmValue>, VmError>;
450
451 fn list_tools(&self) -> Result<Option<VmValue>, VmError> {
452 Ok(None)
453 }
454
455 fn call_tool(&self, _name: &str, _args: &VmValue) -> Result<Option<VmValue>, VmError> {
456 Ok(None)
457 }
458}
459
460thread_local! {
461 static HOST_CALL_BRIDGE: RefCell<Option<Arc<dyn HostCallBridge>>> = const { RefCell::new(None) };
462}
463
464pub fn set_host_call_bridge(bridge: Arc<dyn HostCallBridge>) {
469 HOST_CALL_BRIDGE.with(|b| *b.borrow_mut() = Some(bridge));
470}
471
472pub fn clear_host_call_bridge() {
474 HOST_CALL_BRIDGE.with(|b| *b.borrow_mut() = None);
475}
476
477pub fn dispatch_host_call_bridge(
487 capability: &str,
488 operation: &str,
489 params: &crate::value::DictMap,
490) -> Option<Result<VmValue, VmError>> {
491 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone())?;
492 match bridge.dispatch(capability, operation, params) {
493 Ok(Some(value)) => Some(Ok(value)),
494 Ok(None) => None,
495 Err(error) => Some(Err(error)),
496 }
497}
498
499fn empty_tool_list_value() -> VmValue {
500 VmValue::List(std::sync::Arc::new(Vec::new()))
501}
502
503fn current_vm_host_bridge(
504 ctx: Option<&AsyncBuiltinCtx>,
505) -> Option<std::sync::Arc<crate::bridge::HostBridge>> {
506 ctx.and_then(|ctx| ctx.child_vm().bridge.clone())
507}
508
509#[cfg(test)]
510async fn dispatch_host_tool_list() -> Result<VmValue, VmError> {
511 dispatch_host_tool_list_with_ctx(None).await
512}
513
514async fn dispatch_host_tool_list_with_ctx(
515 ctx: Option<&AsyncBuiltinCtx>,
516) -> Result<VmValue, VmError> {
517 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
518 if let Some(bridge) = bridge {
519 if let Some(value) = bridge.list_tools()? {
520 return Ok(value);
521 }
522 }
523
524 let Some(bridge) = current_vm_host_bridge(ctx) else {
525 return Ok(empty_tool_list_value());
526 };
527 let tools = bridge.list_host_tools().await?;
528 Ok(crate::bridge::json_result_to_vm_value(&JsonValue::Array(
529 tools.into_iter().collect(),
530 )))
531}
532
533pub(crate) async fn dispatch_host_tool_call(
534 name: &str,
535 args: &VmValue,
536) -> Result<VmValue, VmError> {
537 dispatch_host_tool_call_with_ctx(None, name, args).await
538}
539
540pub(crate) async fn dispatch_host_tool_call_with_ctx(
541 ctx: Option<&AsyncBuiltinCtx>,
542 name: &str,
543 args: &VmValue,
544) -> Result<VmValue, VmError> {
545 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
546 if let Some(bridge) = bridge {
547 if let Some(value) = bridge.call_tool(name, args)? {
548 return Ok(value);
549 }
550 }
551
552 let Some(bridge) = current_vm_host_bridge(ctx) else {
553 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
554 "host_tool_call: no host bridge is attached",
555 ))));
556 };
557
558 let result = bridge
559 .call(
560 "builtin_call",
561 serde_json::json!({
562 "name": name,
563 "args": [crate::llm::vm_value_to_json(args)],
564 }),
565 )
566 .await?;
567 Ok(crate::bridge::json_result_to_vm_value(&result))
568}
569
570pub(crate) async fn dispatch_host_operation(
571 capability: &str,
572 operation: &str,
573 params: &crate::value::DictMap,
574) -> Result<VmValue, VmError> {
575 dispatch_host_operation_with_ctx(None, capability, operation, params).await
576}
577
578pub(crate) async fn dispatch_host_operation_with_ctx(
579 ctx: Option<&AsyncBuiltinCtx>,
580 capability: &str,
581 operation: &str,
582 params: &crate::value::DictMap,
583) -> Result<VmValue, VmError> {
584 if let Some(mocked) = dispatch_mock_host_call(capability, operation, params) {
585 return mocked;
586 }
587
588 if (capability, operation) == ("process", "exec") {
589 let caller = serde_json::json!({
590 "surface": "host_call",
591 "capability": "process",
592 "operation": "exec",
593 "session_id": crate::llm::current_agent_session_id(),
594 });
595 return dispatch_process_exec_with_policy(ctx, params, caller).await;
596 }
597
598 if (capability, operation) == ("process", "spawn") {
605 let caller = serde_json::json!({
606 "surface": "host_call",
607 "capability": "process",
608 "operation": "spawn",
609 "session_id": crate::llm::current_agent_session_id(),
610 });
611 return dispatch_process_spawn_with_policy(ctx, params, caller).await;
612 }
613 if capability == "process" && matches!(operation, "poll" | "wait" | "kill" | "release") {
614 if let Some(result) = crate::stdlib::process_spawn::dispatch(
615 operation,
616 params,
617 async_builtin_cancel_token(ctx),
618 )
619 .await
620 {
621 return result;
622 }
623 }
624
625 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
626 if let Some(bridge) = bridge {
627 if let Some(value) = bridge.dispatch(capability, operation, params)? {
628 return Ok(value);
629 }
630 }
631
632 dispatch_builtin_host_operation(capability, operation, params).await
633}
634
635async fn dispatch_builtin_host_operation(
636 capability: &str,
637 operation: &str,
638 params: &crate::value::DictMap,
639) -> Result<VmValue, VmError> {
640 match (capability, operation) {
641 ("process", "list_shells") => Ok(crate::shells::list_shells_vm_value()),
642 ("process", "get_default_shell") => Ok(crate::shells::default_shell_vm_value()),
643 ("process", "set_default_shell") => crate::shells::set_default_shell_vm_value(params),
644 ("process", "shell_invocation") => crate::shells::shell_invocation_vm_value(params),
645 ("template", "render") => {
646 let path = require_param(params, "path")?;
647 let bindings = params.get("bindings").and_then(|v| v.as_dict());
648 Ok(VmValue::String(arcstr::ArcStr::from(render_template(
649 &path, bindings,
650 )?)))
651 }
652 ("interaction", "ask") => {
653 let question = require_param(params, "question")?;
654 use std::io::BufRead;
655 print!("{question}");
656 let _ = std::io::Write::flush(&mut std::io::stdout());
657 let mut input = String::new();
658 if std::io::stdin().lock().read_line(&mut input).is_ok() {
659 Ok(VmValue::String(arcstr::ArcStr::from(input.trim_end())))
660 } else {
661 Ok(VmValue::Nil)
662 }
663 }
664 ("project", "metadata_get") => crate::metadata::project_metadata_host_get(params),
665 ("project", "metadata_inspect") => crate::metadata::project_metadata_host_inspect(params),
666 ("project", "metadata_set") => crate::metadata::project_metadata_host_set(params),
667 ("project", "metadata_save") => crate::metadata::project_metadata_host_save(params),
668 ("project", "metadata_stale") => crate::metadata::project_metadata_host_stale(params),
669 ("project", "metadata_refresh_hashes") => {
670 crate::metadata::project_metadata_host_refresh_hashes(params)
671 }
672 ("runtime", "task") => Ok(VmValue::String(arcstr::ArcStr::from(
677 std::env::var("HARN_TASK").unwrap_or_default(),
678 ))),
679 ("runtime", "set_result") => {
680 Ok(VmValue::Nil)
683 }
684 ("workspace", "project_root") => {
685 let path = crate::stdlib::process::project_root_path()
690 .map(|root| root.display().to_string())
691 .or_else(|| std::env::var("HARN_PROJECT_ROOT").ok())
692 .unwrap_or_else(|| {
693 std::env::current_dir()
694 .map(|p| p.display().to_string())
695 .unwrap_or_default()
696 });
697 Ok(VmValue::String(arcstr::ArcStr::from(path)))
698 }
699 ("workspace", "cwd") => {
700 let path = std::env::current_dir()
701 .map(|p| p.display().to_string())
702 .unwrap_or_default();
703 Ok(VmValue::String(arcstr::ArcStr::from(path)))
704 }
705 _ => Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
706 format!("host_call: unsupported operation {capability}.{operation}"),
707 )))),
708 }
709}
710
711pub(crate) async fn dispatch_process_exec(
712 params: &crate::value::DictMap,
713 caller: serde_json::Value,
714) -> Result<VmValue, VmError> {
715 dispatch_process_exec_with_policy(None, params, caller).await
716}
717
718async fn dispatch_process_exec_with_policy(
719 ctx: Option<&AsyncBuiltinCtx>,
720 params: &crate::value::DictMap,
721 caller: serde_json::Value,
722) -> Result<VmValue, VmError> {
723 let (params, command_policy_context, command_policy_decisions) =
724 match crate::orchestration::run_command_policy_preflight_with_ctx(ctx, params, caller)
725 .await?
726 {
727 crate::orchestration::CommandPolicyPreflight::Proceed {
728 params,
729 context,
730 decisions,
731 } => (params, context, decisions),
732 crate::orchestration::CommandPolicyPreflight::Blocked {
733 status,
734 message,
735 context,
736 decisions,
737 } => {
738 return Ok(crate::orchestration::blocked_command_response(
739 params, status, &message, context, decisions,
740 ));
741 }
742 };
743
744 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
745 if let Some(bridge) = bridge {
746 if let Some(value) = bridge.dispatch("process", "exec", ¶ms)? {
747 return crate::orchestration::run_command_policy_postflight_with_ctx(
748 ctx,
749 ¶ms,
750 value,
751 command_policy_context,
752 command_policy_decisions,
753 )
754 .await;
755 }
756 }
757
758 dispatch_process_exec_after_policy(
759 ctx,
760 ¶ms,
761 command_policy_context,
762 command_policy_decisions,
763 )
764 .await
765}
766
767async fn dispatch_process_spawn_with_policy(
774 ctx: Option<&AsyncBuiltinCtx>,
775 params: &crate::value::DictMap,
776 caller: serde_json::Value,
777) -> Result<VmValue, VmError> {
778 let params =
779 match crate::orchestration::run_command_policy_preflight_with_ctx(ctx, params, caller)
780 .await?
781 {
782 crate::orchestration::CommandPolicyPreflight::Proceed { params, .. } => params,
783 crate::orchestration::CommandPolicyPreflight::Blocked {
784 status,
785 message,
786 context,
787 decisions,
788 } => {
789 return Ok(crate::orchestration::blocked_command_response(
790 params, status, &message, context, decisions,
791 ));
792 }
793 };
794
795 match crate::stdlib::process_spawn::dispatch("spawn", ¶ms, async_builtin_cancel_token(ctx))
796 .await
797 {
798 Some(result) => result,
799 None => Err(VmError::Runtime(
800 "host_call process.spawn: dispatch returned None".to_string(),
801 )),
802 }
803}
804
805async fn dispatch_process_exec_after_policy(
806 ctx: Option<&AsyncBuiltinCtx>,
807 params: &crate::value::DictMap,
808 command_policy_context: JsonValue,
809 command_policy_decisions: Vec<crate::orchestration::CommandPolicyDecision>,
810) -> Result<VmValue, VmError> {
811 let timeout_ms = optional_i64(params, "timeout")
812 .or_else(|| optional_i64(params, "timeout_ms"))
813 .filter(|value| *value > 0)
814 .map(|value| value as u64);
815 let profile_guard = match optional_string(params, "sandbox_profile") {
821 Some(value) => Some(push_sandbox_profile_override(&value)?),
822 None => None,
823 };
824 let mut cmd = build_sandboxed_command(params, "process.exec")?;
825 crate::op_interrupt::configure_tokio_kill_group(&mut cmd);
826 let cleanup_token = crate::op_interrupt::new_process_cleanup_token();
827 cmd.env(
828 crate::op_interrupt::PROCESS_CLEANUP_TOKEN_ENV,
829 &cleanup_token,
830 );
831 cmd.stdin(std::process::Stdio::null())
832 .stdout(std::process::Stdio::piped())
833 .stderr(std::process::Stdio::piped())
834 .kill_on_drop(true);
835 let started_at = audited_utc_now_rfc3339("host_call/process.exec.started_at");
836 let started = crate::clock_mock::leak_audit::instant_now("host_call/process.exec.started");
837 let mut child = cmd
838 .spawn()
839 .map_err(|e| VmError::Runtime(format!("host_call process.exec: {e}")))?;
840 drop(profile_guard);
841 let pid = child.id();
842 let cleanup_registration = crate::op_interrupt::register_active_process_cleanup(
843 pid,
844 &cleanup_token,
845 async_builtin_cancel_token(ctx),
846 );
847 let stdout_pipe = match child.stdout.take() {
848 Some(pipe) => pipe,
849 None => {
850 terminate_process_exec_child(&mut child, pid, &cleanup_token, "missing_stdout_pipe")
851 .await;
852 drop(cleanup_registration);
853 return Err(VmError::Runtime(
854 "host_call process.exec stdout pipe was not captured".to_string(),
855 ));
856 }
857 };
858 let stderr_pipe = match child.stderr.take() {
859 Some(pipe) => pipe,
860 None => {
861 terminate_process_exec_child(&mut child, pid, &cleanup_token, "missing_stderr_pipe")
862 .await;
863 drop(cleanup_registration);
864 return Err(VmError::Runtime(
865 "host_call process.exec stderr pipe was not captured".to_string(),
866 ));
867 }
868 };
869 let stdout_task = tokio::spawn(read_process_exec_pipe(stdout_pipe));
870 let stderr_task = tokio::spawn(read_process_exec_pipe(stderr_pipe));
871
872 enum ProcessExecWait {
873 Exited(std::io::Result<std::process::ExitStatus>),
874 TimedOut,
875 }
876
877 let exec_deadline = timeout_ms.map(|timeout_ms| {
878 tokio::time::Instant::now() + std::time::Duration::from_millis(timeout_ms)
879 });
880 let wait_result = {
881 let wait = child.wait();
882 tokio::pin!(wait);
883 if let Some(deadline) = exec_deadline {
884 let sleep = tokio::time::sleep_until(deadline);
885 tokio::pin!(sleep);
886 tokio::select! {
887 result = &mut wait => ProcessExecWait::Exited(result),
888 _ = &mut sleep => ProcessExecWait::TimedOut,
889 }
890 } else {
891 ProcessExecWait::Exited(wait.await)
892 }
893 };
894
895 let (mut status, mut success, mut timed_out, mut exit_code) = match wait_result {
896 ProcessExecWait::Exited(result) => {
897 let status =
898 result.map_err(|e| VmError::Runtime(format!("host_call process.exec: {e}")))?;
899 let exit_code = status.code().unwrap_or(-1);
900 ("completed", status.success(), false, exit_code)
901 }
902 ProcessExecWait::TimedOut => {
903 terminate_process_exec_child(&mut child, pid, &cleanup_token, "timeout").await;
904 ("timed_out", false, true, -1)
905 }
906 };
907
908 let drain_pipes = async {
909 let stdout = collect_process_exec_pipe(stdout_task, "stdout").await?;
910 let stderr = collect_process_exec_pipe(stderr_task, "stderr").await?;
911 Ok::<_, VmError>((stdout, stderr))
912 };
913 tokio::pin!(drain_pipes);
914 let (stdout, stderr) = if !timed_out {
915 if let Some(deadline) = exec_deadline {
916 tokio::select! {
917 result = &mut drain_pipes => result?,
918 _ = tokio::time::sleep_until(deadline) => {
919 terminate_process_exec_child(
920 &mut child,
921 pid,
922 &cleanup_token,
923 "pipe_drain_timeout",
924 )
925 .await;
926 status = "timed_out";
927 success = false;
928 timed_out = true;
929 exit_code = -1;
930 drain_pipes.await?
931 }
932 }
933 } else {
934 drain_pipes.await?
935 }
936 } else {
937 drain_pipes.await?
938 };
939 drop(cleanup_registration);
940
941 let stdout = String::from_utf8_lossy(&stdout).to_string();
942 let stderr = String::from_utf8_lossy(&stderr).to_string();
943 let response = process_exec_response(ProcessExecResponse {
944 pid,
945 started_at,
946 started,
947 stdout: &stdout,
948 stderr: &stderr,
949 exit_code,
950 status,
951 success,
952 timed_out,
953 });
954 crate::orchestration::run_command_policy_postflight_with_ctx(
955 ctx,
956 params,
957 response,
958 command_policy_context,
959 command_policy_decisions,
960 )
961 .await
962}
963
964async fn read_process_exec_pipe<R>(mut pipe: R) -> std::io::Result<Vec<u8>>
965where
966 R: tokio::io::AsyncRead + Unpin,
967{
968 let mut bytes = Vec::new();
969 pipe.read_to_end(&mut bytes).await?;
970 Ok(bytes)
971}
972
973async fn collect_process_exec_pipe(
974 task: tokio::task::JoinHandle<std::io::Result<Vec<u8>>>,
975 name: &str,
976) -> Result<Vec<u8>, VmError> {
977 match task.await {
978 Ok(Ok(bytes)) => Ok(bytes),
979 Ok(Err(error)) => Err(VmError::Runtime(format!(
980 "host_call process.exec read {name}: {error}"
981 ))),
982 Err(error) => Err(VmError::Runtime(format!(
983 "host_call process.exec join {name} reader: {error}"
984 ))),
985 }
986}
987
988async fn terminate_process_exec_child(
989 child: &mut tokio::process::Child,
990 pid: Option<u32>,
991 cleanup_token: &str,
992 reason: &'static str,
993) {
994 if let Some(pid) = pid {
995 let mut report = crate::op_interrupt::signal_pid_tree_group_and_token_with_report(
996 pid,
997 Some(cleanup_token),
998 9,
999 );
1000 report.refresh_survivor_status();
1001 tracing::warn!(
1002 pid,
1003 children = report.children.len(),
1004 reason,
1005 "host_call process.exec signalled child process tree"
1006 );
1007 }
1008 let _ = child.kill().await;
1009 let _ = child.wait().await;
1010}
1011
1012pub(crate) fn build_sandboxed_command(
1024 params: &crate::value::DictMap,
1025 label: &str,
1026) -> Result<tokio::process::Command, VmError> {
1027 let (program, args) = process_exec_argv(params)?;
1028 let mut cmd = crate::process_sandbox::tokio_command_for(&program, &args)
1029 .map_err(|e| VmError::Runtime(format!("host_call {label} sandbox setup: {e}")))?;
1030 if let Some(cwd) = optional_string(params, "cwd") {
1031 let cwd = resolve_process_exec_cwd(&cwd);
1032 crate::process_sandbox::enforce_process_cwd(&cwd)
1033 .map_err(|e| VmError::Runtime(format!("host_call {label} cwd: {e}")))?;
1034 cmd.current_dir(cwd);
1035 }
1036 let mut caller_env_keys: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
1039 if let Some(env) = optional_string_dict(params, "env")? {
1040 let env_mode = optional_string(params, "env_mode");
1049 match env_mode.as_deref().unwrap_or("merge") {
1050 "replace" => {
1051 cmd.env_clear();
1052 }
1053 "merge" => {}
1054 other => {
1055 return Err(VmError::Runtime(format!(
1056 "host_call {label}: unknown env_mode {other:?}; expected \"merge\" or \"replace\""
1057 )));
1058 }
1059 }
1060 for (key, value) in env {
1061 caller_env_keys.insert(key.clone());
1062 cmd.env(key, value);
1063 }
1064 }
1065 if let Some(env_remove) = optional_string_list(params, "env_remove") {
1071 for key in env_remove {
1072 caller_env_keys.insert(key.clone());
1073 cmd.env_remove(key);
1074 }
1075 }
1076 for (key, value) in crate::process_sandbox::active_workspace_tmpdir_env() {
1085 if caller_env_keys.contains(&key) {
1086 continue;
1087 }
1088 cmd.env(key, value);
1089 }
1090 if !caller_env_keys.contains(crate::process_sandbox::MESSAGE_LOCALE_OVERRIDE_ENV) {
1098 cmd.env_remove(crate::process_sandbox::MESSAGE_LOCALE_OVERRIDE_ENV);
1099 }
1100 for (key, value) in crate::process_sandbox::deterministic_message_locale_env() {
1101 if caller_env_keys.contains(&key) {
1102 continue;
1103 }
1104 cmd.env(key, value);
1105 }
1106 Ok(cmd)
1107}
1108
1109struct ProcessExecResponse<'a> {
1110 pid: Option<u32>,
1111 started_at: String,
1112 started: Instant,
1113 stdout: &'a str,
1114 stderr: &'a str,
1115 exit_code: i32,
1116 status: &'a str,
1117 success: bool,
1118 timed_out: bool,
1119}
1120
1121fn process_exec_response(response: ProcessExecResponse<'_>) -> VmValue {
1122 let combined = format!("{}{}", response.stdout, response.stderr);
1123 let mut result = crate::value::DictMap::new();
1124 result.put_str(
1125 "command_id",
1126 format!(
1127 "cmd_{}_{}",
1128 std::process::id(),
1129 response.started.elapsed().as_nanos()
1130 ),
1131 );
1132 result.put_str("status", response.status);
1133 result.insert(
1134 crate::value::intern_key("pid"),
1135 response
1136 .pid
1137 .map(|pid| VmValue::Int(pid as i64))
1138 .unwrap_or(VmValue::Nil),
1139 );
1140 result.insert(
1141 crate::value::intern_key("process_group_id"),
1142 response
1143 .pid
1144 .map(|pid| VmValue::Int(pid as i64))
1145 .unwrap_or(VmValue::Nil),
1146 );
1147 result.insert(crate::value::intern_key("handle_id"), VmValue::Nil);
1148 result.put_str("started_at", response.started_at);
1149 result.put_str(
1150 "ended_at",
1151 audited_utc_now_rfc3339("host_call/process.exec.ended_at"),
1152 );
1153 result.insert(
1154 crate::value::intern_key("duration_ms"),
1155 VmValue::Int(response.started.elapsed().as_millis() as i64),
1156 );
1157 result.insert(
1158 crate::value::intern_key("exit_code"),
1159 VmValue::Int(response.exit_code as i64),
1160 );
1161 result.insert(crate::value::intern_key("signal"), VmValue::Nil);
1162 result.insert(
1163 crate::value::intern_key("timed_out"),
1164 VmValue::Bool(response.timed_out),
1165 );
1166 result.put_str("stdout", response.stdout);
1167 result.put_str("stderr", response.stderr);
1168 result.put_str("combined", combined);
1169 result.insert(
1170 crate::value::intern_key("exit_status"),
1171 VmValue::Int(response.exit_code as i64),
1172 );
1173 result.insert(
1174 crate::value::intern_key("legacy_status"),
1175 VmValue::Int(response.exit_code as i64),
1176 );
1177 result.insert(
1178 crate::value::intern_key("success"),
1179 VmValue::Bool(response.success),
1180 );
1181 VmValue::dict(result)
1182}
1183
1184fn resolve_process_exec_cwd(cwd: &str) -> std::path::PathBuf {
1185 crate::stdlib::process::resolve_source_relative_path(cwd)
1186}
1187
1188fn process_exec_argv(params: &crate::value::DictMap) -> Result<(String, Vec<String>), VmError> {
1189 match optional_string(params, "mode")
1190 .as_deref()
1191 .unwrap_or("shell")
1192 {
1193 "argv" => {
1194 let argv = optional_string_list(params, "argv").ok_or_else(|| {
1195 VmError::Runtime("host_call process.exec missing argv".to_string())
1196 })?;
1197 split_argv(argv)
1198 }
1199 "shell" => {
1200 let command = require_param(params, "command")?;
1201 let mut invocation_params = params.clone();
1202 invocation_params.put_str("command", command);
1203 let invocation =
1204 crate::shells::resolve_invocation_from_vm_params(&invocation_params)
1205 .map_err(|err| VmError::Runtime(format!("host_call process.exec: {err}")))?;
1206 Ok((invocation.program, invocation.args))
1207 }
1208 other => Err(VmError::Runtime(format!(
1209 "host_call process.exec unsupported mode {other:?}"
1210 ))),
1211 }
1212}
1213
1214fn split_argv(mut argv: Vec<String>) -> Result<(String, Vec<String>), VmError> {
1215 if argv.is_empty() {
1216 return Err(VmError::Runtime(
1217 "host_call process.exec argv must not be empty".to_string(),
1218 ));
1219 }
1220 let program = argv.remove(0);
1221 if program.is_empty() {
1222 return Err(VmError::Runtime(
1223 "host_call process.exec argv[0] must not be empty".to_string(),
1224 ));
1225 }
1226 Ok((program, argv))
1227}
1228
1229pub(crate) fn push_sandbox_profile_override(value: &str) -> Result<SandboxProfileGuard, VmError> {
1235 let profile = crate::orchestration::SandboxProfile::parse(value).ok_or_else(|| {
1236 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
1237 "host_call process.exec: unknown sandbox_profile {value:?}; expected one of \"unrestricted\", \"worktree\", \"os_hardened\", \"wasi\""
1238 ))))
1239 })?;
1240 let mut policy = crate::orchestration::current_execution_policy().unwrap_or_default();
1241 policy.sandbox_profile = profile;
1242 crate::orchestration::push_execution_policy(policy);
1243 Ok(SandboxProfileGuard {
1244 _private: std::marker::PhantomData,
1245 })
1246}
1247
1248pub(crate) struct SandboxProfileGuard {
1249 _private: std::marker::PhantomData<*const ()>,
1250}
1251
1252impl Drop for SandboxProfileGuard {
1253 fn drop(&mut self) {
1254 crate::orchestration::pop_execution_policy();
1255 }
1256}
1257
1258pub(crate) fn optional_i64(params: &crate::value::DictMap, key: &str) -> Option<i64> {
1259 match params.get(key) {
1260 Some(VmValue::Int(value)) => Some(*value),
1261 Some(VmValue::Float(value)) if value.fract() == 0.0 => Some(*value as i64),
1262 _ => None,
1263 }
1264}
1265
1266pub(crate) fn optional_string(params: &crate::value::DictMap, key: &str) -> Option<String> {
1267 params.get(key).and_then(vm_string).map(ToString::to_string)
1268}
1269
1270fn optional_string_list(params: &crate::value::DictMap, key: &str) -> Option<Vec<String>> {
1271 let VmValue::List(values) = params.get(key)? else {
1272 return None;
1273 };
1274 values
1275 .iter()
1276 .map(|value| vm_string(value).map(ToString::to_string))
1277 .collect()
1278}
1279
1280fn optional_string_dict(
1281 params: &crate::value::DictMap,
1282 key: &str,
1283) -> Result<Option<BTreeMap<String, String>>, VmError> {
1284 let Some(value) = params.get(key) else {
1285 return Ok(None);
1286 };
1287 let Some(dict) = value.as_dict() else {
1288 return Err(VmError::Runtime(format!(
1289 "host_call process.exec {key} must be a dict"
1290 )));
1291 };
1292 let mut out = std::collections::BTreeMap::new();
1293 for (key, value) in dict.iter() {
1294 let Some(value) = vm_string(value) else {
1295 return Err(VmError::Runtime(format!(
1296 "host_call process.exec env value for {key:?} must be a string"
1297 )));
1298 };
1299 out.insert(key.to_string(), value.to_string());
1300 }
1301 Ok(Some(out))
1302}
1303
1304fn vm_string(value: &VmValue) -> Option<&str> {
1305 match value {
1306 VmValue::String(value) => Some(value.as_ref()),
1307 _ => None,
1308 }
1309}
1310
1311pub(crate) fn register_host_builtins(vm: &mut Vm) {
1312 for def in MODULE_BUILTINS {
1313 vm.register_builtin_def(def);
1314 }
1315}
1316
1317pub(crate) fn register_missing_host_builtins(vm: &mut Vm) {
1318 for def in MODULE_BUILTINS {
1319 if vm.builtin_metadata_for(def.sig.name).is_none() {
1320 vm.register_builtin_def(def);
1321 }
1322 }
1323}
1324
1325#[harn_builtin(
1326 sig = "host_mock(capability: string, op: string, response_or_config?: any, params?: dict) -> nil",
1327 category = "host"
1328)]
1329fn host_mock_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1330 let host_mock = parse_host_mock(args)?;
1331 push_host_mock(host_mock);
1332 Ok(VmValue::Nil)
1333}
1334
1335#[harn_builtin(sig = "host_mock_clear() -> nil", category = "host")]
1336fn host_mock_clear_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1337 reset_host_state();
1338 Ok(VmValue::Nil)
1339}
1340
1341#[harn_builtin(sig = "host_mock_calls() -> list", category = "host")]
1342fn host_mock_calls_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1343 let calls = HOST_MOCK_CALLS.with(|calls| {
1344 calls
1345 .borrow()
1346 .iter()
1347 .map(mock_call_value)
1348 .collect::<Vec<_>>()
1349 });
1350 Ok(VmValue::List(std::sync::Arc::new(calls)))
1351}
1352
1353#[harn_builtin(sig = "host_mock_push_scope() -> nil", category = "host")]
1354fn host_mock_push_scope_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1355 push_host_mock_scope();
1356 Ok(VmValue::Nil)
1357}
1358
1359#[harn_builtin(sig = "host_mock_pop_scope() -> nil", category = "host")]
1360fn host_mock_pop_scope_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1361 if !pop_host_mock_scope() {
1362 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1363 "host_mock_pop_scope: no scope to pop",
1364 ))));
1365 }
1366 Ok(VmValue::Nil)
1367}
1368
1369#[harn_builtin(sig = "host_capabilities() -> dict", category = "host")]
1370fn host_capabilities_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1371 Ok(capability_manifest_with_mocks())
1372}
1373
1374#[harn_builtin(
1375 sig = "host_has(capability: string, op?: string) -> bool",
1376 category = "host"
1377)]
1378fn host_has_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1379 let capability = args.first().map(|a| a.display()).unwrap_or_default();
1380 let operation = args.get(1).map(|a| a.display());
1381 let manifest = capability_manifest_with_mocks();
1382 let has = manifest
1383 .as_dict()
1384 .and_then(|d| d.get(capability.as_str()))
1385 .and_then(|v| v.as_dict())
1386 .is_some_and(|cap| {
1387 if let Some(operation) = operation {
1388 cap.get("ops")
1389 .and_then(|v| match v {
1390 VmValue::List(list) => {
1391 Some(list.iter().any(|item| item.display() == operation))
1392 }
1393 _ => None,
1394 })
1395 .unwrap_or(false)
1396 } else {
1397 true
1398 }
1399 });
1400 Ok(VmValue::Bool(has))
1401}
1402
1403#[harn_builtin(
1404 sig = "host_call(name: string, args?: dict) -> any",
1405 kind = "async",
1406 category = "host"
1407)]
1408async fn host_call_builtin(
1409 ctx: crate::vm::AsyncBuiltinCtx,
1410 args: Vec<VmValue>,
1411) -> Result<VmValue, VmError> {
1412 let name = args.first().map(|a| a.display()).unwrap_or_default();
1413 let params = args
1414 .get(1)
1415 .and_then(|a| a.as_dict())
1416 .cloned()
1417 .unwrap_or_default();
1418 let Some((capability, operation)) = name.split_once('.') else {
1419 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1420 format!("host_call: unsupported operation name '{name}'"),
1421 ))));
1422 };
1423 dispatch_host_operation_with_ctx(Some(&ctx), capability, operation, ¶ms).await
1424}
1425
1426#[harn_builtin(sig = "host_tool_list() -> list", kind = "async", category = "host")]
1427async fn host_tool_list_builtin(
1428 ctx: crate::vm::AsyncBuiltinCtx,
1429 _args: Vec<VmValue>,
1430) -> Result<VmValue, VmError> {
1431 dispatch_host_tool_list_with_ctx(Some(&ctx)).await
1432}
1433
1434#[harn_builtin(
1435 sig = "host_tool_call(name: string, args?: any) -> any",
1436 kind = "async",
1437 category = "host"
1438)]
1439async fn host_tool_call_builtin(
1440 ctx: crate::vm::AsyncBuiltinCtx,
1441 args: Vec<VmValue>,
1442) -> Result<VmValue, VmError> {
1443 let name = args.first().map(|a| a.display()).unwrap_or_default();
1444 if name.is_empty() {
1445 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1446 "host_tool_call: tool name is required",
1447 ))));
1448 }
1449 let call_args = args.get(1).cloned().unwrap_or(VmValue::Nil);
1450 dispatch_host_tool_call_with_ctx(Some(&ctx), &name, &call_args).await
1451}
1452
1453#[cfg(test)]
1454mod tests {
1455 use super::{
1456 build_sandboxed_command, capability_manifest_with_mocks, clear_host_call_bridge,
1457 dispatch_host_operation, dispatch_host_tool_call, dispatch_host_tool_list,
1458 dispatch_mock_host_call, dispatch_mock_hostlib_call, push_host_mock, reset_host_state,
1459 resolve_process_exec_cwd, set_host_call_bridge, HostCallBridge, HostMock,
1460 };
1461 use crate::value::VmDictExt;
1462
1463 use std::sync::{
1464 atomic::{AtomicUsize, Ordering},
1465 Arc,
1466 };
1467
1468 use crate::value::{VmError, VmValue};
1469
1470 fn command_env(
1474 cmd: &tokio::process::Command,
1475 ) -> std::collections::BTreeMap<String, Option<String>> {
1476 cmd.as_std()
1477 .get_envs()
1478 .map(|(k, v)| {
1479 (
1480 k.to_string_lossy().into_owned(),
1481 v.map(|value| value.to_string_lossy().into_owned()),
1482 )
1483 })
1484 .collect()
1485 }
1486
1487 #[test]
1488 fn build_sandboxed_command_forces_deterministic_message_locale() {
1489 let mut params = crate::value::DictMap::new();
1499 params.put_str("mode", "argv");
1500 params.put(
1501 "argv",
1502 VmValue::List(Arc::new(vec![VmValue::string("/bin/true")])),
1503 );
1504 params.put_str("env_mode", "merge");
1505 let mut caller_env = crate::value::DictMap::new();
1506 caller_env.put_str("CARGO_TARGET_DIR", "/tmp/target");
1508 params.put("env", VmValue::dict_map(caller_env));
1509
1510 let cmd = build_sandboxed_command(¶ms, "process.exec").expect("build command");
1511 let env = command_env(&cmd);
1512
1513 assert_eq!(
1514 env.get("LC_ALL"),
1515 Some(&None),
1516 "the builder must remove LC_ALL from the child so an inherited shell \
1517 value cannot override the forced LC_MESSAGES"
1518 );
1519 assert_eq!(
1520 env.get("LC_MESSAGES"),
1521 Some(&Some("C".to_string())),
1522 "LC_MESSAGES must be pinned to C for untranslated (English) tool output"
1523 );
1524 assert_eq!(
1525 env.get("DOTNET_CLI_UI_LANGUAGE"),
1526 Some(&Some("en".to_string())),
1527 ".NET ignores LC_* and needs its own UI-language override"
1528 );
1529 }
1530
1531 #[test]
1532 fn build_sandboxed_command_respects_a_caller_pinned_locale() {
1533 let mut params = crate::value::DictMap::new();
1536 params.put_str("mode", "argv");
1537 params.put(
1538 "argv",
1539 VmValue::List(Arc::new(vec![VmValue::string("/bin/true")])),
1540 );
1541 params.put_str("env_mode", "merge");
1542 let mut caller_env = crate::value::DictMap::new();
1543 caller_env.put_str("LC_ALL", "fr_FR.UTF-8");
1544 caller_env.put_str("LC_MESSAGES", "fr_FR.UTF-8");
1545 params.put("env", VmValue::dict_map(caller_env));
1546
1547 let cmd = build_sandboxed_command(¶ms, "process.exec").expect("build command");
1548 let env = command_env(&cmd);
1549
1550 assert_eq!(
1551 env.get("LC_ALL"),
1552 Some(&Some("fr_FR.UTF-8".to_string())),
1553 "a caller that pins LC_ALL keeps it — the overlay must not strip an explicit value"
1554 );
1555 assert_eq!(
1556 env.get("LC_MESSAGES"),
1557 Some(&Some("fr_FR.UTF-8".to_string())),
1558 "a caller-pinned LC_MESSAGES wins over the C overlay"
1559 );
1560 }
1561
1562 #[test]
1563 fn process_exec_relative_cwd_resolves_against_execution_root() {
1564 let dir = tempfile::tempdir().expect("tempdir");
1565 crate::stdlib::process::set_thread_execution_context(Some(
1566 crate::orchestration::RunExecutionRecord {
1567 cwd: Some(dir.path().to_string_lossy().into_owned()),
1568 project_root: None,
1569 source_dir: Some(dir.path().join("src").to_string_lossy().into_owned()),
1570 env: std::collections::BTreeMap::new(),
1571 adapter: None,
1572 repo_path: None,
1573 worktree_path: None,
1574 branch: None,
1575 base_ref: None,
1576 cleanup: None,
1577 },
1578 ));
1579
1580 assert_eq!(
1581 resolve_process_exec_cwd("subdir"),
1582 dir.path().join("subdir")
1583 );
1584
1585 crate::stdlib::process::set_thread_execution_context(None);
1586 }
1587
1588 #[test]
1589 fn workspace_project_root_fallback_prefers_execution_context_project_root() {
1590 run_host_async_test(|| async {
1591 let project = tempfile::tempdir().expect("project root");
1592 let cwd = tempfile::tempdir().expect("cwd");
1593 crate::stdlib::process::set_thread_execution_context(Some(
1594 crate::orchestration::RunExecutionRecord {
1595 cwd: Some(cwd.path().to_string_lossy().into_owned()),
1596 project_root: Some(project.path().to_string_lossy().into_owned()),
1597 source_dir: None,
1598 env: std::collections::BTreeMap::new(),
1599 adapter: None,
1600 repo_path: None,
1601 worktree_path: None,
1602 branch: None,
1603 base_ref: None,
1604 cleanup: None,
1605 },
1606 ));
1607
1608 let result =
1609 dispatch_host_operation("workspace", "project_root", &crate::value::DictMap::new())
1610 .await
1611 .expect("workspace.project_root result");
1612
1613 crate::stdlib::process::set_thread_execution_context(None);
1614 assert_eq!(result.display(), project.path().display().to_string());
1615 });
1616 }
1617
1618 #[test]
1619 fn manifest_includes_operation_metadata() {
1620 let manifest = capability_manifest_with_mocks();
1621 let process = manifest
1622 .as_dict()
1623 .and_then(|d| d.get("process"))
1624 .and_then(|v| v.as_dict())
1625 .expect("process capability");
1626 assert!(process.get("description").is_some());
1627 let operations = process
1628 .get("operations")
1629 .and_then(|v| v.as_dict())
1630 .expect("operations dict");
1631 assert!(operations.get("exec").is_some());
1632 }
1633
1634 #[test]
1635 fn mocked_capabilities_appear_in_manifest() {
1636 reset_host_state();
1637 push_host_mock(HostMock {
1638 capability: "project".to_string(),
1639 operation: "metadata_get".to_string(),
1640 params: None,
1641 result: Some(VmValue::dict(crate::value::DictMap::new())),
1642 error: None,
1643 });
1644 let manifest = capability_manifest_with_mocks();
1645 let project = manifest
1646 .as_dict()
1647 .and_then(|d| d.get("project"))
1648 .and_then(|v| v.as_dict())
1649 .expect("project capability");
1650 let operations = project
1651 .get("operations")
1652 .and_then(|v| v.as_dict())
1653 .expect("operations dict");
1654 assert!(operations.get("metadata_get").is_some());
1655 reset_host_state();
1656 }
1657
1658 #[test]
1659 fn mock_host_call_matches_partial_params_and_overrides_order() {
1660 reset_host_state();
1661 let mut exact_params = crate::value::DictMap::new();
1662 exact_params.put_str("namespace", "facts");
1663 push_host_mock(HostMock {
1664 capability: "project".to_string(),
1665 operation: "metadata_get".to_string(),
1666 params: None,
1667 result: Some(VmValue::String(arcstr::ArcStr::from("fallback"))),
1668 error: None,
1669 });
1670 push_host_mock(HostMock {
1671 capability: "project".to_string(),
1672 operation: "metadata_get".to_string(),
1673 params: Some(exact_params),
1674 result: Some(VmValue::String(arcstr::ArcStr::from("facts"))),
1675 error: None,
1676 });
1677
1678 let mut call_params = crate::value::DictMap::new();
1679 call_params.put_str("dir", "pkg");
1680 call_params.put_str("namespace", "facts");
1681 let exact = dispatch_mock_host_call("project", "metadata_get", &call_params)
1682 .expect("expected exact mock")
1683 .expect("exact mock should succeed");
1684 assert_eq!(exact.display(), "facts");
1685
1686 call_params.put_str("namespace", "classification");
1687 let fallback = dispatch_mock_host_call("project", "metadata_get", &call_params)
1688 .expect("expected fallback mock")
1689 .expect("fallback mock should succeed");
1690 assert_eq!(fallback.display(), "fallback");
1691 reset_host_state();
1692 }
1693
1694 #[test]
1695 fn mock_host_call_can_throw_errors() {
1696 reset_host_state();
1697 push_host_mock(HostMock {
1698 capability: "project".to_string(),
1699 operation: "metadata_get".to_string(),
1700 params: None,
1701 result: None,
1702 error: Some("boom".to_string()),
1703 });
1704 let params = crate::value::DictMap::new();
1705 let result = dispatch_mock_host_call("project", "metadata_get", ¶ms)
1706 .expect("expected mock result");
1707 match result {
1708 Err(VmError::Thrown(VmValue::String(message))) => assert_eq!(message.as_str(), "boom"),
1709 other => panic!("unexpected result: {other:?}"),
1710 }
1711 reset_host_state();
1712 }
1713
1714 #[test]
1715 fn hostlib_mock_dispatch_matches_module_method_and_params() {
1716 reset_host_state();
1717 let mut mock_params = crate::value::DictMap::new();
1718 mock_params.put(
1719 "argv",
1720 VmValue::List(Arc::new(vec![VmValue::string("echo")])),
1721 );
1722 push_host_mock(HostMock {
1723 capability: "tools".to_string(),
1724 operation: "run_command".to_string(),
1725 params: Some(mock_params),
1726 result: Some(VmValue::String(arcstr::ArcStr::from("direct"))),
1727 error: None,
1728 });
1729
1730 let mut call_params = crate::value::DictMap::new();
1731 call_params.put(
1732 "argv",
1733 VmValue::List(Arc::new(vec![VmValue::string("echo")])),
1734 );
1735 call_params.put_str("cwd", "/tmp/not-used");
1736 let value = dispatch_mock_hostlib_call("tools", "run_command", &call_params)
1737 .expect("expected hostlib mock")
1738 .expect("hostlib mock should succeed");
1739 assert_eq!(value.display(), "direct");
1740 reset_host_state();
1741 }
1742
1743 #[test]
1744 fn hostlib_run_command_falls_back_to_process_exec_mocks() {
1745 reset_host_state();
1746 let mut mock_params = crate::value::DictMap::new();
1747 mock_params.put(
1748 "argv",
1749 VmValue::List(Arc::new(vec![
1750 VmValue::string("cargo"),
1751 VmValue::string("test"),
1752 ])),
1753 );
1754 push_host_mock(HostMock {
1755 capability: "process".to_string(),
1756 operation: "exec".to_string(),
1757 params: Some(mock_params),
1758 result: Some(VmValue::String(arcstr::ArcStr::from("legacy"))),
1759 error: None,
1760 });
1761
1762 let mut call_params = crate::value::DictMap::new();
1763 call_params.put(
1764 "argv",
1765 VmValue::List(Arc::new(vec![
1766 VmValue::string("cargo"),
1767 VmValue::string("test"),
1768 ])),
1769 );
1770 call_params.put_str("cwd", "/tmp/not-used");
1771 let value = dispatch_mock_hostlib_call("tools", "run_command", &call_params)
1772 .expect("expected legacy process.exec mock")
1773 .expect("legacy mock should succeed");
1774 assert_eq!(value.display(), "legacy");
1775 reset_host_state();
1776 }
1777
1778 #[test]
1779 fn hostlib_run_command_prefers_exact_mock_over_process_exec_alias() {
1780 reset_host_state();
1781 let mut params = crate::value::DictMap::new();
1782 params.put(
1783 "argv",
1784 VmValue::List(Arc::new(vec![
1785 VmValue::string("npm"),
1786 VmValue::string("test"),
1787 ])),
1788 );
1789 push_host_mock(HostMock {
1790 capability: "process".to_string(),
1791 operation: "exec".to_string(),
1792 params: Some(params.clone()),
1793 result: Some(VmValue::String(arcstr::ArcStr::from("legacy"))),
1794 error: None,
1795 });
1796 push_host_mock(HostMock {
1797 capability: "tools".to_string(),
1798 operation: "run_command".to_string(),
1799 params: Some(params.clone()),
1800 result: Some(VmValue::String(arcstr::ArcStr::from("direct"))),
1801 error: None,
1802 });
1803
1804 let value = dispatch_mock_hostlib_call("tools", "run_command", ¶ms)
1805 .expect("expected exact hostlib mock")
1806 .expect("exact mock should succeed");
1807 assert_eq!(value.display(), "direct");
1808 reset_host_state();
1809 }
1810
1811 #[derive(Default)]
1812 struct TestHostToolBridge;
1813
1814 impl HostCallBridge for TestHostToolBridge {
1815 fn dispatch(
1816 &self,
1817 _capability: &str,
1818 _operation: &str,
1819 _params: &crate::value::DictMap,
1820 ) -> Result<Option<VmValue>, VmError> {
1821 Ok(None)
1822 }
1823
1824 fn list_tools(&self) -> Result<Option<VmValue>, VmError> {
1825 let tool = VmValue::dict(crate::value::DictMap::from_iter([
1826 (
1827 crate::value::intern_key("name"),
1828 VmValue::String(arcstr::ArcStr::from("Read".to_string())),
1829 ),
1830 (
1831 crate::value::intern_key("description"),
1832 VmValue::String(arcstr::ArcStr::from(
1833 "Read a file from the host".to_string(),
1834 )),
1835 ),
1836 (
1837 crate::value::intern_key("schema"),
1838 VmValue::dict(crate::value::DictMap::from_iter([(
1839 crate::value::intern_key("type"),
1840 VmValue::String(arcstr::ArcStr::from("object".to_string())),
1841 )])),
1842 ),
1843 (crate::value::intern_key("deprecated"), VmValue::Bool(false)),
1844 ]));
1845 Ok(Some(VmValue::List(std::sync::Arc::new(vec![tool]))))
1846 }
1847
1848 fn call_tool(&self, name: &str, args: &VmValue) -> Result<Option<VmValue>, VmError> {
1849 if name != "Read" {
1850 return Ok(None);
1851 }
1852 let path = args
1853 .as_dict()
1854 .and_then(|dict| dict.get("path"))
1855 .map(|value| value.display())
1856 .unwrap_or_default();
1857 Ok(Some(VmValue::String(arcstr::ArcStr::from(format!(
1858 "read:{path}"
1859 )))))
1860 }
1861 }
1862
1863 struct CountingProcessExecBridge {
1864 calls: Arc<AtomicUsize>,
1865 }
1866
1867 impl HostCallBridge for CountingProcessExecBridge {
1868 fn dispatch(
1869 &self,
1870 capability: &str,
1871 operation: &str,
1872 _params: &crate::value::DictMap,
1873 ) -> Result<Option<VmValue>, VmError> {
1874 if (capability, operation) != ("process", "exec") {
1875 return Ok(None);
1876 }
1877 self.calls.fetch_add(1, Ordering::SeqCst);
1878 Ok(Some(VmValue::dict(crate::value::DictMap::from_iter([
1879 (
1880 crate::value::intern_key("status"),
1881 VmValue::String(arcstr::ArcStr::from("completed".to_string())),
1882 ),
1883 (crate::value::intern_key("exit_code"), VmValue::Int(0)),
1884 (crate::value::intern_key("success"), VmValue::Bool(true)),
1885 ]))))
1886 }
1887 }
1888
1889 fn run_host_async_test<F, Fut>(test: F)
1890 where
1891 F: FnOnce() -> Fut,
1892 Fut: std::future::Future<Output = ()>,
1893 {
1894 let rt = tokio::runtime::Builder::new_current_thread()
1895 .enable_all()
1896 .build()
1897 .expect("runtime");
1898 rt.block_on(async {
1899 let local = tokio::task::LocalSet::new();
1900 local.run_until(test()).await;
1901 });
1902 }
1903
1904 #[test]
1905 fn host_tool_list_uses_installed_host_call_bridge() {
1906 run_host_async_test(|| async {
1907 reset_host_state();
1908 set_host_call_bridge(Arc::new(TestHostToolBridge));
1909 let tools = dispatch_host_tool_list().await.expect("tool list");
1910 clear_host_call_bridge();
1911
1912 let VmValue::List(items) = tools else {
1913 panic!("expected tool list");
1914 };
1915 assert_eq!(items.len(), 1);
1916 let tool = items[0].as_dict().expect("tool dict");
1917 assert_eq!(tool.get("name").unwrap().display(), "Read");
1918 assert_eq!(tool.get("deprecated").unwrap().display(), "false");
1919 });
1920 }
1921
1922 #[test]
1923 fn host_tool_call_uses_installed_host_call_bridge() {
1924 run_host_async_test(|| async {
1925 set_host_call_bridge(Arc::new(TestHostToolBridge));
1926 let args = VmValue::dict(crate::value::DictMap::from_iter([(
1927 crate::value::intern_key("path"),
1928 VmValue::String(arcstr::ArcStr::from("README.md".to_string())),
1929 )]));
1930 let value = dispatch_host_tool_call("Read", &args)
1931 .await
1932 .expect("tool call");
1933 clear_host_call_bridge();
1934 assert_eq!(value.display(), "read:README.md");
1935 });
1936 }
1937
1938 #[test]
1939 fn process_exec_bridge_is_gated_by_command_policy() {
1940 run_host_async_test(|| async {
1941 crate::orchestration::clear_command_policies();
1942 let calls = Arc::new(AtomicUsize::new(0));
1943 set_host_call_bridge(Arc::new(CountingProcessExecBridge {
1944 calls: calls.clone(),
1945 }));
1946 crate::orchestration::push_command_policy(crate::orchestration::CommandPolicy {
1947 tools: vec!["run".to_string()],
1948 workspace_roots: Vec::new(),
1949 default_shell_mode: "shell".to_string(),
1950 deny_patterns: vec!["cat *".to_string()],
1951 require_approval: Default::default(),
1952 deny_labels: Default::default(),
1953 pre: None,
1954 post: None,
1955 consent: None,
1956 allow_recursive: false,
1957 });
1958
1959 let result = dispatch_host_operation(
1960 "process",
1961 "exec",
1962 &crate::value::DictMap::from_iter([
1963 (
1964 crate::value::intern_key("mode"),
1965 VmValue::String(arcstr::ArcStr::from("shell")),
1966 ),
1967 (
1968 crate::value::intern_key("command"),
1969 VmValue::String(arcstr::ArcStr::from("cat Cargo.toml")),
1970 ),
1971 ]),
1972 )
1973 .await
1974 .expect("process.exec result");
1975
1976 crate::orchestration::clear_command_policies();
1977 clear_host_call_bridge();
1978
1979 assert_eq!(
1980 calls.load(Ordering::SeqCst),
1981 0,
1982 "blocked command must not reach host bridge"
1983 );
1984 let result = result.as_dict().expect("blocked result dict");
1985 assert_eq!(result.get("status").unwrap().display(), "blocked");
1986 assert!(
1987 result
1988 .get("reason")
1989 .map(VmValue::display)
1990 .unwrap_or_default()
1991 .contains("cat *"),
1992 "blocked result should name the matched policy pattern"
1993 );
1994 });
1995 }
1996
1997 #[cfg(unix)]
1998 async fn process_exec_env_probe(env: VmValue, env_mode: Option<&str>) -> (String, String) {
1999 std::env::set_var("PARENT_VAR", "inherited");
2004 let mut params = crate::value::DictMap::from_iter([
2005 (
2006 crate::value::intern_key("mode"),
2007 VmValue::String(arcstr::ArcStr::from("argv")),
2008 ),
2009 (
2010 crate::value::intern_key("argv"),
2011 VmValue::List(std::sync::Arc::new(vec![
2012 VmValue::String(arcstr::ArcStr::from("/bin/sh")),
2015 VmValue::String(arcstr::ArcStr::from("-c")),
2016 VmValue::String(arcstr::ArcStr::from(
2017 "printf '%s|%s' \"$PARENT_VAR\" \"$CHILD_VAR\"",
2018 )),
2019 ])),
2020 ),
2021 (crate::value::intern_key("env"), env),
2022 ]);
2023 if let Some(mode) = env_mode {
2024 params.put_str("env_mode", mode);
2025 }
2026 let result = super::dispatch_process_exec(¶ms, serde_json::Value::Null)
2027 .await
2028 .expect("process.exec result");
2029 let dict = result.as_dict().expect("result dict");
2030 let stdout = dict.get("stdout").map(VmValue::display).unwrap_or_default();
2031 std::env::remove_var("PARENT_VAR");
2032 let (parent, child) = stdout.split_once('|').unwrap_or((&stdout, ""));
2033 (parent.to_string(), child.to_string())
2034 }
2035
2036 #[cfg(unix)]
2037 #[test]
2038 fn process_exec_env_default_merges_with_parent() {
2039 run_host_async_test(|| async {
2040 let child_env = VmValue::dict(crate::value::DictMap::from_iter([(
2043 crate::value::intern_key("CHILD_VAR"),
2044 VmValue::String(arcstr::ArcStr::from("provided")),
2045 )]));
2046 let (parent, child) = process_exec_env_probe(child_env, None).await;
2047 assert_eq!(
2048 parent, "inherited",
2049 "default env_mode must inherit parent env"
2050 );
2051 assert_eq!(
2052 child, "provided",
2053 "default env_mode must apply provided keys"
2054 );
2055 });
2056 }
2057
2058 #[cfg(unix)]
2059 #[test]
2060 fn process_exec_env_mode_replace_clears_parent() {
2061 run_host_async_test(|| async {
2062 let child_env = VmValue::dict(crate::value::DictMap::from_iter([(
2066 crate::value::intern_key("CHILD_VAR"),
2067 VmValue::String(arcstr::ArcStr::from("provided")),
2068 )]));
2069 let (parent, child) = process_exec_env_probe(child_env, Some("replace")).await;
2070 assert_eq!(parent, "", "explicit replace must clear parent env");
2071 assert_eq!(
2072 child, "provided",
2073 "explicit replace must keep provided keys"
2074 );
2075 });
2076 }
2077
2078 #[cfg(unix)]
2079 #[test]
2080 fn process_exec_env_mode_unknown_is_rejected() {
2081 run_host_async_test(|| async {
2082 let params = crate::value::DictMap::from_iter([
2083 (
2084 crate::value::intern_key("mode"),
2085 VmValue::String(arcstr::ArcStr::from("argv")),
2086 ),
2087 (
2088 crate::value::intern_key("argv"),
2089 VmValue::List(std::sync::Arc::new(vec![VmValue::String(
2090 arcstr::ArcStr::from("true"),
2091 )])),
2092 ),
2093 (
2094 crate::value::intern_key("env"),
2095 VmValue::dict(crate::value::DictMap::from_iter([(
2096 crate::value::intern_key("CHILD_VAR"),
2097 VmValue::String(arcstr::ArcStr::from("x")),
2098 )])),
2099 ),
2100 (
2101 crate::value::intern_key("env_mode"),
2102 VmValue::String(arcstr::ArcStr::from("bogus")),
2103 ),
2104 ]);
2105 let err = super::dispatch_process_exec(¶ms, serde_json::Value::Null)
2106 .await
2107 .expect_err("unknown env_mode must error");
2108 assert!(
2109 format!("{err:?}").contains("env_mode"),
2110 "error should name env_mode, got {err:?}"
2111 );
2112 });
2113 }
2114
2115 #[cfg(unix)]
2121 async fn process_exec_tmpdir_probe(
2122 workspace: &std::path::Path,
2123 caller_env: Option<VmValue>,
2124 ) -> String {
2125 let mut env_pairs = vec![(
2126 crate::value::intern_key("mode"),
2127 VmValue::String(arcstr::ArcStr::from("argv")),
2128 )];
2129 env_pairs.push((
2130 crate::value::intern_key("argv"),
2131 VmValue::List(std::sync::Arc::new(vec![
2132 VmValue::String(arcstr::ArcStr::from("/bin/sh")),
2133 VmValue::String(arcstr::ArcStr::from("-c")),
2134 VmValue::String(arcstr::ArcStr::from("printf '%s' \"$TMPDIR\"")),
2135 ])),
2136 ));
2137 if let Some(env) = caller_env {
2138 env_pairs.push((crate::value::intern_key("env"), env));
2139 }
2140 let params = crate::value::DictMap::from_iter(env_pairs);
2141
2142 crate::orchestration::push_execution_policy(crate::orchestration::CapabilityPolicy {
2143 sandbox_profile: crate::orchestration::SandboxProfile::Worktree,
2144 workspace_roots: vec![workspace.to_string_lossy().into_owned()],
2145 ..crate::orchestration::CapabilityPolicy::default()
2149 });
2150 std::env::set_var("HARN_HANDLER_SANDBOX", "off");
2151 let result = super::dispatch_process_exec(¶ms, serde_json::Value::Null)
2152 .await
2153 .expect("process.exec result");
2154 std::env::remove_var("HARN_HANDLER_SANDBOX");
2155 crate::orchestration::pop_execution_policy();
2156 result
2157 .as_dict()
2158 .and_then(|d| d.get("stdout"))
2159 .map(VmValue::display)
2160 .unwrap_or_default()
2161 }
2162
2163 #[cfg(unix)]
2164 #[test]
2165 fn process_exec_injects_workspace_local_tmpdir() {
2166 run_host_async_test(|| async {
2167 let workspace = tempfile::tempdir().expect("workspace");
2168 let tmpdir = process_exec_tmpdir_probe(workspace.path(), None).await;
2169
2170 assert!(
2171 !tmpdir.is_empty(),
2172 "sandboxed child must receive a non-empty TMPDIR"
2173 );
2174 let tmpdir_path = std::path::PathBuf::from(&tmpdir);
2175 let canonical_tmpdir = std::fs::canonicalize(&tmpdir_path)
2176 .expect("workspace-local TMPDIR should canonicalize");
2177 let canonical_workspace =
2178 std::fs::canonicalize(workspace.path()).expect("workspace should canonicalize");
2179 assert!(
2180 canonical_tmpdir.starts_with(&canonical_workspace),
2181 "child TMPDIR {tmpdir:?} must live inside the workspace {:?}",
2182 workspace.path()
2183 );
2184 assert!(
2185 tmpdir_path.ends_with(".harn-tmp"),
2186 "child TMPDIR {tmpdir:?} must be the workspace-local .harn-tmp dir"
2187 );
2188 assert!(
2189 tmpdir_path.is_dir(),
2190 "the workspace-local TMPDIR must have been created on disk"
2191 );
2192 });
2193 }
2194
2195 #[cfg(unix)]
2196 #[test]
2197 fn process_exec_respects_caller_pinned_tmpdir() {
2198 run_host_async_test(|| async {
2199 let workspace = tempfile::tempdir().expect("workspace");
2200 let caller_tmp = workspace.path().join("caller-chosen");
2201 std::fs::create_dir_all(&caller_tmp).unwrap();
2202 let caller_env = VmValue::dict(crate::value::DictMap::from_iter([(
2203 crate::value::intern_key("TMPDIR"),
2204 VmValue::String(arcstr::ArcStr::from(
2205 caller_tmp.to_string_lossy().into_owned(),
2206 )),
2207 )]));
2208
2209 let tmpdir = process_exec_tmpdir_probe(workspace.path(), Some(caller_env)).await;
2210
2211 assert_eq!(
2212 std::path::PathBuf::from(&tmpdir),
2213 caller_tmp,
2214 "an explicit caller TMPDIR must override the workspace-local default"
2215 );
2216 });
2217 }
2218
2219 #[test]
2220 fn host_tool_list_is_empty_without_bridge() {
2221 run_host_async_test(|| async {
2222 clear_host_call_bridge();
2223 let tools = dispatch_host_tool_list().await.expect("tool list");
2224 let VmValue::List(items) = tools else {
2225 panic!("expected tool list");
2226 };
2227 assert!(items.is_empty());
2228 });
2229 }
2230}