Skip to main content

harn_vm/stdlib/
host.rs

1use std::cell::RefCell;
2use std::collections::BTreeMap;
3use std::sync::Arc;
4use std::time::Instant;
5
6use serde_json::Value as JsonValue;
7
8use crate::stdlib::macros::{harn_builtin, VmBuiltinDef};
9use crate::value::{values_equal, VmError, VmValue};
10use crate::vm::{AsyncBuiltinCtx, Vm};
11
12/// Audited wrapper for `chrono::Utc::now().to_rfc3339()`. Routes through
13/// the testbench leak audit so a paused-clock session can surface every
14/// host capability that observed real wall-clock time.
15fn audited_utc_now_rfc3339(capability_id: &'static str) -> String {
16    let dt: chrono::DateTime<chrono::Utc> =
17        crate::clock_mock::leak_audit::wall_now(capability_id).into();
18    dt.to_rfc3339()
19}
20
21pub(crate) const MODULE_BUILTINS: &[&VmBuiltinDef] = &[
22    &HOST_MOCK_BUILTIN_DEF,
23    &HOST_MOCK_CLEAR_BUILTIN_DEF,
24    &HOST_MOCK_CALLS_BUILTIN_DEF,
25    &HOST_MOCK_PUSH_SCOPE_BUILTIN_DEF,
26    &HOST_MOCK_POP_SCOPE_BUILTIN_DEF,
27    &HOST_CAPABILITIES_BUILTIN_DEF,
28    &HOST_HAS_BUILTIN_DEF,
29    &HOST_CALL_BUILTIN_DEF,
30    &HOST_TOOL_LIST_BUILTIN_DEF,
31    &HOST_TOOL_CALL_BUILTIN_DEF,
32];
33
34#[derive(Clone)]
35struct HostMock {
36    capability: String,
37    operation: String,
38    params: Option<BTreeMap<String, VmValue>>,
39    result: Option<VmValue>,
40    error: Option<String>,
41}
42
43#[derive(Clone)]
44struct HostMockCall {
45    capability: String,
46    operation: String,
47    params: BTreeMap<String, VmValue>,
48}
49
50thread_local! {
51    static HOST_MOCKS: RefCell<Vec<HostMock>> = const { RefCell::new(Vec::new()) };
52    static HOST_MOCK_CALLS: RefCell<Vec<HostMockCall>> = const { RefCell::new(Vec::new()) };
53    static HOST_MOCK_SCOPES: RefCell<Vec<(Vec<HostMock>, Vec<HostMockCall>)>> =
54        const { RefCell::new(Vec::new()) };
55}
56
57pub(crate) fn reset_host_state() {
58    HOST_MOCKS.with(|mocks| mocks.borrow_mut().clear());
59    HOST_MOCK_CALLS.with(|calls| calls.borrow_mut().clear());
60    HOST_MOCK_SCOPES.with(|scopes| scopes.borrow_mut().clear());
61}
62
63/// Push the current host-mock state onto an internal stack and start a
64/// fresh empty scope. Paired with `pop_host_mock_scope`. Used by the
65/// `with_host_mocks` helper in `std/testing` to give tests automatic
66/// cleanup, including when the body throws.
67fn push_host_mock_scope() {
68    let mocks = HOST_MOCKS.with(|v| std::mem::take(&mut *v.borrow_mut()));
69    let calls = HOST_MOCK_CALLS.with(|v| std::mem::take(&mut *v.borrow_mut()));
70    HOST_MOCK_SCOPES.with(|v| v.borrow_mut().push((mocks, calls)));
71}
72
73/// Restore the most recently pushed host-mock state, replacing any
74/// mocks or recorded calls accumulated inside the scope. Returns
75/// `false` if there is no saved scope to pop, so callers can surface a
76/// clear "imbalanced scope" error rather than silently no-op'ing.
77fn pop_host_mock_scope() -> bool {
78    let entry = HOST_MOCK_SCOPES.with(|v| v.borrow_mut().pop());
79    match entry {
80        Some((mocks, calls)) => {
81            HOST_MOCKS.with(|v| *v.borrow_mut() = mocks);
82            HOST_MOCK_CALLS.with(|v| *v.borrow_mut() = calls);
83            true
84        }
85        None => false,
86    }
87}
88
89fn capability_manifest_map() -> BTreeMap<String, VmValue> {
90    let mut root = BTreeMap::new();
91    root.insert(
92        "process".to_string(),
93        capability(
94            "Process execution.",
95            &[
96                op("exec", "Execute a process in argv or shell mode."),
97                op("list_shells", "List shells discovered by the host/session."),
98                op(
99                    "get_default_shell",
100                    "Return the selected default shell for this host/session.",
101                ),
102                op(
103                    "set_default_shell",
104                    "Select the default shell for this host/session.",
105                ),
106                op(
107                    "shell_invocation",
108                    "Resolve shell selection and login/interactive flags into argv.",
109                ),
110            ],
111        ),
112    );
113    root.insert(
114        "template".to_string(),
115        capability(
116            "Template rendering.",
117            &[op("render", "Render a template file.")],
118        ),
119    );
120    root.insert(
121        "interaction".to_string(),
122        capability(
123            "User interaction.",
124            &[op("ask", "Ask the user a question.")],
125        ),
126    );
127    root.insert(
128        "memory".to_string(),
129        capability(
130            "Vector-aware memory: host-provided embeddings.",
131            &[op(
132                "embed",
133                "Embed text for semantic recall. Params: {text, model_hint?}. \
134                 Returns {vector: list<float>, model: string, dim: int}.",
135            )],
136        ),
137    );
138    root
139}
140
141fn mocked_operation_entry() -> VmValue {
142    op(
143        "mocked",
144        "Mocked host operation registered at runtime for tests.",
145    )
146    .1
147}
148
149fn ensure_mocked_capability(
150    root: &mut BTreeMap<String, VmValue>,
151    capability_name: &str,
152    operation_name: &str,
153) {
154    let Some(existing) = root.get(capability_name).cloned() else {
155        root.insert(
156            capability_name.to_string(),
157            capability(
158                "Mocked host capability registered at runtime for tests.",
159                &[(operation_name.to_string(), mocked_operation_entry())],
160            ),
161        );
162        return;
163    };
164
165    let Some(existing_dict) = existing.as_dict() else {
166        return;
167    };
168    let mut entry = (*existing_dict).clone();
169    let mut ops = entry
170        .get("ops")
171        .and_then(|value| match value {
172            VmValue::List(list) => Some((**list).clone()),
173            _ => None,
174        })
175        .unwrap_or_default();
176    if !ops.iter().any(|value| value.display() == operation_name) {
177        ops.push(VmValue::String(std::sync::Arc::from(
178            operation_name.to_string(),
179        )));
180    }
181
182    let mut operations = entry
183        .get("operations")
184        .and_then(|value| value.as_dict())
185        .map(|dict| (*dict).clone())
186        .unwrap_or_default();
187    operations
188        .entry(operation_name.to_string())
189        .or_insert_with(mocked_operation_entry);
190
191    entry.insert("ops".to_string(), VmValue::List(std::sync::Arc::new(ops)));
192    entry.insert(
193        "operations".to_string(),
194        VmValue::Dict(std::sync::Arc::new(operations)),
195    );
196    root.insert(
197        capability_name.to_string(),
198        VmValue::Dict(std::sync::Arc::new(entry)),
199    );
200}
201
202fn capability_manifest_with_mocks() -> VmValue {
203    let mut root = capability_manifest_map();
204    HOST_MOCKS.with(|mocks| {
205        for host_mock in mocks.borrow().iter() {
206            ensure_mocked_capability(&mut root, &host_mock.capability, &host_mock.operation);
207        }
208    });
209    VmValue::Dict(std::sync::Arc::new(root))
210}
211
212fn op(name: &str, description: &str) -> (String, VmValue) {
213    let mut entry = BTreeMap::new();
214    entry.insert(
215        "description".to_string(),
216        VmValue::String(std::sync::Arc::from(description)),
217    );
218    (name.to_string(), VmValue::Dict(std::sync::Arc::new(entry)))
219}
220
221fn capability(description: &str, ops: &[(String, VmValue)]) -> VmValue {
222    let mut entry = BTreeMap::new();
223    entry.insert(
224        "description".to_string(),
225        VmValue::String(std::sync::Arc::from(description)),
226    );
227    entry.insert(
228        "ops".to_string(),
229        VmValue::List(std::sync::Arc::new(
230            ops.iter()
231                .map(|(name, _)| VmValue::String(std::sync::Arc::from(name.as_str())))
232                .collect(),
233        )),
234    );
235    let mut op_dict = BTreeMap::new();
236    for (name, op) in ops {
237        op_dict.insert(name.clone(), op.clone());
238    }
239    entry.insert(
240        "operations".to_string(),
241        VmValue::Dict(std::sync::Arc::new(op_dict)),
242    );
243    VmValue::Dict(std::sync::Arc::new(entry))
244}
245
246fn require_param(params: &BTreeMap<String, VmValue>, key: &str) -> Result<String, VmError> {
247    params
248        .get(key)
249        .map(|v| v.display())
250        .filter(|v| !v.is_empty())
251        .ok_or_else(|| {
252            VmError::Thrown(VmValue::String(std::sync::Arc::from(format!(
253                "host_call: missing required parameter '{key}'"
254            ))))
255        })
256}
257
258fn render_template(
259    path: &str,
260    bindings: Option<&BTreeMap<String, VmValue>>,
261) -> Result<String, VmError> {
262    let asset = crate::stdlib::template::TemplateAsset::render_target(path).map_err(|msg| {
263        VmError::Thrown(VmValue::String(std::sync::Arc::from(format!(
264            "host_call template.render: {msg}"
265        ))))
266    })?;
267    crate::stdlib::template::render_asset_result(&asset, bindings).map_err(VmError::from)
268}
269
270fn params_match(
271    expected: Option<&BTreeMap<String, VmValue>>,
272    actual: &BTreeMap<String, VmValue>,
273) -> bool {
274    let Some(expected) = expected else {
275        return true;
276    };
277    expected.iter().all(|(key, value)| {
278        actual
279            .get(key)
280            .is_some_and(|candidate| values_equal(candidate, value))
281    })
282}
283
284fn parse_host_mock(args: &[VmValue]) -> Result<HostMock, VmError> {
285    let capability = args
286        .first()
287        .map(|value| value.display())
288        .unwrap_or_default();
289    let operation = args.get(1).map(|value| value.display()).unwrap_or_default();
290    if capability.is_empty() || operation.is_empty() {
291        return Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
292            "host_mock: capability and operation are required",
293        ))));
294    }
295
296    let mut params = args
297        .get(3)
298        .and_then(|value| value.as_dict())
299        .map(|dict| (*dict).clone());
300    let mut result = args.get(2).cloned().or(Some(VmValue::Nil));
301    let mut error = None;
302
303    if let Some(config) = args.get(2).and_then(|value| value.as_dict()) {
304        if config.contains_key("result")
305            || config.contains_key("params")
306            || config.contains_key("error")
307        {
308            params = config
309                .get("params")
310                .and_then(|value| value.as_dict())
311                .map(|dict| (*dict).clone());
312            result = config.get("result").cloned();
313            error = config
314                .get("error")
315                .map(|value| value.display())
316                .filter(|value| !value.is_empty());
317        }
318    }
319
320    Ok(HostMock {
321        capability,
322        operation,
323        params,
324        result,
325        error,
326    })
327}
328
329fn push_host_mock(host_mock: HostMock) {
330    HOST_MOCKS.with(|mocks| mocks.borrow_mut().push(host_mock));
331}
332
333fn mock_call_value(call: &HostMockCall) -> VmValue {
334    let mut item = BTreeMap::new();
335    item.insert(
336        "capability".to_string(),
337        VmValue::String(std::sync::Arc::from(call.capability.clone())),
338    );
339    item.insert(
340        "operation".to_string(),
341        VmValue::String(std::sync::Arc::from(call.operation.clone())),
342    );
343    item.insert(
344        "params".to_string(),
345        VmValue::Dict(std::sync::Arc::new(call.params.clone())),
346    );
347    VmValue::Dict(std::sync::Arc::new(item))
348}
349
350fn record_mock_call(capability: &str, operation: &str, params: &BTreeMap<String, VmValue>) {
351    HOST_MOCK_CALLS.with(|calls| {
352        calls.borrow_mut().push(HostMockCall {
353            capability: capability.to_string(),
354            operation: operation.to_string(),
355            params: params.clone(),
356        });
357    });
358}
359
360pub(crate) fn dispatch_mock_host_call(
361    capability: &str,
362    operation: &str,
363    params: &BTreeMap<String, VmValue>,
364) -> Option<Result<VmValue, VmError>> {
365    let matched = HOST_MOCKS.with(|mocks| {
366        mocks
367            .borrow()
368            .iter()
369            .rev()
370            .find(|host_mock| {
371                host_mock.capability == capability
372                    && host_mock.operation == operation
373                    && params_match(host_mock.params.as_ref(), params)
374            })
375            .cloned()
376    })?;
377
378    record_mock_call(capability, operation, params);
379    if let Some(error) = matched.error {
380        return Some(Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
381            error,
382        )))));
383    }
384    Some(Ok(matched.result.unwrap_or(VmValue::Nil)))
385}
386
387/// Embedder-supplied bridge for `host_call` ops.
388///
389/// Embedders (debug adapters, CLIs, IDE hosts) implement this trait to
390/// satisfy capability/operation pairs that harn-vm itself doesn't know how
391/// to handle. Returning `Ok(None)` means "I don't handle this op — fall
392/// through to the built-in fallbacks (env-derived defaults, then the
393/// `unsupported operation` error)". `Ok(Some(value))` is the result;
394/// `Err(VmError::Thrown(_))` surfaces as a Harn exception.
395///
396/// The trait is intentionally synchronous. Bridges that need async I/O
397/// (e.g. DAP reverse requests) should drive their own runtime or use a
398/// blocking channel — see `harn-dap`'s `DapHostBridge` for the canonical
399/// pattern. Sync keeps the boundary simple and avoids forcing the entire
400/// dispatch path into an opaque future.
401pub trait HostCallBridge: Send + Sync {
402    fn dispatch(
403        &self,
404        capability: &str,
405        operation: &str,
406        params: &BTreeMap<String, VmValue>,
407    ) -> Result<Option<VmValue>, VmError>;
408
409    fn list_tools(&self) -> Result<Option<VmValue>, VmError> {
410        Ok(None)
411    }
412
413    fn call_tool(&self, _name: &str, _args: &VmValue) -> Result<Option<VmValue>, VmError> {
414        Ok(None)
415    }
416}
417
418thread_local! {
419    static HOST_CALL_BRIDGE: RefCell<Option<Arc<dyn HostCallBridge>>> = const { RefCell::new(None) };
420}
421
422/// Install a bridge for the current thread. The bridge is consulted on
423/// every `host_call` *after* mock matching but *before* the built-in
424/// match arms, so embedders can override anything they like (and equally
425/// punt on anything they don't, by returning `Ok(None)`).
426pub fn set_host_call_bridge(bridge: Arc<dyn HostCallBridge>) {
427    HOST_CALL_BRIDGE.with(|b| *b.borrow_mut() = Some(bridge));
428}
429
430/// Remove the current thread's bridge. Idempotent.
431pub fn clear_host_call_bridge() {
432    HOST_CALL_BRIDGE.with(|b| *b.borrow_mut() = None);
433}
434
435/// Dispatch `(capability, operation, params)` to the currently-installed
436/// `HostCallBridge`, if any. `Some(Ok(_))` means the bridge handled the
437/// call; `Some(Err(_))` means it tried but raised; `None` means there is
438/// no bridge or the bridge declined this op (returned `Ok(None)`).
439///
440/// Mirrors the inner block of `dispatch_host_operation` but without the
441/// mock-call check or the built-in fallbacks — useful for callers that
442/// want to treat the bridge as one of several sinks (e.g. inbound MCP
443/// `elicitation/create` requests).
444pub fn dispatch_host_call_bridge(
445    capability: &str,
446    operation: &str,
447    params: &BTreeMap<String, VmValue>,
448) -> Option<Result<VmValue, VmError>> {
449    let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone())?;
450    match bridge.dispatch(capability, operation, params) {
451        Ok(Some(value)) => Some(Ok(value)),
452        Ok(None) => None,
453        Err(error) => Some(Err(error)),
454    }
455}
456
457fn empty_tool_list_value() -> VmValue {
458    VmValue::List(std::sync::Arc::new(Vec::new()))
459}
460
461fn current_vm_host_bridge(
462    ctx: Option<&AsyncBuiltinCtx>,
463) -> Option<std::sync::Arc<crate::bridge::HostBridge>> {
464    ctx.and_then(|ctx| ctx.child_vm().bridge.clone())
465}
466
467#[cfg(test)]
468async fn dispatch_host_tool_list() -> Result<VmValue, VmError> {
469    dispatch_host_tool_list_with_ctx(None).await
470}
471
472async fn dispatch_host_tool_list_with_ctx(
473    ctx: Option<&AsyncBuiltinCtx>,
474) -> Result<VmValue, VmError> {
475    let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
476    if let Some(bridge) = bridge {
477        if let Some(value) = bridge.list_tools()? {
478            return Ok(value);
479        }
480    }
481
482    let Some(bridge) = current_vm_host_bridge(ctx) else {
483        return Ok(empty_tool_list_value());
484    };
485    let tools = bridge.list_host_tools().await?;
486    Ok(crate::bridge::json_result_to_vm_value(&JsonValue::Array(
487        tools.into_iter().collect(),
488    )))
489}
490
491pub(crate) async fn dispatch_host_tool_call(
492    name: &str,
493    args: &VmValue,
494) -> Result<VmValue, VmError> {
495    dispatch_host_tool_call_with_ctx(None, name, args).await
496}
497
498pub(crate) async fn dispatch_host_tool_call_with_ctx(
499    ctx: Option<&AsyncBuiltinCtx>,
500    name: &str,
501    args: &VmValue,
502) -> Result<VmValue, VmError> {
503    let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
504    if let Some(bridge) = bridge {
505        if let Some(value) = bridge.call_tool(name, args)? {
506            return Ok(value);
507        }
508    }
509
510    let Some(bridge) = current_vm_host_bridge(ctx) else {
511        return Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
512            "host_tool_call: no host bridge is attached",
513        ))));
514    };
515
516    let result = bridge
517        .call(
518            "builtin_call",
519            serde_json::json!({
520                "name": name,
521                "args": [crate::llm::vm_value_to_json(args)],
522            }),
523        )
524        .await?;
525    Ok(crate::bridge::json_result_to_vm_value(&result))
526}
527
528pub(crate) async fn dispatch_host_operation(
529    capability: &str,
530    operation: &str,
531    params: &BTreeMap<String, VmValue>,
532) -> Result<VmValue, VmError> {
533    dispatch_host_operation_with_ctx(None, capability, operation, params).await
534}
535
536pub(crate) async fn dispatch_host_operation_with_ctx(
537    ctx: Option<&AsyncBuiltinCtx>,
538    capability: &str,
539    operation: &str,
540    params: &BTreeMap<String, VmValue>,
541) -> Result<VmValue, VmError> {
542    if let Some(mocked) = dispatch_mock_host_call(capability, operation, params) {
543        return mocked;
544    }
545
546    if (capability, operation) == ("process", "exec") {
547        let caller = serde_json::json!({
548            "surface": "host_call",
549            "capability": "process",
550            "operation": "exec",
551            "session_id": crate::llm::current_agent_session_id(),
552        });
553        return dispatch_process_exec_with_policy(ctx, params, caller).await;
554    }
555
556    let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
557    if let Some(bridge) = bridge {
558        if let Some(value) = bridge.dispatch(capability, operation, params)? {
559            return Ok(value);
560        }
561    }
562
563    dispatch_builtin_host_operation(capability, operation, params).await
564}
565
566async fn dispatch_builtin_host_operation(
567    capability: &str,
568    operation: &str,
569    params: &BTreeMap<String, VmValue>,
570) -> Result<VmValue, VmError> {
571    match (capability, operation) {
572        ("process", "list_shells") => Ok(crate::shells::list_shells_vm_value()),
573        ("process", "get_default_shell") => Ok(crate::shells::default_shell_vm_value()),
574        ("process", "set_default_shell") => crate::shells::set_default_shell_vm_value(params),
575        ("process", "shell_invocation") => crate::shells::shell_invocation_vm_value(params),
576        ("template", "render") => {
577            let path = require_param(params, "path")?;
578            let bindings = params.get("bindings").and_then(|v| v.as_dict());
579            Ok(VmValue::String(std::sync::Arc::from(render_template(
580                &path, bindings,
581            )?)))
582        }
583        ("interaction", "ask") => {
584            let question = require_param(params, "question")?;
585            use std::io::BufRead;
586            print!("{question}");
587            let _ = std::io::Write::flush(&mut std::io::stdout());
588            let mut input = String::new();
589            if std::io::stdin().lock().read_line(&mut input).is_ok() {
590                Ok(VmValue::String(std::sync::Arc::from(input.trim_end())))
591            } else {
592                Ok(VmValue::Nil)
593            }
594        }
595        // Standalone-run fallbacks for capabilities normally supplied by
596        // an embedder's JSON-RPC bridge. `runtime.task` lets a debugger or
597        // CLI invocation read the pipeline input from `HARN_TASK` without
598        // the host explicitly wiring a callback for every op.
599        ("runtime", "task") => Ok(VmValue::String(std::sync::Arc::from(
600            std::env::var("HARN_TASK").unwrap_or_default(),
601        ))),
602        ("runtime", "set_result") => {
603            // No-op when no host is attached; swallow silently so standalone
604            // scripts can still call `set_result` without crashing.
605            Ok(VmValue::Nil)
606        }
607        ("workspace", "project_root") => {
608            // Standalone fallback: prefer HARN_PROJECT_ROOT, then the
609            // current working directory. Pipelines call this very early so
610            // crashing here would block any debug-launched script.
611            let path = std::env::var("HARN_PROJECT_ROOT").unwrap_or_else(|_| {
612                std::env::current_dir()
613                    .map(|p| p.display().to_string())
614                    .unwrap_or_default()
615            });
616            Ok(VmValue::String(std::sync::Arc::from(path)))
617        }
618        ("workspace", "cwd") => {
619            let path = std::env::current_dir()
620                .map(|p| p.display().to_string())
621                .unwrap_or_default();
622            Ok(VmValue::String(std::sync::Arc::from(path)))
623        }
624        _ => Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
625            format!("host_call: unsupported operation {capability}.{operation}"),
626        )))),
627    }
628}
629
630pub(crate) async fn dispatch_process_exec(
631    params: &BTreeMap<String, VmValue>,
632    caller: serde_json::Value,
633) -> Result<VmValue, VmError> {
634    dispatch_process_exec_with_policy(None, params, caller).await
635}
636
637async fn dispatch_process_exec_with_policy(
638    ctx: Option<&AsyncBuiltinCtx>,
639    params: &BTreeMap<String, VmValue>,
640    caller: serde_json::Value,
641) -> Result<VmValue, VmError> {
642    let (params, command_policy_context, command_policy_decisions) =
643        match crate::orchestration::run_command_policy_preflight_with_ctx(ctx, params, caller)
644            .await?
645        {
646            crate::orchestration::CommandPolicyPreflight::Proceed {
647                params,
648                context,
649                decisions,
650            } => (params, context, decisions),
651            crate::orchestration::CommandPolicyPreflight::Blocked {
652                status,
653                message,
654                context,
655                decisions,
656            } => {
657                return Ok(crate::orchestration::blocked_command_response(
658                    params, status, &message, context, decisions,
659                ));
660            }
661        };
662
663    let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
664    if let Some(bridge) = bridge {
665        if let Some(value) = bridge.dispatch("process", "exec", &params)? {
666            return crate::orchestration::run_command_policy_postflight_with_ctx(
667                ctx,
668                &params,
669                value,
670                command_policy_context,
671                command_policy_decisions,
672            )
673            .await;
674        }
675    }
676
677    dispatch_process_exec_after_policy(
678        ctx,
679        &params,
680        command_policy_context,
681        command_policy_decisions,
682    )
683    .await
684}
685
686async fn dispatch_process_exec_after_policy(
687    ctx: Option<&AsyncBuiltinCtx>,
688    params: &BTreeMap<String, VmValue>,
689    command_policy_context: JsonValue,
690    command_policy_decisions: Vec<crate::orchestration::CommandPolicyDecision>,
691) -> Result<VmValue, VmError> {
692    let (program, args) = process_exec_argv(params)?;
693    let timeout_ms = optional_i64(params, "timeout")
694        .or_else(|| optional_i64(params, "timeout_ms"))
695        .filter(|value| *value > 0)
696        .map(|value| value as u64);
697    // Optional per-call profile override. Pipelines that want to
698    // promote a single spawn to `os_hardened` (e.g. running
699    // attacker-controlled code) pass `sandbox_profile: "os_hardened"`
700    // without having to rewrite the surrounding policy. The override
701    // is scoped to this call and pops with the guard at end-of-scope.
702    let profile_guard = match optional_string(params, "sandbox_profile") {
703        Some(value) => Some(push_sandbox_profile_override(&value)?),
704        None => None,
705    };
706    let mut cmd = crate::process_sandbox::tokio_command_for(&program, &args)
707        .map_err(|e| VmError::Runtime(format!("host_call process.exec sandbox setup: {e}")))?;
708    if let Some(cwd) = optional_string(params, "cwd") {
709        let cwd = resolve_process_exec_cwd(&cwd);
710        crate::process_sandbox::enforce_process_cwd(&cwd)
711            .map_err(|e| VmError::Runtime(format!("host_call process.exec cwd: {e}")))?;
712        cmd.current_dir(cwd);
713    }
714    if let Some(env) = optional_string_dict(params, "env")? {
715        let env_mode = optional_string(params, "env_mode");
716        if env_mode.as_deref().unwrap_or("replace") == "replace" {
717            cmd.env_clear();
718        }
719        for (key, value) in env {
720            cmd.env(key, value);
721        }
722    }
723    // env_remove: list of environment variable names to strip before
724    // spawning. Applied after `env` so callers can both inherit and
725    // selectively unset (e.g. the git stdlib strips `GIT_*` so its
726    // operations are self-contained even when Harn is invoked from
727    // inside a git hook that sets `GIT_DIR`).
728    if let Some(env_remove) = optional_string_list(params, "env_remove") {
729        for key in env_remove {
730            cmd.env_remove(key);
731        }
732    }
733    cmd.stdin(std::process::Stdio::null())
734        .stdout(std::process::Stdio::piped())
735        .stderr(std::process::Stdio::piped())
736        .kill_on_drop(true);
737    let started_at = audited_utc_now_rfc3339("host_call/process.exec.started_at");
738    let started = crate::clock_mock::leak_audit::instant_now("host_call/process.exec.started");
739    let child = cmd
740        .spawn()
741        .map_err(|e| VmError::Runtime(format!("host_call process.exec: {e}")))?;
742    drop(profile_guard);
743    let pid = child.id();
744    let timed_out;
745    let output_result = if let Some(timeout_ms) = timeout_ms {
746        match tokio::time::timeout(
747            std::time::Duration::from_millis(timeout_ms),
748            child.wait_with_output(),
749        )
750        .await
751        {
752            Ok(result) => {
753                timed_out = false;
754                result
755            }
756            Err(_) => {
757                let response = process_exec_response(ProcessExecResponse {
758                    pid,
759                    started_at,
760                    started,
761                    stdout: "",
762                    stderr: "",
763                    exit_code: -1,
764                    status: "timed_out",
765                    success: false,
766                    timed_out: true,
767                });
768                return crate::orchestration::run_command_policy_postflight_with_ctx(
769                    ctx,
770                    params,
771                    response,
772                    command_policy_context,
773                    command_policy_decisions,
774                )
775                .await;
776            }
777        }
778    } else {
779        timed_out = false;
780        child.wait_with_output().await
781    };
782    let output =
783        output_result.map_err(|e| VmError::Runtime(format!("host_call process.exec: {e}")))?;
784    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
785    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
786    let exit_code = output.status.code().unwrap_or(-1);
787    let response = process_exec_response(ProcessExecResponse {
788        pid,
789        started_at,
790        started,
791        stdout: &stdout,
792        stderr: &stderr,
793        exit_code,
794        status: if timed_out { "timed_out" } else { "completed" },
795        success: output.status.success(),
796        timed_out,
797    });
798    crate::orchestration::run_command_policy_postflight_with_ctx(
799        ctx,
800        params,
801        response,
802        command_policy_context,
803        command_policy_decisions,
804    )
805    .await
806}
807
808struct ProcessExecResponse<'a> {
809    pid: Option<u32>,
810    started_at: String,
811    started: Instant,
812    stdout: &'a str,
813    stderr: &'a str,
814    exit_code: i32,
815    status: &'a str,
816    success: bool,
817    timed_out: bool,
818}
819
820fn process_exec_response(response: ProcessExecResponse<'_>) -> VmValue {
821    let combined = format!("{}{}", response.stdout, response.stderr);
822    let mut result = BTreeMap::new();
823    result.insert(
824        "command_id".to_string(),
825        VmValue::String(std::sync::Arc::from(format!(
826            "cmd_{}_{}",
827            std::process::id(),
828            response.started.elapsed().as_nanos()
829        ))),
830    );
831    result.insert(
832        "status".to_string(),
833        VmValue::String(std::sync::Arc::from(response.status)),
834    );
835    result.insert(
836        "pid".to_string(),
837        response
838            .pid
839            .map(|pid| VmValue::Int(pid as i64))
840            .unwrap_or(VmValue::Nil),
841    );
842    result.insert(
843        "process_group_id".to_string(),
844        response
845            .pid
846            .map(|pid| VmValue::Int(pid as i64))
847            .unwrap_or(VmValue::Nil),
848    );
849    result.insert("handle_id".to_string(), VmValue::Nil);
850    result.insert(
851        "started_at".to_string(),
852        VmValue::String(std::sync::Arc::from(response.started_at)),
853    );
854    result.insert(
855        "ended_at".to_string(),
856        VmValue::String(std::sync::Arc::from(audited_utc_now_rfc3339(
857            "host_call/process.exec.ended_at",
858        ))),
859    );
860    result.insert(
861        "duration_ms".to_string(),
862        VmValue::Int(response.started.elapsed().as_millis() as i64),
863    );
864    result.insert(
865        "exit_code".to_string(),
866        VmValue::Int(response.exit_code as i64),
867    );
868    result.insert("signal".to_string(), VmValue::Nil);
869    result.insert("timed_out".to_string(), VmValue::Bool(response.timed_out));
870    result.insert(
871        "stdout".to_string(),
872        VmValue::String(std::sync::Arc::from(response.stdout.to_string())),
873    );
874    result.insert(
875        "stderr".to_string(),
876        VmValue::String(std::sync::Arc::from(response.stderr.to_string())),
877    );
878    result.insert(
879        "combined".to_string(),
880        VmValue::String(std::sync::Arc::from(combined)),
881    );
882    result.insert(
883        "exit_status".to_string(),
884        VmValue::Int(response.exit_code as i64),
885    );
886    result.insert(
887        "legacy_status".to_string(),
888        VmValue::Int(response.exit_code as i64),
889    );
890    result.insert("success".to_string(), VmValue::Bool(response.success));
891    VmValue::Dict(std::sync::Arc::new(result))
892}
893
894fn resolve_process_exec_cwd(cwd: &str) -> std::path::PathBuf {
895    crate::stdlib::process::resolve_source_relative_path(cwd)
896}
897
898fn process_exec_argv(params: &BTreeMap<String, VmValue>) -> Result<(String, Vec<String>), VmError> {
899    match optional_string(params, "mode")
900        .as_deref()
901        .unwrap_or("shell")
902    {
903        "argv" => {
904            let argv = optional_string_list(params, "argv").ok_or_else(|| {
905                VmError::Runtime("host_call process.exec missing argv".to_string())
906            })?;
907            split_argv(argv)
908        }
909        "shell" => {
910            let command = require_param(params, "command")?;
911            let mut invocation_params = params.clone();
912            invocation_params.insert(
913                "command".to_string(),
914                VmValue::String(std::sync::Arc::from(command)),
915            );
916            let invocation =
917                crate::shells::resolve_invocation_from_vm_params(&invocation_params)
918                    .map_err(|err| VmError::Runtime(format!("host_call process.exec: {err}")))?;
919            Ok((invocation.program, invocation.args))
920        }
921        other => Err(VmError::Runtime(format!(
922            "host_call process.exec unsupported mode {other:?}"
923        ))),
924    }
925}
926
927fn split_argv(mut argv: Vec<String>) -> Result<(String, Vec<String>), VmError> {
928    if argv.is_empty() {
929        return Err(VmError::Runtime(
930            "host_call process.exec argv must not be empty".to_string(),
931        ));
932    }
933    let program = argv.remove(0);
934    if program.is_empty() {
935        return Err(VmError::Runtime(
936            "host_call process.exec argv[0] must not be empty".to_string(),
937        ));
938    }
939    Ok((program, argv))
940}
941
942/// Push a transient policy onto the execution stack with the
943/// requested sandbox profile, returning a guard that pops on drop.
944/// Used by `host_call("process", "exec", ...)` to honor a per-call
945/// `sandbox_profile` override without rewriting the surrounding
946/// orchestration policy.
947fn push_sandbox_profile_override(value: &str) -> Result<SandboxProfileGuard, VmError> {
948    let profile = crate::orchestration::SandboxProfile::parse(value).ok_or_else(|| {
949        VmError::Thrown(VmValue::String(std::sync::Arc::from(format!(
950            "host_call process.exec: unknown sandbox_profile {value:?}; expected one of \"unrestricted\", \"worktree\", \"os_hardened\", \"wasi\""
951        ))))
952    })?;
953    let mut policy = crate::orchestration::current_execution_policy().unwrap_or_default();
954    policy.sandbox_profile = profile;
955    crate::orchestration::push_execution_policy(policy);
956    Ok(SandboxProfileGuard {
957        _private: std::marker::PhantomData,
958    })
959}
960
961struct SandboxProfileGuard {
962    _private: std::marker::PhantomData<*const ()>,
963}
964
965impl Drop for SandboxProfileGuard {
966    fn drop(&mut self) {
967        crate::orchestration::pop_execution_policy();
968    }
969}
970
971fn optional_i64(params: &BTreeMap<String, VmValue>, key: &str) -> Option<i64> {
972    match params.get(key) {
973        Some(VmValue::Int(value)) => Some(*value),
974        Some(VmValue::Float(value)) if value.fract() == 0.0 => Some(*value as i64),
975        _ => None,
976    }
977}
978
979fn optional_string(params: &BTreeMap<String, VmValue>, key: &str) -> Option<String> {
980    params.get(key).and_then(vm_string).map(ToString::to_string)
981}
982
983fn optional_string_list(params: &BTreeMap<String, VmValue>, key: &str) -> Option<Vec<String>> {
984    let VmValue::List(values) = params.get(key)? else {
985        return None;
986    };
987    values
988        .iter()
989        .map(|value| vm_string(value).map(ToString::to_string))
990        .collect()
991}
992
993fn optional_string_dict(
994    params: &BTreeMap<String, VmValue>,
995    key: &str,
996) -> Result<Option<BTreeMap<String, String>>, VmError> {
997    let Some(value) = params.get(key) else {
998        return Ok(None);
999    };
1000    let Some(dict) = value.as_dict() else {
1001        return Err(VmError::Runtime(format!(
1002            "host_call process.exec {key} must be a dict"
1003        )));
1004    };
1005    let mut out = BTreeMap::new();
1006    for (key, value) in dict.iter() {
1007        let Some(value) = vm_string(value) else {
1008            return Err(VmError::Runtime(format!(
1009                "host_call process.exec env value for {key:?} must be a string"
1010            )));
1011        };
1012        out.insert(key.clone(), value.to_string());
1013    }
1014    Ok(Some(out))
1015}
1016
1017fn vm_string(value: &VmValue) -> Option<&str> {
1018    match value {
1019        VmValue::String(value) => Some(value.as_ref()),
1020        _ => None,
1021    }
1022}
1023
1024pub(crate) fn register_host_builtins(vm: &mut Vm) {
1025    for def in MODULE_BUILTINS {
1026        vm.register_builtin_def(def);
1027    }
1028}
1029
1030#[harn_builtin(
1031    sig = "host_mock(capability: string, op: string, response_or_config?: any, params?: dict) -> nil",
1032    category = "host"
1033)]
1034fn host_mock_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1035    let host_mock = parse_host_mock(args)?;
1036    push_host_mock(host_mock);
1037    Ok(VmValue::Nil)
1038}
1039
1040#[harn_builtin(sig = "host_mock_clear() -> nil", category = "host")]
1041fn host_mock_clear_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1042    reset_host_state();
1043    Ok(VmValue::Nil)
1044}
1045
1046#[harn_builtin(sig = "host_mock_calls() -> list", category = "host")]
1047fn host_mock_calls_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1048    let calls = HOST_MOCK_CALLS.with(|calls| {
1049        calls
1050            .borrow()
1051            .iter()
1052            .map(mock_call_value)
1053            .collect::<Vec<_>>()
1054    });
1055    Ok(VmValue::List(std::sync::Arc::new(calls)))
1056}
1057
1058#[harn_builtin(sig = "host_mock_push_scope() -> nil", category = "host")]
1059fn host_mock_push_scope_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1060    push_host_mock_scope();
1061    Ok(VmValue::Nil)
1062}
1063
1064#[harn_builtin(sig = "host_mock_pop_scope() -> nil", category = "host")]
1065fn host_mock_pop_scope_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1066    if !pop_host_mock_scope() {
1067        return Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
1068            "host_mock_pop_scope: no scope to pop",
1069        ))));
1070    }
1071    Ok(VmValue::Nil)
1072}
1073
1074#[harn_builtin(sig = "host_capabilities() -> dict", category = "host")]
1075fn host_capabilities_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1076    Ok(capability_manifest_with_mocks())
1077}
1078
1079#[harn_builtin(
1080    sig = "host_has(capability: string, op?: string) -> bool",
1081    category = "host"
1082)]
1083fn host_has_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1084    let capability = args.first().map(|a| a.display()).unwrap_or_default();
1085    let operation = args.get(1).map(|a| a.display());
1086    let manifest = capability_manifest_with_mocks();
1087    let has = manifest
1088        .as_dict()
1089        .and_then(|d| d.get(&capability))
1090        .and_then(|v| v.as_dict())
1091        .is_some_and(|cap| {
1092            if let Some(operation) = operation {
1093                cap.get("ops")
1094                    .and_then(|v| match v {
1095                        VmValue::List(list) => {
1096                            Some(list.iter().any(|item| item.display() == operation))
1097                        }
1098                        _ => None,
1099                    })
1100                    .unwrap_or(false)
1101            } else {
1102                true
1103            }
1104        });
1105    Ok(VmValue::Bool(has))
1106}
1107
1108#[harn_builtin(
1109    sig = "host_call(name: string, args?: dict) -> any",
1110    kind = "async",
1111    category = "host"
1112)]
1113async fn host_call_builtin(
1114    ctx: crate::vm::AsyncBuiltinCtx,
1115    args: Vec<VmValue>,
1116) -> Result<VmValue, VmError> {
1117    let name = args.first().map(|a| a.display()).unwrap_or_default();
1118    let params = args
1119        .get(1)
1120        .and_then(|a| a.as_dict())
1121        .cloned()
1122        .unwrap_or_default();
1123    let Some((capability, operation)) = name.split_once('.') else {
1124        return Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
1125            format!("host_call: unsupported operation name '{name}'"),
1126        ))));
1127    };
1128    dispatch_host_operation_with_ctx(Some(&ctx), capability, operation, &params).await
1129}
1130
1131#[harn_builtin(sig = "host_tool_list() -> list", kind = "async", category = "host")]
1132async fn host_tool_list_builtin(
1133    ctx: crate::vm::AsyncBuiltinCtx,
1134    _args: Vec<VmValue>,
1135) -> Result<VmValue, VmError> {
1136    dispatch_host_tool_list_with_ctx(Some(&ctx)).await
1137}
1138
1139#[harn_builtin(
1140    sig = "host_tool_call(name: string, args?: any) -> any",
1141    kind = "async",
1142    category = "host"
1143)]
1144async fn host_tool_call_builtin(
1145    ctx: crate::vm::AsyncBuiltinCtx,
1146    args: Vec<VmValue>,
1147) -> Result<VmValue, VmError> {
1148    let name = args.first().map(|a| a.display()).unwrap_or_default();
1149    if name.is_empty() {
1150        return Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
1151            "host_tool_call: tool name is required",
1152        ))));
1153    }
1154    let call_args = args.get(1).cloned().unwrap_or(VmValue::Nil);
1155    dispatch_host_tool_call_with_ctx(Some(&ctx), &name, &call_args).await
1156}
1157
1158#[cfg(test)]
1159mod tests {
1160    use super::{
1161        capability_manifest_with_mocks, clear_host_call_bridge, dispatch_host_operation,
1162        dispatch_host_tool_call, dispatch_host_tool_list, dispatch_mock_host_call, push_host_mock,
1163        reset_host_state, resolve_process_exec_cwd, set_host_call_bridge, HostCallBridge, HostMock,
1164    };
1165    use std::collections::BTreeMap;
1166    use std::sync::{
1167        atomic::{AtomicUsize, Ordering},
1168        Arc,
1169    };
1170
1171    use crate::value::{VmError, VmValue};
1172
1173    #[test]
1174    fn process_exec_relative_cwd_resolves_against_execution_root() {
1175        let dir = tempfile::tempdir().expect("tempdir");
1176        crate::stdlib::process::set_thread_execution_context(Some(
1177            crate::orchestration::RunExecutionRecord {
1178                cwd: Some(dir.path().to_string_lossy().into_owned()),
1179                source_dir: Some(dir.path().join("src").to_string_lossy().into_owned()),
1180                env: BTreeMap::new(),
1181                adapter: None,
1182                repo_path: None,
1183                worktree_path: None,
1184                branch: None,
1185                base_ref: None,
1186                cleanup: None,
1187            },
1188        ));
1189
1190        assert_eq!(
1191            resolve_process_exec_cwd("subdir"),
1192            dir.path().join("subdir")
1193        );
1194
1195        crate::stdlib::process::set_thread_execution_context(None);
1196    }
1197
1198    #[test]
1199    fn manifest_includes_operation_metadata() {
1200        let manifest = capability_manifest_with_mocks();
1201        let process = manifest
1202            .as_dict()
1203            .and_then(|d| d.get("process"))
1204            .and_then(|v| v.as_dict())
1205            .expect("process capability");
1206        assert!(process.get("description").is_some());
1207        let operations = process
1208            .get("operations")
1209            .and_then(|v| v.as_dict())
1210            .expect("operations dict");
1211        assert!(operations.get("exec").is_some());
1212    }
1213
1214    #[test]
1215    fn mocked_capabilities_appear_in_manifest() {
1216        reset_host_state();
1217        push_host_mock(HostMock {
1218            capability: "project".to_string(),
1219            operation: "metadata_get".to_string(),
1220            params: None,
1221            result: Some(VmValue::Dict(std::sync::Arc::new(BTreeMap::new()))),
1222            error: None,
1223        });
1224        let manifest = capability_manifest_with_mocks();
1225        let project = manifest
1226            .as_dict()
1227            .and_then(|d| d.get("project"))
1228            .and_then(|v| v.as_dict())
1229            .expect("project capability");
1230        let operations = project
1231            .get("operations")
1232            .and_then(|v| v.as_dict())
1233            .expect("operations dict");
1234        assert!(operations.get("metadata_get").is_some());
1235        reset_host_state();
1236    }
1237
1238    #[test]
1239    fn mock_host_call_matches_partial_params_and_overrides_order() {
1240        reset_host_state();
1241        let mut exact_params = BTreeMap::new();
1242        exact_params.insert(
1243            "namespace".to_string(),
1244            VmValue::String(std::sync::Arc::from("facts")),
1245        );
1246        push_host_mock(HostMock {
1247            capability: "project".to_string(),
1248            operation: "metadata_get".to_string(),
1249            params: None,
1250            result: Some(VmValue::String(std::sync::Arc::from("fallback"))),
1251            error: None,
1252        });
1253        push_host_mock(HostMock {
1254            capability: "project".to_string(),
1255            operation: "metadata_get".to_string(),
1256            params: Some(exact_params),
1257            result: Some(VmValue::String(std::sync::Arc::from("facts"))),
1258            error: None,
1259        });
1260
1261        let mut call_params = BTreeMap::new();
1262        call_params.insert(
1263            "dir".to_string(),
1264            VmValue::String(std::sync::Arc::from("pkg")),
1265        );
1266        call_params.insert(
1267            "namespace".to_string(),
1268            VmValue::String(std::sync::Arc::from("facts")),
1269        );
1270        let exact = dispatch_mock_host_call("project", "metadata_get", &call_params)
1271            .expect("expected exact mock")
1272            .expect("exact mock should succeed");
1273        assert_eq!(exact.display(), "facts");
1274
1275        call_params.insert(
1276            "namespace".to_string(),
1277            VmValue::String(std::sync::Arc::from("classification")),
1278        );
1279        let fallback = dispatch_mock_host_call("project", "metadata_get", &call_params)
1280            .expect("expected fallback mock")
1281            .expect("fallback mock should succeed");
1282        assert_eq!(fallback.display(), "fallback");
1283        reset_host_state();
1284    }
1285
1286    #[test]
1287    fn mock_host_call_can_throw_errors() {
1288        reset_host_state();
1289        push_host_mock(HostMock {
1290            capability: "project".to_string(),
1291            operation: "metadata_get".to_string(),
1292            params: None,
1293            result: None,
1294            error: Some("boom".to_string()),
1295        });
1296        let params = BTreeMap::new();
1297        let result = dispatch_mock_host_call("project", "metadata_get", &params)
1298            .expect("expected mock result");
1299        match result {
1300            Err(VmError::Thrown(VmValue::String(message))) => assert_eq!(message.as_ref(), "boom"),
1301            other => panic!("unexpected result: {other:?}"),
1302        }
1303        reset_host_state();
1304    }
1305
1306    #[derive(Default)]
1307    struct TestHostToolBridge;
1308
1309    impl HostCallBridge for TestHostToolBridge {
1310        fn dispatch(
1311            &self,
1312            _capability: &str,
1313            _operation: &str,
1314            _params: &BTreeMap<String, VmValue>,
1315        ) -> Result<Option<VmValue>, VmError> {
1316            Ok(None)
1317        }
1318
1319        fn list_tools(&self) -> Result<Option<VmValue>, VmError> {
1320            let tool = VmValue::Dict(std::sync::Arc::new(BTreeMap::from([
1321                (
1322                    "name".to_string(),
1323                    VmValue::String(std::sync::Arc::from("Read".to_string())),
1324                ),
1325                (
1326                    "description".to_string(),
1327                    VmValue::String(std::sync::Arc::from(
1328                        "Read a file from the host".to_string(),
1329                    )),
1330                ),
1331                (
1332                    "schema".to_string(),
1333                    VmValue::Dict(std::sync::Arc::new(BTreeMap::from([(
1334                        "type".to_string(),
1335                        VmValue::String(std::sync::Arc::from("object".to_string())),
1336                    )]))),
1337                ),
1338                ("deprecated".to_string(), VmValue::Bool(false)),
1339            ])));
1340            Ok(Some(VmValue::List(std::sync::Arc::new(vec![tool]))))
1341        }
1342
1343        fn call_tool(&self, name: &str, args: &VmValue) -> Result<Option<VmValue>, VmError> {
1344            if name != "Read" {
1345                return Ok(None);
1346            }
1347            let path = args
1348                .as_dict()
1349                .and_then(|dict| dict.get("path"))
1350                .map(|value| value.display())
1351                .unwrap_or_default();
1352            Ok(Some(VmValue::String(std::sync::Arc::from(format!(
1353                "read:{path}"
1354            )))))
1355        }
1356    }
1357
1358    struct CountingProcessExecBridge {
1359        calls: Arc<AtomicUsize>,
1360    }
1361
1362    impl HostCallBridge for CountingProcessExecBridge {
1363        fn dispatch(
1364            &self,
1365            capability: &str,
1366            operation: &str,
1367            _params: &BTreeMap<String, VmValue>,
1368        ) -> Result<Option<VmValue>, VmError> {
1369            if (capability, operation) != ("process", "exec") {
1370                return Ok(None);
1371            }
1372            self.calls.fetch_add(1, Ordering::SeqCst);
1373            Ok(Some(VmValue::Dict(std::sync::Arc::new(BTreeMap::from([
1374                (
1375                    "status".to_string(),
1376                    VmValue::String(std::sync::Arc::from("completed".to_string())),
1377                ),
1378                ("exit_code".to_string(), VmValue::Int(0)),
1379                ("success".to_string(), VmValue::Bool(true)),
1380            ])))))
1381        }
1382    }
1383
1384    fn run_host_async_test<F, Fut>(test: F)
1385    where
1386        F: FnOnce() -> Fut,
1387        Fut: std::future::Future<Output = ()>,
1388    {
1389        let rt = tokio::runtime::Builder::new_current_thread()
1390            .enable_all()
1391            .build()
1392            .expect("runtime");
1393        rt.block_on(async {
1394            let local = tokio::task::LocalSet::new();
1395            local.run_until(test()).await;
1396        });
1397    }
1398
1399    #[test]
1400    fn host_tool_list_uses_installed_host_call_bridge() {
1401        run_host_async_test(|| async {
1402            reset_host_state();
1403            set_host_call_bridge(Arc::new(TestHostToolBridge));
1404            let tools = dispatch_host_tool_list().await.expect("tool list");
1405            clear_host_call_bridge();
1406
1407            let VmValue::List(items) = tools else {
1408                panic!("expected tool list");
1409            };
1410            assert_eq!(items.len(), 1);
1411            let tool = items[0].as_dict().expect("tool dict");
1412            assert_eq!(tool.get("name").unwrap().display(), "Read");
1413            assert_eq!(tool.get("deprecated").unwrap().display(), "false");
1414        });
1415    }
1416
1417    #[test]
1418    fn host_tool_call_uses_installed_host_call_bridge() {
1419        run_host_async_test(|| async {
1420            set_host_call_bridge(Arc::new(TestHostToolBridge));
1421            let args = VmValue::Dict(std::sync::Arc::new(BTreeMap::from([(
1422                "path".to_string(),
1423                VmValue::String(std::sync::Arc::from("README.md".to_string())),
1424            )])));
1425            let value = dispatch_host_tool_call("Read", &args)
1426                .await
1427                .expect("tool call");
1428            clear_host_call_bridge();
1429            assert_eq!(value.display(), "read:README.md");
1430        });
1431    }
1432
1433    #[test]
1434    fn process_exec_bridge_is_gated_by_command_policy() {
1435        run_host_async_test(|| async {
1436            crate::orchestration::clear_command_policies();
1437            let calls = Arc::new(AtomicUsize::new(0));
1438            set_host_call_bridge(Arc::new(CountingProcessExecBridge {
1439                calls: calls.clone(),
1440            }));
1441            crate::orchestration::push_command_policy(crate::orchestration::CommandPolicy {
1442                tools: vec!["run".to_string()],
1443                workspace_roots: Vec::new(),
1444                default_shell_mode: "shell".to_string(),
1445                deny_patterns: vec!["cat *".to_string()],
1446                require_approval: Default::default(),
1447                pre: None,
1448                post: None,
1449                allow_recursive: false,
1450            });
1451
1452            let result = dispatch_host_operation(
1453                "process",
1454                "exec",
1455                &BTreeMap::from([
1456                    (
1457                        "mode".to_string(),
1458                        VmValue::String(std::sync::Arc::from("shell")),
1459                    ),
1460                    (
1461                        "command".to_string(),
1462                        VmValue::String(std::sync::Arc::from("cat Cargo.toml")),
1463                    ),
1464                ]),
1465            )
1466            .await
1467            .expect("process.exec result");
1468
1469            crate::orchestration::clear_command_policies();
1470            clear_host_call_bridge();
1471
1472            assert_eq!(
1473                calls.load(Ordering::SeqCst),
1474                0,
1475                "blocked command must not reach host bridge"
1476            );
1477            let result = result.as_dict().expect("blocked result dict");
1478            assert_eq!(result.get("status").unwrap().display(), "blocked");
1479            assert!(
1480                result
1481                    .get("reason")
1482                    .map(VmValue::display)
1483                    .unwrap_or_default()
1484                    .contains("cat *"),
1485                "blocked result should name the matched policy pattern"
1486            );
1487        });
1488    }
1489
1490    #[test]
1491    fn host_tool_list_is_empty_without_bridge() {
1492        run_host_async_test(|| async {
1493            clear_host_call_bridge();
1494            let tools = dispatch_host_tool_list().await.expect("tool list");
1495            let VmValue::List(items) = tools else {
1496                panic!("expected tool list");
1497            };
1498            assert!(items.is_empty());
1499        });
1500    }
1501}