Skip to main content

harn_vm/stdlib/
host.rs

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