Skip to main content

harn_vm/stdlib/
host.rs

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