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