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 the typed execution project root,
673            // then the legacy env root, then the current working directory.
674            // Pipelines call this very early, so crashing here would block any
675            // debug-launched script.
676            let path = crate::stdlib::process::project_root_path()
677                .map(|root| root.display().to_string())
678                .or_else(|| std::env::var("HARN_PROJECT_ROOT").ok())
679                .unwrap_or_else(|| {
680                    std::env::current_dir()
681                        .map(|p| p.display().to_string())
682                        .unwrap_or_default()
683                });
684            Ok(VmValue::String(arcstr::ArcStr::from(path)))
685        }
686        ("workspace", "cwd") => {
687            let path = std::env::current_dir()
688                .map(|p| p.display().to_string())
689                .unwrap_or_default();
690            Ok(VmValue::String(arcstr::ArcStr::from(path)))
691        }
692        _ => Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
693            format!("host_call: unsupported operation {capability}.{operation}"),
694        )))),
695    }
696}
697
698pub(crate) async fn dispatch_process_exec(
699    params: &crate::value::DictMap,
700    caller: serde_json::Value,
701) -> Result<VmValue, VmError> {
702    dispatch_process_exec_with_policy(None, params, caller).await
703}
704
705async fn dispatch_process_exec_with_policy(
706    ctx: Option<&AsyncBuiltinCtx>,
707    params: &crate::value::DictMap,
708    caller: serde_json::Value,
709) -> Result<VmValue, VmError> {
710    let (params, command_policy_context, command_policy_decisions) =
711        match crate::orchestration::run_command_policy_preflight_with_ctx(ctx, params, caller)
712            .await?
713        {
714            crate::orchestration::CommandPolicyPreflight::Proceed {
715                params,
716                context,
717                decisions,
718            } => (params, context, decisions),
719            crate::orchestration::CommandPolicyPreflight::Blocked {
720                status,
721                message,
722                context,
723                decisions,
724            } => {
725                return Ok(crate::orchestration::blocked_command_response(
726                    params, status, &message, context, decisions,
727                ));
728            }
729        };
730
731    let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
732    if let Some(bridge) = bridge {
733        if let Some(value) = bridge.dispatch("process", "exec", &params)? {
734            return crate::orchestration::run_command_policy_postflight_with_ctx(
735                ctx,
736                &params,
737                value,
738                command_policy_context,
739                command_policy_decisions,
740            )
741            .await;
742        }
743    }
744
745    dispatch_process_exec_after_policy(
746        ctx,
747        &params,
748        command_policy_context,
749        command_policy_decisions,
750    )
751    .await
752}
753
754/// Apply the command-policy preflight (deny-patterns, approval gating,
755/// sandbox decisions) and then spawn the process non-blocking. Mirrors
756/// [`dispatch_process_exec_with_policy`] so spawn is gated identically to
757/// exec. There is no postflight here: spawn returns a handle immediately,
758/// not a completed command result; completion is observed later via
759/// poll/wait, which are not themselves command executions.
760async fn dispatch_process_spawn_with_policy(
761    ctx: Option<&AsyncBuiltinCtx>,
762    params: &crate::value::DictMap,
763    caller: serde_json::Value,
764) -> Result<VmValue, VmError> {
765    let params =
766        match crate::orchestration::run_command_policy_preflight_with_ctx(ctx, params, caller)
767            .await?
768        {
769            crate::orchestration::CommandPolicyPreflight::Proceed { params, .. } => params,
770            crate::orchestration::CommandPolicyPreflight::Blocked {
771                status,
772                message,
773                context,
774                decisions,
775            } => {
776                return Ok(crate::orchestration::blocked_command_response(
777                    params, status, &message, context, decisions,
778                ));
779            }
780        };
781
782    match crate::stdlib::process_spawn::dispatch("spawn", &params).await {
783        Some(result) => result,
784        None => Err(VmError::Runtime(
785            "host_call process.spawn: dispatch returned None".to_string(),
786        )),
787    }
788}
789
790async fn dispatch_process_exec_after_policy(
791    ctx: Option<&AsyncBuiltinCtx>,
792    params: &crate::value::DictMap,
793    command_policy_context: JsonValue,
794    command_policy_decisions: Vec<crate::orchestration::CommandPolicyDecision>,
795) -> Result<VmValue, VmError> {
796    let timeout_ms = optional_i64(params, "timeout")
797        .or_else(|| optional_i64(params, "timeout_ms"))
798        .filter(|value| *value > 0)
799        .map(|value| value as u64);
800    // Optional per-call profile override. Pipelines that want to
801    // promote a single spawn to `os_hardened` (e.g. running
802    // attacker-controlled code) pass `sandbox_profile: "os_hardened"`
803    // without having to rewrite the surrounding policy. The override
804    // is scoped to this call and pops with the guard at end-of-scope.
805    let profile_guard = match optional_string(params, "sandbox_profile") {
806        Some(value) => Some(push_sandbox_profile_override(&value)?),
807        None => None,
808    };
809    let mut cmd = build_sandboxed_command(params, "process.exec")?;
810    cmd.stdin(std::process::Stdio::null())
811        .stdout(std::process::Stdio::piped())
812        .stderr(std::process::Stdio::piped())
813        .kill_on_drop(true);
814    let started_at = audited_utc_now_rfc3339("host_call/process.exec.started_at");
815    let started = crate::clock_mock::leak_audit::instant_now("host_call/process.exec.started");
816    let child = cmd
817        .spawn()
818        .map_err(|e| VmError::Runtime(format!("host_call process.exec: {e}")))?;
819    drop(profile_guard);
820    let pid = child.id();
821    let timed_out;
822    let output_result = if let Some(timeout_ms) = timeout_ms {
823        match tokio::time::timeout(
824            std::time::Duration::from_millis(timeout_ms),
825            child.wait_with_output(),
826        )
827        .await
828        {
829            Ok(result) => {
830                timed_out = false;
831                result
832            }
833            Err(_) => {
834                let response = process_exec_response(ProcessExecResponse {
835                    pid,
836                    started_at,
837                    started,
838                    stdout: "",
839                    stderr: "",
840                    exit_code: -1,
841                    status: "timed_out",
842                    success: false,
843                    timed_out: true,
844                });
845                return crate::orchestration::run_command_policy_postflight_with_ctx(
846                    ctx,
847                    params,
848                    response,
849                    command_policy_context,
850                    command_policy_decisions,
851                )
852                .await;
853            }
854        }
855    } else {
856        timed_out = false;
857        child.wait_with_output().await
858    };
859    let output =
860        output_result.map_err(|e| VmError::Runtime(format!("host_call process.exec: {e}")))?;
861    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
862    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
863    let exit_code = output.status.code().unwrap_or(-1);
864    let response = process_exec_response(ProcessExecResponse {
865        pid,
866        started_at,
867        started,
868        stdout: &stdout,
869        stderr: &stderr,
870        exit_code,
871        status: if timed_out { "timed_out" } else { "completed" },
872        success: output.status.success(),
873        timed_out,
874    });
875    crate::orchestration::run_command_policy_postflight_with_ctx(
876        ctx,
877        params,
878        response,
879        command_policy_context,
880        command_policy_decisions,
881    )
882    .await
883}
884
885/// Build a sandboxed `tokio::process::Command` from process-call params,
886/// applying argv/shell resolution, the active sandbox policy via
887/// [`crate::process_sandbox::tokio_command_for`], cwd enforcement, and
888/// env/env_mode/env_remove handling.
889///
890/// Shared by `process.exec` (synchronous) and `process.spawn`
891/// (non-blocking) so both go through the identical sandbox-gated build
892/// path. The caller is responsible for any `sandbox_profile` override
893/// guard (it must be live across this call) and for setting stdio/kill
894/// behaviour on the returned command. `label` ("process.exec" or
895/// "process.spawn") is woven into error messages.
896pub(crate) fn build_sandboxed_command(
897    params: &crate::value::DictMap,
898    label: &str,
899) -> Result<tokio::process::Command, VmError> {
900    let (program, args) = process_exec_argv(params)?;
901    let mut cmd = crate::process_sandbox::tokio_command_for(&program, &args)
902        .map_err(|e| VmError::Runtime(format!("host_call {label} sandbox setup: {e}")))?;
903    if let Some(cwd) = optional_string(params, "cwd") {
904        let cwd = resolve_process_exec_cwd(&cwd);
905        crate::process_sandbox::enforce_process_cwd(&cwd)
906            .map_err(|e| VmError::Runtime(format!("host_call {label} cwd: {e}")))?;
907        cmd.current_dir(cwd);
908    }
909    // Track keys the caller set explicitly so the sandbox-local TMPDIR overlay
910    // below never clobbers an intentional per-call value.
911    let mut caller_env_keys: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
912    if let Some(env) = optional_string_dict(params, "env")? {
913        // `env_mode` controls how the provided `env` keys combine with the
914        // parent environment:
915        //   - "merge" (default): inherit the parent env and overlay the
916        //     provided keys. This is the least-surprising behavior — a
917        //     caller passing `env: {ONE_VAR: "x"}` keeps PATH/HOME/etc.
918        //   - "replace": clear the parent env entirely, then set only the
919        //     provided keys. This is the footgun shape and must be requested
920        //     explicitly whenever `env` is supplied.
921        let env_mode = optional_string(params, "env_mode");
922        match env_mode.as_deref().unwrap_or("merge") {
923            "replace" => {
924                cmd.env_clear();
925            }
926            "merge" => {}
927            other => {
928                return Err(VmError::Runtime(format!(
929                    "host_call {label}: unknown env_mode {other:?}; expected \"merge\" or \"replace\""
930                )));
931            }
932        }
933        for (key, value) in env {
934            caller_env_keys.insert(key.clone());
935            cmd.env(key, value);
936        }
937    }
938    // env_remove: list of environment variable names to strip before
939    // spawning. Applied after `env` so callers can both inherit and
940    // selectively unset (e.g. the git stdlib strips `GIT_*` so its
941    // operations are self-contained even when Harn is invoked from
942    // inside a git hook that sets `GIT_DIR`).
943    if let Some(env_remove) = optional_string_list(params, "env_remove") {
944        for key in env_remove {
945            caller_env_keys.insert(key.clone());
946            cmd.env_remove(key);
947        }
948    }
949    // Point the child's temp dir at a sandbox-writable, workspace-local
950    // location so compiler linkers (rustc/cc/ld, Go, Swift, …) and other
951    // toolchains that honor TMPDIR/TMP/TEMP don't false-fail trying to write
952    // intermediates to the unwritable system /tmp. A key the caller set (via
953    // `env`) or explicitly stripped (via `env_remove`) is left as the caller
954    // intended; only keys the caller did not touch receive the overlay. No-op
955    // when the active profile is unrestricted or no writable workspace root is
956    // available.
957    for (key, value) in crate::process_sandbox::active_workspace_tmpdir_env() {
958        if caller_env_keys.contains(&key) {
959            continue;
960        }
961        cmd.env(key, value);
962    }
963    // Pin tool *message* output to a deterministic English/UTF-8 locale so
964    // downstream English-diagnostic matchers (deterministic syntax repair,
965    // error-signature grounding, completion/pass-fail classification) do not
966    // misfire for a non-Anglosphere user whose shell localizes compiler/test
967    // output. A user-inherited `LC_ALL` overrides `LC_MESSAGES`, so strip it
968    // first — unless the caller pinned it via `env`/`env_remove` — then apply
969    // the overlay with the same caller-wins rule as the TMPDIR overlay above.
970    if !caller_env_keys.contains(crate::process_sandbox::MESSAGE_LOCALE_OVERRIDE_ENV) {
971        cmd.env_remove(crate::process_sandbox::MESSAGE_LOCALE_OVERRIDE_ENV);
972    }
973    for (key, value) in crate::process_sandbox::deterministic_message_locale_env() {
974        if caller_env_keys.contains(&key) {
975            continue;
976        }
977        cmd.env(key, value);
978    }
979    Ok(cmd)
980}
981
982struct ProcessExecResponse<'a> {
983    pid: Option<u32>,
984    started_at: String,
985    started: Instant,
986    stdout: &'a str,
987    stderr: &'a str,
988    exit_code: i32,
989    status: &'a str,
990    success: bool,
991    timed_out: bool,
992}
993
994fn process_exec_response(response: ProcessExecResponse<'_>) -> VmValue {
995    let combined = format!("{}{}", response.stdout, response.stderr);
996    let mut result = crate::value::DictMap::new();
997    result.put_str(
998        "command_id",
999        format!(
1000            "cmd_{}_{}",
1001            std::process::id(),
1002            response.started.elapsed().as_nanos()
1003        ),
1004    );
1005    result.put_str("status", response.status);
1006    result.insert(
1007        crate::value::intern_key("pid"),
1008        response
1009            .pid
1010            .map(|pid| VmValue::Int(pid as i64))
1011            .unwrap_or(VmValue::Nil),
1012    );
1013    result.insert(
1014        crate::value::intern_key("process_group_id"),
1015        response
1016            .pid
1017            .map(|pid| VmValue::Int(pid as i64))
1018            .unwrap_or(VmValue::Nil),
1019    );
1020    result.insert(crate::value::intern_key("handle_id"), VmValue::Nil);
1021    result.put_str("started_at", response.started_at);
1022    result.put_str(
1023        "ended_at",
1024        audited_utc_now_rfc3339("host_call/process.exec.ended_at"),
1025    );
1026    result.insert(
1027        crate::value::intern_key("duration_ms"),
1028        VmValue::Int(response.started.elapsed().as_millis() as i64),
1029    );
1030    result.insert(
1031        crate::value::intern_key("exit_code"),
1032        VmValue::Int(response.exit_code as i64),
1033    );
1034    result.insert(crate::value::intern_key("signal"), VmValue::Nil);
1035    result.insert(
1036        crate::value::intern_key("timed_out"),
1037        VmValue::Bool(response.timed_out),
1038    );
1039    result.put_str("stdout", response.stdout);
1040    result.put_str("stderr", response.stderr);
1041    result.put_str("combined", combined);
1042    result.insert(
1043        crate::value::intern_key("exit_status"),
1044        VmValue::Int(response.exit_code as i64),
1045    );
1046    result.insert(
1047        crate::value::intern_key("legacy_status"),
1048        VmValue::Int(response.exit_code as i64),
1049    );
1050    result.insert(
1051        crate::value::intern_key("success"),
1052        VmValue::Bool(response.success),
1053    );
1054    VmValue::dict(result)
1055}
1056
1057fn resolve_process_exec_cwd(cwd: &str) -> std::path::PathBuf {
1058    crate::stdlib::process::resolve_source_relative_path(cwd)
1059}
1060
1061fn process_exec_argv(params: &crate::value::DictMap) -> Result<(String, Vec<String>), VmError> {
1062    match optional_string(params, "mode")
1063        .as_deref()
1064        .unwrap_or("shell")
1065    {
1066        "argv" => {
1067            let argv = optional_string_list(params, "argv").ok_or_else(|| {
1068                VmError::Runtime("host_call process.exec missing argv".to_string())
1069            })?;
1070            split_argv(argv)
1071        }
1072        "shell" => {
1073            let command = require_param(params, "command")?;
1074            let mut invocation_params = params.clone();
1075            invocation_params.put_str("command", command);
1076            let invocation =
1077                crate::shells::resolve_invocation_from_vm_params(&invocation_params)
1078                    .map_err(|err| VmError::Runtime(format!("host_call process.exec: {err}")))?;
1079            Ok((invocation.program, invocation.args))
1080        }
1081        other => Err(VmError::Runtime(format!(
1082            "host_call process.exec unsupported mode {other:?}"
1083        ))),
1084    }
1085}
1086
1087fn split_argv(mut argv: Vec<String>) -> Result<(String, Vec<String>), VmError> {
1088    if argv.is_empty() {
1089        return Err(VmError::Runtime(
1090            "host_call process.exec argv must not be empty".to_string(),
1091        ));
1092    }
1093    let program = argv.remove(0);
1094    if program.is_empty() {
1095        return Err(VmError::Runtime(
1096            "host_call process.exec argv[0] must not be empty".to_string(),
1097        ));
1098    }
1099    Ok((program, argv))
1100}
1101
1102/// Push a transient policy onto the execution stack with the
1103/// requested sandbox profile, returning a guard that pops on drop.
1104/// Used by `host_call("process", "exec", ...)` to honor a per-call
1105/// `sandbox_profile` override without rewriting the surrounding
1106/// orchestration policy.
1107pub(crate) fn push_sandbox_profile_override(value: &str) -> Result<SandboxProfileGuard, VmError> {
1108    let profile = crate::orchestration::SandboxProfile::parse(value).ok_or_else(|| {
1109        VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
1110            "host_call process.exec: unknown sandbox_profile {value:?}; expected one of \"unrestricted\", \"worktree\", \"os_hardened\", \"wasi\""
1111        ))))
1112    })?;
1113    let mut policy = crate::orchestration::current_execution_policy().unwrap_or_default();
1114    policy.sandbox_profile = profile;
1115    crate::orchestration::push_execution_policy(policy);
1116    Ok(SandboxProfileGuard {
1117        _private: std::marker::PhantomData,
1118    })
1119}
1120
1121pub(crate) struct SandboxProfileGuard {
1122    _private: std::marker::PhantomData<*const ()>,
1123}
1124
1125impl Drop for SandboxProfileGuard {
1126    fn drop(&mut self) {
1127        crate::orchestration::pop_execution_policy();
1128    }
1129}
1130
1131pub(crate) fn optional_i64(params: &crate::value::DictMap, key: &str) -> Option<i64> {
1132    match params.get(key) {
1133        Some(VmValue::Int(value)) => Some(*value),
1134        Some(VmValue::Float(value)) if value.fract() == 0.0 => Some(*value as i64),
1135        _ => None,
1136    }
1137}
1138
1139pub(crate) fn optional_string(params: &crate::value::DictMap, key: &str) -> Option<String> {
1140    params.get(key).and_then(vm_string).map(ToString::to_string)
1141}
1142
1143fn optional_string_list(params: &crate::value::DictMap, key: &str) -> Option<Vec<String>> {
1144    let VmValue::List(values) = params.get(key)? else {
1145        return None;
1146    };
1147    values
1148        .iter()
1149        .map(|value| vm_string(value).map(ToString::to_string))
1150        .collect()
1151}
1152
1153fn optional_string_dict(
1154    params: &crate::value::DictMap,
1155    key: &str,
1156) -> Result<Option<BTreeMap<String, String>>, VmError> {
1157    let Some(value) = params.get(key) else {
1158        return Ok(None);
1159    };
1160    let Some(dict) = value.as_dict() else {
1161        return Err(VmError::Runtime(format!(
1162            "host_call process.exec {key} must be a dict"
1163        )));
1164    };
1165    let mut out = std::collections::BTreeMap::new();
1166    for (key, value) in dict.iter() {
1167        let Some(value) = vm_string(value) else {
1168            return Err(VmError::Runtime(format!(
1169                "host_call process.exec env value for {key:?} must be a string"
1170            )));
1171        };
1172        out.insert(key.to_string(), value.to_string());
1173    }
1174    Ok(Some(out))
1175}
1176
1177fn vm_string(value: &VmValue) -> Option<&str> {
1178    match value {
1179        VmValue::String(value) => Some(value.as_ref()),
1180        _ => None,
1181    }
1182}
1183
1184pub(crate) fn register_host_builtins(vm: &mut Vm) {
1185    for def in MODULE_BUILTINS {
1186        vm.register_builtin_def(def);
1187    }
1188}
1189
1190pub(crate) fn register_missing_host_builtins(vm: &mut Vm) {
1191    for def in MODULE_BUILTINS {
1192        if vm.builtin_metadata_for(def.sig.name).is_none() {
1193            vm.register_builtin_def(def);
1194        }
1195    }
1196}
1197
1198#[harn_builtin(
1199    sig = "host_mock(capability: string, op: string, response_or_config?: any, params?: dict) -> nil",
1200    category = "host"
1201)]
1202fn host_mock_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1203    let host_mock = parse_host_mock(args)?;
1204    push_host_mock(host_mock);
1205    Ok(VmValue::Nil)
1206}
1207
1208#[harn_builtin(sig = "host_mock_clear() -> nil", category = "host")]
1209fn host_mock_clear_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1210    reset_host_state();
1211    Ok(VmValue::Nil)
1212}
1213
1214#[harn_builtin(sig = "host_mock_calls() -> list", category = "host")]
1215fn host_mock_calls_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1216    let calls = HOST_MOCK_CALLS.with(|calls| {
1217        calls
1218            .borrow()
1219            .iter()
1220            .map(mock_call_value)
1221            .collect::<Vec<_>>()
1222    });
1223    Ok(VmValue::List(std::sync::Arc::new(calls)))
1224}
1225
1226#[harn_builtin(sig = "host_mock_push_scope() -> nil", category = "host")]
1227fn host_mock_push_scope_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1228    push_host_mock_scope();
1229    Ok(VmValue::Nil)
1230}
1231
1232#[harn_builtin(sig = "host_mock_pop_scope() -> nil", category = "host")]
1233fn host_mock_pop_scope_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1234    if !pop_host_mock_scope() {
1235        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1236            "host_mock_pop_scope: no scope to pop",
1237        ))));
1238    }
1239    Ok(VmValue::Nil)
1240}
1241
1242#[harn_builtin(sig = "host_capabilities() -> dict", category = "host")]
1243fn host_capabilities_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1244    Ok(capability_manifest_with_mocks())
1245}
1246
1247#[harn_builtin(
1248    sig = "host_has(capability: string, op?: string) -> bool",
1249    category = "host"
1250)]
1251fn host_has_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1252    let capability = args.first().map(|a| a.display()).unwrap_or_default();
1253    let operation = args.get(1).map(|a| a.display());
1254    let manifest = capability_manifest_with_mocks();
1255    let has = manifest
1256        .as_dict()
1257        .and_then(|d| d.get(capability.as_str()))
1258        .and_then(|v| v.as_dict())
1259        .is_some_and(|cap| {
1260            if let Some(operation) = operation {
1261                cap.get("ops")
1262                    .and_then(|v| match v {
1263                        VmValue::List(list) => {
1264                            Some(list.iter().any(|item| item.display() == operation))
1265                        }
1266                        _ => None,
1267                    })
1268                    .unwrap_or(false)
1269            } else {
1270                true
1271            }
1272        });
1273    Ok(VmValue::Bool(has))
1274}
1275
1276#[harn_builtin(
1277    sig = "host_call(name: string, args?: dict) -> any",
1278    kind = "async",
1279    category = "host"
1280)]
1281async fn host_call_builtin(
1282    ctx: crate::vm::AsyncBuiltinCtx,
1283    args: Vec<VmValue>,
1284) -> Result<VmValue, VmError> {
1285    let name = args.first().map(|a| a.display()).unwrap_or_default();
1286    let params = args
1287        .get(1)
1288        .and_then(|a| a.as_dict())
1289        .cloned()
1290        .unwrap_or_default();
1291    let Some((capability, operation)) = name.split_once('.') else {
1292        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1293            format!("host_call: unsupported operation name '{name}'"),
1294        ))));
1295    };
1296    dispatch_host_operation_with_ctx(Some(&ctx), capability, operation, &params).await
1297}
1298
1299#[harn_builtin(sig = "host_tool_list() -> list", kind = "async", category = "host")]
1300async fn host_tool_list_builtin(
1301    ctx: crate::vm::AsyncBuiltinCtx,
1302    _args: Vec<VmValue>,
1303) -> Result<VmValue, VmError> {
1304    dispatch_host_tool_list_with_ctx(Some(&ctx)).await
1305}
1306
1307#[harn_builtin(
1308    sig = "host_tool_call(name: string, args?: any) -> any",
1309    kind = "async",
1310    category = "host"
1311)]
1312async fn host_tool_call_builtin(
1313    ctx: crate::vm::AsyncBuiltinCtx,
1314    args: Vec<VmValue>,
1315) -> Result<VmValue, VmError> {
1316    let name = args.first().map(|a| a.display()).unwrap_or_default();
1317    if name.is_empty() {
1318        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1319            "host_tool_call: tool name is required",
1320        ))));
1321    }
1322    let call_args = args.get(1).cloned().unwrap_or(VmValue::Nil);
1323    dispatch_host_tool_call_with_ctx(Some(&ctx), &name, &call_args).await
1324}
1325
1326#[cfg(test)]
1327mod tests {
1328    use super::{
1329        build_sandboxed_command, capability_manifest_with_mocks, clear_host_call_bridge,
1330        dispatch_host_operation, dispatch_host_tool_call, dispatch_host_tool_list,
1331        dispatch_mock_host_call, dispatch_mock_hostlib_call, push_host_mock, reset_host_state,
1332        resolve_process_exec_cwd, set_host_call_bridge, HostCallBridge, HostMock,
1333    };
1334    use crate::value::VmDictExt;
1335
1336    use std::sync::{
1337        atomic::{AtomicUsize, Ordering},
1338        Arc,
1339    };
1340
1341    use crate::value::{VmError, VmValue};
1342
1343    /// Collect a built command's env mutations as `(name, Option<value>)`,
1344    /// where `None` marks a variable the command removes from the inherited
1345    /// environment.
1346    fn command_env(
1347        cmd: &tokio::process::Command,
1348    ) -> std::collections::BTreeMap<String, Option<String>> {
1349        cmd.as_std()
1350            .get_envs()
1351            .map(|(k, v)| {
1352                (
1353                    k.to_string_lossy().into_owned(),
1354                    v.map(|value| value.to_string_lossy().into_owned()),
1355                )
1356            })
1357            .collect()
1358    }
1359
1360    #[test]
1361    fn build_sandboxed_command_forces_deterministic_message_locale() {
1362        // A verify command spawned by a non-Anglosphere user whose *shell*
1363        // exports LC_ALL (inherited via the parent env, NOT pinned by the
1364        // caller's `env` dict) must still emit English diagnostics, or the
1365        // downstream English-keyed matchers (syntax repair, error grounding,
1366        // pass/fail classification) misfire. In merge mode the child inherits
1367        // the parent env implicitly, so the builder must issue an explicit
1368        // LC_ALL removal — observable here as a `(key, None)` mutation — and
1369        // pin LC_MESSAGES=C + DOTNET_CLI_UI_LANGUAGE=en. The caller pins no
1370        // locale key here, so the overlay engages.
1371        let mut params = crate::value::DictMap::new();
1372        params.put_str("mode", "argv");
1373        params.put(
1374            "argv",
1375            VmValue::List(Arc::new(vec![VmValue::string("/bin/true")])),
1376        );
1377        params.put_str("env_mode", "merge");
1378        let mut caller_env = crate::value::DictMap::new();
1379        // An innocuous caller env key that must NOT suppress the locale overlay.
1380        caller_env.put_str("CARGO_TARGET_DIR", "/tmp/target");
1381        params.put("env", VmValue::dict_map(caller_env));
1382
1383        let cmd = build_sandboxed_command(&params, "process.exec").expect("build command");
1384        let env = command_env(&cmd);
1385
1386        assert_eq!(
1387            env.get("LC_ALL"),
1388            Some(&None),
1389            "the builder must remove LC_ALL from the child so an inherited shell \
1390             value cannot override the forced LC_MESSAGES"
1391        );
1392        assert_eq!(
1393            env.get("LC_MESSAGES"),
1394            Some(&Some("C".to_string())),
1395            "LC_MESSAGES must be pinned to C for untranslated (English) tool output"
1396        );
1397        assert_eq!(
1398            env.get("DOTNET_CLI_UI_LANGUAGE"),
1399            Some(&Some("en".to_string())),
1400            ".NET ignores LC_* and needs its own UI-language override"
1401        );
1402    }
1403
1404    #[test]
1405    fn build_sandboxed_command_respects_a_caller_pinned_locale() {
1406        // A caller that explicitly pins the locale keys (or LC_ALL) wins over
1407        // the deterministic overlay — same caller-wins rule as TMPDIR.
1408        let mut params = crate::value::DictMap::new();
1409        params.put_str("mode", "argv");
1410        params.put(
1411            "argv",
1412            VmValue::List(Arc::new(vec![VmValue::string("/bin/true")])),
1413        );
1414        params.put_str("env_mode", "merge");
1415        let mut caller_env = crate::value::DictMap::new();
1416        caller_env.put_str("LC_ALL", "fr_FR.UTF-8");
1417        caller_env.put_str("LC_MESSAGES", "fr_FR.UTF-8");
1418        params.put("env", VmValue::dict_map(caller_env));
1419
1420        let cmd = build_sandboxed_command(&params, "process.exec").expect("build command");
1421        let env = command_env(&cmd);
1422
1423        assert_eq!(
1424            env.get("LC_ALL"),
1425            Some(&Some("fr_FR.UTF-8".to_string())),
1426            "a caller that pins LC_ALL keeps it — the overlay must not strip an explicit value"
1427        );
1428        assert_eq!(
1429            env.get("LC_MESSAGES"),
1430            Some(&Some("fr_FR.UTF-8".to_string())),
1431            "a caller-pinned LC_MESSAGES wins over the C overlay"
1432        );
1433    }
1434
1435    #[test]
1436    fn process_exec_relative_cwd_resolves_against_execution_root() {
1437        let dir = tempfile::tempdir().expect("tempdir");
1438        crate::stdlib::process::set_thread_execution_context(Some(
1439            crate::orchestration::RunExecutionRecord {
1440                cwd: Some(dir.path().to_string_lossy().into_owned()),
1441                project_root: None,
1442                source_dir: Some(dir.path().join("src").to_string_lossy().into_owned()),
1443                env: std::collections::BTreeMap::new(),
1444                adapter: None,
1445                repo_path: None,
1446                worktree_path: None,
1447                branch: None,
1448                base_ref: None,
1449                cleanup: None,
1450            },
1451        ));
1452
1453        assert_eq!(
1454            resolve_process_exec_cwd("subdir"),
1455            dir.path().join("subdir")
1456        );
1457
1458        crate::stdlib::process::set_thread_execution_context(None);
1459    }
1460
1461    #[test]
1462    fn workspace_project_root_fallback_prefers_execution_context_project_root() {
1463        run_host_async_test(|| async {
1464            let project = tempfile::tempdir().expect("project root");
1465            let cwd = tempfile::tempdir().expect("cwd");
1466            crate::stdlib::process::set_thread_execution_context(Some(
1467                crate::orchestration::RunExecutionRecord {
1468                    cwd: Some(cwd.path().to_string_lossy().into_owned()),
1469                    project_root: Some(project.path().to_string_lossy().into_owned()),
1470                    source_dir: None,
1471                    env: std::collections::BTreeMap::new(),
1472                    adapter: None,
1473                    repo_path: None,
1474                    worktree_path: None,
1475                    branch: None,
1476                    base_ref: None,
1477                    cleanup: None,
1478                },
1479            ));
1480
1481            let result =
1482                dispatch_host_operation("workspace", "project_root", &crate::value::DictMap::new())
1483                    .await
1484                    .expect("workspace.project_root result");
1485
1486            crate::stdlib::process::set_thread_execution_context(None);
1487            assert_eq!(result.display(), project.path().display().to_string());
1488        });
1489    }
1490
1491    #[test]
1492    fn manifest_includes_operation_metadata() {
1493        let manifest = capability_manifest_with_mocks();
1494        let process = manifest
1495            .as_dict()
1496            .and_then(|d| d.get("process"))
1497            .and_then(|v| v.as_dict())
1498            .expect("process capability");
1499        assert!(process.get("description").is_some());
1500        let operations = process
1501            .get("operations")
1502            .and_then(|v| v.as_dict())
1503            .expect("operations dict");
1504        assert!(operations.get("exec").is_some());
1505    }
1506
1507    #[test]
1508    fn mocked_capabilities_appear_in_manifest() {
1509        reset_host_state();
1510        push_host_mock(HostMock {
1511            capability: "project".to_string(),
1512            operation: "metadata_get".to_string(),
1513            params: None,
1514            result: Some(VmValue::dict(crate::value::DictMap::new())),
1515            error: None,
1516        });
1517        let manifest = capability_manifest_with_mocks();
1518        let project = manifest
1519            .as_dict()
1520            .and_then(|d| d.get("project"))
1521            .and_then(|v| v.as_dict())
1522            .expect("project capability");
1523        let operations = project
1524            .get("operations")
1525            .and_then(|v| v.as_dict())
1526            .expect("operations dict");
1527        assert!(operations.get("metadata_get").is_some());
1528        reset_host_state();
1529    }
1530
1531    #[test]
1532    fn mock_host_call_matches_partial_params_and_overrides_order() {
1533        reset_host_state();
1534        let mut exact_params = crate::value::DictMap::new();
1535        exact_params.put_str("namespace", "facts");
1536        push_host_mock(HostMock {
1537            capability: "project".to_string(),
1538            operation: "metadata_get".to_string(),
1539            params: None,
1540            result: Some(VmValue::String(arcstr::ArcStr::from("fallback"))),
1541            error: None,
1542        });
1543        push_host_mock(HostMock {
1544            capability: "project".to_string(),
1545            operation: "metadata_get".to_string(),
1546            params: Some(exact_params),
1547            result: Some(VmValue::String(arcstr::ArcStr::from("facts"))),
1548            error: None,
1549        });
1550
1551        let mut call_params = crate::value::DictMap::new();
1552        call_params.put_str("dir", "pkg");
1553        call_params.put_str("namespace", "facts");
1554        let exact = dispatch_mock_host_call("project", "metadata_get", &call_params)
1555            .expect("expected exact mock")
1556            .expect("exact mock should succeed");
1557        assert_eq!(exact.display(), "facts");
1558
1559        call_params.put_str("namespace", "classification");
1560        let fallback = dispatch_mock_host_call("project", "metadata_get", &call_params)
1561            .expect("expected fallback mock")
1562            .expect("fallback mock should succeed");
1563        assert_eq!(fallback.display(), "fallback");
1564        reset_host_state();
1565    }
1566
1567    #[test]
1568    fn mock_host_call_can_throw_errors() {
1569        reset_host_state();
1570        push_host_mock(HostMock {
1571            capability: "project".to_string(),
1572            operation: "metadata_get".to_string(),
1573            params: None,
1574            result: None,
1575            error: Some("boom".to_string()),
1576        });
1577        let params = crate::value::DictMap::new();
1578        let result = dispatch_mock_host_call("project", "metadata_get", &params)
1579            .expect("expected mock result");
1580        match result {
1581            Err(VmError::Thrown(VmValue::String(message))) => assert_eq!(message.as_str(), "boom"),
1582            other => panic!("unexpected result: {other:?}"),
1583        }
1584        reset_host_state();
1585    }
1586
1587    #[test]
1588    fn hostlib_mock_dispatch_matches_module_method_and_params() {
1589        reset_host_state();
1590        let mut mock_params = crate::value::DictMap::new();
1591        mock_params.put(
1592            "argv",
1593            VmValue::List(Arc::new(vec![VmValue::string("echo")])),
1594        );
1595        push_host_mock(HostMock {
1596            capability: "tools".to_string(),
1597            operation: "run_command".to_string(),
1598            params: Some(mock_params),
1599            result: Some(VmValue::String(arcstr::ArcStr::from("direct"))),
1600            error: None,
1601        });
1602
1603        let mut call_params = crate::value::DictMap::new();
1604        call_params.put(
1605            "argv",
1606            VmValue::List(Arc::new(vec![VmValue::string("echo")])),
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 hostlib mock")
1611            .expect("hostlib mock should succeed");
1612        assert_eq!(value.display(), "direct");
1613        reset_host_state();
1614    }
1615
1616    #[test]
1617    fn hostlib_run_command_falls_back_to_process_exec_mocks() {
1618        reset_host_state();
1619        let mut mock_params = crate::value::DictMap::new();
1620        mock_params.put(
1621            "argv",
1622            VmValue::List(Arc::new(vec![
1623                VmValue::string("cargo"),
1624                VmValue::string("test"),
1625            ])),
1626        );
1627        push_host_mock(HostMock {
1628            capability: "process".to_string(),
1629            operation: "exec".to_string(),
1630            params: Some(mock_params),
1631            result: Some(VmValue::String(arcstr::ArcStr::from("legacy"))),
1632            error: None,
1633        });
1634
1635        let mut call_params = crate::value::DictMap::new();
1636        call_params.put(
1637            "argv",
1638            VmValue::List(Arc::new(vec![
1639                VmValue::string("cargo"),
1640                VmValue::string("test"),
1641            ])),
1642        );
1643        call_params.put_str("cwd", "/tmp/not-used");
1644        let value = dispatch_mock_hostlib_call("tools", "run_command", &call_params)
1645            .expect("expected legacy process.exec mock")
1646            .expect("legacy mock should succeed");
1647        assert_eq!(value.display(), "legacy");
1648        reset_host_state();
1649    }
1650
1651    #[test]
1652    fn hostlib_run_command_prefers_exact_mock_over_process_exec_alias() {
1653        reset_host_state();
1654        let mut params = crate::value::DictMap::new();
1655        params.put(
1656            "argv",
1657            VmValue::List(Arc::new(vec![
1658                VmValue::string("npm"),
1659                VmValue::string("test"),
1660            ])),
1661        );
1662        push_host_mock(HostMock {
1663            capability: "process".to_string(),
1664            operation: "exec".to_string(),
1665            params: Some(params.clone()),
1666            result: Some(VmValue::String(arcstr::ArcStr::from("legacy"))),
1667            error: None,
1668        });
1669        push_host_mock(HostMock {
1670            capability: "tools".to_string(),
1671            operation: "run_command".to_string(),
1672            params: Some(params.clone()),
1673            result: Some(VmValue::String(arcstr::ArcStr::from("direct"))),
1674            error: None,
1675        });
1676
1677        let value = dispatch_mock_hostlib_call("tools", "run_command", &params)
1678            .expect("expected exact hostlib mock")
1679            .expect("exact mock should succeed");
1680        assert_eq!(value.display(), "direct");
1681        reset_host_state();
1682    }
1683
1684    #[derive(Default)]
1685    struct TestHostToolBridge;
1686
1687    impl HostCallBridge for TestHostToolBridge {
1688        fn dispatch(
1689            &self,
1690            _capability: &str,
1691            _operation: &str,
1692            _params: &crate::value::DictMap,
1693        ) -> Result<Option<VmValue>, VmError> {
1694            Ok(None)
1695        }
1696
1697        fn list_tools(&self) -> Result<Option<VmValue>, VmError> {
1698            let tool = VmValue::dict(crate::value::DictMap::from_iter([
1699                (
1700                    crate::value::intern_key("name"),
1701                    VmValue::String(arcstr::ArcStr::from("Read".to_string())),
1702                ),
1703                (
1704                    crate::value::intern_key("description"),
1705                    VmValue::String(arcstr::ArcStr::from(
1706                        "Read a file from the host".to_string(),
1707                    )),
1708                ),
1709                (
1710                    crate::value::intern_key("schema"),
1711                    VmValue::dict(crate::value::DictMap::from_iter([(
1712                        crate::value::intern_key("type"),
1713                        VmValue::String(arcstr::ArcStr::from("object".to_string())),
1714                    )])),
1715                ),
1716                (crate::value::intern_key("deprecated"), VmValue::Bool(false)),
1717            ]));
1718            Ok(Some(VmValue::List(std::sync::Arc::new(vec![tool]))))
1719        }
1720
1721        fn call_tool(&self, name: &str, args: &VmValue) -> Result<Option<VmValue>, VmError> {
1722            if name != "Read" {
1723                return Ok(None);
1724            }
1725            let path = args
1726                .as_dict()
1727                .and_then(|dict| dict.get("path"))
1728                .map(|value| value.display())
1729                .unwrap_or_default();
1730            Ok(Some(VmValue::String(arcstr::ArcStr::from(format!(
1731                "read:{path}"
1732            )))))
1733        }
1734    }
1735
1736    struct CountingProcessExecBridge {
1737        calls: Arc<AtomicUsize>,
1738    }
1739
1740    impl HostCallBridge for CountingProcessExecBridge {
1741        fn dispatch(
1742            &self,
1743            capability: &str,
1744            operation: &str,
1745            _params: &crate::value::DictMap,
1746        ) -> Result<Option<VmValue>, VmError> {
1747            if (capability, operation) != ("process", "exec") {
1748                return Ok(None);
1749            }
1750            self.calls.fetch_add(1, Ordering::SeqCst);
1751            Ok(Some(VmValue::dict(crate::value::DictMap::from_iter([
1752                (
1753                    crate::value::intern_key("status"),
1754                    VmValue::String(arcstr::ArcStr::from("completed".to_string())),
1755                ),
1756                (crate::value::intern_key("exit_code"), VmValue::Int(0)),
1757                (crate::value::intern_key("success"), VmValue::Bool(true)),
1758            ]))))
1759        }
1760    }
1761
1762    fn run_host_async_test<F, Fut>(test: F)
1763    where
1764        F: FnOnce() -> Fut,
1765        Fut: std::future::Future<Output = ()>,
1766    {
1767        let rt = tokio::runtime::Builder::new_current_thread()
1768            .enable_all()
1769            .build()
1770            .expect("runtime");
1771        rt.block_on(async {
1772            let local = tokio::task::LocalSet::new();
1773            local.run_until(test()).await;
1774        });
1775    }
1776
1777    #[test]
1778    fn host_tool_list_uses_installed_host_call_bridge() {
1779        run_host_async_test(|| async {
1780            reset_host_state();
1781            set_host_call_bridge(Arc::new(TestHostToolBridge));
1782            let tools = dispatch_host_tool_list().await.expect("tool list");
1783            clear_host_call_bridge();
1784
1785            let VmValue::List(items) = tools else {
1786                panic!("expected tool list");
1787            };
1788            assert_eq!(items.len(), 1);
1789            let tool = items[0].as_dict().expect("tool dict");
1790            assert_eq!(tool.get("name").unwrap().display(), "Read");
1791            assert_eq!(tool.get("deprecated").unwrap().display(), "false");
1792        });
1793    }
1794
1795    #[test]
1796    fn host_tool_call_uses_installed_host_call_bridge() {
1797        run_host_async_test(|| async {
1798            set_host_call_bridge(Arc::new(TestHostToolBridge));
1799            let args = VmValue::dict(crate::value::DictMap::from_iter([(
1800                crate::value::intern_key("path"),
1801                VmValue::String(arcstr::ArcStr::from("README.md".to_string())),
1802            )]));
1803            let value = dispatch_host_tool_call("Read", &args)
1804                .await
1805                .expect("tool call");
1806            clear_host_call_bridge();
1807            assert_eq!(value.display(), "read:README.md");
1808        });
1809    }
1810
1811    #[test]
1812    fn process_exec_bridge_is_gated_by_command_policy() {
1813        run_host_async_test(|| async {
1814            crate::orchestration::clear_command_policies();
1815            let calls = Arc::new(AtomicUsize::new(0));
1816            set_host_call_bridge(Arc::new(CountingProcessExecBridge {
1817                calls: calls.clone(),
1818            }));
1819            crate::orchestration::push_command_policy(crate::orchestration::CommandPolicy {
1820                tools: vec!["run".to_string()],
1821                workspace_roots: Vec::new(),
1822                default_shell_mode: "shell".to_string(),
1823                deny_patterns: vec!["cat *".to_string()],
1824                require_approval: Default::default(),
1825                deny_labels: Default::default(),
1826                pre: None,
1827                post: None,
1828                consent: None,
1829                allow_recursive: false,
1830            });
1831
1832            let result = dispatch_host_operation(
1833                "process",
1834                "exec",
1835                &crate::value::DictMap::from_iter([
1836                    (
1837                        crate::value::intern_key("mode"),
1838                        VmValue::String(arcstr::ArcStr::from("shell")),
1839                    ),
1840                    (
1841                        crate::value::intern_key("command"),
1842                        VmValue::String(arcstr::ArcStr::from("cat Cargo.toml")),
1843                    ),
1844                ]),
1845            )
1846            .await
1847            .expect("process.exec result");
1848
1849            crate::orchestration::clear_command_policies();
1850            clear_host_call_bridge();
1851
1852            assert_eq!(
1853                calls.load(Ordering::SeqCst),
1854                0,
1855                "blocked command must not reach host bridge"
1856            );
1857            let result = result.as_dict().expect("blocked result dict");
1858            assert_eq!(result.get("status").unwrap().display(), "blocked");
1859            assert!(
1860                result
1861                    .get("reason")
1862                    .map(VmValue::display)
1863                    .unwrap_or_default()
1864                    .contains("cat *"),
1865                "blocked result should name the matched policy pattern"
1866            );
1867        });
1868    }
1869
1870    #[cfg(unix)]
1871    async fn process_exec_env_probe(env: VmValue, env_mode: Option<&str>) -> (String, String) {
1872        // Run `sh -c 'printf "%s|%s" "$PARENT_VAR" "$CHILD_VAR"'` so we can
1873        // observe whether an inherited parent var survives alongside the
1874        // explicitly-provided child var. The parent var is set on this
1875        // process's environment immediately before the spawn.
1876        std::env::set_var("PARENT_VAR", "inherited");
1877        let mut params = crate::value::DictMap::from_iter([
1878            (
1879                crate::value::intern_key("mode"),
1880                VmValue::String(arcstr::ArcStr::from("argv")),
1881            ),
1882            (
1883                crate::value::intern_key("argv"),
1884                VmValue::List(std::sync::Arc::new(vec![
1885                    // Absolute path so the spawn does not depend on PATH,
1886                    // which the `replace` case intentionally clears.
1887                    VmValue::String(arcstr::ArcStr::from("/bin/sh")),
1888                    VmValue::String(arcstr::ArcStr::from("-c")),
1889                    VmValue::String(arcstr::ArcStr::from(
1890                        "printf '%s|%s' \"$PARENT_VAR\" \"$CHILD_VAR\"",
1891                    )),
1892                ])),
1893            ),
1894            (crate::value::intern_key("env"), env),
1895        ]);
1896        if let Some(mode) = env_mode {
1897            params.put_str("env_mode", mode);
1898        }
1899        let result = super::dispatch_process_exec(&params, serde_json::Value::Null)
1900            .await
1901            .expect("process.exec result");
1902        let dict = result.as_dict().expect("result dict");
1903        let stdout = dict.get("stdout").map(VmValue::display).unwrap_or_default();
1904        std::env::remove_var("PARENT_VAR");
1905        let (parent, child) = stdout.split_once('|').unwrap_or((&stdout, ""));
1906        (parent.to_string(), child.to_string())
1907    }
1908
1909    #[cfg(unix)]
1910    #[test]
1911    fn process_exec_env_default_merges_with_parent() {
1912        run_host_async_test(|| async {
1913            // No `env_mode`: the provided key must be added WITHOUT clearing
1914            // the inherited parent environment (the env-clear footgun fix).
1915            let child_env = VmValue::dict(crate::value::DictMap::from_iter([(
1916                crate::value::intern_key("CHILD_VAR"),
1917                VmValue::String(arcstr::ArcStr::from("provided")),
1918            )]));
1919            let (parent, child) = process_exec_env_probe(child_env, None).await;
1920            assert_eq!(
1921                parent, "inherited",
1922                "default env_mode must inherit parent env"
1923            );
1924            assert_eq!(
1925                child, "provided",
1926                "default env_mode must apply provided keys"
1927            );
1928        });
1929    }
1930
1931    #[cfg(unix)]
1932    #[test]
1933    fn process_exec_env_mode_replace_clears_parent() {
1934        run_host_async_test(|| async {
1935            // Explicit `replace`: the inherited parent var must be gone and
1936            // only the provided key survives. This preserves the ability to
1937            // fully replace the environment when intentionally requested.
1938            let child_env = VmValue::dict(crate::value::DictMap::from_iter([(
1939                crate::value::intern_key("CHILD_VAR"),
1940                VmValue::String(arcstr::ArcStr::from("provided")),
1941            )]));
1942            let (parent, child) = process_exec_env_probe(child_env, Some("replace")).await;
1943            assert_eq!(parent, "", "explicit replace must clear parent env");
1944            assert_eq!(
1945                child, "provided",
1946                "explicit replace must keep provided keys"
1947            );
1948        });
1949    }
1950
1951    #[cfg(unix)]
1952    #[test]
1953    fn process_exec_env_mode_unknown_is_rejected() {
1954        run_host_async_test(|| async {
1955            let params = crate::value::DictMap::from_iter([
1956                (
1957                    crate::value::intern_key("mode"),
1958                    VmValue::String(arcstr::ArcStr::from("argv")),
1959                ),
1960                (
1961                    crate::value::intern_key("argv"),
1962                    VmValue::List(std::sync::Arc::new(vec![VmValue::String(
1963                        arcstr::ArcStr::from("true"),
1964                    )])),
1965                ),
1966                (
1967                    crate::value::intern_key("env"),
1968                    VmValue::dict(crate::value::DictMap::from_iter([(
1969                        crate::value::intern_key("CHILD_VAR"),
1970                        VmValue::String(arcstr::ArcStr::from("x")),
1971                    )])),
1972                ),
1973                (
1974                    crate::value::intern_key("env_mode"),
1975                    VmValue::String(arcstr::ArcStr::from("bogus")),
1976                ),
1977            ]);
1978            let err = super::dispatch_process_exec(&params, serde_json::Value::Null)
1979                .await
1980                .expect_err("unknown env_mode must error");
1981            assert!(
1982                format!("{err:?}").contains("env_mode"),
1983                "error should name env_mode, got {err:?}"
1984            );
1985        });
1986    }
1987
1988    // Drive the real `host_call("process","exec")` builder under a restricted
1989    // policy and read back the `$TMPDIR` the child actually saw. This is the
1990    // agent-facing path; the assertion is OS-independent (it observes the
1991    // injected env, not OS-sandbox enforcement), so it pins the mechanism on
1992    // every CI host while the live OS-level link proof runs on tornadough.
1993    #[cfg(unix)]
1994    async fn process_exec_tmpdir_probe(
1995        workspace: &std::path::Path,
1996        caller_env: Option<VmValue>,
1997    ) -> String {
1998        let mut env_pairs = vec![(
1999            crate::value::intern_key("mode"),
2000            VmValue::String(arcstr::ArcStr::from("argv")),
2001        )];
2002        env_pairs.push((
2003            crate::value::intern_key("argv"),
2004            VmValue::List(std::sync::Arc::new(vec![
2005                VmValue::String(arcstr::ArcStr::from("/bin/sh")),
2006                VmValue::String(arcstr::ArcStr::from("-c")),
2007                VmValue::String(arcstr::ArcStr::from("printf '%s' \"$TMPDIR\"")),
2008            ])),
2009        ));
2010        if let Some(env) = caller_env {
2011            env_pairs.push((crate::value::intern_key("env"), env));
2012        }
2013        let params = crate::value::DictMap::from_iter(env_pairs);
2014
2015        crate::orchestration::push_execution_policy(crate::orchestration::CapabilityPolicy {
2016            sandbox_profile: crate::orchestration::SandboxProfile::Worktree,
2017            workspace_roots: vec![workspace.to_string_lossy().into_owned()],
2018            // Keep OS confinement out of this unit assertion regardless of host
2019            // Landlock/seatbelt availability; we are pinning the env injection,
2020            // not OS enforcement (which the tornadough run proves end-to-end).
2021            ..crate::orchestration::CapabilityPolicy::default()
2022        });
2023        std::env::set_var("HARN_HANDLER_SANDBOX", "off");
2024        let result = super::dispatch_process_exec(&params, serde_json::Value::Null)
2025            .await
2026            .expect("process.exec result");
2027        std::env::remove_var("HARN_HANDLER_SANDBOX");
2028        crate::orchestration::pop_execution_policy();
2029        result
2030            .as_dict()
2031            .and_then(|d| d.get("stdout"))
2032            .map(VmValue::display)
2033            .unwrap_or_default()
2034    }
2035
2036    #[cfg(unix)]
2037    #[test]
2038    fn process_exec_injects_workspace_local_tmpdir() {
2039        run_host_async_test(|| async {
2040            let workspace = tempfile::tempdir().expect("workspace");
2041            let tmpdir = process_exec_tmpdir_probe(workspace.path(), None).await;
2042
2043            assert!(
2044                !tmpdir.is_empty(),
2045                "sandboxed child must receive a non-empty TMPDIR"
2046            );
2047            let tmpdir_path = std::path::PathBuf::from(&tmpdir);
2048            let canonical_tmpdir = std::fs::canonicalize(&tmpdir_path)
2049                .expect("workspace-local TMPDIR should canonicalize");
2050            let canonical_workspace =
2051                std::fs::canonicalize(workspace.path()).expect("workspace should canonicalize");
2052            assert!(
2053                canonical_tmpdir.starts_with(&canonical_workspace),
2054                "child TMPDIR {tmpdir:?} must live inside the workspace {:?}",
2055                workspace.path()
2056            );
2057            assert!(
2058                tmpdir_path.ends_with(".harn-tmp"),
2059                "child TMPDIR {tmpdir:?} must be the workspace-local .harn-tmp dir"
2060            );
2061            assert!(
2062                tmpdir_path.is_dir(),
2063                "the workspace-local TMPDIR must have been created on disk"
2064            );
2065        });
2066    }
2067
2068    #[cfg(unix)]
2069    #[test]
2070    fn process_exec_respects_caller_pinned_tmpdir() {
2071        run_host_async_test(|| async {
2072            let workspace = tempfile::tempdir().expect("workspace");
2073            let caller_tmp = workspace.path().join("caller-chosen");
2074            std::fs::create_dir_all(&caller_tmp).unwrap();
2075            let caller_env = VmValue::dict(crate::value::DictMap::from_iter([(
2076                crate::value::intern_key("TMPDIR"),
2077                VmValue::String(arcstr::ArcStr::from(
2078                    caller_tmp.to_string_lossy().into_owned(),
2079                )),
2080            )]));
2081
2082            let tmpdir = process_exec_tmpdir_probe(workspace.path(), Some(caller_env)).await;
2083
2084            assert_eq!(
2085                std::path::PathBuf::from(&tmpdir),
2086                caller_tmp,
2087                "an explicit caller TMPDIR must override the workspace-local default"
2088            );
2089        });
2090    }
2091
2092    #[test]
2093    fn host_tool_list_is_empty_without_bridge() {
2094        run_host_async_test(|| async {
2095            clear_host_call_bridge();
2096            let tools = dispatch_host_tool_list().await.expect("tool list");
2097            let VmValue::List(items) = tools else {
2098                panic!("expected tool list");
2099            };
2100            assert!(items.is_empty());
2101        });
2102    }
2103}