Skip to main content

harn_vm/stdlib/
host.rs

1use crate::value::VmDictExt;
2use std::cell::RefCell;
3use std::collections::BTreeMap;
4use std::sync::Arc;
5
6use serde_json::Value as JsonValue;
7
8use crate::stdlib::macros::{harn_builtin, VmBuiltinDef};
9use crate::value::{values_equal, VmError, VmValue};
10use crate::vm::{AsyncBuiltinCtx, Vm};
11
12mod bridge;
13pub(crate) mod fixtured_operations;
14mod operation_registry;
15pub mod process_admission;
16pub(crate) mod process_dispatch;
17mod process_exec;
18// Public so tests and embedders can share the per-turn memo allowlist even
19// when probing host_call outside the stdlib builtin (harn#5190).
20pub mod turn_cache;
21
22use bridge::HOST_CALL_BRIDGE;
23pub use bridge::{
24    clear_host_call_bridge, dispatch_host_call_bridge, host_call_ready, install_host_call_bridge,
25    set_host_call_bridge, HostCallBridge, HostCallBridgeGuard, HostCallDispatchFuture,
26};
27
28use process_dispatch::dispatch_process_exec_with_policy;
29pub(crate) use process_dispatch::{dispatch_process_exec, dispatch_reviewed_git_push_with_lease};
30pub(crate) use process_exec::build_sandboxed_command;
31use process_exec::dispatch_process_spawn_with_policy;
32
33/// Audited wrapper for `chrono::Utc::now().to_rfc3339()`. Routes through
34/// the testbench leak audit so a paused-clock session can surface every
35/// host capability that observed real wall-clock time.
36pub(crate) fn audited_utc_now_rfc3339(capability_id: &'static str) -> String {
37    let dt: chrono::DateTime<chrono::Utc> =
38        crate::clock_mock::leak_audit::wall_now(capability_id).into();
39    dt.to_rfc3339()
40}
41
42pub(crate) const MODULE_BUILTINS: &[&VmBuiltinDef] = &[
43    &HOST_MOCK_BUILTIN_DEF,
44    &HOST_MOCK_CLEAR_BUILTIN_DEF,
45    &HOST_MOCK_CALLS_BUILTIN_DEF,
46    &HOST_MOCK_PUSH_SCOPE_BUILTIN_DEF,
47    &HOST_MOCK_POP_SCOPE_BUILTIN_DEF,
48    &HOST_CAPABILITIES_BUILTIN_DEF,
49    &HOST_HAS_BUILTIN_DEF,
50    &HOST_CALL_BUILTIN_DEF,
51    &HOST_TOOL_LIST_BUILTIN_DEF,
52    &HOST_TOOL_CALL_BUILTIN_DEF,
53];
54
55#[derive(Clone)]
56struct HostMock {
57    capability: String,
58    operation: String,
59    params: Option<crate::value::DictMap>,
60    result: Option<VmValue>,
61    error: Option<String>,
62    unregistered_ok: bool,
63}
64
65#[derive(Clone)]
66struct HostMockCall {
67    capability: String,
68    operation: String,
69    params: crate::value::DictMap,
70}
71
72thread_local! {
73    static HOST_MOCKS: RefCell<Vec<HostMock>> = const { RefCell::new(Vec::new()) };
74    static HOST_MOCK_CALLS: RefCell<Vec<HostMockCall>> = const { RefCell::new(Vec::new()) };
75    static HOST_MOCK_SCOPES: RefCell<Vec<(Vec<HostMock>, Vec<HostMockCall>)>> =
76        const { RefCell::new(Vec::new()) };
77}
78
79pub(crate) fn reset_host_state() {
80    HOST_MOCKS.with(|mocks| mocks.borrow_mut().clear());
81    HOST_MOCK_CALLS.with(|calls| calls.borrow_mut().clear());
82    HOST_MOCK_SCOPES.with(|scopes| scopes.borrow_mut().clear());
83    // Thread-local clear only: this hook runs per-test across the whole crate,
84    // so it must not bump the process-global turn epoch. See
85    // [`turn_cache::reset_local`].
86    turn_cache::reset_local();
87}
88
89pub(crate) fn reset_scoped_host_state() {
90    operation_registry::clear_scoped_mockable();
91}
92
93/// Push the current host-mock state onto an internal stack and start a
94/// fresh empty scope. Paired with `pop_host_mock_scope` for privileged
95/// runtime tests that still exercise the wire layer directly. Script tests use
96/// per-Harness capability fixtures instead.
97fn push_host_mock_scope() {
98    let mocks = HOST_MOCKS.with(|v| std::mem::take(&mut *v.borrow_mut()));
99    let calls = HOST_MOCK_CALLS.with(|v| std::mem::take(&mut *v.borrow_mut()));
100    HOST_MOCK_SCOPES.with(|v| v.borrow_mut().push((mocks, calls)));
101}
102
103/// Restore the most recently pushed host-mock state, replacing any
104/// mocks or recorded calls accumulated inside the scope. Returns
105/// `false` if there is no saved scope to pop, so callers can surface a
106/// clear "imbalanced scope" error rather than silently no-op'ing.
107fn pop_host_mock_scope() -> bool {
108    let entry = HOST_MOCK_SCOPES.with(|v| v.borrow_mut().pop());
109    match entry {
110        Some((mocks, calls)) => {
111            HOST_MOCKS.with(|v| *v.borrow_mut() = mocks);
112            HOST_MOCK_CALLS.with(|v| *v.borrow_mut() = calls);
113            true
114        }
115        None => false,
116    }
117}
118
119fn capability_manifest_map() -> crate::value::DictMap {
120    let mut root = crate::value::DictMap::new();
121    root.insert(
122        crate::value::intern_key("process"),
123        capability(
124            "Process execution.",
125            &[
126                op("exec", "Execute a process in argv or shell mode."),
127                op(
128                    "spawn",
129                    "Spawn a process non-blocking; returns a handle immediately for poll/wait/kill.",
130                ),
131                op(
132                    "poll",
133                    "Non-blocking snapshot of a spawned process: status, captured stdout/stderr.",
134                ),
135                op(
136                    "wait",
137                    "Await a spawned process to completion (optional timeout_ms); returns final result.",
138                ),
139                op(
140                    "kill",
141                    "Terminate a spawned process by handle and await the status transition.",
142                ),
143                op(
144                    "release",
145                    "Release a spawned-process handle and free its retained output.",
146                ),
147                op("list_shells", "List shells discovered by the host/session."),
148                op(
149                    "get_default_shell",
150                    "Return the selected default shell for this host/session.",
151                ),
152                op(
153                    "set_default_shell",
154                    "Select the default shell for this host/session.",
155                ),
156                op(
157                    "shell_invocation",
158                    "Resolve shell selection and login/interactive flags into argv.",
159                ),
160            ],
161        ),
162    );
163    root.insert(
164        crate::value::intern_key("template"),
165        capability(
166            "Template rendering.",
167            &[op("render", "Render a template file.")],
168        ),
169    );
170    root.insert(
171        crate::value::intern_key("interaction"),
172        capability(
173            "User interaction.",
174            &[op("ask", "Ask the user a question.")],
175        ),
176    );
177    root.insert(
178        crate::value::intern_key("memory"),
179        capability(
180            "Vector-aware memory: host-provided embeddings.",
181            &[op(
182                "embed",
183                "Embed text for semantic recall. Params: {text, model_hint?}. \
184                 Returns {vector: list<float>, model: string, dim: int}.",
185            )],
186        ),
187    );
188    root.insert(
189        crate::value::intern_key("project"),
190        capability(
191            "Project metadata and durable project facts.",
192            &[
193                op("metadata_get", "Read project metadata."),
194                op("metadata_inspect", "Inspect project metadata provenance."),
195                op("metadata_set", "Write project metadata."),
196                op("metadata_save", "Persist pending project metadata changes."),
197                op("metadata_stale", "Check whether project metadata is stale."),
198                op(
199                    "metadata_refresh_hashes",
200                    "Refresh project metadata content hashes.",
201                ),
202            ],
203        ),
204    );
205    root.insert(
206        crate::value::intern_key("runtime"),
207        capability(
208            "Runtime task context and run metadata supplied by the active host.",
209            &[
210                op("task", "Read the current runtime task."),
211                op("pipeline_input", "Read the active pipeline input payload."),
212                op("prompt_content", "Read the active session prompt content."),
213                op("dry_run", "Read whether the runtime is in dry-run mode."),
214                op("approved_plan", "Read the approved plan text."),
215                op("record_run", "Record run metadata with the host."),
216                op("set_result", "Write the runtime result payload."),
217            ],
218        ),
219    );
220    root.insert(
221        crate::value::intern_key("workspace"),
222        capability(
223            "Workspace facts and file access supplied by the active host.",
224            &[
225                op("project_root", "Return the active project root."),
226                op("cwd", "Return the active current working directory."),
227                op("read_text", "Read a workspace text file."),
228                op("list", "List workspace files or directories."),
229                op("exists", "Check whether a workspace path exists."),
230            ],
231        ),
232    );
233    root.insert(
234        crate::value::intern_key("oauth_storage"),
235        capability(
236            "Host-managed OAuth token storage.",
237            &[
238                op("cloud_get", "Read a cloud-managed token set."),
239                op("cloud_set", "Write a cloud-managed token set."),
240                op("cloud_delete", "Delete a cloud-managed token set."),
241                op(
242                    "cloud_acquire_refresh_lock",
243                    "Acquire an OAuth refresh lock.",
244                ),
245                op(
246                    "cloud_release_refresh_lock",
247                    "Release an OAuth refresh lock.",
248                ),
249            ],
250        ),
251    );
252    root.insert(
253        crate::value::intern_key("mcp"),
254        capability(
255            "MCP host interactions.",
256            &[op("elicit", "Ask the connected MCP client for input.")],
257        ),
258    );
259    root.insert(
260        crate::value::intern_key("hitl"),
261        capability(
262            "Human-in-the-loop host interactions.",
263            &[
264                op(
265                    "question",
266                    "Ask a human a question through the active host.",
267                ),
268                op(
269                    "approval",
270                    "Request a human approval through the active host.",
271                ),
272                op(
273                    "dual_control",
274                    "Request quorum approval from multiple human reviewers.",
275                ),
276                op(
277                    "escalation",
278                    "Escalate a task to a human role through the active host.",
279                ),
280            ],
281        ),
282    );
283    root
284}
285
286fn mocked_operation_entry() -> VmValue {
287    op(
288        "mocked",
289        "Mocked host operation registered at runtime for tests.",
290    )
291    .1
292}
293
294fn ensure_mocked_capability(
295    root: &mut crate::value::DictMap,
296    capability_name: &str,
297    operation_name: &str,
298) {
299    let Some(existing) = root.get(capability_name).cloned() else {
300        root.insert(
301            crate::value::intern_key(capability_name),
302            capability(
303                "Mocked host capability registered at runtime for tests.",
304                &[(operation_name.to_string(), mocked_operation_entry())],
305            ),
306        );
307        return;
308    };
309
310    let Some(existing_dict) = existing.as_dict() else {
311        return;
312    };
313    let mut entry = (*existing_dict).clone();
314    let mut ops = entry
315        .get("ops")
316        .and_then(|value| match value {
317            VmValue::List(list) => Some((**list).clone()),
318            _ => None,
319        })
320        .unwrap_or_default();
321    if !ops.iter().any(|value| value.display() == operation_name) {
322        ops.push(VmValue::String(arcstr::ArcStr::from(
323            operation_name.to_string(),
324        )));
325    }
326
327    let mut operations = entry
328        .get("operations")
329        .and_then(|value| value.as_dict())
330        .map(|dict| (*dict).clone())
331        .unwrap_or_default();
332    operations
333        .entry(crate::value::intern_key(operation_name))
334        .or_insert_with(mocked_operation_entry);
335
336    entry.insert(
337        crate::value::intern_key("ops"),
338        VmValue::List(std::sync::Arc::new(ops)),
339    );
340    entry.insert(
341        crate::value::intern_key("operations"),
342        VmValue::dict(operations),
343    );
344    root.insert(
345        crate::value::intern_key(capability_name),
346        VmValue::dict(entry),
347    );
348}
349
350fn ensure_registered_operation(
351    root: &mut crate::value::DictMap,
352    capability_name: &str,
353    operation_name: &str,
354    description: &str,
355) {
356    let operation = op(operation_name, description);
357    let Some(existing) = root.get(capability_name).cloned() else {
358        root.insert(
359            crate::value::intern_key(capability_name),
360            capability(description, &[operation]),
361        );
362        return;
363    };
364
365    let Some(existing_dict) = existing.as_dict() else {
366        return;
367    };
368    let mut entry = (*existing_dict).clone();
369    let mut ops = entry
370        .get("ops")
371        .and_then(|value| match value {
372            VmValue::List(list) => Some((**list).clone()),
373            _ => None,
374        })
375        .unwrap_or_default();
376    if !ops.iter().any(|value| value.display() == operation_name) {
377        ops.push(VmValue::String(arcstr::ArcStr::from(
378            operation_name.to_string(),
379        )));
380    }
381
382    let mut operations = entry
383        .get("operations")
384        .and_then(|value| value.as_dict())
385        .map(|dict| (*dict).clone())
386        .unwrap_or_default();
387    operations
388        .entry(crate::value::intern_key(operation_name))
389        .or_insert(operation.1);
390
391    entry.insert(
392        crate::value::intern_key("ops"),
393        VmValue::List(std::sync::Arc::new(ops)),
394    );
395    entry.insert(
396        crate::value::intern_key("operations"),
397        VmValue::dict(operations),
398    );
399    root.insert(
400        crate::value::intern_key(capability_name),
401        VmValue::dict(entry),
402    );
403}
404
405pub fn register_mockable_host_operation(
406    capability_name: impl AsRef<str>,
407    operation_name: impl AsRef<str>,
408    description: impl AsRef<str>,
409) {
410    operation_registry::register_mockable(capability_name, operation_name, description);
411}
412
413/// Register a mock-validation declaration scoped to the current test thread.
414pub fn register_scoped_mockable_host_operation(
415    capability_name: impl AsRef<str>,
416    operation_name: impl AsRef<str>,
417    description: impl AsRef<str>,
418) {
419    operation_registry::register_scoped_mockable(capability_name, operation_name, description);
420}
421
422pub fn register_callable_host_operation(
423    capability_name: impl AsRef<str>,
424    operation_name: impl AsRef<str>,
425    description: impl AsRef<str>,
426) {
427    operation_registry::register_callable(capability_name, operation_name, description);
428}
429
430fn apply_registered_operations(root: &mut crate::value::DictMap) {
431    operation_registry::apply_callable(root);
432}
433
434fn capability_manifest_with_mocks() -> VmValue {
435    let mut root = capability_manifest_map();
436    apply_registered_operations(&mut root);
437    HOST_MOCKS.with(|mocks| {
438        for host_mock in mocks.borrow().iter() {
439            ensure_mocked_capability(&mut root, &host_mock.capability, &host_mock.operation);
440        }
441    });
442    fixtured_operations::apply_to_manifest(&mut root, ensure_mocked_capability);
443    VmValue::dict(root)
444}
445
446fn known_host_operations() -> Vec<(String, String)> {
447    let mut root = capability_manifest_map();
448    apply_registered_operations(&mut root);
449    operation_registry::apply_mockable(&mut root);
450    root.into_iter()
451        .flat_map(|(capability_name, capability)| {
452            let capability_name = capability_name.to_string();
453            capability
454                .as_dict()
455                .and_then(|dict| dict.get("ops"))
456                .and_then(|value| match value {
457                    VmValue::List(list) => Some((**list).clone()),
458                    _ => None,
459                })
460                .unwrap_or_default()
461                .into_iter()
462                .map(move |operation| (capability_name.clone(), operation.display()))
463        })
464        .collect()
465}
466
467pub(crate) fn host_operation_is_registered(capability: &str, operation: &str) -> bool {
468    known_host_operations()
469        .iter()
470        .any(|(known_capability, known_operation)| {
471            known_capability == capability && known_operation == operation
472        })
473}
474
475fn closest_host_operation(capability: &str, operation: &str) -> Option<(String, String)> {
476    let requested = format!("{capability}.{operation}");
477    known_host_operations()
478        .into_iter()
479        .map(|(candidate_capability, candidate_operation)| {
480            let candidate = format!("{candidate_capability}.{candidate_operation}");
481            let distance = strsim::levenshtein(&requested, &candidate);
482            (distance, candidate_capability, candidate_operation)
483        })
484        .filter(|(distance, _, _)| *distance <= 4)
485        .min_by_key(|(distance, _, _)| *distance)
486        .map(|(_, candidate_capability, candidate_operation)| {
487            (candidate_capability, candidate_operation)
488        })
489}
490
491fn validate_host_mock_registration(host_mock: &HostMock) -> Result<(), VmError> {
492    if host_mock.unregistered_ok
493        || host_operation_is_registered(&host_mock.capability, &host_mock.operation)
494    {
495        return Ok(());
496    }
497
498    let mut message = format!(
499        "host_mock: unregistered host operation {}.{}; register the capability/operation on \
500         the host or pass {{unregistered_ok: true}} for a test-local mock",
501        host_mock.capability, host_mock.operation
502    );
503    if let Some((capability, operation)) =
504        closest_host_operation(&host_mock.capability, &host_mock.operation)
505    {
506        message.push_str(&format!(". Did you mean {capability}.{operation}?"));
507    }
508    Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
509        message,
510    ))))
511}
512
513fn op(name: &str, description: &str) -> (String, VmValue) {
514    let mut entry = crate::value::DictMap::new();
515    entry.put_str("description", description);
516    (name.to_string(), VmValue::dict(entry))
517}
518
519fn capability(description: &str, ops: &[(String, VmValue)]) -> VmValue {
520    let mut entry = crate::value::DictMap::new();
521    entry.put_str("description", description);
522    entry.insert(
523        crate::value::intern_key("ops"),
524        VmValue::List(std::sync::Arc::new(
525            ops.iter()
526                .map(|(name, _)| VmValue::String(arcstr::ArcStr::from(name.as_str())))
527                .collect(),
528        )),
529    );
530    let mut op_dict = crate::value::DictMap::new();
531    for (name, op) in ops {
532        op_dict.insert(crate::value::intern_key(name), op.clone());
533    }
534    entry.insert(
535        crate::value::intern_key("operations"),
536        VmValue::dict(op_dict),
537    );
538    VmValue::dict(entry)
539}
540
541pub(crate) fn require_param(params: &crate::value::DictMap, key: &str) -> Result<String, VmError> {
542    params
543        .get(key)
544        .map(|v| v.display())
545        .filter(|v| !v.is_empty())
546        .ok_or_else(|| {
547            VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
548                "host_call: missing required parameter '{key}'"
549            ))))
550        })
551}
552
553fn render_template(
554    path: &str,
555    bindings: Option<&crate::value::DictMap>,
556) -> Result<String, VmError> {
557    let asset = crate::stdlib::template::TemplateAsset::render_target(path).map_err(|msg| {
558        VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
559            "host_call template.render: {msg}"
560        ))))
561    })?;
562    crate::stdlib::template::render_asset_result(&asset, bindings).map_err(VmError::from)
563}
564
565fn params_match(expected: Option<&crate::value::DictMap>, actual: &crate::value::DictMap) -> bool {
566    let Some(expected) = expected else {
567        return true;
568    };
569    expected.iter().all(|(key, value)| {
570        actual
571            .get(key)
572            .is_some_and(|candidate| values_equal(candidate, value))
573    })
574}
575
576fn parse_host_mock(args: &[VmValue]) -> Result<HostMock, VmError> {
577    let capability = args
578        .first()
579        .map(|value| value.display())
580        .unwrap_or_default();
581    let operation = args.get(1).map(|value| value.display()).unwrap_or_default();
582    if capability.is_empty() || operation.is_empty() {
583        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
584            "host_mock: capability and operation are required",
585        ))));
586    }
587
588    let mut params = args
589        .get(3)
590        .and_then(|value| value.as_dict())
591        .map(|dict| (*dict).clone());
592    let mut result = args.get(2).cloned().or(Some(VmValue::Nil));
593    let mut error = None;
594    let mut unregistered_ok = false;
595
596    if let Some(config) = args.get(2).and_then(|value| value.as_dict()) {
597        if config.contains_key("result")
598            || config.contains_key("params")
599            || config.contains_key("error")
600            || config.contains_key("unregistered_ok")
601        {
602            params = config
603                .get("params")
604                .and_then(|value| value.as_dict())
605                .map(|dict| (*dict).clone());
606            result = config.get("result").cloned();
607            error = config
608                .get("error")
609                .map(|value| value.display())
610                .filter(|value| !value.is_empty());
611            unregistered_ok = matches!(config.get("unregistered_ok"), Some(VmValue::Bool(true)));
612        }
613    }
614
615    Ok(HostMock {
616        capability,
617        operation,
618        params,
619        result,
620        error,
621        unregistered_ok,
622    })
623}
624
625fn push_host_mock(host_mock: HostMock) {
626    HOST_MOCKS.with(|mocks| mocks.borrow_mut().push(host_mock));
627}
628
629fn mock_call_value(call: &HostMockCall) -> VmValue {
630    let mut item = crate::value::DictMap::new();
631    item.put_str("capability", call.capability.clone());
632    item.put_str("operation", call.operation.clone());
633    item.insert(
634        crate::value::intern_key("params"),
635        VmValue::dict(call.params.clone()),
636    );
637    VmValue::dict(item)
638}
639
640fn record_mock_call(capability: &str, operation: &str, params: &crate::value::DictMap) {
641    HOST_MOCK_CALLS.with(|calls| {
642        calls.borrow_mut().push(HostMockCall {
643            capability: capability.to_string(),
644            operation: operation.to_string(),
645            params: params.clone(),
646        });
647    });
648}
649
650pub(crate) fn dispatch_mock_host_call(
651    capability: &str,
652    operation: &str,
653    params: &crate::value::DictMap,
654) -> Option<Result<VmValue, VmError>> {
655    let matched = HOST_MOCKS.with(|mocks| {
656        mocks
657            .borrow()
658            .iter()
659            .rev()
660            .find(|host_mock| {
661                host_mock.capability == capability
662                    && host_mock.operation == operation
663                    && params_match(host_mock.params.as_ref(), params)
664            })
665            .cloned()
666    })?;
667
668    record_mock_call(capability, operation, params);
669    if let Some(error) = matched.error {
670        return Some(Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
671            error,
672        )))));
673    }
674    Some(Ok(matched.result.unwrap_or(VmValue::Nil)))
675}
676
677/// Dispatch a hostlib builtin through the same scoped mock registry used by
678/// `host_call`.
679///
680/// Hostlib builtins are addressed by their schema module/method pair, so a test
681/// can mock `hostlib_tools_run_command(...)` with
682/// `{capability: "tools", operation: "run_command", ...}`. During the
683/// `process.exec` -> hostlib `run_command` migration we also honor existing
684/// `{capability: "process", operation: "exec", ...}` command mocks after the
685/// canonical `tools.run_command` lookup, preserving last-write-wins within each
686/// mock lane and giving explicit hostlib mocks precedence.
687pub fn dispatch_mock_hostlib_call(
688    module: &str,
689    method: &str,
690    params: &crate::value::DictMap,
691) -> Option<Result<VmValue, VmError>> {
692    if let Some(mocked) = dispatch_mock_host_call(module, method, params) {
693        return Some(mocked);
694    }
695
696    if (module, method) == ("tools", "run_command") {
697        return dispatch_mock_host_call("process", "exec", params);
698    }
699
700    None
701}
702
703fn empty_tool_list_value() -> VmValue {
704    VmValue::List(std::sync::Arc::new(Vec::new()))
705}
706
707fn current_vm_host_bridge(
708    ctx: Option<&AsyncBuiltinCtx>,
709) -> Option<std::sync::Arc<crate::bridge::HostBridge>> {
710    ctx.and_then(|ctx| ctx.child_vm().bridge.clone())
711}
712
713#[cfg(test)]
714async fn dispatch_host_tool_list() -> Result<VmValue, VmError> {
715    dispatch_host_tool_list_with_ctx(None).await
716}
717
718async fn dispatch_host_tool_list_with_ctx(
719    ctx: Option<&AsyncBuiltinCtx>,
720) -> Result<VmValue, VmError> {
721    let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
722    if let Some(bridge) = bridge {
723        if let Some(value) = bridge.list_tools()? {
724            return Ok(value);
725        }
726    }
727
728    let Some(bridge) = current_vm_host_bridge(ctx) else {
729        return Ok(empty_tool_list_value());
730    };
731    let tools = bridge.list_host_tools().await?;
732    Ok(crate::bridge::json_result_to_vm_value(&JsonValue::Array(
733        tools.into_iter().collect(),
734    )))
735}
736
737pub(crate) async fn dispatch_host_tool_call(
738    name: &str,
739    args: &VmValue,
740) -> Result<VmValue, VmError> {
741    dispatch_host_tool_call_with_ctx(None, name, args).await
742}
743
744pub(crate) async fn dispatch_host_tool_call_with_ctx(
745    ctx: Option<&AsyncBuiltinCtx>,
746    name: &str,
747    args: &VmValue,
748) -> Result<VmValue, VmError> {
749    let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
750    if let Some(bridge) = bridge {
751        if let Some(value) = bridge.call_tool(name, args)? {
752            return Ok(value);
753        }
754    }
755
756    let Some(bridge) = current_vm_host_bridge(ctx) else {
757        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
758            "host_tool_call: no host bridge is attached",
759        ))));
760    };
761
762    let result = bridge
763        .call(
764            "builtin_call",
765            serde_json::json!({
766                "name": name,
767                "args": [crate::llm::vm_value_to_json(args)],
768            }),
769        )
770        .await?;
771    Ok(crate::bridge::json_result_to_vm_value(&result))
772}
773
774/// Public entry for canonical `host_call` dispatch without an async-builtin
775/// context. Used by embedder tests and by MCP/host plumbing that already sits
776/// outside a VM builtin frame.
777pub async fn dispatch_host_operation(
778    capability: &str,
779    operation: &str,
780    params: &crate::value::DictMap,
781) -> Result<VmValue, VmError> {
782    dispatch_host_operation_with_ctx(None, capability, operation, params).await
783}
784
785/// Canonical `host_call` dispatch.
786///
787/// Embedders reach this path through the stdlib `host_call` builtin after
788/// installing a [`HostCallBridge`]. ACP installs that bridge and keeps the
789/// stdlib builtin; it no longer re-registers `host_call` by name (harn#5523).
790/// Cross-cutting behaviour added here therefore reaches editor-hosted sessions
791/// automatically — mocks, command-policy preflight, the process-handle
792/// registry, and the per-turn memo included.
793/// Editor-owned *builtins* (`exec`, `shell`, `run_command`) remain ACP
794/// overrides; that intentional ownership is separate from `host_call` routing.
795/// Direct `host_call("process.exec", ...)` always passes through the policy gates.
796pub async fn dispatch_host_operation_with_ctx(
797    ctx: Option<&AsyncBuiltinCtx>,
798    capability: &str,
799    operation: &str,
800    params: &crate::value::DictMap,
801) -> Result<VmValue, VmError> {
802    let _invalidation = turn_cache::invalidation_scope(capability, operation);
803    if let Some(ctx) = ctx {
804        let vm = ctx.child_vm();
805        if let Some(fixtured) = vm.harness().and_then(|harness| {
806            harness
807                .inner()
808                .fixtures()
809                .dispatch_host(capability, operation, params)
810        }) {
811            return fixtured;
812        }
813    }
814    if let Some(mocked) = dispatch_mock_host_call(capability, operation, params) {
815        return mocked;
816    }
817
818    if (capability, operation) == ("process", "exec") {
819        let caller = serde_json::json!({
820            "surface": "host_call",
821            "capability": "process",
822            "operation": "exec",
823            "session_id": crate::llm::current_agent_session_id(),
824        });
825        return dispatch_process_exec_with_policy(ctx, params, caller).await;
826    }
827
828    // process.spawn is the non-blocking sibling of exec. Route it through the
829    // SAME command-policy preflight so deny-patterns/approval/sandbox gating
830    // are identical; only the completion semantics differ (returns a handle
831    // immediately instead of awaiting). poll/wait/kill/release are pure
832    // registry operations on an already-gated spawn, so they bypass the
833    // command policy.
834    if (capability, operation) == ("process", "spawn") {
835        let caller = serde_json::json!({
836            "surface": "host_call",
837            "capability": "process",
838            "operation": "spawn",
839            "session_id": crate::llm::current_agent_session_id(),
840        });
841        return dispatch_process_spawn_with_policy(ctx, params, caller).await;
842    }
843    if capability == "process" && matches!(operation, "poll" | "wait" | "kill" | "release") {
844        if let Some(result) = crate::stdlib::process_spawn::dispatch(
845            operation,
846            params,
847            process_exec::async_builtin_cancel_token(ctx),
848        )
849        .await
850        {
851            return result;
852        }
853    }
854
855    let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
856    if let Some(bridge) = bridge {
857        // Turn-stable reads share a memo; metadata writes invalidate it on both
858        // sides of dispatch. harn#5190, harn#6914, harn#7172.
859        let dispatched = bridge::dispatch_cached(bridge, capability, operation, params).await?;
860        if let Some(value) = dispatched {
861            return Ok(value);
862        }
863    }
864
865    dispatch_builtin_host_operation(capability, operation, params).await
866}
867
868async fn dispatch_builtin_host_operation(
869    capability: &str,
870    operation: &str,
871    params: &crate::value::DictMap,
872) -> Result<VmValue, VmError> {
873    match (capability, operation) {
874        ("process", "list_shells") => Ok(crate::shells::list_shells_vm_value()),
875        ("process", "get_default_shell") => Ok(crate::shells::default_shell_vm_value()),
876        ("process", "set_default_shell") => crate::shells::set_default_shell_vm_value(params),
877        ("process", "shell_invocation") => crate::shells::shell_invocation_vm_value(params),
878        ("template", "render") => {
879            let path = require_param(params, "path")?;
880            let bindings = params.get("bindings").and_then(|v| v.as_dict());
881            Ok(VmValue::String(arcstr::ArcStr::from(render_template(
882                &path, bindings,
883            )?)))
884        }
885        ("interaction", "ask") => {
886            let question = require_param(params, "question")?;
887            super::io::prompt_user_value(&[VmValue::string(question)], &mut String::new())
888        }
889        ("project", "metadata_get") => crate::metadata::project_metadata_host_get(params),
890        ("project", "metadata_inspect") => crate::metadata::project_metadata_host_inspect(params),
891        ("project", "metadata_set") => crate::metadata::project_metadata_host_set(params),
892        ("project", "metadata_save") => crate::metadata::project_metadata_host_save(params),
893        ("project", "metadata_stale") => crate::metadata::project_metadata_host_stale(params),
894        ("project", "metadata_refresh_hashes") => {
895            crate::metadata::project_metadata_host_refresh_hashes(params)
896        }
897        // Standalone fallbacks for host-supplied capabilities. `HARN_TASK`
898        // backs `runtime.task` for debugger and CLI invocations.
899        ("runtime", "task") => Ok(VmValue::String(arcstr::ArcStr::from(
900            std::env::var("HARN_TASK").unwrap_or_default(),
901        ))),
902        ("runtime", "prompt_content") => Ok(VmValue::List(Arc::new(Vec::new()))),
903        ("runtime", "set_result") => {
904            // No-op when no host is attached; swallow silently so standalone
905            // scripts can still call `set_result` without crashing.
906            Ok(VmValue::Nil)
907        }
908        ("workspace", "project_root") => {
909            // Standalone fallback: prefer the typed execution project root,
910            // then the legacy env root, then the current working directory.
911            // Pipelines call this very early, so crashing here would block any
912            // debug-launched script.
913            let path = crate::stdlib::process::project_root_path()
914                .map(|root| root.display().to_string())
915                .or_else(|| std::env::var("HARN_PROJECT_ROOT").ok())
916                .unwrap_or_else(|| {
917                    std::env::current_dir()
918                        .map(|p| p.display().to_string())
919                        .unwrap_or_default()
920                });
921            Ok(VmValue::String(arcstr::ArcStr::from(path)))
922        }
923        ("workspace", "cwd") => {
924            let path = std::env::current_dir()
925                .map(|p| p.display().to_string())
926                .unwrap_or_default();
927            Ok(VmValue::String(arcstr::ArcStr::from(path)))
928        }
929        _ => Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
930            format!("host_call: unsupported operation {capability}.{operation}"),
931        )))),
932    }
933}
934
935pub(crate) fn optional_i64(params: &crate::value::DictMap, key: &str) -> Option<i64> {
936    match params.get(key) {
937        Some(VmValue::Int(value)) => Some(*value),
938        Some(VmValue::Float(value)) if value.fract() == 0.0 => Some(*value as i64),
939        _ => None,
940    }
941}
942
943pub(crate) fn optional_string(params: &crate::value::DictMap, key: &str) -> Option<String> {
944    params.get(key).and_then(vm_string).map(ToString::to_string)
945}
946
947fn optional_string_list(params: &crate::value::DictMap, key: &str) -> Option<Vec<String>> {
948    let VmValue::List(values) = params.get(key)? else {
949        return None;
950    };
951    values
952        .iter()
953        .map(|value| vm_string(value).map(ToString::to_string))
954        .collect()
955}
956
957fn optional_string_dict(
958    params: &crate::value::DictMap,
959    key: &str,
960) -> Result<Option<BTreeMap<String, String>>, VmError> {
961    let Some(value) = params.get(key) else {
962        return Ok(None);
963    };
964    let Some(dict) = value.as_dict() else {
965        return Err(VmError::Runtime(format!(
966            "host_call process.exec {key} must be a dict"
967        )));
968    };
969    let mut out = std::collections::BTreeMap::new();
970    for (key, value) in dict.iter() {
971        let Some(value) = vm_string(value) else {
972            return Err(VmError::Runtime(format!(
973                "host_call process.exec env value for {key:?} must be a string"
974            )));
975        };
976        out.insert(key.to_string(), value.to_string());
977    }
978    Ok(Some(out))
979}
980
981fn vm_string(value: &VmValue) -> Option<&str> {
982    match value {
983        VmValue::String(value) => Some(value.as_ref()),
984        _ => None,
985    }
986}
987
988pub(crate) fn register_host_builtins(vm: &mut Vm) {
989    for def in MODULE_BUILTINS {
990        vm.register_builtin_def(def);
991    }
992}
993
994pub(crate) fn register_missing_host_builtins(vm: &mut Vm) {
995    for def in MODULE_BUILTINS {
996        if vm.builtin_metadata_for(def.sig.name).is_none() {
997            vm.register_builtin_def(def);
998        }
999    }
1000}
1001
1002#[harn_builtin(
1003    exposure = "privileged_wire",
1004    effects = ["host.mutate@arg0"],
1005    sig = "host_mock(capability: string, op: string, response_or_config?: any, params?: dict) -> nil",
1006    category = "host"
1007)]
1008pub(crate) fn host_mock_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1009    let host_mock = parse_host_mock(args)?;
1010    validate_host_mock_registration(&host_mock)?;
1011    push_host_mock(host_mock);
1012    Ok(VmValue::Nil)
1013}
1014
1015#[harn_builtin(
1016    exposure = "privileged_wire",
1017    effects = [],
1018    sig = "host_mock_clear() -> nil", category = "host"
1019)]
1020fn host_mock_clear_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1021    reset_host_state();
1022    Ok(VmValue::Nil)
1023}
1024
1025#[harn_builtin(
1026    exposure = "runtime_internal",
1027    effects = [],
1028    sig = "host_mock_calls() -> list", category = "host"
1029)]
1030fn host_mock_calls_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1031    let calls = HOST_MOCK_CALLS.with(|calls| {
1032        calls
1033            .borrow()
1034            .iter()
1035            .map(mock_call_value)
1036            .collect::<Vec<_>>()
1037    });
1038    Ok(VmValue::List(std::sync::Arc::new(calls)))
1039}
1040
1041#[harn_builtin(
1042    exposure = "runtime_internal",
1043    effects = [],
1044    sig = "host_mock_push_scope() -> nil", category = "host"
1045)]
1046fn host_mock_push_scope_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1047    push_host_mock_scope();
1048    Ok(VmValue::Nil)
1049}
1050
1051#[harn_builtin(
1052    exposure = "runtime_internal",
1053    effects = [],
1054    sig = "host_mock_pop_scope() -> nil", category = "host"
1055)]
1056fn host_mock_pop_scope_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1057    if !pop_host_mock_scope() {
1058        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1059            "host_mock_pop_scope: no scope to pop",
1060        ))));
1061    }
1062    Ok(VmValue::Nil)
1063}
1064
1065#[harn_builtin(
1066    exposure = "runtime_internal",
1067    effects = [],
1068    sig = "host_capabilities() -> dict", category = "host"
1069)]
1070fn host_capabilities_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1071    Ok(capability_manifest_with_mocks())
1072}
1073
1074#[harn_builtin(
1075    exposure = "runtime_internal",
1076    effects = [],
1077    sig = "host_has(capability: string, op?: string) -> bool",
1078    category = "host"
1079)]
1080fn host_has_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1081    let capability = args.first().map(|a| a.display()).unwrap_or_default();
1082    let operation = args.get(1).map(|a| a.display());
1083    let manifest = capability_manifest_with_mocks();
1084    let has = manifest
1085        .as_dict()
1086        .and_then(|d| d.get(capability.as_str()))
1087        .and_then(|v| v.as_dict())
1088        .is_some_and(|cap| {
1089            if let Some(operation) = operation {
1090                cap.get("ops")
1091                    .and_then(|v| match v {
1092                        VmValue::List(list) => {
1093                            Some(list.iter().any(|item| item.display() == operation))
1094                        }
1095                        _ => None,
1096                    })
1097                    .unwrap_or(false)
1098            } else {
1099                true
1100            }
1101        });
1102    Ok(VmValue::Bool(has))
1103}
1104
1105#[harn_builtin(
1106    exposure = "privileged_wire",
1107    effects = [],
1108    sig = "host_call(name: string, args?: dict) -> any",
1109    kind = "async",
1110    category = "host"
1111)]
1112async fn host_call_builtin(
1113    ctx: crate::vm::AsyncBuiltinCtx,
1114    args: Vec<VmValue>,
1115) -> Result<VmValue, VmError> {
1116    let name = args.first().map(|a| a.display()).unwrap_or_default();
1117    let params = args
1118        .get(1)
1119        .and_then(|a| a.as_dict())
1120        .cloned()
1121        .unwrap_or_default();
1122    let Some((capability, operation)) = name.split_once('.') else {
1123        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1124            format!("host_call: unsupported operation name '{name}'"),
1125        ))));
1126    };
1127    dispatch_host_operation_with_ctx(Some(&ctx), capability, operation, &params).await
1128}
1129
1130#[harn_builtin(
1131    exposure = "runtime_internal",
1132    effects = [],
1133    sig = "host_tool_list() -> list", kind = "async", category = "host"
1134)]
1135async fn host_tool_list_builtin(
1136    ctx: crate::vm::AsyncBuiltinCtx,
1137    _args: Vec<VmValue>,
1138) -> Result<VmValue, VmError> {
1139    dispatch_host_tool_list_with_ctx(Some(&ctx)).await
1140}
1141
1142#[harn_builtin(
1143    exposure = "runtime_internal",
1144    effects = [],
1145    sig = "host_tool_call(name: string, args?: any) -> any",
1146    kind = "async",
1147    category = "host"
1148)]
1149async fn host_tool_call_builtin(
1150    ctx: crate::vm::AsyncBuiltinCtx,
1151    args: Vec<VmValue>,
1152) -> Result<VmValue, VmError> {
1153    let name = args.first().map(|a| a.display()).unwrap_or_default();
1154    if name.is_empty() {
1155        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1156            "host_tool_call: tool name is required",
1157        ))));
1158    }
1159    let call_args = args.get(1).cloned().unwrap_or(VmValue::Nil);
1160    dispatch_host_tool_call_with_ctx(Some(&ctx), &name, &call_args).await
1161}
1162
1163#[cfg(test)]
1164#[path = "host/tests.rs"]
1165mod tests;