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    let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
529    if let Some(bridge) = bridge {
530        if let Some(value) = bridge.dispatch(capability, operation, params)? {
531            return Ok(value);
532        }
533    }
534
535    match (capability, operation) {
536        ("process", "exec") => {
537            let caller = serde_json::json!({
538                "surface": "host_call",
539                "capability": "process",
540                "operation": "exec",
541                "session_id": crate::llm::current_agent_session_id(),
542            });
543            dispatch_process_exec(params, caller).await
544        }
545        ("process", "list_shells") => Ok(crate::shells::list_shells_vm_value()),
546        ("process", "get_default_shell") => Ok(crate::shells::default_shell_vm_value()),
547        ("process", "set_default_shell") => crate::shells::set_default_shell_vm_value(params),
548        ("process", "shell_invocation") => crate::shells::shell_invocation_vm_value(params),
549        ("template", "render") => {
550            let path = require_param(params, "path")?;
551            let bindings = params.get("bindings").and_then(|v| v.as_dict());
552            Ok(VmValue::String(Rc::from(render_template(&path, bindings)?)))
553        }
554        ("interaction", "ask") => {
555            let question = require_param(params, "question")?;
556            use std::io::BufRead;
557            print!("{question}");
558            let _ = std::io::Write::flush(&mut std::io::stdout());
559            let mut input = String::new();
560            if std::io::stdin().lock().read_line(&mut input).is_ok() {
561                Ok(VmValue::String(Rc::from(input.trim_end())))
562            } else {
563                Ok(VmValue::Nil)
564            }
565        }
566        // Standalone-run fallbacks for capabilities normally supplied by
567        // an embedder's JSON-RPC bridge. `runtime.task` lets a debugger or
568        // CLI invocation read the pipeline input from `HARN_TASK` without
569        // the host explicitly wiring a callback for every op.
570        ("runtime", "task") => Ok(VmValue::String(Rc::from(
571            std::env::var("HARN_TASK").unwrap_or_default(),
572        ))),
573        ("runtime", "set_result") => {
574            // No-op when no host is attached; swallow silently so standalone
575            // scripts can still call `set_result` without crashing.
576            Ok(VmValue::Nil)
577        }
578        ("workspace", "project_root") => {
579            // Standalone fallback: prefer HARN_PROJECT_ROOT, then the
580            // current working directory. Pipelines call this very early so
581            // crashing here would block any debug-launched script.
582            let path = std::env::var("HARN_PROJECT_ROOT").unwrap_or_else(|_| {
583                std::env::current_dir()
584                    .map(|p| p.display().to_string())
585                    .unwrap_or_default()
586            });
587            Ok(VmValue::String(Rc::from(path)))
588        }
589        ("workspace", "cwd") => {
590            let path = std::env::current_dir()
591                .map(|p| p.display().to_string())
592                .unwrap_or_default();
593            Ok(VmValue::String(Rc::from(path)))
594        }
595        _ => Err(VmError::Thrown(VmValue::String(Rc::from(format!(
596            "host_call: unsupported operation {capability}.{operation}"
597        ))))),
598    }
599}
600
601pub(crate) async fn dispatch_process_exec(
602    params: &BTreeMap<String, VmValue>,
603    caller: serde_json::Value,
604) -> Result<VmValue, VmError> {
605    let (params, command_policy_context, command_policy_decisions) =
606        match crate::orchestration::run_command_policy_preflight(params, caller).await? {
607            crate::orchestration::CommandPolicyPreflight::Proceed {
608                params,
609                context,
610                decisions,
611            } => (params, context, decisions),
612            crate::orchestration::CommandPolicyPreflight::Blocked {
613                status,
614                message,
615                context,
616                decisions,
617            } => {
618                return Ok(crate::orchestration::blocked_command_response(
619                    params, status, &message, context, decisions,
620                ));
621            }
622        };
623    let (program, args) = process_exec_argv(&params)?;
624    let timeout_ms = optional_i64(&params, "timeout")
625        .or_else(|| optional_i64(&params, "timeout_ms"))
626        .filter(|value| *value > 0)
627        .map(|value| value as u64);
628    let mut cmd = crate::process_sandbox::tokio_command_for(&program, &args)
629        .map_err(|e| VmError::Runtime(format!("host_call process.exec sandbox setup: {e}")))?;
630    if let Some(cwd) = optional_string(&params, "cwd") {
631        crate::process_sandbox::enforce_process_cwd(std::path::Path::new(&cwd))
632            .map_err(|e| VmError::Runtime(format!("host_call process.exec cwd: {e}")))?;
633        cmd.current_dir(cwd);
634    }
635    if let Some(env) = optional_string_dict(&params, "env")? {
636        let env_mode = optional_string(&params, "env_mode");
637        if env_mode.as_deref().unwrap_or("replace") == "replace" {
638            cmd.env_clear();
639        }
640        for (key, value) in env {
641            cmd.env(key, value);
642        }
643    }
644    // env_remove: list of environment variable names to strip before
645    // spawning. Applied after `env` so callers can both inherit and
646    // selectively unset (e.g. the git stdlib strips `GIT_*` so its
647    // operations are self-contained even when Harn is invoked from
648    // inside a git hook that sets `GIT_DIR`).
649    if let Some(env_remove) = optional_string_list(&params, "env_remove") {
650        for key in env_remove {
651            cmd.env_remove(key);
652        }
653    }
654    cmd.stdin(std::process::Stdio::null())
655        .stdout(std::process::Stdio::piped())
656        .stderr(std::process::Stdio::piped())
657        .kill_on_drop(true);
658    let started_at = chrono::Utc::now().to_rfc3339();
659    let started = Instant::now();
660    let child = cmd
661        .spawn()
662        .map_err(|e| VmError::Runtime(format!("host_call process.exec: {e}")))?;
663    let pid = child.id();
664    let timed_out;
665    let output_result = if let Some(timeout_ms) = timeout_ms {
666        match tokio::time::timeout(
667            std::time::Duration::from_millis(timeout_ms),
668            child.wait_with_output(),
669        )
670        .await
671        {
672            Ok(result) => {
673                timed_out = false;
674                result
675            }
676            Err(_) => {
677                let response = process_exec_response(ProcessExecResponse {
678                    pid,
679                    started_at,
680                    started,
681                    stdout: "",
682                    stderr: "",
683                    exit_code: -1,
684                    status: "timed_out",
685                    success: false,
686                    timed_out: true,
687                });
688                return crate::orchestration::run_command_policy_postflight(
689                    &params,
690                    response,
691                    command_policy_context,
692                    command_policy_decisions,
693                )
694                .await;
695            }
696        }
697    } else {
698        timed_out = false;
699        child.wait_with_output().await
700    };
701    let output =
702        output_result.map_err(|e| VmError::Runtime(format!("host_call process.exec: {e}")))?;
703    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
704    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
705    let exit_code = output.status.code().unwrap_or(-1);
706    let response = process_exec_response(ProcessExecResponse {
707        pid,
708        started_at,
709        started,
710        stdout: &stdout,
711        stderr: &stderr,
712        exit_code,
713        status: if timed_out { "timed_out" } else { "completed" },
714        success: output.status.success(),
715        timed_out,
716    });
717    crate::orchestration::run_command_policy_postflight(
718        &params,
719        response,
720        command_policy_context,
721        command_policy_decisions,
722    )
723    .await
724}
725
726struct ProcessExecResponse<'a> {
727    pid: Option<u32>,
728    started_at: String,
729    started: Instant,
730    stdout: &'a str,
731    stderr: &'a str,
732    exit_code: i32,
733    status: &'a str,
734    success: bool,
735    timed_out: bool,
736}
737
738fn process_exec_response(response: ProcessExecResponse<'_>) -> VmValue {
739    let combined = format!("{}{}", response.stdout, response.stderr);
740    let mut result = BTreeMap::new();
741    result.insert(
742        "command_id".to_string(),
743        VmValue::String(Rc::from(format!(
744            "cmd_{}_{}",
745            std::process::id(),
746            response.started.elapsed().as_nanos()
747        ))),
748    );
749    result.insert(
750        "status".to_string(),
751        VmValue::String(Rc::from(response.status)),
752    );
753    result.insert(
754        "pid".to_string(),
755        response
756            .pid
757            .map(|pid| VmValue::Int(pid as i64))
758            .unwrap_or(VmValue::Nil),
759    );
760    result.insert(
761        "process_group_id".to_string(),
762        response
763            .pid
764            .map(|pid| VmValue::Int(pid as i64))
765            .unwrap_or(VmValue::Nil),
766    );
767    result.insert("handle_id".to_string(), VmValue::Nil);
768    result.insert(
769        "started_at".to_string(),
770        VmValue::String(Rc::from(response.started_at)),
771    );
772    result.insert(
773        "ended_at".to_string(),
774        VmValue::String(Rc::from(chrono::Utc::now().to_rfc3339())),
775    );
776    result.insert(
777        "duration_ms".to_string(),
778        VmValue::Int(response.started.elapsed().as_millis() as i64),
779    );
780    result.insert(
781        "exit_code".to_string(),
782        VmValue::Int(response.exit_code as i64),
783    );
784    result.insert("signal".to_string(), VmValue::Nil);
785    result.insert("timed_out".to_string(), VmValue::Bool(response.timed_out));
786    result.insert(
787        "stdout".to_string(),
788        VmValue::String(Rc::from(response.stdout.to_string())),
789    );
790    result.insert(
791        "stderr".to_string(),
792        VmValue::String(Rc::from(response.stderr.to_string())),
793    );
794    result.insert("combined".to_string(), VmValue::String(Rc::from(combined)));
795    result.insert(
796        "exit_status".to_string(),
797        VmValue::Int(response.exit_code as i64),
798    );
799    result.insert(
800        "legacy_status".to_string(),
801        VmValue::Int(response.exit_code as i64),
802    );
803    result.insert("success".to_string(), VmValue::Bool(response.success));
804    VmValue::Dict(Rc::new(result))
805}
806
807fn process_exec_argv(params: &BTreeMap<String, VmValue>) -> Result<(String, Vec<String>), VmError> {
808    match optional_string(params, "mode")
809        .as_deref()
810        .unwrap_or("shell")
811    {
812        "argv" => {
813            let argv = optional_string_list(params, "argv").ok_or_else(|| {
814                VmError::Runtime("host_call process.exec missing argv".to_string())
815            })?;
816            split_argv(argv)
817        }
818        "shell" => {
819            let command = require_param(params, "command")?;
820            let mut invocation_params = params.clone();
821            invocation_params.insert("command".to_string(), VmValue::String(Rc::from(command)));
822            let invocation =
823                crate::shells::resolve_invocation_from_vm_params(&invocation_params)
824                    .map_err(|err| VmError::Runtime(format!("host_call process.exec: {err}")))?;
825            Ok((invocation.program, invocation.args))
826        }
827        other => Err(VmError::Runtime(format!(
828            "host_call process.exec unsupported mode {other:?}"
829        ))),
830    }
831}
832
833fn split_argv(mut argv: Vec<String>) -> Result<(String, Vec<String>), VmError> {
834    if argv.is_empty() {
835        return Err(VmError::Runtime(
836            "host_call process.exec argv must not be empty".to_string(),
837        ));
838    }
839    let program = argv.remove(0);
840    if program.is_empty() {
841        return Err(VmError::Runtime(
842            "host_call process.exec argv[0] must not be empty".to_string(),
843        ));
844    }
845    Ok((program, argv))
846}
847
848fn optional_i64(params: &BTreeMap<String, VmValue>, key: &str) -> Option<i64> {
849    match params.get(key) {
850        Some(VmValue::Int(value)) => Some(*value),
851        Some(VmValue::Float(value)) if value.fract() == 0.0 => Some(*value as i64),
852        _ => None,
853    }
854}
855
856fn optional_string(params: &BTreeMap<String, VmValue>, key: &str) -> Option<String> {
857    params.get(key).and_then(vm_string).map(ToString::to_string)
858}
859
860fn optional_string_list(params: &BTreeMap<String, VmValue>, key: &str) -> Option<Vec<String>> {
861    let VmValue::List(values) = params.get(key)? else {
862        return None;
863    };
864    values
865        .iter()
866        .map(|value| vm_string(value).map(ToString::to_string))
867        .collect()
868}
869
870fn optional_string_dict(
871    params: &BTreeMap<String, VmValue>,
872    key: &str,
873) -> Result<Option<BTreeMap<String, String>>, VmError> {
874    let Some(value) = params.get(key) else {
875        return Ok(None);
876    };
877    let Some(dict) = value.as_dict() else {
878        return Err(VmError::Runtime(format!(
879            "host_call process.exec {key} must be a dict"
880        )));
881    };
882    let mut out = BTreeMap::new();
883    for (key, value) in dict.iter() {
884        let Some(value) = vm_string(value) else {
885            return Err(VmError::Runtime(format!(
886                "host_call process.exec env value for {key:?} must be a string"
887            )));
888        };
889        out.insert(key.clone(), value.to_string());
890    }
891    Ok(Some(out))
892}
893
894fn vm_string(value: &VmValue) -> Option<&str> {
895    match value {
896        VmValue::String(value) => Some(value.as_ref()),
897        _ => None,
898    }
899}
900
901pub(crate) fn register_host_builtins(vm: &mut Vm) {
902    register_builtin_group(vm, HOST_PRIMITIVES);
903}
904
905fn host_mock_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
906    let host_mock = parse_host_mock(args)?;
907    push_host_mock(host_mock);
908    Ok(VmValue::Nil)
909}
910
911fn host_mock_clear_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
912    reset_host_state();
913    Ok(VmValue::Nil)
914}
915
916fn host_mock_calls_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
917    let calls = HOST_MOCK_CALLS.with(|calls| {
918        calls
919            .borrow()
920            .iter()
921            .map(mock_call_value)
922            .collect::<Vec<_>>()
923    });
924    Ok(VmValue::List(Rc::new(calls)))
925}
926
927fn host_mock_push_scope_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
928    push_host_mock_scope();
929    Ok(VmValue::Nil)
930}
931
932fn host_mock_pop_scope_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
933    if !pop_host_mock_scope() {
934        return Err(VmError::Thrown(VmValue::String(Rc::from(
935            "host_mock_pop_scope: no scope to pop",
936        ))));
937    }
938    Ok(VmValue::Nil)
939}
940
941fn host_capabilities_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
942    Ok(capability_manifest_with_mocks())
943}
944
945fn host_has_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
946    let capability = args.first().map(|a| a.display()).unwrap_or_default();
947    let operation = args.get(1).map(|a| a.display());
948    let manifest = capability_manifest_with_mocks();
949    let has = manifest
950        .as_dict()
951        .and_then(|d| d.get(&capability))
952        .and_then(|v| v.as_dict())
953        .is_some_and(|cap| {
954            if let Some(operation) = operation {
955                cap.get("ops")
956                    .and_then(|v| match v {
957                        VmValue::List(list) => {
958                            Some(list.iter().any(|item| item.display() == operation))
959                        }
960                        _ => None,
961                    })
962                    .unwrap_or(false)
963            } else {
964                true
965            }
966        });
967    Ok(VmValue::Bool(has))
968}
969
970async fn host_call_builtin(args: Vec<VmValue>) -> Result<VmValue, VmError> {
971    let name = args.first().map(|a| a.display()).unwrap_or_default();
972    let params = args
973        .get(1)
974        .and_then(|a| a.as_dict())
975        .cloned()
976        .unwrap_or_default();
977    let Some((capability, operation)) = name.split_once('.') else {
978        return Err(VmError::Thrown(VmValue::String(Rc::from(format!(
979            "host_call: unsupported operation name '{name}'"
980        )))));
981    };
982    dispatch_host_operation(capability, operation, &params).await
983}
984
985async fn host_tool_list_builtin(_args: Vec<VmValue>) -> Result<VmValue, VmError> {
986    dispatch_host_tool_list().await
987}
988
989async fn host_tool_call_builtin(args: Vec<VmValue>) -> Result<VmValue, VmError> {
990    let name = args.first().map(|a| a.display()).unwrap_or_default();
991    if name.is_empty() {
992        return Err(VmError::Thrown(VmValue::String(Rc::from(
993            "host_tool_call: tool name is required",
994        ))));
995    }
996    let call_args = args.get(1).cloned().unwrap_or(VmValue::Nil);
997    dispatch_host_tool_call(&name, &call_args).await
998}
999
1000#[cfg(test)]
1001mod tests {
1002    use super::{
1003        capability_manifest_with_mocks, clear_host_call_bridge, dispatch_host_tool_call,
1004        dispatch_host_tool_list, dispatch_mock_host_call, push_host_mock, reset_host_state,
1005        set_host_call_bridge, HostCallBridge, HostMock,
1006    };
1007    use std::collections::BTreeMap;
1008    use std::rc::Rc;
1009
1010    use crate::value::{VmError, VmValue};
1011
1012    #[test]
1013    fn manifest_includes_operation_metadata() {
1014        let manifest = capability_manifest_with_mocks();
1015        let process = manifest
1016            .as_dict()
1017            .and_then(|d| d.get("process"))
1018            .and_then(|v| v.as_dict())
1019            .expect("process capability");
1020        assert!(process.get("description").is_some());
1021        let operations = process
1022            .get("operations")
1023            .and_then(|v| v.as_dict())
1024            .expect("operations dict");
1025        assert!(operations.get("exec").is_some());
1026    }
1027
1028    #[test]
1029    fn mocked_capabilities_appear_in_manifest() {
1030        reset_host_state();
1031        push_host_mock(HostMock {
1032            capability: "project".to_string(),
1033            operation: "metadata_get".to_string(),
1034            params: None,
1035            result: Some(VmValue::Dict(Rc::new(BTreeMap::new()))),
1036            error: None,
1037        });
1038        let manifest = capability_manifest_with_mocks();
1039        let project = manifest
1040            .as_dict()
1041            .and_then(|d| d.get("project"))
1042            .and_then(|v| v.as_dict())
1043            .expect("project capability");
1044        let operations = project
1045            .get("operations")
1046            .and_then(|v| v.as_dict())
1047            .expect("operations dict");
1048        assert!(operations.get("metadata_get").is_some());
1049        reset_host_state();
1050    }
1051
1052    #[test]
1053    fn mock_host_call_matches_partial_params_and_overrides_order() {
1054        reset_host_state();
1055        let mut exact_params = BTreeMap::new();
1056        exact_params.insert("namespace".to_string(), VmValue::String(Rc::from("facts")));
1057        push_host_mock(HostMock {
1058            capability: "project".to_string(),
1059            operation: "metadata_get".to_string(),
1060            params: None,
1061            result: Some(VmValue::String(Rc::from("fallback"))),
1062            error: None,
1063        });
1064        push_host_mock(HostMock {
1065            capability: "project".to_string(),
1066            operation: "metadata_get".to_string(),
1067            params: Some(exact_params),
1068            result: Some(VmValue::String(Rc::from("facts"))),
1069            error: None,
1070        });
1071
1072        let mut call_params = BTreeMap::new();
1073        call_params.insert("dir".to_string(), VmValue::String(Rc::from("pkg")));
1074        call_params.insert("namespace".to_string(), VmValue::String(Rc::from("facts")));
1075        let exact = dispatch_mock_host_call("project", "metadata_get", &call_params)
1076            .expect("expected exact mock")
1077            .expect("exact mock should succeed");
1078        assert_eq!(exact.display(), "facts");
1079
1080        call_params.insert(
1081            "namespace".to_string(),
1082            VmValue::String(Rc::from("classification")),
1083        );
1084        let fallback = dispatch_mock_host_call("project", "metadata_get", &call_params)
1085            .expect("expected fallback mock")
1086            .expect("fallback mock should succeed");
1087        assert_eq!(fallback.display(), "fallback");
1088        reset_host_state();
1089    }
1090
1091    #[test]
1092    fn mock_host_call_can_throw_errors() {
1093        reset_host_state();
1094        push_host_mock(HostMock {
1095            capability: "project".to_string(),
1096            operation: "metadata_get".to_string(),
1097            params: None,
1098            result: None,
1099            error: Some("boom".to_string()),
1100        });
1101        let params = BTreeMap::new();
1102        let result = dispatch_mock_host_call("project", "metadata_get", &params)
1103            .expect("expected mock result");
1104        match result {
1105            Err(VmError::Thrown(VmValue::String(message))) => assert_eq!(message.as_ref(), "boom"),
1106            other => panic!("unexpected result: {other:?}"),
1107        }
1108        reset_host_state();
1109    }
1110
1111    #[derive(Default)]
1112    struct TestHostToolBridge;
1113
1114    impl HostCallBridge for TestHostToolBridge {
1115        fn dispatch(
1116            &self,
1117            _capability: &str,
1118            _operation: &str,
1119            _params: &BTreeMap<String, VmValue>,
1120        ) -> Result<Option<VmValue>, VmError> {
1121            Ok(None)
1122        }
1123
1124        fn list_tools(&self) -> Result<Option<VmValue>, VmError> {
1125            let tool = VmValue::Dict(Rc::new(BTreeMap::from([
1126                (
1127                    "name".to_string(),
1128                    VmValue::String(Rc::from("Read".to_string())),
1129                ),
1130                (
1131                    "description".to_string(),
1132                    VmValue::String(Rc::from("Read a file from the host".to_string())),
1133                ),
1134                (
1135                    "schema".to_string(),
1136                    VmValue::Dict(Rc::new(BTreeMap::from([(
1137                        "type".to_string(),
1138                        VmValue::String(Rc::from("object".to_string())),
1139                    )]))),
1140                ),
1141                ("deprecated".to_string(), VmValue::Bool(false)),
1142            ])));
1143            Ok(Some(VmValue::List(Rc::new(vec![tool]))))
1144        }
1145
1146        fn call_tool(&self, name: &str, args: &VmValue) -> Result<Option<VmValue>, VmError> {
1147            if name != "Read" {
1148                return Ok(None);
1149            }
1150            let path = args
1151                .as_dict()
1152                .and_then(|dict| dict.get("path"))
1153                .map(|value| value.display())
1154                .unwrap_or_default();
1155            Ok(Some(VmValue::String(Rc::from(format!("read:{path}")))))
1156        }
1157    }
1158
1159    fn run_host_async_test<F, Fut>(test: F)
1160    where
1161        F: FnOnce() -> Fut,
1162        Fut: std::future::Future<Output = ()>,
1163    {
1164        let rt = tokio::runtime::Builder::new_current_thread()
1165            .enable_all()
1166            .build()
1167            .expect("runtime");
1168        rt.block_on(async {
1169            let local = tokio::task::LocalSet::new();
1170            local.run_until(test()).await;
1171        });
1172    }
1173
1174    #[test]
1175    fn host_tool_list_uses_installed_host_call_bridge() {
1176        run_host_async_test(|| async {
1177            reset_host_state();
1178            set_host_call_bridge(Rc::new(TestHostToolBridge));
1179            let tools = dispatch_host_tool_list().await.expect("tool list");
1180            clear_host_call_bridge();
1181
1182            let VmValue::List(items) = tools else {
1183                panic!("expected tool list");
1184            };
1185            assert_eq!(items.len(), 1);
1186            let tool = items[0].as_dict().expect("tool dict");
1187            assert_eq!(tool.get("name").unwrap().display(), "Read");
1188            assert_eq!(tool.get("deprecated").unwrap().display(), "false");
1189        });
1190    }
1191
1192    #[test]
1193    fn host_tool_call_uses_installed_host_call_bridge() {
1194        run_host_async_test(|| async {
1195            set_host_call_bridge(Rc::new(TestHostToolBridge));
1196            let args = VmValue::Dict(Rc::new(BTreeMap::from([(
1197                "path".to_string(),
1198                VmValue::String(Rc::from("README.md".to_string())),
1199            )])));
1200            let value = dispatch_host_tool_call("Read", &args)
1201                .await
1202                .expect("tool call");
1203            clear_host_call_bridge();
1204            assert_eq!(value.display(), "read:README.md");
1205        });
1206    }
1207
1208    #[test]
1209    fn host_tool_list_is_empty_without_bridge() {
1210        run_host_async_test(|| async {
1211            clear_host_call_bridge();
1212            let tools = dispatch_host_tool_list().await.expect("tool list");
1213            let VmValue::List(items) = tools else {
1214                panic!("expected tool list");
1215            };
1216            assert!(items.is_empty());
1217        });
1218    }
1219}