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
14mod operation_registry;
15
16pub(crate) fn audited_utc_now_rfc3339(capability_id: &'static str) -> String {
20 let dt: chrono::DateTime<chrono::Utc> =
21 crate::clock_mock::leak_audit::wall_now(capability_id).into();
22 dt.to_rfc3339()
23}
24
25pub(crate) const MODULE_BUILTINS: &[&VmBuiltinDef] = &[
26 &HOST_MOCK_BUILTIN_DEF,
27 &HOST_MOCK_CLEAR_BUILTIN_DEF,
28 &HOST_MOCK_CALLS_BUILTIN_DEF,
29 &HOST_MOCK_PUSH_SCOPE_BUILTIN_DEF,
30 &HOST_MOCK_POP_SCOPE_BUILTIN_DEF,
31 &HOST_CAPABILITIES_BUILTIN_DEF,
32 &HOST_HAS_BUILTIN_DEF,
33 &HOST_CALL_BUILTIN_DEF,
34 &HOST_TOOL_LIST_BUILTIN_DEF,
35 &HOST_TOOL_CALL_BUILTIN_DEF,
36];
37
38#[derive(Clone)]
39struct HostMock {
40 capability: String,
41 operation: String,
42 params: Option<crate::value::DictMap>,
43 result: Option<VmValue>,
44 error: Option<String>,
45 unregistered_ok: bool,
46}
47
48#[derive(Clone)]
49struct HostMockCall {
50 capability: String,
51 operation: String,
52 params: crate::value::DictMap,
53}
54
55thread_local! {
56 static HOST_MOCKS: RefCell<Vec<HostMock>> = const { RefCell::new(Vec::new()) };
57 static HOST_MOCK_CALLS: RefCell<Vec<HostMockCall>> = const { RefCell::new(Vec::new()) };
58 static HOST_MOCK_SCOPES: RefCell<Vec<(Vec<HostMock>, Vec<HostMockCall>)>> =
59 const { RefCell::new(Vec::new()) };
60}
61
62pub(crate) fn reset_host_state() {
63 HOST_MOCKS.with(|mocks| mocks.borrow_mut().clear());
64 HOST_MOCK_CALLS.with(|calls| calls.borrow_mut().clear());
65 HOST_MOCK_SCOPES.with(|scopes| scopes.borrow_mut().clear());
66}
67
68pub(crate) fn reset_scoped_host_state() {
69 operation_registry::clear_scoped_mockable();
70}
71
72fn push_host_mock_scope() {
77 let mocks = HOST_MOCKS.with(|v| std::mem::take(&mut *v.borrow_mut()));
78 let calls = HOST_MOCK_CALLS.with(|v| std::mem::take(&mut *v.borrow_mut()));
79 HOST_MOCK_SCOPES.with(|v| v.borrow_mut().push((mocks, calls)));
80}
81
82fn pop_host_mock_scope() -> bool {
87 let entry = HOST_MOCK_SCOPES.with(|v| v.borrow_mut().pop());
88 match entry {
89 Some((mocks, calls)) => {
90 HOST_MOCKS.with(|v| *v.borrow_mut() = mocks);
91 HOST_MOCK_CALLS.with(|v| *v.borrow_mut() = calls);
92 true
93 }
94 None => false,
95 }
96}
97
98fn async_builtin_cancel_token(
99 ctx: Option<&AsyncBuiltinCtx>,
100) -> Option<std::sync::Arc<std::sync::atomic::AtomicBool>> {
101 ctx.and_then(|ctx| ctx.child_vm().cancel_token.clone())
102}
103
104fn capability_manifest_map() -> crate::value::DictMap {
105 let mut root = crate::value::DictMap::new();
106 root.insert(
107 crate::value::intern_key("process"),
108 capability(
109 "Process execution.",
110 &[
111 op("exec", "Execute a process in argv or shell mode."),
112 op(
113 "spawn",
114 "Spawn a process non-blocking; returns a handle immediately for poll/wait/kill.",
115 ),
116 op(
117 "poll",
118 "Non-blocking snapshot of a spawned process: status, captured stdout/stderr.",
119 ),
120 op(
121 "wait",
122 "Await a spawned process to completion (optional timeout_ms); returns final result.",
123 ),
124 op(
125 "kill",
126 "Terminate a spawned process by handle and await the status transition.",
127 ),
128 op(
129 "release",
130 "Release a spawned-process handle and free its retained output.",
131 ),
132 op("list_shells", "List shells discovered by the host/session."),
133 op(
134 "get_default_shell",
135 "Return the selected default shell for this host/session.",
136 ),
137 op(
138 "set_default_shell",
139 "Select the default shell for this host/session.",
140 ),
141 op(
142 "shell_invocation",
143 "Resolve shell selection and login/interactive flags into argv.",
144 ),
145 ],
146 ),
147 );
148 root.insert(
149 crate::value::intern_key("template"),
150 capability(
151 "Template rendering.",
152 &[op("render", "Render a template file.")],
153 ),
154 );
155 root.insert(
156 crate::value::intern_key("interaction"),
157 capability(
158 "User interaction.",
159 &[op("ask", "Ask the user a question.")],
160 ),
161 );
162 root.insert(
163 crate::value::intern_key("memory"),
164 capability(
165 "Vector-aware memory: host-provided embeddings.",
166 &[op(
167 "embed",
168 "Embed text for semantic recall. Params: {text, model_hint?}. \
169 Returns {vector: list<float>, model: string, dim: int}.",
170 )],
171 ),
172 );
173 root.insert(
174 crate::value::intern_key("project"),
175 capability(
176 "Project metadata and durable project facts.",
177 &[
178 op("metadata_get", "Read project metadata."),
179 op("metadata_inspect", "Inspect project metadata provenance."),
180 op("metadata_set", "Write project metadata."),
181 op("metadata_save", "Persist pending project metadata changes."),
182 op("metadata_stale", "Check whether project metadata is stale."),
183 op(
184 "metadata_refresh_hashes",
185 "Refresh project metadata content hashes.",
186 ),
187 ],
188 ),
189 );
190 root.insert(
191 crate::value::intern_key("runtime"),
192 capability(
193 "Runtime task context and run metadata supplied by the active host.",
194 &[
195 op("task", "Read the current runtime task."),
196 op("pipeline_input", "Read the active pipeline input payload."),
197 op("dry_run", "Read whether the runtime is in dry-run mode."),
198 op("approved_plan", "Read the approved plan text."),
199 op("record_run", "Record run metadata with the host."),
200 op("set_result", "Write the runtime result payload."),
201 ],
202 ),
203 );
204 root.insert(
205 crate::value::intern_key("workspace"),
206 capability(
207 "Workspace facts and file access supplied by the active host.",
208 &[
209 op("project_root", "Return the active project root."),
210 op("cwd", "Return the active current working directory."),
211 op("read_text", "Read a workspace text file."),
212 op("list", "List workspace files or directories."),
213 op("exists", "Check whether a workspace path exists."),
214 ],
215 ),
216 );
217 root.insert(
218 crate::value::intern_key("oauth_storage"),
219 capability(
220 "Host-managed OAuth token storage.",
221 &[
222 op("cloud_get", "Read a cloud-managed token set."),
223 op("cloud_set", "Write a cloud-managed token set."),
224 op("cloud_delete", "Delete a cloud-managed token set."),
225 op(
226 "cloud_acquire_refresh_lock",
227 "Acquire an OAuth refresh lock.",
228 ),
229 op(
230 "cloud_release_refresh_lock",
231 "Release an OAuth refresh lock.",
232 ),
233 ],
234 ),
235 );
236 root.insert(
237 crate::value::intern_key("mcp"),
238 capability(
239 "MCP host interactions.",
240 &[op("elicit", "Ask the connected MCP client for input.")],
241 ),
242 );
243 root.insert(
244 crate::value::intern_key("hitl"),
245 capability(
246 "Human-in-the-loop host interactions.",
247 &[
248 op(
249 "question",
250 "Ask a human a question through the active host.",
251 ),
252 op(
253 "approval",
254 "Request a human approval through the active host.",
255 ),
256 op(
257 "dual_control",
258 "Request quorum approval from multiple human reviewers.",
259 ),
260 op(
261 "escalation",
262 "Escalate a task to a human role through the active host.",
263 ),
264 ],
265 ),
266 );
267 root
268}
269
270fn mocked_operation_entry() -> VmValue {
271 op(
272 "mocked",
273 "Mocked host operation registered at runtime for tests.",
274 )
275 .1
276}
277
278fn ensure_mocked_capability(
279 root: &mut crate::value::DictMap,
280 capability_name: &str,
281 operation_name: &str,
282) {
283 let Some(existing) = root.get(capability_name).cloned() else {
284 root.insert(
285 crate::value::intern_key(capability_name),
286 capability(
287 "Mocked host capability registered at runtime for tests.",
288 &[(operation_name.to_string(), mocked_operation_entry())],
289 ),
290 );
291 return;
292 };
293
294 let Some(existing_dict) = existing.as_dict() else {
295 return;
296 };
297 let mut entry = (*existing_dict).clone();
298 let mut ops = entry
299 .get("ops")
300 .and_then(|value| match value {
301 VmValue::List(list) => Some((**list).clone()),
302 _ => None,
303 })
304 .unwrap_or_default();
305 if !ops.iter().any(|value| value.display() == operation_name) {
306 ops.push(VmValue::String(arcstr::ArcStr::from(
307 operation_name.to_string(),
308 )));
309 }
310
311 let mut operations = entry
312 .get("operations")
313 .and_then(|value| value.as_dict())
314 .map(|dict| (*dict).clone())
315 .unwrap_or_default();
316 operations
317 .entry(crate::value::intern_key(operation_name))
318 .or_insert_with(mocked_operation_entry);
319
320 entry.insert(
321 crate::value::intern_key("ops"),
322 VmValue::List(std::sync::Arc::new(ops)),
323 );
324 entry.insert(
325 crate::value::intern_key("operations"),
326 VmValue::dict(operations),
327 );
328 root.insert(
329 crate::value::intern_key(capability_name),
330 VmValue::dict(entry),
331 );
332}
333
334fn ensure_registered_operation(
335 root: &mut crate::value::DictMap,
336 capability_name: &str,
337 operation_name: &str,
338 description: &str,
339) {
340 let operation = op(operation_name, description);
341 let Some(existing) = root.get(capability_name).cloned() else {
342 root.insert(
343 crate::value::intern_key(capability_name),
344 capability(description, &[operation]),
345 );
346 return;
347 };
348
349 let Some(existing_dict) = existing.as_dict() else {
350 return;
351 };
352 let mut entry = (*existing_dict).clone();
353 let mut ops = entry
354 .get("ops")
355 .and_then(|value| match value {
356 VmValue::List(list) => Some((**list).clone()),
357 _ => None,
358 })
359 .unwrap_or_default();
360 if !ops.iter().any(|value| value.display() == operation_name) {
361 ops.push(VmValue::String(arcstr::ArcStr::from(
362 operation_name.to_string(),
363 )));
364 }
365
366 let mut operations = entry
367 .get("operations")
368 .and_then(|value| value.as_dict())
369 .map(|dict| (*dict).clone())
370 .unwrap_or_default();
371 operations
372 .entry(crate::value::intern_key(operation_name))
373 .or_insert(operation.1);
374
375 entry.insert(
376 crate::value::intern_key("ops"),
377 VmValue::List(std::sync::Arc::new(ops)),
378 );
379 entry.insert(
380 crate::value::intern_key("operations"),
381 VmValue::dict(operations),
382 );
383 root.insert(
384 crate::value::intern_key(capability_name),
385 VmValue::dict(entry),
386 );
387}
388
389pub fn register_mockable_host_operation(
390 capability_name: impl AsRef<str>,
391 operation_name: impl AsRef<str>,
392 description: impl AsRef<str>,
393) {
394 operation_registry::register_mockable(capability_name, operation_name, description);
395}
396
397pub fn register_scoped_mockable_host_operation(
399 capability_name: impl AsRef<str>,
400 operation_name: impl AsRef<str>,
401 description: impl AsRef<str>,
402) {
403 operation_registry::register_scoped_mockable(capability_name, operation_name, description);
404}
405
406pub fn register_callable_host_operation(
407 capability_name: impl AsRef<str>,
408 operation_name: impl AsRef<str>,
409 description: impl AsRef<str>,
410) {
411 operation_registry::register_callable(capability_name, operation_name, description);
412}
413
414fn apply_registered_operations(root: &mut crate::value::DictMap) {
415 operation_registry::apply_callable(root);
416}
417
418fn apply_mockable_operations(root: &mut crate::value::DictMap) {
419 operation_registry::apply_mockable(root);
420}
421
422fn capability_manifest_with_mocks() -> VmValue {
423 let mut root = capability_manifest_map();
424 apply_registered_operations(&mut root);
425 HOST_MOCKS.with(|mocks| {
426 for host_mock in mocks.borrow().iter() {
427 ensure_mocked_capability(&mut root, &host_mock.capability, &host_mock.operation);
428 }
429 });
430 VmValue::dict(root)
431}
432
433fn known_host_operations() -> Vec<(String, String)> {
434 let mut root = capability_manifest_map();
435 apply_registered_operations(&mut root);
436 apply_mockable_operations(&mut root);
437 root.into_iter()
438 .flat_map(|(capability_name, capability)| {
439 let capability_name = capability_name.to_string();
440 capability
441 .as_dict()
442 .and_then(|dict| dict.get("ops"))
443 .and_then(|value| match value {
444 VmValue::List(list) => Some((**list).clone()),
445 _ => None,
446 })
447 .unwrap_or_default()
448 .into_iter()
449 .map(move |operation| (capability_name.clone(), operation.display()))
450 })
451 .collect()
452}
453
454fn host_operation_is_registered(capability: &str, operation: &str) -> bool {
455 known_host_operations()
456 .iter()
457 .any(|(known_capability, known_operation)| {
458 known_capability == capability && known_operation == operation
459 })
460}
461
462fn edit_distance(a: &str, b: &str) -> usize {
463 let mut previous: Vec<usize> = (0..=b.chars().count()).collect();
464 let mut current = vec![0; previous.len()];
465 for (i, ca) in a.chars().enumerate() {
466 current[0] = i + 1;
467 for (j, cb) in b.chars().enumerate() {
468 let substitution = previous[j] + usize::from(ca != cb);
469 let insertion = current[j] + 1;
470 let deletion = previous[j + 1] + 1;
471 current[j + 1] = substitution.min(insertion).min(deletion);
472 }
473 std::mem::swap(&mut previous, &mut current);
474 }
475 previous[b.chars().count()]
476}
477
478fn closest_host_operation(capability: &str, operation: &str) -> Option<(String, String)> {
479 let requested = format!("{capability}.{operation}");
480 known_host_operations()
481 .into_iter()
482 .map(|(candidate_capability, candidate_operation)| {
483 let candidate = format!("{candidate_capability}.{candidate_operation}");
484 let distance = edit_distance(&requested, &candidate);
485 (distance, candidate_capability, candidate_operation)
486 })
487 .filter(|(distance, _, _)| *distance <= 4)
488 .min_by_key(|(distance, _, _)| *distance)
489 .map(|(_, candidate_capability, candidate_operation)| {
490 (candidate_capability, candidate_operation)
491 })
492}
493
494fn validate_host_mock_registration(host_mock: &HostMock) -> Result<(), VmError> {
495 if host_mock.unregistered_ok
496 || host_operation_is_registered(&host_mock.capability, &host_mock.operation)
497 {
498 return Ok(());
499 }
500
501 let mut message = format!(
502 "host_mock: unregistered host operation {}.{}; register the capability/operation on \
503 the host or pass {{unregistered_ok: true}} for a test-local mock",
504 host_mock.capability, host_mock.operation
505 );
506 if let Some((capability, operation)) =
507 closest_host_operation(&host_mock.capability, &host_mock.operation)
508 {
509 message.push_str(&format!(". Did you mean {capability}.{operation}?"));
510 }
511 Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
512 message,
513 ))))
514}
515
516fn op(name: &str, description: &str) -> (String, VmValue) {
517 let mut entry = crate::value::DictMap::new();
518 entry.put_str("description", description);
519 (name.to_string(), VmValue::dict(entry))
520}
521
522fn capability(description: &str, ops: &[(String, VmValue)]) -> VmValue {
523 let mut entry = crate::value::DictMap::new();
524 entry.put_str("description", description);
525 entry.insert(
526 crate::value::intern_key("ops"),
527 VmValue::List(std::sync::Arc::new(
528 ops.iter()
529 .map(|(name, _)| VmValue::String(arcstr::ArcStr::from(name.as_str())))
530 .collect(),
531 )),
532 );
533 let mut op_dict = crate::value::DictMap::new();
534 for (name, op) in ops {
535 op_dict.insert(crate::value::intern_key(name), op.clone());
536 }
537 entry.insert(
538 crate::value::intern_key("operations"),
539 VmValue::dict(op_dict),
540 );
541 VmValue::dict(entry)
542}
543
544pub(crate) fn require_param(params: &crate::value::DictMap, key: &str) -> Result<String, VmError> {
545 params
546 .get(key)
547 .map(|v| v.display())
548 .filter(|v| !v.is_empty())
549 .ok_or_else(|| {
550 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
551 "host_call: missing required parameter '{key}'"
552 ))))
553 })
554}
555
556fn render_template(
557 path: &str,
558 bindings: Option<&crate::value::DictMap>,
559) -> Result<String, VmError> {
560 let asset = crate::stdlib::template::TemplateAsset::render_target(path).map_err(|msg| {
561 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
562 "host_call template.render: {msg}"
563 ))))
564 })?;
565 crate::stdlib::template::render_asset_result(&asset, bindings).map_err(VmError::from)
566}
567
568fn params_match(expected: Option<&crate::value::DictMap>, actual: &crate::value::DictMap) -> bool {
569 let Some(expected) = expected else {
570 return true;
571 };
572 expected.iter().all(|(key, value)| {
573 actual
574 .get(key)
575 .is_some_and(|candidate| values_equal(candidate, value))
576 })
577}
578
579fn parse_host_mock(args: &[VmValue]) -> Result<HostMock, VmError> {
580 let capability = args
581 .first()
582 .map(|value| value.display())
583 .unwrap_or_default();
584 let operation = args.get(1).map(|value| value.display()).unwrap_or_default();
585 if capability.is_empty() || operation.is_empty() {
586 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
587 "host_mock: capability and operation are required",
588 ))));
589 }
590
591 let mut params = args
592 .get(3)
593 .and_then(|value| value.as_dict())
594 .map(|dict| (*dict).clone());
595 let mut result = args.get(2).cloned().or(Some(VmValue::Nil));
596 let mut error = None;
597 let mut unregistered_ok = false;
598
599 if let Some(config) = args.get(2).and_then(|value| value.as_dict()) {
600 if config.contains_key("result")
601 || config.contains_key("params")
602 || config.contains_key("error")
603 || config.contains_key("unregistered_ok")
604 {
605 params = config
606 .get("params")
607 .and_then(|value| value.as_dict())
608 .map(|dict| (*dict).clone());
609 result = config.get("result").cloned();
610 error = config
611 .get("error")
612 .map(|value| value.display())
613 .filter(|value| !value.is_empty());
614 unregistered_ok = matches!(config.get("unregistered_ok"), Some(VmValue::Bool(true)));
615 }
616 }
617
618 Ok(HostMock {
619 capability,
620 operation,
621 params,
622 result,
623 error,
624 unregistered_ok,
625 })
626}
627
628fn push_host_mock(host_mock: HostMock) {
629 HOST_MOCKS.with(|mocks| mocks.borrow_mut().push(host_mock));
630}
631
632fn mock_call_value(call: &HostMockCall) -> VmValue {
633 let mut item = crate::value::DictMap::new();
634 item.put_str("capability", call.capability.clone());
635 item.put_str("operation", call.operation.clone());
636 item.insert(
637 crate::value::intern_key("params"),
638 VmValue::dict(call.params.clone()),
639 );
640 VmValue::dict(item)
641}
642
643fn record_mock_call(capability: &str, operation: &str, params: &crate::value::DictMap) {
644 HOST_MOCK_CALLS.with(|calls| {
645 calls.borrow_mut().push(HostMockCall {
646 capability: capability.to_string(),
647 operation: operation.to_string(),
648 params: params.clone(),
649 });
650 });
651}
652
653pub(crate) fn dispatch_mock_host_call(
654 capability: &str,
655 operation: &str,
656 params: &crate::value::DictMap,
657) -> Option<Result<VmValue, VmError>> {
658 let matched = HOST_MOCKS.with(|mocks| {
659 mocks
660 .borrow()
661 .iter()
662 .rev()
663 .find(|host_mock| {
664 host_mock.capability == capability
665 && host_mock.operation == operation
666 && params_match(host_mock.params.as_ref(), params)
667 })
668 .cloned()
669 })?;
670
671 record_mock_call(capability, operation, params);
672 if let Some(error) = matched.error {
673 return Some(Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
674 error,
675 )))));
676 }
677 Some(Ok(matched.result.unwrap_or(VmValue::Nil)))
678}
679
680pub fn dispatch_mock_hostlib_call(
691 module: &str,
692 method: &str,
693 params: &crate::value::DictMap,
694) -> Option<Result<VmValue, VmError>> {
695 if let Some(mocked) = dispatch_mock_host_call(module, method, params) {
696 return Some(mocked);
697 }
698
699 if (module, method) == ("tools", "run_command") {
700 return dispatch_mock_host_call("process", "exec", params);
701 }
702
703 None
704}
705
706pub trait HostCallBridge: Send + Sync {
721 fn dispatch(
722 &self,
723 capability: &str,
724 operation: &str,
725 params: &crate::value::DictMap,
726 ) -> Result<Option<VmValue>, VmError>;
727
728 fn list_tools(&self) -> Result<Option<VmValue>, VmError> {
729 Ok(None)
730 }
731
732 fn call_tool(&self, _name: &str, _args: &VmValue) -> Result<Option<VmValue>, VmError> {
733 Ok(None)
734 }
735}
736
737thread_local! {
738 static HOST_CALL_BRIDGE: RefCell<Option<Arc<dyn HostCallBridge>>> = const { RefCell::new(None) };
739}
740
741pub fn set_host_call_bridge(bridge: Arc<dyn HostCallBridge>) {
746 HOST_CALL_BRIDGE.with(|b| *b.borrow_mut() = Some(bridge));
747}
748
749pub fn clear_host_call_bridge() {
751 HOST_CALL_BRIDGE.with(|b| *b.borrow_mut() = None);
752}
753
754pub fn dispatch_host_call_bridge(
764 capability: &str,
765 operation: &str,
766 params: &crate::value::DictMap,
767) -> Option<Result<VmValue, VmError>> {
768 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone())?;
769 match bridge.dispatch(capability, operation, params) {
770 Ok(Some(value)) => Some(Ok(value)),
771 Ok(None) => None,
772 Err(error) => Some(Err(error)),
773 }
774}
775
776fn empty_tool_list_value() -> VmValue {
777 VmValue::List(std::sync::Arc::new(Vec::new()))
778}
779
780fn current_vm_host_bridge(
781 ctx: Option<&AsyncBuiltinCtx>,
782) -> Option<std::sync::Arc<crate::bridge::HostBridge>> {
783 ctx.and_then(|ctx| ctx.child_vm().bridge.clone())
784}
785
786#[cfg(test)]
787async fn dispatch_host_tool_list() -> Result<VmValue, VmError> {
788 dispatch_host_tool_list_with_ctx(None).await
789}
790
791async fn dispatch_host_tool_list_with_ctx(
792 ctx: Option<&AsyncBuiltinCtx>,
793) -> Result<VmValue, VmError> {
794 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
795 if let Some(bridge) = bridge {
796 if let Some(value) = bridge.list_tools()? {
797 return Ok(value);
798 }
799 }
800
801 let Some(bridge) = current_vm_host_bridge(ctx) else {
802 return Ok(empty_tool_list_value());
803 };
804 let tools = bridge.list_host_tools().await?;
805 Ok(crate::bridge::json_result_to_vm_value(&JsonValue::Array(
806 tools.into_iter().collect(),
807 )))
808}
809
810pub(crate) async fn dispatch_host_tool_call(
811 name: &str,
812 args: &VmValue,
813) -> Result<VmValue, VmError> {
814 dispatch_host_tool_call_with_ctx(None, name, args).await
815}
816
817pub(crate) async fn dispatch_host_tool_call_with_ctx(
818 ctx: Option<&AsyncBuiltinCtx>,
819 name: &str,
820 args: &VmValue,
821) -> Result<VmValue, VmError> {
822 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
823 if let Some(bridge) = bridge {
824 if let Some(value) = bridge.call_tool(name, args)? {
825 return Ok(value);
826 }
827 }
828
829 let Some(bridge) = current_vm_host_bridge(ctx) else {
830 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
831 "host_tool_call: no host bridge is attached",
832 ))));
833 };
834
835 let result = bridge
836 .call(
837 "builtin_call",
838 serde_json::json!({
839 "name": name,
840 "args": [crate::llm::vm_value_to_json(args)],
841 }),
842 )
843 .await?;
844 Ok(crate::bridge::json_result_to_vm_value(&result))
845}
846
847pub(crate) async fn dispatch_host_operation(
848 capability: &str,
849 operation: &str,
850 params: &crate::value::DictMap,
851) -> Result<VmValue, VmError> {
852 dispatch_host_operation_with_ctx(None, capability, operation, params).await
853}
854
855pub(crate) async fn dispatch_host_operation_with_ctx(
856 ctx: Option<&AsyncBuiltinCtx>,
857 capability: &str,
858 operation: &str,
859 params: &crate::value::DictMap,
860) -> Result<VmValue, VmError> {
861 if let Some(mocked) = dispatch_mock_host_call(capability, operation, params) {
862 return mocked;
863 }
864
865 if (capability, operation) == ("process", "exec") {
866 let caller = serde_json::json!({
867 "surface": "host_call",
868 "capability": "process",
869 "operation": "exec",
870 "session_id": crate::llm::current_agent_session_id(),
871 });
872 return dispatch_process_exec_with_policy(ctx, params, caller).await;
873 }
874
875 if (capability, operation) == ("process", "spawn") {
882 let caller = serde_json::json!({
883 "surface": "host_call",
884 "capability": "process",
885 "operation": "spawn",
886 "session_id": crate::llm::current_agent_session_id(),
887 });
888 return dispatch_process_spawn_with_policy(ctx, params, caller).await;
889 }
890 if capability == "process" && matches!(operation, "poll" | "wait" | "kill" | "release") {
891 if let Some(result) = crate::stdlib::process_spawn::dispatch(
892 operation,
893 params,
894 async_builtin_cancel_token(ctx),
895 )
896 .await
897 {
898 return result;
899 }
900 }
901
902 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
903 if let Some(bridge) = bridge {
904 if let Some(value) = bridge.dispatch(capability, operation, params)? {
905 return Ok(value);
906 }
907 }
908
909 dispatch_builtin_host_operation(capability, operation, params).await
910}
911
912async fn dispatch_builtin_host_operation(
913 capability: &str,
914 operation: &str,
915 params: &crate::value::DictMap,
916) -> Result<VmValue, VmError> {
917 match (capability, operation) {
918 ("process", "list_shells") => Ok(crate::shells::list_shells_vm_value()),
919 ("process", "get_default_shell") => Ok(crate::shells::default_shell_vm_value()),
920 ("process", "set_default_shell") => crate::shells::set_default_shell_vm_value(params),
921 ("process", "shell_invocation") => crate::shells::shell_invocation_vm_value(params),
922 ("template", "render") => {
923 let path = require_param(params, "path")?;
924 let bindings = params.get("bindings").and_then(|v| v.as_dict());
925 Ok(VmValue::String(arcstr::ArcStr::from(render_template(
926 &path, bindings,
927 )?)))
928 }
929 ("interaction", "ask") => {
930 let question = require_param(params, "question")?;
931 super::io::prompt_user_value(&[VmValue::string(question)], &mut String::new())
932 }
933 ("project", "metadata_get") => crate::metadata::project_metadata_host_get(params),
934 ("project", "metadata_inspect") => crate::metadata::project_metadata_host_inspect(params),
935 ("project", "metadata_set") => crate::metadata::project_metadata_host_set(params),
936 ("project", "metadata_save") => crate::metadata::project_metadata_host_save(params),
937 ("project", "metadata_stale") => crate::metadata::project_metadata_host_stale(params),
938 ("project", "metadata_refresh_hashes") => {
939 crate::metadata::project_metadata_host_refresh_hashes(params)
940 }
941 ("runtime", "task") => Ok(VmValue::String(arcstr::ArcStr::from(
946 std::env::var("HARN_TASK").unwrap_or_default(),
947 ))),
948 ("runtime", "set_result") => {
949 Ok(VmValue::Nil)
952 }
953 ("workspace", "project_root") => {
954 let path = crate::stdlib::process::project_root_path()
959 .map(|root| root.display().to_string())
960 .or_else(|| std::env::var("HARN_PROJECT_ROOT").ok())
961 .unwrap_or_else(|| {
962 std::env::current_dir()
963 .map(|p| p.display().to_string())
964 .unwrap_or_default()
965 });
966 Ok(VmValue::String(arcstr::ArcStr::from(path)))
967 }
968 ("workspace", "cwd") => {
969 let path = std::env::current_dir()
970 .map(|p| p.display().to_string())
971 .unwrap_or_default();
972 Ok(VmValue::String(arcstr::ArcStr::from(path)))
973 }
974 _ => Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
975 format!("host_call: unsupported operation {capability}.{operation}"),
976 )))),
977 }
978}
979
980pub(crate) async fn dispatch_process_exec(
981 params: &crate::value::DictMap,
982 caller: serde_json::Value,
983) -> Result<VmValue, VmError> {
984 dispatch_process_exec_with_policy(None, params, caller).await
985}
986
987async fn dispatch_process_exec_with_policy(
988 ctx: Option<&AsyncBuiltinCtx>,
989 params: &crate::value::DictMap,
990 caller: serde_json::Value,
991) -> Result<VmValue, VmError> {
992 let (params, command_policy_context, command_policy_decisions) =
993 match crate::orchestration::run_command_policy_preflight_with_ctx(ctx, params, caller)
994 .await?
995 {
996 crate::orchestration::CommandPolicyPreflight::Proceed {
997 params,
998 context,
999 decisions,
1000 } => (params, context, decisions),
1001 crate::orchestration::CommandPolicyPreflight::Blocked {
1002 status,
1003 message,
1004 context,
1005 decisions,
1006 } => {
1007 return Ok(crate::orchestration::blocked_command_response(
1008 params, status, &message, context, decisions,
1009 ));
1010 }
1011 };
1012
1013 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
1014 if let Some(bridge) = bridge {
1015 if let Some(value) = bridge.dispatch("process", "exec", ¶ms)? {
1016 return crate::orchestration::run_command_policy_postflight_with_ctx(
1017 ctx,
1018 ¶ms,
1019 value,
1020 command_policy_context,
1021 command_policy_decisions,
1022 )
1023 .await;
1024 }
1025 }
1026
1027 dispatch_process_exec_after_policy(
1028 ctx,
1029 ¶ms,
1030 command_policy_context,
1031 command_policy_decisions,
1032 )
1033 .await
1034}
1035
1036async fn dispatch_process_spawn_with_policy(
1043 ctx: Option<&AsyncBuiltinCtx>,
1044 params: &crate::value::DictMap,
1045 caller: serde_json::Value,
1046) -> Result<VmValue, VmError> {
1047 let params =
1048 match crate::orchestration::run_command_policy_preflight_with_ctx(ctx, params, caller)
1049 .await?
1050 {
1051 crate::orchestration::CommandPolicyPreflight::Proceed { params, .. } => params,
1052 crate::orchestration::CommandPolicyPreflight::Blocked {
1053 status,
1054 message,
1055 context,
1056 decisions,
1057 } => {
1058 return Ok(crate::orchestration::blocked_command_response(
1059 params, status, &message, context, decisions,
1060 ));
1061 }
1062 };
1063
1064 match crate::stdlib::process_spawn::dispatch("spawn", ¶ms, async_builtin_cancel_token(ctx))
1065 .await
1066 {
1067 Some(result) => result,
1068 None => Err(VmError::Runtime(
1069 "host_call process.spawn: dispatch returned None".to_string(),
1070 )),
1071 }
1072}
1073
1074async fn dispatch_process_exec_after_policy(
1075 ctx: Option<&AsyncBuiltinCtx>,
1076 params: &crate::value::DictMap,
1077 command_policy_context: JsonValue,
1078 command_policy_decisions: Vec<crate::orchestration::CommandPolicyDecision>,
1079) -> Result<VmValue, VmError> {
1080 let timeout_ms = optional_i64(params, "timeout")
1081 .or_else(|| optional_i64(params, "timeout_ms"))
1082 .filter(|value| *value > 0)
1083 .map(|value| value as u64);
1084 let profile_guard = match optional_string(params, "sandbox_profile") {
1090 Some(value) => Some(push_sandbox_profile_override(&value)?),
1091 None => None,
1092 };
1093 let mut cmd = build_sandboxed_command(params, "process.exec")?;
1094 crate::op_interrupt::configure_tokio_kill_group(&mut cmd);
1095 let cleanup_token = crate::op_interrupt::new_process_cleanup_token();
1096 cmd.env(
1097 crate::op_interrupt::PROCESS_CLEANUP_TOKEN_ENV,
1098 &cleanup_token,
1099 );
1100 cmd.stdin(std::process::Stdio::null())
1101 .stdout(std::process::Stdio::piped())
1102 .stderr(std::process::Stdio::piped())
1103 .kill_on_drop(true);
1104 let started_at = audited_utc_now_rfc3339("host_call/process.exec.started_at");
1105 let started = crate::clock_mock::leak_audit::instant_now("host_call/process.exec.started");
1106 let mut child = cmd
1107 .spawn()
1108 .map_err(|e| VmError::Runtime(format!("host_call process.exec: {e}")))?;
1109 drop(profile_guard);
1110 let pid = child.id();
1111 let cleanup_registration = crate::op_interrupt::register_active_process_cleanup(
1112 pid,
1113 &cleanup_token,
1114 async_builtin_cancel_token(ctx),
1115 );
1116 let stdout_pipe = match child.stdout.take() {
1117 Some(pipe) => pipe,
1118 None => {
1119 terminate_process_exec_child(&mut child, pid, &cleanup_token, "missing_stdout_pipe")
1120 .await;
1121 drop(cleanup_registration);
1122 return Err(VmError::Runtime(
1123 "host_call process.exec stdout pipe was not captured".to_string(),
1124 ));
1125 }
1126 };
1127 let stderr_pipe = match child.stderr.take() {
1128 Some(pipe) => pipe,
1129 None => {
1130 terminate_process_exec_child(&mut child, pid, &cleanup_token, "missing_stderr_pipe")
1131 .await;
1132 drop(cleanup_registration);
1133 return Err(VmError::Runtime(
1134 "host_call process.exec stderr pipe was not captured".to_string(),
1135 ));
1136 }
1137 };
1138 let stdout_task = tokio::spawn(read_process_exec_pipe(stdout_pipe));
1139 let stderr_task = tokio::spawn(read_process_exec_pipe(stderr_pipe));
1140
1141 enum ProcessExecWait {
1142 Exited(std::io::Result<std::process::ExitStatus>),
1143 TimedOut,
1144 }
1145
1146 let exec_deadline = timeout_ms.map(|timeout_ms| {
1147 tokio::time::Instant::now() + std::time::Duration::from_millis(timeout_ms)
1148 });
1149 let wait_result = {
1150 let wait = child.wait();
1151 tokio::pin!(wait);
1152 if let Some(deadline) = exec_deadline {
1153 let sleep = tokio::time::sleep_until(deadline);
1154 tokio::pin!(sleep);
1155 tokio::select! {
1156 result = &mut wait => ProcessExecWait::Exited(result),
1157 _ = &mut sleep => ProcessExecWait::TimedOut,
1158 }
1159 } else {
1160 ProcessExecWait::Exited(wait.await)
1161 }
1162 };
1163
1164 let (mut status, mut success, mut timed_out, mut exit_code) = match wait_result {
1165 ProcessExecWait::Exited(result) => {
1166 let status =
1167 result.map_err(|e| VmError::Runtime(format!("host_call process.exec: {e}")))?;
1168 let exit_code = status.code().unwrap_or(-1);
1169 ("completed", status.success(), false, exit_code)
1170 }
1171 ProcessExecWait::TimedOut => {
1172 terminate_process_exec_child(&mut child, pid, &cleanup_token, "timeout").await;
1173 ("timed_out", false, true, -1)
1174 }
1175 };
1176
1177 let drain_pipes = async {
1178 let stdout = collect_process_exec_pipe(stdout_task, "stdout").await?;
1179 let stderr = collect_process_exec_pipe(stderr_task, "stderr").await?;
1180 Ok::<_, VmError>((stdout, stderr))
1181 };
1182 tokio::pin!(drain_pipes);
1183 let (stdout, stderr) = if !timed_out {
1184 if let Some(deadline) = exec_deadline {
1185 tokio::select! {
1186 result = &mut drain_pipes => result?,
1187 _ = tokio::time::sleep_until(deadline) => {
1188 terminate_process_exec_child(
1189 &mut child,
1190 pid,
1191 &cleanup_token,
1192 "pipe_drain_timeout",
1193 )
1194 .await;
1195 status = "timed_out";
1196 success = false;
1197 timed_out = true;
1198 exit_code = -1;
1199 drain_pipes.await?
1200 }
1201 }
1202 } else {
1203 drain_pipes.await?
1204 }
1205 } else {
1206 drain_pipes.await?
1207 };
1208 drop(cleanup_registration);
1209
1210 let stdout_utf8_valid = std::str::from_utf8(&stdout).is_ok();
1211 let stderr_utf8_valid = std::str::from_utf8(&stderr).is_ok();
1212 let stdout = String::from_utf8_lossy(&stdout).to_string();
1213 let stderr = String::from_utf8_lossy(&stderr).to_string();
1214 let response = process_exec_response(ProcessExecResponse {
1215 pid,
1216 started_at,
1217 started,
1218 stdout: &stdout,
1219 stderr: &stderr,
1220 exit_code,
1221 status,
1222 success,
1223 timed_out,
1224 stdout_utf8_valid,
1225 stderr_utf8_valid,
1226 });
1227 crate::orchestration::run_command_policy_postflight_with_ctx(
1228 ctx,
1229 params,
1230 response,
1231 command_policy_context,
1232 command_policy_decisions,
1233 )
1234 .await
1235}
1236
1237async fn read_process_exec_pipe<R>(mut pipe: R) -> std::io::Result<Vec<u8>>
1238where
1239 R: tokio::io::AsyncRead + Unpin,
1240{
1241 let mut bytes = Vec::new();
1242 pipe.read_to_end(&mut bytes).await?;
1243 Ok(bytes)
1244}
1245
1246async fn collect_process_exec_pipe(
1247 task: tokio::task::JoinHandle<std::io::Result<Vec<u8>>>,
1248 name: &str,
1249) -> Result<Vec<u8>, VmError> {
1250 match task.await {
1251 Ok(Ok(bytes)) => Ok(bytes),
1252 Ok(Err(error)) => Err(VmError::Runtime(format!(
1253 "host_call process.exec read {name}: {error}"
1254 ))),
1255 Err(error) => Err(VmError::Runtime(format!(
1256 "host_call process.exec join {name} reader: {error}"
1257 ))),
1258 }
1259}
1260
1261async fn terminate_process_exec_child(
1262 child: &mut tokio::process::Child,
1263 pid: Option<u32>,
1264 cleanup_token: &str,
1265 reason: &'static str,
1266) {
1267 if let Some(pid) = pid {
1268 let mut report = crate::op_interrupt::signal_pid_tree_group_and_token_with_report(
1269 pid,
1270 Some(cleanup_token),
1271 9,
1272 );
1273 report.refresh_survivor_status();
1274 tracing::warn!(
1275 pid,
1276 children = report.children.len(),
1277 reason,
1278 "host_call process.exec signalled child process tree"
1279 );
1280 }
1281 let _ = child.kill().await;
1282 let _ = child.wait().await;
1283}
1284
1285pub(crate) fn build_sandboxed_command(
1297 params: &crate::value::DictMap,
1298 label: &str,
1299) -> Result<tokio::process::Command, VmError> {
1300 let (program, args) = process_exec_argv(params)?;
1301 let mut cmd = crate::process_sandbox::tokio_command_for(&program, &args)
1302 .map_err(|e| VmError::Runtime(format!("host_call {label} sandbox setup: {e}")))?;
1303 if let Some(cwd) = optional_string(params, "cwd") {
1304 let cwd = resolve_process_exec_cwd(&cwd);
1305 crate::process_sandbox::enforce_process_cwd(&cwd)
1306 .map_err(|e| VmError::Runtime(format!("host_call {label} cwd: {e}")))?;
1307 cmd.current_dir(cwd);
1308 }
1309 let mut caller_env_keys: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
1312 if let Some(env) = optional_string_dict(params, "env")? {
1313 let env_mode = optional_string(params, "env_mode");
1322 match env_mode.as_deref().unwrap_or("merge") {
1323 "replace" => {
1324 cmd.env_clear();
1325 }
1326 "merge" => {}
1327 other => {
1328 return Err(VmError::Runtime(format!(
1329 "host_call {label}: unknown env_mode {other:?}; expected \"merge\" or \"replace\""
1330 )));
1331 }
1332 }
1333 for (key, value) in env {
1334 caller_env_keys.insert(key.clone());
1335 cmd.env(key, value);
1336 }
1337 }
1338 if let Some(env_remove) = optional_string_list(params, "env_remove") {
1344 for key in env_remove {
1345 caller_env_keys.insert(key.clone());
1346 cmd.env_remove(key);
1347 }
1348 }
1349 for (key, value) in crate::process_sandbox::active_workspace_tmpdir_env() {
1358 if caller_env_keys.contains(&key) {
1359 continue;
1360 }
1361 cmd.env(key, value);
1362 }
1363 if !caller_env_keys.contains(crate::process_sandbox::MESSAGE_LOCALE_OVERRIDE_ENV) {
1371 cmd.env_remove(crate::process_sandbox::MESSAGE_LOCALE_OVERRIDE_ENV);
1372 }
1373 for (key, value) in crate::process_sandbox::deterministic_message_locale_env() {
1374 if caller_env_keys.contains(&key) {
1375 continue;
1376 }
1377 cmd.env(key, value);
1378 }
1379 Ok(cmd)
1380}
1381
1382struct ProcessExecResponse<'a> {
1383 pid: Option<u32>,
1384 started_at: String,
1385 started: Instant,
1386 stdout: &'a str,
1387 stderr: &'a str,
1388 exit_code: i32,
1389 status: &'a str,
1390 success: bool,
1391 timed_out: bool,
1392 stdout_utf8_valid: bool,
1393 stderr_utf8_valid: bool,
1394}
1395
1396fn process_exec_response(response: ProcessExecResponse<'_>) -> VmValue {
1397 let combined = format!("{}{}", response.stdout, response.stderr);
1398 let mut result = crate::value::DictMap::new();
1399 result.put_str(
1400 "command_id",
1401 format!(
1402 "cmd_{}_{}",
1403 std::process::id(),
1404 response.started.elapsed().as_nanos()
1405 ),
1406 );
1407 result.put_str("status", response.status);
1408 result.insert(
1409 crate::value::intern_key("pid"),
1410 response
1411 .pid
1412 .map(|pid| VmValue::Int(pid as i64))
1413 .unwrap_or(VmValue::Nil),
1414 );
1415 result.insert(
1416 crate::value::intern_key("process_group_id"),
1417 response
1418 .pid
1419 .map(|pid| VmValue::Int(pid as i64))
1420 .unwrap_or(VmValue::Nil),
1421 );
1422 result.insert(crate::value::intern_key("handle_id"), VmValue::Nil);
1423 result.put_str("started_at", response.started_at);
1424 result.put_str(
1425 "ended_at",
1426 audited_utc_now_rfc3339("host_call/process.exec.ended_at"),
1427 );
1428 result.insert(
1429 crate::value::intern_key("duration_ms"),
1430 VmValue::Int(response.started.elapsed().as_millis() as i64),
1431 );
1432 result.insert(
1433 crate::value::intern_key("exit_code"),
1434 VmValue::Int(response.exit_code as i64),
1435 );
1436 result.insert(crate::value::intern_key("signal"), VmValue::Nil);
1437 result.insert(
1438 crate::value::intern_key("timed_out"),
1439 VmValue::Bool(response.timed_out),
1440 );
1441 result.put_str("stdout", response.stdout);
1442 result.put_str("stderr", response.stderr);
1443 result.insert(
1444 crate::value::intern_key("stdout_utf8_valid"),
1445 VmValue::Bool(response.stdout_utf8_valid),
1446 );
1447 result.insert(
1448 crate::value::intern_key("stderr_utf8_valid"),
1449 VmValue::Bool(response.stderr_utf8_valid),
1450 );
1451 result.put_str("combined", combined);
1452 result.insert(
1453 crate::value::intern_key("exit_status"),
1454 VmValue::Int(response.exit_code as i64),
1455 );
1456 result.insert(
1457 crate::value::intern_key("legacy_status"),
1458 VmValue::Int(response.exit_code as i64),
1459 );
1460 result.insert(
1461 crate::value::intern_key("success"),
1462 VmValue::Bool(response.success),
1463 );
1464 VmValue::dict(result)
1465}
1466
1467fn resolve_process_exec_cwd(cwd: &str) -> std::path::PathBuf {
1468 crate::stdlib::process::resolve_source_relative_path(cwd)
1469}
1470
1471fn process_exec_argv(params: &crate::value::DictMap) -> Result<(String, Vec<String>), VmError> {
1472 match optional_string(params, "mode")
1473 .as_deref()
1474 .unwrap_or("shell")
1475 {
1476 "argv" => {
1477 let argv = optional_string_list(params, "argv").ok_or_else(|| {
1478 VmError::Runtime("host_call process.exec missing argv".to_string())
1479 })?;
1480 split_argv(argv)
1481 }
1482 "shell" => {
1483 let command = require_param(params, "command")?;
1484 let mut invocation_params = params.clone();
1485 invocation_params.put_str("command", command);
1486 let invocation =
1487 crate::shells::resolve_invocation_from_vm_params(&invocation_params)
1488 .map_err(|err| VmError::Runtime(format!("host_call process.exec: {err}")))?;
1489 Ok((invocation.program, invocation.args))
1490 }
1491 other => Err(VmError::Runtime(format!(
1492 "host_call process.exec unsupported mode {other:?}"
1493 ))),
1494 }
1495}
1496
1497fn split_argv(mut argv: Vec<String>) -> Result<(String, Vec<String>), VmError> {
1498 if argv.is_empty() {
1499 return Err(VmError::Runtime(
1500 "host_call process.exec argv must not be empty".to_string(),
1501 ));
1502 }
1503 let program = argv.remove(0);
1504 if program.is_empty() {
1505 return Err(VmError::Runtime(
1506 "host_call process.exec argv[0] must not be empty".to_string(),
1507 ));
1508 }
1509 Ok((program, argv))
1510}
1511
1512pub(crate) fn push_sandbox_profile_override(value: &str) -> Result<SandboxProfileGuard, VmError> {
1518 let profile = crate::orchestration::SandboxProfile::parse(value).ok_or_else(|| {
1519 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
1520 "host_call process.exec: unknown sandbox_profile {value:?}; expected one of \"unrestricted\", \"worktree\", \"os_hardened\", \"wasi\""
1521 ))))
1522 })?;
1523 let mut policy = crate::orchestration::current_execution_policy().unwrap_or_default();
1524 policy.sandbox_profile = profile;
1525 crate::orchestration::push_execution_policy(policy);
1526 Ok(SandboxProfileGuard {
1527 _private: std::marker::PhantomData,
1528 })
1529}
1530
1531pub(crate) struct SandboxProfileGuard {
1532 _private: std::marker::PhantomData<*const ()>,
1533}
1534
1535impl Drop for SandboxProfileGuard {
1536 fn drop(&mut self) {
1537 crate::orchestration::pop_execution_policy();
1538 }
1539}
1540
1541pub(crate) fn optional_i64(params: &crate::value::DictMap, key: &str) -> Option<i64> {
1542 match params.get(key) {
1543 Some(VmValue::Int(value)) => Some(*value),
1544 Some(VmValue::Float(value)) if value.fract() == 0.0 => Some(*value as i64),
1545 _ => None,
1546 }
1547}
1548
1549pub(crate) fn optional_string(params: &crate::value::DictMap, key: &str) -> Option<String> {
1550 params.get(key).and_then(vm_string).map(ToString::to_string)
1551}
1552
1553fn optional_string_list(params: &crate::value::DictMap, key: &str) -> Option<Vec<String>> {
1554 let VmValue::List(values) = params.get(key)? else {
1555 return None;
1556 };
1557 values
1558 .iter()
1559 .map(|value| vm_string(value).map(ToString::to_string))
1560 .collect()
1561}
1562
1563fn optional_string_dict(
1564 params: &crate::value::DictMap,
1565 key: &str,
1566) -> Result<Option<BTreeMap<String, String>>, VmError> {
1567 let Some(value) = params.get(key) else {
1568 return Ok(None);
1569 };
1570 let Some(dict) = value.as_dict() else {
1571 return Err(VmError::Runtime(format!(
1572 "host_call process.exec {key} must be a dict"
1573 )));
1574 };
1575 let mut out = std::collections::BTreeMap::new();
1576 for (key, value) in dict.iter() {
1577 let Some(value) = vm_string(value) else {
1578 return Err(VmError::Runtime(format!(
1579 "host_call process.exec env value for {key:?} must be a string"
1580 )));
1581 };
1582 out.insert(key.to_string(), value.to_string());
1583 }
1584 Ok(Some(out))
1585}
1586
1587fn vm_string(value: &VmValue) -> Option<&str> {
1588 match value {
1589 VmValue::String(value) => Some(value.as_ref()),
1590 _ => None,
1591 }
1592}
1593
1594pub(crate) fn register_host_builtins(vm: &mut Vm) {
1595 for def in MODULE_BUILTINS {
1596 vm.register_builtin_def(def);
1597 }
1598}
1599
1600pub(crate) fn register_missing_host_builtins(vm: &mut Vm) {
1601 for def in MODULE_BUILTINS {
1602 if vm.builtin_metadata_for(def.sig.name).is_none() {
1603 vm.register_builtin_def(def);
1604 }
1605 }
1606}
1607
1608#[harn_builtin(
1609 sig = "host_mock(capability: string, op: string, response_or_config?: any, params?: dict) -> nil",
1610 category = "host"
1611)]
1612fn host_mock_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1613 let host_mock = parse_host_mock(args)?;
1614 validate_host_mock_registration(&host_mock)?;
1615 push_host_mock(host_mock);
1616 Ok(VmValue::Nil)
1617}
1618
1619#[harn_builtin(sig = "host_mock_clear() -> nil", category = "host")]
1620fn host_mock_clear_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1621 reset_host_state();
1622 Ok(VmValue::Nil)
1623}
1624
1625#[harn_builtin(sig = "host_mock_calls() -> list", category = "host")]
1626fn host_mock_calls_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1627 let calls = HOST_MOCK_CALLS.with(|calls| {
1628 calls
1629 .borrow()
1630 .iter()
1631 .map(mock_call_value)
1632 .collect::<Vec<_>>()
1633 });
1634 Ok(VmValue::List(std::sync::Arc::new(calls)))
1635}
1636
1637#[harn_builtin(sig = "host_mock_push_scope() -> nil", category = "host")]
1638fn host_mock_push_scope_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1639 push_host_mock_scope();
1640 Ok(VmValue::Nil)
1641}
1642
1643#[harn_builtin(sig = "host_mock_pop_scope() -> nil", category = "host")]
1644fn host_mock_pop_scope_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1645 if !pop_host_mock_scope() {
1646 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1647 "host_mock_pop_scope: no scope to pop",
1648 ))));
1649 }
1650 Ok(VmValue::Nil)
1651}
1652
1653#[harn_builtin(sig = "host_capabilities() -> dict", category = "host")]
1654fn host_capabilities_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1655 Ok(capability_manifest_with_mocks())
1656}
1657
1658#[harn_builtin(
1659 sig = "host_has(capability: string, op?: string) -> bool",
1660 category = "host"
1661)]
1662fn host_has_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1663 let capability = args.first().map(|a| a.display()).unwrap_or_default();
1664 let operation = args.get(1).map(|a| a.display());
1665 let manifest = capability_manifest_with_mocks();
1666 let has = manifest
1667 .as_dict()
1668 .and_then(|d| d.get(capability.as_str()))
1669 .and_then(|v| v.as_dict())
1670 .is_some_and(|cap| {
1671 if let Some(operation) = operation {
1672 cap.get("ops")
1673 .and_then(|v| match v {
1674 VmValue::List(list) => {
1675 Some(list.iter().any(|item| item.display() == operation))
1676 }
1677 _ => None,
1678 })
1679 .unwrap_or(false)
1680 } else {
1681 true
1682 }
1683 });
1684 Ok(VmValue::Bool(has))
1685}
1686
1687#[harn_builtin(
1688 sig = "host_call(name: string, args?: dict) -> any",
1689 kind = "async",
1690 category = "host"
1691)]
1692async fn host_call_builtin(
1693 ctx: crate::vm::AsyncBuiltinCtx,
1694 args: Vec<VmValue>,
1695) -> Result<VmValue, VmError> {
1696 let name = args.first().map(|a| a.display()).unwrap_or_default();
1697 let params = args
1698 .get(1)
1699 .and_then(|a| a.as_dict())
1700 .cloned()
1701 .unwrap_or_default();
1702 let Some((capability, operation)) = name.split_once('.') else {
1703 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1704 format!("host_call: unsupported operation name '{name}'"),
1705 ))));
1706 };
1707 dispatch_host_operation_with_ctx(Some(&ctx), capability, operation, ¶ms).await
1708}
1709
1710#[harn_builtin(sig = "host_tool_list() -> list", kind = "async", category = "host")]
1711async fn host_tool_list_builtin(
1712 ctx: crate::vm::AsyncBuiltinCtx,
1713 _args: Vec<VmValue>,
1714) -> Result<VmValue, VmError> {
1715 dispatch_host_tool_list_with_ctx(Some(&ctx)).await
1716}
1717
1718#[harn_builtin(
1719 sig = "host_tool_call(name: string, args?: any) -> any",
1720 kind = "async",
1721 category = "host"
1722)]
1723async fn host_tool_call_builtin(
1724 ctx: crate::vm::AsyncBuiltinCtx,
1725 args: Vec<VmValue>,
1726) -> Result<VmValue, VmError> {
1727 let name = args.first().map(|a| a.display()).unwrap_or_default();
1728 if name.is_empty() {
1729 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1730 "host_tool_call: tool name is required",
1731 ))));
1732 }
1733 let call_args = args.get(1).cloned().unwrap_or(VmValue::Nil);
1734 dispatch_host_tool_call_with_ctx(Some(&ctx), &name, &call_args).await
1735}
1736
1737#[cfg(test)]
1738mod tests {
1739 use super::{
1740 build_sandboxed_command, capability_manifest_with_mocks, clear_host_call_bridge,
1741 dispatch_host_operation, dispatch_host_tool_call, dispatch_host_tool_list,
1742 dispatch_mock_host_call, dispatch_mock_hostlib_call, host_has_builtin,
1743 host_mock_clear_builtin, parse_host_mock, push_host_mock, register_mockable_host_operation,
1744 register_scoped_mockable_host_operation, reset_host_state, reset_scoped_host_state,
1745 resolve_process_exec_cwd, set_host_call_bridge, validate_host_mock_registration,
1746 HostCallBridge, HostMock,
1747 };
1748 use crate::value::VmDictExt;
1749
1750 use std::sync::{
1751 atomic::{AtomicUsize, Ordering},
1752 Arc,
1753 };
1754
1755 use crate::value::{VmError, VmValue};
1756
1757 fn command_env(
1761 cmd: &tokio::process::Command,
1762 ) -> std::collections::BTreeMap<String, Option<String>> {
1763 cmd.as_std()
1764 .get_envs()
1765 .map(|(k, v)| {
1766 (
1767 k.to_string_lossy().into_owned(),
1768 v.map(|value| value.to_string_lossy().into_owned()),
1769 )
1770 })
1771 .collect()
1772 }
1773
1774 #[test]
1775 fn build_sandboxed_command_forces_deterministic_message_locale() {
1776 let mut params = crate::value::DictMap::new();
1786 params.put_str("mode", "argv");
1787 params.put(
1788 "argv",
1789 VmValue::List(Arc::new(vec![VmValue::string("/bin/true")])),
1790 );
1791 params.put_str("env_mode", "merge");
1792 let mut caller_env = crate::value::DictMap::new();
1793 caller_env.put_str("CARGO_TARGET_DIR", "/tmp/target");
1795 params.put("env", VmValue::dict_map(caller_env));
1796
1797 let cmd = build_sandboxed_command(¶ms, "process.exec").expect("build command");
1798 let env = command_env(&cmd);
1799
1800 assert_eq!(
1801 env.get("LC_ALL"),
1802 Some(&None),
1803 "the builder must remove LC_ALL from the child so an inherited shell \
1804 value cannot override the forced LC_MESSAGES"
1805 );
1806 assert_eq!(
1807 env.get("LC_MESSAGES"),
1808 Some(&Some("C".to_string())),
1809 "LC_MESSAGES must be pinned to C for untranslated (English) tool output"
1810 );
1811 assert_eq!(
1812 env.get("DOTNET_CLI_UI_LANGUAGE"),
1813 Some(&Some("en".to_string())),
1814 ".NET ignores LC_* and needs its own UI-language override"
1815 );
1816 }
1817
1818 #[test]
1819 fn build_sandboxed_command_respects_a_caller_pinned_locale() {
1820 let mut params = crate::value::DictMap::new();
1823 params.put_str("mode", "argv");
1824 params.put(
1825 "argv",
1826 VmValue::List(Arc::new(vec![VmValue::string("/bin/true")])),
1827 );
1828 params.put_str("env_mode", "merge");
1829 let mut caller_env = crate::value::DictMap::new();
1830 caller_env.put_str("LC_ALL", "fr_FR.UTF-8");
1831 caller_env.put_str("LC_MESSAGES", "fr_FR.UTF-8");
1832 params.put("env", VmValue::dict_map(caller_env));
1833
1834 let cmd = build_sandboxed_command(¶ms, "process.exec").expect("build command");
1835 let env = command_env(&cmd);
1836
1837 assert_eq!(
1838 env.get("LC_ALL"),
1839 Some(&Some("fr_FR.UTF-8".to_string())),
1840 "a caller that pins LC_ALL keeps it — the overlay must not strip an explicit value"
1841 );
1842 assert_eq!(
1843 env.get("LC_MESSAGES"),
1844 Some(&Some("fr_FR.UTF-8".to_string())),
1845 "a caller-pinned LC_MESSAGES wins over the C overlay"
1846 );
1847 }
1848
1849 #[test]
1850 fn process_exec_relative_cwd_resolves_against_execution_root() {
1851 let dir = tempfile::tempdir().expect("tempdir");
1852 crate::stdlib::process::set_thread_execution_context(Some(
1853 crate::orchestration::RunExecutionRecord {
1854 cwd: Some(dir.path().to_string_lossy().into_owned()),
1855 project_root: None,
1856 source_dir: Some(dir.path().join("src").to_string_lossy().into_owned()),
1857 env: std::collections::BTreeMap::new(),
1858 adapter: None,
1859 repo_path: None,
1860 worktree_path: None,
1861 branch: None,
1862 base_ref: None,
1863 cleanup: None,
1864 },
1865 ));
1866
1867 assert_eq!(
1868 resolve_process_exec_cwd("subdir"),
1869 dir.path().join("subdir")
1870 );
1871
1872 crate::stdlib::process::set_thread_execution_context(None);
1873 }
1874
1875 #[test]
1876 fn workspace_project_root_fallback_prefers_execution_context_project_root() {
1877 run_host_async_test(|| async {
1878 let project = tempfile::tempdir().expect("project root");
1879 let cwd = tempfile::tempdir().expect("cwd");
1880 crate::stdlib::process::set_thread_execution_context(Some(
1881 crate::orchestration::RunExecutionRecord {
1882 cwd: Some(cwd.path().to_string_lossy().into_owned()),
1883 project_root: Some(project.path().to_string_lossy().into_owned()),
1884 source_dir: None,
1885 env: std::collections::BTreeMap::new(),
1886 adapter: None,
1887 repo_path: None,
1888 worktree_path: None,
1889 branch: None,
1890 base_ref: None,
1891 cleanup: None,
1892 },
1893 ));
1894
1895 let result =
1896 dispatch_host_operation("workspace", "project_root", &crate::value::DictMap::new())
1897 .await
1898 .expect("workspace.project_root result");
1899
1900 crate::stdlib::process::set_thread_execution_context(None);
1901 assert_eq!(result.display(), project.path().display().to_string());
1902 });
1903 }
1904
1905 #[test]
1906 fn manifest_includes_operation_metadata() {
1907 let manifest = capability_manifest_with_mocks();
1908 let process = manifest
1909 .as_dict()
1910 .and_then(|d| d.get("process"))
1911 .and_then(|v| v.as_dict())
1912 .expect("process capability");
1913 assert!(process.get("description").is_some());
1914 let operations = process
1915 .get("operations")
1916 .and_then(|v| v.as_dict())
1917 .expect("operations dict");
1918 assert!(operations.get("exec").is_some());
1919 }
1920
1921 #[test]
1922 fn mocked_capabilities_appear_in_manifest() {
1923 reset_host_state();
1924 push_host_mock(HostMock {
1925 capability: "project".to_string(),
1926 operation: "metadata_get".to_string(),
1927 params: None,
1928 result: Some(VmValue::dict(crate::value::DictMap::new())),
1929 error: None,
1930 unregistered_ok: false,
1931 });
1932 let manifest = capability_manifest_with_mocks();
1933 let project = manifest
1934 .as_dict()
1935 .and_then(|d| d.get("project"))
1936 .and_then(|v| v.as_dict())
1937 .expect("project capability");
1938 let operations = project
1939 .get("operations")
1940 .and_then(|v| v.as_dict())
1941 .expect("operations dict");
1942 assert!(operations.get("metadata_get").is_some());
1943 reset_host_state();
1944 }
1945
1946 #[test]
1947 fn mock_host_call_matches_partial_params_and_overrides_order() {
1948 reset_host_state();
1949 let mut exact_params = crate::value::DictMap::new();
1950 exact_params.put_str("namespace", "facts");
1951 push_host_mock(HostMock {
1952 capability: "project".to_string(),
1953 operation: "metadata_get".to_string(),
1954 params: None,
1955 result: Some(VmValue::String(arcstr::ArcStr::from("fallback"))),
1956 error: None,
1957 unregistered_ok: false,
1958 });
1959 push_host_mock(HostMock {
1960 capability: "project".to_string(),
1961 operation: "metadata_get".to_string(),
1962 params: Some(exact_params),
1963 result: Some(VmValue::String(arcstr::ArcStr::from("facts"))),
1964 error: None,
1965 unregistered_ok: false,
1966 });
1967
1968 let mut call_params = crate::value::DictMap::new();
1969 call_params.put_str("dir", "pkg");
1970 call_params.put_str("namespace", "facts");
1971 let exact = dispatch_mock_host_call("project", "metadata_get", &call_params)
1972 .expect("expected exact mock")
1973 .expect("exact mock should succeed");
1974 assert_eq!(exact.display(), "facts");
1975
1976 call_params.put_str("namespace", "classification");
1977 let fallback = dispatch_mock_host_call("project", "metadata_get", &call_params)
1978 .expect("expected fallback mock")
1979 .expect("fallback mock should succeed");
1980 assert_eq!(fallback.display(), "fallback");
1981 reset_host_state();
1982 }
1983
1984 #[test]
1985 fn mock_host_call_can_throw_errors() {
1986 reset_host_state();
1987 push_host_mock(HostMock {
1988 capability: "project".to_string(),
1989 operation: "metadata_get".to_string(),
1990 params: None,
1991 result: None,
1992 error: Some("boom".to_string()),
1993 unregistered_ok: false,
1994 });
1995 let params = crate::value::DictMap::new();
1996 let result = dispatch_mock_host_call("project", "metadata_get", ¶ms)
1997 .expect("expected mock result");
1998 match result {
1999 Err(VmError::Thrown(VmValue::String(message))) => assert_eq!(message.as_str(), "boom"),
2000 other => panic!("unexpected result: {other:?}"),
2001 }
2002 reset_host_state();
2003 }
2004
2005 #[test]
2006 fn host_mock_registration_rejects_unknown_operations_by_default() {
2007 let host_mock = HostMock {
2008 capability: "runtime".to_string(),
2009 operation: "tas".to_string(),
2010 params: None,
2011 result: Some(VmValue::Nil),
2012 error: None,
2013 unregistered_ok: false,
2014 };
2015 let error = validate_host_mock_registration(&host_mock)
2016 .expect_err("unknown host operation should fail at registration");
2017 match error {
2018 VmError::Thrown(VmValue::String(message)) => {
2019 assert!(message.contains("runtime.tas"));
2020 assert!(message.contains("unregistered_ok"));
2021 assert!(message.contains("runtime.task"));
2022 }
2023 other => panic!("unexpected error: {other:?}"),
2024 }
2025 }
2026
2027 #[test]
2028 fn host_mock_registration_allows_explicit_test_local_operations() {
2029 let host_mock = HostMock {
2030 capability: "synthetic".to_string(),
2031 operation: "op".to_string(),
2032 params: None,
2033 result: Some(VmValue::Nil),
2034 error: None,
2035 unregistered_ok: true,
2036 };
2037 validate_host_mock_registration(&host_mock)
2038 .expect("explicit unregistered_ok should permit synthetic mocks");
2039 }
2040
2041 #[test]
2042 fn host_mock_registration_accepts_runtime_registered_operations() {
2043 register_mockable_host_operation(
2044 "code_index",
2045 "stats",
2046 "Hostlib schema-backed operation registered at runtime.",
2047 );
2048 let host_mock = HostMock {
2049 capability: "code_index".to_string(),
2050 operation: "stats".to_string(),
2051 params: None,
2052 result: Some(VmValue::Nil),
2053 error: None,
2054 unregistered_ok: false,
2055 };
2056 validate_host_mock_registration(&host_mock)
2057 .expect("registered hostlib operations should be mockable");
2058 }
2059
2060 #[test]
2061 fn clearing_live_mocks_preserves_scoped_manifest_declarations() {
2062 reset_scoped_host_state();
2063 register_scoped_mockable_host_operation(
2064 "scoped_clear_fixture",
2065 "answer",
2066 "Test-scoped manifest declaration.",
2067 );
2068 let host_mock = HostMock {
2069 capability: "scoped_clear_fixture".to_string(),
2070 operation: "answer".to_string(),
2071 params: None,
2072 result: Some(VmValue::Nil),
2073 error: None,
2074 unregistered_ok: false,
2075 };
2076
2077 validate_host_mock_registration(&host_mock).expect("scoped declaration is registered");
2078 host_mock_clear_builtin(&[], &mut String::new()).expect("clear live mocks");
2079 validate_host_mock_registration(&host_mock)
2080 .expect("clearing live mocks must preserve manifest declarations");
2081 reset_scoped_host_state();
2082 }
2083
2084 #[tokio::test]
2085 async fn declared_mockable_operation_is_not_reported_as_callable() {
2086 std::thread::spawn(|| {
2087 register_mockable_host_operation(
2088 "async_host_registration",
2089 "cross_thread",
2090 "Embedding operation registered before async worker migration.",
2091 );
2092 })
2093 .join()
2094 .expect("registration worker should finish");
2095
2096 std::thread::spawn(|| {
2097 let host_mock = HostMock {
2098 capability: "async_host_registration".to_string(),
2099 operation: "cross_thread".to_string(),
2100 params: None,
2101 result: Some(VmValue::Nil),
2102 error: None,
2103 unregistered_ok: false,
2104 };
2105 validate_host_mock_registration(&host_mock)
2106 .expect("process host registration should be visible after worker migration");
2107
2108 let typo = HostMock {
2109 operation: "cross_tread".to_string(),
2110 ..host_mock
2111 };
2112 validate_host_mock_registration(&typo)
2113 .expect_err("an undeclared operation should still fail closed");
2114 })
2115 .join()
2116 .expect("validation worker should finish");
2117
2118 assert!(matches!(
2119 host_has_builtin(
2120 &[
2121 VmValue::string("async_host_registration"),
2122 VmValue::string("cross_thread"),
2123 ],
2124 &mut String::new(),
2125 )
2126 .expect("host_has should succeed"),
2127 VmValue::Bool(false)
2128 ));
2129 dispatch_host_operation(
2130 "async_host_registration",
2131 "cross_thread",
2132 &crate::value::DictMap::new(),
2133 )
2134 .await
2135 .expect_err("an unmocked declaration must remain unsupported at dispatch");
2136 }
2137
2138 #[test]
2139 fn host_mock_parse_preserves_unregistered_ok_config() {
2140 let config = VmValue::dict(crate::value::DictMap::from_iter([
2141 (crate::value::intern_key("result"), VmValue::string("ok")),
2142 (
2143 crate::value::intern_key("unregistered_ok"),
2144 VmValue::Bool(true),
2145 ),
2146 ]));
2147 let host_mock =
2148 parse_host_mock(&[VmValue::string("synthetic"), VmValue::string("op"), config])
2149 .expect("parse host mock config");
2150 assert!(host_mock.unregistered_ok);
2151 }
2152
2153 #[test]
2154 fn hostlib_mock_dispatch_matches_module_method_and_params() {
2155 reset_host_state();
2156 let mut mock_params = crate::value::DictMap::new();
2157 mock_params.put(
2158 "argv",
2159 VmValue::List(Arc::new(vec![VmValue::string("echo")])),
2160 );
2161 push_host_mock(HostMock {
2162 capability: "tools".to_string(),
2163 operation: "run_command".to_string(),
2164 params: Some(mock_params),
2165 result: Some(VmValue::String(arcstr::ArcStr::from("direct"))),
2166 error: None,
2167 unregistered_ok: false,
2168 });
2169
2170 let mut call_params = crate::value::DictMap::new();
2171 call_params.put(
2172 "argv",
2173 VmValue::List(Arc::new(vec![VmValue::string("echo")])),
2174 );
2175 call_params.put_str("cwd", "/tmp/not-used");
2176 let value = dispatch_mock_hostlib_call("tools", "run_command", &call_params)
2177 .expect("expected hostlib mock")
2178 .expect("hostlib mock should succeed");
2179 assert_eq!(value.display(), "direct");
2180 reset_host_state();
2181 }
2182
2183 #[test]
2184 fn hostlib_run_command_falls_back_to_process_exec_mocks() {
2185 reset_host_state();
2186 let mut mock_params = crate::value::DictMap::new();
2187 mock_params.put(
2188 "argv",
2189 VmValue::List(Arc::new(vec![
2190 VmValue::string("cargo"),
2191 VmValue::string("test"),
2192 ])),
2193 );
2194 push_host_mock(HostMock {
2195 capability: "process".to_string(),
2196 operation: "exec".to_string(),
2197 params: Some(mock_params),
2198 result: Some(VmValue::String(arcstr::ArcStr::from("legacy"))),
2199 error: None,
2200 unregistered_ok: false,
2201 });
2202
2203 let mut call_params = crate::value::DictMap::new();
2204 call_params.put(
2205 "argv",
2206 VmValue::List(Arc::new(vec![
2207 VmValue::string("cargo"),
2208 VmValue::string("test"),
2209 ])),
2210 );
2211 call_params.put_str("cwd", "/tmp/not-used");
2212 let value = dispatch_mock_hostlib_call("tools", "run_command", &call_params)
2213 .expect("expected legacy process.exec mock")
2214 .expect("legacy mock should succeed");
2215 assert_eq!(value.display(), "legacy");
2216 reset_host_state();
2217 }
2218
2219 #[test]
2220 fn hostlib_run_command_prefers_exact_mock_over_process_exec_alias() {
2221 reset_host_state();
2222 let mut params = crate::value::DictMap::new();
2223 params.put(
2224 "argv",
2225 VmValue::List(Arc::new(vec![
2226 VmValue::string("npm"),
2227 VmValue::string("test"),
2228 ])),
2229 );
2230 push_host_mock(HostMock {
2231 capability: "process".to_string(),
2232 operation: "exec".to_string(),
2233 params: Some(params.clone()),
2234 result: Some(VmValue::String(arcstr::ArcStr::from("legacy"))),
2235 error: None,
2236 unregistered_ok: false,
2237 });
2238 push_host_mock(HostMock {
2239 capability: "tools".to_string(),
2240 operation: "run_command".to_string(),
2241 params: Some(params.clone()),
2242 result: Some(VmValue::String(arcstr::ArcStr::from("direct"))),
2243 error: None,
2244 unregistered_ok: false,
2245 });
2246
2247 let value = dispatch_mock_hostlib_call("tools", "run_command", ¶ms)
2248 .expect("expected exact hostlib mock")
2249 .expect("exact mock should succeed");
2250 assert_eq!(value.display(), "direct");
2251 reset_host_state();
2252 }
2253
2254 #[derive(Default)]
2255 struct TestHostToolBridge;
2256
2257 impl HostCallBridge for TestHostToolBridge {
2258 fn dispatch(
2259 &self,
2260 _capability: &str,
2261 _operation: &str,
2262 _params: &crate::value::DictMap,
2263 ) -> Result<Option<VmValue>, VmError> {
2264 Ok(None)
2265 }
2266
2267 fn list_tools(&self) -> Result<Option<VmValue>, VmError> {
2268 let tool = VmValue::dict(crate::value::DictMap::from_iter([
2269 (
2270 crate::value::intern_key("name"),
2271 VmValue::String(arcstr::ArcStr::from("Read".to_string())),
2272 ),
2273 (
2274 crate::value::intern_key("description"),
2275 VmValue::String(arcstr::ArcStr::from(
2276 "Read a file from the host".to_string(),
2277 )),
2278 ),
2279 (
2280 crate::value::intern_key("schema"),
2281 VmValue::dict(crate::value::DictMap::from_iter([(
2282 crate::value::intern_key("type"),
2283 VmValue::String(arcstr::ArcStr::from("object".to_string())),
2284 )])),
2285 ),
2286 (crate::value::intern_key("deprecated"), VmValue::Bool(false)),
2287 ]));
2288 Ok(Some(VmValue::List(std::sync::Arc::new(vec![tool]))))
2289 }
2290
2291 fn call_tool(&self, name: &str, args: &VmValue) -> Result<Option<VmValue>, VmError> {
2292 if name != "Read" {
2293 return Ok(None);
2294 }
2295 let path = args
2296 .as_dict()
2297 .and_then(|dict| dict.get("path"))
2298 .map(|value| value.display())
2299 .unwrap_or_default();
2300 Ok(Some(VmValue::String(arcstr::ArcStr::from(format!(
2301 "read:{path}"
2302 )))))
2303 }
2304 }
2305
2306 struct CountingProcessExecBridge {
2307 calls: Arc<AtomicUsize>,
2308 }
2309
2310 impl HostCallBridge for CountingProcessExecBridge {
2311 fn dispatch(
2312 &self,
2313 capability: &str,
2314 operation: &str,
2315 _params: &crate::value::DictMap,
2316 ) -> Result<Option<VmValue>, VmError> {
2317 if (capability, operation) != ("process", "exec") {
2318 return Ok(None);
2319 }
2320 self.calls.fetch_add(1, Ordering::SeqCst);
2321 Ok(Some(VmValue::dict(crate::value::DictMap::from_iter([
2322 (
2323 crate::value::intern_key("status"),
2324 VmValue::String(arcstr::ArcStr::from("completed".to_string())),
2325 ),
2326 (crate::value::intern_key("exit_code"), VmValue::Int(0)),
2327 (crate::value::intern_key("success"), VmValue::Bool(true)),
2328 ]))))
2329 }
2330 }
2331
2332 fn run_host_async_test<F, Fut>(test: F)
2333 where
2334 F: FnOnce() -> Fut,
2335 Fut: std::future::Future<Output = ()>,
2336 {
2337 let rt = tokio::runtime::Builder::new_current_thread()
2338 .enable_all()
2339 .build()
2340 .expect("runtime");
2341 rt.block_on(async {
2342 let local = tokio::task::LocalSet::new();
2343 local.run_until(test()).await;
2344 });
2345 }
2346
2347 #[test]
2348 fn host_tool_list_uses_installed_host_call_bridge() {
2349 run_host_async_test(|| async {
2350 reset_host_state();
2351 set_host_call_bridge(Arc::new(TestHostToolBridge));
2352 let tools = dispatch_host_tool_list().await.expect("tool list");
2353 clear_host_call_bridge();
2354
2355 let VmValue::List(items) = tools else {
2356 panic!("expected tool list");
2357 };
2358 assert_eq!(items.len(), 1);
2359 let tool = items[0].as_dict().expect("tool dict");
2360 assert_eq!(tool.get("name").unwrap().display(), "Read");
2361 assert_eq!(tool.get("deprecated").unwrap().display(), "false");
2362 });
2363 }
2364
2365 #[test]
2366 fn host_tool_call_uses_installed_host_call_bridge() {
2367 run_host_async_test(|| async {
2368 set_host_call_bridge(Arc::new(TestHostToolBridge));
2369 let args = VmValue::dict(crate::value::DictMap::from_iter([(
2370 crate::value::intern_key("path"),
2371 VmValue::String(arcstr::ArcStr::from("README.md".to_string())),
2372 )]));
2373 let value = dispatch_host_tool_call("Read", &args)
2374 .await
2375 .expect("tool call");
2376 clear_host_call_bridge();
2377 assert_eq!(value.display(), "read:README.md");
2378 });
2379 }
2380
2381 #[test]
2382 fn process_exec_bridge_is_gated_by_command_policy() {
2383 run_host_async_test(|| async {
2384 crate::orchestration::clear_command_policies();
2385 let calls = Arc::new(AtomicUsize::new(0));
2386 set_host_call_bridge(Arc::new(CountingProcessExecBridge {
2387 calls: calls.clone(),
2388 }));
2389 crate::orchestration::push_command_policy(crate::orchestration::CommandPolicy {
2390 tools: vec!["run".to_string()],
2391 workspace_roots: Vec::new(),
2392 default_shell_mode: "shell".to_string(),
2393 deny_patterns: vec!["cat *".to_string()],
2394 require_approval: Default::default(),
2395 deny_labels: Default::default(),
2396 pre: None,
2397 post: None,
2398 consent: None,
2399 allow_recursive: false,
2400 });
2401
2402 let result = dispatch_host_operation(
2403 "process",
2404 "exec",
2405 &crate::value::DictMap::from_iter([
2406 (
2407 crate::value::intern_key("mode"),
2408 VmValue::String(arcstr::ArcStr::from("shell")),
2409 ),
2410 (
2411 crate::value::intern_key("command"),
2412 VmValue::String(arcstr::ArcStr::from("cat Cargo.toml")),
2413 ),
2414 ]),
2415 )
2416 .await
2417 .expect("process.exec result");
2418
2419 crate::orchestration::clear_command_policies();
2420 clear_host_call_bridge();
2421
2422 assert_eq!(
2423 calls.load(Ordering::SeqCst),
2424 0,
2425 "blocked command must not reach host bridge"
2426 );
2427 let result = result.as_dict().expect("blocked result dict");
2428 assert_eq!(result.get("status").unwrap().display(), "blocked");
2429 assert!(
2430 result
2431 .get("reason")
2432 .map(VmValue::display)
2433 .unwrap_or_default()
2434 .contains("cat *"),
2435 "blocked result should name the matched policy pattern"
2436 );
2437 });
2438 }
2439
2440 #[cfg(unix)]
2441 async fn process_exec_env_probe(env: VmValue, env_mode: Option<&str>) -> (String, String) {
2442 std::env::set_var("PARENT_VAR", "inherited");
2447 let mut params = crate::value::DictMap::from_iter([
2448 (
2449 crate::value::intern_key("mode"),
2450 VmValue::String(arcstr::ArcStr::from("argv")),
2451 ),
2452 (
2453 crate::value::intern_key("argv"),
2454 VmValue::List(std::sync::Arc::new(vec![
2455 VmValue::String(arcstr::ArcStr::from("/bin/sh")),
2458 VmValue::String(arcstr::ArcStr::from("-c")),
2459 VmValue::String(arcstr::ArcStr::from(
2460 "printf '%s|%s' \"$PARENT_VAR\" \"$CHILD_VAR\"",
2461 )),
2462 ])),
2463 ),
2464 (crate::value::intern_key("env"), env),
2465 ]);
2466 if let Some(mode) = env_mode {
2467 params.put_str("env_mode", mode);
2468 }
2469 let result = super::dispatch_process_exec(¶ms, serde_json::Value::Null)
2470 .await
2471 .expect("process.exec result");
2472 let dict = result.as_dict().expect("result dict");
2473 let stdout = dict.get("stdout").map(VmValue::display).unwrap_or_default();
2474 std::env::remove_var("PARENT_VAR");
2475 let (parent, child) = stdout.split_once('|').unwrap_or((&stdout, ""));
2476 (parent.to_string(), child.to_string())
2477 }
2478
2479 #[cfg(unix)]
2480 #[test]
2481 fn process_exec_env_default_merges_with_parent() {
2482 run_host_async_test(|| async {
2483 let child_env = VmValue::dict(crate::value::DictMap::from_iter([(
2486 crate::value::intern_key("CHILD_VAR"),
2487 VmValue::String(arcstr::ArcStr::from("provided")),
2488 )]));
2489 let (parent, child) = process_exec_env_probe(child_env, None).await;
2490 assert_eq!(
2491 parent, "inherited",
2492 "default env_mode must inherit parent env"
2493 );
2494 assert_eq!(
2495 child, "provided",
2496 "default env_mode must apply provided keys"
2497 );
2498 });
2499 }
2500
2501 #[cfg(unix)]
2502 #[test]
2503 fn process_exec_env_mode_replace_clears_parent() {
2504 run_host_async_test(|| async {
2505 let child_env = VmValue::dict(crate::value::DictMap::from_iter([(
2509 crate::value::intern_key("CHILD_VAR"),
2510 VmValue::String(arcstr::ArcStr::from("provided")),
2511 )]));
2512 let (parent, child) = process_exec_env_probe(child_env, Some("replace")).await;
2513 assert_eq!(parent, "", "explicit replace must clear parent env");
2514 assert_eq!(
2515 child, "provided",
2516 "explicit replace must keep provided keys"
2517 );
2518 });
2519 }
2520
2521 #[cfg(unix)]
2522 #[test]
2523 fn process_exec_env_mode_unknown_is_rejected() {
2524 run_host_async_test(|| async {
2525 let params = crate::value::DictMap::from_iter([
2526 (
2527 crate::value::intern_key("mode"),
2528 VmValue::String(arcstr::ArcStr::from("argv")),
2529 ),
2530 (
2531 crate::value::intern_key("argv"),
2532 VmValue::List(std::sync::Arc::new(vec![VmValue::String(
2533 arcstr::ArcStr::from("true"),
2534 )])),
2535 ),
2536 (
2537 crate::value::intern_key("env"),
2538 VmValue::dict(crate::value::DictMap::from_iter([(
2539 crate::value::intern_key("CHILD_VAR"),
2540 VmValue::String(arcstr::ArcStr::from("x")),
2541 )])),
2542 ),
2543 (
2544 crate::value::intern_key("env_mode"),
2545 VmValue::String(arcstr::ArcStr::from("bogus")),
2546 ),
2547 ]);
2548 let err = super::dispatch_process_exec(¶ms, serde_json::Value::Null)
2549 .await
2550 .expect_err("unknown env_mode must error");
2551 assert!(
2552 format!("{err:?}").contains("env_mode"),
2553 "error should name env_mode, got {err:?}"
2554 );
2555 });
2556 }
2557
2558 #[cfg(unix)]
2564 async fn process_exec_tmpdir_probe(
2565 workspace: &std::path::Path,
2566 caller_env: Option<VmValue>,
2567 ) -> String {
2568 let mut env_pairs = vec![(
2569 crate::value::intern_key("mode"),
2570 VmValue::String(arcstr::ArcStr::from("argv")),
2571 )];
2572 env_pairs.push((
2573 crate::value::intern_key("argv"),
2574 VmValue::List(std::sync::Arc::new(vec![
2575 VmValue::String(arcstr::ArcStr::from("/bin/sh")),
2576 VmValue::String(arcstr::ArcStr::from("-c")),
2577 VmValue::String(arcstr::ArcStr::from("printf '%s' \"$TMPDIR\"")),
2578 ])),
2579 ));
2580 if let Some(env) = caller_env {
2581 env_pairs.push((crate::value::intern_key("env"), env));
2582 }
2583 let params = crate::value::DictMap::from_iter(env_pairs);
2584
2585 crate::orchestration::push_execution_policy(crate::orchestration::CapabilityPolicy {
2586 sandbox_profile: crate::orchestration::SandboxProfile::Worktree,
2587 workspace_roots: vec![workspace.to_string_lossy().into_owned()],
2588 ..crate::orchestration::CapabilityPolicy::default()
2592 });
2593 std::env::set_var("HARN_HANDLER_SANDBOX", "off");
2594 let result = super::dispatch_process_exec(¶ms, serde_json::Value::Null)
2595 .await
2596 .expect("process.exec result");
2597 std::env::remove_var("HARN_HANDLER_SANDBOX");
2598 crate::orchestration::pop_execution_policy();
2599 result
2600 .as_dict()
2601 .and_then(|d| d.get("stdout"))
2602 .map(VmValue::display)
2603 .unwrap_or_default()
2604 }
2605
2606 #[cfg(unix)]
2607 #[test]
2608 fn process_exec_injects_workspace_local_tmpdir() {
2609 run_host_async_test(|| async {
2610 let workspace = tempfile::tempdir().expect("workspace");
2611 let tmpdir = process_exec_tmpdir_probe(workspace.path(), None).await;
2612
2613 assert!(
2614 !tmpdir.is_empty(),
2615 "sandboxed child must receive a non-empty TMPDIR"
2616 );
2617 let tmpdir_path = std::path::PathBuf::from(&tmpdir);
2618 let canonical_tmpdir = std::fs::canonicalize(&tmpdir_path)
2619 .expect("workspace-local TMPDIR should canonicalize");
2620 let canonical_workspace =
2621 std::fs::canonicalize(workspace.path()).expect("workspace should canonicalize");
2622 assert!(
2623 canonical_tmpdir.starts_with(&canonical_workspace),
2624 "child TMPDIR {tmpdir:?} must live inside the workspace {:?}",
2625 workspace.path()
2626 );
2627 assert!(
2628 tmpdir_path.ends_with(".harn-tmp"),
2629 "child TMPDIR {tmpdir:?} must be the workspace-local .harn-tmp dir"
2630 );
2631 assert!(
2632 tmpdir_path.is_dir(),
2633 "the workspace-local TMPDIR must have been created on disk"
2634 );
2635 });
2636 }
2637
2638 #[cfg(unix)]
2639 #[test]
2640 fn process_exec_respects_caller_pinned_tmpdir() {
2641 run_host_async_test(|| async {
2642 let workspace = tempfile::tempdir().expect("workspace");
2643 let caller_tmp = workspace.path().join("caller-chosen");
2644 std::fs::create_dir_all(&caller_tmp).unwrap();
2645 let caller_env = VmValue::dict(crate::value::DictMap::from_iter([(
2646 crate::value::intern_key("TMPDIR"),
2647 VmValue::String(arcstr::ArcStr::from(
2648 caller_tmp.to_string_lossy().into_owned(),
2649 )),
2650 )]));
2651
2652 let tmpdir = process_exec_tmpdir_probe(workspace.path(), Some(caller_env)).await;
2653
2654 assert_eq!(
2655 std::path::PathBuf::from(&tmpdir),
2656 caller_tmp,
2657 "an explicit caller TMPDIR must override the workspace-local default"
2658 );
2659 });
2660 }
2661
2662 #[test]
2663 fn host_tool_list_is_empty_without_bridge() {
2664 run_host_async_test(|| async {
2665 clear_host_call_bridge();
2666 let tools = dispatch_host_tool_list().await.expect("tool list");
2667 let VmValue::List(items) = tools else {
2668 panic!("expected tool list");
2669 };
2670 assert!(items.is_empty());
2671 });
2672 }
2673}