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 trait HostCallBridge: Send + Sync {
411 fn dispatch(
412 &self,
413 capability: &str,
414 operation: &str,
415 params: &crate::value::DictMap,
416 ) -> Result<Option<VmValue>, VmError>;
417
418 fn list_tools(&self) -> Result<Option<VmValue>, VmError> {
419 Ok(None)
420 }
421
422 fn call_tool(&self, _name: &str, _args: &VmValue) -> Result<Option<VmValue>, VmError> {
423 Ok(None)
424 }
425}
426
427thread_local! {
428 static HOST_CALL_BRIDGE: RefCell<Option<Arc<dyn HostCallBridge>>> = const { RefCell::new(None) };
429}
430
431pub fn set_host_call_bridge(bridge: Arc<dyn HostCallBridge>) {
436 HOST_CALL_BRIDGE.with(|b| *b.borrow_mut() = Some(bridge));
437}
438
439pub fn clear_host_call_bridge() {
441 HOST_CALL_BRIDGE.with(|b| *b.borrow_mut() = None);
442}
443
444pub fn dispatch_host_call_bridge(
454 capability: &str,
455 operation: &str,
456 params: &crate::value::DictMap,
457) -> Option<Result<VmValue, VmError>> {
458 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone())?;
459 match bridge.dispatch(capability, operation, params) {
460 Ok(Some(value)) => Some(Ok(value)),
461 Ok(None) => None,
462 Err(error) => Some(Err(error)),
463 }
464}
465
466fn empty_tool_list_value() -> VmValue {
467 VmValue::List(std::sync::Arc::new(Vec::new()))
468}
469
470fn current_vm_host_bridge(
471 ctx: Option<&AsyncBuiltinCtx>,
472) -> Option<std::sync::Arc<crate::bridge::HostBridge>> {
473 ctx.and_then(|ctx| ctx.child_vm().bridge.clone())
474}
475
476#[cfg(test)]
477async fn dispatch_host_tool_list() -> Result<VmValue, VmError> {
478 dispatch_host_tool_list_with_ctx(None).await
479}
480
481async fn dispatch_host_tool_list_with_ctx(
482 ctx: Option<&AsyncBuiltinCtx>,
483) -> Result<VmValue, VmError> {
484 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
485 if let Some(bridge) = bridge {
486 if let Some(value) = bridge.list_tools()? {
487 return Ok(value);
488 }
489 }
490
491 let Some(bridge) = current_vm_host_bridge(ctx) else {
492 return Ok(empty_tool_list_value());
493 };
494 let tools = bridge.list_host_tools().await?;
495 Ok(crate::bridge::json_result_to_vm_value(&JsonValue::Array(
496 tools.into_iter().collect(),
497 )))
498}
499
500pub(crate) async fn dispatch_host_tool_call(
501 name: &str,
502 args: &VmValue,
503) -> Result<VmValue, VmError> {
504 dispatch_host_tool_call_with_ctx(None, name, args).await
505}
506
507pub(crate) async fn dispatch_host_tool_call_with_ctx(
508 ctx: Option<&AsyncBuiltinCtx>,
509 name: &str,
510 args: &VmValue,
511) -> Result<VmValue, VmError> {
512 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
513 if let Some(bridge) = bridge {
514 if let Some(value) = bridge.call_tool(name, args)? {
515 return Ok(value);
516 }
517 }
518
519 let Some(bridge) = current_vm_host_bridge(ctx) else {
520 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
521 "host_tool_call: no host bridge is attached",
522 ))));
523 };
524
525 let result = bridge
526 .call(
527 "builtin_call",
528 serde_json::json!({
529 "name": name,
530 "args": [crate::llm::vm_value_to_json(args)],
531 }),
532 )
533 .await?;
534 Ok(crate::bridge::json_result_to_vm_value(&result))
535}
536
537pub(crate) async fn dispatch_host_operation(
538 capability: &str,
539 operation: &str,
540 params: &crate::value::DictMap,
541) -> Result<VmValue, VmError> {
542 dispatch_host_operation_with_ctx(None, capability, operation, params).await
543}
544
545pub(crate) async fn dispatch_host_operation_with_ctx(
546 ctx: Option<&AsyncBuiltinCtx>,
547 capability: &str,
548 operation: &str,
549 params: &crate::value::DictMap,
550) -> Result<VmValue, VmError> {
551 if let Some(mocked) = dispatch_mock_host_call(capability, operation, params) {
552 return mocked;
553 }
554
555 if (capability, operation) == ("process", "exec") {
556 let caller = serde_json::json!({
557 "surface": "host_call",
558 "capability": "process",
559 "operation": "exec",
560 "session_id": crate::llm::current_agent_session_id(),
561 });
562 return dispatch_process_exec_with_policy(ctx, params, caller).await;
563 }
564
565 if (capability, operation) == ("process", "spawn") {
572 let caller = serde_json::json!({
573 "surface": "host_call",
574 "capability": "process",
575 "operation": "spawn",
576 "session_id": crate::llm::current_agent_session_id(),
577 });
578 return dispatch_process_spawn_with_policy(ctx, params, caller).await;
579 }
580 if capability == "process" && matches!(operation, "poll" | "wait" | "kill" | "release") {
581 if let Some(result) = crate::stdlib::process_spawn::dispatch(operation, params).await {
582 return result;
583 }
584 }
585
586 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
587 if let Some(bridge) = bridge {
588 if let Some(value) = bridge.dispatch(capability, operation, params)? {
589 return Ok(value);
590 }
591 }
592
593 dispatch_builtin_host_operation(capability, operation, params).await
594}
595
596async fn dispatch_builtin_host_operation(
597 capability: &str,
598 operation: &str,
599 params: &crate::value::DictMap,
600) -> Result<VmValue, VmError> {
601 match (capability, operation) {
602 ("process", "list_shells") => Ok(crate::shells::list_shells_vm_value()),
603 ("process", "get_default_shell") => Ok(crate::shells::default_shell_vm_value()),
604 ("process", "set_default_shell") => crate::shells::set_default_shell_vm_value(params),
605 ("process", "shell_invocation") => crate::shells::shell_invocation_vm_value(params),
606 ("template", "render") => {
607 let path = require_param(params, "path")?;
608 let bindings = params.get("bindings").and_then(|v| v.as_dict());
609 Ok(VmValue::String(arcstr::ArcStr::from(render_template(
610 &path, bindings,
611 )?)))
612 }
613 ("interaction", "ask") => {
614 let question = require_param(params, "question")?;
615 use std::io::BufRead;
616 print!("{question}");
617 let _ = std::io::Write::flush(&mut std::io::stdout());
618 let mut input = String::new();
619 if std::io::stdin().lock().read_line(&mut input).is_ok() {
620 Ok(VmValue::String(arcstr::ArcStr::from(input.trim_end())))
621 } else {
622 Ok(VmValue::Nil)
623 }
624 }
625 ("project", "metadata_get") => crate::metadata::project_metadata_host_get(params),
626 ("project", "metadata_inspect") => crate::metadata::project_metadata_host_inspect(params),
627 ("project", "metadata_set") => crate::metadata::project_metadata_host_set(params),
628 ("project", "metadata_save") => crate::metadata::project_metadata_host_save(params),
629 ("project", "metadata_stale") => crate::metadata::project_metadata_host_stale(params),
630 ("project", "metadata_refresh_hashes") => {
631 crate::metadata::project_metadata_host_refresh_hashes(params)
632 }
633 ("runtime", "task") => Ok(VmValue::String(arcstr::ArcStr::from(
638 std::env::var("HARN_TASK").unwrap_or_default(),
639 ))),
640 ("runtime", "set_result") => {
641 Ok(VmValue::Nil)
644 }
645 ("workspace", "project_root") => {
646 let path = std::env::var("HARN_PROJECT_ROOT").unwrap_or_else(|_| {
650 std::env::current_dir()
651 .map(|p| p.display().to_string())
652 .unwrap_or_default()
653 });
654 Ok(VmValue::String(arcstr::ArcStr::from(path)))
655 }
656 ("workspace", "cwd") => {
657 let path = std::env::current_dir()
658 .map(|p| p.display().to_string())
659 .unwrap_or_default();
660 Ok(VmValue::String(arcstr::ArcStr::from(path)))
661 }
662 _ => Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
663 format!("host_call: unsupported operation {capability}.{operation}"),
664 )))),
665 }
666}
667
668pub(crate) async fn dispatch_process_exec(
669 params: &crate::value::DictMap,
670 caller: serde_json::Value,
671) -> Result<VmValue, VmError> {
672 dispatch_process_exec_with_policy(None, params, caller).await
673}
674
675async fn dispatch_process_exec_with_policy(
676 ctx: Option<&AsyncBuiltinCtx>,
677 params: &crate::value::DictMap,
678 caller: serde_json::Value,
679) -> Result<VmValue, VmError> {
680 let (params, command_policy_context, command_policy_decisions) =
681 match crate::orchestration::run_command_policy_preflight_with_ctx(ctx, params, caller)
682 .await?
683 {
684 crate::orchestration::CommandPolicyPreflight::Proceed {
685 params,
686 context,
687 decisions,
688 } => (params, context, decisions),
689 crate::orchestration::CommandPolicyPreflight::Blocked {
690 status,
691 message,
692 context,
693 decisions,
694 } => {
695 return Ok(crate::orchestration::blocked_command_response(
696 params, status, &message, context, decisions,
697 ));
698 }
699 };
700
701 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
702 if let Some(bridge) = bridge {
703 if let Some(value) = bridge.dispatch("process", "exec", ¶ms)? {
704 return crate::orchestration::run_command_policy_postflight_with_ctx(
705 ctx,
706 ¶ms,
707 value,
708 command_policy_context,
709 command_policy_decisions,
710 )
711 .await;
712 }
713 }
714
715 dispatch_process_exec_after_policy(
716 ctx,
717 ¶ms,
718 command_policy_context,
719 command_policy_decisions,
720 )
721 .await
722}
723
724async fn dispatch_process_spawn_with_policy(
731 ctx: Option<&AsyncBuiltinCtx>,
732 params: &crate::value::DictMap,
733 caller: serde_json::Value,
734) -> Result<VmValue, VmError> {
735 let params =
736 match crate::orchestration::run_command_policy_preflight_with_ctx(ctx, params, caller)
737 .await?
738 {
739 crate::orchestration::CommandPolicyPreflight::Proceed { params, .. } => params,
740 crate::orchestration::CommandPolicyPreflight::Blocked {
741 status,
742 message,
743 context,
744 decisions,
745 } => {
746 return Ok(crate::orchestration::blocked_command_response(
747 params, status, &message, context, decisions,
748 ));
749 }
750 };
751
752 match crate::stdlib::process_spawn::dispatch("spawn", ¶ms).await {
753 Some(result) => result,
754 None => Err(VmError::Runtime(
755 "host_call process.spawn: dispatch returned None".to_string(),
756 )),
757 }
758}
759
760async fn dispatch_process_exec_after_policy(
761 ctx: Option<&AsyncBuiltinCtx>,
762 params: &crate::value::DictMap,
763 command_policy_context: JsonValue,
764 command_policy_decisions: Vec<crate::orchestration::CommandPolicyDecision>,
765) -> Result<VmValue, VmError> {
766 let timeout_ms = optional_i64(params, "timeout")
767 .or_else(|| optional_i64(params, "timeout_ms"))
768 .filter(|value| *value > 0)
769 .map(|value| value as u64);
770 let profile_guard = match optional_string(params, "sandbox_profile") {
776 Some(value) => Some(push_sandbox_profile_override(&value)?),
777 None => None,
778 };
779 let mut cmd = build_sandboxed_command(params, "process.exec")?;
780 cmd.stdin(std::process::Stdio::null())
781 .stdout(std::process::Stdio::piped())
782 .stderr(std::process::Stdio::piped())
783 .kill_on_drop(true);
784 let started_at = audited_utc_now_rfc3339("host_call/process.exec.started_at");
785 let started = crate::clock_mock::leak_audit::instant_now("host_call/process.exec.started");
786 let child = cmd
787 .spawn()
788 .map_err(|e| VmError::Runtime(format!("host_call process.exec: {e}")))?;
789 drop(profile_guard);
790 let pid = child.id();
791 let timed_out;
792 let output_result = if let Some(timeout_ms) = timeout_ms {
793 match tokio::time::timeout(
794 std::time::Duration::from_millis(timeout_ms),
795 child.wait_with_output(),
796 )
797 .await
798 {
799 Ok(result) => {
800 timed_out = false;
801 result
802 }
803 Err(_) => {
804 let response = process_exec_response(ProcessExecResponse {
805 pid,
806 started_at,
807 started,
808 stdout: "",
809 stderr: "",
810 exit_code: -1,
811 status: "timed_out",
812 success: false,
813 timed_out: true,
814 });
815 return crate::orchestration::run_command_policy_postflight_with_ctx(
816 ctx,
817 params,
818 response,
819 command_policy_context,
820 command_policy_decisions,
821 )
822 .await;
823 }
824 }
825 } else {
826 timed_out = false;
827 child.wait_with_output().await
828 };
829 let output =
830 output_result.map_err(|e| VmError::Runtime(format!("host_call process.exec: {e}")))?;
831 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
832 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
833 let exit_code = output.status.code().unwrap_or(-1);
834 let response = process_exec_response(ProcessExecResponse {
835 pid,
836 started_at,
837 started,
838 stdout: &stdout,
839 stderr: &stderr,
840 exit_code,
841 status: if timed_out { "timed_out" } else { "completed" },
842 success: output.status.success(),
843 timed_out,
844 });
845 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
855pub(crate) fn build_sandboxed_command(
867 params: &crate::value::DictMap,
868 label: &str,
869) -> Result<tokio::process::Command, VmError> {
870 let (program, args) = process_exec_argv(params)?;
871 let mut cmd = crate::process_sandbox::tokio_command_for(&program, &args)
872 .map_err(|e| VmError::Runtime(format!("host_call {label} sandbox setup: {e}")))?;
873 if let Some(cwd) = optional_string(params, "cwd") {
874 let cwd = resolve_process_exec_cwd(&cwd);
875 crate::process_sandbox::enforce_process_cwd(&cwd)
876 .map_err(|e| VmError::Runtime(format!("host_call {label} cwd: {e}")))?;
877 cmd.current_dir(cwd);
878 }
879 let mut caller_env_keys: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
882 if let Some(env) = optional_string_dict(params, "env")? {
883 let env_mode = optional_string(params, "env_mode");
892 match env_mode.as_deref().unwrap_or("merge") {
893 "replace" => {
894 cmd.env_clear();
895 }
896 "merge" => {}
897 other => {
898 return Err(VmError::Runtime(format!(
899 "host_call {label}: unknown env_mode {other:?}; expected \"merge\" or \"replace\""
900 )));
901 }
902 }
903 for (key, value) in env {
904 caller_env_keys.insert(key.clone());
905 cmd.env(key, value);
906 }
907 }
908 if let Some(env_remove) = optional_string_list(params, "env_remove") {
914 for key in env_remove {
915 caller_env_keys.insert(key.clone());
916 cmd.env_remove(key);
917 }
918 }
919 for (key, value) in crate::process_sandbox::active_workspace_tmpdir_env() {
928 if caller_env_keys.contains(&key) {
929 continue;
930 }
931 cmd.env(key, value);
932 }
933 if !caller_env_keys.contains(crate::process_sandbox::MESSAGE_LOCALE_OVERRIDE_ENV) {
941 cmd.env_remove(crate::process_sandbox::MESSAGE_LOCALE_OVERRIDE_ENV);
942 }
943 for (key, value) in crate::process_sandbox::deterministic_message_locale_env() {
944 if caller_env_keys.contains(&key) {
945 continue;
946 }
947 cmd.env(key, value);
948 }
949 Ok(cmd)
950}
951
952struct ProcessExecResponse<'a> {
953 pid: Option<u32>,
954 started_at: String,
955 started: Instant,
956 stdout: &'a str,
957 stderr: &'a str,
958 exit_code: i32,
959 status: &'a str,
960 success: bool,
961 timed_out: bool,
962}
963
964fn process_exec_response(response: ProcessExecResponse<'_>) -> VmValue {
965 let combined = format!("{}{}", response.stdout, response.stderr);
966 let mut result = crate::value::DictMap::new();
967 result.put_str(
968 "command_id",
969 format!(
970 "cmd_{}_{}",
971 std::process::id(),
972 response.started.elapsed().as_nanos()
973 ),
974 );
975 result.put_str("status", response.status);
976 result.insert(
977 crate::value::intern_key("pid"),
978 response
979 .pid
980 .map(|pid| VmValue::Int(pid as i64))
981 .unwrap_or(VmValue::Nil),
982 );
983 result.insert(
984 crate::value::intern_key("process_group_id"),
985 response
986 .pid
987 .map(|pid| VmValue::Int(pid as i64))
988 .unwrap_or(VmValue::Nil),
989 );
990 result.insert(crate::value::intern_key("handle_id"), VmValue::Nil);
991 result.put_str("started_at", response.started_at);
992 result.put_str(
993 "ended_at",
994 audited_utc_now_rfc3339("host_call/process.exec.ended_at"),
995 );
996 result.insert(
997 crate::value::intern_key("duration_ms"),
998 VmValue::Int(response.started.elapsed().as_millis() as i64),
999 );
1000 result.insert(
1001 crate::value::intern_key("exit_code"),
1002 VmValue::Int(response.exit_code as i64),
1003 );
1004 result.insert(crate::value::intern_key("signal"), VmValue::Nil);
1005 result.insert(
1006 crate::value::intern_key("timed_out"),
1007 VmValue::Bool(response.timed_out),
1008 );
1009 result.put_str("stdout", response.stdout);
1010 result.put_str("stderr", response.stderr);
1011 result.put_str("combined", combined);
1012 result.insert(
1013 crate::value::intern_key("exit_status"),
1014 VmValue::Int(response.exit_code as i64),
1015 );
1016 result.insert(
1017 crate::value::intern_key("legacy_status"),
1018 VmValue::Int(response.exit_code as i64),
1019 );
1020 result.insert(
1021 crate::value::intern_key("success"),
1022 VmValue::Bool(response.success),
1023 );
1024 VmValue::dict(result)
1025}
1026
1027fn resolve_process_exec_cwd(cwd: &str) -> std::path::PathBuf {
1028 crate::stdlib::process::resolve_source_relative_path(cwd)
1029}
1030
1031fn process_exec_argv(params: &crate::value::DictMap) -> Result<(String, Vec<String>), VmError> {
1032 match optional_string(params, "mode")
1033 .as_deref()
1034 .unwrap_or("shell")
1035 {
1036 "argv" => {
1037 let argv = optional_string_list(params, "argv").ok_or_else(|| {
1038 VmError::Runtime("host_call process.exec missing argv".to_string())
1039 })?;
1040 split_argv(argv)
1041 }
1042 "shell" => {
1043 let command = require_param(params, "command")?;
1044 let mut invocation_params = params.clone();
1045 invocation_params.put_str("command", command);
1046 let invocation =
1047 crate::shells::resolve_invocation_from_vm_params(&invocation_params)
1048 .map_err(|err| VmError::Runtime(format!("host_call process.exec: {err}")))?;
1049 Ok((invocation.program, invocation.args))
1050 }
1051 other => Err(VmError::Runtime(format!(
1052 "host_call process.exec unsupported mode {other:?}"
1053 ))),
1054 }
1055}
1056
1057fn split_argv(mut argv: Vec<String>) -> Result<(String, Vec<String>), VmError> {
1058 if argv.is_empty() {
1059 return Err(VmError::Runtime(
1060 "host_call process.exec argv must not be empty".to_string(),
1061 ));
1062 }
1063 let program = argv.remove(0);
1064 if program.is_empty() {
1065 return Err(VmError::Runtime(
1066 "host_call process.exec argv[0] must not be empty".to_string(),
1067 ));
1068 }
1069 Ok((program, argv))
1070}
1071
1072pub(crate) fn push_sandbox_profile_override(value: &str) -> Result<SandboxProfileGuard, VmError> {
1078 let profile = crate::orchestration::SandboxProfile::parse(value).ok_or_else(|| {
1079 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
1080 "host_call process.exec: unknown sandbox_profile {value:?}; expected one of \"unrestricted\", \"worktree\", \"os_hardened\", \"wasi\""
1081 ))))
1082 })?;
1083 let mut policy = crate::orchestration::current_execution_policy().unwrap_or_default();
1084 policy.sandbox_profile = profile;
1085 crate::orchestration::push_execution_policy(policy);
1086 Ok(SandboxProfileGuard {
1087 _private: std::marker::PhantomData,
1088 })
1089}
1090
1091pub(crate) struct SandboxProfileGuard {
1092 _private: std::marker::PhantomData<*const ()>,
1093}
1094
1095impl Drop for SandboxProfileGuard {
1096 fn drop(&mut self) {
1097 crate::orchestration::pop_execution_policy();
1098 }
1099}
1100
1101pub(crate) fn optional_i64(params: &crate::value::DictMap, key: &str) -> Option<i64> {
1102 match params.get(key) {
1103 Some(VmValue::Int(value)) => Some(*value),
1104 Some(VmValue::Float(value)) if value.fract() == 0.0 => Some(*value as i64),
1105 _ => None,
1106 }
1107}
1108
1109pub(crate) fn optional_string(params: &crate::value::DictMap, key: &str) -> Option<String> {
1110 params.get(key).and_then(vm_string).map(ToString::to_string)
1111}
1112
1113fn optional_string_list(params: &crate::value::DictMap, key: &str) -> Option<Vec<String>> {
1114 let VmValue::List(values) = params.get(key)? else {
1115 return None;
1116 };
1117 values
1118 .iter()
1119 .map(|value| vm_string(value).map(ToString::to_string))
1120 .collect()
1121}
1122
1123fn optional_string_dict(
1124 params: &crate::value::DictMap,
1125 key: &str,
1126) -> Result<Option<BTreeMap<String, String>>, VmError> {
1127 let Some(value) = params.get(key) else {
1128 return Ok(None);
1129 };
1130 let Some(dict) = value.as_dict() else {
1131 return Err(VmError::Runtime(format!(
1132 "host_call process.exec {key} must be a dict"
1133 )));
1134 };
1135 let mut out = std::collections::BTreeMap::new();
1136 for (key, value) in dict.iter() {
1137 let Some(value) = vm_string(value) else {
1138 return Err(VmError::Runtime(format!(
1139 "host_call process.exec env value for {key:?} must be a string"
1140 )));
1141 };
1142 out.insert(key.to_string(), value.to_string());
1143 }
1144 Ok(Some(out))
1145}
1146
1147fn vm_string(value: &VmValue) -> Option<&str> {
1148 match value {
1149 VmValue::String(value) => Some(value.as_ref()),
1150 _ => None,
1151 }
1152}
1153
1154pub(crate) fn register_host_builtins(vm: &mut Vm) {
1155 for def in MODULE_BUILTINS {
1156 vm.register_builtin_def(def);
1157 }
1158}
1159
1160pub(crate) fn register_missing_host_builtins(vm: &mut Vm) {
1161 for def in MODULE_BUILTINS {
1162 if vm.builtin_metadata_for(def.sig.name).is_none() {
1163 vm.register_builtin_def(def);
1164 }
1165 }
1166}
1167
1168#[harn_builtin(
1169 sig = "host_mock(capability: string, op: string, response_or_config?: any, params?: dict) -> nil",
1170 category = "host"
1171)]
1172fn host_mock_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1173 let host_mock = parse_host_mock(args)?;
1174 push_host_mock(host_mock);
1175 Ok(VmValue::Nil)
1176}
1177
1178#[harn_builtin(sig = "host_mock_clear() -> nil", category = "host")]
1179fn host_mock_clear_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1180 reset_host_state();
1181 Ok(VmValue::Nil)
1182}
1183
1184#[harn_builtin(sig = "host_mock_calls() -> list", category = "host")]
1185fn host_mock_calls_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1186 let calls = HOST_MOCK_CALLS.with(|calls| {
1187 calls
1188 .borrow()
1189 .iter()
1190 .map(mock_call_value)
1191 .collect::<Vec<_>>()
1192 });
1193 Ok(VmValue::List(std::sync::Arc::new(calls)))
1194}
1195
1196#[harn_builtin(sig = "host_mock_push_scope() -> nil", category = "host")]
1197fn host_mock_push_scope_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1198 push_host_mock_scope();
1199 Ok(VmValue::Nil)
1200}
1201
1202#[harn_builtin(sig = "host_mock_pop_scope() -> nil", category = "host")]
1203fn host_mock_pop_scope_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1204 if !pop_host_mock_scope() {
1205 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1206 "host_mock_pop_scope: no scope to pop",
1207 ))));
1208 }
1209 Ok(VmValue::Nil)
1210}
1211
1212#[harn_builtin(sig = "host_capabilities() -> dict", category = "host")]
1213fn host_capabilities_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1214 Ok(capability_manifest_with_mocks())
1215}
1216
1217#[harn_builtin(
1218 sig = "host_has(capability: string, op?: string) -> bool",
1219 category = "host"
1220)]
1221fn host_has_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1222 let capability = args.first().map(|a| a.display()).unwrap_or_default();
1223 let operation = args.get(1).map(|a| a.display());
1224 let manifest = capability_manifest_with_mocks();
1225 let has = manifest
1226 .as_dict()
1227 .and_then(|d| d.get(capability.as_str()))
1228 .and_then(|v| v.as_dict())
1229 .is_some_and(|cap| {
1230 if let Some(operation) = operation {
1231 cap.get("ops")
1232 .and_then(|v| match v {
1233 VmValue::List(list) => {
1234 Some(list.iter().any(|item| item.display() == operation))
1235 }
1236 _ => None,
1237 })
1238 .unwrap_or(false)
1239 } else {
1240 true
1241 }
1242 });
1243 Ok(VmValue::Bool(has))
1244}
1245
1246#[harn_builtin(
1247 sig = "host_call(name: string, args?: dict) -> any",
1248 kind = "async",
1249 category = "host"
1250)]
1251async fn host_call_builtin(
1252 ctx: crate::vm::AsyncBuiltinCtx,
1253 args: Vec<VmValue>,
1254) -> Result<VmValue, VmError> {
1255 let name = args.first().map(|a| a.display()).unwrap_or_default();
1256 let params = args
1257 .get(1)
1258 .and_then(|a| a.as_dict())
1259 .cloned()
1260 .unwrap_or_default();
1261 let Some((capability, operation)) = name.split_once('.') else {
1262 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1263 format!("host_call: unsupported operation name '{name}'"),
1264 ))));
1265 };
1266 dispatch_host_operation_with_ctx(Some(&ctx), capability, operation, ¶ms).await
1267}
1268
1269#[harn_builtin(sig = "host_tool_list() -> list", kind = "async", category = "host")]
1270async fn host_tool_list_builtin(
1271 ctx: crate::vm::AsyncBuiltinCtx,
1272 _args: Vec<VmValue>,
1273) -> Result<VmValue, VmError> {
1274 dispatch_host_tool_list_with_ctx(Some(&ctx)).await
1275}
1276
1277#[harn_builtin(
1278 sig = "host_tool_call(name: string, args?: any) -> any",
1279 kind = "async",
1280 category = "host"
1281)]
1282async fn host_tool_call_builtin(
1283 ctx: crate::vm::AsyncBuiltinCtx,
1284 args: Vec<VmValue>,
1285) -> Result<VmValue, VmError> {
1286 let name = args.first().map(|a| a.display()).unwrap_or_default();
1287 if name.is_empty() {
1288 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1289 "host_tool_call: tool name is required",
1290 ))));
1291 }
1292 let call_args = args.get(1).cloned().unwrap_or(VmValue::Nil);
1293 dispatch_host_tool_call_with_ctx(Some(&ctx), &name, &call_args).await
1294}
1295
1296#[cfg(test)]
1297mod tests {
1298 use super::{
1299 build_sandboxed_command, capability_manifest_with_mocks, clear_host_call_bridge,
1300 dispatch_host_operation, dispatch_host_tool_call, dispatch_host_tool_list,
1301 dispatch_mock_host_call, push_host_mock, reset_host_state, resolve_process_exec_cwd,
1302 set_host_call_bridge, HostCallBridge, HostMock,
1303 };
1304 use crate::value::VmDictExt;
1305
1306 use std::sync::{
1307 atomic::{AtomicUsize, Ordering},
1308 Arc,
1309 };
1310
1311 use crate::value::{VmError, VmValue};
1312
1313 fn command_env(
1317 cmd: &tokio::process::Command,
1318 ) -> std::collections::BTreeMap<String, Option<String>> {
1319 cmd.as_std()
1320 .get_envs()
1321 .map(|(k, v)| {
1322 (
1323 k.to_string_lossy().into_owned(),
1324 v.map(|value| value.to_string_lossy().into_owned()),
1325 )
1326 })
1327 .collect()
1328 }
1329
1330 #[test]
1331 fn build_sandboxed_command_forces_deterministic_message_locale() {
1332 let mut params = crate::value::DictMap::new();
1342 params.put_str("mode", "argv");
1343 params.put(
1344 "argv",
1345 VmValue::List(Arc::new(vec![VmValue::string("/bin/true")])),
1346 );
1347 params.put_str("env_mode", "merge");
1348 let mut caller_env = crate::value::DictMap::new();
1349 caller_env.put_str("CARGO_TARGET_DIR", "/tmp/target");
1351 params.put("env", VmValue::dict_map(caller_env));
1352
1353 let cmd = build_sandboxed_command(¶ms, "process.exec").expect("build command");
1354 let env = command_env(&cmd);
1355
1356 assert_eq!(
1357 env.get("LC_ALL"),
1358 Some(&None),
1359 "the builder must remove LC_ALL from the child so an inherited shell \
1360 value cannot override the forced LC_MESSAGES"
1361 );
1362 assert_eq!(
1363 env.get("LC_MESSAGES"),
1364 Some(&Some("C".to_string())),
1365 "LC_MESSAGES must be pinned to C for untranslated (English) tool output"
1366 );
1367 assert_eq!(
1368 env.get("DOTNET_CLI_UI_LANGUAGE"),
1369 Some(&Some("en".to_string())),
1370 ".NET ignores LC_* and needs its own UI-language override"
1371 );
1372 }
1373
1374 #[test]
1375 fn build_sandboxed_command_respects_a_caller_pinned_locale() {
1376 let mut params = crate::value::DictMap::new();
1379 params.put_str("mode", "argv");
1380 params.put(
1381 "argv",
1382 VmValue::List(Arc::new(vec![VmValue::string("/bin/true")])),
1383 );
1384 params.put_str("env_mode", "merge");
1385 let mut caller_env = crate::value::DictMap::new();
1386 caller_env.put_str("LC_ALL", "fr_FR.UTF-8");
1387 caller_env.put_str("LC_MESSAGES", "fr_FR.UTF-8");
1388 params.put("env", VmValue::dict_map(caller_env));
1389
1390 let cmd = build_sandboxed_command(¶ms, "process.exec").expect("build command");
1391 let env = command_env(&cmd);
1392
1393 assert_eq!(
1394 env.get("LC_ALL"),
1395 Some(&Some("fr_FR.UTF-8".to_string())),
1396 "a caller that pins LC_ALL keeps it — the overlay must not strip an explicit value"
1397 );
1398 assert_eq!(
1399 env.get("LC_MESSAGES"),
1400 Some(&Some("fr_FR.UTF-8".to_string())),
1401 "a caller-pinned LC_MESSAGES wins over the C overlay"
1402 );
1403 }
1404
1405 #[test]
1406 fn process_exec_relative_cwd_resolves_against_execution_root() {
1407 let dir = tempfile::tempdir().expect("tempdir");
1408 crate::stdlib::process::set_thread_execution_context(Some(
1409 crate::orchestration::RunExecutionRecord {
1410 cwd: Some(dir.path().to_string_lossy().into_owned()),
1411 source_dir: Some(dir.path().join("src").to_string_lossy().into_owned()),
1412 env: std::collections::BTreeMap::new(),
1413 adapter: None,
1414 repo_path: None,
1415 worktree_path: None,
1416 branch: None,
1417 base_ref: None,
1418 cleanup: None,
1419 },
1420 ));
1421
1422 assert_eq!(
1423 resolve_process_exec_cwd("subdir"),
1424 dir.path().join("subdir")
1425 );
1426
1427 crate::stdlib::process::set_thread_execution_context(None);
1428 }
1429
1430 #[test]
1431 fn manifest_includes_operation_metadata() {
1432 let manifest = capability_manifest_with_mocks();
1433 let process = manifest
1434 .as_dict()
1435 .and_then(|d| d.get("process"))
1436 .and_then(|v| v.as_dict())
1437 .expect("process capability");
1438 assert!(process.get("description").is_some());
1439 let operations = process
1440 .get("operations")
1441 .and_then(|v| v.as_dict())
1442 .expect("operations dict");
1443 assert!(operations.get("exec").is_some());
1444 }
1445
1446 #[test]
1447 fn mocked_capabilities_appear_in_manifest() {
1448 reset_host_state();
1449 push_host_mock(HostMock {
1450 capability: "project".to_string(),
1451 operation: "metadata_get".to_string(),
1452 params: None,
1453 result: Some(VmValue::dict(crate::value::DictMap::new())),
1454 error: None,
1455 });
1456 let manifest = capability_manifest_with_mocks();
1457 let project = manifest
1458 .as_dict()
1459 .and_then(|d| d.get("project"))
1460 .and_then(|v| v.as_dict())
1461 .expect("project capability");
1462 let operations = project
1463 .get("operations")
1464 .and_then(|v| v.as_dict())
1465 .expect("operations dict");
1466 assert!(operations.get("metadata_get").is_some());
1467 reset_host_state();
1468 }
1469
1470 #[test]
1471 fn mock_host_call_matches_partial_params_and_overrides_order() {
1472 reset_host_state();
1473 let mut exact_params = crate::value::DictMap::new();
1474 exact_params.put_str("namespace", "facts");
1475 push_host_mock(HostMock {
1476 capability: "project".to_string(),
1477 operation: "metadata_get".to_string(),
1478 params: None,
1479 result: Some(VmValue::String(arcstr::ArcStr::from("fallback"))),
1480 error: None,
1481 });
1482 push_host_mock(HostMock {
1483 capability: "project".to_string(),
1484 operation: "metadata_get".to_string(),
1485 params: Some(exact_params),
1486 result: Some(VmValue::String(arcstr::ArcStr::from("facts"))),
1487 error: None,
1488 });
1489
1490 let mut call_params = crate::value::DictMap::new();
1491 call_params.put_str("dir", "pkg");
1492 call_params.put_str("namespace", "facts");
1493 let exact = dispatch_mock_host_call("project", "metadata_get", &call_params)
1494 .expect("expected exact mock")
1495 .expect("exact mock should succeed");
1496 assert_eq!(exact.display(), "facts");
1497
1498 call_params.put_str("namespace", "classification");
1499 let fallback = dispatch_mock_host_call("project", "metadata_get", &call_params)
1500 .expect("expected fallback mock")
1501 .expect("fallback mock should succeed");
1502 assert_eq!(fallback.display(), "fallback");
1503 reset_host_state();
1504 }
1505
1506 #[test]
1507 fn mock_host_call_can_throw_errors() {
1508 reset_host_state();
1509 push_host_mock(HostMock {
1510 capability: "project".to_string(),
1511 operation: "metadata_get".to_string(),
1512 params: None,
1513 result: None,
1514 error: Some("boom".to_string()),
1515 });
1516 let params = crate::value::DictMap::new();
1517 let result = dispatch_mock_host_call("project", "metadata_get", ¶ms)
1518 .expect("expected mock result");
1519 match result {
1520 Err(VmError::Thrown(VmValue::String(message))) => assert_eq!(message.as_str(), "boom"),
1521 other => panic!("unexpected result: {other:?}"),
1522 }
1523 reset_host_state();
1524 }
1525
1526 #[derive(Default)]
1527 struct TestHostToolBridge;
1528
1529 impl HostCallBridge for TestHostToolBridge {
1530 fn dispatch(
1531 &self,
1532 _capability: &str,
1533 _operation: &str,
1534 _params: &crate::value::DictMap,
1535 ) -> Result<Option<VmValue>, VmError> {
1536 Ok(None)
1537 }
1538
1539 fn list_tools(&self) -> Result<Option<VmValue>, VmError> {
1540 let tool = VmValue::dict(crate::value::DictMap::from_iter([
1541 (
1542 crate::value::intern_key("name"),
1543 VmValue::String(arcstr::ArcStr::from("Read".to_string())),
1544 ),
1545 (
1546 crate::value::intern_key("description"),
1547 VmValue::String(arcstr::ArcStr::from(
1548 "Read a file from the host".to_string(),
1549 )),
1550 ),
1551 (
1552 crate::value::intern_key("schema"),
1553 VmValue::dict(crate::value::DictMap::from_iter([(
1554 crate::value::intern_key("type"),
1555 VmValue::String(arcstr::ArcStr::from("object".to_string())),
1556 )])),
1557 ),
1558 (crate::value::intern_key("deprecated"), VmValue::Bool(false)),
1559 ]));
1560 Ok(Some(VmValue::List(std::sync::Arc::new(vec![tool]))))
1561 }
1562
1563 fn call_tool(&self, name: &str, args: &VmValue) -> Result<Option<VmValue>, VmError> {
1564 if name != "Read" {
1565 return Ok(None);
1566 }
1567 let path = args
1568 .as_dict()
1569 .and_then(|dict| dict.get("path"))
1570 .map(|value| value.display())
1571 .unwrap_or_default();
1572 Ok(Some(VmValue::String(arcstr::ArcStr::from(format!(
1573 "read:{path}"
1574 )))))
1575 }
1576 }
1577
1578 struct CountingProcessExecBridge {
1579 calls: Arc<AtomicUsize>,
1580 }
1581
1582 impl HostCallBridge for CountingProcessExecBridge {
1583 fn dispatch(
1584 &self,
1585 capability: &str,
1586 operation: &str,
1587 _params: &crate::value::DictMap,
1588 ) -> Result<Option<VmValue>, VmError> {
1589 if (capability, operation) != ("process", "exec") {
1590 return Ok(None);
1591 }
1592 self.calls.fetch_add(1, Ordering::SeqCst);
1593 Ok(Some(VmValue::dict(crate::value::DictMap::from_iter([
1594 (
1595 crate::value::intern_key("status"),
1596 VmValue::String(arcstr::ArcStr::from("completed".to_string())),
1597 ),
1598 (crate::value::intern_key("exit_code"), VmValue::Int(0)),
1599 (crate::value::intern_key("success"), VmValue::Bool(true)),
1600 ]))))
1601 }
1602 }
1603
1604 fn run_host_async_test<F, Fut>(test: F)
1605 where
1606 F: FnOnce() -> Fut,
1607 Fut: std::future::Future<Output = ()>,
1608 {
1609 let rt = tokio::runtime::Builder::new_current_thread()
1610 .enable_all()
1611 .build()
1612 .expect("runtime");
1613 rt.block_on(async {
1614 let local = tokio::task::LocalSet::new();
1615 local.run_until(test()).await;
1616 });
1617 }
1618
1619 #[test]
1620 fn host_tool_list_uses_installed_host_call_bridge() {
1621 run_host_async_test(|| async {
1622 reset_host_state();
1623 set_host_call_bridge(Arc::new(TestHostToolBridge));
1624 let tools = dispatch_host_tool_list().await.expect("tool list");
1625 clear_host_call_bridge();
1626
1627 let VmValue::List(items) = tools else {
1628 panic!("expected tool list");
1629 };
1630 assert_eq!(items.len(), 1);
1631 let tool = items[0].as_dict().expect("tool dict");
1632 assert_eq!(tool.get("name").unwrap().display(), "Read");
1633 assert_eq!(tool.get("deprecated").unwrap().display(), "false");
1634 });
1635 }
1636
1637 #[test]
1638 fn host_tool_call_uses_installed_host_call_bridge() {
1639 run_host_async_test(|| async {
1640 set_host_call_bridge(Arc::new(TestHostToolBridge));
1641 let args = VmValue::dict(crate::value::DictMap::from_iter([(
1642 crate::value::intern_key("path"),
1643 VmValue::String(arcstr::ArcStr::from("README.md".to_string())),
1644 )]));
1645 let value = dispatch_host_tool_call("Read", &args)
1646 .await
1647 .expect("tool call");
1648 clear_host_call_bridge();
1649 assert_eq!(value.display(), "read:README.md");
1650 });
1651 }
1652
1653 #[test]
1654 fn process_exec_bridge_is_gated_by_command_policy() {
1655 run_host_async_test(|| async {
1656 crate::orchestration::clear_command_policies();
1657 let calls = Arc::new(AtomicUsize::new(0));
1658 set_host_call_bridge(Arc::new(CountingProcessExecBridge {
1659 calls: calls.clone(),
1660 }));
1661 crate::orchestration::push_command_policy(crate::orchestration::CommandPolicy {
1662 tools: vec!["run".to_string()],
1663 workspace_roots: Vec::new(),
1664 default_shell_mode: "shell".to_string(),
1665 deny_patterns: vec!["cat *".to_string()],
1666 require_approval: Default::default(),
1667 pre: None,
1668 post: None,
1669 allow_recursive: false,
1670 });
1671
1672 let result = dispatch_host_operation(
1673 "process",
1674 "exec",
1675 &crate::value::DictMap::from_iter([
1676 (
1677 crate::value::intern_key("mode"),
1678 VmValue::String(arcstr::ArcStr::from("shell")),
1679 ),
1680 (
1681 crate::value::intern_key("command"),
1682 VmValue::String(arcstr::ArcStr::from("cat Cargo.toml")),
1683 ),
1684 ]),
1685 )
1686 .await
1687 .expect("process.exec result");
1688
1689 crate::orchestration::clear_command_policies();
1690 clear_host_call_bridge();
1691
1692 assert_eq!(
1693 calls.load(Ordering::SeqCst),
1694 0,
1695 "blocked command must not reach host bridge"
1696 );
1697 let result = result.as_dict().expect("blocked result dict");
1698 assert_eq!(result.get("status").unwrap().display(), "blocked");
1699 assert!(
1700 result
1701 .get("reason")
1702 .map(VmValue::display)
1703 .unwrap_or_default()
1704 .contains("cat *"),
1705 "blocked result should name the matched policy pattern"
1706 );
1707 });
1708 }
1709
1710 #[cfg(unix)]
1711 async fn process_exec_env_probe(env: VmValue, env_mode: Option<&str>) -> (String, String) {
1712 std::env::set_var("PARENT_VAR", "inherited");
1717 let mut params = crate::value::DictMap::from_iter([
1718 (
1719 crate::value::intern_key("mode"),
1720 VmValue::String(arcstr::ArcStr::from("argv")),
1721 ),
1722 (
1723 crate::value::intern_key("argv"),
1724 VmValue::List(std::sync::Arc::new(vec![
1725 VmValue::String(arcstr::ArcStr::from("/bin/sh")),
1728 VmValue::String(arcstr::ArcStr::from("-c")),
1729 VmValue::String(arcstr::ArcStr::from(
1730 "printf '%s|%s' \"$PARENT_VAR\" \"$CHILD_VAR\"",
1731 )),
1732 ])),
1733 ),
1734 (crate::value::intern_key("env"), env),
1735 ]);
1736 if let Some(mode) = env_mode {
1737 params.put_str("env_mode", mode);
1738 }
1739 let result = super::dispatch_process_exec(¶ms, serde_json::Value::Null)
1740 .await
1741 .expect("process.exec result");
1742 let dict = result.as_dict().expect("result dict");
1743 let stdout = dict.get("stdout").map(VmValue::display).unwrap_or_default();
1744 std::env::remove_var("PARENT_VAR");
1745 let (parent, child) = stdout.split_once('|').unwrap_or((&stdout, ""));
1746 (parent.to_string(), child.to_string())
1747 }
1748
1749 #[cfg(unix)]
1750 #[test]
1751 fn process_exec_env_default_merges_with_parent() {
1752 run_host_async_test(|| async {
1753 let child_env = VmValue::dict(crate::value::DictMap::from_iter([(
1756 crate::value::intern_key("CHILD_VAR"),
1757 VmValue::String(arcstr::ArcStr::from("provided")),
1758 )]));
1759 let (parent, child) = process_exec_env_probe(child_env, None).await;
1760 assert_eq!(
1761 parent, "inherited",
1762 "default env_mode must inherit parent env"
1763 );
1764 assert_eq!(
1765 child, "provided",
1766 "default env_mode must apply provided keys"
1767 );
1768 });
1769 }
1770
1771 #[cfg(unix)]
1772 #[test]
1773 fn process_exec_env_mode_replace_clears_parent() {
1774 run_host_async_test(|| async {
1775 let child_env = VmValue::dict(crate::value::DictMap::from_iter([(
1779 crate::value::intern_key("CHILD_VAR"),
1780 VmValue::String(arcstr::ArcStr::from("provided")),
1781 )]));
1782 let (parent, child) = process_exec_env_probe(child_env, Some("replace")).await;
1783 assert_eq!(parent, "", "explicit replace must clear parent env");
1784 assert_eq!(
1785 child, "provided",
1786 "explicit replace must keep provided keys"
1787 );
1788 });
1789 }
1790
1791 #[cfg(unix)]
1792 #[test]
1793 fn process_exec_env_mode_unknown_is_rejected() {
1794 run_host_async_test(|| async {
1795 let params = crate::value::DictMap::from_iter([
1796 (
1797 crate::value::intern_key("mode"),
1798 VmValue::String(arcstr::ArcStr::from("argv")),
1799 ),
1800 (
1801 crate::value::intern_key("argv"),
1802 VmValue::List(std::sync::Arc::new(vec![VmValue::String(
1803 arcstr::ArcStr::from("true"),
1804 )])),
1805 ),
1806 (
1807 crate::value::intern_key("env"),
1808 VmValue::dict(crate::value::DictMap::from_iter([(
1809 crate::value::intern_key("CHILD_VAR"),
1810 VmValue::String(arcstr::ArcStr::from("x")),
1811 )])),
1812 ),
1813 (
1814 crate::value::intern_key("env_mode"),
1815 VmValue::String(arcstr::ArcStr::from("bogus")),
1816 ),
1817 ]);
1818 let err = super::dispatch_process_exec(¶ms, serde_json::Value::Null)
1819 .await
1820 .expect_err("unknown env_mode must error");
1821 assert!(
1822 format!("{err:?}").contains("env_mode"),
1823 "error should name env_mode, got {err:?}"
1824 );
1825 });
1826 }
1827
1828 #[cfg(unix)]
1834 async fn process_exec_tmpdir_probe(
1835 workspace: &std::path::Path,
1836 caller_env: Option<VmValue>,
1837 ) -> String {
1838 let mut env_pairs = vec![(
1839 crate::value::intern_key("mode"),
1840 VmValue::String(arcstr::ArcStr::from("argv")),
1841 )];
1842 env_pairs.push((
1843 crate::value::intern_key("argv"),
1844 VmValue::List(std::sync::Arc::new(vec![
1845 VmValue::String(arcstr::ArcStr::from("/bin/sh")),
1846 VmValue::String(arcstr::ArcStr::from("-c")),
1847 VmValue::String(arcstr::ArcStr::from("printf '%s' \"$TMPDIR\"")),
1848 ])),
1849 ));
1850 if let Some(env) = caller_env {
1851 env_pairs.push((crate::value::intern_key("env"), env));
1852 }
1853 let params = crate::value::DictMap::from_iter(env_pairs);
1854
1855 crate::orchestration::push_execution_policy(crate::orchestration::CapabilityPolicy {
1856 sandbox_profile: crate::orchestration::SandboxProfile::Worktree,
1857 workspace_roots: vec![workspace.to_string_lossy().into_owned()],
1858 ..crate::orchestration::CapabilityPolicy::default()
1862 });
1863 std::env::set_var("HARN_HANDLER_SANDBOX", "off");
1864 let result = super::dispatch_process_exec(¶ms, serde_json::Value::Null)
1865 .await
1866 .expect("process.exec result");
1867 std::env::remove_var("HARN_HANDLER_SANDBOX");
1868 crate::orchestration::pop_execution_policy();
1869 result
1870 .as_dict()
1871 .and_then(|d| d.get("stdout"))
1872 .map(VmValue::display)
1873 .unwrap_or_default()
1874 }
1875
1876 #[cfg(unix)]
1877 #[test]
1878 fn process_exec_injects_workspace_local_tmpdir() {
1879 run_host_async_test(|| async {
1880 let workspace = tempfile::tempdir().expect("workspace");
1881 let tmpdir = process_exec_tmpdir_probe(workspace.path(), None).await;
1882
1883 assert!(
1884 !tmpdir.is_empty(),
1885 "sandboxed child must receive a non-empty TMPDIR"
1886 );
1887 let tmpdir_path = std::path::PathBuf::from(&tmpdir);
1888 let canonical_tmpdir = std::fs::canonicalize(&tmpdir_path)
1889 .expect("workspace-local TMPDIR should canonicalize");
1890 let canonical_workspace =
1891 std::fs::canonicalize(workspace.path()).expect("workspace should canonicalize");
1892 assert!(
1893 canonical_tmpdir.starts_with(&canonical_workspace),
1894 "child TMPDIR {tmpdir:?} must live inside the workspace {:?}",
1895 workspace.path()
1896 );
1897 assert!(
1898 tmpdir_path.ends_with(".harn-tmp"),
1899 "child TMPDIR {tmpdir:?} must be the workspace-local .harn-tmp dir"
1900 );
1901 assert!(
1902 tmpdir_path.is_dir(),
1903 "the workspace-local TMPDIR must have been created on disk"
1904 );
1905 });
1906 }
1907
1908 #[cfg(unix)]
1909 #[test]
1910 fn process_exec_respects_caller_pinned_tmpdir() {
1911 run_host_async_test(|| async {
1912 let workspace = tempfile::tempdir().expect("workspace");
1913 let caller_tmp = workspace.path().join("caller-chosen");
1914 std::fs::create_dir_all(&caller_tmp).unwrap();
1915 let caller_env = VmValue::dict(crate::value::DictMap::from_iter([(
1916 crate::value::intern_key("TMPDIR"),
1917 VmValue::String(arcstr::ArcStr::from(
1918 caller_tmp.to_string_lossy().into_owned(),
1919 )),
1920 )]));
1921
1922 let tmpdir = process_exec_tmpdir_probe(workspace.path(), Some(caller_env)).await;
1923
1924 assert_eq!(
1925 std::path::PathBuf::from(&tmpdir),
1926 caller_tmp,
1927 "an explicit caller TMPDIR must override the workspace-local default"
1928 );
1929 });
1930 }
1931
1932 #[test]
1933 fn host_tool_list_is_empty_without_bridge() {
1934 run_host_async_test(|| async {
1935 clear_host_call_bridge();
1936 let tools = dispatch_host_tool_list().await.expect("tool list");
1937 let VmValue::List(items) = tools else {
1938 panic!("expected tool list");
1939 };
1940 assert!(items.is_empty());
1941 });
1942 }
1943}