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