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