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