Skip to main content

harn_vm/stdlib/
host.rs

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