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