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;
5use std::time::Instant;
6
7use serde_json::Value as JsonValue;
8use tokio::io::AsyncReadExt;
9
10use crate::stdlib::macros::{harn_builtin, VmBuiltinDef};
11use crate::value::{values_equal, VmError, VmValue};
12use crate::vm::{AsyncBuiltinCtx, Vm};
13
14mod operation_registry;
15
16/// Audited wrapper for `chrono::Utc::now().to_rfc3339()`. Routes through
17/// the testbench leak audit so a paused-clock session can surface every
18/// host capability that observed real wall-clock time.
19pub(crate) fn audited_utc_now_rfc3339(capability_id: &'static str) -> String {
20    let dt: chrono::DateTime<chrono::Utc> =
21        crate::clock_mock::leak_audit::wall_now(capability_id).into();
22    dt.to_rfc3339()
23}
24
25pub(crate) const MODULE_BUILTINS: &[&VmBuiltinDef] = &[
26    &HOST_MOCK_BUILTIN_DEF,
27    &HOST_MOCK_CLEAR_BUILTIN_DEF,
28    &HOST_MOCK_CALLS_BUILTIN_DEF,
29    &HOST_MOCK_PUSH_SCOPE_BUILTIN_DEF,
30    &HOST_MOCK_POP_SCOPE_BUILTIN_DEF,
31    &HOST_CAPABILITIES_BUILTIN_DEF,
32    &HOST_HAS_BUILTIN_DEF,
33    &HOST_CALL_BUILTIN_DEF,
34    &HOST_TOOL_LIST_BUILTIN_DEF,
35    &HOST_TOOL_CALL_BUILTIN_DEF,
36];
37
38#[derive(Clone)]
39struct HostMock {
40    capability: String,
41    operation: String,
42    params: Option<crate::value::DictMap>,
43    result: Option<VmValue>,
44    error: Option<String>,
45    unregistered_ok: bool,
46}
47
48#[derive(Clone)]
49struct HostMockCall {
50    capability: String,
51    operation: String,
52    params: crate::value::DictMap,
53}
54
55thread_local! {
56    static HOST_MOCKS: RefCell<Vec<HostMock>> = const { RefCell::new(Vec::new()) };
57    static HOST_MOCK_CALLS: RefCell<Vec<HostMockCall>> = const { RefCell::new(Vec::new()) };
58    static HOST_MOCK_SCOPES: RefCell<Vec<(Vec<HostMock>, Vec<HostMockCall>)>> =
59        const { RefCell::new(Vec::new()) };
60}
61
62pub(crate) fn reset_host_state() {
63    HOST_MOCKS.with(|mocks| mocks.borrow_mut().clear());
64    HOST_MOCK_CALLS.with(|calls| calls.borrow_mut().clear());
65    HOST_MOCK_SCOPES.with(|scopes| scopes.borrow_mut().clear());
66}
67
68pub(crate) fn reset_scoped_host_state() {
69    operation_registry::clear_scoped_mockable();
70}
71
72/// Push the current host-mock state onto an internal stack and start a
73/// fresh empty scope. Paired with `pop_host_mock_scope`. Used by the
74/// `with_host_mocks` helper in `std/testing` to give tests automatic
75/// cleanup, including when the body throws.
76fn push_host_mock_scope() {
77    let mocks = HOST_MOCKS.with(|v| std::mem::take(&mut *v.borrow_mut()));
78    let calls = HOST_MOCK_CALLS.with(|v| std::mem::take(&mut *v.borrow_mut()));
79    HOST_MOCK_SCOPES.with(|v| v.borrow_mut().push((mocks, calls)));
80}
81
82/// Restore the most recently pushed host-mock state, replacing any
83/// mocks or recorded calls accumulated inside the scope. Returns
84/// `false` if there is no saved scope to pop, so callers can surface a
85/// clear "imbalanced scope" error rather than silently no-op'ing.
86fn pop_host_mock_scope() -> bool {
87    let entry = HOST_MOCK_SCOPES.with(|v| v.borrow_mut().pop());
88    match entry {
89        Some((mocks, calls)) => {
90            HOST_MOCKS.with(|v| *v.borrow_mut() = mocks);
91            HOST_MOCK_CALLS.with(|v| *v.borrow_mut() = calls);
92            true
93        }
94        None => false,
95    }
96}
97
98fn async_builtin_cancel_token(
99    ctx: Option<&AsyncBuiltinCtx>,
100) -> Option<std::sync::Arc<std::sync::atomic::AtomicBool>> {
101    ctx.and_then(|ctx| ctx.child_vm().cancel_token.clone())
102}
103
104fn capability_manifest_map() -> crate::value::DictMap {
105    let mut root = crate::value::DictMap::new();
106    root.insert(
107        crate::value::intern_key("process"),
108        capability(
109            "Process execution.",
110            &[
111                op("exec", "Execute a process in argv or shell mode."),
112                op(
113                    "spawn",
114                    "Spawn a process non-blocking; returns a handle immediately for poll/wait/kill.",
115                ),
116                op(
117                    "poll",
118                    "Non-blocking snapshot of a spawned process: status, captured stdout/stderr.",
119                ),
120                op(
121                    "wait",
122                    "Await a spawned process to completion (optional timeout_ms); returns final result.",
123                ),
124                op(
125                    "kill",
126                    "Terminate a spawned process by handle and await the status transition.",
127                ),
128                op(
129                    "release",
130                    "Release a spawned-process handle and free its retained output.",
131                ),
132                op("list_shells", "List shells discovered by the host/session."),
133                op(
134                    "get_default_shell",
135                    "Return the selected default shell for this host/session.",
136                ),
137                op(
138                    "set_default_shell",
139                    "Select the default shell for this host/session.",
140                ),
141                op(
142                    "shell_invocation",
143                    "Resolve shell selection and login/interactive flags into argv.",
144                ),
145            ],
146        ),
147    );
148    root.insert(
149        crate::value::intern_key("template"),
150        capability(
151            "Template rendering.",
152            &[op("render", "Render a template file.")],
153        ),
154    );
155    root.insert(
156        crate::value::intern_key("interaction"),
157        capability(
158            "User interaction.",
159            &[op("ask", "Ask the user a question.")],
160        ),
161    );
162    root.insert(
163        crate::value::intern_key("memory"),
164        capability(
165            "Vector-aware memory: host-provided embeddings.",
166            &[op(
167                "embed",
168                "Embed text for semantic recall. Params: {text, model_hint?}. \
169                 Returns {vector: list<float>, model: string, dim: int}.",
170            )],
171        ),
172    );
173    root.insert(
174        crate::value::intern_key("project"),
175        capability(
176            "Project metadata and durable project facts.",
177            &[
178                op("metadata_get", "Read project metadata."),
179                op("metadata_inspect", "Inspect project metadata provenance."),
180                op("metadata_set", "Write project metadata."),
181                op("metadata_save", "Persist pending project metadata changes."),
182                op("metadata_stale", "Check whether project metadata is stale."),
183                op(
184                    "metadata_refresh_hashes",
185                    "Refresh project metadata content hashes.",
186                ),
187            ],
188        ),
189    );
190    root.insert(
191        crate::value::intern_key("runtime"),
192        capability(
193            "Runtime task context and run metadata supplied by the active host.",
194            &[
195                op("task", "Read the current runtime task."),
196                op("pipeline_input", "Read the active pipeline input payload."),
197                op("dry_run", "Read whether the runtime is in dry-run mode."),
198                op("approved_plan", "Read the approved plan text."),
199                op("record_run", "Record run metadata with the host."),
200                op("set_result", "Write the runtime result payload."),
201            ],
202        ),
203    );
204    root.insert(
205        crate::value::intern_key("workspace"),
206        capability(
207            "Workspace facts and file access supplied by the active host.",
208            &[
209                op("project_root", "Return the active project root."),
210                op("cwd", "Return the active current working directory."),
211                op("read_text", "Read a workspace text file."),
212                op("list", "List workspace files or directories."),
213                op("exists", "Check whether a workspace path exists."),
214            ],
215        ),
216    );
217    root.insert(
218        crate::value::intern_key("oauth_storage"),
219        capability(
220            "Host-managed OAuth token storage.",
221            &[
222                op("cloud_get", "Read a cloud-managed token set."),
223                op("cloud_set", "Write a cloud-managed token set."),
224                op("cloud_delete", "Delete a cloud-managed token set."),
225                op(
226                    "cloud_acquire_refresh_lock",
227                    "Acquire an OAuth refresh lock.",
228                ),
229                op(
230                    "cloud_release_refresh_lock",
231                    "Release an OAuth refresh lock.",
232                ),
233            ],
234        ),
235    );
236    root.insert(
237        crate::value::intern_key("mcp"),
238        capability(
239            "MCP host interactions.",
240            &[op("elicit", "Ask the connected MCP client for input.")],
241        ),
242    );
243    root.insert(
244        crate::value::intern_key("hitl"),
245        capability(
246            "Human-in-the-loop host interactions.",
247            &[
248                op(
249                    "question",
250                    "Ask a human a question through the active host.",
251                ),
252                op(
253                    "approval",
254                    "Request a human approval through the active host.",
255                ),
256                op(
257                    "dual_control",
258                    "Request quorum approval from multiple human reviewers.",
259                ),
260                op(
261                    "escalation",
262                    "Escalate a task to a human role through the active host.",
263                ),
264            ],
265        ),
266    );
267    root
268}
269
270fn mocked_operation_entry() -> VmValue {
271    op(
272        "mocked",
273        "Mocked host operation registered at runtime for tests.",
274    )
275    .1
276}
277
278fn ensure_mocked_capability(
279    root: &mut crate::value::DictMap,
280    capability_name: &str,
281    operation_name: &str,
282) {
283    let Some(existing) = root.get(capability_name).cloned() else {
284        root.insert(
285            crate::value::intern_key(capability_name),
286            capability(
287                "Mocked host capability registered at runtime for tests.",
288                &[(operation_name.to_string(), mocked_operation_entry())],
289            ),
290        );
291        return;
292    };
293
294    let Some(existing_dict) = existing.as_dict() else {
295        return;
296    };
297    let mut entry = (*existing_dict).clone();
298    let mut ops = entry
299        .get("ops")
300        .and_then(|value| match value {
301            VmValue::List(list) => Some((**list).clone()),
302            _ => None,
303        })
304        .unwrap_or_default();
305    if !ops.iter().any(|value| value.display() == operation_name) {
306        ops.push(VmValue::String(arcstr::ArcStr::from(
307            operation_name.to_string(),
308        )));
309    }
310
311    let mut operations = entry
312        .get("operations")
313        .and_then(|value| value.as_dict())
314        .map(|dict| (*dict).clone())
315        .unwrap_or_default();
316    operations
317        .entry(crate::value::intern_key(operation_name))
318        .or_insert_with(mocked_operation_entry);
319
320    entry.insert(
321        crate::value::intern_key("ops"),
322        VmValue::List(std::sync::Arc::new(ops)),
323    );
324    entry.insert(
325        crate::value::intern_key("operations"),
326        VmValue::dict(operations),
327    );
328    root.insert(
329        crate::value::intern_key(capability_name),
330        VmValue::dict(entry),
331    );
332}
333
334fn ensure_registered_operation(
335    root: &mut crate::value::DictMap,
336    capability_name: &str,
337    operation_name: &str,
338    description: &str,
339) {
340    let operation = op(operation_name, description);
341    let Some(existing) = root.get(capability_name).cloned() else {
342        root.insert(
343            crate::value::intern_key(capability_name),
344            capability(description, &[operation]),
345        );
346        return;
347    };
348
349    let Some(existing_dict) = existing.as_dict() else {
350        return;
351    };
352    let mut entry = (*existing_dict).clone();
353    let mut ops = entry
354        .get("ops")
355        .and_then(|value| match value {
356            VmValue::List(list) => Some((**list).clone()),
357            _ => None,
358        })
359        .unwrap_or_default();
360    if !ops.iter().any(|value| value.display() == operation_name) {
361        ops.push(VmValue::String(arcstr::ArcStr::from(
362            operation_name.to_string(),
363        )));
364    }
365
366    let mut operations = entry
367        .get("operations")
368        .and_then(|value| value.as_dict())
369        .map(|dict| (*dict).clone())
370        .unwrap_or_default();
371    operations
372        .entry(crate::value::intern_key(operation_name))
373        .or_insert(operation.1);
374
375    entry.insert(
376        crate::value::intern_key("ops"),
377        VmValue::List(std::sync::Arc::new(ops)),
378    );
379    entry.insert(
380        crate::value::intern_key("operations"),
381        VmValue::dict(operations),
382    );
383    root.insert(
384        crate::value::intern_key(capability_name),
385        VmValue::dict(entry),
386    );
387}
388
389pub fn register_mockable_host_operation(
390    capability_name: impl AsRef<str>,
391    operation_name: impl AsRef<str>,
392    description: impl AsRef<str>,
393) {
394    operation_registry::register_mockable(capability_name, operation_name, description);
395}
396
397/// Register a mock-validation declaration scoped to the current test thread.
398pub fn register_scoped_mockable_host_operation(
399    capability_name: impl AsRef<str>,
400    operation_name: impl AsRef<str>,
401    description: impl AsRef<str>,
402) {
403    operation_registry::register_scoped_mockable(capability_name, operation_name, description);
404}
405
406pub fn register_callable_host_operation(
407    capability_name: impl AsRef<str>,
408    operation_name: impl AsRef<str>,
409    description: impl AsRef<str>,
410) {
411    operation_registry::register_callable(capability_name, operation_name, description);
412}
413
414fn apply_registered_operations(root: &mut crate::value::DictMap) {
415    operation_registry::apply_callable(root);
416}
417
418fn apply_mockable_operations(root: &mut crate::value::DictMap) {
419    operation_registry::apply_mockable(root);
420}
421
422fn capability_manifest_with_mocks() -> VmValue {
423    let mut root = capability_manifest_map();
424    apply_registered_operations(&mut root);
425    HOST_MOCKS.with(|mocks| {
426        for host_mock in mocks.borrow().iter() {
427            ensure_mocked_capability(&mut root, &host_mock.capability, &host_mock.operation);
428        }
429    });
430    VmValue::dict(root)
431}
432
433fn known_host_operations() -> Vec<(String, String)> {
434    let mut root = capability_manifest_map();
435    apply_registered_operations(&mut root);
436    apply_mockable_operations(&mut root);
437    root.into_iter()
438        .flat_map(|(capability_name, capability)| {
439            let capability_name = capability_name.to_string();
440            capability
441                .as_dict()
442                .and_then(|dict| dict.get("ops"))
443                .and_then(|value| match value {
444                    VmValue::List(list) => Some((**list).clone()),
445                    _ => None,
446                })
447                .unwrap_or_default()
448                .into_iter()
449                .map(move |operation| (capability_name.clone(), operation.display()))
450        })
451        .collect()
452}
453
454fn host_operation_is_registered(capability: &str, operation: &str) -> bool {
455    known_host_operations()
456        .iter()
457        .any(|(known_capability, known_operation)| {
458            known_capability == capability && known_operation == operation
459        })
460}
461
462fn edit_distance(a: &str, b: &str) -> usize {
463    let mut previous: Vec<usize> = (0..=b.chars().count()).collect();
464    let mut current = vec![0; previous.len()];
465    for (i, ca) in a.chars().enumerate() {
466        current[0] = i + 1;
467        for (j, cb) in b.chars().enumerate() {
468            let substitution = previous[j] + usize::from(ca != cb);
469            let insertion = current[j] + 1;
470            let deletion = previous[j + 1] + 1;
471            current[j + 1] = substitution.min(insertion).min(deletion);
472        }
473        std::mem::swap(&mut previous, &mut current);
474    }
475    previous[b.chars().count()]
476}
477
478fn closest_host_operation(capability: &str, operation: &str) -> Option<(String, String)> {
479    let requested = format!("{capability}.{operation}");
480    known_host_operations()
481        .into_iter()
482        .map(|(candidate_capability, candidate_operation)| {
483            let candidate = format!("{candidate_capability}.{candidate_operation}");
484            let distance = edit_distance(&requested, &candidate);
485            (distance, candidate_capability, candidate_operation)
486        })
487        .filter(|(distance, _, _)| *distance <= 4)
488        .min_by_key(|(distance, _, _)| *distance)
489        .map(|(_, candidate_capability, candidate_operation)| {
490            (candidate_capability, candidate_operation)
491        })
492}
493
494fn validate_host_mock_registration(host_mock: &HostMock) -> Result<(), VmError> {
495    if host_mock.unregistered_ok
496        || host_operation_is_registered(&host_mock.capability, &host_mock.operation)
497    {
498        return Ok(());
499    }
500
501    let mut message = format!(
502        "host_mock: unregistered host operation {}.{}; register the capability/operation on \
503         the host or pass {{unregistered_ok: true}} for a test-local mock",
504        host_mock.capability, host_mock.operation
505    );
506    if let Some((capability, operation)) =
507        closest_host_operation(&host_mock.capability, &host_mock.operation)
508    {
509        message.push_str(&format!(". Did you mean {capability}.{operation}?"));
510    }
511    Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
512        message,
513    ))))
514}
515
516fn op(name: &str, description: &str) -> (String, VmValue) {
517    let mut entry = crate::value::DictMap::new();
518    entry.put_str("description", description);
519    (name.to_string(), VmValue::dict(entry))
520}
521
522fn capability(description: &str, ops: &[(String, VmValue)]) -> VmValue {
523    let mut entry = crate::value::DictMap::new();
524    entry.put_str("description", description);
525    entry.insert(
526        crate::value::intern_key("ops"),
527        VmValue::List(std::sync::Arc::new(
528            ops.iter()
529                .map(|(name, _)| VmValue::String(arcstr::ArcStr::from(name.as_str())))
530                .collect(),
531        )),
532    );
533    let mut op_dict = crate::value::DictMap::new();
534    for (name, op) in ops {
535        op_dict.insert(crate::value::intern_key(name), op.clone());
536    }
537    entry.insert(
538        crate::value::intern_key("operations"),
539        VmValue::dict(op_dict),
540    );
541    VmValue::dict(entry)
542}
543
544pub(crate) fn require_param(params: &crate::value::DictMap, key: &str) -> Result<String, VmError> {
545    params
546        .get(key)
547        .map(|v| v.display())
548        .filter(|v| !v.is_empty())
549        .ok_or_else(|| {
550            VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
551                "host_call: missing required parameter '{key}'"
552            ))))
553        })
554}
555
556fn render_template(
557    path: &str,
558    bindings: Option<&crate::value::DictMap>,
559) -> Result<String, VmError> {
560    let asset = crate::stdlib::template::TemplateAsset::render_target(path).map_err(|msg| {
561        VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
562            "host_call template.render: {msg}"
563        ))))
564    })?;
565    crate::stdlib::template::render_asset_result(&asset, bindings).map_err(VmError::from)
566}
567
568fn params_match(expected: Option<&crate::value::DictMap>, actual: &crate::value::DictMap) -> bool {
569    let Some(expected) = expected else {
570        return true;
571    };
572    expected.iter().all(|(key, value)| {
573        actual
574            .get(key)
575            .is_some_and(|candidate| values_equal(candidate, value))
576    })
577}
578
579fn parse_host_mock(args: &[VmValue]) -> Result<HostMock, VmError> {
580    let capability = args
581        .first()
582        .map(|value| value.display())
583        .unwrap_or_default();
584    let operation = args.get(1).map(|value| value.display()).unwrap_or_default();
585    if capability.is_empty() || operation.is_empty() {
586        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
587            "host_mock: capability and operation are required",
588        ))));
589    }
590
591    let mut params = args
592        .get(3)
593        .and_then(|value| value.as_dict())
594        .map(|dict| (*dict).clone());
595    let mut result = args.get(2).cloned().or(Some(VmValue::Nil));
596    let mut error = None;
597    let mut unregistered_ok = false;
598
599    if let Some(config) = args.get(2).and_then(|value| value.as_dict()) {
600        if config.contains_key("result")
601            || config.contains_key("params")
602            || config.contains_key("error")
603            || config.contains_key("unregistered_ok")
604        {
605            params = config
606                .get("params")
607                .and_then(|value| value.as_dict())
608                .map(|dict| (*dict).clone());
609            result = config.get("result").cloned();
610            error = config
611                .get("error")
612                .map(|value| value.display())
613                .filter(|value| !value.is_empty());
614            unregistered_ok = matches!(config.get("unregistered_ok"), Some(VmValue::Bool(true)));
615        }
616    }
617
618    Ok(HostMock {
619        capability,
620        operation,
621        params,
622        result,
623        error,
624        unregistered_ok,
625    })
626}
627
628fn push_host_mock(host_mock: HostMock) {
629    HOST_MOCKS.with(|mocks| mocks.borrow_mut().push(host_mock));
630}
631
632fn mock_call_value(call: &HostMockCall) -> VmValue {
633    let mut item = crate::value::DictMap::new();
634    item.put_str("capability", call.capability.clone());
635    item.put_str("operation", call.operation.clone());
636    item.insert(
637        crate::value::intern_key("params"),
638        VmValue::dict(call.params.clone()),
639    );
640    VmValue::dict(item)
641}
642
643fn record_mock_call(capability: &str, operation: &str, params: &crate::value::DictMap) {
644    HOST_MOCK_CALLS.with(|calls| {
645        calls.borrow_mut().push(HostMockCall {
646            capability: capability.to_string(),
647            operation: operation.to_string(),
648            params: params.clone(),
649        });
650    });
651}
652
653pub(crate) fn dispatch_mock_host_call(
654    capability: &str,
655    operation: &str,
656    params: &crate::value::DictMap,
657) -> Option<Result<VmValue, VmError>> {
658    let matched = HOST_MOCKS.with(|mocks| {
659        mocks
660            .borrow()
661            .iter()
662            .rev()
663            .find(|host_mock| {
664                host_mock.capability == capability
665                    && host_mock.operation == operation
666                    && params_match(host_mock.params.as_ref(), params)
667            })
668            .cloned()
669    })?;
670
671    record_mock_call(capability, operation, params);
672    if let Some(error) = matched.error {
673        return Some(Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
674            error,
675        )))));
676    }
677    Some(Ok(matched.result.unwrap_or(VmValue::Nil)))
678}
679
680/// Dispatch a hostlib builtin through the same scoped mock registry used by
681/// `host_call`.
682///
683/// Hostlib builtins are addressed by their schema module/method pair, so a test
684/// can mock `hostlib_tools_run_command(...)` with
685/// `{capability: "tools", operation: "run_command", ...}`. During the
686/// `process.exec` -> hostlib `run_command` migration we also honor existing
687/// `{capability: "process", operation: "exec", ...}` command mocks after the
688/// canonical `tools.run_command` lookup, preserving last-write-wins within each
689/// mock lane and giving explicit hostlib mocks precedence.
690pub fn dispatch_mock_hostlib_call(
691    module: &str,
692    method: &str,
693    params: &crate::value::DictMap,
694) -> Option<Result<VmValue, VmError>> {
695    if let Some(mocked) = dispatch_mock_host_call(module, method, params) {
696        return Some(mocked);
697    }
698
699    if (module, method) == ("tools", "run_command") {
700        return dispatch_mock_host_call("process", "exec", params);
701    }
702
703    None
704}
705
706/// Embedder-supplied bridge for `host_call` ops.
707///
708/// Embedders (debug adapters, CLIs, IDE hosts) implement this trait to
709/// satisfy capability/operation pairs that harn-vm itself doesn't know how
710/// to handle. Returning `Ok(None)` means "I don't handle this op — fall
711/// through to the built-in fallbacks (env-derived defaults, then the
712/// `unsupported operation` error)". `Ok(Some(value))` is the result;
713/// `Err(VmError::Thrown(_))` surfaces as a Harn exception.
714///
715/// The trait is intentionally synchronous. Bridges that need async I/O
716/// (e.g. DAP reverse requests) should drive their own runtime or use a
717/// blocking channel — see `harn-dap`'s `DapHostBridge` for the canonical
718/// pattern. Sync keeps the boundary simple and avoids forcing the entire
719/// dispatch path into an opaque future.
720pub trait HostCallBridge: Send + Sync {
721    fn dispatch(
722        &self,
723        capability: &str,
724        operation: &str,
725        params: &crate::value::DictMap,
726    ) -> Result<Option<VmValue>, VmError>;
727
728    fn list_tools(&self) -> Result<Option<VmValue>, VmError> {
729        Ok(None)
730    }
731
732    fn call_tool(&self, _name: &str, _args: &VmValue) -> Result<Option<VmValue>, VmError> {
733        Ok(None)
734    }
735}
736
737thread_local! {
738    static HOST_CALL_BRIDGE: RefCell<Option<Arc<dyn HostCallBridge>>> = const { RefCell::new(None) };
739}
740
741/// Install a bridge for the current thread. The bridge is consulted on
742/// every `host_call` *after* mock matching but *before* the built-in
743/// match arms, so embedders can override anything they like (and equally
744/// punt on anything they don't, by returning `Ok(None)`).
745pub fn set_host_call_bridge(bridge: Arc<dyn HostCallBridge>) {
746    HOST_CALL_BRIDGE.with(|b| *b.borrow_mut() = Some(bridge));
747}
748
749/// Remove the current thread's bridge. Idempotent.
750pub fn clear_host_call_bridge() {
751    HOST_CALL_BRIDGE.with(|b| *b.borrow_mut() = None);
752}
753
754/// Dispatch `(capability, operation, params)` to the currently-installed
755/// `HostCallBridge`, if any. `Some(Ok(_))` means the bridge handled the
756/// call; `Some(Err(_))` means it tried but raised; `None` means there is
757/// no bridge or the bridge declined this op (returned `Ok(None)`).
758///
759/// Mirrors the inner block of `dispatch_host_operation` but without the
760/// mock-call check or the built-in fallbacks — useful for callers that
761/// want to treat the bridge as one of several sinks (e.g. inbound MCP
762/// `elicitation/create` requests).
763pub fn dispatch_host_call_bridge(
764    capability: &str,
765    operation: &str,
766    params: &crate::value::DictMap,
767) -> Option<Result<VmValue, VmError>> {
768    let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone())?;
769    match bridge.dispatch(capability, operation, params) {
770        Ok(Some(value)) => Some(Ok(value)),
771        Ok(None) => None,
772        Err(error) => Some(Err(error)),
773    }
774}
775
776fn empty_tool_list_value() -> VmValue {
777    VmValue::List(std::sync::Arc::new(Vec::new()))
778}
779
780fn current_vm_host_bridge(
781    ctx: Option<&AsyncBuiltinCtx>,
782) -> Option<std::sync::Arc<crate::bridge::HostBridge>> {
783    ctx.and_then(|ctx| ctx.child_vm().bridge.clone())
784}
785
786#[cfg(test)]
787async fn dispatch_host_tool_list() -> Result<VmValue, VmError> {
788    dispatch_host_tool_list_with_ctx(None).await
789}
790
791async fn dispatch_host_tool_list_with_ctx(
792    ctx: Option<&AsyncBuiltinCtx>,
793) -> Result<VmValue, VmError> {
794    let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
795    if let Some(bridge) = bridge {
796        if let Some(value) = bridge.list_tools()? {
797            return Ok(value);
798        }
799    }
800
801    let Some(bridge) = current_vm_host_bridge(ctx) else {
802        return Ok(empty_tool_list_value());
803    };
804    let tools = bridge.list_host_tools().await?;
805    Ok(crate::bridge::json_result_to_vm_value(&JsonValue::Array(
806        tools.into_iter().collect(),
807    )))
808}
809
810pub(crate) async fn dispatch_host_tool_call(
811    name: &str,
812    args: &VmValue,
813) -> Result<VmValue, VmError> {
814    dispatch_host_tool_call_with_ctx(None, name, args).await
815}
816
817pub(crate) async fn dispatch_host_tool_call_with_ctx(
818    ctx: Option<&AsyncBuiltinCtx>,
819    name: &str,
820    args: &VmValue,
821) -> Result<VmValue, VmError> {
822    let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
823    if let Some(bridge) = bridge {
824        if let Some(value) = bridge.call_tool(name, args)? {
825            return Ok(value);
826        }
827    }
828
829    let Some(bridge) = current_vm_host_bridge(ctx) else {
830        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
831            "host_tool_call: no host bridge is attached",
832        ))));
833    };
834
835    let result = bridge
836        .call(
837            "builtin_call",
838            serde_json::json!({
839                "name": name,
840                "args": [crate::llm::vm_value_to_json(args)],
841            }),
842        )
843        .await?;
844    Ok(crate::bridge::json_result_to_vm_value(&result))
845}
846
847pub(crate) async fn dispatch_host_operation(
848    capability: &str,
849    operation: &str,
850    params: &crate::value::DictMap,
851) -> Result<VmValue, VmError> {
852    dispatch_host_operation_with_ctx(None, capability, operation, params).await
853}
854
855pub(crate) async fn dispatch_host_operation_with_ctx(
856    ctx: Option<&AsyncBuiltinCtx>,
857    capability: &str,
858    operation: &str,
859    params: &crate::value::DictMap,
860) -> Result<VmValue, VmError> {
861    if let Some(mocked) = dispatch_mock_host_call(capability, operation, params) {
862        return mocked;
863    }
864
865    if (capability, operation) == ("process", "exec") {
866        let caller = serde_json::json!({
867            "surface": "host_call",
868            "capability": "process",
869            "operation": "exec",
870            "session_id": crate::llm::current_agent_session_id(),
871        });
872        return dispatch_process_exec_with_policy(ctx, params, caller).await;
873    }
874
875    // process.spawn is the non-blocking sibling of exec. Route it through the
876    // SAME command-policy preflight so deny-patterns/approval/sandbox gating
877    // are identical; only the completion semantics differ (returns a handle
878    // immediately instead of awaiting). poll/wait/kill/release are pure
879    // registry operations on an already-gated spawn, so they bypass the
880    // command policy.
881    if (capability, operation) == ("process", "spawn") {
882        let caller = serde_json::json!({
883            "surface": "host_call",
884            "capability": "process",
885            "operation": "spawn",
886            "session_id": crate::llm::current_agent_session_id(),
887        });
888        return dispatch_process_spawn_with_policy(ctx, params, caller).await;
889    }
890    if capability == "process" && matches!(operation, "poll" | "wait" | "kill" | "release") {
891        if let Some(result) = crate::stdlib::process_spawn::dispatch(
892            operation,
893            params,
894            async_builtin_cancel_token(ctx),
895        )
896        .await
897        {
898            return result;
899        }
900    }
901
902    let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
903    if let Some(bridge) = bridge {
904        if let Some(value) = bridge.dispatch(capability, operation, params)? {
905            return Ok(value);
906        }
907    }
908
909    dispatch_builtin_host_operation(capability, operation, params).await
910}
911
912async fn dispatch_builtin_host_operation(
913    capability: &str,
914    operation: &str,
915    params: &crate::value::DictMap,
916) -> Result<VmValue, VmError> {
917    match (capability, operation) {
918        ("process", "list_shells") => Ok(crate::shells::list_shells_vm_value()),
919        ("process", "get_default_shell") => Ok(crate::shells::default_shell_vm_value()),
920        ("process", "set_default_shell") => crate::shells::set_default_shell_vm_value(params),
921        ("process", "shell_invocation") => crate::shells::shell_invocation_vm_value(params),
922        ("template", "render") => {
923            let path = require_param(params, "path")?;
924            let bindings = params.get("bindings").and_then(|v| v.as_dict());
925            Ok(VmValue::String(arcstr::ArcStr::from(render_template(
926                &path, bindings,
927            )?)))
928        }
929        ("interaction", "ask") => {
930            let question = require_param(params, "question")?;
931            super::io::prompt_user_value(&[VmValue::string(question)], &mut String::new())
932        }
933        ("project", "metadata_get") => crate::metadata::project_metadata_host_get(params),
934        ("project", "metadata_inspect") => crate::metadata::project_metadata_host_inspect(params),
935        ("project", "metadata_set") => crate::metadata::project_metadata_host_set(params),
936        ("project", "metadata_save") => crate::metadata::project_metadata_host_save(params),
937        ("project", "metadata_stale") => crate::metadata::project_metadata_host_stale(params),
938        ("project", "metadata_refresh_hashes") => {
939            crate::metadata::project_metadata_host_refresh_hashes(params)
940        }
941        // Standalone-run fallbacks for capabilities normally supplied by
942        // an embedder's JSON-RPC bridge. `runtime.task` lets a debugger or
943        // CLI invocation read the pipeline input from `HARN_TASK` without
944        // the host explicitly wiring a callback for every op.
945        ("runtime", "task") => Ok(VmValue::String(arcstr::ArcStr::from(
946            std::env::var("HARN_TASK").unwrap_or_default(),
947        ))),
948        ("runtime", "set_result") => {
949            // No-op when no host is attached; swallow silently so standalone
950            // scripts can still call `set_result` without crashing.
951            Ok(VmValue::Nil)
952        }
953        ("workspace", "project_root") => {
954            // Standalone fallback: prefer the typed execution project root,
955            // then the legacy env root, then the current working directory.
956            // Pipelines call this very early, so crashing here would block any
957            // debug-launched script.
958            let path = crate::stdlib::process::project_root_path()
959                .map(|root| root.display().to_string())
960                .or_else(|| std::env::var("HARN_PROJECT_ROOT").ok())
961                .unwrap_or_else(|| {
962                    std::env::current_dir()
963                        .map(|p| p.display().to_string())
964                        .unwrap_or_default()
965                });
966            Ok(VmValue::String(arcstr::ArcStr::from(path)))
967        }
968        ("workspace", "cwd") => {
969            let path = std::env::current_dir()
970                .map(|p| p.display().to_string())
971                .unwrap_or_default();
972            Ok(VmValue::String(arcstr::ArcStr::from(path)))
973        }
974        _ => Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
975            format!("host_call: unsupported operation {capability}.{operation}"),
976        )))),
977    }
978}
979
980pub(crate) async fn dispatch_process_exec(
981    params: &crate::value::DictMap,
982    caller: serde_json::Value,
983) -> Result<VmValue, VmError> {
984    dispatch_process_exec_with_policy(None, params, caller).await
985}
986
987async fn dispatch_process_exec_with_policy(
988    ctx: Option<&AsyncBuiltinCtx>,
989    params: &crate::value::DictMap,
990    caller: serde_json::Value,
991) -> Result<VmValue, VmError> {
992    let (params, command_policy_context, command_policy_decisions) =
993        match crate::orchestration::run_command_policy_preflight_with_ctx(ctx, params, caller)
994            .await?
995        {
996            crate::orchestration::CommandPolicyPreflight::Proceed {
997                params,
998                context,
999                decisions,
1000            } => (params, context, decisions),
1001            crate::orchestration::CommandPolicyPreflight::Blocked {
1002                status,
1003                message,
1004                context,
1005                decisions,
1006            } => {
1007                return Ok(crate::orchestration::blocked_command_response(
1008                    params, status, &message, context, decisions,
1009                ));
1010            }
1011        };
1012
1013    let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
1014    if let Some(bridge) = bridge {
1015        if let Some(value) = bridge.dispatch("process", "exec", &params)? {
1016            return crate::orchestration::run_command_policy_postflight_with_ctx(
1017                ctx,
1018                &params,
1019                value,
1020                command_policy_context,
1021                command_policy_decisions,
1022            )
1023            .await;
1024        }
1025    }
1026
1027    dispatch_process_exec_after_policy(
1028        ctx,
1029        &params,
1030        command_policy_context,
1031        command_policy_decisions,
1032    )
1033    .await
1034}
1035
1036/// Apply the command-policy preflight (deny-patterns, approval gating,
1037/// sandbox decisions) and then spawn the process non-blocking. Mirrors
1038/// [`dispatch_process_exec_with_policy`] so spawn is gated identically to
1039/// exec. There is no postflight here: spawn returns a handle immediately,
1040/// not a completed command result; completion is observed later via
1041/// poll/wait, which are not themselves command executions.
1042async fn dispatch_process_spawn_with_policy(
1043    ctx: Option<&AsyncBuiltinCtx>,
1044    params: &crate::value::DictMap,
1045    caller: serde_json::Value,
1046) -> Result<VmValue, VmError> {
1047    let params =
1048        match crate::orchestration::run_command_policy_preflight_with_ctx(ctx, params, caller)
1049            .await?
1050        {
1051            crate::orchestration::CommandPolicyPreflight::Proceed { params, .. } => params,
1052            crate::orchestration::CommandPolicyPreflight::Blocked {
1053                status,
1054                message,
1055                context,
1056                decisions,
1057            } => {
1058                return Ok(crate::orchestration::blocked_command_response(
1059                    params, status, &message, context, decisions,
1060                ));
1061            }
1062        };
1063
1064    match crate::stdlib::process_spawn::dispatch("spawn", &params, async_builtin_cancel_token(ctx))
1065        .await
1066    {
1067        Some(result) => result,
1068        None => Err(VmError::Runtime(
1069            "host_call process.spawn: dispatch returned None".to_string(),
1070        )),
1071    }
1072}
1073
1074async fn dispatch_process_exec_after_policy(
1075    ctx: Option<&AsyncBuiltinCtx>,
1076    params: &crate::value::DictMap,
1077    command_policy_context: JsonValue,
1078    command_policy_decisions: Vec<crate::orchestration::CommandPolicyDecision>,
1079) -> Result<VmValue, VmError> {
1080    let timeout_ms = optional_i64(params, "timeout")
1081        .or_else(|| optional_i64(params, "timeout_ms"))
1082        .filter(|value| *value > 0)
1083        .map(|value| value as u64);
1084    // Optional per-call profile override. Pipelines that want to
1085    // promote a single spawn to `os_hardened` (e.g. running
1086    // attacker-controlled code) pass `sandbox_profile: "os_hardened"`
1087    // without having to rewrite the surrounding policy. The override
1088    // is scoped to this call and pops with the guard at end-of-scope.
1089    let profile_guard = match optional_string(params, "sandbox_profile") {
1090        Some(value) => Some(push_sandbox_profile_override(&value)?),
1091        None => None,
1092    };
1093    let mut cmd = build_sandboxed_command(params, "process.exec")?;
1094    crate::op_interrupt::configure_tokio_kill_group(&mut cmd);
1095    let cleanup_token = crate::op_interrupt::new_process_cleanup_token();
1096    cmd.env(
1097        crate::op_interrupt::PROCESS_CLEANUP_TOKEN_ENV,
1098        &cleanup_token,
1099    );
1100    cmd.stdin(std::process::Stdio::null())
1101        .stdout(std::process::Stdio::piped())
1102        .stderr(std::process::Stdio::piped())
1103        .kill_on_drop(true);
1104    let started_at = audited_utc_now_rfc3339("host_call/process.exec.started_at");
1105    let started = crate::clock_mock::leak_audit::instant_now("host_call/process.exec.started");
1106    let mut child = cmd
1107        .spawn()
1108        .map_err(|e| VmError::Runtime(format!("host_call process.exec: {e}")))?;
1109    drop(profile_guard);
1110    let pid = child.id();
1111    let cleanup_registration = crate::op_interrupt::register_active_process_cleanup(
1112        pid,
1113        &cleanup_token,
1114        async_builtin_cancel_token(ctx),
1115    );
1116    let stdout_pipe = match child.stdout.take() {
1117        Some(pipe) => pipe,
1118        None => {
1119            terminate_process_exec_child(&mut child, pid, &cleanup_token, "missing_stdout_pipe")
1120                .await;
1121            drop(cleanup_registration);
1122            return Err(VmError::Runtime(
1123                "host_call process.exec stdout pipe was not captured".to_string(),
1124            ));
1125        }
1126    };
1127    let stderr_pipe = match child.stderr.take() {
1128        Some(pipe) => pipe,
1129        None => {
1130            terminate_process_exec_child(&mut child, pid, &cleanup_token, "missing_stderr_pipe")
1131                .await;
1132            drop(cleanup_registration);
1133            return Err(VmError::Runtime(
1134                "host_call process.exec stderr pipe was not captured".to_string(),
1135            ));
1136        }
1137    };
1138    let stdout_task = tokio::spawn(read_process_exec_pipe(stdout_pipe));
1139    let stderr_task = tokio::spawn(read_process_exec_pipe(stderr_pipe));
1140
1141    enum ProcessExecWait {
1142        Exited(std::io::Result<std::process::ExitStatus>),
1143        TimedOut,
1144    }
1145
1146    let exec_deadline = timeout_ms.map(|timeout_ms| {
1147        tokio::time::Instant::now() + std::time::Duration::from_millis(timeout_ms)
1148    });
1149    let wait_result = {
1150        let wait = child.wait();
1151        tokio::pin!(wait);
1152        if let Some(deadline) = exec_deadline {
1153            let sleep = tokio::time::sleep_until(deadline);
1154            tokio::pin!(sleep);
1155            tokio::select! {
1156                result = &mut wait => ProcessExecWait::Exited(result),
1157                _ = &mut sleep => ProcessExecWait::TimedOut,
1158            }
1159        } else {
1160            ProcessExecWait::Exited(wait.await)
1161        }
1162    };
1163
1164    let (mut status, mut success, mut timed_out, mut exit_code) = match wait_result {
1165        ProcessExecWait::Exited(result) => {
1166            let status =
1167                result.map_err(|e| VmError::Runtime(format!("host_call process.exec: {e}")))?;
1168            let exit_code = status.code().unwrap_or(-1);
1169            ("completed", status.success(), false, exit_code)
1170        }
1171        ProcessExecWait::TimedOut => {
1172            terminate_process_exec_child(&mut child, pid, &cleanup_token, "timeout").await;
1173            ("timed_out", false, true, -1)
1174        }
1175    };
1176
1177    let drain_pipes = async {
1178        let stdout = collect_process_exec_pipe(stdout_task, "stdout").await?;
1179        let stderr = collect_process_exec_pipe(stderr_task, "stderr").await?;
1180        Ok::<_, VmError>((stdout, stderr))
1181    };
1182    tokio::pin!(drain_pipes);
1183    let (stdout, stderr) = if !timed_out {
1184        if let Some(deadline) = exec_deadline {
1185            tokio::select! {
1186                result = &mut drain_pipes => result?,
1187                _ = tokio::time::sleep_until(deadline) => {
1188                    terminate_process_exec_child(
1189                        &mut child,
1190                        pid,
1191                        &cleanup_token,
1192                        "pipe_drain_timeout",
1193                    )
1194                    .await;
1195                    status = "timed_out";
1196                    success = false;
1197                    timed_out = true;
1198                    exit_code = -1;
1199                    drain_pipes.await?
1200                }
1201            }
1202        } else {
1203            drain_pipes.await?
1204        }
1205    } else {
1206        drain_pipes.await?
1207    };
1208    drop(cleanup_registration);
1209
1210    let stdout_utf8_valid = std::str::from_utf8(&stdout).is_ok();
1211    let stderr_utf8_valid = std::str::from_utf8(&stderr).is_ok();
1212    let stdout = String::from_utf8_lossy(&stdout).to_string();
1213    let stderr = String::from_utf8_lossy(&stderr).to_string();
1214    let response = process_exec_response(ProcessExecResponse {
1215        pid,
1216        started_at,
1217        started,
1218        stdout: &stdout,
1219        stderr: &stderr,
1220        exit_code,
1221        status,
1222        success,
1223        timed_out,
1224        stdout_utf8_valid,
1225        stderr_utf8_valid,
1226    });
1227    crate::orchestration::run_command_policy_postflight_with_ctx(
1228        ctx,
1229        params,
1230        response,
1231        command_policy_context,
1232        command_policy_decisions,
1233    )
1234    .await
1235}
1236
1237async fn read_process_exec_pipe<R>(mut pipe: R) -> std::io::Result<Vec<u8>>
1238where
1239    R: tokio::io::AsyncRead + Unpin,
1240{
1241    let mut bytes = Vec::new();
1242    pipe.read_to_end(&mut bytes).await?;
1243    Ok(bytes)
1244}
1245
1246async fn collect_process_exec_pipe(
1247    task: tokio::task::JoinHandle<std::io::Result<Vec<u8>>>,
1248    name: &str,
1249) -> Result<Vec<u8>, VmError> {
1250    match task.await {
1251        Ok(Ok(bytes)) => Ok(bytes),
1252        Ok(Err(error)) => Err(VmError::Runtime(format!(
1253            "host_call process.exec read {name}: {error}"
1254        ))),
1255        Err(error) => Err(VmError::Runtime(format!(
1256            "host_call process.exec join {name} reader: {error}"
1257        ))),
1258    }
1259}
1260
1261async fn terminate_process_exec_child(
1262    child: &mut tokio::process::Child,
1263    pid: Option<u32>,
1264    cleanup_token: &str,
1265    reason: &'static str,
1266) {
1267    if let Some(pid) = pid {
1268        let mut report = crate::op_interrupt::signal_pid_tree_group_and_token_with_report(
1269            pid,
1270            Some(cleanup_token),
1271            9,
1272        );
1273        report.refresh_survivor_status();
1274        tracing::warn!(
1275            pid,
1276            children = report.children.len(),
1277            reason,
1278            "host_call process.exec signalled child process tree"
1279        );
1280    }
1281    let _ = child.kill().await;
1282    let _ = child.wait().await;
1283}
1284
1285/// Build a sandboxed `tokio::process::Command` from process-call params,
1286/// applying argv/shell resolution, the active sandbox policy via
1287/// [`crate::process_sandbox::tokio_command_for`], cwd enforcement, and
1288/// env/env_mode/env_remove handling.
1289///
1290/// Shared by `process.exec` (synchronous) and `process.spawn`
1291/// (non-blocking) so both go through the identical sandbox-gated build
1292/// path. The caller is responsible for any `sandbox_profile` override
1293/// guard (it must be live across this call) and for setting stdio/kill
1294/// behaviour on the returned command. `label` ("process.exec" or
1295/// "process.spawn") is woven into error messages.
1296pub(crate) fn build_sandboxed_command(
1297    params: &crate::value::DictMap,
1298    label: &str,
1299) -> Result<tokio::process::Command, VmError> {
1300    let (program, args) = process_exec_argv(params)?;
1301    let mut cmd = crate::process_sandbox::tokio_command_for(&program, &args)
1302        .map_err(|e| VmError::Runtime(format!("host_call {label} sandbox setup: {e}")))?;
1303    if let Some(cwd) = optional_string(params, "cwd") {
1304        let cwd = resolve_process_exec_cwd(&cwd);
1305        crate::process_sandbox::enforce_process_cwd(&cwd)
1306            .map_err(|e| VmError::Runtime(format!("host_call {label} cwd: {e}")))?;
1307        cmd.current_dir(cwd);
1308    }
1309    // Track keys the caller set explicitly so the sandbox-local TMPDIR overlay
1310    // below never clobbers an intentional per-call value.
1311    let mut caller_env_keys: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
1312    if let Some(env) = optional_string_dict(params, "env")? {
1313        // `env_mode` controls how the provided `env` keys combine with the
1314        // parent environment:
1315        //   - "merge" (default): inherit the parent env and overlay the
1316        //     provided keys. This is the least-surprising behavior — a
1317        //     caller passing `env: {ONE_VAR: "x"}` keeps PATH/HOME/etc.
1318        //   - "replace": clear the parent env entirely, then set only the
1319        //     provided keys. This is the footgun shape and must be requested
1320        //     explicitly whenever `env` is supplied.
1321        let env_mode = optional_string(params, "env_mode");
1322        match env_mode.as_deref().unwrap_or("merge") {
1323            "replace" => {
1324                cmd.env_clear();
1325            }
1326            "merge" => {}
1327            other => {
1328                return Err(VmError::Runtime(format!(
1329                    "host_call {label}: unknown env_mode {other:?}; expected \"merge\" or \"replace\""
1330                )));
1331            }
1332        }
1333        for (key, value) in env {
1334            caller_env_keys.insert(key.clone());
1335            cmd.env(key, value);
1336        }
1337    }
1338    // env_remove: list of environment variable names to strip before
1339    // spawning. Applied after `env` so callers can both inherit and
1340    // selectively unset (e.g. the git stdlib strips `GIT_*` so its
1341    // operations are self-contained even when Harn is invoked from
1342    // inside a git hook that sets `GIT_DIR`).
1343    if let Some(env_remove) = optional_string_list(params, "env_remove") {
1344        for key in env_remove {
1345            caller_env_keys.insert(key.clone());
1346            cmd.env_remove(key);
1347        }
1348    }
1349    // Point the child's temp dir at a sandbox-writable, workspace-local
1350    // location so compiler linkers (rustc/cc/ld, Go, Swift, …) and other
1351    // toolchains that honor TMPDIR/TMP/TEMP don't false-fail trying to write
1352    // intermediates to the unwritable system /tmp. A key the caller set (via
1353    // `env`) or explicitly stripped (via `env_remove`) is left as the caller
1354    // intended; only keys the caller did not touch receive the overlay. No-op
1355    // when the active profile is unrestricted or no writable workspace root is
1356    // available.
1357    for (key, value) in crate::process_sandbox::active_workspace_tmpdir_env() {
1358        if caller_env_keys.contains(&key) {
1359            continue;
1360        }
1361        cmd.env(key, value);
1362    }
1363    // Pin tool *message* output to a deterministic English/UTF-8 locale so
1364    // downstream English-diagnostic matchers (deterministic syntax repair,
1365    // error-signature grounding, completion/pass-fail classification) do not
1366    // misfire for a non-Anglosphere user whose shell localizes compiler/test
1367    // output. A user-inherited `LC_ALL` overrides `LC_MESSAGES`, so strip it
1368    // first — unless the caller pinned it via `env`/`env_remove` — then apply
1369    // the overlay with the same caller-wins rule as the TMPDIR overlay above.
1370    if !caller_env_keys.contains(crate::process_sandbox::MESSAGE_LOCALE_OVERRIDE_ENV) {
1371        cmd.env_remove(crate::process_sandbox::MESSAGE_LOCALE_OVERRIDE_ENV);
1372    }
1373    for (key, value) in crate::process_sandbox::deterministic_message_locale_env() {
1374        if caller_env_keys.contains(&key) {
1375            continue;
1376        }
1377        cmd.env(key, value);
1378    }
1379    Ok(cmd)
1380}
1381
1382struct ProcessExecResponse<'a> {
1383    pid: Option<u32>,
1384    started_at: String,
1385    started: Instant,
1386    stdout: &'a str,
1387    stderr: &'a str,
1388    exit_code: i32,
1389    status: &'a str,
1390    success: bool,
1391    timed_out: bool,
1392    stdout_utf8_valid: bool,
1393    stderr_utf8_valid: bool,
1394}
1395
1396fn process_exec_response(response: ProcessExecResponse<'_>) -> VmValue {
1397    let combined = format!("{}{}", response.stdout, response.stderr);
1398    let mut result = crate::value::DictMap::new();
1399    result.put_str(
1400        "command_id",
1401        format!(
1402            "cmd_{}_{}",
1403            std::process::id(),
1404            response.started.elapsed().as_nanos()
1405        ),
1406    );
1407    result.put_str("status", response.status);
1408    result.insert(
1409        crate::value::intern_key("pid"),
1410        response
1411            .pid
1412            .map(|pid| VmValue::Int(pid as i64))
1413            .unwrap_or(VmValue::Nil),
1414    );
1415    result.insert(
1416        crate::value::intern_key("process_group_id"),
1417        response
1418            .pid
1419            .map(|pid| VmValue::Int(pid as i64))
1420            .unwrap_or(VmValue::Nil),
1421    );
1422    result.insert(crate::value::intern_key("handle_id"), VmValue::Nil);
1423    result.put_str("started_at", response.started_at);
1424    result.put_str(
1425        "ended_at",
1426        audited_utc_now_rfc3339("host_call/process.exec.ended_at"),
1427    );
1428    result.insert(
1429        crate::value::intern_key("duration_ms"),
1430        VmValue::Int(response.started.elapsed().as_millis() as i64),
1431    );
1432    result.insert(
1433        crate::value::intern_key("exit_code"),
1434        VmValue::Int(response.exit_code as i64),
1435    );
1436    result.insert(crate::value::intern_key("signal"), VmValue::Nil);
1437    result.insert(
1438        crate::value::intern_key("timed_out"),
1439        VmValue::Bool(response.timed_out),
1440    );
1441    result.put_str("stdout", response.stdout);
1442    result.put_str("stderr", response.stderr);
1443    result.insert(
1444        crate::value::intern_key("stdout_utf8_valid"),
1445        VmValue::Bool(response.stdout_utf8_valid),
1446    );
1447    result.insert(
1448        crate::value::intern_key("stderr_utf8_valid"),
1449        VmValue::Bool(response.stderr_utf8_valid),
1450    );
1451    result.put_str("combined", combined);
1452    result.insert(
1453        crate::value::intern_key("exit_status"),
1454        VmValue::Int(response.exit_code as i64),
1455    );
1456    result.insert(
1457        crate::value::intern_key("legacy_status"),
1458        VmValue::Int(response.exit_code as i64),
1459    );
1460    result.insert(
1461        crate::value::intern_key("success"),
1462        VmValue::Bool(response.success),
1463    );
1464    VmValue::dict(result)
1465}
1466
1467fn resolve_process_exec_cwd(cwd: &str) -> std::path::PathBuf {
1468    crate::stdlib::process::resolve_source_relative_path(cwd)
1469}
1470
1471fn process_exec_argv(params: &crate::value::DictMap) -> Result<(String, Vec<String>), VmError> {
1472    match optional_string(params, "mode")
1473        .as_deref()
1474        .unwrap_or("shell")
1475    {
1476        "argv" => {
1477            let argv = optional_string_list(params, "argv").ok_or_else(|| {
1478                VmError::Runtime("host_call process.exec missing argv".to_string())
1479            })?;
1480            split_argv(argv)
1481        }
1482        "shell" => {
1483            let command = require_param(params, "command")?;
1484            let mut invocation_params = params.clone();
1485            invocation_params.put_str("command", command);
1486            let invocation =
1487                crate::shells::resolve_invocation_from_vm_params(&invocation_params)
1488                    .map_err(|err| VmError::Runtime(format!("host_call process.exec: {err}")))?;
1489            Ok((invocation.program, invocation.args))
1490        }
1491        other => Err(VmError::Runtime(format!(
1492            "host_call process.exec unsupported mode {other:?}"
1493        ))),
1494    }
1495}
1496
1497fn split_argv(mut argv: Vec<String>) -> Result<(String, Vec<String>), VmError> {
1498    if argv.is_empty() {
1499        return Err(VmError::Runtime(
1500            "host_call process.exec argv must not be empty".to_string(),
1501        ));
1502    }
1503    let program = argv.remove(0);
1504    if program.is_empty() {
1505        return Err(VmError::Runtime(
1506            "host_call process.exec argv[0] must not be empty".to_string(),
1507        ));
1508    }
1509    Ok((program, argv))
1510}
1511
1512/// Push a transient policy onto the execution stack with the
1513/// requested sandbox profile, returning a guard that pops on drop.
1514/// Used by `host_call("process", "exec", ...)` to honor a per-call
1515/// `sandbox_profile` override without rewriting the surrounding
1516/// orchestration policy.
1517pub(crate) fn push_sandbox_profile_override(value: &str) -> Result<SandboxProfileGuard, VmError> {
1518    let profile = crate::orchestration::SandboxProfile::parse(value).ok_or_else(|| {
1519        VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
1520            "host_call process.exec: unknown sandbox_profile {value:?}; expected one of \"unrestricted\", \"worktree\", \"os_hardened\", \"wasi\""
1521        ))))
1522    })?;
1523    let mut policy = crate::orchestration::current_execution_policy().unwrap_or_default();
1524    policy.sandbox_profile = profile;
1525    crate::orchestration::push_execution_policy(policy);
1526    Ok(SandboxProfileGuard {
1527        _private: std::marker::PhantomData,
1528    })
1529}
1530
1531pub(crate) struct SandboxProfileGuard {
1532    _private: std::marker::PhantomData<*const ()>,
1533}
1534
1535impl Drop for SandboxProfileGuard {
1536    fn drop(&mut self) {
1537        crate::orchestration::pop_execution_policy();
1538    }
1539}
1540
1541pub(crate) fn optional_i64(params: &crate::value::DictMap, key: &str) -> Option<i64> {
1542    match params.get(key) {
1543        Some(VmValue::Int(value)) => Some(*value),
1544        Some(VmValue::Float(value)) if value.fract() == 0.0 => Some(*value as i64),
1545        _ => None,
1546    }
1547}
1548
1549pub(crate) fn optional_string(params: &crate::value::DictMap, key: &str) -> Option<String> {
1550    params.get(key).and_then(vm_string).map(ToString::to_string)
1551}
1552
1553fn optional_string_list(params: &crate::value::DictMap, key: &str) -> Option<Vec<String>> {
1554    let VmValue::List(values) = params.get(key)? else {
1555        return None;
1556    };
1557    values
1558        .iter()
1559        .map(|value| vm_string(value).map(ToString::to_string))
1560        .collect()
1561}
1562
1563fn optional_string_dict(
1564    params: &crate::value::DictMap,
1565    key: &str,
1566) -> Result<Option<BTreeMap<String, String>>, VmError> {
1567    let Some(value) = params.get(key) else {
1568        return Ok(None);
1569    };
1570    let Some(dict) = value.as_dict() else {
1571        return Err(VmError::Runtime(format!(
1572            "host_call process.exec {key} must be a dict"
1573        )));
1574    };
1575    let mut out = std::collections::BTreeMap::new();
1576    for (key, value) in dict.iter() {
1577        let Some(value) = vm_string(value) else {
1578            return Err(VmError::Runtime(format!(
1579                "host_call process.exec env value for {key:?} must be a string"
1580            )));
1581        };
1582        out.insert(key.to_string(), value.to_string());
1583    }
1584    Ok(Some(out))
1585}
1586
1587fn vm_string(value: &VmValue) -> Option<&str> {
1588    match value {
1589        VmValue::String(value) => Some(value.as_ref()),
1590        _ => None,
1591    }
1592}
1593
1594pub(crate) fn register_host_builtins(vm: &mut Vm) {
1595    for def in MODULE_BUILTINS {
1596        vm.register_builtin_def(def);
1597    }
1598}
1599
1600pub(crate) fn register_missing_host_builtins(vm: &mut Vm) {
1601    for def in MODULE_BUILTINS {
1602        if vm.builtin_metadata_for(def.sig.name).is_none() {
1603            vm.register_builtin_def(def);
1604        }
1605    }
1606}
1607
1608#[harn_builtin(
1609    sig = "host_mock(capability: string, op: string, response_or_config?: any, params?: dict) -> nil",
1610    category = "host"
1611)]
1612fn host_mock_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1613    let host_mock = parse_host_mock(args)?;
1614    validate_host_mock_registration(&host_mock)?;
1615    push_host_mock(host_mock);
1616    Ok(VmValue::Nil)
1617}
1618
1619#[harn_builtin(sig = "host_mock_clear() -> nil", category = "host")]
1620fn host_mock_clear_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1621    reset_host_state();
1622    Ok(VmValue::Nil)
1623}
1624
1625#[harn_builtin(sig = "host_mock_calls() -> list", category = "host")]
1626fn host_mock_calls_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1627    let calls = HOST_MOCK_CALLS.with(|calls| {
1628        calls
1629            .borrow()
1630            .iter()
1631            .map(mock_call_value)
1632            .collect::<Vec<_>>()
1633    });
1634    Ok(VmValue::List(std::sync::Arc::new(calls)))
1635}
1636
1637#[harn_builtin(sig = "host_mock_push_scope() -> nil", category = "host")]
1638fn host_mock_push_scope_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1639    push_host_mock_scope();
1640    Ok(VmValue::Nil)
1641}
1642
1643#[harn_builtin(sig = "host_mock_pop_scope() -> nil", category = "host")]
1644fn host_mock_pop_scope_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1645    if !pop_host_mock_scope() {
1646        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1647            "host_mock_pop_scope: no scope to pop",
1648        ))));
1649    }
1650    Ok(VmValue::Nil)
1651}
1652
1653#[harn_builtin(sig = "host_capabilities() -> dict", category = "host")]
1654fn host_capabilities_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1655    Ok(capability_manifest_with_mocks())
1656}
1657
1658#[harn_builtin(
1659    sig = "host_has(capability: string, op?: string) -> bool",
1660    category = "host"
1661)]
1662fn host_has_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1663    let capability = args.first().map(|a| a.display()).unwrap_or_default();
1664    let operation = args.get(1).map(|a| a.display());
1665    let manifest = capability_manifest_with_mocks();
1666    let has = manifest
1667        .as_dict()
1668        .and_then(|d| d.get(capability.as_str()))
1669        .and_then(|v| v.as_dict())
1670        .is_some_and(|cap| {
1671            if let Some(operation) = operation {
1672                cap.get("ops")
1673                    .and_then(|v| match v {
1674                        VmValue::List(list) => {
1675                            Some(list.iter().any(|item| item.display() == operation))
1676                        }
1677                        _ => None,
1678                    })
1679                    .unwrap_or(false)
1680            } else {
1681                true
1682            }
1683        });
1684    Ok(VmValue::Bool(has))
1685}
1686
1687#[harn_builtin(
1688    sig = "host_call(name: string, args?: dict) -> any",
1689    kind = "async",
1690    category = "host"
1691)]
1692async fn host_call_builtin(
1693    ctx: crate::vm::AsyncBuiltinCtx,
1694    args: Vec<VmValue>,
1695) -> Result<VmValue, VmError> {
1696    let name = args.first().map(|a| a.display()).unwrap_or_default();
1697    let params = args
1698        .get(1)
1699        .and_then(|a| a.as_dict())
1700        .cloned()
1701        .unwrap_or_default();
1702    let Some((capability, operation)) = name.split_once('.') else {
1703        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1704            format!("host_call: unsupported operation name '{name}'"),
1705        ))));
1706    };
1707    dispatch_host_operation_with_ctx(Some(&ctx), capability, operation, &params).await
1708}
1709
1710#[harn_builtin(sig = "host_tool_list() -> list", kind = "async", category = "host")]
1711async fn host_tool_list_builtin(
1712    ctx: crate::vm::AsyncBuiltinCtx,
1713    _args: Vec<VmValue>,
1714) -> Result<VmValue, VmError> {
1715    dispatch_host_tool_list_with_ctx(Some(&ctx)).await
1716}
1717
1718#[harn_builtin(
1719    sig = "host_tool_call(name: string, args?: any) -> any",
1720    kind = "async",
1721    category = "host"
1722)]
1723async fn host_tool_call_builtin(
1724    ctx: crate::vm::AsyncBuiltinCtx,
1725    args: Vec<VmValue>,
1726) -> Result<VmValue, VmError> {
1727    let name = args.first().map(|a| a.display()).unwrap_or_default();
1728    if name.is_empty() {
1729        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1730            "host_tool_call: tool name is required",
1731        ))));
1732    }
1733    let call_args = args.get(1).cloned().unwrap_or(VmValue::Nil);
1734    dispatch_host_tool_call_with_ctx(Some(&ctx), &name, &call_args).await
1735}
1736
1737#[cfg(test)]
1738mod tests {
1739    use super::{
1740        build_sandboxed_command, capability_manifest_with_mocks, clear_host_call_bridge,
1741        dispatch_host_operation, dispatch_host_tool_call, dispatch_host_tool_list,
1742        dispatch_mock_host_call, dispatch_mock_hostlib_call, host_has_builtin,
1743        host_mock_clear_builtin, parse_host_mock, push_host_mock, register_mockable_host_operation,
1744        register_scoped_mockable_host_operation, reset_host_state, reset_scoped_host_state,
1745        resolve_process_exec_cwd, set_host_call_bridge, validate_host_mock_registration,
1746        HostCallBridge, HostMock,
1747    };
1748    use crate::value::VmDictExt;
1749
1750    use std::sync::{
1751        atomic::{AtomicUsize, Ordering},
1752        Arc,
1753    };
1754
1755    use crate::value::{VmError, VmValue};
1756
1757    /// Collect a built command's env mutations as `(name, Option<value>)`,
1758    /// where `None` marks a variable the command removes from the inherited
1759    /// environment.
1760    fn command_env(
1761        cmd: &tokio::process::Command,
1762    ) -> std::collections::BTreeMap<String, Option<String>> {
1763        cmd.as_std()
1764            .get_envs()
1765            .map(|(k, v)| {
1766                (
1767                    k.to_string_lossy().into_owned(),
1768                    v.map(|value| value.to_string_lossy().into_owned()),
1769                )
1770            })
1771            .collect()
1772    }
1773
1774    #[test]
1775    fn build_sandboxed_command_forces_deterministic_message_locale() {
1776        // A verify command spawned by a non-Anglosphere user whose *shell*
1777        // exports LC_ALL (inherited via the parent env, NOT pinned by the
1778        // caller's `env` dict) must still emit English diagnostics, or the
1779        // downstream English-keyed matchers (syntax repair, error grounding,
1780        // pass/fail classification) misfire. In merge mode the child inherits
1781        // the parent env implicitly, so the builder must issue an explicit
1782        // LC_ALL removal — observable here as a `(key, None)` mutation — and
1783        // pin LC_MESSAGES=C + DOTNET_CLI_UI_LANGUAGE=en. The caller pins no
1784        // locale key here, so the overlay engages.
1785        let mut params = crate::value::DictMap::new();
1786        params.put_str("mode", "argv");
1787        params.put(
1788            "argv",
1789            VmValue::List(Arc::new(vec![VmValue::string("/bin/true")])),
1790        );
1791        params.put_str("env_mode", "merge");
1792        let mut caller_env = crate::value::DictMap::new();
1793        // An innocuous caller env key that must NOT suppress the locale overlay.
1794        caller_env.put_str("CARGO_TARGET_DIR", "/tmp/target");
1795        params.put("env", VmValue::dict_map(caller_env));
1796
1797        let cmd = build_sandboxed_command(&params, "process.exec").expect("build command");
1798        let env = command_env(&cmd);
1799
1800        assert_eq!(
1801            env.get("LC_ALL"),
1802            Some(&None),
1803            "the builder must remove LC_ALL from the child so an inherited shell \
1804             value cannot override the forced LC_MESSAGES"
1805        );
1806        assert_eq!(
1807            env.get("LC_MESSAGES"),
1808            Some(&Some("C".to_string())),
1809            "LC_MESSAGES must be pinned to C for untranslated (English) tool output"
1810        );
1811        assert_eq!(
1812            env.get("DOTNET_CLI_UI_LANGUAGE"),
1813            Some(&Some("en".to_string())),
1814            ".NET ignores LC_* and needs its own UI-language override"
1815        );
1816    }
1817
1818    #[test]
1819    fn build_sandboxed_command_respects_a_caller_pinned_locale() {
1820        // A caller that explicitly pins the locale keys (or LC_ALL) wins over
1821        // the deterministic overlay — same caller-wins rule as TMPDIR.
1822        let mut params = crate::value::DictMap::new();
1823        params.put_str("mode", "argv");
1824        params.put(
1825            "argv",
1826            VmValue::List(Arc::new(vec![VmValue::string("/bin/true")])),
1827        );
1828        params.put_str("env_mode", "merge");
1829        let mut caller_env = crate::value::DictMap::new();
1830        caller_env.put_str("LC_ALL", "fr_FR.UTF-8");
1831        caller_env.put_str("LC_MESSAGES", "fr_FR.UTF-8");
1832        params.put("env", VmValue::dict_map(caller_env));
1833
1834        let cmd = build_sandboxed_command(&params, "process.exec").expect("build command");
1835        let env = command_env(&cmd);
1836
1837        assert_eq!(
1838            env.get("LC_ALL"),
1839            Some(&Some("fr_FR.UTF-8".to_string())),
1840            "a caller that pins LC_ALL keeps it — the overlay must not strip an explicit value"
1841        );
1842        assert_eq!(
1843            env.get("LC_MESSAGES"),
1844            Some(&Some("fr_FR.UTF-8".to_string())),
1845            "a caller-pinned LC_MESSAGES wins over the C overlay"
1846        );
1847    }
1848
1849    #[test]
1850    fn process_exec_relative_cwd_resolves_against_execution_root() {
1851        let dir = tempfile::tempdir().expect("tempdir");
1852        crate::stdlib::process::set_thread_execution_context(Some(
1853            crate::orchestration::RunExecutionRecord {
1854                cwd: Some(dir.path().to_string_lossy().into_owned()),
1855                project_root: None,
1856                source_dir: Some(dir.path().join("src").to_string_lossy().into_owned()),
1857                env: std::collections::BTreeMap::new(),
1858                adapter: None,
1859                repo_path: None,
1860                worktree_path: None,
1861                branch: None,
1862                base_ref: None,
1863                cleanup: None,
1864            },
1865        ));
1866
1867        assert_eq!(
1868            resolve_process_exec_cwd("subdir"),
1869            dir.path().join("subdir")
1870        );
1871
1872        crate::stdlib::process::set_thread_execution_context(None);
1873    }
1874
1875    #[test]
1876    fn workspace_project_root_fallback_prefers_execution_context_project_root() {
1877        run_host_async_test(|| async {
1878            let project = tempfile::tempdir().expect("project root");
1879            let cwd = tempfile::tempdir().expect("cwd");
1880            crate::stdlib::process::set_thread_execution_context(Some(
1881                crate::orchestration::RunExecutionRecord {
1882                    cwd: Some(cwd.path().to_string_lossy().into_owned()),
1883                    project_root: Some(project.path().to_string_lossy().into_owned()),
1884                    source_dir: None,
1885                    env: std::collections::BTreeMap::new(),
1886                    adapter: None,
1887                    repo_path: None,
1888                    worktree_path: None,
1889                    branch: None,
1890                    base_ref: None,
1891                    cleanup: None,
1892                },
1893            ));
1894
1895            let result =
1896                dispatch_host_operation("workspace", "project_root", &crate::value::DictMap::new())
1897                    .await
1898                    .expect("workspace.project_root result");
1899
1900            crate::stdlib::process::set_thread_execution_context(None);
1901            assert_eq!(result.display(), project.path().display().to_string());
1902        });
1903    }
1904
1905    #[test]
1906    fn manifest_includes_operation_metadata() {
1907        let manifest = capability_manifest_with_mocks();
1908        let process = manifest
1909            .as_dict()
1910            .and_then(|d| d.get("process"))
1911            .and_then(|v| v.as_dict())
1912            .expect("process capability");
1913        assert!(process.get("description").is_some());
1914        let operations = process
1915            .get("operations")
1916            .and_then(|v| v.as_dict())
1917            .expect("operations dict");
1918        assert!(operations.get("exec").is_some());
1919    }
1920
1921    #[test]
1922    fn mocked_capabilities_appear_in_manifest() {
1923        reset_host_state();
1924        push_host_mock(HostMock {
1925            capability: "project".to_string(),
1926            operation: "metadata_get".to_string(),
1927            params: None,
1928            result: Some(VmValue::dict(crate::value::DictMap::new())),
1929            error: None,
1930            unregistered_ok: false,
1931        });
1932        let manifest = capability_manifest_with_mocks();
1933        let project = manifest
1934            .as_dict()
1935            .and_then(|d| d.get("project"))
1936            .and_then(|v| v.as_dict())
1937            .expect("project capability");
1938        let operations = project
1939            .get("operations")
1940            .and_then(|v| v.as_dict())
1941            .expect("operations dict");
1942        assert!(operations.get("metadata_get").is_some());
1943        reset_host_state();
1944    }
1945
1946    #[test]
1947    fn mock_host_call_matches_partial_params_and_overrides_order() {
1948        reset_host_state();
1949        let mut exact_params = crate::value::DictMap::new();
1950        exact_params.put_str("namespace", "facts");
1951        push_host_mock(HostMock {
1952            capability: "project".to_string(),
1953            operation: "metadata_get".to_string(),
1954            params: None,
1955            result: Some(VmValue::String(arcstr::ArcStr::from("fallback"))),
1956            error: None,
1957            unregistered_ok: false,
1958        });
1959        push_host_mock(HostMock {
1960            capability: "project".to_string(),
1961            operation: "metadata_get".to_string(),
1962            params: Some(exact_params),
1963            result: Some(VmValue::String(arcstr::ArcStr::from("facts"))),
1964            error: None,
1965            unregistered_ok: false,
1966        });
1967
1968        let mut call_params = crate::value::DictMap::new();
1969        call_params.put_str("dir", "pkg");
1970        call_params.put_str("namespace", "facts");
1971        let exact = dispatch_mock_host_call("project", "metadata_get", &call_params)
1972            .expect("expected exact mock")
1973            .expect("exact mock should succeed");
1974        assert_eq!(exact.display(), "facts");
1975
1976        call_params.put_str("namespace", "classification");
1977        let fallback = dispatch_mock_host_call("project", "metadata_get", &call_params)
1978            .expect("expected fallback mock")
1979            .expect("fallback mock should succeed");
1980        assert_eq!(fallback.display(), "fallback");
1981        reset_host_state();
1982    }
1983
1984    #[test]
1985    fn mock_host_call_can_throw_errors() {
1986        reset_host_state();
1987        push_host_mock(HostMock {
1988            capability: "project".to_string(),
1989            operation: "metadata_get".to_string(),
1990            params: None,
1991            result: None,
1992            error: Some("boom".to_string()),
1993            unregistered_ok: false,
1994        });
1995        let params = crate::value::DictMap::new();
1996        let result = dispatch_mock_host_call("project", "metadata_get", &params)
1997            .expect("expected mock result");
1998        match result {
1999            Err(VmError::Thrown(VmValue::String(message))) => assert_eq!(message.as_str(), "boom"),
2000            other => panic!("unexpected result: {other:?}"),
2001        }
2002        reset_host_state();
2003    }
2004
2005    #[test]
2006    fn host_mock_registration_rejects_unknown_operations_by_default() {
2007        let host_mock = HostMock {
2008            capability: "runtime".to_string(),
2009            operation: "tas".to_string(),
2010            params: None,
2011            result: Some(VmValue::Nil),
2012            error: None,
2013            unregistered_ok: false,
2014        };
2015        let error = validate_host_mock_registration(&host_mock)
2016            .expect_err("unknown host operation should fail at registration");
2017        match error {
2018            VmError::Thrown(VmValue::String(message)) => {
2019                assert!(message.contains("runtime.tas"));
2020                assert!(message.contains("unregistered_ok"));
2021                assert!(message.contains("runtime.task"));
2022            }
2023            other => panic!("unexpected error: {other:?}"),
2024        }
2025    }
2026
2027    #[test]
2028    fn host_mock_registration_allows_explicit_test_local_operations() {
2029        let host_mock = HostMock {
2030            capability: "synthetic".to_string(),
2031            operation: "op".to_string(),
2032            params: None,
2033            result: Some(VmValue::Nil),
2034            error: None,
2035            unregistered_ok: true,
2036        };
2037        validate_host_mock_registration(&host_mock)
2038            .expect("explicit unregistered_ok should permit synthetic mocks");
2039    }
2040
2041    #[test]
2042    fn host_mock_registration_accepts_runtime_registered_operations() {
2043        register_mockable_host_operation(
2044            "code_index",
2045            "stats",
2046            "Hostlib schema-backed operation registered at runtime.",
2047        );
2048        let host_mock = HostMock {
2049            capability: "code_index".to_string(),
2050            operation: "stats".to_string(),
2051            params: None,
2052            result: Some(VmValue::Nil),
2053            error: None,
2054            unregistered_ok: false,
2055        };
2056        validate_host_mock_registration(&host_mock)
2057            .expect("registered hostlib operations should be mockable");
2058    }
2059
2060    #[test]
2061    fn clearing_live_mocks_preserves_scoped_manifest_declarations() {
2062        reset_scoped_host_state();
2063        register_scoped_mockable_host_operation(
2064            "scoped_clear_fixture",
2065            "answer",
2066            "Test-scoped manifest declaration.",
2067        );
2068        let host_mock = HostMock {
2069            capability: "scoped_clear_fixture".to_string(),
2070            operation: "answer".to_string(),
2071            params: None,
2072            result: Some(VmValue::Nil),
2073            error: None,
2074            unregistered_ok: false,
2075        };
2076
2077        validate_host_mock_registration(&host_mock).expect("scoped declaration is registered");
2078        host_mock_clear_builtin(&[], &mut String::new()).expect("clear live mocks");
2079        validate_host_mock_registration(&host_mock)
2080            .expect("clearing live mocks must preserve manifest declarations");
2081        reset_scoped_host_state();
2082    }
2083
2084    #[tokio::test]
2085    async fn declared_mockable_operation_is_not_reported_as_callable() {
2086        std::thread::spawn(|| {
2087            register_mockable_host_operation(
2088                "async_host_registration",
2089                "cross_thread",
2090                "Embedding operation registered before async worker migration.",
2091            );
2092        })
2093        .join()
2094        .expect("registration worker should finish");
2095
2096        std::thread::spawn(|| {
2097            let host_mock = HostMock {
2098                capability: "async_host_registration".to_string(),
2099                operation: "cross_thread".to_string(),
2100                params: None,
2101                result: Some(VmValue::Nil),
2102                error: None,
2103                unregistered_ok: false,
2104            };
2105            validate_host_mock_registration(&host_mock)
2106                .expect("process host registration should be visible after worker migration");
2107
2108            let typo = HostMock {
2109                operation: "cross_tread".to_string(),
2110                ..host_mock
2111            };
2112            validate_host_mock_registration(&typo)
2113                .expect_err("an undeclared operation should still fail closed");
2114        })
2115        .join()
2116        .expect("validation worker should finish");
2117
2118        assert!(matches!(
2119            host_has_builtin(
2120                &[
2121                    VmValue::string("async_host_registration"),
2122                    VmValue::string("cross_thread"),
2123                ],
2124                &mut String::new(),
2125            )
2126            .expect("host_has should succeed"),
2127            VmValue::Bool(false)
2128        ));
2129        dispatch_host_operation(
2130            "async_host_registration",
2131            "cross_thread",
2132            &crate::value::DictMap::new(),
2133        )
2134        .await
2135        .expect_err("an unmocked declaration must remain unsupported at dispatch");
2136    }
2137
2138    #[test]
2139    fn host_mock_parse_preserves_unregistered_ok_config() {
2140        let config = VmValue::dict(crate::value::DictMap::from_iter([
2141            (crate::value::intern_key("result"), VmValue::string("ok")),
2142            (
2143                crate::value::intern_key("unregistered_ok"),
2144                VmValue::Bool(true),
2145            ),
2146        ]));
2147        let host_mock =
2148            parse_host_mock(&[VmValue::string("synthetic"), VmValue::string("op"), config])
2149                .expect("parse host mock config");
2150        assert!(host_mock.unregistered_ok);
2151    }
2152
2153    #[test]
2154    fn hostlib_mock_dispatch_matches_module_method_and_params() {
2155        reset_host_state();
2156        let mut mock_params = crate::value::DictMap::new();
2157        mock_params.put(
2158            "argv",
2159            VmValue::List(Arc::new(vec![VmValue::string("echo")])),
2160        );
2161        push_host_mock(HostMock {
2162            capability: "tools".to_string(),
2163            operation: "run_command".to_string(),
2164            params: Some(mock_params),
2165            result: Some(VmValue::String(arcstr::ArcStr::from("direct"))),
2166            error: None,
2167            unregistered_ok: false,
2168        });
2169
2170        let mut call_params = crate::value::DictMap::new();
2171        call_params.put(
2172            "argv",
2173            VmValue::List(Arc::new(vec![VmValue::string("echo")])),
2174        );
2175        call_params.put_str("cwd", "/tmp/not-used");
2176        let value = dispatch_mock_hostlib_call("tools", "run_command", &call_params)
2177            .expect("expected hostlib mock")
2178            .expect("hostlib mock should succeed");
2179        assert_eq!(value.display(), "direct");
2180        reset_host_state();
2181    }
2182
2183    #[test]
2184    fn hostlib_run_command_falls_back_to_process_exec_mocks() {
2185        reset_host_state();
2186        let mut mock_params = crate::value::DictMap::new();
2187        mock_params.put(
2188            "argv",
2189            VmValue::List(Arc::new(vec![
2190                VmValue::string("cargo"),
2191                VmValue::string("test"),
2192            ])),
2193        );
2194        push_host_mock(HostMock {
2195            capability: "process".to_string(),
2196            operation: "exec".to_string(),
2197            params: Some(mock_params),
2198            result: Some(VmValue::String(arcstr::ArcStr::from("legacy"))),
2199            error: None,
2200            unregistered_ok: false,
2201        });
2202
2203        let mut call_params = crate::value::DictMap::new();
2204        call_params.put(
2205            "argv",
2206            VmValue::List(Arc::new(vec![
2207                VmValue::string("cargo"),
2208                VmValue::string("test"),
2209            ])),
2210        );
2211        call_params.put_str("cwd", "/tmp/not-used");
2212        let value = dispatch_mock_hostlib_call("tools", "run_command", &call_params)
2213            .expect("expected legacy process.exec mock")
2214            .expect("legacy mock should succeed");
2215        assert_eq!(value.display(), "legacy");
2216        reset_host_state();
2217    }
2218
2219    #[test]
2220    fn hostlib_run_command_prefers_exact_mock_over_process_exec_alias() {
2221        reset_host_state();
2222        let mut params = crate::value::DictMap::new();
2223        params.put(
2224            "argv",
2225            VmValue::List(Arc::new(vec![
2226                VmValue::string("npm"),
2227                VmValue::string("test"),
2228            ])),
2229        );
2230        push_host_mock(HostMock {
2231            capability: "process".to_string(),
2232            operation: "exec".to_string(),
2233            params: Some(params.clone()),
2234            result: Some(VmValue::String(arcstr::ArcStr::from("legacy"))),
2235            error: None,
2236            unregistered_ok: false,
2237        });
2238        push_host_mock(HostMock {
2239            capability: "tools".to_string(),
2240            operation: "run_command".to_string(),
2241            params: Some(params.clone()),
2242            result: Some(VmValue::String(arcstr::ArcStr::from("direct"))),
2243            error: None,
2244            unregistered_ok: false,
2245        });
2246
2247        let value = dispatch_mock_hostlib_call("tools", "run_command", &params)
2248            .expect("expected exact hostlib mock")
2249            .expect("exact mock should succeed");
2250        assert_eq!(value.display(), "direct");
2251        reset_host_state();
2252    }
2253
2254    #[derive(Default)]
2255    struct TestHostToolBridge;
2256
2257    impl HostCallBridge for TestHostToolBridge {
2258        fn dispatch(
2259            &self,
2260            _capability: &str,
2261            _operation: &str,
2262            _params: &crate::value::DictMap,
2263        ) -> Result<Option<VmValue>, VmError> {
2264            Ok(None)
2265        }
2266
2267        fn list_tools(&self) -> Result<Option<VmValue>, VmError> {
2268            let tool = VmValue::dict(crate::value::DictMap::from_iter([
2269                (
2270                    crate::value::intern_key("name"),
2271                    VmValue::String(arcstr::ArcStr::from("Read".to_string())),
2272                ),
2273                (
2274                    crate::value::intern_key("description"),
2275                    VmValue::String(arcstr::ArcStr::from(
2276                        "Read a file from the host".to_string(),
2277                    )),
2278                ),
2279                (
2280                    crate::value::intern_key("schema"),
2281                    VmValue::dict(crate::value::DictMap::from_iter([(
2282                        crate::value::intern_key("type"),
2283                        VmValue::String(arcstr::ArcStr::from("object".to_string())),
2284                    )])),
2285                ),
2286                (crate::value::intern_key("deprecated"), VmValue::Bool(false)),
2287            ]));
2288            Ok(Some(VmValue::List(std::sync::Arc::new(vec![tool]))))
2289        }
2290
2291        fn call_tool(&self, name: &str, args: &VmValue) -> Result<Option<VmValue>, VmError> {
2292            if name != "Read" {
2293                return Ok(None);
2294            }
2295            let path = args
2296                .as_dict()
2297                .and_then(|dict| dict.get("path"))
2298                .map(|value| value.display())
2299                .unwrap_or_default();
2300            Ok(Some(VmValue::String(arcstr::ArcStr::from(format!(
2301                "read:{path}"
2302            )))))
2303        }
2304    }
2305
2306    struct CountingProcessExecBridge {
2307        calls: Arc<AtomicUsize>,
2308    }
2309
2310    impl HostCallBridge for CountingProcessExecBridge {
2311        fn dispatch(
2312            &self,
2313            capability: &str,
2314            operation: &str,
2315            _params: &crate::value::DictMap,
2316        ) -> Result<Option<VmValue>, VmError> {
2317            if (capability, operation) != ("process", "exec") {
2318                return Ok(None);
2319            }
2320            self.calls.fetch_add(1, Ordering::SeqCst);
2321            Ok(Some(VmValue::dict(crate::value::DictMap::from_iter([
2322                (
2323                    crate::value::intern_key("status"),
2324                    VmValue::String(arcstr::ArcStr::from("completed".to_string())),
2325                ),
2326                (crate::value::intern_key("exit_code"), VmValue::Int(0)),
2327                (crate::value::intern_key("success"), VmValue::Bool(true)),
2328            ]))))
2329        }
2330    }
2331
2332    fn run_host_async_test<F, Fut>(test: F)
2333    where
2334        F: FnOnce() -> Fut,
2335        Fut: std::future::Future<Output = ()>,
2336    {
2337        let rt = tokio::runtime::Builder::new_current_thread()
2338            .enable_all()
2339            .build()
2340            .expect("runtime");
2341        rt.block_on(async {
2342            let local = tokio::task::LocalSet::new();
2343            local.run_until(test()).await;
2344        });
2345    }
2346
2347    #[test]
2348    fn host_tool_list_uses_installed_host_call_bridge() {
2349        run_host_async_test(|| async {
2350            reset_host_state();
2351            set_host_call_bridge(Arc::new(TestHostToolBridge));
2352            let tools = dispatch_host_tool_list().await.expect("tool list");
2353            clear_host_call_bridge();
2354
2355            let VmValue::List(items) = tools else {
2356                panic!("expected tool list");
2357            };
2358            assert_eq!(items.len(), 1);
2359            let tool = items[0].as_dict().expect("tool dict");
2360            assert_eq!(tool.get("name").unwrap().display(), "Read");
2361            assert_eq!(tool.get("deprecated").unwrap().display(), "false");
2362        });
2363    }
2364
2365    #[test]
2366    fn host_tool_call_uses_installed_host_call_bridge() {
2367        run_host_async_test(|| async {
2368            set_host_call_bridge(Arc::new(TestHostToolBridge));
2369            let args = VmValue::dict(crate::value::DictMap::from_iter([(
2370                crate::value::intern_key("path"),
2371                VmValue::String(arcstr::ArcStr::from("README.md".to_string())),
2372            )]));
2373            let value = dispatch_host_tool_call("Read", &args)
2374                .await
2375                .expect("tool call");
2376            clear_host_call_bridge();
2377            assert_eq!(value.display(), "read:README.md");
2378        });
2379    }
2380
2381    #[test]
2382    fn process_exec_bridge_is_gated_by_command_policy() {
2383        run_host_async_test(|| async {
2384            crate::orchestration::clear_command_policies();
2385            let calls = Arc::new(AtomicUsize::new(0));
2386            set_host_call_bridge(Arc::new(CountingProcessExecBridge {
2387                calls: calls.clone(),
2388            }));
2389            crate::orchestration::push_command_policy(crate::orchestration::CommandPolicy {
2390                tools: vec!["run".to_string()],
2391                workspace_roots: Vec::new(),
2392                default_shell_mode: "shell".to_string(),
2393                deny_patterns: vec!["cat *".to_string()],
2394                require_approval: Default::default(),
2395                deny_labels: Default::default(),
2396                pre: None,
2397                post: None,
2398                consent: None,
2399                allow_recursive: false,
2400            });
2401
2402            let result = dispatch_host_operation(
2403                "process",
2404                "exec",
2405                &crate::value::DictMap::from_iter([
2406                    (
2407                        crate::value::intern_key("mode"),
2408                        VmValue::String(arcstr::ArcStr::from("shell")),
2409                    ),
2410                    (
2411                        crate::value::intern_key("command"),
2412                        VmValue::String(arcstr::ArcStr::from("cat Cargo.toml")),
2413                    ),
2414                ]),
2415            )
2416            .await
2417            .expect("process.exec result");
2418
2419            crate::orchestration::clear_command_policies();
2420            clear_host_call_bridge();
2421
2422            assert_eq!(
2423                calls.load(Ordering::SeqCst),
2424                0,
2425                "blocked command must not reach host bridge"
2426            );
2427            let result = result.as_dict().expect("blocked result dict");
2428            assert_eq!(result.get("status").unwrap().display(), "blocked");
2429            assert!(
2430                result
2431                    .get("reason")
2432                    .map(VmValue::display)
2433                    .unwrap_or_default()
2434                    .contains("cat *"),
2435                "blocked result should name the matched policy pattern"
2436            );
2437        });
2438    }
2439
2440    #[cfg(unix)]
2441    async fn process_exec_env_probe(env: VmValue, env_mode: Option<&str>) -> (String, String) {
2442        // Run `sh -c 'printf "%s|%s" "$PARENT_VAR" "$CHILD_VAR"'` so we can
2443        // observe whether an inherited parent var survives alongside the
2444        // explicitly-provided child var. The parent var is set on this
2445        // process's environment immediately before the spawn.
2446        std::env::set_var("PARENT_VAR", "inherited");
2447        let mut params = crate::value::DictMap::from_iter([
2448            (
2449                crate::value::intern_key("mode"),
2450                VmValue::String(arcstr::ArcStr::from("argv")),
2451            ),
2452            (
2453                crate::value::intern_key("argv"),
2454                VmValue::List(std::sync::Arc::new(vec![
2455                    // Absolute path so the spawn does not depend on PATH,
2456                    // which the `replace` case intentionally clears.
2457                    VmValue::String(arcstr::ArcStr::from("/bin/sh")),
2458                    VmValue::String(arcstr::ArcStr::from("-c")),
2459                    VmValue::String(arcstr::ArcStr::from(
2460                        "printf '%s|%s' \"$PARENT_VAR\" \"$CHILD_VAR\"",
2461                    )),
2462                ])),
2463            ),
2464            (crate::value::intern_key("env"), env),
2465        ]);
2466        if let Some(mode) = env_mode {
2467            params.put_str("env_mode", mode);
2468        }
2469        let result = super::dispatch_process_exec(&params, serde_json::Value::Null)
2470            .await
2471            .expect("process.exec result");
2472        let dict = result.as_dict().expect("result dict");
2473        let stdout = dict.get("stdout").map(VmValue::display).unwrap_or_default();
2474        std::env::remove_var("PARENT_VAR");
2475        let (parent, child) = stdout.split_once('|').unwrap_or((&stdout, ""));
2476        (parent.to_string(), child.to_string())
2477    }
2478
2479    #[cfg(unix)]
2480    #[test]
2481    fn process_exec_env_default_merges_with_parent() {
2482        run_host_async_test(|| async {
2483            // No `env_mode`: the provided key must be added WITHOUT clearing
2484            // the inherited parent environment (the env-clear footgun fix).
2485            let child_env = VmValue::dict(crate::value::DictMap::from_iter([(
2486                crate::value::intern_key("CHILD_VAR"),
2487                VmValue::String(arcstr::ArcStr::from("provided")),
2488            )]));
2489            let (parent, child) = process_exec_env_probe(child_env, None).await;
2490            assert_eq!(
2491                parent, "inherited",
2492                "default env_mode must inherit parent env"
2493            );
2494            assert_eq!(
2495                child, "provided",
2496                "default env_mode must apply provided keys"
2497            );
2498        });
2499    }
2500
2501    #[cfg(unix)]
2502    #[test]
2503    fn process_exec_env_mode_replace_clears_parent() {
2504        run_host_async_test(|| async {
2505            // Explicit `replace`: the inherited parent var must be gone and
2506            // only the provided key survives. This preserves the ability to
2507            // fully replace the environment when intentionally requested.
2508            let child_env = VmValue::dict(crate::value::DictMap::from_iter([(
2509                crate::value::intern_key("CHILD_VAR"),
2510                VmValue::String(arcstr::ArcStr::from("provided")),
2511            )]));
2512            let (parent, child) = process_exec_env_probe(child_env, Some("replace")).await;
2513            assert_eq!(parent, "", "explicit replace must clear parent env");
2514            assert_eq!(
2515                child, "provided",
2516                "explicit replace must keep provided keys"
2517            );
2518        });
2519    }
2520
2521    #[cfg(unix)]
2522    #[test]
2523    fn process_exec_env_mode_unknown_is_rejected() {
2524        run_host_async_test(|| async {
2525            let params = crate::value::DictMap::from_iter([
2526                (
2527                    crate::value::intern_key("mode"),
2528                    VmValue::String(arcstr::ArcStr::from("argv")),
2529                ),
2530                (
2531                    crate::value::intern_key("argv"),
2532                    VmValue::List(std::sync::Arc::new(vec![VmValue::String(
2533                        arcstr::ArcStr::from("true"),
2534                    )])),
2535                ),
2536                (
2537                    crate::value::intern_key("env"),
2538                    VmValue::dict(crate::value::DictMap::from_iter([(
2539                        crate::value::intern_key("CHILD_VAR"),
2540                        VmValue::String(arcstr::ArcStr::from("x")),
2541                    )])),
2542                ),
2543                (
2544                    crate::value::intern_key("env_mode"),
2545                    VmValue::String(arcstr::ArcStr::from("bogus")),
2546                ),
2547            ]);
2548            let err = super::dispatch_process_exec(&params, serde_json::Value::Null)
2549                .await
2550                .expect_err("unknown env_mode must error");
2551            assert!(
2552                format!("{err:?}").contains("env_mode"),
2553                "error should name env_mode, got {err:?}"
2554            );
2555        });
2556    }
2557
2558    // Drive the real `host_call("process","exec")` builder under a restricted
2559    // policy and read back the `$TMPDIR` the child actually saw. This is the
2560    // agent-facing path; the assertion is OS-independent (it observes the
2561    // injected env, not OS-sandbox enforcement), so it pins the mechanism on
2562    // every CI host while the live OS-level link proof runs on tornadough.
2563    #[cfg(unix)]
2564    async fn process_exec_tmpdir_probe(
2565        workspace: &std::path::Path,
2566        caller_env: Option<VmValue>,
2567    ) -> String {
2568        let mut env_pairs = vec![(
2569            crate::value::intern_key("mode"),
2570            VmValue::String(arcstr::ArcStr::from("argv")),
2571        )];
2572        env_pairs.push((
2573            crate::value::intern_key("argv"),
2574            VmValue::List(std::sync::Arc::new(vec![
2575                VmValue::String(arcstr::ArcStr::from("/bin/sh")),
2576                VmValue::String(arcstr::ArcStr::from("-c")),
2577                VmValue::String(arcstr::ArcStr::from("printf '%s' \"$TMPDIR\"")),
2578            ])),
2579        ));
2580        if let Some(env) = caller_env {
2581            env_pairs.push((crate::value::intern_key("env"), env));
2582        }
2583        let params = crate::value::DictMap::from_iter(env_pairs);
2584
2585        crate::orchestration::push_execution_policy(crate::orchestration::CapabilityPolicy {
2586            sandbox_profile: crate::orchestration::SandboxProfile::Worktree,
2587            workspace_roots: vec![workspace.to_string_lossy().into_owned()],
2588            // Keep OS confinement out of this unit assertion regardless of host
2589            // Landlock/seatbelt availability; we are pinning the env injection,
2590            // not OS enforcement (which the tornadough run proves end-to-end).
2591            ..crate::orchestration::CapabilityPolicy::default()
2592        });
2593        std::env::set_var("HARN_HANDLER_SANDBOX", "off");
2594        let result = super::dispatch_process_exec(&params, serde_json::Value::Null)
2595            .await
2596            .expect("process.exec result");
2597        std::env::remove_var("HARN_HANDLER_SANDBOX");
2598        crate::orchestration::pop_execution_policy();
2599        result
2600            .as_dict()
2601            .and_then(|d| d.get("stdout"))
2602            .map(VmValue::display)
2603            .unwrap_or_default()
2604    }
2605
2606    #[cfg(unix)]
2607    #[test]
2608    fn process_exec_injects_workspace_local_tmpdir() {
2609        run_host_async_test(|| async {
2610            let workspace = tempfile::tempdir().expect("workspace");
2611            let tmpdir = process_exec_tmpdir_probe(workspace.path(), None).await;
2612
2613            assert!(
2614                !tmpdir.is_empty(),
2615                "sandboxed child must receive a non-empty TMPDIR"
2616            );
2617            let tmpdir_path = std::path::PathBuf::from(&tmpdir);
2618            let canonical_tmpdir = std::fs::canonicalize(&tmpdir_path)
2619                .expect("workspace-local TMPDIR should canonicalize");
2620            let canonical_workspace =
2621                std::fs::canonicalize(workspace.path()).expect("workspace should canonicalize");
2622            assert!(
2623                canonical_tmpdir.starts_with(&canonical_workspace),
2624                "child TMPDIR {tmpdir:?} must live inside the workspace {:?}",
2625                workspace.path()
2626            );
2627            assert!(
2628                tmpdir_path.ends_with(".harn-tmp"),
2629                "child TMPDIR {tmpdir:?} must be the workspace-local .harn-tmp dir"
2630            );
2631            assert!(
2632                tmpdir_path.is_dir(),
2633                "the workspace-local TMPDIR must have been created on disk"
2634            );
2635        });
2636    }
2637
2638    #[cfg(unix)]
2639    #[test]
2640    fn process_exec_respects_caller_pinned_tmpdir() {
2641        run_host_async_test(|| async {
2642            let workspace = tempfile::tempdir().expect("workspace");
2643            let caller_tmp = workspace.path().join("caller-chosen");
2644            std::fs::create_dir_all(&caller_tmp).unwrap();
2645            let caller_env = VmValue::dict(crate::value::DictMap::from_iter([(
2646                crate::value::intern_key("TMPDIR"),
2647                VmValue::String(arcstr::ArcStr::from(
2648                    caller_tmp.to_string_lossy().into_owned(),
2649                )),
2650            )]));
2651
2652            let tmpdir = process_exec_tmpdir_probe(workspace.path(), Some(caller_env)).await;
2653
2654            assert_eq!(
2655                std::path::PathBuf::from(&tmpdir),
2656                caller_tmp,
2657                "an explicit caller TMPDIR must override the workspace-local default"
2658            );
2659        });
2660    }
2661
2662    #[test]
2663    fn host_tool_list_is_empty_without_bridge() {
2664        run_host_async_test(|| async {
2665            clear_host_call_bridge();
2666            let tools = dispatch_host_tool_list().await.expect("tool list");
2667            let VmValue::List(items) = tools else {
2668                panic!("expected tool list");
2669            };
2670            assert!(items.is_empty());
2671        });
2672    }
2673}