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