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