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