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
474pub(crate) fn 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(ctx) = ctx {
812 let vm = ctx.child_vm();
813 if let Some(fixtured) = vm.harness().and_then(|harness| {
814 harness
815 .inner()
816 .fixtures()
817 .dispatch_host(capability, operation, params)
818 }) {
819 return fixtured;
820 }
821 }
822 if let Some(mocked) = dispatch_mock_host_call(capability, operation, params) {
823 return mocked;
824 }
825
826 if (capability, operation) == ("process", "exec") {
827 let caller = serde_json::json!({
828 "surface": "host_call",
829 "capability": "process",
830 "operation": "exec",
831 "session_id": crate::llm::current_agent_session_id(),
832 });
833 return dispatch_process_exec_with_policy(ctx, params, caller).await;
834 }
835
836 if (capability, operation) == ("process", "spawn") {
843 let caller = serde_json::json!({
844 "surface": "host_call",
845 "capability": "process",
846 "operation": "spawn",
847 "session_id": crate::llm::current_agent_session_id(),
848 });
849 return dispatch_process_spawn_with_policy(ctx, params, caller).await;
850 }
851 if capability == "process" && matches!(operation, "poll" | "wait" | "kill" | "release") {
852 if let Some(result) = crate::stdlib::process_spawn::dispatch(
853 operation,
854 params,
855 async_builtin_cancel_token(ctx),
856 )
857 .await
858 {
859 return result;
860 }
861 }
862
863 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
864 if let Some(bridge) = bridge {
865 let dispatched = turn_cache::cached_or(capability, operation, params, || {
869 bridge.dispatch(capability, operation, params)
870 })
871 .await?;
872 if let Some(value) = dispatched {
873 return Ok(value);
874 }
875 }
876
877 dispatch_builtin_host_operation(capability, operation, params).await
878}
879
880async fn dispatch_builtin_host_operation(
881 capability: &str,
882 operation: &str,
883 params: &crate::value::DictMap,
884) -> Result<VmValue, VmError> {
885 match (capability, operation) {
886 ("process", "list_shells") => Ok(crate::shells::list_shells_vm_value()),
887 ("process", "get_default_shell") => Ok(crate::shells::default_shell_vm_value()),
888 ("process", "set_default_shell") => crate::shells::set_default_shell_vm_value(params),
889 ("process", "shell_invocation") => crate::shells::shell_invocation_vm_value(params),
890 ("template", "render") => {
891 let path = require_param(params, "path")?;
892 let bindings = params.get("bindings").and_then(|v| v.as_dict());
893 Ok(VmValue::String(arcstr::ArcStr::from(render_template(
894 &path, bindings,
895 )?)))
896 }
897 ("interaction", "ask") => {
898 let question = require_param(params, "question")?;
899 super::io::prompt_user_value(&[VmValue::string(question)], &mut String::new())
900 }
901 ("project", "metadata_get") => crate::metadata::project_metadata_host_get(params),
902 ("project", "metadata_inspect") => crate::metadata::project_metadata_host_inspect(params),
903 ("project", "metadata_set") => crate::metadata::project_metadata_host_set(params),
904 ("project", "metadata_save") => crate::metadata::project_metadata_host_save(params),
905 ("project", "metadata_stale") => crate::metadata::project_metadata_host_stale(params),
906 ("project", "metadata_refresh_hashes") => {
907 crate::metadata::project_metadata_host_refresh_hashes(params)
908 }
909 ("runtime", "task") => Ok(VmValue::String(arcstr::ArcStr::from(
912 std::env::var("HARN_TASK").unwrap_or_default(),
913 ))),
914 ("runtime", "prompt_content") => Ok(VmValue::List(Arc::new(Vec::new()))),
915 ("runtime", "set_result") => {
916 Ok(VmValue::Nil)
919 }
920 ("workspace", "project_root") => {
921 let path = crate::stdlib::process::project_root_path()
926 .map(|root| root.display().to_string())
927 .or_else(|| std::env::var("HARN_PROJECT_ROOT").ok())
928 .unwrap_or_else(|| {
929 std::env::current_dir()
930 .map(|p| p.display().to_string())
931 .unwrap_or_default()
932 });
933 Ok(VmValue::String(arcstr::ArcStr::from(path)))
934 }
935 ("workspace", "cwd") => {
936 let path = std::env::current_dir()
937 .map(|p| p.display().to_string())
938 .unwrap_or_default();
939 Ok(VmValue::String(arcstr::ArcStr::from(path)))
940 }
941 _ => Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
942 format!("host_call: unsupported operation {capability}.{operation}"),
943 )))),
944 }
945}
946
947pub(crate) fn optional_i64(params: &crate::value::DictMap, key: &str) -> Option<i64> {
948 match params.get(key) {
949 Some(VmValue::Int(value)) => Some(*value),
950 Some(VmValue::Float(value)) if value.fract() == 0.0 => Some(*value as i64),
951 _ => None,
952 }
953}
954
955pub(crate) fn optional_string(params: &crate::value::DictMap, key: &str) -> Option<String> {
956 params.get(key).and_then(vm_string).map(ToString::to_string)
957}
958
959fn optional_string_list(params: &crate::value::DictMap, key: &str) -> Option<Vec<String>> {
960 let VmValue::List(values) = params.get(key)? else {
961 return None;
962 };
963 values
964 .iter()
965 .map(|value| vm_string(value).map(ToString::to_string))
966 .collect()
967}
968
969fn optional_string_dict(
970 params: &crate::value::DictMap,
971 key: &str,
972) -> Result<Option<BTreeMap<String, String>>, VmError> {
973 let Some(value) = params.get(key) else {
974 return Ok(None);
975 };
976 let Some(dict) = value.as_dict() else {
977 return Err(VmError::Runtime(format!(
978 "host_call process.exec {key} must be a dict"
979 )));
980 };
981 let mut out = std::collections::BTreeMap::new();
982 for (key, value) in dict.iter() {
983 let Some(value) = vm_string(value) else {
984 return Err(VmError::Runtime(format!(
985 "host_call process.exec env value for {key:?} must be a string"
986 )));
987 };
988 out.insert(key.to_string(), value.to_string());
989 }
990 Ok(Some(out))
991}
992
993fn vm_string(value: &VmValue) -> Option<&str> {
994 match value {
995 VmValue::String(value) => Some(value.as_ref()),
996 _ => None,
997 }
998}
999
1000pub(crate) fn register_host_builtins(vm: &mut Vm) {
1001 for def in MODULE_BUILTINS {
1002 vm.register_builtin_def(def);
1003 }
1004}
1005
1006pub(crate) fn register_missing_host_builtins(vm: &mut Vm) {
1007 for def in MODULE_BUILTINS {
1008 if vm.builtin_metadata_for(def.sig.name).is_none() {
1009 vm.register_builtin_def(def);
1010 }
1011 }
1012}
1013
1014#[harn_builtin(
1015 exposure = "privileged_wire",
1016 effects = ["host.mutate@arg0"],
1017 sig = "host_mock(capability: string, op: string, response_or_config?: any, params?: dict) -> nil",
1018 category = "host"
1019)]
1020fn host_mock_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1021 let host_mock = parse_host_mock(args)?;
1022 validate_host_mock_registration(&host_mock)?;
1023 push_host_mock(host_mock);
1024 Ok(VmValue::Nil)
1025}
1026
1027#[harn_builtin(
1028 exposure = "privileged_wire",
1029 effects = [],
1030 sig = "host_mock_clear() -> nil", category = "host"
1031)]
1032fn host_mock_clear_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1033 reset_host_state();
1034 Ok(VmValue::Nil)
1035}
1036
1037#[harn_builtin(
1038 exposure = "runtime_internal",
1039 effects = [],
1040 sig = "host_mock_calls() -> list", category = "host"
1041)]
1042fn host_mock_calls_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1043 let calls = HOST_MOCK_CALLS.with(|calls| {
1044 calls
1045 .borrow()
1046 .iter()
1047 .map(mock_call_value)
1048 .collect::<Vec<_>>()
1049 });
1050 Ok(VmValue::List(std::sync::Arc::new(calls)))
1051}
1052
1053#[harn_builtin(
1054 exposure = "runtime_internal",
1055 effects = [],
1056 sig = "host_mock_push_scope() -> nil", category = "host"
1057)]
1058fn host_mock_push_scope_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1059 push_host_mock_scope();
1060 Ok(VmValue::Nil)
1061}
1062
1063#[harn_builtin(
1064 exposure = "runtime_internal",
1065 effects = [],
1066 sig = "host_mock_pop_scope() -> nil", category = "host"
1067)]
1068fn host_mock_pop_scope_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1069 if !pop_host_mock_scope() {
1070 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1071 "host_mock_pop_scope: no scope to pop",
1072 ))));
1073 }
1074 Ok(VmValue::Nil)
1075}
1076
1077#[harn_builtin(
1078 exposure = "runtime_internal",
1079 effects = [],
1080 sig = "host_capabilities() -> dict", category = "host"
1081)]
1082fn host_capabilities_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1083 Ok(capability_manifest_with_mocks())
1084}
1085
1086#[harn_builtin(
1087 exposure = "runtime_internal",
1088 effects = [],
1089 sig = "host_has(capability: string, op?: string) -> bool",
1090 category = "host"
1091)]
1092fn host_has_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1093 let capability = args.first().map(|a| a.display()).unwrap_or_default();
1094 let operation = args.get(1).map(|a| a.display());
1095 let manifest = capability_manifest_with_mocks();
1096 let has = manifest
1097 .as_dict()
1098 .and_then(|d| d.get(capability.as_str()))
1099 .and_then(|v| v.as_dict())
1100 .is_some_and(|cap| {
1101 if let Some(operation) = operation {
1102 cap.get("ops")
1103 .and_then(|v| match v {
1104 VmValue::List(list) => {
1105 Some(list.iter().any(|item| item.display() == operation))
1106 }
1107 _ => None,
1108 })
1109 .unwrap_or(false)
1110 } else {
1111 true
1112 }
1113 });
1114 Ok(VmValue::Bool(has))
1115}
1116
1117#[harn_builtin(
1118 exposure = "privileged_wire",
1119 effects = [],
1120 sig = "host_call(name: string, args?: dict) -> any",
1121 kind = "async",
1122 category = "host"
1123)]
1124async fn host_call_builtin(
1125 ctx: crate::vm::AsyncBuiltinCtx,
1126 args: Vec<VmValue>,
1127) -> Result<VmValue, VmError> {
1128 let name = args.first().map(|a| a.display()).unwrap_or_default();
1129 let params = args
1130 .get(1)
1131 .and_then(|a| a.as_dict())
1132 .cloned()
1133 .unwrap_or_default();
1134 let Some((capability, operation)) = name.split_once('.') else {
1135 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1136 format!("host_call: unsupported operation name '{name}'"),
1137 ))));
1138 };
1139 dispatch_host_operation_with_ctx(Some(&ctx), capability, operation, ¶ms).await
1140}
1141
1142#[harn_builtin(
1143 exposure = "runtime_internal",
1144 effects = [],
1145 sig = "host_tool_list() -> list", kind = "async", category = "host"
1146)]
1147async fn host_tool_list_builtin(
1148 ctx: crate::vm::AsyncBuiltinCtx,
1149 _args: Vec<VmValue>,
1150) -> Result<VmValue, VmError> {
1151 dispatch_host_tool_list_with_ctx(Some(&ctx)).await
1152}
1153
1154#[harn_builtin(
1155 exposure = "runtime_internal",
1156 effects = [],
1157 sig = "host_tool_call(name: string, args?: any) -> any",
1158 kind = "async",
1159 category = "host"
1160)]
1161async fn host_tool_call_builtin(
1162 ctx: crate::vm::AsyncBuiltinCtx,
1163 args: Vec<VmValue>,
1164) -> Result<VmValue, VmError> {
1165 let name = args.first().map(|a| a.display()).unwrap_or_default();
1166 if name.is_empty() {
1167 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1168 "host_tool_call: tool name is required",
1169 ))));
1170 }
1171 let call_args = args.get(1).cloned().unwrap_or(VmValue::Nil);
1172 dispatch_host_tool_call_with_ctx(Some(&ctx), &name, &call_args).await
1173}
1174
1175#[cfg(test)]
1176mod tests {
1177 use super::process_exec::resolve_process_exec_cwd;
1178 use super::{
1179 build_sandboxed_command, capability_manifest_with_mocks, clear_host_call_bridge,
1180 dispatch_host_operation, dispatch_host_tool_call, dispatch_host_tool_list,
1181 dispatch_mock_host_call, dispatch_mock_hostlib_call, host_call_ready, host_has_builtin,
1182 host_mock_clear_builtin, parse_host_mock, push_host_mock, register_mockable_host_operation,
1183 register_scoped_mockable_host_operation, reset_host_state, reset_scoped_host_state,
1184 set_host_call_bridge, validate_host_mock_registration, HostCallBridge,
1185 HostCallDispatchFuture, HostMock,
1186 };
1187 use crate::value::VmDictExt;
1188
1189 use std::sync::{
1190 atomic::{AtomicUsize, Ordering},
1191 Arc,
1192 };
1193
1194 use crate::value::{VmError, VmValue};
1195
1196 fn command_env(
1200 cmd: &tokio::process::Command,
1201 ) -> std::collections::BTreeMap<String, Option<String>> {
1202 cmd.as_std()
1203 .get_envs()
1204 .map(|(k, v)| {
1205 (
1206 k.to_string_lossy().into_owned(),
1207 v.map(|value| value.to_string_lossy().into_owned()),
1208 )
1209 })
1210 .collect()
1211 }
1212
1213 #[test]
1214 fn build_sandboxed_command_forces_deterministic_message_locale() {
1215 let mut params = crate::value::DictMap::new();
1225 params.put_str("mode", "argv");
1226 params.put(
1227 "argv",
1228 VmValue::List(Arc::new(vec![VmValue::string("/bin/true")])),
1229 );
1230 params.put_str("env_mode", "merge");
1231 let mut caller_env = crate::value::DictMap::new();
1232 caller_env.put_str("CARGO_TARGET_DIR", "/tmp/target");
1234 params.put("env", VmValue::dict_map(caller_env));
1235
1236 let cmd = build_sandboxed_command(¶ms, "process.exec").expect("build command");
1237 let env = command_env(&cmd);
1238
1239 assert_eq!(
1240 env.get("LC_ALL"),
1241 Some(&None),
1242 "the builder must remove LC_ALL from the child so an inherited shell \
1243 value cannot override the forced LC_MESSAGES"
1244 );
1245 assert_eq!(
1246 env.get("LC_MESSAGES"),
1247 Some(&Some("C".to_string())),
1248 "LC_MESSAGES must be pinned to C for untranslated (English) tool output"
1249 );
1250 assert_eq!(
1251 env.get("DOTNET_CLI_UI_LANGUAGE"),
1252 Some(&Some("en".to_string())),
1253 ".NET ignores LC_* and needs its own UI-language override"
1254 );
1255 }
1256
1257 #[test]
1258 fn build_sandboxed_command_respects_a_caller_pinned_locale() {
1259 let mut params = crate::value::DictMap::new();
1262 params.put_str("mode", "argv");
1263 params.put(
1264 "argv",
1265 VmValue::List(Arc::new(vec![VmValue::string("/bin/true")])),
1266 );
1267 params.put_str("env_mode", "merge");
1268 let mut caller_env = crate::value::DictMap::new();
1269 caller_env.put_str("LC_ALL", "fr_FR.UTF-8");
1270 caller_env.put_str("LC_MESSAGES", "fr_FR.UTF-8");
1271 params.put("env", VmValue::dict_map(caller_env));
1272
1273 let cmd = build_sandboxed_command(¶ms, "process.exec").expect("build command");
1274 let env = command_env(&cmd);
1275
1276 assert_eq!(
1277 env.get("LC_ALL"),
1278 Some(&Some("fr_FR.UTF-8".to_string())),
1279 "a caller that pins LC_ALL keeps it — the overlay must not strip an explicit value"
1280 );
1281 assert_eq!(
1282 env.get("LC_MESSAGES"),
1283 Some(&Some("fr_FR.UTF-8".to_string())),
1284 "a caller-pinned LC_MESSAGES wins over the C overlay"
1285 );
1286 }
1287
1288 #[test]
1289 fn process_exec_relative_cwd_resolves_against_execution_root() {
1290 let dir = tempfile::tempdir().expect("tempdir");
1291 crate::stdlib::process::set_thread_execution_context(Some(
1292 crate::orchestration::RunExecutionRecord {
1293 cwd: Some(dir.path().to_string_lossy().into_owned()),
1294 source_dir: Some(dir.path().join("src").to_string_lossy().into_owned()),
1295 ..Default::default()
1296 },
1297 ));
1298
1299 assert_eq!(
1300 resolve_process_exec_cwd("subdir"),
1301 dir.path().join("subdir")
1302 );
1303
1304 crate::stdlib::process::set_thread_execution_context(None);
1305 }
1306
1307 #[test]
1308 fn workspace_project_root_fallback_prefers_execution_context_project_root() {
1309 run_host_async_test(|| async {
1310 let project = tempfile::tempdir().expect("project root");
1311 let cwd = tempfile::tempdir().expect("cwd");
1312 crate::stdlib::process::set_thread_execution_context(Some(
1313 crate::orchestration::RunExecutionRecord {
1314 cwd: Some(cwd.path().to_string_lossy().into_owned()),
1315 project_root: Some(project.path().to_string_lossy().into_owned()),
1316 ..Default::default()
1317 },
1318 ));
1319
1320 let result =
1321 dispatch_host_operation("workspace", "project_root", &crate::value::DictMap::new())
1322 .await
1323 .expect("workspace.project_root result");
1324
1325 crate::stdlib::process::set_thread_execution_context(None);
1326 assert_eq!(result.display(), project.path().display().to_string());
1327 });
1328 }
1329
1330 #[test]
1331 fn manifest_includes_operation_metadata() {
1332 let manifest = capability_manifest_with_mocks();
1333 let process = manifest
1334 .as_dict()
1335 .and_then(|d| d.get("process"))
1336 .and_then(|v| v.as_dict())
1337 .expect("process capability");
1338 assert!(process.get("description").is_some());
1339 let operations = process
1340 .get("operations")
1341 .and_then(|v| v.as_dict())
1342 .expect("operations dict");
1343 assert!(operations.get("exec").is_some());
1344 }
1345
1346 #[test]
1347 fn mocked_capabilities_appear_in_manifest() {
1348 reset_host_state();
1349 push_host_mock(HostMock {
1350 capability: "project".to_string(),
1351 operation: "metadata_get".to_string(),
1352 params: None,
1353 result: Some(VmValue::dict(crate::value::DictMap::new())),
1354 error: None,
1355 unregistered_ok: false,
1356 });
1357 let manifest = capability_manifest_with_mocks();
1358 let project = manifest
1359 .as_dict()
1360 .and_then(|d| d.get("project"))
1361 .and_then(|v| v.as_dict())
1362 .expect("project capability");
1363 let operations = project
1364 .get("operations")
1365 .and_then(|v| v.as_dict())
1366 .expect("operations dict");
1367 assert!(operations.get("metadata_get").is_some());
1368 reset_host_state();
1369 }
1370
1371 #[test]
1372 fn mock_host_call_matches_partial_params_and_overrides_order() {
1373 reset_host_state();
1374 let mut exact_params = crate::value::DictMap::new();
1375 exact_params.put_str("namespace", "facts");
1376 push_host_mock(HostMock {
1377 capability: "project".to_string(),
1378 operation: "metadata_get".to_string(),
1379 params: None,
1380 result: Some(VmValue::String(arcstr::ArcStr::from("fallback"))),
1381 error: None,
1382 unregistered_ok: false,
1383 });
1384 push_host_mock(HostMock {
1385 capability: "project".to_string(),
1386 operation: "metadata_get".to_string(),
1387 params: Some(exact_params),
1388 result: Some(VmValue::String(arcstr::ArcStr::from("facts"))),
1389 error: None,
1390 unregistered_ok: false,
1391 });
1392
1393 let mut call_params = crate::value::DictMap::new();
1394 call_params.put_str("dir", "pkg");
1395 call_params.put_str("namespace", "facts");
1396 let exact = dispatch_mock_host_call("project", "metadata_get", &call_params)
1397 .expect("expected exact mock")
1398 .expect("exact mock should succeed");
1399 assert_eq!(exact.display(), "facts");
1400
1401 call_params.put_str("namespace", "classification");
1402 let fallback = dispatch_mock_host_call("project", "metadata_get", &call_params)
1403 .expect("expected fallback mock")
1404 .expect("fallback mock should succeed");
1405 assert_eq!(fallback.display(), "fallback");
1406 reset_host_state();
1407 }
1408
1409 #[test]
1410 fn mock_host_call_can_throw_errors() {
1411 reset_host_state();
1412 push_host_mock(HostMock {
1413 capability: "project".to_string(),
1414 operation: "metadata_get".to_string(),
1415 params: None,
1416 result: None,
1417 error: Some("boom".to_string()),
1418 unregistered_ok: false,
1419 });
1420 let params = crate::value::DictMap::new();
1421 let result = dispatch_mock_host_call("project", "metadata_get", ¶ms)
1422 .expect("expected mock result");
1423 match result {
1424 Err(VmError::Thrown(VmValue::String(message))) => assert_eq!(message.as_str(), "boom"),
1425 other => panic!("unexpected result: {other:?}"),
1426 }
1427 reset_host_state();
1428 }
1429
1430 #[test]
1431 fn host_mock_registration_rejects_unknown_operations_by_default() {
1432 let host_mock = HostMock {
1433 capability: "runtime".to_string(),
1434 operation: "tas".to_string(),
1435 params: None,
1436 result: Some(VmValue::Nil),
1437 error: None,
1438 unregistered_ok: false,
1439 };
1440 let error = validate_host_mock_registration(&host_mock)
1441 .expect_err("unknown host operation should fail at registration");
1442 match error {
1443 VmError::Thrown(VmValue::String(message)) => {
1444 assert!(message.contains("runtime.tas"));
1445 assert!(message.contains("unregistered_ok"));
1446 assert!(message.contains("runtime.task"));
1447 }
1448 other => panic!("unexpected error: {other:?}"),
1449 }
1450 }
1451
1452 #[test]
1453 fn host_mock_registration_allows_explicit_test_local_operations() {
1454 let host_mock = HostMock {
1455 capability: "synthetic".to_string(),
1456 operation: "op".to_string(),
1457 params: None,
1458 result: Some(VmValue::Nil),
1459 error: None,
1460 unregistered_ok: true,
1461 };
1462 validate_host_mock_registration(&host_mock)
1463 .expect("explicit unregistered_ok should permit synthetic mocks");
1464 }
1465
1466 #[test]
1467 fn host_mock_registration_accepts_runtime_registered_operations() {
1468 register_mockable_host_operation(
1469 "code_index",
1470 "stats",
1471 "Hostlib schema-backed operation registered at runtime.",
1472 );
1473 let host_mock = HostMock {
1474 capability: "code_index".to_string(),
1475 operation: "stats".to_string(),
1476 params: None,
1477 result: Some(VmValue::Nil),
1478 error: None,
1479 unregistered_ok: false,
1480 };
1481 validate_host_mock_registration(&host_mock)
1482 .expect("registered hostlib operations should be mockable");
1483 }
1484
1485 #[test]
1486 fn clearing_live_mocks_preserves_scoped_manifest_declarations() {
1487 reset_scoped_host_state();
1488 register_scoped_mockable_host_operation(
1489 "scoped_clear_fixture",
1490 "answer",
1491 "Test-scoped manifest declaration.",
1492 );
1493 let host_mock = HostMock {
1494 capability: "scoped_clear_fixture".to_string(),
1495 operation: "answer".to_string(),
1496 params: None,
1497 result: Some(VmValue::Nil),
1498 error: None,
1499 unregistered_ok: false,
1500 };
1501
1502 validate_host_mock_registration(&host_mock).expect("scoped declaration is registered");
1503 host_mock_clear_builtin(&[], &mut String::new()).expect("clear live mocks");
1504 validate_host_mock_registration(&host_mock)
1505 .expect("clearing live mocks must preserve manifest declarations");
1506 reset_scoped_host_state();
1507 }
1508
1509 #[tokio::test]
1510 async fn declared_mockable_operation_is_not_reported_as_callable() {
1511 std::thread::spawn(|| {
1512 register_mockable_host_operation(
1513 "async_host_registration",
1514 "cross_thread",
1515 "Embedding operation registered before async worker migration.",
1516 );
1517 })
1518 .join()
1519 .expect("registration worker should finish");
1520
1521 std::thread::spawn(|| {
1522 let host_mock = HostMock {
1523 capability: "async_host_registration".to_string(),
1524 operation: "cross_thread".to_string(),
1525 params: None,
1526 result: Some(VmValue::Nil),
1527 error: None,
1528 unregistered_ok: false,
1529 };
1530 validate_host_mock_registration(&host_mock)
1531 .expect("process host registration should be visible after worker migration");
1532
1533 let typo = HostMock {
1534 operation: "cross_tread".to_string(),
1535 ..host_mock
1536 };
1537 validate_host_mock_registration(&typo)
1538 .expect_err("an undeclared operation should still fail closed");
1539 })
1540 .join()
1541 .expect("validation worker should finish");
1542
1543 assert!(matches!(
1544 host_has_builtin(
1545 &[
1546 VmValue::string("async_host_registration"),
1547 VmValue::string("cross_thread"),
1548 ],
1549 &mut String::new(),
1550 )
1551 .expect("host_has should succeed"),
1552 VmValue::Bool(false)
1553 ));
1554 dispatch_host_operation(
1555 "async_host_registration",
1556 "cross_thread",
1557 &crate::value::DictMap::new(),
1558 )
1559 .await
1560 .expect_err("an unmocked declaration must remain unsupported at dispatch");
1561 }
1562
1563 #[test]
1564 fn host_mock_parse_preserves_unregistered_ok_config() {
1565 let config = VmValue::dict(crate::value::DictMap::from_iter([
1566 (crate::value::intern_key("result"), VmValue::string("ok")),
1567 (
1568 crate::value::intern_key("unregistered_ok"),
1569 VmValue::Bool(true),
1570 ),
1571 ]));
1572 let host_mock =
1573 parse_host_mock(&[VmValue::string("synthetic"), VmValue::string("op"), config])
1574 .expect("parse host mock config");
1575 assert!(host_mock.unregistered_ok);
1576 }
1577
1578 #[test]
1579 fn hostlib_mock_dispatch_matches_module_method_and_params() {
1580 reset_host_state();
1581 let mut mock_params = crate::value::DictMap::new();
1582 mock_params.put(
1583 "argv",
1584 VmValue::List(Arc::new(vec![VmValue::string("echo")])),
1585 );
1586 push_host_mock(HostMock {
1587 capability: "tools".to_string(),
1588 operation: "run_command".to_string(),
1589 params: Some(mock_params),
1590 result: Some(VmValue::String(arcstr::ArcStr::from("direct"))),
1591 error: None,
1592 unregistered_ok: false,
1593 });
1594
1595 let mut call_params = crate::value::DictMap::new();
1596 call_params.put(
1597 "argv",
1598 VmValue::List(Arc::new(vec![VmValue::string("echo")])),
1599 );
1600 call_params.put_str("cwd", "/tmp/not-used");
1601 let value = dispatch_mock_hostlib_call("tools", "run_command", &call_params)
1602 .expect("expected hostlib mock")
1603 .expect("hostlib mock should succeed");
1604 assert_eq!(value.display(), "direct");
1605 reset_host_state();
1606 }
1607
1608 #[test]
1609 fn hostlib_run_command_falls_back_to_process_exec_mocks() {
1610 reset_host_state();
1611 let mut mock_params = crate::value::DictMap::new();
1612 mock_params.put(
1613 "argv",
1614 VmValue::List(Arc::new(vec![
1615 VmValue::string("cargo"),
1616 VmValue::string("test"),
1617 ])),
1618 );
1619 push_host_mock(HostMock {
1620 capability: "process".to_string(),
1621 operation: "exec".to_string(),
1622 params: Some(mock_params),
1623 result: Some(VmValue::String(arcstr::ArcStr::from("legacy"))),
1624 error: None,
1625 unregistered_ok: false,
1626 });
1627
1628 let mut call_params = crate::value::DictMap::new();
1629 call_params.put(
1630 "argv",
1631 VmValue::List(Arc::new(vec![
1632 VmValue::string("cargo"),
1633 VmValue::string("test"),
1634 ])),
1635 );
1636 call_params.put_str("cwd", "/tmp/not-used");
1637 let value = dispatch_mock_hostlib_call("tools", "run_command", &call_params)
1638 .expect("expected legacy process.exec mock")
1639 .expect("legacy mock should succeed");
1640 assert_eq!(value.display(), "legacy");
1641 reset_host_state();
1642 }
1643
1644 #[test]
1645 fn hostlib_run_command_prefers_exact_mock_over_process_exec_alias() {
1646 reset_host_state();
1647 let mut params = crate::value::DictMap::new();
1648 params.put(
1649 "argv",
1650 VmValue::List(Arc::new(vec![
1651 VmValue::string("npm"),
1652 VmValue::string("test"),
1653 ])),
1654 );
1655 push_host_mock(HostMock {
1656 capability: "process".to_string(),
1657 operation: "exec".to_string(),
1658 params: Some(params.clone()),
1659 result: Some(VmValue::String(arcstr::ArcStr::from("legacy"))),
1660 error: None,
1661 unregistered_ok: false,
1662 });
1663 push_host_mock(HostMock {
1664 capability: "tools".to_string(),
1665 operation: "run_command".to_string(),
1666 params: Some(params.clone()),
1667 result: Some(VmValue::String(arcstr::ArcStr::from("direct"))),
1668 error: None,
1669 unregistered_ok: false,
1670 });
1671
1672 let value = dispatch_mock_hostlib_call("tools", "run_command", ¶ms)
1673 .expect("expected exact hostlib mock")
1674 .expect("exact mock should succeed");
1675 assert_eq!(value.display(), "direct");
1676 reset_host_state();
1677 }
1678
1679 #[derive(Default)]
1680 struct TestHostToolBridge;
1681
1682 impl HostCallBridge for TestHostToolBridge {
1683 fn dispatch<'a>(
1684 &'a self,
1685 _capability: &'a str,
1686 _operation: &'a str,
1687 _params: &'a crate::value::DictMap,
1688 ) -> HostCallDispatchFuture<'a> {
1689 host_call_ready(Ok(None))
1690 }
1691
1692 fn list_tools(&self) -> Result<Option<VmValue>, VmError> {
1693 let tool = VmValue::dict(crate::value::DictMap::from_iter([
1694 (
1695 crate::value::intern_key("name"),
1696 VmValue::String(arcstr::ArcStr::from("Read".to_string())),
1697 ),
1698 (
1699 crate::value::intern_key("description"),
1700 VmValue::String(arcstr::ArcStr::from(
1701 "Read a file from the host".to_string(),
1702 )),
1703 ),
1704 (
1705 crate::value::intern_key("schema"),
1706 VmValue::dict(crate::value::DictMap::from_iter([(
1707 crate::value::intern_key("type"),
1708 VmValue::String(arcstr::ArcStr::from("object".to_string())),
1709 )])),
1710 ),
1711 (crate::value::intern_key("deprecated"), VmValue::Bool(false)),
1712 ]));
1713 Ok(Some(VmValue::List(std::sync::Arc::new(vec![tool]))))
1714 }
1715
1716 fn call_tool(&self, name: &str, args: &VmValue) -> Result<Option<VmValue>, VmError> {
1717 if name != "Read" {
1718 return Ok(None);
1719 }
1720 let path = args
1721 .as_dict()
1722 .and_then(|dict| dict.get("path"))
1723 .map(|value| value.display())
1724 .unwrap_or_default();
1725 Ok(Some(VmValue::String(arcstr::ArcStr::from(format!(
1726 "read:{path}"
1727 )))))
1728 }
1729 }
1730
1731 struct CountingProcessExecBridge {
1732 calls: Arc<AtomicUsize>,
1733 }
1734
1735 impl HostCallBridge for CountingProcessExecBridge {
1736 fn dispatch<'a>(
1737 &'a self,
1738 capability: &'a str,
1739 operation: &'a str,
1740 _params: &'a crate::value::DictMap,
1741 ) -> HostCallDispatchFuture<'a> {
1742 if (capability, operation) != ("process", "exec") {
1743 return host_call_ready(Ok(None));
1744 }
1745 self.calls.fetch_add(1, Ordering::SeqCst);
1746 host_call_ready(Ok(Some(VmValue::dict(crate::value::DictMap::from_iter([
1747 (
1748 crate::value::intern_key("status"),
1749 VmValue::String(arcstr::ArcStr::from("completed".to_string())),
1750 ),
1751 (crate::value::intern_key("exit_code"), VmValue::Int(0)),
1752 (crate::value::intern_key("success"), VmValue::Bool(true)),
1753 ])))))
1754 }
1755 }
1756
1757 fn run_host_async_test<F, Fut>(test: F)
1758 where
1759 F: FnOnce() -> Fut,
1760 Fut: std::future::Future<Output = ()>,
1761 {
1762 let _guard = super::turn_cache::epoch_test_lock()
1766 .lock()
1767 .unwrap_or_else(|e| e.into_inner());
1768 let rt = tokio::runtime::Builder::new_current_thread()
1769 .enable_all()
1770 .build()
1771 .expect("runtime");
1772 rt.block_on(async {
1773 let local = tokio::task::LocalSet::new();
1774 local.run_until(test()).await;
1775 });
1776 }
1777
1778 #[test]
1779 fn host_tool_list_uses_installed_host_call_bridge() {
1780 run_host_async_test(|| async {
1781 reset_host_state();
1782 set_host_call_bridge(Arc::new(TestHostToolBridge));
1783 let tools = dispatch_host_tool_list().await.expect("tool list");
1784 clear_host_call_bridge();
1785
1786 let VmValue::List(items) = tools else {
1787 panic!("expected tool list");
1788 };
1789 assert_eq!(items.len(), 1);
1790 let tool = items[0].as_dict().expect("tool dict");
1791 assert_eq!(tool.get("name").unwrap().display(), "Read");
1792 assert_eq!(tool.get("deprecated").unwrap().display(), "false");
1793 });
1794 }
1795
1796 #[test]
1797 fn host_tool_call_uses_installed_host_call_bridge() {
1798 run_host_async_test(|| async {
1799 set_host_call_bridge(Arc::new(TestHostToolBridge));
1800 let args = VmValue::dict(crate::value::DictMap::from_iter([(
1801 crate::value::intern_key("path"),
1802 VmValue::String(arcstr::ArcStr::from("README.md".to_string())),
1803 )]));
1804 let value = dispatch_host_tool_call("Read", &args)
1805 .await
1806 .expect("tool call");
1807 clear_host_call_bridge();
1808 assert_eq!(value.display(), "read:README.md");
1809 });
1810 }
1811
1812 #[test]
1813 fn process_exec_bridge_is_gated_by_command_policy() {
1814 run_host_async_test(|| async {
1815 crate::orchestration::clear_command_policies();
1816 let calls = Arc::new(AtomicUsize::new(0));
1817 set_host_call_bridge(Arc::new(CountingProcessExecBridge {
1818 calls: calls.clone(),
1819 }));
1820 crate::orchestration::push_command_policy(crate::orchestration::CommandPolicy {
1821 tools: vec!["run".to_string()],
1822 workspace_roots: Vec::new(),
1823 default_shell_mode: "shell".to_string(),
1824 deny_patterns: vec!["cat *".to_string()],
1825 require_approval: Default::default(),
1826 deny_labels: Default::default(),
1827 pre: None,
1828 post: None,
1829 consent: None,
1830 allow_recursive: false,
1831 });
1832
1833 let result = dispatch_host_operation(
1834 "process",
1835 "exec",
1836 &crate::value::DictMap::from_iter([
1837 (
1838 crate::value::intern_key("mode"),
1839 VmValue::String(arcstr::ArcStr::from("shell")),
1840 ),
1841 (
1842 crate::value::intern_key("command"),
1843 VmValue::String(arcstr::ArcStr::from("cat Cargo.toml")),
1844 ),
1845 ]),
1846 )
1847 .await
1848 .expect("process.exec result");
1849
1850 crate::orchestration::clear_command_policies();
1851 clear_host_call_bridge();
1852
1853 assert_eq!(
1854 calls.load(Ordering::SeqCst),
1855 0,
1856 "blocked command must not reach host bridge"
1857 );
1858 let result = result.as_dict().expect("blocked result dict");
1859 assert_eq!(result.get("status").unwrap().display(), "blocked");
1860 assert!(
1861 result
1862 .get("reason")
1863 .map(VmValue::display)
1864 .unwrap_or_default()
1865 .contains("cat *"),
1866 "blocked result should name the matched policy pattern"
1867 );
1868 });
1869 }
1870
1871 #[cfg(unix)]
1872 async fn process_exec_env_probe(env: VmValue, env_mode: Option<&str>) -> (String, String) {
1873 std::env::set_var("PARENT_VAR", "inherited");
1878 let mut params = crate::value::DictMap::from_iter([
1879 (
1880 crate::value::intern_key("mode"),
1881 VmValue::String(arcstr::ArcStr::from("argv")),
1882 ),
1883 (
1884 crate::value::intern_key("argv"),
1885 VmValue::List(std::sync::Arc::new(vec![
1886 VmValue::String(arcstr::ArcStr::from("/bin/sh")),
1889 VmValue::String(arcstr::ArcStr::from("-c")),
1890 VmValue::String(arcstr::ArcStr::from(
1891 "printf '%s|%s' \"$PARENT_VAR\" \"$CHILD_VAR\"",
1892 )),
1893 ])),
1894 ),
1895 (crate::value::intern_key("env"), env),
1896 ]);
1897 if let Some(mode) = env_mode {
1898 params.put_str("env_mode", mode);
1899 }
1900 let result = super::dispatch_process_exec(¶ms, serde_json::Value::Null)
1901 .await
1902 .expect("process.exec result");
1903 let dict = result.as_dict().expect("result dict");
1904 let stdout = dict.get("stdout").map(VmValue::display).unwrap_or_default();
1905 std::env::remove_var("PARENT_VAR");
1906 let (parent, child) = stdout.split_once('|').unwrap_or((&stdout, ""));
1907 (parent.to_string(), child.to_string())
1908 }
1909
1910 #[cfg(unix)]
1911 #[test]
1912 fn process_exec_env_default_merges_with_parent() {
1913 run_host_async_test(|| async {
1914 let child_env = VmValue::dict(crate::value::DictMap::from_iter([(
1917 crate::value::intern_key("CHILD_VAR"),
1918 VmValue::String(arcstr::ArcStr::from("provided")),
1919 )]));
1920 let (parent, child) = process_exec_env_probe(child_env, None).await;
1921 assert_eq!(
1922 parent, "inherited",
1923 "default env_mode must inherit parent env"
1924 );
1925 assert_eq!(
1926 child, "provided",
1927 "default env_mode must apply provided keys"
1928 );
1929 });
1930 }
1931
1932 #[cfg(unix)]
1933 #[test]
1934 fn process_exec_env_mode_replace_clears_parent() {
1935 run_host_async_test(|| async {
1936 let child_env = VmValue::dict(crate::value::DictMap::from_iter([(
1940 crate::value::intern_key("CHILD_VAR"),
1941 VmValue::String(arcstr::ArcStr::from("provided")),
1942 )]));
1943 let (parent, child) = process_exec_env_probe(child_env, Some("replace")).await;
1944 assert_eq!(parent, "", "explicit replace must clear parent env");
1945 assert_eq!(
1946 child, "provided",
1947 "explicit replace must keep provided keys"
1948 );
1949 });
1950 }
1951
1952 #[cfg(unix)]
1953 #[test]
1954 fn process_exec_env_mode_unknown_is_rejected() {
1955 run_host_async_test(|| async {
1956 let params = crate::value::DictMap::from_iter([
1957 (
1958 crate::value::intern_key("mode"),
1959 VmValue::String(arcstr::ArcStr::from("argv")),
1960 ),
1961 (
1962 crate::value::intern_key("argv"),
1963 VmValue::List(std::sync::Arc::new(vec![VmValue::String(
1964 arcstr::ArcStr::from("true"),
1965 )])),
1966 ),
1967 (
1968 crate::value::intern_key("env"),
1969 VmValue::dict(crate::value::DictMap::from_iter([(
1970 crate::value::intern_key("CHILD_VAR"),
1971 VmValue::String(arcstr::ArcStr::from("x")),
1972 )])),
1973 ),
1974 (
1975 crate::value::intern_key("env_mode"),
1976 VmValue::String(arcstr::ArcStr::from("bogus")),
1977 ),
1978 ]);
1979 let err = super::dispatch_process_exec(¶ms, serde_json::Value::Null)
1980 .await
1981 .expect_err("unknown env_mode must error");
1982 assert!(
1983 format!("{err:?}").contains("env_mode"),
1984 "error should name env_mode, got {err:?}"
1985 );
1986 });
1987 }
1988
1989 #[cfg(unix)]
1995 async fn process_exec_tmpdir_probe(
1996 workspace: &std::path::Path,
1997 caller_env: Option<VmValue>,
1998 ) -> String {
1999 let mut env_pairs = vec![(
2000 crate::value::intern_key("mode"),
2001 VmValue::String(arcstr::ArcStr::from("argv")),
2002 )];
2003 env_pairs.push((
2004 crate::value::intern_key("argv"),
2005 VmValue::List(std::sync::Arc::new(vec![
2006 VmValue::String(arcstr::ArcStr::from("/bin/sh")),
2007 VmValue::String(arcstr::ArcStr::from("-c")),
2008 VmValue::String(arcstr::ArcStr::from("printf '%s' \"$TMPDIR\"")),
2009 ])),
2010 ));
2011 if let Some(env) = caller_env {
2012 env_pairs.push((crate::value::intern_key("env"), env));
2013 }
2014 let params = crate::value::DictMap::from_iter(env_pairs);
2015
2016 crate::orchestration::push_execution_policy(crate::orchestration::CapabilityPolicy {
2017 sandbox_profile: crate::orchestration::SandboxProfile::Worktree,
2018 workspace_roots: vec![workspace.to_string_lossy().into_owned()],
2019 ..crate::orchestration::CapabilityPolicy::default()
2023 });
2024 std::env::set_var("HARN_HANDLER_SANDBOX", "off");
2025 let result = super::dispatch_process_exec(¶ms, serde_json::Value::Null)
2026 .await
2027 .expect("process.exec result");
2028 std::env::remove_var("HARN_HANDLER_SANDBOX");
2029 crate::orchestration::pop_execution_policy();
2030 result
2031 .as_dict()
2032 .and_then(|d| d.get("stdout"))
2033 .map(VmValue::display)
2034 .unwrap_or_default()
2035 }
2036
2037 #[cfg(unix)]
2038 #[test]
2039 fn process_exec_injects_workspace_local_tmpdir() {
2040 run_host_async_test(|| async {
2041 let workspace = tempfile::tempdir().expect("workspace");
2042 let tmpdir = process_exec_tmpdir_probe(workspace.path(), None).await;
2043
2044 assert!(
2045 !tmpdir.is_empty(),
2046 "sandboxed child must receive a non-empty TMPDIR"
2047 );
2048 let tmpdir_path = std::path::PathBuf::from(&tmpdir);
2049 let canonical_tmpdir = std::fs::canonicalize(&tmpdir_path)
2050 .expect("workspace-local TMPDIR should canonicalize");
2051 let canonical_workspace =
2052 std::fs::canonicalize(workspace.path()).expect("workspace should canonicalize");
2053 assert!(
2054 canonical_tmpdir.starts_with(&canonical_workspace),
2055 "child TMPDIR {tmpdir:?} must live inside the workspace {:?}",
2056 workspace.path()
2057 );
2058 assert!(
2059 tmpdir_path.ends_with(".harn-tmp"),
2060 "child TMPDIR {tmpdir:?} must be the workspace-local .harn-tmp dir"
2061 );
2062 assert!(
2063 tmpdir_path.is_dir(),
2064 "the workspace-local TMPDIR must have been created on disk"
2065 );
2066 });
2067 }
2068
2069 #[cfg(unix)]
2070 #[test]
2071 fn process_exec_respects_caller_pinned_tmpdir() {
2072 run_host_async_test(|| async {
2073 let workspace = tempfile::tempdir().expect("workspace");
2074 let caller_tmp = workspace.path().join("caller-chosen");
2075 std::fs::create_dir_all(&caller_tmp).unwrap();
2076 let caller_env = VmValue::dict(crate::value::DictMap::from_iter([(
2077 crate::value::intern_key("TMPDIR"),
2078 VmValue::String(arcstr::ArcStr::from(
2079 caller_tmp.to_string_lossy().into_owned(),
2080 )),
2081 )]));
2082
2083 let tmpdir = process_exec_tmpdir_probe(workspace.path(), Some(caller_env)).await;
2084
2085 assert_eq!(
2086 std::path::PathBuf::from(&tmpdir),
2087 caller_tmp,
2088 "an explicit caller TMPDIR must override the workspace-local default"
2089 );
2090 });
2091 }
2092
2093 #[test]
2094 fn host_tool_list_is_empty_without_bridge() {
2095 run_host_async_test(|| async {
2096 clear_host_call_bridge();
2097 let tools = dispatch_host_tool_list().await.expect("tool list");
2098 let VmValue::List(items) = tools else {
2099 panic!("expected tool list");
2100 };
2101 assert!(items.is_empty());
2102 });
2103 }
2104}