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