1use crate::value::VmDictExt;
2use std::cell::RefCell;
3use std::collections::BTreeMap;
4use std::sync::Arc;
5
6use serde_json::Value as JsonValue;
7
8use crate::stdlib::macros::{harn_builtin, VmBuiltinDef};
9use crate::value::{values_equal, VmError, VmValue};
10use crate::vm::{AsyncBuiltinCtx, Vm};
11
12mod bridge;
13pub(crate) mod fixtured_operations;
14mod operation_registry;
15pub mod process_admission;
16pub(crate) mod process_dispatch;
17mod process_exec;
18pub mod turn_cache;
21
22use bridge::HOST_CALL_BRIDGE;
23pub use bridge::{
24 clear_host_call_bridge, dispatch_host_call_bridge, host_call_ready, install_host_call_bridge,
25 set_host_call_bridge, HostCallBridge, HostCallBridgeGuard, HostCallDispatchFuture,
26};
27
28use process_dispatch::dispatch_process_exec_with_policy;
29pub(crate) use process_dispatch::{dispatch_process_exec, dispatch_reviewed_git_push_with_lease};
30pub(crate) use process_exec::build_sandboxed_command;
31use process_exec::dispatch_process_spawn_with_policy;
32
33pub(crate) fn audited_utc_now_rfc3339(capability_id: &'static str) -> String {
37 let dt: chrono::DateTime<chrono::Utc> =
38 crate::clock_mock::leak_audit::wall_now(capability_id).into();
39 dt.to_rfc3339()
40}
41
42pub(crate) const MODULE_BUILTINS: &[&VmBuiltinDef] = &[
43 &HOST_MOCK_BUILTIN_DEF,
44 &HOST_MOCK_CLEAR_BUILTIN_DEF,
45 &HOST_MOCK_CALLS_BUILTIN_DEF,
46 &HOST_MOCK_PUSH_SCOPE_BUILTIN_DEF,
47 &HOST_MOCK_POP_SCOPE_BUILTIN_DEF,
48 &HOST_CAPABILITIES_BUILTIN_DEF,
49 &HOST_HAS_BUILTIN_DEF,
50 &HOST_CALL_BUILTIN_DEF,
51 &HOST_TOOL_LIST_BUILTIN_DEF,
52 &HOST_TOOL_CALL_BUILTIN_DEF,
53];
54
55#[derive(Clone)]
56struct HostMock {
57 capability: String,
58 operation: String,
59 params: Option<crate::value::DictMap>,
60 result: Option<VmValue>,
61 error: Option<String>,
62 unregistered_ok: bool,
63}
64
65#[derive(Clone)]
66struct HostMockCall {
67 capability: String,
68 operation: String,
69 params: crate::value::DictMap,
70}
71
72thread_local! {
73 static HOST_MOCKS: RefCell<Vec<HostMock>> = const { RefCell::new(Vec::new()) };
74 static HOST_MOCK_CALLS: RefCell<Vec<HostMockCall>> = const { RefCell::new(Vec::new()) };
75 static HOST_MOCK_SCOPES: RefCell<Vec<(Vec<HostMock>, Vec<HostMockCall>)>> =
76 const { RefCell::new(Vec::new()) };
77}
78
79pub(crate) fn reset_host_state() {
80 HOST_MOCKS.with(|mocks| mocks.borrow_mut().clear());
81 HOST_MOCK_CALLS.with(|calls| calls.borrow_mut().clear());
82 HOST_MOCK_SCOPES.with(|scopes| scopes.borrow_mut().clear());
83 turn_cache::reset_local();
87}
88
89pub(crate) fn reset_scoped_host_state() {
90 operation_registry::clear_scoped_mockable();
91}
92
93fn push_host_mock_scope() {
98 let mocks = HOST_MOCKS.with(|v| std::mem::take(&mut *v.borrow_mut()));
99 let calls = HOST_MOCK_CALLS.with(|v| std::mem::take(&mut *v.borrow_mut()));
100 HOST_MOCK_SCOPES.with(|v| v.borrow_mut().push((mocks, calls)));
101}
102
103fn pop_host_mock_scope() -> bool {
108 let entry = HOST_MOCK_SCOPES.with(|v| v.borrow_mut().pop());
109 match entry {
110 Some((mocks, calls)) => {
111 HOST_MOCKS.with(|v| *v.borrow_mut() = mocks);
112 HOST_MOCK_CALLS.with(|v| *v.borrow_mut() = calls);
113 true
114 }
115 None => false,
116 }
117}
118
119fn capability_manifest_map() -> crate::value::DictMap {
120 let mut root = crate::value::DictMap::new();
121 root.insert(
122 crate::value::intern_key("process"),
123 capability(
124 "Process execution.",
125 &[
126 op("exec", "Execute a process in argv or shell mode."),
127 op(
128 "spawn",
129 "Spawn a process non-blocking; returns a handle immediately for poll/wait/kill.",
130 ),
131 op(
132 "poll",
133 "Non-blocking snapshot of a spawned process: status, captured stdout/stderr.",
134 ),
135 op(
136 "wait",
137 "Await a spawned process to completion (optional timeout_ms); returns final result.",
138 ),
139 op(
140 "kill",
141 "Terminate a spawned process by handle and await the status transition.",
142 ),
143 op(
144 "release",
145 "Release a spawned-process handle and free its retained output.",
146 ),
147 op("list_shells", "List shells discovered by the host/session."),
148 op(
149 "get_default_shell",
150 "Return the selected default shell for this host/session.",
151 ),
152 op(
153 "set_default_shell",
154 "Select the default shell for this host/session.",
155 ),
156 op(
157 "shell_invocation",
158 "Resolve shell selection and login/interactive flags into argv.",
159 ),
160 ],
161 ),
162 );
163 root.insert(
164 crate::value::intern_key("template"),
165 capability(
166 "Template rendering.",
167 &[op("render", "Render a template file.")],
168 ),
169 );
170 root.insert(
171 crate::value::intern_key("interaction"),
172 capability(
173 "User interaction.",
174 &[op("ask", "Ask the user a question.")],
175 ),
176 );
177 root.insert(
178 crate::value::intern_key("memory"),
179 capability(
180 "Vector-aware memory: host-provided embeddings.",
181 &[op(
182 "embed",
183 "Embed text for semantic recall. Params: {text, model_hint?}. \
184 Returns {vector: list<float>, model: string, dim: int}.",
185 )],
186 ),
187 );
188 root.insert(
189 crate::value::intern_key("project"),
190 capability(
191 "Project metadata and durable project facts.",
192 &[
193 op("metadata_get", "Read project metadata."),
194 op("metadata_inspect", "Inspect project metadata provenance."),
195 op("metadata_set", "Write project metadata."),
196 op("metadata_save", "Persist pending project metadata changes."),
197 op("metadata_stale", "Check whether project metadata is stale."),
198 op(
199 "metadata_refresh_hashes",
200 "Refresh project metadata content hashes.",
201 ),
202 ],
203 ),
204 );
205 root.insert(
206 crate::value::intern_key("runtime"),
207 capability(
208 "Runtime task context and run metadata supplied by the active host.",
209 &[
210 op("task", "Read the current runtime task."),
211 op("pipeline_input", "Read the active pipeline input payload."),
212 op("prompt_content", "Read the active session prompt content."),
213 op("dry_run", "Read whether the runtime is in dry-run mode."),
214 op("approved_plan", "Read the approved plan text."),
215 op("record_run", "Record run metadata with the host."),
216 op("set_result", "Write the runtime result payload."),
217 ],
218 ),
219 );
220 root.insert(
221 crate::value::intern_key("workspace"),
222 capability(
223 "Workspace facts and file access supplied by the active host.",
224 &[
225 op("project_root", "Return the active project root."),
226 op("cwd", "Return the active current working directory."),
227 op("read_text", "Read a workspace text file."),
228 op("list", "List workspace files or directories."),
229 op("exists", "Check whether a workspace path exists."),
230 ],
231 ),
232 );
233 root.insert(
234 crate::value::intern_key("oauth_storage"),
235 capability(
236 "Host-managed OAuth token storage.",
237 &[
238 op("cloud_get", "Read a cloud-managed token set."),
239 op("cloud_set", "Write a cloud-managed token set."),
240 op("cloud_delete", "Delete a cloud-managed token set."),
241 op(
242 "cloud_acquire_refresh_lock",
243 "Acquire an OAuth refresh lock.",
244 ),
245 op(
246 "cloud_release_refresh_lock",
247 "Release an OAuth refresh lock.",
248 ),
249 ],
250 ),
251 );
252 root.insert(
253 crate::value::intern_key("mcp"),
254 capability(
255 "MCP host interactions.",
256 &[op("elicit", "Ask the connected MCP client for input.")],
257 ),
258 );
259 root.insert(
260 crate::value::intern_key("hitl"),
261 capability(
262 "Human-in-the-loop host interactions.",
263 &[
264 op(
265 "question",
266 "Ask a human a question through the active host.",
267 ),
268 op(
269 "approval",
270 "Request a human approval through the active host.",
271 ),
272 op(
273 "dual_control",
274 "Request quorum approval from multiple human reviewers.",
275 ),
276 op(
277 "escalation",
278 "Escalate a task to a human role through the active host.",
279 ),
280 ],
281 ),
282 );
283 root
284}
285
286fn mocked_operation_entry() -> VmValue {
287 op(
288 "mocked",
289 "Mocked host operation registered at runtime for tests.",
290 )
291 .1
292}
293
294fn ensure_mocked_capability(
295 root: &mut crate::value::DictMap,
296 capability_name: &str,
297 operation_name: &str,
298) {
299 let Some(existing) = root.get(capability_name).cloned() else {
300 root.insert(
301 crate::value::intern_key(capability_name),
302 capability(
303 "Mocked host capability registered at runtime for tests.",
304 &[(operation_name.to_string(), mocked_operation_entry())],
305 ),
306 );
307 return;
308 };
309
310 let Some(existing_dict) = existing.as_dict() else {
311 return;
312 };
313 let mut entry = (*existing_dict).clone();
314 let mut ops = entry
315 .get("ops")
316 .and_then(|value| match value {
317 VmValue::List(list) => Some((**list).clone()),
318 _ => None,
319 })
320 .unwrap_or_default();
321 if !ops.iter().any(|value| value.display() == operation_name) {
322 ops.push(VmValue::String(arcstr::ArcStr::from(
323 operation_name.to_string(),
324 )));
325 }
326
327 let mut operations = entry
328 .get("operations")
329 .and_then(|value| value.as_dict())
330 .map(|dict| (*dict).clone())
331 .unwrap_or_default();
332 operations
333 .entry(crate::value::intern_key(operation_name))
334 .or_insert_with(mocked_operation_entry);
335
336 entry.insert(
337 crate::value::intern_key("ops"),
338 VmValue::List(std::sync::Arc::new(ops)),
339 );
340 entry.insert(
341 crate::value::intern_key("operations"),
342 VmValue::dict(operations),
343 );
344 root.insert(
345 crate::value::intern_key(capability_name),
346 VmValue::dict(entry),
347 );
348}
349
350fn ensure_registered_operation(
351 root: &mut crate::value::DictMap,
352 capability_name: &str,
353 operation_name: &str,
354 description: &str,
355) {
356 let operation = op(operation_name, description);
357 let Some(existing) = root.get(capability_name).cloned() else {
358 root.insert(
359 crate::value::intern_key(capability_name),
360 capability(description, &[operation]),
361 );
362 return;
363 };
364
365 let Some(existing_dict) = existing.as_dict() else {
366 return;
367 };
368 let mut entry = (*existing_dict).clone();
369 let mut ops = entry
370 .get("ops")
371 .and_then(|value| match value {
372 VmValue::List(list) => Some((**list).clone()),
373 _ => None,
374 })
375 .unwrap_or_default();
376 if !ops.iter().any(|value| value.display() == operation_name) {
377 ops.push(VmValue::String(arcstr::ArcStr::from(
378 operation_name.to_string(),
379 )));
380 }
381
382 let mut operations = entry
383 .get("operations")
384 .and_then(|value| value.as_dict())
385 .map(|dict| (*dict).clone())
386 .unwrap_or_default();
387 operations
388 .entry(crate::value::intern_key(operation_name))
389 .or_insert(operation.1);
390
391 entry.insert(
392 crate::value::intern_key("ops"),
393 VmValue::List(std::sync::Arc::new(ops)),
394 );
395 entry.insert(
396 crate::value::intern_key("operations"),
397 VmValue::dict(operations),
398 );
399 root.insert(
400 crate::value::intern_key(capability_name),
401 VmValue::dict(entry),
402 );
403}
404
405pub fn register_mockable_host_operation(
406 capability_name: impl AsRef<str>,
407 operation_name: impl AsRef<str>,
408 description: impl AsRef<str>,
409) {
410 operation_registry::register_mockable(capability_name, operation_name, description);
411}
412
413pub fn register_scoped_mockable_host_operation(
415 capability_name: impl AsRef<str>,
416 operation_name: impl AsRef<str>,
417 description: impl AsRef<str>,
418) {
419 operation_registry::register_scoped_mockable(capability_name, operation_name, description);
420}
421
422pub fn register_callable_host_operation(
423 capability_name: impl AsRef<str>,
424 operation_name: impl AsRef<str>,
425 description: impl AsRef<str>,
426) {
427 operation_registry::register_callable(capability_name, operation_name, description);
428}
429
430fn apply_registered_operations(root: &mut crate::value::DictMap) {
431 operation_registry::apply_callable(root);
432}
433
434fn capability_manifest_with_mocks() -> VmValue {
435 let mut root = capability_manifest_map();
436 apply_registered_operations(&mut root);
437 HOST_MOCKS.with(|mocks| {
438 for host_mock in mocks.borrow().iter() {
439 ensure_mocked_capability(&mut root, &host_mock.capability, &host_mock.operation);
440 }
441 });
442 fixtured_operations::apply_to_manifest(&mut root, ensure_mocked_capability);
443 VmValue::dict(root)
444}
445
446fn known_host_operations() -> Vec<(String, String)> {
447 let mut root = capability_manifest_map();
448 apply_registered_operations(&mut root);
449 operation_registry::apply_mockable(&mut root);
450 root.into_iter()
451 .flat_map(|(capability_name, capability)| {
452 let capability_name = capability_name.to_string();
453 capability
454 .as_dict()
455 .and_then(|dict| dict.get("ops"))
456 .and_then(|value| match value {
457 VmValue::List(list) => Some((**list).clone()),
458 _ => None,
459 })
460 .unwrap_or_default()
461 .into_iter()
462 .map(move |operation| (capability_name.clone(), operation.display()))
463 })
464 .collect()
465}
466
467pub(crate) fn host_operation_is_registered(capability: &str, operation: &str) -> bool {
468 known_host_operations()
469 .iter()
470 .any(|(known_capability, known_operation)| {
471 known_capability == capability && known_operation == operation
472 })
473}
474
475fn closest_host_operation(capability: &str, operation: &str) -> Option<(String, String)> {
476 let requested = format!("{capability}.{operation}");
477 known_host_operations()
478 .into_iter()
479 .map(|(candidate_capability, candidate_operation)| {
480 let candidate = format!("{candidate_capability}.{candidate_operation}");
481 let distance = strsim::levenshtein(&requested, &candidate);
482 (distance, candidate_capability, candidate_operation)
483 })
484 .filter(|(distance, _, _)| *distance <= 4)
485 .min_by_key(|(distance, _, _)| *distance)
486 .map(|(_, candidate_capability, candidate_operation)| {
487 (candidate_capability, candidate_operation)
488 })
489}
490
491fn validate_host_mock_registration(host_mock: &HostMock) -> Result<(), VmError> {
492 if host_mock.unregistered_ok
493 || host_operation_is_registered(&host_mock.capability, &host_mock.operation)
494 {
495 return Ok(());
496 }
497
498 let mut message = format!(
499 "host_mock: unregistered host operation {}.{}; register the capability/operation on \
500 the host or pass {{unregistered_ok: true}} for a test-local mock",
501 host_mock.capability, host_mock.operation
502 );
503 if let Some((capability, operation)) =
504 closest_host_operation(&host_mock.capability, &host_mock.operation)
505 {
506 message.push_str(&format!(". Did you mean {capability}.{operation}?"));
507 }
508 Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
509 message,
510 ))))
511}
512
513fn op(name: &str, description: &str) -> (String, VmValue) {
514 let mut entry = crate::value::DictMap::new();
515 entry.put_str("description", description);
516 (name.to_string(), VmValue::dict(entry))
517}
518
519fn capability(description: &str, ops: &[(String, VmValue)]) -> VmValue {
520 let mut entry = crate::value::DictMap::new();
521 entry.put_str("description", description);
522 entry.insert(
523 crate::value::intern_key("ops"),
524 VmValue::List(std::sync::Arc::new(
525 ops.iter()
526 .map(|(name, _)| VmValue::String(arcstr::ArcStr::from(name.as_str())))
527 .collect(),
528 )),
529 );
530 let mut op_dict = crate::value::DictMap::new();
531 for (name, op) in ops {
532 op_dict.insert(crate::value::intern_key(name), op.clone());
533 }
534 entry.insert(
535 crate::value::intern_key("operations"),
536 VmValue::dict(op_dict),
537 );
538 VmValue::dict(entry)
539}
540
541pub(crate) fn require_param(params: &crate::value::DictMap, key: &str) -> Result<String, VmError> {
542 params
543 .get(key)
544 .map(|v| v.display())
545 .filter(|v| !v.is_empty())
546 .ok_or_else(|| {
547 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
548 "host_call: missing required parameter '{key}'"
549 ))))
550 })
551}
552
553fn render_template(
554 path: &str,
555 bindings: Option<&crate::value::DictMap>,
556) -> Result<String, VmError> {
557 let asset = crate::stdlib::template::TemplateAsset::render_target(path).map_err(|msg| {
558 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
559 "host_call template.render: {msg}"
560 ))))
561 })?;
562 crate::stdlib::template::render_asset_result(&asset, bindings).map_err(VmError::from)
563}
564
565fn params_match(expected: Option<&crate::value::DictMap>, actual: &crate::value::DictMap) -> bool {
566 let Some(expected) = expected else {
567 return true;
568 };
569 expected.iter().all(|(key, value)| {
570 actual
571 .get(key)
572 .is_some_and(|candidate| values_equal(candidate, value))
573 })
574}
575
576fn parse_host_mock(args: &[VmValue]) -> Result<HostMock, VmError> {
577 let capability = args
578 .first()
579 .map(|value| value.display())
580 .unwrap_or_default();
581 let operation = args.get(1).map(|value| value.display()).unwrap_or_default();
582 if capability.is_empty() || operation.is_empty() {
583 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
584 "host_mock: capability and operation are required",
585 ))));
586 }
587
588 let mut params = args
589 .get(3)
590 .and_then(|value| value.as_dict())
591 .map(|dict| (*dict).clone());
592 let mut result = args.get(2).cloned().or(Some(VmValue::Nil));
593 let mut error = None;
594 let mut unregistered_ok = false;
595
596 if let Some(config) = args.get(2).and_then(|value| value.as_dict()) {
597 if config.contains_key("result")
598 || config.contains_key("params")
599 || config.contains_key("error")
600 || config.contains_key("unregistered_ok")
601 {
602 params = config
603 .get("params")
604 .and_then(|value| value.as_dict())
605 .map(|dict| (*dict).clone());
606 result = config.get("result").cloned();
607 error = config
608 .get("error")
609 .map(|value| value.display())
610 .filter(|value| !value.is_empty());
611 unregistered_ok = matches!(config.get("unregistered_ok"), Some(VmValue::Bool(true)));
612 }
613 }
614
615 Ok(HostMock {
616 capability,
617 operation,
618 params,
619 result,
620 error,
621 unregistered_ok,
622 })
623}
624
625fn push_host_mock(host_mock: HostMock) {
626 HOST_MOCKS.with(|mocks| mocks.borrow_mut().push(host_mock));
627}
628
629fn mock_call_value(call: &HostMockCall) -> VmValue {
630 let mut item = crate::value::DictMap::new();
631 item.put_str("capability", call.capability.clone());
632 item.put_str("operation", call.operation.clone());
633 item.insert(
634 crate::value::intern_key("params"),
635 VmValue::dict(call.params.clone()),
636 );
637 VmValue::dict(item)
638}
639
640fn record_mock_call(capability: &str, operation: &str, params: &crate::value::DictMap) {
641 HOST_MOCK_CALLS.with(|calls| {
642 calls.borrow_mut().push(HostMockCall {
643 capability: capability.to_string(),
644 operation: operation.to_string(),
645 params: params.clone(),
646 });
647 });
648}
649
650pub(crate) fn dispatch_mock_host_call(
651 capability: &str,
652 operation: &str,
653 params: &crate::value::DictMap,
654) -> Option<Result<VmValue, VmError>> {
655 let matched = HOST_MOCKS.with(|mocks| {
656 mocks
657 .borrow()
658 .iter()
659 .rev()
660 .find(|host_mock| {
661 host_mock.capability == capability
662 && host_mock.operation == operation
663 && params_match(host_mock.params.as_ref(), params)
664 })
665 .cloned()
666 })?;
667
668 record_mock_call(capability, operation, params);
669 if let Some(error) = matched.error {
670 return Some(Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
671 error,
672 )))));
673 }
674 Some(Ok(matched.result.unwrap_or(VmValue::Nil)))
675}
676
677pub fn dispatch_mock_hostlib_call(
688 module: &str,
689 method: &str,
690 params: &crate::value::DictMap,
691) -> Option<Result<VmValue, VmError>> {
692 if let Some(mocked) = dispatch_mock_host_call(module, method, params) {
693 return Some(mocked);
694 }
695
696 if (module, method) == ("tools", "run_command") {
697 return dispatch_mock_host_call("process", "exec", params);
698 }
699
700 None
701}
702
703fn empty_tool_list_value() -> VmValue {
704 VmValue::List(std::sync::Arc::new(Vec::new()))
705}
706
707fn current_vm_host_bridge(
708 ctx: Option<&AsyncBuiltinCtx>,
709) -> Option<std::sync::Arc<crate::bridge::HostBridge>> {
710 ctx.and_then(|ctx| ctx.child_vm().bridge.clone())
711}
712
713#[cfg(test)]
714async fn dispatch_host_tool_list() -> Result<VmValue, VmError> {
715 dispatch_host_tool_list_with_ctx(None).await
716}
717
718async fn dispatch_host_tool_list_with_ctx(
719 ctx: Option<&AsyncBuiltinCtx>,
720) -> Result<VmValue, VmError> {
721 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
722 if let Some(bridge) = bridge {
723 if let Some(value) = bridge.list_tools()? {
724 return Ok(value);
725 }
726 }
727
728 let Some(bridge) = current_vm_host_bridge(ctx) else {
729 return Ok(empty_tool_list_value());
730 };
731 let tools = bridge.list_host_tools().await?;
732 Ok(crate::bridge::json_result_to_vm_value(&JsonValue::Array(
733 tools.into_iter().collect(),
734 )))
735}
736
737pub(crate) async fn dispatch_host_tool_call(
738 name: &str,
739 args: &VmValue,
740) -> Result<VmValue, VmError> {
741 dispatch_host_tool_call_with_ctx(None, name, args).await
742}
743
744pub(crate) async fn dispatch_host_tool_call_with_ctx(
745 ctx: Option<&AsyncBuiltinCtx>,
746 name: &str,
747 args: &VmValue,
748) -> Result<VmValue, VmError> {
749 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
750 if let Some(bridge) = bridge {
751 if let Some(value) = bridge.call_tool(name, args)? {
752 return Ok(value);
753 }
754 }
755
756 let Some(bridge) = current_vm_host_bridge(ctx) else {
757 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
758 "host_tool_call: no host bridge is attached",
759 ))));
760 };
761
762 let result = bridge
763 .call(
764 "builtin_call",
765 serde_json::json!({
766 "name": name,
767 "args": [crate::llm::vm_value_to_json(args)],
768 }),
769 )
770 .await?;
771 Ok(crate::bridge::json_result_to_vm_value(&result))
772}
773
774pub async fn dispatch_host_operation(
778 capability: &str,
779 operation: &str,
780 params: &crate::value::DictMap,
781) -> Result<VmValue, VmError> {
782 dispatch_host_operation_with_ctx(None, capability, operation, params).await
783}
784
785pub async fn dispatch_host_operation_with_ctx(
797 ctx: Option<&AsyncBuiltinCtx>,
798 capability: &str,
799 operation: &str,
800 params: &crate::value::DictMap,
801) -> Result<VmValue, VmError> {
802 let _invalidation = turn_cache::invalidation_scope(capability, operation);
803 if let Some(ctx) = ctx {
804 let vm = ctx.child_vm();
805 if let Some(fixtured) = vm.harness().and_then(|harness| {
806 harness
807 .inner()
808 .fixtures()
809 .dispatch_host(capability, operation, params)
810 }) {
811 return fixtured;
812 }
813 }
814 if let Some(mocked) = dispatch_mock_host_call(capability, operation, params) {
815 return mocked;
816 }
817
818 if (capability, operation) == ("process", "exec") {
819 let caller = serde_json::json!({
820 "surface": "host_call",
821 "capability": "process",
822 "operation": "exec",
823 "session_id": crate::llm::current_agent_session_id(),
824 });
825 return dispatch_process_exec_with_policy(ctx, params, caller).await;
826 }
827
828 if (capability, operation) == ("process", "spawn") {
835 let caller = serde_json::json!({
836 "surface": "host_call",
837 "capability": "process",
838 "operation": "spawn",
839 "session_id": crate::llm::current_agent_session_id(),
840 });
841 return dispatch_process_spawn_with_policy(ctx, params, caller).await;
842 }
843 if capability == "process" && matches!(operation, "poll" | "wait" | "kill" | "release") {
844 if let Some(result) = crate::stdlib::process_spawn::dispatch(
845 operation,
846 params,
847 process_exec::async_builtin_cancel_token(ctx),
848 )
849 .await
850 {
851 return result;
852 }
853 }
854
855 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
856 if let Some(bridge) = bridge {
857 let dispatched = bridge::dispatch_cached(bridge, capability, operation, params).await?;
860 if let Some(value) = dispatched {
861 return Ok(value);
862 }
863 }
864
865 dispatch_builtin_host_operation(capability, operation, params).await
866}
867
868async fn dispatch_builtin_host_operation(
869 capability: &str,
870 operation: &str,
871 params: &crate::value::DictMap,
872) -> Result<VmValue, VmError> {
873 match (capability, operation) {
874 ("process", "list_shells") => Ok(crate::shells::list_shells_vm_value()),
875 ("process", "get_default_shell") => Ok(crate::shells::default_shell_vm_value()),
876 ("process", "set_default_shell") => crate::shells::set_default_shell_vm_value(params),
877 ("process", "shell_invocation") => crate::shells::shell_invocation_vm_value(params),
878 ("template", "render") => {
879 let path = require_param(params, "path")?;
880 let bindings = params.get("bindings").and_then(|v| v.as_dict());
881 Ok(VmValue::String(arcstr::ArcStr::from(render_template(
882 &path, bindings,
883 )?)))
884 }
885 ("interaction", "ask") => {
886 let question = require_param(params, "question")?;
887 super::io::prompt_user_value(&[VmValue::string(question)], &mut String::new())
888 }
889 ("project", "metadata_get") => crate::metadata::project_metadata_host_get(params),
890 ("project", "metadata_inspect") => crate::metadata::project_metadata_host_inspect(params),
891 ("project", "metadata_set") => crate::metadata::project_metadata_host_set(params),
892 ("project", "metadata_save") => crate::metadata::project_metadata_host_save(params),
893 ("project", "metadata_stale") => crate::metadata::project_metadata_host_stale(params),
894 ("project", "metadata_refresh_hashes") => {
895 crate::metadata::project_metadata_host_refresh_hashes(params)
896 }
897 ("runtime", "task") => Ok(VmValue::String(arcstr::ArcStr::from(
900 std::env::var("HARN_TASK").unwrap_or_default(),
901 ))),
902 ("runtime", "prompt_content") => Ok(VmValue::List(Arc::new(Vec::new()))),
903 ("runtime", "set_result") => {
904 Ok(VmValue::Nil)
907 }
908 ("workspace", "project_root") => {
909 let path = crate::stdlib::process::project_root_path()
914 .map(|root| root.display().to_string())
915 .or_else(|| std::env::var("HARN_PROJECT_ROOT").ok())
916 .unwrap_or_else(|| {
917 std::env::current_dir()
918 .map(|p| p.display().to_string())
919 .unwrap_or_default()
920 });
921 Ok(VmValue::String(arcstr::ArcStr::from(path)))
922 }
923 ("workspace", "cwd") => {
924 let path = std::env::current_dir()
925 .map(|p| p.display().to_string())
926 .unwrap_or_default();
927 Ok(VmValue::String(arcstr::ArcStr::from(path)))
928 }
929 _ => Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
930 format!("host_call: unsupported operation {capability}.{operation}"),
931 )))),
932 }
933}
934
935pub(crate) fn optional_i64(params: &crate::value::DictMap, key: &str) -> Option<i64> {
936 match params.get(key) {
937 Some(VmValue::Int(value)) => Some(*value),
938 Some(VmValue::Float(value)) if value.fract() == 0.0 => Some(*value as i64),
939 _ => None,
940 }
941}
942
943pub(crate) fn optional_string(params: &crate::value::DictMap, key: &str) -> Option<String> {
944 params.get(key).and_then(vm_string).map(ToString::to_string)
945}
946
947fn optional_string_list(params: &crate::value::DictMap, key: &str) -> Option<Vec<String>> {
948 let VmValue::List(values) = params.get(key)? else {
949 return None;
950 };
951 values
952 .iter()
953 .map(|value| vm_string(value).map(ToString::to_string))
954 .collect()
955}
956
957fn optional_string_dict(
958 params: &crate::value::DictMap,
959 key: &str,
960) -> Result<Option<BTreeMap<String, String>>, VmError> {
961 let Some(value) = params.get(key) else {
962 return Ok(None);
963 };
964 let Some(dict) = value.as_dict() else {
965 return Err(VmError::Runtime(format!(
966 "host_call process.exec {key} must be a dict"
967 )));
968 };
969 let mut out = std::collections::BTreeMap::new();
970 for (key, value) in dict.iter() {
971 let Some(value) = vm_string(value) else {
972 return Err(VmError::Runtime(format!(
973 "host_call process.exec env value for {key:?} must be a string"
974 )));
975 };
976 out.insert(key.to_string(), value.to_string());
977 }
978 Ok(Some(out))
979}
980
981fn vm_string(value: &VmValue) -> Option<&str> {
982 match value {
983 VmValue::String(value) => Some(value.as_ref()),
984 _ => None,
985 }
986}
987
988pub(crate) fn register_host_builtins(vm: &mut Vm) {
989 for def in MODULE_BUILTINS {
990 vm.register_builtin_def(def);
991 }
992}
993
994pub(crate) fn register_missing_host_builtins(vm: &mut Vm) {
995 for def in MODULE_BUILTINS {
996 if vm.builtin_metadata_for(def.sig.name).is_none() {
997 vm.register_builtin_def(def);
998 }
999 }
1000}
1001
1002#[harn_builtin(
1003 exposure = "privileged_wire",
1004 effects = ["host.mutate@arg0"],
1005 sig = "host_mock(capability: string, op: string, response_or_config?: any, params?: dict) -> nil",
1006 category = "host"
1007)]
1008pub(crate) fn host_mock_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1009 let host_mock = parse_host_mock(args)?;
1010 validate_host_mock_registration(&host_mock)?;
1011 push_host_mock(host_mock);
1012 Ok(VmValue::Nil)
1013}
1014
1015#[harn_builtin(
1016 exposure = "privileged_wire",
1017 effects = [],
1018 sig = "host_mock_clear() -> nil", category = "host"
1019)]
1020fn host_mock_clear_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1021 reset_host_state();
1022 Ok(VmValue::Nil)
1023}
1024
1025#[harn_builtin(
1026 exposure = "runtime_internal",
1027 effects = [],
1028 sig = "host_mock_calls() -> list", category = "host"
1029)]
1030fn host_mock_calls_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1031 let calls = HOST_MOCK_CALLS.with(|calls| {
1032 calls
1033 .borrow()
1034 .iter()
1035 .map(mock_call_value)
1036 .collect::<Vec<_>>()
1037 });
1038 Ok(VmValue::List(std::sync::Arc::new(calls)))
1039}
1040
1041#[harn_builtin(
1042 exposure = "runtime_internal",
1043 effects = [],
1044 sig = "host_mock_push_scope() -> nil", category = "host"
1045)]
1046fn host_mock_push_scope_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1047 push_host_mock_scope();
1048 Ok(VmValue::Nil)
1049}
1050
1051#[harn_builtin(
1052 exposure = "runtime_internal",
1053 effects = [],
1054 sig = "host_mock_pop_scope() -> nil", category = "host"
1055)]
1056fn host_mock_pop_scope_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1057 if !pop_host_mock_scope() {
1058 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1059 "host_mock_pop_scope: no scope to pop",
1060 ))));
1061 }
1062 Ok(VmValue::Nil)
1063}
1064
1065#[harn_builtin(
1066 exposure = "runtime_internal",
1067 effects = [],
1068 sig = "host_capabilities() -> dict", category = "host"
1069)]
1070fn host_capabilities_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1071 Ok(capability_manifest_with_mocks())
1072}
1073
1074#[harn_builtin(
1075 exposure = "runtime_internal",
1076 effects = [],
1077 sig = "host_has(capability: string, op?: string) -> bool",
1078 category = "host"
1079)]
1080fn host_has_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1081 let capability = args.first().map(|a| a.display()).unwrap_or_default();
1082 let operation = args.get(1).map(|a| a.display());
1083 let manifest = capability_manifest_with_mocks();
1084 let has = manifest
1085 .as_dict()
1086 .and_then(|d| d.get(capability.as_str()))
1087 .and_then(|v| v.as_dict())
1088 .is_some_and(|cap| {
1089 if let Some(operation) = operation {
1090 cap.get("ops")
1091 .and_then(|v| match v {
1092 VmValue::List(list) => {
1093 Some(list.iter().any(|item| item.display() == operation))
1094 }
1095 _ => None,
1096 })
1097 .unwrap_or(false)
1098 } else {
1099 true
1100 }
1101 });
1102 Ok(VmValue::Bool(has))
1103}
1104
1105#[harn_builtin(
1106 exposure = "privileged_wire",
1107 effects = [],
1108 sig = "host_call(name: string, args?: dict) -> any",
1109 kind = "async",
1110 category = "host"
1111)]
1112async fn host_call_builtin(
1113 ctx: crate::vm::AsyncBuiltinCtx,
1114 args: Vec<VmValue>,
1115) -> Result<VmValue, VmError> {
1116 let name = args.first().map(|a| a.display()).unwrap_or_default();
1117 let params = args
1118 .get(1)
1119 .and_then(|a| a.as_dict())
1120 .cloned()
1121 .unwrap_or_default();
1122 let Some((capability, operation)) = name.split_once('.') else {
1123 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1124 format!("host_call: unsupported operation name '{name}'"),
1125 ))));
1126 };
1127 dispatch_host_operation_with_ctx(Some(&ctx), capability, operation, ¶ms).await
1128}
1129
1130#[harn_builtin(
1131 exposure = "runtime_internal",
1132 effects = [],
1133 sig = "host_tool_list() -> list", kind = "async", category = "host"
1134)]
1135async fn host_tool_list_builtin(
1136 ctx: crate::vm::AsyncBuiltinCtx,
1137 _args: Vec<VmValue>,
1138) -> Result<VmValue, VmError> {
1139 dispatch_host_tool_list_with_ctx(Some(&ctx)).await
1140}
1141
1142#[harn_builtin(
1143 exposure = "runtime_internal",
1144 effects = [],
1145 sig = "host_tool_call(name: string, args?: any) -> any",
1146 kind = "async",
1147 category = "host"
1148)]
1149async fn host_tool_call_builtin(
1150 ctx: crate::vm::AsyncBuiltinCtx,
1151 args: Vec<VmValue>,
1152) -> Result<VmValue, VmError> {
1153 let name = args.first().map(|a| a.display()).unwrap_or_default();
1154 if name.is_empty() {
1155 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1156 "host_tool_call: tool name is required",
1157 ))));
1158 }
1159 let call_args = args.get(1).cloned().unwrap_or(VmValue::Nil);
1160 dispatch_host_tool_call_with_ctx(Some(&ctx), &name, &call_args).await
1161}
1162
1163#[cfg(test)]
1164mod tests {
1165 use super::process_exec::resolve_process_exec_cwd;
1166 use super::{
1167 build_sandboxed_command, capability_manifest_with_mocks, clear_host_call_bridge,
1168 dispatch_host_operation, dispatch_host_tool_call, dispatch_host_tool_list,
1169 dispatch_mock_host_call, dispatch_mock_hostlib_call, host_call_ready, host_has_builtin,
1170 host_mock_clear_builtin, parse_host_mock, push_host_mock, register_mockable_host_operation,
1171 register_scoped_mockable_host_operation, reset_host_state, reset_scoped_host_state,
1172 set_host_call_bridge, validate_host_mock_registration, HostCallBridge,
1173 HostCallDispatchFuture, HostMock,
1174 };
1175 use crate::value::VmDictExt;
1176
1177 use std::sync::{
1178 atomic::{AtomicUsize, Ordering},
1179 Arc,
1180 };
1181
1182 use crate::value::{VmError, VmValue};
1183
1184 fn command_env(
1188 cmd: &tokio::process::Command,
1189 ) -> std::collections::BTreeMap<String, Option<String>> {
1190 cmd.as_std()
1191 .get_envs()
1192 .map(|(k, v)| {
1193 (
1194 k.to_string_lossy().into_owned(),
1195 v.map(|value| value.to_string_lossy().into_owned()),
1196 )
1197 })
1198 .collect()
1199 }
1200
1201 #[test]
1202 fn build_sandboxed_command_forces_deterministic_message_locale() {
1203 let mut params = crate::value::DictMap::new();
1213 params.put_str("mode", "argv");
1214 params.put(
1215 "argv",
1216 VmValue::List(Arc::new(vec![VmValue::string("/bin/true")])),
1217 );
1218 params.put_str("env_mode", "merge");
1219 let mut caller_env = crate::value::DictMap::new();
1220 caller_env.put_str("CARGO_TARGET_DIR", "/tmp/target");
1222 params.put("env", VmValue::dict_map(caller_env));
1223
1224 let cmd = build_sandboxed_command(¶ms, "process.exec").expect("build command");
1225 let env = command_env(&cmd);
1226
1227 assert_eq!(
1228 env.get("LC_ALL"),
1229 Some(&None),
1230 "the builder must remove LC_ALL from the child so an inherited shell \
1231 value cannot override the forced LC_MESSAGES"
1232 );
1233 assert_eq!(
1234 env.get("LC_MESSAGES"),
1235 Some(&Some("C".to_string())),
1236 "LC_MESSAGES must be pinned to C for untranslated (English) tool output"
1237 );
1238 assert_eq!(
1239 env.get("DOTNET_CLI_UI_LANGUAGE"),
1240 Some(&Some("en".to_string())),
1241 ".NET ignores LC_* and needs its own UI-language override"
1242 );
1243 }
1244
1245 #[test]
1246 fn build_sandboxed_command_respects_a_caller_pinned_locale() {
1247 let mut params = crate::value::DictMap::new();
1250 params.put_str("mode", "argv");
1251 params.put(
1252 "argv",
1253 VmValue::List(Arc::new(vec![VmValue::string("/bin/true")])),
1254 );
1255 params.put_str("env_mode", "merge");
1256 let mut caller_env = crate::value::DictMap::new();
1257 caller_env.put_str("LC_ALL", "fr_FR.UTF-8");
1258 caller_env.put_str("LC_MESSAGES", "fr_FR.UTF-8");
1259 params.put("env", VmValue::dict_map(caller_env));
1260
1261 let cmd = build_sandboxed_command(¶ms, "process.exec").expect("build command");
1262 let env = command_env(&cmd);
1263
1264 assert_eq!(
1265 env.get("LC_ALL"),
1266 Some(&Some("fr_FR.UTF-8".to_string())),
1267 "a caller that pins LC_ALL keeps it — the overlay must not strip an explicit value"
1268 );
1269 assert_eq!(
1270 env.get("LC_MESSAGES"),
1271 Some(&Some("fr_FR.UTF-8".to_string())),
1272 "a caller-pinned LC_MESSAGES wins over the C overlay"
1273 );
1274 }
1275
1276 #[test]
1277 fn process_exec_relative_cwd_resolves_against_execution_root() {
1278 let dir = tempfile::tempdir().expect("tempdir");
1279 crate::stdlib::process::set_thread_execution_context(Some(
1280 crate::orchestration::RunExecutionRecord {
1281 cwd: Some(dir.path().to_string_lossy().into_owned()),
1282 source_dir: Some(dir.path().join("src").to_string_lossy().into_owned()),
1283 ..Default::default()
1284 },
1285 ));
1286
1287 assert_eq!(
1288 resolve_process_exec_cwd("subdir"),
1289 dir.path().join("subdir")
1290 );
1291
1292 crate::stdlib::process::set_thread_execution_context(None);
1293 }
1294
1295 #[test]
1296 fn workspace_project_root_fallback_prefers_execution_context_project_root() {
1297 run_host_async_test(|| async {
1298 let project = tempfile::tempdir().expect("project root");
1299 let cwd = tempfile::tempdir().expect("cwd");
1300 crate::stdlib::process::set_thread_execution_context(Some(
1301 crate::orchestration::RunExecutionRecord {
1302 cwd: Some(cwd.path().to_string_lossy().into_owned()),
1303 project_root: Some(project.path().to_string_lossy().into_owned()),
1304 ..Default::default()
1305 },
1306 ));
1307
1308 let result =
1309 dispatch_host_operation("workspace", "project_root", &crate::value::DictMap::new())
1310 .await
1311 .expect("workspace.project_root result");
1312
1313 crate::stdlib::process::set_thread_execution_context(None);
1314 assert_eq!(result.display(), project.path().display().to_string());
1315 });
1316 }
1317
1318 #[test]
1319 fn manifest_includes_operation_metadata() {
1320 let manifest = capability_manifest_with_mocks();
1321 let process = manifest
1322 .as_dict()
1323 .and_then(|d| d.get("process"))
1324 .and_then(|v| v.as_dict())
1325 .expect("process capability");
1326 assert!(process.get("description").is_some());
1327 let operations = process
1328 .get("operations")
1329 .and_then(|v| v.as_dict())
1330 .expect("operations dict");
1331 assert!(operations.get("exec").is_some());
1332 }
1333
1334 #[test]
1335 fn mocked_capabilities_appear_in_manifest() {
1336 reset_host_state();
1337 push_host_mock(HostMock {
1338 capability: "project".to_string(),
1339 operation: "metadata_get".to_string(),
1340 params: None,
1341 result: Some(VmValue::dict(crate::value::DictMap::new())),
1342 error: None,
1343 unregistered_ok: false,
1344 });
1345 let manifest = capability_manifest_with_mocks();
1346 let project = manifest
1347 .as_dict()
1348 .and_then(|d| d.get("project"))
1349 .and_then(|v| v.as_dict())
1350 .expect("project capability");
1351 let operations = project
1352 .get("operations")
1353 .and_then(|v| v.as_dict())
1354 .expect("operations dict");
1355 assert!(operations.get("metadata_get").is_some());
1356 reset_host_state();
1357 }
1358
1359 #[test]
1360 fn mock_host_call_matches_partial_params_and_overrides_order() {
1361 reset_host_state();
1362 let mut exact_params = crate::value::DictMap::new();
1363 exact_params.put_str("namespace", "facts");
1364 push_host_mock(HostMock {
1365 capability: "project".to_string(),
1366 operation: "metadata_get".to_string(),
1367 params: None,
1368 result: Some(VmValue::String(arcstr::ArcStr::from("fallback"))),
1369 error: None,
1370 unregistered_ok: false,
1371 });
1372 push_host_mock(HostMock {
1373 capability: "project".to_string(),
1374 operation: "metadata_get".to_string(),
1375 params: Some(exact_params),
1376 result: Some(VmValue::String(arcstr::ArcStr::from("facts"))),
1377 error: None,
1378 unregistered_ok: false,
1379 });
1380
1381 let mut call_params = crate::value::DictMap::new();
1382 call_params.put_str("dir", "pkg");
1383 call_params.put_str("namespace", "facts");
1384 let exact = dispatch_mock_host_call("project", "metadata_get", &call_params)
1385 .expect("expected exact mock")
1386 .expect("exact mock should succeed");
1387 assert_eq!(exact.display(), "facts");
1388
1389 call_params.put_str("namespace", "classification");
1390 let fallback = dispatch_mock_host_call("project", "metadata_get", &call_params)
1391 .expect("expected fallback mock")
1392 .expect("fallback mock should succeed");
1393 assert_eq!(fallback.display(), "fallback");
1394 reset_host_state();
1395 }
1396
1397 #[test]
1398 fn mock_host_call_can_throw_errors() {
1399 reset_host_state();
1400 push_host_mock(HostMock {
1401 capability: "project".to_string(),
1402 operation: "metadata_get".to_string(),
1403 params: None,
1404 result: None,
1405 error: Some("boom".to_string()),
1406 unregistered_ok: false,
1407 });
1408 let params = crate::value::DictMap::new();
1409 let result = dispatch_mock_host_call("project", "metadata_get", ¶ms)
1410 .expect("expected mock result");
1411 match result {
1412 Err(VmError::Thrown(VmValue::String(message))) => assert_eq!(message.as_str(), "boom"),
1413 other => panic!("unexpected result: {other:?}"),
1414 }
1415 reset_host_state();
1416 }
1417
1418 #[test]
1419 fn host_mock_registration_rejects_unknown_operations_by_default() {
1420 let host_mock = HostMock {
1421 capability: "runtime".to_string(),
1422 operation: "tas".to_string(),
1423 params: None,
1424 result: Some(VmValue::Nil),
1425 error: None,
1426 unregistered_ok: false,
1427 };
1428 let error = validate_host_mock_registration(&host_mock)
1429 .expect_err("unknown host operation should fail at registration");
1430 match error {
1431 VmError::Thrown(VmValue::String(message)) => {
1432 assert!(message.contains("runtime.tas"));
1433 assert!(message.contains("unregistered_ok"));
1434 assert!(message.contains("runtime.task"));
1435 }
1436 other => panic!("unexpected error: {other:?}"),
1437 }
1438 }
1439
1440 #[test]
1441 fn host_mock_registration_allows_explicit_test_local_operations() {
1442 let host_mock = HostMock {
1443 capability: "synthetic".to_string(),
1444 operation: "op".to_string(),
1445 params: None,
1446 result: Some(VmValue::Nil),
1447 error: None,
1448 unregistered_ok: true,
1449 };
1450 validate_host_mock_registration(&host_mock)
1451 .expect("explicit unregistered_ok should permit synthetic mocks");
1452 }
1453
1454 #[test]
1455 fn host_mock_registration_accepts_runtime_registered_operations() {
1456 register_mockable_host_operation(
1457 "code_index",
1458 "stats",
1459 "Hostlib schema-backed operation registered at runtime.",
1460 );
1461 let host_mock = HostMock {
1462 capability: "code_index".to_string(),
1463 operation: "stats".to_string(),
1464 params: None,
1465 result: Some(VmValue::Nil),
1466 error: None,
1467 unregistered_ok: false,
1468 };
1469 validate_host_mock_registration(&host_mock)
1470 .expect("registered hostlib operations should be mockable");
1471 }
1472
1473 #[test]
1474 fn clearing_live_mocks_preserves_scoped_manifest_declarations() {
1475 reset_scoped_host_state();
1476 register_scoped_mockable_host_operation(
1477 "scoped_clear_fixture",
1478 "answer",
1479 "Test-scoped manifest declaration.",
1480 );
1481 let host_mock = HostMock {
1482 capability: "scoped_clear_fixture".to_string(),
1483 operation: "answer".to_string(),
1484 params: None,
1485 result: Some(VmValue::Nil),
1486 error: None,
1487 unregistered_ok: false,
1488 };
1489
1490 validate_host_mock_registration(&host_mock).expect("scoped declaration is registered");
1491 host_mock_clear_builtin(&[], &mut String::new()).expect("clear live mocks");
1492 validate_host_mock_registration(&host_mock)
1493 .expect("clearing live mocks must preserve manifest declarations");
1494 reset_scoped_host_state();
1495 }
1496
1497 #[tokio::test]
1498 async fn declared_mockable_operation_is_not_reported_as_callable() {
1499 std::thread::spawn(|| {
1500 register_mockable_host_operation(
1501 "async_host_registration",
1502 "cross_thread",
1503 "Embedding operation registered before async worker migration.",
1504 );
1505 })
1506 .join()
1507 .expect("registration worker should finish");
1508
1509 std::thread::spawn(|| {
1510 let host_mock = HostMock {
1511 capability: "async_host_registration".to_string(),
1512 operation: "cross_thread".to_string(),
1513 params: None,
1514 result: Some(VmValue::Nil),
1515 error: None,
1516 unregistered_ok: false,
1517 };
1518 validate_host_mock_registration(&host_mock)
1519 .expect("process host registration should be visible after worker migration");
1520
1521 let typo = HostMock {
1522 operation: "cross_tread".to_string(),
1523 ..host_mock
1524 };
1525 validate_host_mock_registration(&typo)
1526 .expect_err("an undeclared operation should still fail closed");
1527 })
1528 .join()
1529 .expect("validation worker should finish");
1530
1531 assert!(matches!(
1532 host_has_builtin(
1533 &[
1534 VmValue::string("async_host_registration"),
1535 VmValue::string("cross_thread"),
1536 ],
1537 &mut String::new(),
1538 )
1539 .expect("host_has should succeed"),
1540 VmValue::Bool(false)
1541 ));
1542 dispatch_host_operation(
1543 "async_host_registration",
1544 "cross_thread",
1545 &crate::value::DictMap::new(),
1546 )
1547 .await
1548 .expect_err("an unmocked declaration must remain unsupported at dispatch");
1549 }
1550
1551 #[test]
1552 fn host_mock_parse_preserves_unregistered_ok_config() {
1553 let config = VmValue::dict(crate::value::DictMap::from_iter([
1554 (crate::value::intern_key("result"), VmValue::string("ok")),
1555 (
1556 crate::value::intern_key("unregistered_ok"),
1557 VmValue::Bool(true),
1558 ),
1559 ]));
1560 let host_mock =
1561 parse_host_mock(&[VmValue::string("synthetic"), VmValue::string("op"), config])
1562 .expect("parse host mock config");
1563 assert!(host_mock.unregistered_ok);
1564 }
1565
1566 #[test]
1567 fn hostlib_mock_dispatch_matches_module_method_and_params() {
1568 reset_host_state();
1569 let mut mock_params = crate::value::DictMap::new();
1570 mock_params.put(
1571 "argv",
1572 VmValue::List(Arc::new(vec![VmValue::string("echo")])),
1573 );
1574 push_host_mock(HostMock {
1575 capability: "tools".to_string(),
1576 operation: "run_command".to_string(),
1577 params: Some(mock_params),
1578 result: Some(VmValue::String(arcstr::ArcStr::from("direct"))),
1579 error: None,
1580 unregistered_ok: false,
1581 });
1582
1583 let mut call_params = crate::value::DictMap::new();
1584 call_params.put(
1585 "argv",
1586 VmValue::List(Arc::new(vec![VmValue::string("echo")])),
1587 );
1588 call_params.put_str("cwd", "/tmp/not-used");
1589 let value = dispatch_mock_hostlib_call("tools", "run_command", &call_params)
1590 .expect("expected hostlib mock")
1591 .expect("hostlib mock should succeed");
1592 assert_eq!(value.display(), "direct");
1593 reset_host_state();
1594 }
1595
1596 #[test]
1597 fn hostlib_run_command_falls_back_to_process_exec_mocks() {
1598 reset_host_state();
1599 let mut mock_params = crate::value::DictMap::new();
1600 mock_params.put(
1601 "argv",
1602 VmValue::List(Arc::new(vec![
1603 VmValue::string("cargo"),
1604 VmValue::string("test"),
1605 ])),
1606 );
1607 push_host_mock(HostMock {
1608 capability: "process".to_string(),
1609 operation: "exec".to_string(),
1610 params: Some(mock_params),
1611 result: Some(VmValue::String(arcstr::ArcStr::from("legacy"))),
1612 error: None,
1613 unregistered_ok: false,
1614 });
1615
1616 let mut call_params = crate::value::DictMap::new();
1617 call_params.put(
1618 "argv",
1619 VmValue::List(Arc::new(vec![
1620 VmValue::string("cargo"),
1621 VmValue::string("test"),
1622 ])),
1623 );
1624 call_params.put_str("cwd", "/tmp/not-used");
1625 let value = dispatch_mock_hostlib_call("tools", "run_command", &call_params)
1626 .expect("expected legacy process.exec mock")
1627 .expect("legacy mock should succeed");
1628 assert_eq!(value.display(), "legacy");
1629 reset_host_state();
1630 }
1631
1632 #[test]
1633 fn hostlib_run_command_prefers_exact_mock_over_process_exec_alias() {
1634 reset_host_state();
1635 let mut params = crate::value::DictMap::new();
1636 params.put(
1637 "argv",
1638 VmValue::List(Arc::new(vec![
1639 VmValue::string("npm"),
1640 VmValue::string("test"),
1641 ])),
1642 );
1643 push_host_mock(HostMock {
1644 capability: "process".to_string(),
1645 operation: "exec".to_string(),
1646 params: Some(params.clone()),
1647 result: Some(VmValue::String(arcstr::ArcStr::from("legacy"))),
1648 error: None,
1649 unregistered_ok: false,
1650 });
1651 push_host_mock(HostMock {
1652 capability: "tools".to_string(),
1653 operation: "run_command".to_string(),
1654 params: Some(params.clone()),
1655 result: Some(VmValue::String(arcstr::ArcStr::from("direct"))),
1656 error: None,
1657 unregistered_ok: false,
1658 });
1659
1660 let value = dispatch_mock_hostlib_call("tools", "run_command", ¶ms)
1661 .expect("expected exact hostlib mock")
1662 .expect("exact mock should succeed");
1663 assert_eq!(value.display(), "direct");
1664 reset_host_state();
1665 }
1666
1667 #[derive(Default)]
1668 struct TestHostToolBridge;
1669
1670 impl HostCallBridge for TestHostToolBridge {
1671 fn dispatch<'a>(
1672 &'a self,
1673 _capability: &'a str,
1674 _operation: &'a str,
1675 _params: &'a crate::value::DictMap,
1676 ) -> HostCallDispatchFuture<'a> {
1677 host_call_ready(Ok(None))
1678 }
1679
1680 fn list_tools(&self) -> Result<Option<VmValue>, VmError> {
1681 let tool = VmValue::dict(crate::value::DictMap::from_iter([
1682 (
1683 crate::value::intern_key("name"),
1684 VmValue::String(arcstr::ArcStr::from("Read".to_string())),
1685 ),
1686 (
1687 crate::value::intern_key("description"),
1688 VmValue::String(arcstr::ArcStr::from(
1689 "Read a file from the host".to_string(),
1690 )),
1691 ),
1692 (
1693 crate::value::intern_key("schema"),
1694 VmValue::dict(crate::value::DictMap::from_iter([(
1695 crate::value::intern_key("type"),
1696 VmValue::String(arcstr::ArcStr::from("object".to_string())),
1697 )])),
1698 ),
1699 (crate::value::intern_key("deprecated"), VmValue::Bool(false)),
1700 ]));
1701 Ok(Some(VmValue::List(std::sync::Arc::new(vec![tool]))))
1702 }
1703
1704 fn call_tool(&self, name: &str, args: &VmValue) -> Result<Option<VmValue>, VmError> {
1705 if name != "Read" {
1706 return Ok(None);
1707 }
1708 let path = args
1709 .as_dict()
1710 .and_then(|dict| dict.get("path"))
1711 .map(|value| value.display())
1712 .unwrap_or_default();
1713 Ok(Some(VmValue::String(arcstr::ArcStr::from(format!(
1714 "read:{path}"
1715 )))))
1716 }
1717 }
1718
1719 struct CountingProcessExecBridge {
1720 calls: Arc<AtomicUsize>,
1721 }
1722
1723 impl HostCallBridge for CountingProcessExecBridge {
1724 fn dispatch<'a>(
1725 &'a self,
1726 capability: &'a str,
1727 operation: &'a str,
1728 _params: &'a crate::value::DictMap,
1729 ) -> HostCallDispatchFuture<'a> {
1730 if (capability, operation) != ("process", "exec") {
1731 return host_call_ready(Ok(None));
1732 }
1733 self.calls.fetch_add(1, Ordering::SeqCst);
1734 host_call_ready(Ok(Some(VmValue::dict(crate::value::DictMap::from_iter([
1735 (
1736 crate::value::intern_key("status"),
1737 VmValue::String(arcstr::ArcStr::from("completed".to_string())),
1738 ),
1739 (crate::value::intern_key("exit_code"), VmValue::Int(0)),
1740 (crate::value::intern_key("success"), VmValue::Bool(true)),
1741 ])))))
1742 }
1743 }
1744
1745 fn run_host_async_test<F, Fut>(test: F)
1746 where
1747 F: FnOnce() -> Fut,
1748 Fut: std::future::Future<Output = ()>,
1749 {
1750 let _guard = super::turn_cache::epoch_test_lock()
1754 .lock()
1755 .unwrap_or_else(|e| e.into_inner());
1756 let rt = tokio::runtime::Builder::new_current_thread()
1757 .enable_all()
1758 .build()
1759 .expect("runtime");
1760 rt.block_on(async {
1761 let local = tokio::task::LocalSet::new();
1762 local.run_until(test()).await;
1763 });
1764 }
1765
1766 #[test]
1767 fn host_tool_list_uses_installed_host_call_bridge() {
1768 run_host_async_test(|| async {
1769 reset_host_state();
1770 set_host_call_bridge(Arc::new(TestHostToolBridge));
1771 let tools = dispatch_host_tool_list().await.expect("tool list");
1772 clear_host_call_bridge();
1773
1774 let VmValue::List(items) = tools else {
1775 panic!("expected tool list");
1776 };
1777 assert_eq!(items.len(), 1);
1778 let tool = items[0].as_dict().expect("tool dict");
1779 assert_eq!(tool.get("name").unwrap().display(), "Read");
1780 assert_eq!(tool.get("deprecated").unwrap().display(), "false");
1781 });
1782 }
1783
1784 #[test]
1785 fn host_tool_call_uses_installed_host_call_bridge() {
1786 run_host_async_test(|| async {
1787 set_host_call_bridge(Arc::new(TestHostToolBridge));
1788 let args = VmValue::dict(crate::value::DictMap::from_iter([(
1789 crate::value::intern_key("path"),
1790 VmValue::String(arcstr::ArcStr::from("README.md".to_string())),
1791 )]));
1792 let value = dispatch_host_tool_call("Read", &args)
1793 .await
1794 .expect("tool call");
1795 clear_host_call_bridge();
1796 assert_eq!(value.display(), "read:README.md");
1797 });
1798 }
1799
1800 #[test]
1801 fn process_exec_bridge_is_gated_by_command_policy() {
1802 run_host_async_test(|| async {
1803 crate::orchestration::clear_command_policies();
1804 let calls = Arc::new(AtomicUsize::new(0));
1805 set_host_call_bridge(Arc::new(CountingProcessExecBridge {
1806 calls: calls.clone(),
1807 }));
1808 crate::orchestration::push_command_policy(crate::orchestration::CommandPolicy {
1809 tools: vec!["run".to_string()],
1810 workspace_roots: Vec::new(),
1811 default_shell_mode: "shell".to_string(),
1812 deny_patterns: vec!["cat *".to_string()],
1813 ..Default::default()
1814 });
1815
1816 let result = dispatch_host_operation(
1817 "process",
1818 "exec",
1819 &crate::value::DictMap::from_iter([
1820 (
1821 crate::value::intern_key("mode"),
1822 VmValue::String(arcstr::ArcStr::from("shell")),
1823 ),
1824 (
1825 crate::value::intern_key("command"),
1826 VmValue::String(arcstr::ArcStr::from("cat Cargo.toml")),
1827 ),
1828 ]),
1829 )
1830 .await
1831 .expect("process.exec result");
1832
1833 crate::orchestration::clear_command_policies();
1834 clear_host_call_bridge();
1835
1836 assert_eq!(
1837 calls.load(Ordering::SeqCst),
1838 0,
1839 "blocked command must not reach host bridge"
1840 );
1841 let result = result.as_dict().expect("blocked result dict");
1842 assert_eq!(result.get("status").unwrap().display(), "blocked");
1843 assert!(
1844 result
1845 .get("reason")
1846 .map(VmValue::display)
1847 .unwrap_or_default()
1848 .contains("cat *"),
1849 "blocked result should name the matched policy pattern"
1850 );
1851 });
1852 }
1853
1854 #[cfg(unix)]
1855 async fn process_exec_env_probe(env: VmValue, env_mode: Option<&str>) -> (String, String) {
1856 std::env::set_var("PARENT_VAR", "inherited");
1861 let mut params = crate::value::DictMap::from_iter([
1862 (
1863 crate::value::intern_key("mode"),
1864 VmValue::String(arcstr::ArcStr::from("argv")),
1865 ),
1866 (
1867 crate::value::intern_key("argv"),
1868 VmValue::List(std::sync::Arc::new(vec![
1869 VmValue::String(arcstr::ArcStr::from("/bin/sh")),
1872 VmValue::String(arcstr::ArcStr::from("-c")),
1873 VmValue::String(arcstr::ArcStr::from(
1874 "printf '%s|%s' \"$PARENT_VAR\" \"$CHILD_VAR\"",
1875 )),
1876 ])),
1877 ),
1878 (crate::value::intern_key("env"), env),
1879 ]);
1880 if let Some(mode) = env_mode {
1881 params.put_str("env_mode", mode);
1882 }
1883 let result = super::dispatch_process_exec(¶ms, serde_json::Value::Null)
1884 .await
1885 .expect("process.exec result");
1886 let dict = result.as_dict().expect("result dict");
1887 let stdout = dict.get("stdout").map(VmValue::display).unwrap_or_default();
1888 std::env::remove_var("PARENT_VAR");
1889 let (parent, child) = stdout.split_once('|').unwrap_or((&stdout, ""));
1890 (parent.to_string(), child.to_string())
1891 }
1892
1893 #[cfg(unix)]
1894 #[test]
1895 fn process_exec_env_default_merges_with_parent() {
1896 run_host_async_test(|| async {
1897 let child_env = VmValue::dict(crate::value::DictMap::from_iter([(
1900 crate::value::intern_key("CHILD_VAR"),
1901 VmValue::String(arcstr::ArcStr::from("provided")),
1902 )]));
1903 let (parent, child) = process_exec_env_probe(child_env, None).await;
1904 assert_eq!(
1905 parent, "inherited",
1906 "default env_mode must inherit parent env"
1907 );
1908 assert_eq!(
1909 child, "provided",
1910 "default env_mode must apply provided keys"
1911 );
1912 });
1913 }
1914
1915 #[cfg(unix)]
1916 #[test]
1917 fn process_exec_env_mode_replace_clears_parent() {
1918 run_host_async_test(|| async {
1919 let child_env = VmValue::dict(crate::value::DictMap::from_iter([(
1923 crate::value::intern_key("CHILD_VAR"),
1924 VmValue::String(arcstr::ArcStr::from("provided")),
1925 )]));
1926 let (parent, child) = process_exec_env_probe(child_env, Some("replace")).await;
1927 assert_eq!(parent, "", "explicit replace must clear parent env");
1928 assert_eq!(
1929 child, "provided",
1930 "explicit replace must keep provided keys"
1931 );
1932 });
1933 }
1934
1935 #[cfg(unix)]
1936 #[test]
1937 fn process_exec_env_mode_unknown_is_rejected() {
1938 run_host_async_test(|| async {
1939 let params = crate::value::DictMap::from_iter([
1940 (
1941 crate::value::intern_key("mode"),
1942 VmValue::String(arcstr::ArcStr::from("argv")),
1943 ),
1944 (
1945 crate::value::intern_key("argv"),
1946 VmValue::List(std::sync::Arc::new(vec![VmValue::String(
1947 arcstr::ArcStr::from("true"),
1948 )])),
1949 ),
1950 (
1951 crate::value::intern_key("env"),
1952 VmValue::dict(crate::value::DictMap::from_iter([(
1953 crate::value::intern_key("CHILD_VAR"),
1954 VmValue::String(arcstr::ArcStr::from("x")),
1955 )])),
1956 ),
1957 (
1958 crate::value::intern_key("env_mode"),
1959 VmValue::String(arcstr::ArcStr::from("bogus")),
1960 ),
1961 ]);
1962 let err = super::dispatch_process_exec(¶ms, serde_json::Value::Null)
1963 .await
1964 .expect_err("unknown env_mode must error");
1965 assert!(
1966 format!("{err:?}").contains("env_mode"),
1967 "error should name env_mode, got {err:?}"
1968 );
1969 });
1970 }
1971
1972 #[cfg(unix)]
1978 async fn process_exec_tmpdir_probe(
1979 workspace: &std::path::Path,
1980 caller_env: Option<VmValue>,
1981 ) -> String {
1982 let mut env_pairs = vec![(
1983 crate::value::intern_key("mode"),
1984 VmValue::String(arcstr::ArcStr::from("argv")),
1985 )];
1986 env_pairs.push((
1987 crate::value::intern_key("argv"),
1988 VmValue::List(std::sync::Arc::new(vec![
1989 VmValue::String(arcstr::ArcStr::from("/bin/sh")),
1990 VmValue::String(arcstr::ArcStr::from("-c")),
1991 VmValue::String(arcstr::ArcStr::from("printf '%s' \"$TMPDIR\"")),
1992 ])),
1993 ));
1994 if let Some(env) = caller_env {
1995 env_pairs.push((crate::value::intern_key("env"), env));
1996 }
1997 let params = crate::value::DictMap::from_iter(env_pairs);
1998
1999 crate::orchestration::push_execution_policy(crate::orchestration::CapabilityPolicy {
2000 sandbox_profile: crate::orchestration::SandboxProfile::Worktree,
2001 workspace_roots: vec![workspace.to_string_lossy().into_owned()],
2002 ..crate::orchestration::CapabilityPolicy::default()
2006 });
2007 std::env::set_var("HARN_HANDLER_SANDBOX", "off");
2008 let result = super::dispatch_process_exec(¶ms, serde_json::Value::Null)
2009 .await
2010 .expect("process.exec result");
2011 std::env::remove_var("HARN_HANDLER_SANDBOX");
2012 crate::orchestration::pop_execution_policy();
2013 result
2014 .as_dict()
2015 .and_then(|d| d.get("stdout"))
2016 .map(VmValue::display)
2017 .unwrap_or_default()
2018 }
2019
2020 #[cfg(unix)]
2021 #[test]
2022 fn process_exec_injects_workspace_local_tmpdir() {
2023 run_host_async_test(|| async {
2024 let workspace = tempfile::tempdir().expect("workspace");
2025 let tmpdir = process_exec_tmpdir_probe(workspace.path(), None).await;
2026
2027 assert!(
2028 !tmpdir.is_empty(),
2029 "sandboxed child must receive a non-empty TMPDIR"
2030 );
2031 let tmpdir_path = std::path::PathBuf::from(&tmpdir);
2032 let canonical_tmpdir = std::fs::canonicalize(&tmpdir_path)
2033 .expect("workspace-local TMPDIR should canonicalize");
2034 let canonical_workspace =
2035 std::fs::canonicalize(workspace.path()).expect("workspace should canonicalize");
2036 assert!(
2037 canonical_tmpdir.starts_with(&canonical_workspace),
2038 "child TMPDIR {tmpdir:?} must live inside the workspace {:?}",
2039 workspace.path()
2040 );
2041 assert!(
2042 tmpdir_path.ends_with(".harn-tmp"),
2043 "child TMPDIR {tmpdir:?} must be the workspace-local .harn-tmp dir"
2044 );
2045 assert!(
2046 tmpdir_path.is_dir(),
2047 "the workspace-local TMPDIR must have been created on disk"
2048 );
2049 });
2050 }
2051
2052 #[cfg(unix)]
2053 #[test]
2054 fn process_exec_respects_caller_pinned_tmpdir() {
2055 run_host_async_test(|| async {
2056 let workspace = tempfile::tempdir().expect("workspace");
2057 let caller_tmp = workspace.path().join("caller-chosen");
2058 std::fs::create_dir_all(&caller_tmp).unwrap();
2059 let caller_env = VmValue::dict(crate::value::DictMap::from_iter([(
2060 crate::value::intern_key("TMPDIR"),
2061 VmValue::String(arcstr::ArcStr::from(
2062 caller_tmp.to_string_lossy().into_owned(),
2063 )),
2064 )]));
2065
2066 let tmpdir = process_exec_tmpdir_probe(workspace.path(), Some(caller_env)).await;
2067
2068 assert_eq!(
2069 std::path::PathBuf::from(&tmpdir),
2070 caller_tmp,
2071 "an explicit caller TMPDIR must override the workspace-local default"
2072 );
2073 });
2074 }
2075
2076 #[test]
2077 fn host_tool_list_is_empty_without_bridge() {
2078 run_host_async_test(|| async {
2079 clear_host_call_bridge();
2080 let tools = dispatch_host_tool_list().await.expect("tool list");
2081 let VmValue::List(items) = tools else {
2082 panic!("expected tool list");
2083 };
2084 assert!(items.is_empty());
2085 });
2086 }
2087}