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