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