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