Skip to main content

harn_vm/stdlib/
host.rs

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