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
474pub(crate) fn host_operation_is_registered(capability: &str, operation: &str) -> bool {
475    known_host_operations()
476        .iter()
477        .any(|(known_capability, known_operation)| {
478            known_capability == capability && known_operation == operation
479        })
480}
481
482fn closest_host_operation(capability: &str, operation: &str) -> Option<(String, String)> {
483    let requested = format!("{capability}.{operation}");
484    known_host_operations()
485        .into_iter()
486        .map(|(candidate_capability, candidate_operation)| {
487            let candidate = format!("{candidate_capability}.{candidate_operation}");
488            let distance = strsim::levenshtein(&requested, &candidate);
489            (distance, candidate_capability, candidate_operation)
490        })
491        .filter(|(distance, _, _)| *distance <= 4)
492        .min_by_key(|(distance, _, _)| *distance)
493        .map(|(_, candidate_capability, candidate_operation)| {
494            (candidate_capability, candidate_operation)
495        })
496}
497
498fn validate_host_mock_registration(host_mock: &HostMock) -> Result<(), VmError> {
499    if host_mock.unregistered_ok
500        || host_operation_is_registered(&host_mock.capability, &host_mock.operation)
501    {
502        return Ok(());
503    }
504
505    let mut message = format!(
506        "host_mock: unregistered host operation {}.{}; register the capability/operation on \
507         the host or pass {{unregistered_ok: true}} for a test-local mock",
508        host_mock.capability, host_mock.operation
509    );
510    if let Some((capability, operation)) =
511        closest_host_operation(&host_mock.capability, &host_mock.operation)
512    {
513        message.push_str(&format!(". Did you mean {capability}.{operation}?"));
514    }
515    Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
516        message,
517    ))))
518}
519
520fn op(name: &str, description: &str) -> (String, VmValue) {
521    let mut entry = crate::value::DictMap::new();
522    entry.put_str("description", description);
523    (name.to_string(), VmValue::dict(entry))
524}
525
526fn capability(description: &str, ops: &[(String, VmValue)]) -> VmValue {
527    let mut entry = crate::value::DictMap::new();
528    entry.put_str("description", description);
529    entry.insert(
530        crate::value::intern_key("ops"),
531        VmValue::List(std::sync::Arc::new(
532            ops.iter()
533                .map(|(name, _)| VmValue::String(arcstr::ArcStr::from(name.as_str())))
534                .collect(),
535        )),
536    );
537    let mut op_dict = crate::value::DictMap::new();
538    for (name, op) in ops {
539        op_dict.insert(crate::value::intern_key(name), op.clone());
540    }
541    entry.insert(
542        crate::value::intern_key("operations"),
543        VmValue::dict(op_dict),
544    );
545    VmValue::dict(entry)
546}
547
548pub(crate) fn require_param(params: &crate::value::DictMap, key: &str) -> Result<String, VmError> {
549    params
550        .get(key)
551        .map(|v| v.display())
552        .filter(|v| !v.is_empty())
553        .ok_or_else(|| {
554            VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
555                "host_call: missing required parameter '{key}'"
556            ))))
557        })
558}
559
560fn render_template(
561    path: &str,
562    bindings: Option<&crate::value::DictMap>,
563) -> Result<String, VmError> {
564    let asset = crate::stdlib::template::TemplateAsset::render_target(path).map_err(|msg| {
565        VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
566            "host_call template.render: {msg}"
567        ))))
568    })?;
569    crate::stdlib::template::render_asset_result(&asset, bindings).map_err(VmError::from)
570}
571
572fn params_match(expected: Option<&crate::value::DictMap>, actual: &crate::value::DictMap) -> bool {
573    let Some(expected) = expected else {
574        return true;
575    };
576    expected.iter().all(|(key, value)| {
577        actual
578            .get(key)
579            .is_some_and(|candidate| values_equal(candidate, value))
580    })
581}
582
583fn parse_host_mock(args: &[VmValue]) -> Result<HostMock, VmError> {
584    let capability = args
585        .first()
586        .map(|value| value.display())
587        .unwrap_or_default();
588    let operation = args.get(1).map(|value| value.display()).unwrap_or_default();
589    if capability.is_empty() || operation.is_empty() {
590        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
591            "host_mock: capability and operation are required",
592        ))));
593    }
594
595    let mut params = args
596        .get(3)
597        .and_then(|value| value.as_dict())
598        .map(|dict| (*dict).clone());
599    let mut result = args.get(2).cloned().or(Some(VmValue::Nil));
600    let mut error = None;
601    let mut unregistered_ok = false;
602
603    if let Some(config) = args.get(2).and_then(|value| value.as_dict()) {
604        if config.contains_key("result")
605            || config.contains_key("params")
606            || config.contains_key("error")
607            || config.contains_key("unregistered_ok")
608        {
609            params = config
610                .get("params")
611                .and_then(|value| value.as_dict())
612                .map(|dict| (*dict).clone());
613            result = config.get("result").cloned();
614            error = config
615                .get("error")
616                .map(|value| value.display())
617                .filter(|value| !value.is_empty());
618            unregistered_ok = matches!(config.get("unregistered_ok"), Some(VmValue::Bool(true)));
619        }
620    }
621
622    Ok(HostMock {
623        capability,
624        operation,
625        params,
626        result,
627        error,
628        unregistered_ok,
629    })
630}
631
632fn push_host_mock(host_mock: HostMock) {
633    HOST_MOCKS.with(|mocks| mocks.borrow_mut().push(host_mock));
634}
635
636fn mock_call_value(call: &HostMockCall) -> VmValue {
637    let mut item = crate::value::DictMap::new();
638    item.put_str("capability", call.capability.clone());
639    item.put_str("operation", call.operation.clone());
640    item.insert(
641        crate::value::intern_key("params"),
642        VmValue::dict(call.params.clone()),
643    );
644    VmValue::dict(item)
645}
646
647fn record_mock_call(capability: &str, operation: &str, params: &crate::value::DictMap) {
648    HOST_MOCK_CALLS.with(|calls| {
649        calls.borrow_mut().push(HostMockCall {
650            capability: capability.to_string(),
651            operation: operation.to_string(),
652            params: params.clone(),
653        });
654    });
655}
656
657pub(crate) fn dispatch_mock_host_call(
658    capability: &str,
659    operation: &str,
660    params: &crate::value::DictMap,
661) -> Option<Result<VmValue, VmError>> {
662    let matched = HOST_MOCKS.with(|mocks| {
663        mocks
664            .borrow()
665            .iter()
666            .rev()
667            .find(|host_mock| {
668                host_mock.capability == capability
669                    && host_mock.operation == operation
670                    && params_match(host_mock.params.as_ref(), params)
671            })
672            .cloned()
673    })?;
674
675    record_mock_call(capability, operation, params);
676    if let Some(error) = matched.error {
677        return Some(Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
678            error,
679        )))));
680    }
681    Some(Ok(matched.result.unwrap_or(VmValue::Nil)))
682}
683
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(ctx) = ctx {
812        let vm = ctx.child_vm();
813        if let Some(fixtured) = vm.harness().and_then(|harness| {
814            harness
815                .inner()
816                .fixtures()
817                .dispatch_host(capability, operation, params)
818        }) {
819            return fixtured;
820        }
821    }
822    if let Some(mocked) = dispatch_mock_host_call(capability, operation, params) {
823        return mocked;
824    }
825
826    if (capability, operation) == ("process", "exec") {
827        let caller = serde_json::json!({
828            "surface": "host_call",
829            "capability": "process",
830            "operation": "exec",
831            "session_id": crate::llm::current_agent_session_id(),
832        });
833        return dispatch_process_exec_with_policy(ctx, params, caller).await;
834    }
835
836    // process.spawn is the non-blocking sibling of exec. Route it through the
837    // SAME command-policy preflight so deny-patterns/approval/sandbox gating
838    // are identical; only the completion semantics differ (returns a handle
839    // immediately instead of awaiting). poll/wait/kill/release are pure
840    // registry operations on an already-gated spawn, so they bypass the
841    // command policy.
842    if (capability, operation) == ("process", "spawn") {
843        let caller = serde_json::json!({
844            "surface": "host_call",
845            "capability": "process",
846            "operation": "spawn",
847            "session_id": crate::llm::current_agent_session_id(),
848        });
849        return dispatch_process_spawn_with_policy(ctx, params, caller).await;
850    }
851    if capability == "process" && matches!(operation, "poll" | "wait" | "kill" | "release") {
852        if let Some(result) = crate::stdlib::process_spawn::dispatch(
853            operation,
854            params,
855            async_builtin_cancel_token(ctx),
856        )
857        .await
858        {
859            return result;
860        }
861    }
862
863    let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
864    if let Some(bridge) = bridge {
865        // Serve turn-stable reads (e.g. `runtime.pipeline_input`) from the
866        // per-turn memo so context assembly pays one host round-trip per turn
867        // instead of once per shard. harn#5190.
868        let dispatched = turn_cache::cached_or(capability, operation, params, || {
869            bridge.dispatch(capability, operation, params)
870        })
871        .await?;
872        if let Some(value) = dispatched {
873            return Ok(value);
874        }
875    }
876
877    dispatch_builtin_host_operation(capability, operation, params).await
878}
879
880async fn dispatch_builtin_host_operation(
881    capability: &str,
882    operation: &str,
883    params: &crate::value::DictMap,
884) -> Result<VmValue, VmError> {
885    match (capability, operation) {
886        ("process", "list_shells") => Ok(crate::shells::list_shells_vm_value()),
887        ("process", "get_default_shell") => Ok(crate::shells::default_shell_vm_value()),
888        ("process", "set_default_shell") => crate::shells::set_default_shell_vm_value(params),
889        ("process", "shell_invocation") => crate::shells::shell_invocation_vm_value(params),
890        ("template", "render") => {
891            let path = require_param(params, "path")?;
892            let bindings = params.get("bindings").and_then(|v| v.as_dict());
893            Ok(VmValue::String(arcstr::ArcStr::from(render_template(
894                &path, bindings,
895            )?)))
896        }
897        ("interaction", "ask") => {
898            let question = require_param(params, "question")?;
899            super::io::prompt_user_value(&[VmValue::string(question)], &mut String::new())
900        }
901        ("project", "metadata_get") => crate::metadata::project_metadata_host_get(params),
902        ("project", "metadata_inspect") => crate::metadata::project_metadata_host_inspect(params),
903        ("project", "metadata_set") => crate::metadata::project_metadata_host_set(params),
904        ("project", "metadata_save") => crate::metadata::project_metadata_host_save(params),
905        ("project", "metadata_stale") => crate::metadata::project_metadata_host_stale(params),
906        ("project", "metadata_refresh_hashes") => {
907            crate::metadata::project_metadata_host_refresh_hashes(params)
908        }
909        // Standalone fallbacks for host-supplied capabilities. `HARN_TASK`
910        // backs `runtime.task` for debugger and CLI invocations.
911        ("runtime", "task") => Ok(VmValue::String(arcstr::ArcStr::from(
912            std::env::var("HARN_TASK").unwrap_or_default(),
913        ))),
914        ("runtime", "prompt_content") => Ok(VmValue::List(Arc::new(Vec::new()))),
915        ("runtime", "set_result") => {
916            // No-op when no host is attached; swallow silently so standalone
917            // scripts can still call `set_result` without crashing.
918            Ok(VmValue::Nil)
919        }
920        ("workspace", "project_root") => {
921            // Standalone fallback: prefer the typed execution project root,
922            // then the legacy env root, then the current working directory.
923            // Pipelines call this very early, so crashing here would block any
924            // debug-launched script.
925            let path = crate::stdlib::process::project_root_path()
926                .map(|root| root.display().to_string())
927                .or_else(|| std::env::var("HARN_PROJECT_ROOT").ok())
928                .unwrap_or_else(|| {
929                    std::env::current_dir()
930                        .map(|p| p.display().to_string())
931                        .unwrap_or_default()
932                });
933            Ok(VmValue::String(arcstr::ArcStr::from(path)))
934        }
935        ("workspace", "cwd") => {
936            let path = std::env::current_dir()
937                .map(|p| p.display().to_string())
938                .unwrap_or_default();
939            Ok(VmValue::String(arcstr::ArcStr::from(path)))
940        }
941        _ => Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
942            format!("host_call: unsupported operation {capability}.{operation}"),
943        )))),
944    }
945}
946
947pub(crate) fn optional_i64(params: &crate::value::DictMap, key: &str) -> Option<i64> {
948    match params.get(key) {
949        Some(VmValue::Int(value)) => Some(*value),
950        Some(VmValue::Float(value)) if value.fract() == 0.0 => Some(*value as i64),
951        _ => None,
952    }
953}
954
955pub(crate) fn optional_string(params: &crate::value::DictMap, key: &str) -> Option<String> {
956    params.get(key).and_then(vm_string).map(ToString::to_string)
957}
958
959fn optional_string_list(params: &crate::value::DictMap, key: &str) -> Option<Vec<String>> {
960    let VmValue::List(values) = params.get(key)? else {
961        return None;
962    };
963    values
964        .iter()
965        .map(|value| vm_string(value).map(ToString::to_string))
966        .collect()
967}
968
969fn optional_string_dict(
970    params: &crate::value::DictMap,
971    key: &str,
972) -> Result<Option<BTreeMap<String, String>>, VmError> {
973    let Some(value) = params.get(key) else {
974        return Ok(None);
975    };
976    let Some(dict) = value.as_dict() else {
977        return Err(VmError::Runtime(format!(
978            "host_call process.exec {key} must be a dict"
979        )));
980    };
981    let mut out = std::collections::BTreeMap::new();
982    for (key, value) in dict.iter() {
983        let Some(value) = vm_string(value) else {
984            return Err(VmError::Runtime(format!(
985                "host_call process.exec env value for {key:?} must be a string"
986            )));
987        };
988        out.insert(key.to_string(), value.to_string());
989    }
990    Ok(Some(out))
991}
992
993fn vm_string(value: &VmValue) -> Option<&str> {
994    match value {
995        VmValue::String(value) => Some(value.as_ref()),
996        _ => None,
997    }
998}
999
1000pub(crate) fn register_host_builtins(vm: &mut Vm) {
1001    for def in MODULE_BUILTINS {
1002        vm.register_builtin_def(def);
1003    }
1004}
1005
1006pub(crate) fn register_missing_host_builtins(vm: &mut Vm) {
1007    for def in MODULE_BUILTINS {
1008        if vm.builtin_metadata_for(def.sig.name).is_none() {
1009            vm.register_builtin_def(def);
1010        }
1011    }
1012}
1013
1014#[harn_builtin(
1015    exposure = "privileged_wire",
1016    effects = ["host.mutate@arg0"],
1017    sig = "host_mock(capability: string, op: string, response_or_config?: any, params?: dict) -> nil",
1018    category = "host"
1019)]
1020fn host_mock_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1021    let host_mock = parse_host_mock(args)?;
1022    validate_host_mock_registration(&host_mock)?;
1023    push_host_mock(host_mock);
1024    Ok(VmValue::Nil)
1025}
1026
1027#[harn_builtin(
1028    exposure = "privileged_wire",
1029    effects = [],
1030    sig = "host_mock_clear() -> nil", category = "host"
1031)]
1032fn host_mock_clear_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1033    reset_host_state();
1034    Ok(VmValue::Nil)
1035}
1036
1037#[harn_builtin(
1038    exposure = "runtime_internal",
1039    effects = [],
1040    sig = "host_mock_calls() -> list", category = "host"
1041)]
1042fn host_mock_calls_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1043    let calls = HOST_MOCK_CALLS.with(|calls| {
1044        calls
1045            .borrow()
1046            .iter()
1047            .map(mock_call_value)
1048            .collect::<Vec<_>>()
1049    });
1050    Ok(VmValue::List(std::sync::Arc::new(calls)))
1051}
1052
1053#[harn_builtin(
1054    exposure = "runtime_internal",
1055    effects = [],
1056    sig = "host_mock_push_scope() -> nil", category = "host"
1057)]
1058fn host_mock_push_scope_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1059    push_host_mock_scope();
1060    Ok(VmValue::Nil)
1061}
1062
1063#[harn_builtin(
1064    exposure = "runtime_internal",
1065    effects = [],
1066    sig = "host_mock_pop_scope() -> nil", category = "host"
1067)]
1068fn host_mock_pop_scope_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1069    if !pop_host_mock_scope() {
1070        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1071            "host_mock_pop_scope: no scope to pop",
1072        ))));
1073    }
1074    Ok(VmValue::Nil)
1075}
1076
1077#[harn_builtin(
1078    exposure = "runtime_internal",
1079    effects = [],
1080    sig = "host_capabilities() -> dict", category = "host"
1081)]
1082fn host_capabilities_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1083    Ok(capability_manifest_with_mocks())
1084}
1085
1086#[harn_builtin(
1087    exposure = "runtime_internal",
1088    effects = [],
1089    sig = "host_has(capability: string, op?: string) -> bool",
1090    category = "host"
1091)]
1092fn host_has_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1093    let capability = args.first().map(|a| a.display()).unwrap_or_default();
1094    let operation = args.get(1).map(|a| a.display());
1095    let manifest = capability_manifest_with_mocks();
1096    let has = manifest
1097        .as_dict()
1098        .and_then(|d| d.get(capability.as_str()))
1099        .and_then(|v| v.as_dict())
1100        .is_some_and(|cap| {
1101            if let Some(operation) = operation {
1102                cap.get("ops")
1103                    .and_then(|v| match v {
1104                        VmValue::List(list) => {
1105                            Some(list.iter().any(|item| item.display() == operation))
1106                        }
1107                        _ => None,
1108                    })
1109                    .unwrap_or(false)
1110            } else {
1111                true
1112            }
1113        });
1114    Ok(VmValue::Bool(has))
1115}
1116
1117#[harn_builtin(
1118    exposure = "privileged_wire",
1119    effects = [],
1120    sig = "host_call(name: string, args?: dict) -> any",
1121    kind = "async",
1122    category = "host"
1123)]
1124async fn host_call_builtin(
1125    ctx: crate::vm::AsyncBuiltinCtx,
1126    args: Vec<VmValue>,
1127) -> Result<VmValue, VmError> {
1128    let name = args.first().map(|a| a.display()).unwrap_or_default();
1129    let params = args
1130        .get(1)
1131        .and_then(|a| a.as_dict())
1132        .cloned()
1133        .unwrap_or_default();
1134    let Some((capability, operation)) = name.split_once('.') else {
1135        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1136            format!("host_call: unsupported operation name '{name}'"),
1137        ))));
1138    };
1139    dispatch_host_operation_with_ctx(Some(&ctx), capability, operation, &params).await
1140}
1141
1142#[harn_builtin(
1143    exposure = "runtime_internal",
1144    effects = [],
1145    sig = "host_tool_list() -> list", kind = "async", category = "host"
1146)]
1147async fn host_tool_list_builtin(
1148    ctx: crate::vm::AsyncBuiltinCtx,
1149    _args: Vec<VmValue>,
1150) -> Result<VmValue, VmError> {
1151    dispatch_host_tool_list_with_ctx(Some(&ctx)).await
1152}
1153
1154#[harn_builtin(
1155    exposure = "runtime_internal",
1156    effects = [],
1157    sig = "host_tool_call(name: string, args?: any) -> any",
1158    kind = "async",
1159    category = "host"
1160)]
1161async fn host_tool_call_builtin(
1162    ctx: crate::vm::AsyncBuiltinCtx,
1163    args: Vec<VmValue>,
1164) -> Result<VmValue, VmError> {
1165    let name = args.first().map(|a| a.display()).unwrap_or_default();
1166    if name.is_empty() {
1167        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1168            "host_tool_call: tool name is required",
1169        ))));
1170    }
1171    let call_args = args.get(1).cloned().unwrap_or(VmValue::Nil);
1172    dispatch_host_tool_call_with_ctx(Some(&ctx), &name, &call_args).await
1173}
1174
1175#[cfg(test)]
1176mod tests {
1177    use super::process_exec::resolve_process_exec_cwd;
1178    use super::{
1179        build_sandboxed_command, capability_manifest_with_mocks, clear_host_call_bridge,
1180        dispatch_host_operation, dispatch_host_tool_call, dispatch_host_tool_list,
1181        dispatch_mock_host_call, dispatch_mock_hostlib_call, host_call_ready, host_has_builtin,
1182        host_mock_clear_builtin, parse_host_mock, push_host_mock, register_mockable_host_operation,
1183        register_scoped_mockable_host_operation, reset_host_state, reset_scoped_host_state,
1184        set_host_call_bridge, validate_host_mock_registration, HostCallBridge,
1185        HostCallDispatchFuture, HostMock,
1186    };
1187    use crate::value::VmDictExt;
1188
1189    use std::sync::{
1190        atomic::{AtomicUsize, Ordering},
1191        Arc,
1192    };
1193
1194    use crate::value::{VmError, VmValue};
1195
1196    /// Collect a built command's env mutations as `(name, Option<value>)`,
1197    /// where `None` marks a variable the command removes from the inherited
1198    /// environment.
1199    fn command_env(
1200        cmd: &tokio::process::Command,
1201    ) -> std::collections::BTreeMap<String, Option<String>> {
1202        cmd.as_std()
1203            .get_envs()
1204            .map(|(k, v)| {
1205                (
1206                    k.to_string_lossy().into_owned(),
1207                    v.map(|value| value.to_string_lossy().into_owned()),
1208                )
1209            })
1210            .collect()
1211    }
1212
1213    #[test]
1214    fn build_sandboxed_command_forces_deterministic_message_locale() {
1215        // A verify command spawned by a non-Anglosphere user whose *shell*
1216        // exports LC_ALL (inherited via the parent env, NOT pinned by the
1217        // caller's `env` dict) must still emit English diagnostics, or the
1218        // downstream English-keyed matchers (syntax repair, error grounding,
1219        // pass/fail classification) misfire. In merge mode the child inherits
1220        // the parent env implicitly, so the builder must issue an explicit
1221        // LC_ALL removal — observable here as a `(key, None)` mutation — and
1222        // pin LC_MESSAGES=C + DOTNET_CLI_UI_LANGUAGE=en. The caller pins no
1223        // locale key here, so the overlay engages.
1224        let mut params = crate::value::DictMap::new();
1225        params.put_str("mode", "argv");
1226        params.put(
1227            "argv",
1228            VmValue::List(Arc::new(vec![VmValue::string("/bin/true")])),
1229        );
1230        params.put_str("env_mode", "merge");
1231        let mut caller_env = crate::value::DictMap::new();
1232        // An innocuous caller env key that must NOT suppress the locale overlay.
1233        caller_env.put_str("CARGO_TARGET_DIR", "/tmp/target");
1234        params.put("env", VmValue::dict_map(caller_env));
1235
1236        let cmd = build_sandboxed_command(&params, "process.exec").expect("build command");
1237        let env = command_env(&cmd);
1238
1239        assert_eq!(
1240            env.get("LC_ALL"),
1241            Some(&None),
1242            "the builder must remove LC_ALL from the child so an inherited shell \
1243             value cannot override the forced LC_MESSAGES"
1244        );
1245        assert_eq!(
1246            env.get("LC_MESSAGES"),
1247            Some(&Some("C".to_string())),
1248            "LC_MESSAGES must be pinned to C for untranslated (English) tool output"
1249        );
1250        assert_eq!(
1251            env.get("DOTNET_CLI_UI_LANGUAGE"),
1252            Some(&Some("en".to_string())),
1253            ".NET ignores LC_* and needs its own UI-language override"
1254        );
1255    }
1256
1257    #[test]
1258    fn build_sandboxed_command_respects_a_caller_pinned_locale() {
1259        // A caller that explicitly pins the locale keys (or LC_ALL) wins over
1260        // the deterministic overlay — same caller-wins rule as TMPDIR.
1261        let mut params = crate::value::DictMap::new();
1262        params.put_str("mode", "argv");
1263        params.put(
1264            "argv",
1265            VmValue::List(Arc::new(vec![VmValue::string("/bin/true")])),
1266        );
1267        params.put_str("env_mode", "merge");
1268        let mut caller_env = crate::value::DictMap::new();
1269        caller_env.put_str("LC_ALL", "fr_FR.UTF-8");
1270        caller_env.put_str("LC_MESSAGES", "fr_FR.UTF-8");
1271        params.put("env", VmValue::dict_map(caller_env));
1272
1273        let cmd = build_sandboxed_command(&params, "process.exec").expect("build command");
1274        let env = command_env(&cmd);
1275
1276        assert_eq!(
1277            env.get("LC_ALL"),
1278            Some(&Some("fr_FR.UTF-8".to_string())),
1279            "a caller that pins LC_ALL keeps it — the overlay must not strip an explicit value"
1280        );
1281        assert_eq!(
1282            env.get("LC_MESSAGES"),
1283            Some(&Some("fr_FR.UTF-8".to_string())),
1284            "a caller-pinned LC_MESSAGES wins over the C overlay"
1285        );
1286    }
1287
1288    #[test]
1289    fn process_exec_relative_cwd_resolves_against_execution_root() {
1290        let dir = tempfile::tempdir().expect("tempdir");
1291        crate::stdlib::process::set_thread_execution_context(Some(
1292            crate::orchestration::RunExecutionRecord {
1293                cwd: Some(dir.path().to_string_lossy().into_owned()),
1294                source_dir: Some(dir.path().join("src").to_string_lossy().into_owned()),
1295                ..Default::default()
1296            },
1297        ));
1298
1299        assert_eq!(
1300            resolve_process_exec_cwd("subdir"),
1301            dir.path().join("subdir")
1302        );
1303
1304        crate::stdlib::process::set_thread_execution_context(None);
1305    }
1306
1307    #[test]
1308    fn workspace_project_root_fallback_prefers_execution_context_project_root() {
1309        run_host_async_test(|| async {
1310            let project = tempfile::tempdir().expect("project root");
1311            let cwd = tempfile::tempdir().expect("cwd");
1312            crate::stdlib::process::set_thread_execution_context(Some(
1313                crate::orchestration::RunExecutionRecord {
1314                    cwd: Some(cwd.path().to_string_lossy().into_owned()),
1315                    project_root: Some(project.path().to_string_lossy().into_owned()),
1316                    ..Default::default()
1317                },
1318            ));
1319
1320            let result =
1321                dispatch_host_operation("workspace", "project_root", &crate::value::DictMap::new())
1322                    .await
1323                    .expect("workspace.project_root result");
1324
1325            crate::stdlib::process::set_thread_execution_context(None);
1326            assert_eq!(result.display(), project.path().display().to_string());
1327        });
1328    }
1329
1330    #[test]
1331    fn manifest_includes_operation_metadata() {
1332        let manifest = capability_manifest_with_mocks();
1333        let process = manifest
1334            .as_dict()
1335            .and_then(|d| d.get("process"))
1336            .and_then(|v| v.as_dict())
1337            .expect("process capability");
1338        assert!(process.get("description").is_some());
1339        let operations = process
1340            .get("operations")
1341            .and_then(|v| v.as_dict())
1342            .expect("operations dict");
1343        assert!(operations.get("exec").is_some());
1344    }
1345
1346    #[test]
1347    fn mocked_capabilities_appear_in_manifest() {
1348        reset_host_state();
1349        push_host_mock(HostMock {
1350            capability: "project".to_string(),
1351            operation: "metadata_get".to_string(),
1352            params: None,
1353            result: Some(VmValue::dict(crate::value::DictMap::new())),
1354            error: None,
1355            unregistered_ok: false,
1356        });
1357        let manifest = capability_manifest_with_mocks();
1358        let project = manifest
1359            .as_dict()
1360            .and_then(|d| d.get("project"))
1361            .and_then(|v| v.as_dict())
1362            .expect("project capability");
1363        let operations = project
1364            .get("operations")
1365            .and_then(|v| v.as_dict())
1366            .expect("operations dict");
1367        assert!(operations.get("metadata_get").is_some());
1368        reset_host_state();
1369    }
1370
1371    #[test]
1372    fn mock_host_call_matches_partial_params_and_overrides_order() {
1373        reset_host_state();
1374        let mut exact_params = crate::value::DictMap::new();
1375        exact_params.put_str("namespace", "facts");
1376        push_host_mock(HostMock {
1377            capability: "project".to_string(),
1378            operation: "metadata_get".to_string(),
1379            params: None,
1380            result: Some(VmValue::String(arcstr::ArcStr::from("fallback"))),
1381            error: None,
1382            unregistered_ok: false,
1383        });
1384        push_host_mock(HostMock {
1385            capability: "project".to_string(),
1386            operation: "metadata_get".to_string(),
1387            params: Some(exact_params),
1388            result: Some(VmValue::String(arcstr::ArcStr::from("facts"))),
1389            error: None,
1390            unregistered_ok: false,
1391        });
1392
1393        let mut call_params = crate::value::DictMap::new();
1394        call_params.put_str("dir", "pkg");
1395        call_params.put_str("namespace", "facts");
1396        let exact = dispatch_mock_host_call("project", "metadata_get", &call_params)
1397            .expect("expected exact mock")
1398            .expect("exact mock should succeed");
1399        assert_eq!(exact.display(), "facts");
1400
1401        call_params.put_str("namespace", "classification");
1402        let fallback = dispatch_mock_host_call("project", "metadata_get", &call_params)
1403            .expect("expected fallback mock")
1404            .expect("fallback mock should succeed");
1405        assert_eq!(fallback.display(), "fallback");
1406        reset_host_state();
1407    }
1408
1409    #[test]
1410    fn mock_host_call_can_throw_errors() {
1411        reset_host_state();
1412        push_host_mock(HostMock {
1413            capability: "project".to_string(),
1414            operation: "metadata_get".to_string(),
1415            params: None,
1416            result: None,
1417            error: Some("boom".to_string()),
1418            unregistered_ok: false,
1419        });
1420        let params = crate::value::DictMap::new();
1421        let result = dispatch_mock_host_call("project", "metadata_get", &params)
1422            .expect("expected mock result");
1423        match result {
1424            Err(VmError::Thrown(VmValue::String(message))) => assert_eq!(message.as_str(), "boom"),
1425            other => panic!("unexpected result: {other:?}"),
1426        }
1427        reset_host_state();
1428    }
1429
1430    #[test]
1431    fn host_mock_registration_rejects_unknown_operations_by_default() {
1432        let host_mock = HostMock {
1433            capability: "runtime".to_string(),
1434            operation: "tas".to_string(),
1435            params: None,
1436            result: Some(VmValue::Nil),
1437            error: None,
1438            unregistered_ok: false,
1439        };
1440        let error = validate_host_mock_registration(&host_mock)
1441            .expect_err("unknown host operation should fail at registration");
1442        match error {
1443            VmError::Thrown(VmValue::String(message)) => {
1444                assert!(message.contains("runtime.tas"));
1445                assert!(message.contains("unregistered_ok"));
1446                assert!(message.contains("runtime.task"));
1447            }
1448            other => panic!("unexpected error: {other:?}"),
1449        }
1450    }
1451
1452    #[test]
1453    fn host_mock_registration_allows_explicit_test_local_operations() {
1454        let host_mock = HostMock {
1455            capability: "synthetic".to_string(),
1456            operation: "op".to_string(),
1457            params: None,
1458            result: Some(VmValue::Nil),
1459            error: None,
1460            unregistered_ok: true,
1461        };
1462        validate_host_mock_registration(&host_mock)
1463            .expect("explicit unregistered_ok should permit synthetic mocks");
1464    }
1465
1466    #[test]
1467    fn host_mock_registration_accepts_runtime_registered_operations() {
1468        register_mockable_host_operation(
1469            "code_index",
1470            "stats",
1471            "Hostlib schema-backed operation registered at runtime.",
1472        );
1473        let host_mock = HostMock {
1474            capability: "code_index".to_string(),
1475            operation: "stats".to_string(),
1476            params: None,
1477            result: Some(VmValue::Nil),
1478            error: None,
1479            unregistered_ok: false,
1480        };
1481        validate_host_mock_registration(&host_mock)
1482            .expect("registered hostlib operations should be mockable");
1483    }
1484
1485    #[test]
1486    fn clearing_live_mocks_preserves_scoped_manifest_declarations() {
1487        reset_scoped_host_state();
1488        register_scoped_mockable_host_operation(
1489            "scoped_clear_fixture",
1490            "answer",
1491            "Test-scoped manifest declaration.",
1492        );
1493        let host_mock = HostMock {
1494            capability: "scoped_clear_fixture".to_string(),
1495            operation: "answer".to_string(),
1496            params: None,
1497            result: Some(VmValue::Nil),
1498            error: None,
1499            unregistered_ok: false,
1500        };
1501
1502        validate_host_mock_registration(&host_mock).expect("scoped declaration is registered");
1503        host_mock_clear_builtin(&[], &mut String::new()).expect("clear live mocks");
1504        validate_host_mock_registration(&host_mock)
1505            .expect("clearing live mocks must preserve manifest declarations");
1506        reset_scoped_host_state();
1507    }
1508
1509    #[tokio::test]
1510    async fn declared_mockable_operation_is_not_reported_as_callable() {
1511        std::thread::spawn(|| {
1512            register_mockable_host_operation(
1513                "async_host_registration",
1514                "cross_thread",
1515                "Embedding operation registered before async worker migration.",
1516            );
1517        })
1518        .join()
1519        .expect("registration worker should finish");
1520
1521        std::thread::spawn(|| {
1522            let host_mock = HostMock {
1523                capability: "async_host_registration".to_string(),
1524                operation: "cross_thread".to_string(),
1525                params: None,
1526                result: Some(VmValue::Nil),
1527                error: None,
1528                unregistered_ok: false,
1529            };
1530            validate_host_mock_registration(&host_mock)
1531                .expect("process host registration should be visible after worker migration");
1532
1533            let typo = HostMock {
1534                operation: "cross_tread".to_string(),
1535                ..host_mock
1536            };
1537            validate_host_mock_registration(&typo)
1538                .expect_err("an undeclared operation should still fail closed");
1539        })
1540        .join()
1541        .expect("validation worker should finish");
1542
1543        assert!(matches!(
1544            host_has_builtin(
1545                &[
1546                    VmValue::string("async_host_registration"),
1547                    VmValue::string("cross_thread"),
1548                ],
1549                &mut String::new(),
1550            )
1551            .expect("host_has should succeed"),
1552            VmValue::Bool(false)
1553        ));
1554        dispatch_host_operation(
1555            "async_host_registration",
1556            "cross_thread",
1557            &crate::value::DictMap::new(),
1558        )
1559        .await
1560        .expect_err("an unmocked declaration must remain unsupported at dispatch");
1561    }
1562
1563    #[test]
1564    fn host_mock_parse_preserves_unregistered_ok_config() {
1565        let config = VmValue::dict(crate::value::DictMap::from_iter([
1566            (crate::value::intern_key("result"), VmValue::string("ok")),
1567            (
1568                crate::value::intern_key("unregistered_ok"),
1569                VmValue::Bool(true),
1570            ),
1571        ]));
1572        let host_mock =
1573            parse_host_mock(&[VmValue::string("synthetic"), VmValue::string("op"), config])
1574                .expect("parse host mock config");
1575        assert!(host_mock.unregistered_ok);
1576    }
1577
1578    #[test]
1579    fn hostlib_mock_dispatch_matches_module_method_and_params() {
1580        reset_host_state();
1581        let mut mock_params = crate::value::DictMap::new();
1582        mock_params.put(
1583            "argv",
1584            VmValue::List(Arc::new(vec![VmValue::string("echo")])),
1585        );
1586        push_host_mock(HostMock {
1587            capability: "tools".to_string(),
1588            operation: "run_command".to_string(),
1589            params: Some(mock_params),
1590            result: Some(VmValue::String(arcstr::ArcStr::from("direct"))),
1591            error: None,
1592            unregistered_ok: false,
1593        });
1594
1595        let mut call_params = crate::value::DictMap::new();
1596        call_params.put(
1597            "argv",
1598            VmValue::List(Arc::new(vec![VmValue::string("echo")])),
1599        );
1600        call_params.put_str("cwd", "/tmp/not-used");
1601        let value = dispatch_mock_hostlib_call("tools", "run_command", &call_params)
1602            .expect("expected hostlib mock")
1603            .expect("hostlib mock should succeed");
1604        assert_eq!(value.display(), "direct");
1605        reset_host_state();
1606    }
1607
1608    #[test]
1609    fn hostlib_run_command_falls_back_to_process_exec_mocks() {
1610        reset_host_state();
1611        let mut mock_params = crate::value::DictMap::new();
1612        mock_params.put(
1613            "argv",
1614            VmValue::List(Arc::new(vec![
1615                VmValue::string("cargo"),
1616                VmValue::string("test"),
1617            ])),
1618        );
1619        push_host_mock(HostMock {
1620            capability: "process".to_string(),
1621            operation: "exec".to_string(),
1622            params: Some(mock_params),
1623            result: Some(VmValue::String(arcstr::ArcStr::from("legacy"))),
1624            error: None,
1625            unregistered_ok: false,
1626        });
1627
1628        let mut call_params = crate::value::DictMap::new();
1629        call_params.put(
1630            "argv",
1631            VmValue::List(Arc::new(vec![
1632                VmValue::string("cargo"),
1633                VmValue::string("test"),
1634            ])),
1635        );
1636        call_params.put_str("cwd", "/tmp/not-used");
1637        let value = dispatch_mock_hostlib_call("tools", "run_command", &call_params)
1638            .expect("expected legacy process.exec mock")
1639            .expect("legacy mock should succeed");
1640        assert_eq!(value.display(), "legacy");
1641        reset_host_state();
1642    }
1643
1644    #[test]
1645    fn hostlib_run_command_prefers_exact_mock_over_process_exec_alias() {
1646        reset_host_state();
1647        let mut params = crate::value::DictMap::new();
1648        params.put(
1649            "argv",
1650            VmValue::List(Arc::new(vec![
1651                VmValue::string("npm"),
1652                VmValue::string("test"),
1653            ])),
1654        );
1655        push_host_mock(HostMock {
1656            capability: "process".to_string(),
1657            operation: "exec".to_string(),
1658            params: Some(params.clone()),
1659            result: Some(VmValue::String(arcstr::ArcStr::from("legacy"))),
1660            error: None,
1661            unregistered_ok: false,
1662        });
1663        push_host_mock(HostMock {
1664            capability: "tools".to_string(),
1665            operation: "run_command".to_string(),
1666            params: Some(params.clone()),
1667            result: Some(VmValue::String(arcstr::ArcStr::from("direct"))),
1668            error: None,
1669            unregistered_ok: false,
1670        });
1671
1672        let value = dispatch_mock_hostlib_call("tools", "run_command", &params)
1673            .expect("expected exact hostlib mock")
1674            .expect("exact mock should succeed");
1675        assert_eq!(value.display(), "direct");
1676        reset_host_state();
1677    }
1678
1679    #[derive(Default)]
1680    struct TestHostToolBridge;
1681
1682    impl HostCallBridge for TestHostToolBridge {
1683        fn dispatch<'a>(
1684            &'a self,
1685            _capability: &'a str,
1686            _operation: &'a str,
1687            _params: &'a crate::value::DictMap,
1688        ) -> HostCallDispatchFuture<'a> {
1689            host_call_ready(Ok(None))
1690        }
1691
1692        fn list_tools(&self) -> Result<Option<VmValue>, VmError> {
1693            let tool = VmValue::dict(crate::value::DictMap::from_iter([
1694                (
1695                    crate::value::intern_key("name"),
1696                    VmValue::String(arcstr::ArcStr::from("Read".to_string())),
1697                ),
1698                (
1699                    crate::value::intern_key("description"),
1700                    VmValue::String(arcstr::ArcStr::from(
1701                        "Read a file from the host".to_string(),
1702                    )),
1703                ),
1704                (
1705                    crate::value::intern_key("schema"),
1706                    VmValue::dict(crate::value::DictMap::from_iter([(
1707                        crate::value::intern_key("type"),
1708                        VmValue::String(arcstr::ArcStr::from("object".to_string())),
1709                    )])),
1710                ),
1711                (crate::value::intern_key("deprecated"), VmValue::Bool(false)),
1712            ]));
1713            Ok(Some(VmValue::List(std::sync::Arc::new(vec![tool]))))
1714        }
1715
1716        fn call_tool(&self, name: &str, args: &VmValue) -> Result<Option<VmValue>, VmError> {
1717            if name != "Read" {
1718                return Ok(None);
1719            }
1720            let path = args
1721                .as_dict()
1722                .and_then(|dict| dict.get("path"))
1723                .map(|value| value.display())
1724                .unwrap_or_default();
1725            Ok(Some(VmValue::String(arcstr::ArcStr::from(format!(
1726                "read:{path}"
1727            )))))
1728        }
1729    }
1730
1731    struct CountingProcessExecBridge {
1732        calls: Arc<AtomicUsize>,
1733    }
1734
1735    impl HostCallBridge for CountingProcessExecBridge {
1736        fn dispatch<'a>(
1737            &'a self,
1738            capability: &'a str,
1739            operation: &'a str,
1740            _params: &'a crate::value::DictMap,
1741        ) -> HostCallDispatchFuture<'a> {
1742            if (capability, operation) != ("process", "exec") {
1743                return host_call_ready(Ok(None));
1744            }
1745            self.calls.fetch_add(1, Ordering::SeqCst);
1746            host_call_ready(Ok(Some(VmValue::dict(crate::value::DictMap::from_iter([
1747                (
1748                    crate::value::intern_key("status"),
1749                    VmValue::String(arcstr::ArcStr::from("completed".to_string())),
1750                ),
1751                (crate::value::intern_key("exit_code"), VmValue::Int(0)),
1752                (crate::value::intern_key("success"), VmValue::Bool(true)),
1753            ])))))
1754        }
1755    }
1756
1757    fn run_host_async_test<F, Fut>(test: F)
1758    where
1759        F: FnOnce() -> Fut,
1760        Fut: std::future::Future<Output = ()>,
1761    {
1762        // Several of these install or clear a host-call bridge, which opens a
1763        // new turn and so bumps the process-global epoch. Hold the shared lock
1764        // so that cannot invalidate a `turn_cache` test's entry mid-assertion.
1765        let _guard = super::turn_cache::epoch_test_lock()
1766            .lock()
1767            .unwrap_or_else(|e| e.into_inner());
1768        let rt = tokio::runtime::Builder::new_current_thread()
1769            .enable_all()
1770            .build()
1771            .expect("runtime");
1772        rt.block_on(async {
1773            let local = tokio::task::LocalSet::new();
1774            local.run_until(test()).await;
1775        });
1776    }
1777
1778    #[test]
1779    fn host_tool_list_uses_installed_host_call_bridge() {
1780        run_host_async_test(|| async {
1781            reset_host_state();
1782            set_host_call_bridge(Arc::new(TestHostToolBridge));
1783            let tools = dispatch_host_tool_list().await.expect("tool list");
1784            clear_host_call_bridge();
1785
1786            let VmValue::List(items) = tools else {
1787                panic!("expected tool list");
1788            };
1789            assert_eq!(items.len(), 1);
1790            let tool = items[0].as_dict().expect("tool dict");
1791            assert_eq!(tool.get("name").unwrap().display(), "Read");
1792            assert_eq!(tool.get("deprecated").unwrap().display(), "false");
1793        });
1794    }
1795
1796    #[test]
1797    fn host_tool_call_uses_installed_host_call_bridge() {
1798        run_host_async_test(|| async {
1799            set_host_call_bridge(Arc::new(TestHostToolBridge));
1800            let args = VmValue::dict(crate::value::DictMap::from_iter([(
1801                crate::value::intern_key("path"),
1802                VmValue::String(arcstr::ArcStr::from("README.md".to_string())),
1803            )]));
1804            let value = dispatch_host_tool_call("Read", &args)
1805                .await
1806                .expect("tool call");
1807            clear_host_call_bridge();
1808            assert_eq!(value.display(), "read:README.md");
1809        });
1810    }
1811
1812    #[test]
1813    fn process_exec_bridge_is_gated_by_command_policy() {
1814        run_host_async_test(|| async {
1815            crate::orchestration::clear_command_policies();
1816            let calls = Arc::new(AtomicUsize::new(0));
1817            set_host_call_bridge(Arc::new(CountingProcessExecBridge {
1818                calls: calls.clone(),
1819            }));
1820            crate::orchestration::push_command_policy(crate::orchestration::CommandPolicy {
1821                tools: vec!["run".to_string()],
1822                workspace_roots: Vec::new(),
1823                default_shell_mode: "shell".to_string(),
1824                deny_patterns: vec!["cat *".to_string()],
1825                require_approval: Default::default(),
1826                deny_labels: Default::default(),
1827                pre: None,
1828                post: None,
1829                consent: None,
1830                allow_recursive: false,
1831            });
1832
1833            let result = dispatch_host_operation(
1834                "process",
1835                "exec",
1836                &crate::value::DictMap::from_iter([
1837                    (
1838                        crate::value::intern_key("mode"),
1839                        VmValue::String(arcstr::ArcStr::from("shell")),
1840                    ),
1841                    (
1842                        crate::value::intern_key("command"),
1843                        VmValue::String(arcstr::ArcStr::from("cat Cargo.toml")),
1844                    ),
1845                ]),
1846            )
1847            .await
1848            .expect("process.exec result");
1849
1850            crate::orchestration::clear_command_policies();
1851            clear_host_call_bridge();
1852
1853            assert_eq!(
1854                calls.load(Ordering::SeqCst),
1855                0,
1856                "blocked command must not reach host bridge"
1857            );
1858            let result = result.as_dict().expect("blocked result dict");
1859            assert_eq!(result.get("status").unwrap().display(), "blocked");
1860            assert!(
1861                result
1862                    .get("reason")
1863                    .map(VmValue::display)
1864                    .unwrap_or_default()
1865                    .contains("cat *"),
1866                "blocked result should name the matched policy pattern"
1867            );
1868        });
1869    }
1870
1871    #[cfg(unix)]
1872    async fn process_exec_env_probe(env: VmValue, env_mode: Option<&str>) -> (String, String) {
1873        // Run `sh -c 'printf "%s|%s" "$PARENT_VAR" "$CHILD_VAR"'` so we can
1874        // observe whether an inherited parent var survives alongside the
1875        // explicitly-provided child var. The parent var is set on this
1876        // process's environment immediately before the spawn.
1877        std::env::set_var("PARENT_VAR", "inherited");
1878        let mut params = crate::value::DictMap::from_iter([
1879            (
1880                crate::value::intern_key("mode"),
1881                VmValue::String(arcstr::ArcStr::from("argv")),
1882            ),
1883            (
1884                crate::value::intern_key("argv"),
1885                VmValue::List(std::sync::Arc::new(vec![
1886                    // Absolute path so the spawn does not depend on PATH,
1887                    // which the `replace` case intentionally clears.
1888                    VmValue::String(arcstr::ArcStr::from("/bin/sh")),
1889                    VmValue::String(arcstr::ArcStr::from("-c")),
1890                    VmValue::String(arcstr::ArcStr::from(
1891                        "printf '%s|%s' \"$PARENT_VAR\" \"$CHILD_VAR\"",
1892                    )),
1893                ])),
1894            ),
1895            (crate::value::intern_key("env"), env),
1896        ]);
1897        if let Some(mode) = env_mode {
1898            params.put_str("env_mode", mode);
1899        }
1900        let result = super::dispatch_process_exec(&params, serde_json::Value::Null)
1901            .await
1902            .expect("process.exec result");
1903        let dict = result.as_dict().expect("result dict");
1904        let stdout = dict.get("stdout").map(VmValue::display).unwrap_or_default();
1905        std::env::remove_var("PARENT_VAR");
1906        let (parent, child) = stdout.split_once('|').unwrap_or((&stdout, ""));
1907        (parent.to_string(), child.to_string())
1908    }
1909
1910    #[cfg(unix)]
1911    #[test]
1912    fn process_exec_env_default_merges_with_parent() {
1913        run_host_async_test(|| async {
1914            // No `env_mode`: the provided key must be added WITHOUT clearing
1915            // the inherited parent environment (the env-clear footgun fix).
1916            let child_env = VmValue::dict(crate::value::DictMap::from_iter([(
1917                crate::value::intern_key("CHILD_VAR"),
1918                VmValue::String(arcstr::ArcStr::from("provided")),
1919            )]));
1920            let (parent, child) = process_exec_env_probe(child_env, None).await;
1921            assert_eq!(
1922                parent, "inherited",
1923                "default env_mode must inherit parent env"
1924            );
1925            assert_eq!(
1926                child, "provided",
1927                "default env_mode must apply provided keys"
1928            );
1929        });
1930    }
1931
1932    #[cfg(unix)]
1933    #[test]
1934    fn process_exec_env_mode_replace_clears_parent() {
1935        run_host_async_test(|| async {
1936            // Explicit `replace`: the inherited parent var must be gone and
1937            // only the provided key survives. This preserves the ability to
1938            // fully replace the environment when intentionally requested.
1939            let child_env = VmValue::dict(crate::value::DictMap::from_iter([(
1940                crate::value::intern_key("CHILD_VAR"),
1941                VmValue::String(arcstr::ArcStr::from("provided")),
1942            )]));
1943            let (parent, child) = process_exec_env_probe(child_env, Some("replace")).await;
1944            assert_eq!(parent, "", "explicit replace must clear parent env");
1945            assert_eq!(
1946                child, "provided",
1947                "explicit replace must keep provided keys"
1948            );
1949        });
1950    }
1951
1952    #[cfg(unix)]
1953    #[test]
1954    fn process_exec_env_mode_unknown_is_rejected() {
1955        run_host_async_test(|| async {
1956            let params = crate::value::DictMap::from_iter([
1957                (
1958                    crate::value::intern_key("mode"),
1959                    VmValue::String(arcstr::ArcStr::from("argv")),
1960                ),
1961                (
1962                    crate::value::intern_key("argv"),
1963                    VmValue::List(std::sync::Arc::new(vec![VmValue::String(
1964                        arcstr::ArcStr::from("true"),
1965                    )])),
1966                ),
1967                (
1968                    crate::value::intern_key("env"),
1969                    VmValue::dict(crate::value::DictMap::from_iter([(
1970                        crate::value::intern_key("CHILD_VAR"),
1971                        VmValue::String(arcstr::ArcStr::from("x")),
1972                    )])),
1973                ),
1974                (
1975                    crate::value::intern_key("env_mode"),
1976                    VmValue::String(arcstr::ArcStr::from("bogus")),
1977                ),
1978            ]);
1979            let err = super::dispatch_process_exec(&params, serde_json::Value::Null)
1980                .await
1981                .expect_err("unknown env_mode must error");
1982            assert!(
1983                format!("{err:?}").contains("env_mode"),
1984                "error should name env_mode, got {err:?}"
1985            );
1986        });
1987    }
1988
1989    // Drive the real `host_call("process","exec")` builder under a restricted
1990    // policy and read back the `$TMPDIR` the child actually saw. This is the
1991    // agent-facing path; the assertion is OS-independent (it observes the
1992    // injected env, not OS-sandbox enforcement), so it pins the mechanism on
1993    // every CI host while the live OS-level link proof runs on tornadough.
1994    #[cfg(unix)]
1995    async fn process_exec_tmpdir_probe(
1996        workspace: &std::path::Path,
1997        caller_env: Option<VmValue>,
1998    ) -> String {
1999        let mut env_pairs = vec![(
2000            crate::value::intern_key("mode"),
2001            VmValue::String(arcstr::ArcStr::from("argv")),
2002        )];
2003        env_pairs.push((
2004            crate::value::intern_key("argv"),
2005            VmValue::List(std::sync::Arc::new(vec![
2006                VmValue::String(arcstr::ArcStr::from("/bin/sh")),
2007                VmValue::String(arcstr::ArcStr::from("-c")),
2008                VmValue::String(arcstr::ArcStr::from("printf '%s' \"$TMPDIR\"")),
2009            ])),
2010        ));
2011        if let Some(env) = caller_env {
2012            env_pairs.push((crate::value::intern_key("env"), env));
2013        }
2014        let params = crate::value::DictMap::from_iter(env_pairs);
2015
2016        crate::orchestration::push_execution_policy(crate::orchestration::CapabilityPolicy {
2017            sandbox_profile: crate::orchestration::SandboxProfile::Worktree,
2018            workspace_roots: vec![workspace.to_string_lossy().into_owned()],
2019            // Keep OS confinement out of this unit assertion regardless of host
2020            // Landlock/seatbelt availability; we are pinning the env injection,
2021            // not OS enforcement (which the tornadough run proves end-to-end).
2022            ..crate::orchestration::CapabilityPolicy::default()
2023        });
2024        std::env::set_var("HARN_HANDLER_SANDBOX", "off");
2025        let result = super::dispatch_process_exec(&params, serde_json::Value::Null)
2026            .await
2027            .expect("process.exec result");
2028        std::env::remove_var("HARN_HANDLER_SANDBOX");
2029        crate::orchestration::pop_execution_policy();
2030        result
2031            .as_dict()
2032            .and_then(|d| d.get("stdout"))
2033            .map(VmValue::display)
2034            .unwrap_or_default()
2035    }
2036
2037    #[cfg(unix)]
2038    #[test]
2039    fn process_exec_injects_workspace_local_tmpdir() {
2040        run_host_async_test(|| async {
2041            let workspace = tempfile::tempdir().expect("workspace");
2042            let tmpdir = process_exec_tmpdir_probe(workspace.path(), None).await;
2043
2044            assert!(
2045                !tmpdir.is_empty(),
2046                "sandboxed child must receive a non-empty TMPDIR"
2047            );
2048            let tmpdir_path = std::path::PathBuf::from(&tmpdir);
2049            let canonical_tmpdir = std::fs::canonicalize(&tmpdir_path)
2050                .expect("workspace-local TMPDIR should canonicalize");
2051            let canonical_workspace =
2052                std::fs::canonicalize(workspace.path()).expect("workspace should canonicalize");
2053            assert!(
2054                canonical_tmpdir.starts_with(&canonical_workspace),
2055                "child TMPDIR {tmpdir:?} must live inside the workspace {:?}",
2056                workspace.path()
2057            );
2058            assert!(
2059                tmpdir_path.ends_with(".harn-tmp"),
2060                "child TMPDIR {tmpdir:?} must be the workspace-local .harn-tmp dir"
2061            );
2062            assert!(
2063                tmpdir_path.is_dir(),
2064                "the workspace-local TMPDIR must have been created on disk"
2065            );
2066        });
2067    }
2068
2069    #[cfg(unix)]
2070    #[test]
2071    fn process_exec_respects_caller_pinned_tmpdir() {
2072        run_host_async_test(|| async {
2073            let workspace = tempfile::tempdir().expect("workspace");
2074            let caller_tmp = workspace.path().join("caller-chosen");
2075            std::fs::create_dir_all(&caller_tmp).unwrap();
2076            let caller_env = VmValue::dict(crate::value::DictMap::from_iter([(
2077                crate::value::intern_key("TMPDIR"),
2078                VmValue::String(arcstr::ArcStr::from(
2079                    caller_tmp.to_string_lossy().into_owned(),
2080                )),
2081            )]));
2082
2083            let tmpdir = process_exec_tmpdir_probe(workspace.path(), Some(caller_env)).await;
2084
2085            assert_eq!(
2086                std::path::PathBuf::from(&tmpdir),
2087                caller_tmp,
2088                "an explicit caller TMPDIR must override the workspace-local default"
2089            );
2090        });
2091    }
2092
2093    #[test]
2094    fn host_tool_list_is_empty_without_bridge() {
2095        run_host_async_test(|| async {
2096            clear_host_call_bridge();
2097            let tools = dispatch_host_tool_list().await.expect("tool list");
2098            let VmValue::List(items) = tools else {
2099                panic!("expected tool list");
2100            };
2101            assert!(items.is_empty());
2102        });
2103    }
2104}