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/// Embedder-supplied bridge for `host_call` ops.
397///
398/// Embedders (debug adapters, CLIs, IDE hosts) implement this trait to
399/// satisfy capability/operation pairs that harn-vm itself doesn't know how
400/// to handle. Returning `Ok(None)` means "I don't handle this op — fall
401/// through to the built-in fallbacks (env-derived defaults, then the
402/// `unsupported operation` error)". `Ok(Some(value))` is the result;
403/// `Err(VmError::Thrown(_))` surfaces as a Harn exception.
404///
405/// The trait is intentionally synchronous. Bridges that need async I/O
406/// (e.g. DAP reverse requests) should drive their own runtime or use a
407/// blocking channel — see `harn-dap`'s `DapHostBridge` for the canonical
408/// pattern. Sync keeps the boundary simple and avoids forcing the entire
409/// dispatch path into an opaque future.
410pub trait HostCallBridge: Send + Sync {
411    fn dispatch(
412        &self,
413        capability: &str,
414        operation: &str,
415        params: &crate::value::DictMap,
416    ) -> Result<Option<VmValue>, VmError>;
417
418    fn list_tools(&self) -> Result<Option<VmValue>, VmError> {
419        Ok(None)
420    }
421
422    fn call_tool(&self, _name: &str, _args: &VmValue) -> Result<Option<VmValue>, VmError> {
423        Ok(None)
424    }
425}
426
427thread_local! {
428    static HOST_CALL_BRIDGE: RefCell<Option<Arc<dyn HostCallBridge>>> = const { RefCell::new(None) };
429}
430
431/// Install a bridge for the current thread. The bridge is consulted on
432/// every `host_call` *after* mock matching but *before* the built-in
433/// match arms, so embedders can override anything they like (and equally
434/// punt on anything they don't, by returning `Ok(None)`).
435pub fn set_host_call_bridge(bridge: Arc<dyn HostCallBridge>) {
436    HOST_CALL_BRIDGE.with(|b| *b.borrow_mut() = Some(bridge));
437}
438
439/// Remove the current thread's bridge. Idempotent.
440pub fn clear_host_call_bridge() {
441    HOST_CALL_BRIDGE.with(|b| *b.borrow_mut() = None);
442}
443
444/// Dispatch `(capability, operation, params)` to the currently-installed
445/// `HostCallBridge`, if any. `Some(Ok(_))` means the bridge handled the
446/// call; `Some(Err(_))` means it tried but raised; `None` means there is
447/// no bridge or the bridge declined this op (returned `Ok(None)`).
448///
449/// Mirrors the inner block of `dispatch_host_operation` but without the
450/// mock-call check or the built-in fallbacks — useful for callers that
451/// want to treat the bridge as one of several sinks (e.g. inbound MCP
452/// `elicitation/create` requests).
453pub fn dispatch_host_call_bridge(
454    capability: &str,
455    operation: &str,
456    params: &crate::value::DictMap,
457) -> Option<Result<VmValue, VmError>> {
458    let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone())?;
459    match bridge.dispatch(capability, operation, params) {
460        Ok(Some(value)) => Some(Ok(value)),
461        Ok(None) => None,
462        Err(error) => Some(Err(error)),
463    }
464}
465
466fn empty_tool_list_value() -> VmValue {
467    VmValue::List(std::sync::Arc::new(Vec::new()))
468}
469
470fn current_vm_host_bridge(
471    ctx: Option<&AsyncBuiltinCtx>,
472) -> Option<std::sync::Arc<crate::bridge::HostBridge>> {
473    ctx.and_then(|ctx| ctx.child_vm().bridge.clone())
474}
475
476#[cfg(test)]
477async fn dispatch_host_tool_list() -> Result<VmValue, VmError> {
478    dispatch_host_tool_list_with_ctx(None).await
479}
480
481async fn dispatch_host_tool_list_with_ctx(
482    ctx: Option<&AsyncBuiltinCtx>,
483) -> Result<VmValue, VmError> {
484    let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
485    if let Some(bridge) = bridge {
486        if let Some(value) = bridge.list_tools()? {
487            return Ok(value);
488        }
489    }
490
491    let Some(bridge) = current_vm_host_bridge(ctx) else {
492        return Ok(empty_tool_list_value());
493    };
494    let tools = bridge.list_host_tools().await?;
495    Ok(crate::bridge::json_result_to_vm_value(&JsonValue::Array(
496        tools.into_iter().collect(),
497    )))
498}
499
500pub(crate) async fn dispatch_host_tool_call(
501    name: &str,
502    args: &VmValue,
503) -> Result<VmValue, VmError> {
504    dispatch_host_tool_call_with_ctx(None, name, args).await
505}
506
507pub(crate) async fn dispatch_host_tool_call_with_ctx(
508    ctx: Option<&AsyncBuiltinCtx>,
509    name: &str,
510    args: &VmValue,
511) -> Result<VmValue, VmError> {
512    let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
513    if let Some(bridge) = bridge {
514        if let Some(value) = bridge.call_tool(name, args)? {
515            return Ok(value);
516        }
517    }
518
519    let Some(bridge) = current_vm_host_bridge(ctx) else {
520        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
521            "host_tool_call: no host bridge is attached",
522        ))));
523    };
524
525    let result = bridge
526        .call(
527            "builtin_call",
528            serde_json::json!({
529                "name": name,
530                "args": [crate::llm::vm_value_to_json(args)],
531            }),
532        )
533        .await?;
534    Ok(crate::bridge::json_result_to_vm_value(&result))
535}
536
537pub(crate) async fn dispatch_host_operation(
538    capability: &str,
539    operation: &str,
540    params: &crate::value::DictMap,
541) -> Result<VmValue, VmError> {
542    dispatch_host_operation_with_ctx(None, capability, operation, params).await
543}
544
545pub(crate) async fn dispatch_host_operation_with_ctx(
546    ctx: Option<&AsyncBuiltinCtx>,
547    capability: &str,
548    operation: &str,
549    params: &crate::value::DictMap,
550) -> Result<VmValue, VmError> {
551    if let Some(mocked) = dispatch_mock_host_call(capability, operation, params) {
552        return mocked;
553    }
554
555    if (capability, operation) == ("process", "exec") {
556        let caller = serde_json::json!({
557            "surface": "host_call",
558            "capability": "process",
559            "operation": "exec",
560            "session_id": crate::llm::current_agent_session_id(),
561        });
562        return dispatch_process_exec_with_policy(ctx, params, caller).await;
563    }
564
565    // process.spawn is the non-blocking sibling of exec. Route it through the
566    // SAME command-policy preflight so deny-patterns/approval/sandbox gating
567    // are identical; only the completion semantics differ (returns a handle
568    // immediately instead of awaiting). poll/wait/kill/release are pure
569    // registry operations on an already-gated spawn, so they bypass the
570    // command policy.
571    if (capability, operation) == ("process", "spawn") {
572        let caller = serde_json::json!({
573            "surface": "host_call",
574            "capability": "process",
575            "operation": "spawn",
576            "session_id": crate::llm::current_agent_session_id(),
577        });
578        return dispatch_process_spawn_with_policy(ctx, params, caller).await;
579    }
580    if capability == "process" && matches!(operation, "poll" | "wait" | "kill" | "release") {
581        if let Some(result) = crate::stdlib::process_spawn::dispatch(operation, params).await {
582            return result;
583        }
584    }
585
586    let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
587    if let Some(bridge) = bridge {
588        if let Some(value) = bridge.dispatch(capability, operation, params)? {
589            return Ok(value);
590        }
591    }
592
593    dispatch_builtin_host_operation(capability, operation, params).await
594}
595
596async fn dispatch_builtin_host_operation(
597    capability: &str,
598    operation: &str,
599    params: &crate::value::DictMap,
600) -> Result<VmValue, VmError> {
601    match (capability, operation) {
602        ("process", "list_shells") => Ok(crate::shells::list_shells_vm_value()),
603        ("process", "get_default_shell") => Ok(crate::shells::default_shell_vm_value()),
604        ("process", "set_default_shell") => crate::shells::set_default_shell_vm_value(params),
605        ("process", "shell_invocation") => crate::shells::shell_invocation_vm_value(params),
606        ("template", "render") => {
607            let path = require_param(params, "path")?;
608            let bindings = params.get("bindings").and_then(|v| v.as_dict());
609            Ok(VmValue::String(arcstr::ArcStr::from(render_template(
610                &path, bindings,
611            )?)))
612        }
613        ("interaction", "ask") => {
614            let question = require_param(params, "question")?;
615            use std::io::BufRead;
616            print!("{question}");
617            let _ = std::io::Write::flush(&mut std::io::stdout());
618            let mut input = String::new();
619            if std::io::stdin().lock().read_line(&mut input).is_ok() {
620                Ok(VmValue::String(arcstr::ArcStr::from(input.trim_end())))
621            } else {
622                Ok(VmValue::Nil)
623            }
624        }
625        ("project", "metadata_get") => crate::metadata::project_metadata_host_get(params),
626        ("project", "metadata_inspect") => crate::metadata::project_metadata_host_inspect(params),
627        ("project", "metadata_set") => crate::metadata::project_metadata_host_set(params),
628        ("project", "metadata_save") => crate::metadata::project_metadata_host_save(params),
629        ("project", "metadata_stale") => crate::metadata::project_metadata_host_stale(params),
630        ("project", "metadata_refresh_hashes") => {
631            crate::metadata::project_metadata_host_refresh_hashes(params)
632        }
633        // Standalone-run fallbacks for capabilities normally supplied by
634        // an embedder's JSON-RPC bridge. `runtime.task` lets a debugger or
635        // CLI invocation read the pipeline input from `HARN_TASK` without
636        // the host explicitly wiring a callback for every op.
637        ("runtime", "task") => Ok(VmValue::String(arcstr::ArcStr::from(
638            std::env::var("HARN_TASK").unwrap_or_default(),
639        ))),
640        ("runtime", "set_result") => {
641            // No-op when no host is attached; swallow silently so standalone
642            // scripts can still call `set_result` without crashing.
643            Ok(VmValue::Nil)
644        }
645        ("workspace", "project_root") => {
646            // Standalone fallback: prefer HARN_PROJECT_ROOT, then the
647            // current working directory. Pipelines call this very early so
648            // crashing here would block any debug-launched script.
649            let path = std::env::var("HARN_PROJECT_ROOT").unwrap_or_else(|_| {
650                std::env::current_dir()
651                    .map(|p| p.display().to_string())
652                    .unwrap_or_default()
653            });
654            Ok(VmValue::String(arcstr::ArcStr::from(path)))
655        }
656        ("workspace", "cwd") => {
657            let path = std::env::current_dir()
658                .map(|p| p.display().to_string())
659                .unwrap_or_default();
660            Ok(VmValue::String(arcstr::ArcStr::from(path)))
661        }
662        _ => Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
663            format!("host_call: unsupported operation {capability}.{operation}"),
664        )))),
665    }
666}
667
668pub(crate) async fn dispatch_process_exec(
669    params: &crate::value::DictMap,
670    caller: serde_json::Value,
671) -> Result<VmValue, VmError> {
672    dispatch_process_exec_with_policy(None, params, caller).await
673}
674
675async fn dispatch_process_exec_with_policy(
676    ctx: Option<&AsyncBuiltinCtx>,
677    params: &crate::value::DictMap,
678    caller: serde_json::Value,
679) -> Result<VmValue, VmError> {
680    let (params, command_policy_context, command_policy_decisions) =
681        match crate::orchestration::run_command_policy_preflight_with_ctx(ctx, params, caller)
682            .await?
683        {
684            crate::orchestration::CommandPolicyPreflight::Proceed {
685                params,
686                context,
687                decisions,
688            } => (params, context, decisions),
689            crate::orchestration::CommandPolicyPreflight::Blocked {
690                status,
691                message,
692                context,
693                decisions,
694            } => {
695                return Ok(crate::orchestration::blocked_command_response(
696                    params, status, &message, context, decisions,
697                ));
698            }
699        };
700
701    let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
702    if let Some(bridge) = bridge {
703        if let Some(value) = bridge.dispatch("process", "exec", &params)? {
704            return crate::orchestration::run_command_policy_postflight_with_ctx(
705                ctx,
706                &params,
707                value,
708                command_policy_context,
709                command_policy_decisions,
710            )
711            .await;
712        }
713    }
714
715    dispatch_process_exec_after_policy(
716        ctx,
717        &params,
718        command_policy_context,
719        command_policy_decisions,
720    )
721    .await
722}
723
724/// Apply the command-policy preflight (deny-patterns, approval gating,
725/// sandbox decisions) and then spawn the process non-blocking. Mirrors
726/// [`dispatch_process_exec_with_policy`] so spawn is gated identically to
727/// exec. There is no postflight here: spawn returns a handle immediately,
728/// not a completed command result; completion is observed later via
729/// poll/wait, which are not themselves command executions.
730async fn dispatch_process_spawn_with_policy(
731    ctx: Option<&AsyncBuiltinCtx>,
732    params: &crate::value::DictMap,
733    caller: serde_json::Value,
734) -> Result<VmValue, VmError> {
735    let params =
736        match crate::orchestration::run_command_policy_preflight_with_ctx(ctx, params, caller)
737            .await?
738        {
739            crate::orchestration::CommandPolicyPreflight::Proceed { params, .. } => params,
740            crate::orchestration::CommandPolicyPreflight::Blocked {
741                status,
742                message,
743                context,
744                decisions,
745            } => {
746                return Ok(crate::orchestration::blocked_command_response(
747                    params, status, &message, context, decisions,
748                ));
749            }
750        };
751
752    match crate::stdlib::process_spawn::dispatch("spawn", &params).await {
753        Some(result) => result,
754        None => Err(VmError::Runtime(
755            "host_call process.spawn: dispatch returned None".to_string(),
756        )),
757    }
758}
759
760async fn dispatch_process_exec_after_policy(
761    ctx: Option<&AsyncBuiltinCtx>,
762    params: &crate::value::DictMap,
763    command_policy_context: JsonValue,
764    command_policy_decisions: Vec<crate::orchestration::CommandPolicyDecision>,
765) -> Result<VmValue, VmError> {
766    let timeout_ms = optional_i64(params, "timeout")
767        .or_else(|| optional_i64(params, "timeout_ms"))
768        .filter(|value| *value > 0)
769        .map(|value| value as u64);
770    // Optional per-call profile override. Pipelines that want to
771    // promote a single spawn to `os_hardened` (e.g. running
772    // attacker-controlled code) pass `sandbox_profile: "os_hardened"`
773    // without having to rewrite the surrounding policy. The override
774    // is scoped to this call and pops with the guard at end-of-scope.
775    let profile_guard = match optional_string(params, "sandbox_profile") {
776        Some(value) => Some(push_sandbox_profile_override(&value)?),
777        None => None,
778    };
779    let mut cmd = build_sandboxed_command(params, "process.exec")?;
780    cmd.stdin(std::process::Stdio::null())
781        .stdout(std::process::Stdio::piped())
782        .stderr(std::process::Stdio::piped())
783        .kill_on_drop(true);
784    let started_at = audited_utc_now_rfc3339("host_call/process.exec.started_at");
785    let started = crate::clock_mock::leak_audit::instant_now("host_call/process.exec.started");
786    let child = cmd
787        .spawn()
788        .map_err(|e| VmError::Runtime(format!("host_call process.exec: {e}")))?;
789    drop(profile_guard);
790    let pid = child.id();
791    let timed_out;
792    let output_result = if let Some(timeout_ms) = timeout_ms {
793        match tokio::time::timeout(
794            std::time::Duration::from_millis(timeout_ms),
795            child.wait_with_output(),
796        )
797        .await
798        {
799            Ok(result) => {
800                timed_out = false;
801                result
802            }
803            Err(_) => {
804                let response = process_exec_response(ProcessExecResponse {
805                    pid,
806                    started_at,
807                    started,
808                    stdout: "",
809                    stderr: "",
810                    exit_code: -1,
811                    status: "timed_out",
812                    success: false,
813                    timed_out: true,
814                });
815                return crate::orchestration::run_command_policy_postflight_with_ctx(
816                    ctx,
817                    params,
818                    response,
819                    command_policy_context,
820                    command_policy_decisions,
821                )
822                .await;
823            }
824        }
825    } else {
826        timed_out = false;
827        child.wait_with_output().await
828    };
829    let output =
830        output_result.map_err(|e| VmError::Runtime(format!("host_call process.exec: {e}")))?;
831    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
832    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
833    let exit_code = output.status.code().unwrap_or(-1);
834    let response = process_exec_response(ProcessExecResponse {
835        pid,
836        started_at,
837        started,
838        stdout: &stdout,
839        stderr: &stderr,
840        exit_code,
841        status: if timed_out { "timed_out" } else { "completed" },
842        success: output.status.success(),
843        timed_out,
844    });
845    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/// Build a sandboxed `tokio::process::Command` from process-call params,
856/// applying argv/shell resolution, the active sandbox policy via
857/// [`crate::process_sandbox::tokio_command_for`], cwd enforcement, and
858/// env/env_mode/env_remove handling.
859///
860/// Shared by `process.exec` (synchronous) and `process.spawn`
861/// (non-blocking) so both go through the identical sandbox-gated build
862/// path. The caller is responsible for any `sandbox_profile` override
863/// guard (it must be live across this call) and for setting stdio/kill
864/// behaviour on the returned command. `label` ("process.exec" or
865/// "process.spawn") is woven into error messages.
866pub(crate) fn build_sandboxed_command(
867    params: &crate::value::DictMap,
868    label: &str,
869) -> Result<tokio::process::Command, VmError> {
870    let (program, args) = process_exec_argv(params)?;
871    let mut cmd = crate::process_sandbox::tokio_command_for(&program, &args)
872        .map_err(|e| VmError::Runtime(format!("host_call {label} sandbox setup: {e}")))?;
873    if let Some(cwd) = optional_string(params, "cwd") {
874        let cwd = resolve_process_exec_cwd(&cwd);
875        crate::process_sandbox::enforce_process_cwd(&cwd)
876            .map_err(|e| VmError::Runtime(format!("host_call {label} cwd: {e}")))?;
877        cmd.current_dir(cwd);
878    }
879    // Track keys the caller set explicitly so the sandbox-local TMPDIR overlay
880    // below never clobbers an intentional per-call value.
881    let mut caller_env_keys: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
882    if let Some(env) = optional_string_dict(params, "env")? {
883        // `env_mode` controls how the provided `env` keys combine with the
884        // parent environment:
885        //   - "merge" (default): inherit the parent env and overlay the
886        //     provided keys. This is the least-surprising behavior — a
887        //     caller passing `env: {ONE_VAR: "x"}` keeps PATH/HOME/etc.
888        //   - "replace": clear the parent env entirely, then set only the
889        //     provided keys. This is the footgun shape and must be requested
890        //     explicitly whenever `env` is supplied.
891        let env_mode = optional_string(params, "env_mode");
892        match env_mode.as_deref().unwrap_or("merge") {
893            "replace" => {
894                cmd.env_clear();
895            }
896            "merge" => {}
897            other => {
898                return Err(VmError::Runtime(format!(
899                    "host_call {label}: unknown env_mode {other:?}; expected \"merge\" or \"replace\""
900                )));
901            }
902        }
903        for (key, value) in env {
904            caller_env_keys.insert(key.clone());
905            cmd.env(key, value);
906        }
907    }
908    // env_remove: list of environment variable names to strip before
909    // spawning. Applied after `env` so callers can both inherit and
910    // selectively unset (e.g. the git stdlib strips `GIT_*` so its
911    // operations are self-contained even when Harn is invoked from
912    // inside a git hook that sets `GIT_DIR`).
913    if let Some(env_remove) = optional_string_list(params, "env_remove") {
914        for key in env_remove {
915            caller_env_keys.insert(key.clone());
916            cmd.env_remove(key);
917        }
918    }
919    // Point the child's temp dir at a sandbox-writable, workspace-local
920    // location so compiler linkers (rustc/cc/ld, Go, Swift, …) and other
921    // toolchains that honor TMPDIR/TMP/TEMP don't false-fail trying to write
922    // intermediates to the unwritable system /tmp. A key the caller set (via
923    // `env`) or explicitly stripped (via `env_remove`) is left as the caller
924    // intended; only keys the caller did not touch receive the overlay. No-op
925    // when the active profile is unrestricted or no writable workspace root is
926    // available.
927    for (key, value) in crate::process_sandbox::active_workspace_tmpdir_env() {
928        if caller_env_keys.contains(&key) {
929            continue;
930        }
931        cmd.env(key, value);
932    }
933    Ok(cmd)
934}
935
936struct ProcessExecResponse<'a> {
937    pid: Option<u32>,
938    started_at: String,
939    started: Instant,
940    stdout: &'a str,
941    stderr: &'a str,
942    exit_code: i32,
943    status: &'a str,
944    success: bool,
945    timed_out: bool,
946}
947
948fn process_exec_response(response: ProcessExecResponse<'_>) -> VmValue {
949    let combined = format!("{}{}", response.stdout, response.stderr);
950    let mut result = crate::value::DictMap::new();
951    result.put_str(
952        "command_id",
953        format!(
954            "cmd_{}_{}",
955            std::process::id(),
956            response.started.elapsed().as_nanos()
957        ),
958    );
959    result.put_str("status", response.status);
960    result.insert(
961        crate::value::intern_key("pid"),
962        response
963            .pid
964            .map(|pid| VmValue::Int(pid as i64))
965            .unwrap_or(VmValue::Nil),
966    );
967    result.insert(
968        crate::value::intern_key("process_group_id"),
969        response
970            .pid
971            .map(|pid| VmValue::Int(pid as i64))
972            .unwrap_or(VmValue::Nil),
973    );
974    result.insert(crate::value::intern_key("handle_id"), VmValue::Nil);
975    result.put_str("started_at", response.started_at);
976    result.put_str(
977        "ended_at",
978        audited_utc_now_rfc3339("host_call/process.exec.ended_at"),
979    );
980    result.insert(
981        crate::value::intern_key("duration_ms"),
982        VmValue::Int(response.started.elapsed().as_millis() as i64),
983    );
984    result.insert(
985        crate::value::intern_key("exit_code"),
986        VmValue::Int(response.exit_code as i64),
987    );
988    result.insert(crate::value::intern_key("signal"), VmValue::Nil);
989    result.insert(
990        crate::value::intern_key("timed_out"),
991        VmValue::Bool(response.timed_out),
992    );
993    result.put_str("stdout", response.stdout);
994    result.put_str("stderr", response.stderr);
995    result.put_str("combined", combined);
996    result.insert(
997        crate::value::intern_key("exit_status"),
998        VmValue::Int(response.exit_code as i64),
999    );
1000    result.insert(
1001        crate::value::intern_key("legacy_status"),
1002        VmValue::Int(response.exit_code as i64),
1003    );
1004    result.insert(
1005        crate::value::intern_key("success"),
1006        VmValue::Bool(response.success),
1007    );
1008    VmValue::dict(result)
1009}
1010
1011fn resolve_process_exec_cwd(cwd: &str) -> std::path::PathBuf {
1012    crate::stdlib::process::resolve_source_relative_path(cwd)
1013}
1014
1015fn process_exec_argv(params: &crate::value::DictMap) -> Result<(String, Vec<String>), VmError> {
1016    match optional_string(params, "mode")
1017        .as_deref()
1018        .unwrap_or("shell")
1019    {
1020        "argv" => {
1021            let argv = optional_string_list(params, "argv").ok_or_else(|| {
1022                VmError::Runtime("host_call process.exec missing argv".to_string())
1023            })?;
1024            split_argv(argv)
1025        }
1026        "shell" => {
1027            let command = require_param(params, "command")?;
1028            let mut invocation_params = params.clone();
1029            invocation_params.put_str("command", command);
1030            let invocation =
1031                crate::shells::resolve_invocation_from_vm_params(&invocation_params)
1032                    .map_err(|err| VmError::Runtime(format!("host_call process.exec: {err}")))?;
1033            Ok((invocation.program, invocation.args))
1034        }
1035        other => Err(VmError::Runtime(format!(
1036            "host_call process.exec unsupported mode {other:?}"
1037        ))),
1038    }
1039}
1040
1041fn split_argv(mut argv: Vec<String>) -> Result<(String, Vec<String>), VmError> {
1042    if argv.is_empty() {
1043        return Err(VmError::Runtime(
1044            "host_call process.exec argv must not be empty".to_string(),
1045        ));
1046    }
1047    let program = argv.remove(0);
1048    if program.is_empty() {
1049        return Err(VmError::Runtime(
1050            "host_call process.exec argv[0] must not be empty".to_string(),
1051        ));
1052    }
1053    Ok((program, argv))
1054}
1055
1056/// Push a transient policy onto the execution stack with the
1057/// requested sandbox profile, returning a guard that pops on drop.
1058/// Used by `host_call("process", "exec", ...)` to honor a per-call
1059/// `sandbox_profile` override without rewriting the surrounding
1060/// orchestration policy.
1061pub(crate) fn push_sandbox_profile_override(value: &str) -> Result<SandboxProfileGuard, VmError> {
1062    let profile = crate::orchestration::SandboxProfile::parse(value).ok_or_else(|| {
1063        VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
1064            "host_call process.exec: unknown sandbox_profile {value:?}; expected one of \"unrestricted\", \"worktree\", \"os_hardened\", \"wasi\""
1065        ))))
1066    })?;
1067    let mut policy = crate::orchestration::current_execution_policy().unwrap_or_default();
1068    policy.sandbox_profile = profile;
1069    crate::orchestration::push_execution_policy(policy);
1070    Ok(SandboxProfileGuard {
1071        _private: std::marker::PhantomData,
1072    })
1073}
1074
1075pub(crate) struct SandboxProfileGuard {
1076    _private: std::marker::PhantomData<*const ()>,
1077}
1078
1079impl Drop for SandboxProfileGuard {
1080    fn drop(&mut self) {
1081        crate::orchestration::pop_execution_policy();
1082    }
1083}
1084
1085pub(crate) fn optional_i64(params: &crate::value::DictMap, key: &str) -> Option<i64> {
1086    match params.get(key) {
1087        Some(VmValue::Int(value)) => Some(*value),
1088        Some(VmValue::Float(value)) if value.fract() == 0.0 => Some(*value as i64),
1089        _ => None,
1090    }
1091}
1092
1093pub(crate) fn optional_string(params: &crate::value::DictMap, key: &str) -> Option<String> {
1094    params.get(key).and_then(vm_string).map(ToString::to_string)
1095}
1096
1097fn optional_string_list(params: &crate::value::DictMap, key: &str) -> Option<Vec<String>> {
1098    let VmValue::List(values) = params.get(key)? else {
1099        return None;
1100    };
1101    values
1102        .iter()
1103        .map(|value| vm_string(value).map(ToString::to_string))
1104        .collect()
1105}
1106
1107fn optional_string_dict(
1108    params: &crate::value::DictMap,
1109    key: &str,
1110) -> Result<Option<BTreeMap<String, String>>, VmError> {
1111    let Some(value) = params.get(key) else {
1112        return Ok(None);
1113    };
1114    let Some(dict) = value.as_dict() else {
1115        return Err(VmError::Runtime(format!(
1116            "host_call process.exec {key} must be a dict"
1117        )));
1118    };
1119    let mut out = std::collections::BTreeMap::new();
1120    for (key, value) in dict.iter() {
1121        let Some(value) = vm_string(value) else {
1122            return Err(VmError::Runtime(format!(
1123                "host_call process.exec env value for {key:?} must be a string"
1124            )));
1125        };
1126        out.insert(key.to_string(), value.to_string());
1127    }
1128    Ok(Some(out))
1129}
1130
1131fn vm_string(value: &VmValue) -> Option<&str> {
1132    match value {
1133        VmValue::String(value) => Some(value.as_ref()),
1134        _ => None,
1135    }
1136}
1137
1138pub(crate) fn register_host_builtins(vm: &mut Vm) {
1139    for def in MODULE_BUILTINS {
1140        vm.register_builtin_def(def);
1141    }
1142}
1143
1144#[harn_builtin(
1145    sig = "host_mock(capability: string, op: string, response_or_config?: any, params?: dict) -> nil",
1146    category = "host"
1147)]
1148fn host_mock_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1149    let host_mock = parse_host_mock(args)?;
1150    push_host_mock(host_mock);
1151    Ok(VmValue::Nil)
1152}
1153
1154#[harn_builtin(sig = "host_mock_clear() -> nil", category = "host")]
1155fn host_mock_clear_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1156    reset_host_state();
1157    Ok(VmValue::Nil)
1158}
1159
1160#[harn_builtin(sig = "host_mock_calls() -> list", category = "host")]
1161fn host_mock_calls_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1162    let calls = HOST_MOCK_CALLS.with(|calls| {
1163        calls
1164            .borrow()
1165            .iter()
1166            .map(mock_call_value)
1167            .collect::<Vec<_>>()
1168    });
1169    Ok(VmValue::List(std::sync::Arc::new(calls)))
1170}
1171
1172#[harn_builtin(sig = "host_mock_push_scope() -> nil", category = "host")]
1173fn host_mock_push_scope_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1174    push_host_mock_scope();
1175    Ok(VmValue::Nil)
1176}
1177
1178#[harn_builtin(sig = "host_mock_pop_scope() -> nil", category = "host")]
1179fn host_mock_pop_scope_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1180    if !pop_host_mock_scope() {
1181        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1182            "host_mock_pop_scope: no scope to pop",
1183        ))));
1184    }
1185    Ok(VmValue::Nil)
1186}
1187
1188#[harn_builtin(sig = "host_capabilities() -> dict", category = "host")]
1189fn host_capabilities_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1190    Ok(capability_manifest_with_mocks())
1191}
1192
1193#[harn_builtin(
1194    sig = "host_has(capability: string, op?: string) -> bool",
1195    category = "host"
1196)]
1197fn host_has_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1198    let capability = args.first().map(|a| a.display()).unwrap_or_default();
1199    let operation = args.get(1).map(|a| a.display());
1200    let manifest = capability_manifest_with_mocks();
1201    let has = manifest
1202        .as_dict()
1203        .and_then(|d| d.get(capability.as_str()))
1204        .and_then(|v| v.as_dict())
1205        .is_some_and(|cap| {
1206            if let Some(operation) = operation {
1207                cap.get("ops")
1208                    .and_then(|v| match v {
1209                        VmValue::List(list) => {
1210                            Some(list.iter().any(|item| item.display() == operation))
1211                        }
1212                        _ => None,
1213                    })
1214                    .unwrap_or(false)
1215            } else {
1216                true
1217            }
1218        });
1219    Ok(VmValue::Bool(has))
1220}
1221
1222#[harn_builtin(
1223    sig = "host_call(name: string, args?: dict) -> any",
1224    kind = "async",
1225    category = "host"
1226)]
1227async fn host_call_builtin(
1228    ctx: crate::vm::AsyncBuiltinCtx,
1229    args: Vec<VmValue>,
1230) -> Result<VmValue, VmError> {
1231    let name = args.first().map(|a| a.display()).unwrap_or_default();
1232    let params = args
1233        .get(1)
1234        .and_then(|a| a.as_dict())
1235        .cloned()
1236        .unwrap_or_default();
1237    let Some((capability, operation)) = name.split_once('.') else {
1238        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1239            format!("host_call: unsupported operation name '{name}'"),
1240        ))));
1241    };
1242    dispatch_host_operation_with_ctx(Some(&ctx), capability, operation, &params).await
1243}
1244
1245#[harn_builtin(sig = "host_tool_list() -> list", kind = "async", category = "host")]
1246async fn host_tool_list_builtin(
1247    ctx: crate::vm::AsyncBuiltinCtx,
1248    _args: Vec<VmValue>,
1249) -> Result<VmValue, VmError> {
1250    dispatch_host_tool_list_with_ctx(Some(&ctx)).await
1251}
1252
1253#[harn_builtin(
1254    sig = "host_tool_call(name: string, args?: any) -> any",
1255    kind = "async",
1256    category = "host"
1257)]
1258async fn host_tool_call_builtin(
1259    ctx: crate::vm::AsyncBuiltinCtx,
1260    args: Vec<VmValue>,
1261) -> Result<VmValue, VmError> {
1262    let name = args.first().map(|a| a.display()).unwrap_or_default();
1263    if name.is_empty() {
1264        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1265            "host_tool_call: tool name is required",
1266        ))));
1267    }
1268    let call_args = args.get(1).cloned().unwrap_or(VmValue::Nil);
1269    dispatch_host_tool_call_with_ctx(Some(&ctx), &name, &call_args).await
1270}
1271
1272#[cfg(test)]
1273mod tests {
1274    use super::{
1275        capability_manifest_with_mocks, clear_host_call_bridge, dispatch_host_operation,
1276        dispatch_host_tool_call, dispatch_host_tool_list, dispatch_mock_host_call, push_host_mock,
1277        reset_host_state, resolve_process_exec_cwd, set_host_call_bridge, HostCallBridge, HostMock,
1278    };
1279    use crate::value::VmDictExt;
1280
1281    use std::sync::{
1282        atomic::{AtomicUsize, Ordering},
1283        Arc,
1284    };
1285
1286    use crate::value::{VmError, VmValue};
1287
1288    #[test]
1289    fn process_exec_relative_cwd_resolves_against_execution_root() {
1290        let dir = tempfile::tempdir().expect("tempdir");
1291        crate::stdlib::process::set_thread_execution_context(Some(
1292            crate::orchestration::RunExecutionRecord {
1293                cwd: Some(dir.path().to_string_lossy().into_owned()),
1294                source_dir: Some(dir.path().join("src").to_string_lossy().into_owned()),
1295                env: std::collections::BTreeMap::new(),
1296                adapter: None,
1297                repo_path: None,
1298                worktree_path: None,
1299                branch: None,
1300                base_ref: None,
1301                cleanup: None,
1302            },
1303        ));
1304
1305        assert_eq!(
1306            resolve_process_exec_cwd("subdir"),
1307            dir.path().join("subdir")
1308        );
1309
1310        crate::stdlib::process::set_thread_execution_context(None);
1311    }
1312
1313    #[test]
1314    fn manifest_includes_operation_metadata() {
1315        let manifest = capability_manifest_with_mocks();
1316        let process = manifest
1317            .as_dict()
1318            .and_then(|d| d.get("process"))
1319            .and_then(|v| v.as_dict())
1320            .expect("process capability");
1321        assert!(process.get("description").is_some());
1322        let operations = process
1323            .get("operations")
1324            .and_then(|v| v.as_dict())
1325            .expect("operations dict");
1326        assert!(operations.get("exec").is_some());
1327    }
1328
1329    #[test]
1330    fn mocked_capabilities_appear_in_manifest() {
1331        reset_host_state();
1332        push_host_mock(HostMock {
1333            capability: "project".to_string(),
1334            operation: "metadata_get".to_string(),
1335            params: None,
1336            result: Some(VmValue::dict(crate::value::DictMap::new())),
1337            error: None,
1338        });
1339        let manifest = capability_manifest_with_mocks();
1340        let project = manifest
1341            .as_dict()
1342            .and_then(|d| d.get("project"))
1343            .and_then(|v| v.as_dict())
1344            .expect("project capability");
1345        let operations = project
1346            .get("operations")
1347            .and_then(|v| v.as_dict())
1348            .expect("operations dict");
1349        assert!(operations.get("metadata_get").is_some());
1350        reset_host_state();
1351    }
1352
1353    #[test]
1354    fn mock_host_call_matches_partial_params_and_overrides_order() {
1355        reset_host_state();
1356        let mut exact_params = crate::value::DictMap::new();
1357        exact_params.put_str("namespace", "facts");
1358        push_host_mock(HostMock {
1359            capability: "project".to_string(),
1360            operation: "metadata_get".to_string(),
1361            params: None,
1362            result: Some(VmValue::String(arcstr::ArcStr::from("fallback"))),
1363            error: None,
1364        });
1365        push_host_mock(HostMock {
1366            capability: "project".to_string(),
1367            operation: "metadata_get".to_string(),
1368            params: Some(exact_params),
1369            result: Some(VmValue::String(arcstr::ArcStr::from("facts"))),
1370            error: None,
1371        });
1372
1373        let mut call_params = crate::value::DictMap::new();
1374        call_params.put_str("dir", "pkg");
1375        call_params.put_str("namespace", "facts");
1376        let exact = dispatch_mock_host_call("project", "metadata_get", &call_params)
1377            .expect("expected exact mock")
1378            .expect("exact mock should succeed");
1379        assert_eq!(exact.display(), "facts");
1380
1381        call_params.put_str("namespace", "classification");
1382        let fallback = dispatch_mock_host_call("project", "metadata_get", &call_params)
1383            .expect("expected fallback mock")
1384            .expect("fallback mock should succeed");
1385        assert_eq!(fallback.display(), "fallback");
1386        reset_host_state();
1387    }
1388
1389    #[test]
1390    fn mock_host_call_can_throw_errors() {
1391        reset_host_state();
1392        push_host_mock(HostMock {
1393            capability: "project".to_string(),
1394            operation: "metadata_get".to_string(),
1395            params: None,
1396            result: None,
1397            error: Some("boom".to_string()),
1398        });
1399        let params = crate::value::DictMap::new();
1400        let result = dispatch_mock_host_call("project", "metadata_get", &params)
1401            .expect("expected mock result");
1402        match result {
1403            Err(VmError::Thrown(VmValue::String(message))) => assert_eq!(message.as_str(), "boom"),
1404            other => panic!("unexpected result: {other:?}"),
1405        }
1406        reset_host_state();
1407    }
1408
1409    #[derive(Default)]
1410    struct TestHostToolBridge;
1411
1412    impl HostCallBridge for TestHostToolBridge {
1413        fn dispatch(
1414            &self,
1415            _capability: &str,
1416            _operation: &str,
1417            _params: &crate::value::DictMap,
1418        ) -> Result<Option<VmValue>, VmError> {
1419            Ok(None)
1420        }
1421
1422        fn list_tools(&self) -> Result<Option<VmValue>, VmError> {
1423            let tool = VmValue::dict(crate::value::DictMap::from_iter([
1424                (
1425                    crate::value::intern_key("name"),
1426                    VmValue::String(arcstr::ArcStr::from("Read".to_string())),
1427                ),
1428                (
1429                    crate::value::intern_key("description"),
1430                    VmValue::String(arcstr::ArcStr::from(
1431                        "Read a file from the host".to_string(),
1432                    )),
1433                ),
1434                (
1435                    crate::value::intern_key("schema"),
1436                    VmValue::dict(crate::value::DictMap::from_iter([(
1437                        crate::value::intern_key("type"),
1438                        VmValue::String(arcstr::ArcStr::from("object".to_string())),
1439                    )])),
1440                ),
1441                (crate::value::intern_key("deprecated"), VmValue::Bool(false)),
1442            ]));
1443            Ok(Some(VmValue::List(std::sync::Arc::new(vec![tool]))))
1444        }
1445
1446        fn call_tool(&self, name: &str, args: &VmValue) -> Result<Option<VmValue>, VmError> {
1447            if name != "Read" {
1448                return Ok(None);
1449            }
1450            let path = args
1451                .as_dict()
1452                .and_then(|dict| dict.get("path"))
1453                .map(|value| value.display())
1454                .unwrap_or_default();
1455            Ok(Some(VmValue::String(arcstr::ArcStr::from(format!(
1456                "read:{path}"
1457            )))))
1458        }
1459    }
1460
1461    struct CountingProcessExecBridge {
1462        calls: Arc<AtomicUsize>,
1463    }
1464
1465    impl HostCallBridge for CountingProcessExecBridge {
1466        fn dispatch(
1467            &self,
1468            capability: &str,
1469            operation: &str,
1470            _params: &crate::value::DictMap,
1471        ) -> Result<Option<VmValue>, VmError> {
1472            if (capability, operation) != ("process", "exec") {
1473                return Ok(None);
1474            }
1475            self.calls.fetch_add(1, Ordering::SeqCst);
1476            Ok(Some(VmValue::dict(crate::value::DictMap::from_iter([
1477                (
1478                    crate::value::intern_key("status"),
1479                    VmValue::String(arcstr::ArcStr::from("completed".to_string())),
1480                ),
1481                (crate::value::intern_key("exit_code"), VmValue::Int(0)),
1482                (crate::value::intern_key("success"), VmValue::Bool(true)),
1483            ]))))
1484        }
1485    }
1486
1487    fn run_host_async_test<F, Fut>(test: F)
1488    where
1489        F: FnOnce() -> Fut,
1490        Fut: std::future::Future<Output = ()>,
1491    {
1492        let rt = tokio::runtime::Builder::new_current_thread()
1493            .enable_all()
1494            .build()
1495            .expect("runtime");
1496        rt.block_on(async {
1497            let local = tokio::task::LocalSet::new();
1498            local.run_until(test()).await;
1499        });
1500    }
1501
1502    #[test]
1503    fn host_tool_list_uses_installed_host_call_bridge() {
1504        run_host_async_test(|| async {
1505            reset_host_state();
1506            set_host_call_bridge(Arc::new(TestHostToolBridge));
1507            let tools = dispatch_host_tool_list().await.expect("tool list");
1508            clear_host_call_bridge();
1509
1510            let VmValue::List(items) = tools else {
1511                panic!("expected tool list");
1512            };
1513            assert_eq!(items.len(), 1);
1514            let tool = items[0].as_dict().expect("tool dict");
1515            assert_eq!(tool.get("name").unwrap().display(), "Read");
1516            assert_eq!(tool.get("deprecated").unwrap().display(), "false");
1517        });
1518    }
1519
1520    #[test]
1521    fn host_tool_call_uses_installed_host_call_bridge() {
1522        run_host_async_test(|| async {
1523            set_host_call_bridge(Arc::new(TestHostToolBridge));
1524            let args = VmValue::dict(crate::value::DictMap::from_iter([(
1525                crate::value::intern_key("path"),
1526                VmValue::String(arcstr::ArcStr::from("README.md".to_string())),
1527            )]));
1528            let value = dispatch_host_tool_call("Read", &args)
1529                .await
1530                .expect("tool call");
1531            clear_host_call_bridge();
1532            assert_eq!(value.display(), "read:README.md");
1533        });
1534    }
1535
1536    #[test]
1537    fn process_exec_bridge_is_gated_by_command_policy() {
1538        run_host_async_test(|| async {
1539            crate::orchestration::clear_command_policies();
1540            let calls = Arc::new(AtomicUsize::new(0));
1541            set_host_call_bridge(Arc::new(CountingProcessExecBridge {
1542                calls: calls.clone(),
1543            }));
1544            crate::orchestration::push_command_policy(crate::orchestration::CommandPolicy {
1545                tools: vec!["run".to_string()],
1546                workspace_roots: Vec::new(),
1547                default_shell_mode: "shell".to_string(),
1548                deny_patterns: vec!["cat *".to_string()],
1549                require_approval: Default::default(),
1550                pre: None,
1551                post: None,
1552                allow_recursive: false,
1553            });
1554
1555            let result = dispatch_host_operation(
1556                "process",
1557                "exec",
1558                &crate::value::DictMap::from_iter([
1559                    (
1560                        crate::value::intern_key("mode"),
1561                        VmValue::String(arcstr::ArcStr::from("shell")),
1562                    ),
1563                    (
1564                        crate::value::intern_key("command"),
1565                        VmValue::String(arcstr::ArcStr::from("cat Cargo.toml")),
1566                    ),
1567                ]),
1568            )
1569            .await
1570            .expect("process.exec result");
1571
1572            crate::orchestration::clear_command_policies();
1573            clear_host_call_bridge();
1574
1575            assert_eq!(
1576                calls.load(Ordering::SeqCst),
1577                0,
1578                "blocked command must not reach host bridge"
1579            );
1580            let result = result.as_dict().expect("blocked result dict");
1581            assert_eq!(result.get("status").unwrap().display(), "blocked");
1582            assert!(
1583                result
1584                    .get("reason")
1585                    .map(VmValue::display)
1586                    .unwrap_or_default()
1587                    .contains("cat *"),
1588                "blocked result should name the matched policy pattern"
1589            );
1590        });
1591    }
1592
1593    #[cfg(unix)]
1594    async fn process_exec_env_probe(env: VmValue, env_mode: Option<&str>) -> (String, String) {
1595        // Run `sh -c 'printf "%s|%s" "$PARENT_VAR" "$CHILD_VAR"'` so we can
1596        // observe whether an inherited parent var survives alongside the
1597        // explicitly-provided child var. The parent var is set on this
1598        // process's environment immediately before the spawn.
1599        std::env::set_var("PARENT_VAR", "inherited");
1600        let mut params = crate::value::DictMap::from_iter([
1601            (
1602                crate::value::intern_key("mode"),
1603                VmValue::String(arcstr::ArcStr::from("argv")),
1604            ),
1605            (
1606                crate::value::intern_key("argv"),
1607                VmValue::List(std::sync::Arc::new(vec![
1608                    // Absolute path so the spawn does not depend on PATH,
1609                    // which the `replace` case intentionally clears.
1610                    VmValue::String(arcstr::ArcStr::from("/bin/sh")),
1611                    VmValue::String(arcstr::ArcStr::from("-c")),
1612                    VmValue::String(arcstr::ArcStr::from(
1613                        "printf '%s|%s' \"$PARENT_VAR\" \"$CHILD_VAR\"",
1614                    )),
1615                ])),
1616            ),
1617            (crate::value::intern_key("env"), env),
1618        ]);
1619        if let Some(mode) = env_mode {
1620            params.put_str("env_mode", mode);
1621        }
1622        let result = super::dispatch_process_exec(&params, serde_json::Value::Null)
1623            .await
1624            .expect("process.exec result");
1625        let dict = result.as_dict().expect("result dict");
1626        let stdout = dict.get("stdout").map(VmValue::display).unwrap_or_default();
1627        std::env::remove_var("PARENT_VAR");
1628        let (parent, child) = stdout.split_once('|').unwrap_or((&stdout, ""));
1629        (parent.to_string(), child.to_string())
1630    }
1631
1632    #[cfg(unix)]
1633    #[test]
1634    fn process_exec_env_default_merges_with_parent() {
1635        run_host_async_test(|| async {
1636            // No `env_mode`: the provided key must be added WITHOUT clearing
1637            // the inherited parent environment (the env-clear footgun fix).
1638            let child_env = VmValue::dict(crate::value::DictMap::from_iter([(
1639                crate::value::intern_key("CHILD_VAR"),
1640                VmValue::String(arcstr::ArcStr::from("provided")),
1641            )]));
1642            let (parent, child) = process_exec_env_probe(child_env, None).await;
1643            assert_eq!(
1644                parent, "inherited",
1645                "default env_mode must inherit parent env"
1646            );
1647            assert_eq!(
1648                child, "provided",
1649                "default env_mode must apply provided keys"
1650            );
1651        });
1652    }
1653
1654    #[cfg(unix)]
1655    #[test]
1656    fn process_exec_env_mode_replace_clears_parent() {
1657        run_host_async_test(|| async {
1658            // Explicit `replace`: the inherited parent var must be gone and
1659            // only the provided key survives. This preserves the ability to
1660            // fully replace the environment when intentionally requested.
1661            let child_env = VmValue::dict(crate::value::DictMap::from_iter([(
1662                crate::value::intern_key("CHILD_VAR"),
1663                VmValue::String(arcstr::ArcStr::from("provided")),
1664            )]));
1665            let (parent, child) = process_exec_env_probe(child_env, Some("replace")).await;
1666            assert_eq!(parent, "", "explicit replace must clear parent env");
1667            assert_eq!(
1668                child, "provided",
1669                "explicit replace must keep provided keys"
1670            );
1671        });
1672    }
1673
1674    #[cfg(unix)]
1675    #[test]
1676    fn process_exec_env_mode_unknown_is_rejected() {
1677        run_host_async_test(|| async {
1678            let params = crate::value::DictMap::from_iter([
1679                (
1680                    crate::value::intern_key("mode"),
1681                    VmValue::String(arcstr::ArcStr::from("argv")),
1682                ),
1683                (
1684                    crate::value::intern_key("argv"),
1685                    VmValue::List(std::sync::Arc::new(vec![VmValue::String(
1686                        arcstr::ArcStr::from("true"),
1687                    )])),
1688                ),
1689                (
1690                    crate::value::intern_key("env"),
1691                    VmValue::dict(crate::value::DictMap::from_iter([(
1692                        crate::value::intern_key("CHILD_VAR"),
1693                        VmValue::String(arcstr::ArcStr::from("x")),
1694                    )])),
1695                ),
1696                (
1697                    crate::value::intern_key("env_mode"),
1698                    VmValue::String(arcstr::ArcStr::from("bogus")),
1699                ),
1700            ]);
1701            let err = super::dispatch_process_exec(&params, serde_json::Value::Null)
1702                .await
1703                .expect_err("unknown env_mode must error");
1704            assert!(
1705                format!("{err:?}").contains("env_mode"),
1706                "error should name env_mode, got {err:?}"
1707            );
1708        });
1709    }
1710
1711    // Drive the real `host_call("process","exec")` builder under a restricted
1712    // policy and read back the `$TMPDIR` the child actually saw. This is the
1713    // agent-facing path; the assertion is OS-independent (it observes the
1714    // injected env, not OS-sandbox enforcement), so it pins the mechanism on
1715    // every CI host while the live OS-level link proof runs on tornadough.
1716    #[cfg(unix)]
1717    async fn process_exec_tmpdir_probe(
1718        workspace: &std::path::Path,
1719        caller_env: Option<VmValue>,
1720    ) -> String {
1721        let mut env_pairs = vec![(
1722            crate::value::intern_key("mode"),
1723            VmValue::String(arcstr::ArcStr::from("argv")),
1724        )];
1725        env_pairs.push((
1726            crate::value::intern_key("argv"),
1727            VmValue::List(std::sync::Arc::new(vec![
1728                VmValue::String(arcstr::ArcStr::from("/bin/sh")),
1729                VmValue::String(arcstr::ArcStr::from("-c")),
1730                VmValue::String(arcstr::ArcStr::from("printf '%s' \"$TMPDIR\"")),
1731            ])),
1732        ));
1733        if let Some(env) = caller_env {
1734            env_pairs.push((crate::value::intern_key("env"), env));
1735        }
1736        let params = crate::value::DictMap::from_iter(env_pairs);
1737
1738        crate::orchestration::push_execution_policy(crate::orchestration::CapabilityPolicy {
1739            sandbox_profile: crate::orchestration::SandboxProfile::Worktree,
1740            workspace_roots: vec![workspace.to_string_lossy().into_owned()],
1741            // Keep OS confinement out of this unit assertion regardless of host
1742            // Landlock/seatbelt availability; we are pinning the env injection,
1743            // not OS enforcement (which the tornadough run proves end-to-end).
1744            ..crate::orchestration::CapabilityPolicy::default()
1745        });
1746        std::env::set_var("HARN_HANDLER_SANDBOX", "off");
1747        let result = super::dispatch_process_exec(&params, serde_json::Value::Null)
1748            .await
1749            .expect("process.exec result");
1750        std::env::remove_var("HARN_HANDLER_SANDBOX");
1751        crate::orchestration::pop_execution_policy();
1752        result
1753            .as_dict()
1754            .and_then(|d| d.get("stdout"))
1755            .map(VmValue::display)
1756            .unwrap_or_default()
1757    }
1758
1759    #[cfg(unix)]
1760    #[test]
1761    fn process_exec_injects_workspace_local_tmpdir() {
1762        run_host_async_test(|| async {
1763            let workspace = tempfile::tempdir().expect("workspace");
1764            let tmpdir = process_exec_tmpdir_probe(workspace.path(), None).await;
1765
1766            assert!(
1767                !tmpdir.is_empty(),
1768                "sandboxed child must receive a non-empty TMPDIR"
1769            );
1770            let tmpdir_path = std::path::PathBuf::from(&tmpdir);
1771            let canonical_tmpdir = std::fs::canonicalize(&tmpdir_path)
1772                .expect("workspace-local TMPDIR should canonicalize");
1773            let canonical_workspace =
1774                std::fs::canonicalize(workspace.path()).expect("workspace should canonicalize");
1775            assert!(
1776                canonical_tmpdir.starts_with(&canonical_workspace),
1777                "child TMPDIR {tmpdir:?} must live inside the workspace {:?}",
1778                workspace.path()
1779            );
1780            assert!(
1781                tmpdir_path.ends_with(".harn-tmp"),
1782                "child TMPDIR {tmpdir:?} must be the workspace-local .harn-tmp dir"
1783            );
1784            assert!(
1785                tmpdir_path.is_dir(),
1786                "the workspace-local TMPDIR must have been created on disk"
1787            );
1788        });
1789    }
1790
1791    #[cfg(unix)]
1792    #[test]
1793    fn process_exec_respects_caller_pinned_tmpdir() {
1794        run_host_async_test(|| async {
1795            let workspace = tempfile::tempdir().expect("workspace");
1796            let caller_tmp = workspace.path().join("caller-chosen");
1797            std::fs::create_dir_all(&caller_tmp).unwrap();
1798            let caller_env = VmValue::dict(crate::value::DictMap::from_iter([(
1799                crate::value::intern_key("TMPDIR"),
1800                VmValue::String(arcstr::ArcStr::from(
1801                    caller_tmp.to_string_lossy().into_owned(),
1802                )),
1803            )]));
1804
1805            let tmpdir = process_exec_tmpdir_probe(workspace.path(), Some(caller_env)).await;
1806
1807            assert_eq!(
1808                std::path::PathBuf::from(&tmpdir),
1809                caller_tmp,
1810                "an explicit caller TMPDIR must override the workspace-local default"
1811            );
1812        });
1813    }
1814
1815    #[test]
1816    fn host_tool_list_is_empty_without_bridge() {
1817        run_host_async_test(|| async {
1818            clear_host_call_bridge();
1819            let tools = dispatch_host_tool_list().await.expect("tool list");
1820            let VmValue::List(items) = tools else {
1821                panic!("expected tool list");
1822            };
1823            assert!(items.is_empty());
1824        });
1825    }
1826}