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;
5use std::time::Instant;
6
7use serde_json::Value as JsonValue;
8use tokio::io::AsyncReadExt;
9
10use crate::stdlib::macros::{harn_builtin, VmBuiltinDef};
11use crate::value::{values_equal, VmError, VmValue};
12use crate::vm::{AsyncBuiltinCtx, Vm};
13
14mod operation_registry;
15mod process_dispatch;
16pub(crate) mod turn_cache;
17
18use process_dispatch::dispatch_process_exec_with_policy;
19pub(crate) use process_dispatch::{dispatch_process_exec, dispatch_reviewed_git_push_with_lease};
20
21/// Audited wrapper for `chrono::Utc::now().to_rfc3339()`. Routes through
22/// the testbench leak audit so a paused-clock session can surface every
23/// host capability that observed real wall-clock time.
24pub(crate) fn audited_utc_now_rfc3339(capability_id: &'static str) -> String {
25    let dt: chrono::DateTime<chrono::Utc> =
26        crate::clock_mock::leak_audit::wall_now(capability_id).into();
27    dt.to_rfc3339()
28}
29
30pub(crate) const MODULE_BUILTINS: &[&VmBuiltinDef] = &[
31    &HOST_MOCK_BUILTIN_DEF,
32    &HOST_MOCK_CLEAR_BUILTIN_DEF,
33    &HOST_MOCK_CALLS_BUILTIN_DEF,
34    &HOST_MOCK_PUSH_SCOPE_BUILTIN_DEF,
35    &HOST_MOCK_POP_SCOPE_BUILTIN_DEF,
36    &HOST_CAPABILITIES_BUILTIN_DEF,
37    &HOST_HAS_BUILTIN_DEF,
38    &HOST_CALL_BUILTIN_DEF,
39    &HOST_TOOL_LIST_BUILTIN_DEF,
40    &HOST_TOOL_CALL_BUILTIN_DEF,
41];
42
43#[derive(Clone)]
44struct HostMock {
45    capability: String,
46    operation: String,
47    params: Option<crate::value::DictMap>,
48    result: Option<VmValue>,
49    error: Option<String>,
50    unregistered_ok: bool,
51}
52
53#[derive(Clone)]
54struct HostMockCall {
55    capability: String,
56    operation: String,
57    params: crate::value::DictMap,
58}
59
60thread_local! {
61    static HOST_MOCKS: RefCell<Vec<HostMock>> = const { RefCell::new(Vec::new()) };
62    static HOST_MOCK_CALLS: RefCell<Vec<HostMockCall>> = const { RefCell::new(Vec::new()) };
63    static HOST_MOCK_SCOPES: RefCell<Vec<(Vec<HostMock>, Vec<HostMockCall>)>> =
64        const { RefCell::new(Vec::new()) };
65}
66
67pub(crate) fn reset_host_state() {
68    HOST_MOCKS.with(|mocks| mocks.borrow_mut().clear());
69    HOST_MOCK_CALLS.with(|calls| calls.borrow_mut().clear());
70    HOST_MOCK_SCOPES.with(|scopes| scopes.borrow_mut().clear());
71    turn_cache::reset();
72}
73
74pub(crate) fn reset_scoped_host_state() {
75    operation_registry::clear_scoped_mockable();
76}
77
78/// Push the current host-mock state onto an internal stack and start a
79/// fresh empty scope. Paired with `pop_host_mock_scope`. Used by the
80/// `with_host_mocks` helper in `std/testing` to give tests automatic
81/// cleanup, including when the body throws.
82fn push_host_mock_scope() {
83    let mocks = HOST_MOCKS.with(|v| std::mem::take(&mut *v.borrow_mut()));
84    let calls = HOST_MOCK_CALLS.with(|v| std::mem::take(&mut *v.borrow_mut()));
85    HOST_MOCK_SCOPES.with(|v| v.borrow_mut().push((mocks, calls)));
86}
87
88/// Restore the most recently pushed host-mock state, replacing any
89/// mocks or recorded calls accumulated inside the scope. Returns
90/// `false` if there is no saved scope to pop, so callers can surface a
91/// clear "imbalanced scope" error rather than silently no-op'ing.
92fn pop_host_mock_scope() -> bool {
93    let entry = HOST_MOCK_SCOPES.with(|v| v.borrow_mut().pop());
94    match entry {
95        Some((mocks, calls)) => {
96            HOST_MOCKS.with(|v| *v.borrow_mut() = mocks);
97            HOST_MOCK_CALLS.with(|v| *v.borrow_mut() = calls);
98            true
99        }
100        None => false,
101    }
102}
103
104fn async_builtin_cancel_token(
105    ctx: Option<&AsyncBuiltinCtx>,
106) -> Option<std::sync::Arc<std::sync::atomic::AtomicBool>> {
107    ctx.and_then(|ctx| ctx.child_vm().cancel_token.clone())
108}
109
110fn capability_manifest_map() -> crate::value::DictMap {
111    let mut root = crate::value::DictMap::new();
112    root.insert(
113        crate::value::intern_key("process"),
114        capability(
115            "Process execution.",
116            &[
117                op("exec", "Execute a process in argv or shell mode."),
118                op(
119                    "spawn",
120                    "Spawn a process non-blocking; returns a handle immediately for poll/wait/kill.",
121                ),
122                op(
123                    "poll",
124                    "Non-blocking snapshot of a spawned process: status, captured stdout/stderr.",
125                ),
126                op(
127                    "wait",
128                    "Await a spawned process to completion (optional timeout_ms); returns final result.",
129                ),
130                op(
131                    "kill",
132                    "Terminate a spawned process by handle and await the status transition.",
133                ),
134                op(
135                    "release",
136                    "Release a spawned-process handle and free its retained output.",
137                ),
138                op("list_shells", "List shells discovered by the host/session."),
139                op(
140                    "get_default_shell",
141                    "Return the selected default shell for this host/session.",
142                ),
143                op(
144                    "set_default_shell",
145                    "Select the default shell for this host/session.",
146                ),
147                op(
148                    "shell_invocation",
149                    "Resolve shell selection and login/interactive flags into argv.",
150                ),
151            ],
152        ),
153    );
154    root.insert(
155        crate::value::intern_key("template"),
156        capability(
157            "Template rendering.",
158            &[op("render", "Render a template file.")],
159        ),
160    );
161    root.insert(
162        crate::value::intern_key("interaction"),
163        capability(
164            "User interaction.",
165            &[op("ask", "Ask the user a question.")],
166        ),
167    );
168    root.insert(
169        crate::value::intern_key("memory"),
170        capability(
171            "Vector-aware memory: host-provided embeddings.",
172            &[op(
173                "embed",
174                "Embed text for semantic recall. Params: {text, model_hint?}. \
175                 Returns {vector: list<float>, model: string, dim: int}.",
176            )],
177        ),
178    );
179    root.insert(
180        crate::value::intern_key("project"),
181        capability(
182            "Project metadata and durable project facts.",
183            &[
184                op("metadata_get", "Read project metadata."),
185                op("metadata_inspect", "Inspect project metadata provenance."),
186                op("metadata_set", "Write project metadata."),
187                op("metadata_save", "Persist pending project metadata changes."),
188                op("metadata_stale", "Check whether project metadata is stale."),
189                op(
190                    "metadata_refresh_hashes",
191                    "Refresh project metadata content hashes.",
192                ),
193            ],
194        ),
195    );
196    root.insert(
197        crate::value::intern_key("runtime"),
198        capability(
199            "Runtime task context and run metadata supplied by the active host.",
200            &[
201                op("task", "Read the current runtime task."),
202                op("pipeline_input", "Read the active pipeline input payload."),
203                op("dry_run", "Read whether the runtime is in dry-run mode."),
204                op("approved_plan", "Read the approved plan text."),
205                op("record_run", "Record run metadata with the host."),
206                op("set_result", "Write the runtime result payload."),
207            ],
208        ),
209    );
210    root.insert(
211        crate::value::intern_key("workspace"),
212        capability(
213            "Workspace facts and file access supplied by the active host.",
214            &[
215                op("project_root", "Return the active project root."),
216                op("cwd", "Return the active current working directory."),
217                op("read_text", "Read a workspace text file."),
218                op("list", "List workspace files or directories."),
219                op("exists", "Check whether a workspace path exists."),
220            ],
221        ),
222    );
223    root.insert(
224        crate::value::intern_key("oauth_storage"),
225        capability(
226            "Host-managed OAuth token storage.",
227            &[
228                op("cloud_get", "Read a cloud-managed token set."),
229                op("cloud_set", "Write a cloud-managed token set."),
230                op("cloud_delete", "Delete a cloud-managed token set."),
231                op(
232                    "cloud_acquire_refresh_lock",
233                    "Acquire an OAuth refresh lock.",
234                ),
235                op(
236                    "cloud_release_refresh_lock",
237                    "Release an OAuth refresh lock.",
238                ),
239            ],
240        ),
241    );
242    root.insert(
243        crate::value::intern_key("mcp"),
244        capability(
245            "MCP host interactions.",
246            &[op("elicit", "Ask the connected MCP client for input.")],
247        ),
248    );
249    root.insert(
250        crate::value::intern_key("hitl"),
251        capability(
252            "Human-in-the-loop host interactions.",
253            &[
254                op(
255                    "question",
256                    "Ask a human a question through the active host.",
257                ),
258                op(
259                    "approval",
260                    "Request a human approval through the active host.",
261                ),
262                op(
263                    "dual_control",
264                    "Request quorum approval from multiple human reviewers.",
265                ),
266                op(
267                    "escalation",
268                    "Escalate a task to a human role through the active host.",
269                ),
270            ],
271        ),
272    );
273    root
274}
275
276fn mocked_operation_entry() -> VmValue {
277    op(
278        "mocked",
279        "Mocked host operation registered at runtime for tests.",
280    )
281    .1
282}
283
284fn ensure_mocked_capability(
285    root: &mut crate::value::DictMap,
286    capability_name: &str,
287    operation_name: &str,
288) {
289    let Some(existing) = root.get(capability_name).cloned() else {
290        root.insert(
291            crate::value::intern_key(capability_name),
292            capability(
293                "Mocked host capability registered at runtime for tests.",
294                &[(operation_name.to_string(), mocked_operation_entry())],
295            ),
296        );
297        return;
298    };
299
300    let Some(existing_dict) = existing.as_dict() else {
301        return;
302    };
303    let mut entry = (*existing_dict).clone();
304    let mut ops = entry
305        .get("ops")
306        .and_then(|value| match value {
307            VmValue::List(list) => Some((**list).clone()),
308            _ => None,
309        })
310        .unwrap_or_default();
311    if !ops.iter().any(|value| value.display() == operation_name) {
312        ops.push(VmValue::String(arcstr::ArcStr::from(
313            operation_name.to_string(),
314        )));
315    }
316
317    let mut operations = entry
318        .get("operations")
319        .and_then(|value| value.as_dict())
320        .map(|dict| (*dict).clone())
321        .unwrap_or_default();
322    operations
323        .entry(crate::value::intern_key(operation_name))
324        .or_insert_with(mocked_operation_entry);
325
326    entry.insert(
327        crate::value::intern_key("ops"),
328        VmValue::List(std::sync::Arc::new(ops)),
329    );
330    entry.insert(
331        crate::value::intern_key("operations"),
332        VmValue::dict(operations),
333    );
334    root.insert(
335        crate::value::intern_key(capability_name),
336        VmValue::dict(entry),
337    );
338}
339
340fn ensure_registered_operation(
341    root: &mut crate::value::DictMap,
342    capability_name: &str,
343    operation_name: &str,
344    description: &str,
345) {
346    let operation = op(operation_name, description);
347    let Some(existing) = root.get(capability_name).cloned() else {
348        root.insert(
349            crate::value::intern_key(capability_name),
350            capability(description, &[operation]),
351        );
352        return;
353    };
354
355    let Some(existing_dict) = existing.as_dict() else {
356        return;
357    };
358    let mut entry = (*existing_dict).clone();
359    let mut ops = entry
360        .get("ops")
361        .and_then(|value| match value {
362            VmValue::List(list) => Some((**list).clone()),
363            _ => None,
364        })
365        .unwrap_or_default();
366    if !ops.iter().any(|value| value.display() == operation_name) {
367        ops.push(VmValue::String(arcstr::ArcStr::from(
368            operation_name.to_string(),
369        )));
370    }
371
372    let mut operations = entry
373        .get("operations")
374        .and_then(|value| value.as_dict())
375        .map(|dict| (*dict).clone())
376        .unwrap_or_default();
377    operations
378        .entry(crate::value::intern_key(operation_name))
379        .or_insert(operation.1);
380
381    entry.insert(
382        crate::value::intern_key("ops"),
383        VmValue::List(std::sync::Arc::new(ops)),
384    );
385    entry.insert(
386        crate::value::intern_key("operations"),
387        VmValue::dict(operations),
388    );
389    root.insert(
390        crate::value::intern_key(capability_name),
391        VmValue::dict(entry),
392    );
393}
394
395pub fn register_mockable_host_operation(
396    capability_name: impl AsRef<str>,
397    operation_name: impl AsRef<str>,
398    description: impl AsRef<str>,
399) {
400    operation_registry::register_mockable(capability_name, operation_name, description);
401}
402
403/// Register a mock-validation declaration scoped to the current test thread.
404pub fn register_scoped_mockable_host_operation(
405    capability_name: impl AsRef<str>,
406    operation_name: impl AsRef<str>,
407    description: impl AsRef<str>,
408) {
409    operation_registry::register_scoped_mockable(capability_name, operation_name, description);
410}
411
412pub fn register_callable_host_operation(
413    capability_name: impl AsRef<str>,
414    operation_name: impl AsRef<str>,
415    description: impl AsRef<str>,
416) {
417    operation_registry::register_callable(capability_name, operation_name, description);
418}
419
420fn apply_registered_operations(root: &mut crate::value::DictMap) {
421    operation_registry::apply_callable(root);
422}
423
424fn apply_mockable_operations(root: &mut crate::value::DictMap) {
425    operation_registry::apply_mockable(root);
426}
427
428fn capability_manifest_with_mocks() -> VmValue {
429    let mut root = capability_manifest_map();
430    apply_registered_operations(&mut root);
431    HOST_MOCKS.with(|mocks| {
432        for host_mock in mocks.borrow().iter() {
433            ensure_mocked_capability(&mut root, &host_mock.capability, &host_mock.operation);
434        }
435    });
436    VmValue::dict(root)
437}
438
439fn known_host_operations() -> Vec<(String, String)> {
440    let mut root = capability_manifest_map();
441    apply_registered_operations(&mut root);
442    apply_mockable_operations(&mut root);
443    root.into_iter()
444        .flat_map(|(capability_name, capability)| {
445            let capability_name = capability_name.to_string();
446            capability
447                .as_dict()
448                .and_then(|dict| dict.get("ops"))
449                .and_then(|value| match value {
450                    VmValue::List(list) => Some((**list).clone()),
451                    _ => None,
452                })
453                .unwrap_or_default()
454                .into_iter()
455                .map(move |operation| (capability_name.clone(), operation.display()))
456        })
457        .collect()
458}
459
460fn host_operation_is_registered(capability: &str, operation: &str) -> bool {
461    known_host_operations()
462        .iter()
463        .any(|(known_capability, known_operation)| {
464            known_capability == capability && known_operation == operation
465        })
466}
467
468fn closest_host_operation(capability: &str, operation: &str) -> Option<(String, String)> {
469    let requested = format!("{capability}.{operation}");
470    known_host_operations()
471        .into_iter()
472        .map(|(candidate_capability, candidate_operation)| {
473            let candidate = format!("{candidate_capability}.{candidate_operation}");
474            let distance = strsim::levenshtein(&requested, &candidate);
475            (distance, candidate_capability, candidate_operation)
476        })
477        .filter(|(distance, _, _)| *distance <= 4)
478        .min_by_key(|(distance, _, _)| *distance)
479        .map(|(_, candidate_capability, candidate_operation)| {
480            (candidate_capability, candidate_operation)
481        })
482}
483
484fn validate_host_mock_registration(host_mock: &HostMock) -> Result<(), VmError> {
485    if host_mock.unregistered_ok
486        || host_operation_is_registered(&host_mock.capability, &host_mock.operation)
487    {
488        return Ok(());
489    }
490
491    let mut message = format!(
492        "host_mock: unregistered host operation {}.{}; register the capability/operation on \
493         the host or pass {{unregistered_ok: true}} for a test-local mock",
494        host_mock.capability, host_mock.operation
495    );
496    if let Some((capability, operation)) =
497        closest_host_operation(&host_mock.capability, &host_mock.operation)
498    {
499        message.push_str(&format!(". Did you mean {capability}.{operation}?"));
500    }
501    Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
502        message,
503    ))))
504}
505
506fn op(name: &str, description: &str) -> (String, VmValue) {
507    let mut entry = crate::value::DictMap::new();
508    entry.put_str("description", description);
509    (name.to_string(), VmValue::dict(entry))
510}
511
512fn capability(description: &str, ops: &[(String, VmValue)]) -> VmValue {
513    let mut entry = crate::value::DictMap::new();
514    entry.put_str("description", description);
515    entry.insert(
516        crate::value::intern_key("ops"),
517        VmValue::List(std::sync::Arc::new(
518            ops.iter()
519                .map(|(name, _)| VmValue::String(arcstr::ArcStr::from(name.as_str())))
520                .collect(),
521        )),
522    );
523    let mut op_dict = crate::value::DictMap::new();
524    for (name, op) in ops {
525        op_dict.insert(crate::value::intern_key(name), op.clone());
526    }
527    entry.insert(
528        crate::value::intern_key("operations"),
529        VmValue::dict(op_dict),
530    );
531    VmValue::dict(entry)
532}
533
534pub(crate) fn require_param(params: &crate::value::DictMap, key: &str) -> Result<String, VmError> {
535    params
536        .get(key)
537        .map(|v| v.display())
538        .filter(|v| !v.is_empty())
539        .ok_or_else(|| {
540            VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
541                "host_call: missing required parameter '{key}'"
542            ))))
543        })
544}
545
546fn render_template(
547    path: &str,
548    bindings: Option<&crate::value::DictMap>,
549) -> Result<String, VmError> {
550    let asset = crate::stdlib::template::TemplateAsset::render_target(path).map_err(|msg| {
551        VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
552            "host_call template.render: {msg}"
553        ))))
554    })?;
555    crate::stdlib::template::render_asset_result(&asset, bindings).map_err(VmError::from)
556}
557
558fn params_match(expected: Option<&crate::value::DictMap>, actual: &crate::value::DictMap) -> bool {
559    let Some(expected) = expected else {
560        return true;
561    };
562    expected.iter().all(|(key, value)| {
563        actual
564            .get(key)
565            .is_some_and(|candidate| values_equal(candidate, value))
566    })
567}
568
569fn parse_host_mock(args: &[VmValue]) -> Result<HostMock, VmError> {
570    let capability = args
571        .first()
572        .map(|value| value.display())
573        .unwrap_or_default();
574    let operation = args.get(1).map(|value| value.display()).unwrap_or_default();
575    if capability.is_empty() || operation.is_empty() {
576        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
577            "host_mock: capability and operation are required",
578        ))));
579    }
580
581    let mut params = args
582        .get(3)
583        .and_then(|value| value.as_dict())
584        .map(|dict| (*dict).clone());
585    let mut result = args.get(2).cloned().or(Some(VmValue::Nil));
586    let mut error = None;
587    let mut unregistered_ok = false;
588
589    if let Some(config) = args.get(2).and_then(|value| value.as_dict()) {
590        if config.contains_key("result")
591            || config.contains_key("params")
592            || config.contains_key("error")
593            || config.contains_key("unregistered_ok")
594        {
595            params = config
596                .get("params")
597                .and_then(|value| value.as_dict())
598                .map(|dict| (*dict).clone());
599            result = config.get("result").cloned();
600            error = config
601                .get("error")
602                .map(|value| value.display())
603                .filter(|value| !value.is_empty());
604            unregistered_ok = matches!(config.get("unregistered_ok"), Some(VmValue::Bool(true)));
605        }
606    }
607
608    Ok(HostMock {
609        capability,
610        operation,
611        params,
612        result,
613        error,
614        unregistered_ok,
615    })
616}
617
618fn push_host_mock(host_mock: HostMock) {
619    HOST_MOCKS.with(|mocks| mocks.borrow_mut().push(host_mock));
620}
621
622fn mock_call_value(call: &HostMockCall) -> VmValue {
623    let mut item = crate::value::DictMap::new();
624    item.put_str("capability", call.capability.clone());
625    item.put_str("operation", call.operation.clone());
626    item.insert(
627        crate::value::intern_key("params"),
628        VmValue::dict(call.params.clone()),
629    );
630    VmValue::dict(item)
631}
632
633fn record_mock_call(capability: &str, operation: &str, params: &crate::value::DictMap) {
634    HOST_MOCK_CALLS.with(|calls| {
635        calls.borrow_mut().push(HostMockCall {
636            capability: capability.to_string(),
637            operation: operation.to_string(),
638            params: params.clone(),
639        });
640    });
641}
642
643pub(crate) fn dispatch_mock_host_call(
644    capability: &str,
645    operation: &str,
646    params: &crate::value::DictMap,
647) -> Option<Result<VmValue, VmError>> {
648    let matched = HOST_MOCKS.with(|mocks| {
649        mocks
650            .borrow()
651            .iter()
652            .rev()
653            .find(|host_mock| {
654                host_mock.capability == capability
655                    && host_mock.operation == operation
656                    && params_match(host_mock.params.as_ref(), params)
657            })
658            .cloned()
659    })?;
660
661    record_mock_call(capability, operation, params);
662    if let Some(error) = matched.error {
663        return Some(Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
664            error,
665        )))));
666    }
667    Some(Ok(matched.result.unwrap_or(VmValue::Nil)))
668}
669
670/// Dispatch a hostlib builtin through the same scoped mock registry used by
671/// `host_call`.
672///
673/// Hostlib builtins are addressed by their schema module/method pair, so a test
674/// can mock `hostlib_tools_run_command(...)` with
675/// `{capability: "tools", operation: "run_command", ...}`. During the
676/// `process.exec` -> hostlib `run_command` migration we also honor existing
677/// `{capability: "process", operation: "exec", ...}` command mocks after the
678/// canonical `tools.run_command` lookup, preserving last-write-wins within each
679/// mock lane and giving explicit hostlib mocks precedence.
680pub fn dispatch_mock_hostlib_call(
681    module: &str,
682    method: &str,
683    params: &crate::value::DictMap,
684) -> Option<Result<VmValue, VmError>> {
685    if let Some(mocked) = dispatch_mock_host_call(module, method, params) {
686        return Some(mocked);
687    }
688
689    if (module, method) == ("tools", "run_command") {
690        return dispatch_mock_host_call("process", "exec", params);
691    }
692
693    None
694}
695
696/// Embedder-supplied bridge for `host_call` ops.
697///
698/// Embedders (debug adapters, CLIs, IDE hosts) implement this trait to
699/// satisfy capability/operation pairs that harn-vm itself doesn't know how
700/// to handle. Returning `Ok(None)` means "I don't handle this op — fall
701/// through to the built-in fallbacks (env-derived defaults, then the
702/// `unsupported operation` error)". `Ok(Some(value))` is the result;
703/// `Err(VmError::Thrown(_))` surfaces as a Harn exception.
704///
705/// The trait is intentionally synchronous. Bridges that need async I/O
706/// (e.g. DAP reverse requests) should drive their own runtime or use a
707/// blocking channel — see `harn-dap`'s `DapHostBridge` for the canonical
708/// pattern. Sync keeps the boundary simple and avoids forcing the entire
709/// dispatch path into an opaque future.
710pub trait HostCallBridge: Send + Sync {
711    fn dispatch(
712        &self,
713        capability: &str,
714        operation: &str,
715        params: &crate::value::DictMap,
716    ) -> Result<Option<VmValue>, VmError>;
717
718    fn list_tools(&self) -> Result<Option<VmValue>, VmError> {
719        Ok(None)
720    }
721
722    fn call_tool(&self, _name: &str, _args: &VmValue) -> Result<Option<VmValue>, VmError> {
723        Ok(None)
724    }
725}
726
727thread_local! {
728    static HOST_CALL_BRIDGE: RefCell<Option<Arc<dyn HostCallBridge>>> = const { RefCell::new(None) };
729}
730
731/// Install a bridge for the current thread. The bridge is consulted on
732/// every `host_call` *after* mock matching but *before* the built-in
733/// match arms, so embedders can override anything they like (and equally
734/// punt on anything they don't, by returning `Ok(None)`).
735pub fn set_host_call_bridge(bridge: Arc<dyn HostCallBridge>) {
736    turn_cache::reset();
737    HOST_CALL_BRIDGE.with(|b| *b.borrow_mut() = Some(bridge));
738}
739
740/// Remove the current thread's bridge. Idempotent.
741pub fn clear_host_call_bridge() {
742    turn_cache::reset();
743    HOST_CALL_BRIDGE.with(|b| *b.borrow_mut() = None);
744}
745
746/// Dispatch `(capability, operation, params)` to the currently-installed
747/// `HostCallBridge`, if any. `Some(Ok(_))` means the bridge handled the
748/// call; `Some(Err(_))` means it tried but raised; `None` means there is
749/// no bridge or the bridge declined this op (returned `Ok(None)`).
750///
751/// Mirrors the inner block of `dispatch_host_operation` but without the
752/// mock-call check or the built-in fallbacks — useful for callers that
753/// want to treat the bridge as one of several sinks (e.g. inbound MCP
754/// `elicitation/create` requests).
755pub fn dispatch_host_call_bridge(
756    capability: &str,
757    operation: &str,
758    params: &crate::value::DictMap,
759) -> Option<Result<VmValue, VmError>> {
760    let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone())?;
761    match bridge.dispatch(capability, operation, params) {
762        Ok(Some(value)) => Some(Ok(value)),
763        Ok(None) => None,
764        Err(error) => Some(Err(error)),
765    }
766}
767
768fn empty_tool_list_value() -> VmValue {
769    VmValue::List(std::sync::Arc::new(Vec::new()))
770}
771
772fn current_vm_host_bridge(
773    ctx: Option<&AsyncBuiltinCtx>,
774) -> Option<std::sync::Arc<crate::bridge::HostBridge>> {
775    ctx.and_then(|ctx| ctx.child_vm().bridge.clone())
776}
777
778#[cfg(test)]
779async fn dispatch_host_tool_list() -> Result<VmValue, VmError> {
780    dispatch_host_tool_list_with_ctx(None).await
781}
782
783async fn dispatch_host_tool_list_with_ctx(
784    ctx: Option<&AsyncBuiltinCtx>,
785) -> Result<VmValue, VmError> {
786    let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
787    if let Some(bridge) = bridge {
788        if let Some(value) = bridge.list_tools()? {
789            return Ok(value);
790        }
791    }
792
793    let Some(bridge) = current_vm_host_bridge(ctx) else {
794        return Ok(empty_tool_list_value());
795    };
796    let tools = bridge.list_host_tools().await?;
797    Ok(crate::bridge::json_result_to_vm_value(&JsonValue::Array(
798        tools.into_iter().collect(),
799    )))
800}
801
802pub(crate) async fn dispatch_host_tool_call(
803    name: &str,
804    args: &VmValue,
805) -> Result<VmValue, VmError> {
806    dispatch_host_tool_call_with_ctx(None, name, args).await
807}
808
809pub(crate) async fn dispatch_host_tool_call_with_ctx(
810    ctx: Option<&AsyncBuiltinCtx>,
811    name: &str,
812    args: &VmValue,
813) -> Result<VmValue, VmError> {
814    let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
815    if let Some(bridge) = bridge {
816        if let Some(value) = bridge.call_tool(name, args)? {
817            return Ok(value);
818        }
819    }
820
821    let Some(bridge) = current_vm_host_bridge(ctx) else {
822        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
823            "host_tool_call: no host bridge is attached",
824        ))));
825    };
826
827    let result = bridge
828        .call(
829            "builtin_call",
830            serde_json::json!({
831                "name": name,
832                "args": [crate::llm::vm_value_to_json(args)],
833            }),
834        )
835        .await?;
836    Ok(crate::bridge::json_result_to_vm_value(&result))
837}
838
839pub(crate) async fn dispatch_host_operation(
840    capability: &str,
841    operation: &str,
842    params: &crate::value::DictMap,
843) -> Result<VmValue, VmError> {
844    dispatch_host_operation_with_ctx(None, capability, operation, params).await
845}
846
847pub(crate) async fn dispatch_host_operation_with_ctx(
848    ctx: Option<&AsyncBuiltinCtx>,
849    capability: &str,
850    operation: &str,
851    params: &crate::value::DictMap,
852) -> Result<VmValue, VmError> {
853    if let Some(mocked) = dispatch_mock_host_call(capability, operation, params) {
854        return mocked;
855    }
856
857    if (capability, operation) == ("process", "exec") {
858        let caller = serde_json::json!({
859            "surface": "host_call",
860            "capability": "process",
861            "operation": "exec",
862            "session_id": crate::llm::current_agent_session_id(),
863        });
864        return dispatch_process_exec_with_policy(ctx, params, caller).await;
865    }
866
867    // process.spawn is the non-blocking sibling of exec. Route it through the
868    // SAME command-policy preflight so deny-patterns/approval/sandbox gating
869    // are identical; only the completion semantics differ (returns a handle
870    // immediately instead of awaiting). poll/wait/kill/release are pure
871    // registry operations on an already-gated spawn, so they bypass the
872    // command policy.
873    if (capability, operation) == ("process", "spawn") {
874        let caller = serde_json::json!({
875            "surface": "host_call",
876            "capability": "process",
877            "operation": "spawn",
878            "session_id": crate::llm::current_agent_session_id(),
879        });
880        return dispatch_process_spawn_with_policy(ctx, params, caller).await;
881    }
882    if capability == "process" && matches!(operation, "poll" | "wait" | "kill" | "release") {
883        if let Some(result) = crate::stdlib::process_spawn::dispatch(
884            operation,
885            params,
886            async_builtin_cancel_token(ctx),
887        )
888        .await
889        {
890            return result;
891        }
892    }
893
894    let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
895    if let Some(bridge) = bridge {
896        // Serve turn-stable reads (e.g. `runtime.pipeline_input`) from the
897        // per-turn memo so context assembly pays one host round-trip per turn
898        // instead of once per shard. harn#5190.
899        let dispatched = turn_cache::cached_or(capability, operation, params, || {
900            bridge.dispatch(capability, operation, params)
901        })?;
902        if let Some(value) = dispatched {
903            return Ok(value);
904        }
905    }
906
907    dispatch_builtin_host_operation(capability, operation, params).await
908}
909
910async fn dispatch_builtin_host_operation(
911    capability: &str,
912    operation: &str,
913    params: &crate::value::DictMap,
914) -> Result<VmValue, VmError> {
915    match (capability, operation) {
916        ("process", "list_shells") => Ok(crate::shells::list_shells_vm_value()),
917        ("process", "get_default_shell") => Ok(crate::shells::default_shell_vm_value()),
918        ("process", "set_default_shell") => crate::shells::set_default_shell_vm_value(params),
919        ("process", "shell_invocation") => crate::shells::shell_invocation_vm_value(params),
920        ("template", "render") => {
921            let path = require_param(params, "path")?;
922            let bindings = params.get("bindings").and_then(|v| v.as_dict());
923            Ok(VmValue::String(arcstr::ArcStr::from(render_template(
924                &path, bindings,
925            )?)))
926        }
927        ("interaction", "ask") => {
928            let question = require_param(params, "question")?;
929            super::io::prompt_user_value(&[VmValue::string(question)], &mut String::new())
930        }
931        ("project", "metadata_get") => crate::metadata::project_metadata_host_get(params),
932        ("project", "metadata_inspect") => crate::metadata::project_metadata_host_inspect(params),
933        ("project", "metadata_set") => crate::metadata::project_metadata_host_set(params),
934        ("project", "metadata_save") => crate::metadata::project_metadata_host_save(params),
935        ("project", "metadata_stale") => crate::metadata::project_metadata_host_stale(params),
936        ("project", "metadata_refresh_hashes") => {
937            crate::metadata::project_metadata_host_refresh_hashes(params)
938        }
939        // Standalone-run fallbacks for capabilities normally supplied by
940        // an embedder's JSON-RPC bridge. `runtime.task` lets a debugger or
941        // CLI invocation read the pipeline input from `HARN_TASK` without
942        // the host explicitly wiring a callback for every op.
943        ("runtime", "task") => Ok(VmValue::String(arcstr::ArcStr::from(
944            std::env::var("HARN_TASK").unwrap_or_default(),
945        ))),
946        ("runtime", "set_result") => {
947            // No-op when no host is attached; swallow silently so standalone
948            // scripts can still call `set_result` without crashing.
949            Ok(VmValue::Nil)
950        }
951        ("workspace", "project_root") => {
952            // Standalone fallback: prefer the typed execution project root,
953            // then the legacy env root, then the current working directory.
954            // Pipelines call this very early, so crashing here would block any
955            // debug-launched script.
956            let path = crate::stdlib::process::project_root_path()
957                .map(|root| root.display().to_string())
958                .or_else(|| std::env::var("HARN_PROJECT_ROOT").ok())
959                .unwrap_or_else(|| {
960                    std::env::current_dir()
961                        .map(|p| p.display().to_string())
962                        .unwrap_or_default()
963                });
964            Ok(VmValue::String(arcstr::ArcStr::from(path)))
965        }
966        ("workspace", "cwd") => {
967            let path = std::env::current_dir()
968                .map(|p| p.display().to_string())
969                .unwrap_or_default();
970            Ok(VmValue::String(arcstr::ArcStr::from(path)))
971        }
972        _ => Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
973            format!("host_call: unsupported operation {capability}.{operation}"),
974        )))),
975    }
976}
977
978/// Apply the command-policy preflight (deny-patterns, approval gating,
979/// sandbox decisions) and then spawn the process non-blocking. Mirrors
980/// [`dispatch_process_exec_with_policy`] so spawn is gated identically to
981/// exec. There is no postflight here: spawn returns a handle immediately,
982/// not a completed command result; completion is observed later via
983/// poll/wait, which are not themselves command executions.
984async fn dispatch_process_spawn_with_policy(
985    ctx: Option<&AsyncBuiltinCtx>,
986    params: &crate::value::DictMap,
987    caller: serde_json::Value,
988) -> Result<VmValue, VmError> {
989    let params =
990        match crate::orchestration::run_command_policy_preflight_with_ctx(ctx, params, caller)
991            .await?
992        {
993            crate::orchestration::CommandPolicyPreflight::Proceed { params, .. } => params,
994            crate::orchestration::CommandPolicyPreflight::Blocked {
995                status,
996                message,
997                context,
998                decisions,
999            } => {
1000                return Ok(crate::orchestration::blocked_command_response(
1001                    params, status, &message, context, decisions,
1002                ));
1003            }
1004        };
1005
1006    match crate::stdlib::process_spawn::dispatch("spawn", &params, async_builtin_cancel_token(ctx))
1007        .await
1008    {
1009        Some(result) => result,
1010        None => Err(VmError::Runtime(
1011            "host_call process.spawn: dispatch returned None".to_string(),
1012        )),
1013    }
1014}
1015
1016async fn dispatch_process_exec_after_policy(
1017    ctx: Option<&AsyncBuiltinCtx>,
1018    params: &crate::value::DictMap,
1019    command_policy_context: JsonValue,
1020    command_policy_decisions: Vec<crate::orchestration::CommandPolicyDecision>,
1021) -> Result<VmValue, VmError> {
1022    let timeout_ms = optional_i64(params, "timeout")
1023        .or_else(|| optional_i64(params, "timeout_ms"))
1024        .filter(|value| *value > 0)
1025        .map(|value| value as u64);
1026    // Optional per-call profile override. Pipelines that want to
1027    // promote a single spawn to `os_hardened` (e.g. running
1028    // attacker-controlled code) pass `sandbox_profile: "os_hardened"`
1029    // without having to rewrite the surrounding policy. The override
1030    // is scoped to this call and pops with the guard at end-of-scope.
1031    let profile_guard = match optional_string(params, "sandbox_profile") {
1032        Some(value) => Some(push_sandbox_profile_override(&value)?),
1033        None => None,
1034    };
1035    let mut cmd = build_sandboxed_command(params, "process.exec")?;
1036    crate::op_interrupt::configure_tokio_kill_group(&mut cmd);
1037    let cleanup_token = crate::op_interrupt::new_process_cleanup_token();
1038    cmd.env(
1039        crate::op_interrupt::PROCESS_CLEANUP_TOKEN_ENV,
1040        &cleanup_token,
1041    );
1042    cmd.stdin(std::process::Stdio::null())
1043        .stdout(std::process::Stdio::piped())
1044        .stderr(std::process::Stdio::piped())
1045        .kill_on_drop(true);
1046    let started_at = audited_utc_now_rfc3339("host_call/process.exec.started_at");
1047    let started = crate::clock_mock::leak_audit::instant_now("host_call/process.exec.started");
1048    let mut child = cmd
1049        .spawn()
1050        .map_err(|e| VmError::Runtime(format!("host_call process.exec: {e}")))?;
1051    drop(profile_guard);
1052    let pid = child.id();
1053    let cleanup_registration = crate::op_interrupt::register_active_process_cleanup(
1054        pid,
1055        &cleanup_token,
1056        async_builtin_cancel_token(ctx),
1057    );
1058    let stdout_pipe = match child.stdout.take() {
1059        Some(pipe) => pipe,
1060        None => {
1061            terminate_process_exec_child(&mut child, pid, &cleanup_token, "missing_stdout_pipe")
1062                .await;
1063            drop(cleanup_registration);
1064            return Err(VmError::Runtime(
1065                "host_call process.exec stdout pipe was not captured".to_string(),
1066            ));
1067        }
1068    };
1069    let stderr_pipe = match child.stderr.take() {
1070        Some(pipe) => pipe,
1071        None => {
1072            terminate_process_exec_child(&mut child, pid, &cleanup_token, "missing_stderr_pipe")
1073                .await;
1074            drop(cleanup_registration);
1075            return Err(VmError::Runtime(
1076                "host_call process.exec stderr pipe was not captured".to_string(),
1077            ));
1078        }
1079    };
1080    let stdout_task = tokio::spawn(read_process_exec_pipe(stdout_pipe));
1081    let stderr_task = tokio::spawn(read_process_exec_pipe(stderr_pipe));
1082
1083    enum ProcessExecWait {
1084        Exited(std::io::Result<std::process::ExitStatus>),
1085        TimedOut,
1086    }
1087
1088    let exec_deadline = timeout_ms.map(|timeout_ms| {
1089        tokio::time::Instant::now() + std::time::Duration::from_millis(timeout_ms)
1090    });
1091    let wait_result = {
1092        let wait = child.wait();
1093        tokio::pin!(wait);
1094        if let Some(deadline) = exec_deadline {
1095            let sleep = tokio::time::sleep_until(deadline);
1096            tokio::pin!(sleep);
1097            tokio::select! {
1098                result = &mut wait => ProcessExecWait::Exited(result),
1099                _ = &mut sleep => ProcessExecWait::TimedOut,
1100            }
1101        } else {
1102            ProcessExecWait::Exited(wait.await)
1103        }
1104    };
1105
1106    let (mut status, mut success, mut timed_out, mut exit_code) = match wait_result {
1107        ProcessExecWait::Exited(result) => {
1108            let status =
1109                result.map_err(|e| VmError::Runtime(format!("host_call process.exec: {e}")))?;
1110            let exit_code = status.code().unwrap_or(-1);
1111            ("completed", status.success(), false, exit_code)
1112        }
1113        ProcessExecWait::TimedOut => {
1114            terminate_process_exec_child(&mut child, pid, &cleanup_token, "timeout").await;
1115            ("timed_out", false, true, -1)
1116        }
1117    };
1118
1119    let drain_pipes = async {
1120        let stdout = collect_process_exec_pipe(stdout_task, "stdout").await?;
1121        let stderr = collect_process_exec_pipe(stderr_task, "stderr").await?;
1122        Ok::<_, VmError>((stdout, stderr))
1123    };
1124    tokio::pin!(drain_pipes);
1125    let (stdout, stderr) = if !timed_out {
1126        if let Some(deadline) = exec_deadline {
1127            tokio::select! {
1128                result = &mut drain_pipes => result?,
1129                _ = tokio::time::sleep_until(deadline) => {
1130                    terminate_process_exec_child(
1131                        &mut child,
1132                        pid,
1133                        &cleanup_token,
1134                        "pipe_drain_timeout",
1135                    )
1136                    .await;
1137                    status = "timed_out";
1138                    success = false;
1139                    timed_out = true;
1140                    exit_code = -1;
1141                    drain_pipes.await?
1142                }
1143            }
1144        } else {
1145            drain_pipes.await?
1146        }
1147    } else {
1148        drain_pipes.await?
1149    };
1150    drop(cleanup_registration);
1151
1152    let stdout_utf8_valid = std::str::from_utf8(&stdout).is_ok();
1153    let stderr_utf8_valid = std::str::from_utf8(&stderr).is_ok();
1154    let stdout = String::from_utf8_lossy(&stdout).to_string();
1155    let stderr = String::from_utf8_lossy(&stderr).to_string();
1156    let response = process_exec_response(ProcessExecResponse {
1157        pid,
1158        started_at,
1159        started,
1160        stdout: &stdout,
1161        stderr: &stderr,
1162        exit_code,
1163        status,
1164        success,
1165        timed_out,
1166        stdout_utf8_valid,
1167        stderr_utf8_valid,
1168    });
1169    crate::orchestration::run_command_policy_postflight_with_ctx(
1170        ctx,
1171        params,
1172        response,
1173        command_policy_context,
1174        command_policy_decisions,
1175    )
1176    .await
1177}
1178
1179async fn read_process_exec_pipe<R>(mut pipe: R) -> std::io::Result<Vec<u8>>
1180where
1181    R: tokio::io::AsyncRead + Unpin,
1182{
1183    let mut bytes = Vec::new();
1184    pipe.read_to_end(&mut bytes).await?;
1185    Ok(bytes)
1186}
1187
1188async fn collect_process_exec_pipe(
1189    task: tokio::task::JoinHandle<std::io::Result<Vec<u8>>>,
1190    name: &str,
1191) -> Result<Vec<u8>, VmError> {
1192    match task.await {
1193        Ok(Ok(bytes)) => Ok(bytes),
1194        Ok(Err(error)) => Err(VmError::Runtime(format!(
1195            "host_call process.exec read {name}: {error}"
1196        ))),
1197        Err(error) => Err(VmError::Runtime(format!(
1198            "host_call process.exec join {name} reader: {error}"
1199        ))),
1200    }
1201}
1202
1203async fn terminate_process_exec_child(
1204    child: &mut tokio::process::Child,
1205    pid: Option<u32>,
1206    cleanup_token: &str,
1207    reason: &'static str,
1208) {
1209    if let Some(pid) = pid {
1210        let mut report = crate::op_interrupt::signal_pid_tree_group_and_token_with_report(
1211            pid,
1212            Some(cleanup_token),
1213            9,
1214        );
1215        report.refresh_survivor_status();
1216        tracing::warn!(
1217            pid,
1218            children = report.children.len(),
1219            reason,
1220            "host_call process.exec signalled child process tree"
1221        );
1222    }
1223    let _ = child.kill().await;
1224    let _ = child.wait().await;
1225}
1226
1227/// Build a sandboxed `tokio::process::Command` from process-call params,
1228/// applying argv/shell resolution, the active sandbox policy via
1229/// [`crate::process_sandbox::tokio_command_for`], cwd enforcement, and
1230/// env/env_mode/env_remove handling.
1231///
1232/// Shared by `process.exec` (synchronous) and `process.spawn`
1233/// (non-blocking) so both go through the identical sandbox-gated build
1234/// path. The caller is responsible for any `sandbox_profile` override
1235/// guard (it must be live across this call) and for setting stdio/kill
1236/// behaviour on the returned command. `label` ("process.exec" or
1237/// "process.spawn") is woven into error messages.
1238pub(crate) fn build_sandboxed_command(
1239    params: &crate::value::DictMap,
1240    label: &str,
1241) -> Result<tokio::process::Command, VmError> {
1242    let (program, args) = process_exec_argv(params)?;
1243    let mut cmd = crate::process_sandbox::tokio_command_for(&program, &args)
1244        .map_err(|e| VmError::Runtime(format!("host_call {label} sandbox setup: {e}")))?;
1245    if let Some(cwd) = optional_string(params, "cwd") {
1246        let cwd = resolve_process_exec_cwd(&cwd);
1247        crate::process_sandbox::enforce_process_cwd(&cwd)
1248            .map_err(|e| VmError::Runtime(format!("host_call {label} cwd: {e}")))?;
1249        cmd.current_dir(cwd);
1250    }
1251    // Under a session profile the command from `tokio_command_for` already
1252    // carries the resolver's closed env (parent env cleared), applied once at
1253    // the sandbox funnel; everything below layers onto that closed base.
1254    //
1255    // Track keys the caller set explicitly so the sandbox-local TMPDIR overlay
1256    // below never clobbers an intentional per-call value.
1257    let mut caller_env_keys: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
1258    if let Some(env) = optional_string_dict(params, "env")? {
1259        // `env_mode` controls how the provided `env` keys combine with the
1260        // parent environment:
1261        //   - "merge" (default): inherit the parent env and overlay the
1262        //     provided keys. This is the least-surprising behavior — a
1263        //     caller passing `env: {ONE_VAR: "x"}` keeps PATH/HOME/etc.
1264        //   - "replace": clear the parent env entirely, then set only the
1265        //     provided keys. This is the footgun shape and must be requested
1266        //     explicitly whenever `env` is supplied.
1267        let env_mode = optional_string(params, "env_mode");
1268        match env_mode.as_deref().unwrap_or("merge") {
1269            "replace" => {
1270                cmd.env_clear();
1271            }
1272            "merge" => {}
1273            other => {
1274                return Err(VmError::Runtime(format!(
1275                    "host_call {label}: unknown env_mode {other:?}; expected \"merge\" or \"replace\""
1276                )));
1277            }
1278        }
1279        for (key, value) in env {
1280            caller_env_keys.insert(key.clone());
1281            cmd.env(key, value);
1282        }
1283    }
1284    // env_remove: list of environment variable names to strip before
1285    // spawning. Applied after `env` so callers can both inherit and
1286    // selectively unset (e.g. the git stdlib strips `GIT_*` so its
1287    // operations are self-contained even when Harn is invoked from
1288    // inside a git hook that sets `GIT_DIR`).
1289    if let Some(env_remove) = optional_string_list(params, "env_remove") {
1290        for key in env_remove {
1291            caller_env_keys.insert(key.clone());
1292            cmd.env_remove(key);
1293        }
1294    }
1295    // Give the child workspace-local temp, home, and toolchain-cache paths. A
1296    // key the caller set (via `env`) or explicitly stripped (via `env_remove`)
1297    // is left as intended; only untouched keys receive the overlay.
1298    for (key, value) in crate::process_sandbox::active_workspace_process_env() {
1299        if caller_env_keys.contains(&key) {
1300            continue;
1301        }
1302        cmd.env(key, value);
1303    }
1304    // Pin tool *message* output to a deterministic English/UTF-8 locale so
1305    // downstream English-diagnostic matchers (deterministic syntax repair,
1306    // error-signature grounding, completion/pass-fail classification) do not
1307    // misfire for a non-Anglosphere user whose shell localizes compiler/test
1308    // output. A user-inherited `LC_ALL` overrides `LC_MESSAGES`, so strip it
1309    // first — unless the caller pinned it via `env`/`env_remove` — then apply
1310    // the overlay with the same caller-wins rule as the TMPDIR overlay above.
1311    if !caller_env_keys.contains(crate::process_sandbox::MESSAGE_LOCALE_OVERRIDE_ENV) {
1312        cmd.env_remove(crate::process_sandbox::MESSAGE_LOCALE_OVERRIDE_ENV);
1313    }
1314    for (key, value) in crate::process_sandbox::deterministic_message_locale_env() {
1315        if caller_env_keys.contains(&key) {
1316            continue;
1317        }
1318        cmd.env(key, value);
1319    }
1320    Ok(cmd)
1321}
1322
1323struct ProcessExecResponse<'a> {
1324    pid: Option<u32>,
1325    started_at: String,
1326    started: Instant,
1327    stdout: &'a str,
1328    stderr: &'a str,
1329    exit_code: i32,
1330    status: &'a str,
1331    success: bool,
1332    timed_out: bool,
1333    stdout_utf8_valid: bool,
1334    stderr_utf8_valid: bool,
1335}
1336
1337fn process_exec_response(response: ProcessExecResponse<'_>) -> VmValue {
1338    let combined = format!("{}{}", response.stdout, response.stderr);
1339    let mut result = crate::value::DictMap::new();
1340    result.put_str(
1341        "command_id",
1342        format!(
1343            "cmd_{}_{}",
1344            std::process::id(),
1345            response.started.elapsed().as_nanos()
1346        ),
1347    );
1348    result.put_str("status", response.status);
1349    result.insert(
1350        crate::value::intern_key("pid"),
1351        response
1352            .pid
1353            .map(|pid| VmValue::Int(pid as i64))
1354            .unwrap_or(VmValue::Nil),
1355    );
1356    result.insert(
1357        crate::value::intern_key("process_group_id"),
1358        response
1359            .pid
1360            .map(|pid| VmValue::Int(pid as i64))
1361            .unwrap_or(VmValue::Nil),
1362    );
1363    result.insert(crate::value::intern_key("handle_id"), VmValue::Nil);
1364    result.put_str("started_at", response.started_at);
1365    result.put_str(
1366        "ended_at",
1367        audited_utc_now_rfc3339("host_call/process.exec.ended_at"),
1368    );
1369    result.insert(
1370        crate::value::intern_key("duration_ms"),
1371        VmValue::Int(response.started.elapsed().as_millis() as i64),
1372    );
1373    result.insert(
1374        crate::value::intern_key("exit_code"),
1375        VmValue::Int(response.exit_code as i64),
1376    );
1377    result.insert(crate::value::intern_key("signal"), VmValue::Nil);
1378    result.insert(
1379        crate::value::intern_key("timed_out"),
1380        VmValue::Bool(response.timed_out),
1381    );
1382    result.put_str("stdout", response.stdout);
1383    result.put_str("stderr", response.stderr);
1384    result.insert(
1385        crate::value::intern_key("stdout_utf8_valid"),
1386        VmValue::Bool(response.stdout_utf8_valid),
1387    );
1388    result.insert(
1389        crate::value::intern_key("stderr_utf8_valid"),
1390        VmValue::Bool(response.stderr_utf8_valid),
1391    );
1392    result.put_str("combined", combined);
1393    result.insert(
1394        crate::value::intern_key("exit_status"),
1395        VmValue::Int(response.exit_code as i64),
1396    );
1397    result.insert(
1398        crate::value::intern_key("legacy_status"),
1399        VmValue::Int(response.exit_code as i64),
1400    );
1401    result.insert(
1402        crate::value::intern_key("success"),
1403        VmValue::Bool(response.success),
1404    );
1405    VmValue::dict(result)
1406}
1407
1408fn resolve_process_exec_cwd(cwd: &str) -> std::path::PathBuf {
1409    crate::stdlib::process::resolve_source_relative_path(cwd)
1410}
1411
1412fn process_exec_argv(params: &crate::value::DictMap) -> Result<(String, Vec<String>), VmError> {
1413    match optional_string(params, "mode")
1414        .as_deref()
1415        .unwrap_or("shell")
1416    {
1417        "argv" => {
1418            let argv = optional_string_list(params, "argv").ok_or_else(|| {
1419                VmError::Runtime("host_call process.exec missing argv".to_string())
1420            })?;
1421            split_argv(argv)
1422        }
1423        "shell" => {
1424            let command = require_param(params, "command")?;
1425            let mut invocation_params = params.clone();
1426            invocation_params.put_str("command", command);
1427            let invocation =
1428                crate::shells::resolve_invocation_from_vm_params(&invocation_params)
1429                    .map_err(|err| VmError::Runtime(format!("host_call process.exec: {err}")))?;
1430            Ok((invocation.program, invocation.args))
1431        }
1432        other => Err(VmError::Runtime(format!(
1433            "host_call process.exec unsupported mode {other:?}"
1434        ))),
1435    }
1436}
1437
1438fn split_argv(mut argv: Vec<String>) -> Result<(String, Vec<String>), VmError> {
1439    if argv.is_empty() {
1440        return Err(VmError::Runtime(
1441            "host_call process.exec argv must not be empty".to_string(),
1442        ));
1443    }
1444    let program = argv.remove(0);
1445    if program.is_empty() {
1446        return Err(VmError::Runtime(
1447            "host_call process.exec argv[0] must not be empty".to_string(),
1448        ));
1449    }
1450    Ok((program, argv))
1451}
1452
1453/// Push a transient policy onto the execution stack with the
1454/// requested sandbox profile, returning a guard that pops on drop.
1455/// Used by `host_call("process", "exec", ...)` to honor a per-call
1456/// `sandbox_profile` override without rewriting the surrounding
1457/// orchestration policy.
1458pub(crate) fn push_sandbox_profile_override(value: &str) -> Result<SandboxProfileGuard, VmError> {
1459    let profile = crate::orchestration::SandboxProfile::parse(value).ok_or_else(|| {
1460        VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
1461            "host_call process.exec: unknown sandbox_profile {value:?}; expected one of \"unrestricted\", \"worktree\", \"os_hardened\", \"wasi\""
1462        ))))
1463    })?;
1464    let mut policy = crate::orchestration::current_execution_policy().unwrap_or_default();
1465    policy.sandbox_profile = profile;
1466    crate::orchestration::push_execution_policy(policy);
1467    Ok(SandboxProfileGuard {
1468        _private: std::marker::PhantomData,
1469    })
1470}
1471
1472pub(crate) struct SandboxProfileGuard {
1473    _private: std::marker::PhantomData<*const ()>,
1474}
1475
1476impl Drop for SandboxProfileGuard {
1477    fn drop(&mut self) {
1478        crate::orchestration::pop_execution_policy();
1479    }
1480}
1481
1482pub(crate) fn optional_i64(params: &crate::value::DictMap, key: &str) -> Option<i64> {
1483    match params.get(key) {
1484        Some(VmValue::Int(value)) => Some(*value),
1485        Some(VmValue::Float(value)) if value.fract() == 0.0 => Some(*value as i64),
1486        _ => None,
1487    }
1488}
1489
1490pub(crate) fn optional_string(params: &crate::value::DictMap, key: &str) -> Option<String> {
1491    params.get(key).and_then(vm_string).map(ToString::to_string)
1492}
1493
1494fn optional_string_list(params: &crate::value::DictMap, key: &str) -> Option<Vec<String>> {
1495    let VmValue::List(values) = params.get(key)? else {
1496        return None;
1497    };
1498    values
1499        .iter()
1500        .map(|value| vm_string(value).map(ToString::to_string))
1501        .collect()
1502}
1503
1504fn optional_string_dict(
1505    params: &crate::value::DictMap,
1506    key: &str,
1507) -> Result<Option<BTreeMap<String, String>>, VmError> {
1508    let Some(value) = params.get(key) else {
1509        return Ok(None);
1510    };
1511    let Some(dict) = value.as_dict() else {
1512        return Err(VmError::Runtime(format!(
1513            "host_call process.exec {key} must be a dict"
1514        )));
1515    };
1516    let mut out = std::collections::BTreeMap::new();
1517    for (key, value) in dict.iter() {
1518        let Some(value) = vm_string(value) else {
1519            return Err(VmError::Runtime(format!(
1520                "host_call process.exec env value for {key:?} must be a string"
1521            )));
1522        };
1523        out.insert(key.to_string(), value.to_string());
1524    }
1525    Ok(Some(out))
1526}
1527
1528fn vm_string(value: &VmValue) -> Option<&str> {
1529    match value {
1530        VmValue::String(value) => Some(value.as_ref()),
1531        _ => None,
1532    }
1533}
1534
1535pub(crate) fn register_host_builtins(vm: &mut Vm) {
1536    for def in MODULE_BUILTINS {
1537        vm.register_builtin_def(def);
1538    }
1539}
1540
1541pub(crate) fn register_missing_host_builtins(vm: &mut Vm) {
1542    for def in MODULE_BUILTINS {
1543        if vm.builtin_metadata_for(def.sig.name).is_none() {
1544            vm.register_builtin_def(def);
1545        }
1546    }
1547}
1548
1549#[harn_builtin(
1550    sig = "host_mock(capability: string, op: string, response_or_config?: any, params?: dict) -> nil",
1551    category = "host"
1552)]
1553fn host_mock_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1554    let host_mock = parse_host_mock(args)?;
1555    validate_host_mock_registration(&host_mock)?;
1556    push_host_mock(host_mock);
1557    Ok(VmValue::Nil)
1558}
1559
1560#[harn_builtin(sig = "host_mock_clear() -> nil", category = "host")]
1561fn host_mock_clear_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1562    reset_host_state();
1563    Ok(VmValue::Nil)
1564}
1565
1566#[harn_builtin(sig = "host_mock_calls() -> list", category = "host")]
1567fn host_mock_calls_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1568    let calls = HOST_MOCK_CALLS.with(|calls| {
1569        calls
1570            .borrow()
1571            .iter()
1572            .map(mock_call_value)
1573            .collect::<Vec<_>>()
1574    });
1575    Ok(VmValue::List(std::sync::Arc::new(calls)))
1576}
1577
1578#[harn_builtin(sig = "host_mock_push_scope() -> nil", category = "host")]
1579fn host_mock_push_scope_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1580    push_host_mock_scope();
1581    Ok(VmValue::Nil)
1582}
1583
1584#[harn_builtin(sig = "host_mock_pop_scope() -> nil", category = "host")]
1585fn host_mock_pop_scope_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1586    if !pop_host_mock_scope() {
1587        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1588            "host_mock_pop_scope: no scope to pop",
1589        ))));
1590    }
1591    Ok(VmValue::Nil)
1592}
1593
1594#[harn_builtin(sig = "host_capabilities() -> dict", category = "host")]
1595fn host_capabilities_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1596    Ok(capability_manifest_with_mocks())
1597}
1598
1599#[harn_builtin(
1600    sig = "host_has(capability: string, op?: string) -> bool",
1601    category = "host"
1602)]
1603fn host_has_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1604    let capability = args.first().map(|a| a.display()).unwrap_or_default();
1605    let operation = args.get(1).map(|a| a.display());
1606    let manifest = capability_manifest_with_mocks();
1607    let has = manifest
1608        .as_dict()
1609        .and_then(|d| d.get(capability.as_str()))
1610        .and_then(|v| v.as_dict())
1611        .is_some_and(|cap| {
1612            if let Some(operation) = operation {
1613                cap.get("ops")
1614                    .and_then(|v| match v {
1615                        VmValue::List(list) => {
1616                            Some(list.iter().any(|item| item.display() == operation))
1617                        }
1618                        _ => None,
1619                    })
1620                    .unwrap_or(false)
1621            } else {
1622                true
1623            }
1624        });
1625    Ok(VmValue::Bool(has))
1626}
1627
1628#[harn_builtin(
1629    sig = "host_call(name: string, args?: dict) -> any",
1630    kind = "async",
1631    category = "host"
1632)]
1633async fn host_call_builtin(
1634    ctx: crate::vm::AsyncBuiltinCtx,
1635    args: Vec<VmValue>,
1636) -> Result<VmValue, VmError> {
1637    let name = args.first().map(|a| a.display()).unwrap_or_default();
1638    let params = args
1639        .get(1)
1640        .and_then(|a| a.as_dict())
1641        .cloned()
1642        .unwrap_or_default();
1643    let Some((capability, operation)) = name.split_once('.') else {
1644        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1645            format!("host_call: unsupported operation name '{name}'"),
1646        ))));
1647    };
1648    dispatch_host_operation_with_ctx(Some(&ctx), capability, operation, &params).await
1649}
1650
1651#[harn_builtin(sig = "host_tool_list() -> list", kind = "async", category = "host")]
1652async fn host_tool_list_builtin(
1653    ctx: crate::vm::AsyncBuiltinCtx,
1654    _args: Vec<VmValue>,
1655) -> Result<VmValue, VmError> {
1656    dispatch_host_tool_list_with_ctx(Some(&ctx)).await
1657}
1658
1659#[harn_builtin(
1660    sig = "host_tool_call(name: string, args?: any) -> any",
1661    kind = "async",
1662    category = "host"
1663)]
1664async fn host_tool_call_builtin(
1665    ctx: crate::vm::AsyncBuiltinCtx,
1666    args: Vec<VmValue>,
1667) -> Result<VmValue, VmError> {
1668    let name = args.first().map(|a| a.display()).unwrap_or_default();
1669    if name.is_empty() {
1670        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1671            "host_tool_call: tool name is required",
1672        ))));
1673    }
1674    let call_args = args.get(1).cloned().unwrap_or(VmValue::Nil);
1675    dispatch_host_tool_call_with_ctx(Some(&ctx), &name, &call_args).await
1676}
1677
1678#[cfg(test)]
1679mod tests {
1680    use super::{
1681        build_sandboxed_command, capability_manifest_with_mocks, clear_host_call_bridge,
1682        dispatch_host_operation, dispatch_host_tool_call, dispatch_host_tool_list,
1683        dispatch_mock_host_call, dispatch_mock_hostlib_call, host_has_builtin,
1684        host_mock_clear_builtin, parse_host_mock, push_host_mock, register_mockable_host_operation,
1685        register_scoped_mockable_host_operation, reset_host_state, reset_scoped_host_state,
1686        resolve_process_exec_cwd, set_host_call_bridge, validate_host_mock_registration,
1687        HostCallBridge, HostMock,
1688    };
1689    use crate::value::VmDictExt;
1690
1691    use std::sync::{
1692        atomic::{AtomicUsize, Ordering},
1693        Arc,
1694    };
1695
1696    use crate::value::{VmError, VmValue};
1697
1698    /// Collect a built command's env mutations as `(name, Option<value>)`,
1699    /// where `None` marks a variable the command removes from the inherited
1700    /// environment.
1701    fn command_env(
1702        cmd: &tokio::process::Command,
1703    ) -> std::collections::BTreeMap<String, Option<String>> {
1704        cmd.as_std()
1705            .get_envs()
1706            .map(|(k, v)| {
1707                (
1708                    k.to_string_lossy().into_owned(),
1709                    v.map(|value| value.to_string_lossy().into_owned()),
1710                )
1711            })
1712            .collect()
1713    }
1714
1715    #[test]
1716    fn build_sandboxed_command_forces_deterministic_message_locale() {
1717        // A verify command spawned by a non-Anglosphere user whose *shell*
1718        // exports LC_ALL (inherited via the parent env, NOT pinned by the
1719        // caller's `env` dict) must still emit English diagnostics, or the
1720        // downstream English-keyed matchers (syntax repair, error grounding,
1721        // pass/fail classification) misfire. In merge mode the child inherits
1722        // the parent env implicitly, so the builder must issue an explicit
1723        // LC_ALL removal — observable here as a `(key, None)` mutation — and
1724        // pin LC_MESSAGES=C + DOTNET_CLI_UI_LANGUAGE=en. The caller pins no
1725        // locale key here, so the overlay engages.
1726        let mut params = crate::value::DictMap::new();
1727        params.put_str("mode", "argv");
1728        params.put(
1729            "argv",
1730            VmValue::List(Arc::new(vec![VmValue::string("/bin/true")])),
1731        );
1732        params.put_str("env_mode", "merge");
1733        let mut caller_env = crate::value::DictMap::new();
1734        // An innocuous caller env key that must NOT suppress the locale overlay.
1735        caller_env.put_str("CARGO_TARGET_DIR", "/tmp/target");
1736        params.put("env", VmValue::dict_map(caller_env));
1737
1738        let cmd = build_sandboxed_command(&params, "process.exec").expect("build command");
1739        let env = command_env(&cmd);
1740
1741        assert_eq!(
1742            env.get("LC_ALL"),
1743            Some(&None),
1744            "the builder must remove LC_ALL from the child so an inherited shell \
1745             value cannot override the forced LC_MESSAGES"
1746        );
1747        assert_eq!(
1748            env.get("LC_MESSAGES"),
1749            Some(&Some("C".to_string())),
1750            "LC_MESSAGES must be pinned to C for untranslated (English) tool output"
1751        );
1752        assert_eq!(
1753            env.get("DOTNET_CLI_UI_LANGUAGE"),
1754            Some(&Some("en".to_string())),
1755            ".NET ignores LC_* and needs its own UI-language override"
1756        );
1757    }
1758
1759    #[test]
1760    fn build_sandboxed_command_respects_a_caller_pinned_locale() {
1761        // A caller that explicitly pins the locale keys (or LC_ALL) wins over
1762        // the deterministic overlay — same caller-wins rule as TMPDIR.
1763        let mut params = crate::value::DictMap::new();
1764        params.put_str("mode", "argv");
1765        params.put(
1766            "argv",
1767            VmValue::List(Arc::new(vec![VmValue::string("/bin/true")])),
1768        );
1769        params.put_str("env_mode", "merge");
1770        let mut caller_env = crate::value::DictMap::new();
1771        caller_env.put_str("LC_ALL", "fr_FR.UTF-8");
1772        caller_env.put_str("LC_MESSAGES", "fr_FR.UTF-8");
1773        params.put("env", VmValue::dict_map(caller_env));
1774
1775        let cmd = build_sandboxed_command(&params, "process.exec").expect("build command");
1776        let env = command_env(&cmd);
1777
1778        assert_eq!(
1779            env.get("LC_ALL"),
1780            Some(&Some("fr_FR.UTF-8".to_string())),
1781            "a caller that pins LC_ALL keeps it — the overlay must not strip an explicit value"
1782        );
1783        assert_eq!(
1784            env.get("LC_MESSAGES"),
1785            Some(&Some("fr_FR.UTF-8".to_string())),
1786            "a caller-pinned LC_MESSAGES wins over the C overlay"
1787        );
1788    }
1789
1790    #[test]
1791    fn process_exec_relative_cwd_resolves_against_execution_root() {
1792        let dir = tempfile::tempdir().expect("tempdir");
1793        crate::stdlib::process::set_thread_execution_context(Some(
1794            crate::orchestration::RunExecutionRecord {
1795                cwd: Some(dir.path().to_string_lossy().into_owned()),
1796                project_root: None,
1797                source_dir: Some(dir.path().join("src").to_string_lossy().into_owned()),
1798                env: std::collections::BTreeMap::new(),
1799                adapter: None,
1800                repo_path: None,
1801                worktree_path: None,
1802                branch: None,
1803                base_ref: None,
1804                cleanup: None,
1805                grants: Vec::new(),
1806            },
1807        ));
1808
1809        assert_eq!(
1810            resolve_process_exec_cwd("subdir"),
1811            dir.path().join("subdir")
1812        );
1813
1814        crate::stdlib::process::set_thread_execution_context(None);
1815    }
1816
1817    #[test]
1818    fn workspace_project_root_fallback_prefers_execution_context_project_root() {
1819        run_host_async_test(|| async {
1820            let project = tempfile::tempdir().expect("project root");
1821            let cwd = tempfile::tempdir().expect("cwd");
1822            crate::stdlib::process::set_thread_execution_context(Some(
1823                crate::orchestration::RunExecutionRecord {
1824                    cwd: Some(cwd.path().to_string_lossy().into_owned()),
1825                    project_root: Some(project.path().to_string_lossy().into_owned()),
1826                    source_dir: None,
1827                    env: std::collections::BTreeMap::new(),
1828                    adapter: None,
1829                    repo_path: None,
1830                    worktree_path: None,
1831                    branch: None,
1832                    base_ref: None,
1833                    cleanup: None,
1834                    grants: Vec::new(),
1835                },
1836            ));
1837
1838            let result =
1839                dispatch_host_operation("workspace", "project_root", &crate::value::DictMap::new())
1840                    .await
1841                    .expect("workspace.project_root result");
1842
1843            crate::stdlib::process::set_thread_execution_context(None);
1844            assert_eq!(result.display(), project.path().display().to_string());
1845        });
1846    }
1847
1848    #[test]
1849    fn manifest_includes_operation_metadata() {
1850        let manifest = capability_manifest_with_mocks();
1851        let process = manifest
1852            .as_dict()
1853            .and_then(|d| d.get("process"))
1854            .and_then(|v| v.as_dict())
1855            .expect("process capability");
1856        assert!(process.get("description").is_some());
1857        let operations = process
1858            .get("operations")
1859            .and_then(|v| v.as_dict())
1860            .expect("operations dict");
1861        assert!(operations.get("exec").is_some());
1862    }
1863
1864    #[test]
1865    fn mocked_capabilities_appear_in_manifest() {
1866        reset_host_state();
1867        push_host_mock(HostMock {
1868            capability: "project".to_string(),
1869            operation: "metadata_get".to_string(),
1870            params: None,
1871            result: Some(VmValue::dict(crate::value::DictMap::new())),
1872            error: None,
1873            unregistered_ok: false,
1874        });
1875        let manifest = capability_manifest_with_mocks();
1876        let project = manifest
1877            .as_dict()
1878            .and_then(|d| d.get("project"))
1879            .and_then(|v| v.as_dict())
1880            .expect("project capability");
1881        let operations = project
1882            .get("operations")
1883            .and_then(|v| v.as_dict())
1884            .expect("operations dict");
1885        assert!(operations.get("metadata_get").is_some());
1886        reset_host_state();
1887    }
1888
1889    #[test]
1890    fn mock_host_call_matches_partial_params_and_overrides_order() {
1891        reset_host_state();
1892        let mut exact_params = crate::value::DictMap::new();
1893        exact_params.put_str("namespace", "facts");
1894        push_host_mock(HostMock {
1895            capability: "project".to_string(),
1896            operation: "metadata_get".to_string(),
1897            params: None,
1898            result: Some(VmValue::String(arcstr::ArcStr::from("fallback"))),
1899            error: None,
1900            unregistered_ok: false,
1901        });
1902        push_host_mock(HostMock {
1903            capability: "project".to_string(),
1904            operation: "metadata_get".to_string(),
1905            params: Some(exact_params),
1906            result: Some(VmValue::String(arcstr::ArcStr::from("facts"))),
1907            error: None,
1908            unregistered_ok: false,
1909        });
1910
1911        let mut call_params = crate::value::DictMap::new();
1912        call_params.put_str("dir", "pkg");
1913        call_params.put_str("namespace", "facts");
1914        let exact = dispatch_mock_host_call("project", "metadata_get", &call_params)
1915            .expect("expected exact mock")
1916            .expect("exact mock should succeed");
1917        assert_eq!(exact.display(), "facts");
1918
1919        call_params.put_str("namespace", "classification");
1920        let fallback = dispatch_mock_host_call("project", "metadata_get", &call_params)
1921            .expect("expected fallback mock")
1922            .expect("fallback mock should succeed");
1923        assert_eq!(fallback.display(), "fallback");
1924        reset_host_state();
1925    }
1926
1927    #[test]
1928    fn mock_host_call_can_throw_errors() {
1929        reset_host_state();
1930        push_host_mock(HostMock {
1931            capability: "project".to_string(),
1932            operation: "metadata_get".to_string(),
1933            params: None,
1934            result: None,
1935            error: Some("boom".to_string()),
1936            unregistered_ok: false,
1937        });
1938        let params = crate::value::DictMap::new();
1939        let result = dispatch_mock_host_call("project", "metadata_get", &params)
1940            .expect("expected mock result");
1941        match result {
1942            Err(VmError::Thrown(VmValue::String(message))) => assert_eq!(message.as_str(), "boom"),
1943            other => panic!("unexpected result: {other:?}"),
1944        }
1945        reset_host_state();
1946    }
1947
1948    #[test]
1949    fn host_mock_registration_rejects_unknown_operations_by_default() {
1950        let host_mock = HostMock {
1951            capability: "runtime".to_string(),
1952            operation: "tas".to_string(),
1953            params: None,
1954            result: Some(VmValue::Nil),
1955            error: None,
1956            unregistered_ok: false,
1957        };
1958        let error = validate_host_mock_registration(&host_mock)
1959            .expect_err("unknown host operation should fail at registration");
1960        match error {
1961            VmError::Thrown(VmValue::String(message)) => {
1962                assert!(message.contains("runtime.tas"));
1963                assert!(message.contains("unregistered_ok"));
1964                assert!(message.contains("runtime.task"));
1965            }
1966            other => panic!("unexpected error: {other:?}"),
1967        }
1968    }
1969
1970    #[test]
1971    fn host_mock_registration_allows_explicit_test_local_operations() {
1972        let host_mock = HostMock {
1973            capability: "synthetic".to_string(),
1974            operation: "op".to_string(),
1975            params: None,
1976            result: Some(VmValue::Nil),
1977            error: None,
1978            unregistered_ok: true,
1979        };
1980        validate_host_mock_registration(&host_mock)
1981            .expect("explicit unregistered_ok should permit synthetic mocks");
1982    }
1983
1984    #[test]
1985    fn host_mock_registration_accepts_runtime_registered_operations() {
1986        register_mockable_host_operation(
1987            "code_index",
1988            "stats",
1989            "Hostlib schema-backed operation registered at runtime.",
1990        );
1991        let host_mock = HostMock {
1992            capability: "code_index".to_string(),
1993            operation: "stats".to_string(),
1994            params: None,
1995            result: Some(VmValue::Nil),
1996            error: None,
1997            unregistered_ok: false,
1998        };
1999        validate_host_mock_registration(&host_mock)
2000            .expect("registered hostlib operations should be mockable");
2001    }
2002
2003    #[test]
2004    fn clearing_live_mocks_preserves_scoped_manifest_declarations() {
2005        reset_scoped_host_state();
2006        register_scoped_mockable_host_operation(
2007            "scoped_clear_fixture",
2008            "answer",
2009            "Test-scoped manifest declaration.",
2010        );
2011        let host_mock = HostMock {
2012            capability: "scoped_clear_fixture".to_string(),
2013            operation: "answer".to_string(),
2014            params: None,
2015            result: Some(VmValue::Nil),
2016            error: None,
2017            unregistered_ok: false,
2018        };
2019
2020        validate_host_mock_registration(&host_mock).expect("scoped declaration is registered");
2021        host_mock_clear_builtin(&[], &mut String::new()).expect("clear live mocks");
2022        validate_host_mock_registration(&host_mock)
2023            .expect("clearing live mocks must preserve manifest declarations");
2024        reset_scoped_host_state();
2025    }
2026
2027    #[tokio::test]
2028    async fn declared_mockable_operation_is_not_reported_as_callable() {
2029        std::thread::spawn(|| {
2030            register_mockable_host_operation(
2031                "async_host_registration",
2032                "cross_thread",
2033                "Embedding operation registered before async worker migration.",
2034            );
2035        })
2036        .join()
2037        .expect("registration worker should finish");
2038
2039        std::thread::spawn(|| {
2040            let host_mock = HostMock {
2041                capability: "async_host_registration".to_string(),
2042                operation: "cross_thread".to_string(),
2043                params: None,
2044                result: Some(VmValue::Nil),
2045                error: None,
2046                unregistered_ok: false,
2047            };
2048            validate_host_mock_registration(&host_mock)
2049                .expect("process host registration should be visible after worker migration");
2050
2051            let typo = HostMock {
2052                operation: "cross_tread".to_string(),
2053                ..host_mock
2054            };
2055            validate_host_mock_registration(&typo)
2056                .expect_err("an undeclared operation should still fail closed");
2057        })
2058        .join()
2059        .expect("validation worker should finish");
2060
2061        assert!(matches!(
2062            host_has_builtin(
2063                &[
2064                    VmValue::string("async_host_registration"),
2065                    VmValue::string("cross_thread"),
2066                ],
2067                &mut String::new(),
2068            )
2069            .expect("host_has should succeed"),
2070            VmValue::Bool(false)
2071        ));
2072        dispatch_host_operation(
2073            "async_host_registration",
2074            "cross_thread",
2075            &crate::value::DictMap::new(),
2076        )
2077        .await
2078        .expect_err("an unmocked declaration must remain unsupported at dispatch");
2079    }
2080
2081    #[test]
2082    fn host_mock_parse_preserves_unregistered_ok_config() {
2083        let config = VmValue::dict(crate::value::DictMap::from_iter([
2084            (crate::value::intern_key("result"), VmValue::string("ok")),
2085            (
2086                crate::value::intern_key("unregistered_ok"),
2087                VmValue::Bool(true),
2088            ),
2089        ]));
2090        let host_mock =
2091            parse_host_mock(&[VmValue::string("synthetic"), VmValue::string("op"), config])
2092                .expect("parse host mock config");
2093        assert!(host_mock.unregistered_ok);
2094    }
2095
2096    #[test]
2097    fn hostlib_mock_dispatch_matches_module_method_and_params() {
2098        reset_host_state();
2099        let mut mock_params = crate::value::DictMap::new();
2100        mock_params.put(
2101            "argv",
2102            VmValue::List(Arc::new(vec![VmValue::string("echo")])),
2103        );
2104        push_host_mock(HostMock {
2105            capability: "tools".to_string(),
2106            operation: "run_command".to_string(),
2107            params: Some(mock_params),
2108            result: Some(VmValue::String(arcstr::ArcStr::from("direct"))),
2109            error: None,
2110            unregistered_ok: false,
2111        });
2112
2113        let mut call_params = crate::value::DictMap::new();
2114        call_params.put(
2115            "argv",
2116            VmValue::List(Arc::new(vec![VmValue::string("echo")])),
2117        );
2118        call_params.put_str("cwd", "/tmp/not-used");
2119        let value = dispatch_mock_hostlib_call("tools", "run_command", &call_params)
2120            .expect("expected hostlib mock")
2121            .expect("hostlib mock should succeed");
2122        assert_eq!(value.display(), "direct");
2123        reset_host_state();
2124    }
2125
2126    #[test]
2127    fn hostlib_run_command_falls_back_to_process_exec_mocks() {
2128        reset_host_state();
2129        let mut mock_params = crate::value::DictMap::new();
2130        mock_params.put(
2131            "argv",
2132            VmValue::List(Arc::new(vec![
2133                VmValue::string("cargo"),
2134                VmValue::string("test"),
2135            ])),
2136        );
2137        push_host_mock(HostMock {
2138            capability: "process".to_string(),
2139            operation: "exec".to_string(),
2140            params: Some(mock_params),
2141            result: Some(VmValue::String(arcstr::ArcStr::from("legacy"))),
2142            error: None,
2143            unregistered_ok: false,
2144        });
2145
2146        let mut call_params = crate::value::DictMap::new();
2147        call_params.put(
2148            "argv",
2149            VmValue::List(Arc::new(vec![
2150                VmValue::string("cargo"),
2151                VmValue::string("test"),
2152            ])),
2153        );
2154        call_params.put_str("cwd", "/tmp/not-used");
2155        let value = dispatch_mock_hostlib_call("tools", "run_command", &call_params)
2156            .expect("expected legacy process.exec mock")
2157            .expect("legacy mock should succeed");
2158        assert_eq!(value.display(), "legacy");
2159        reset_host_state();
2160    }
2161
2162    #[test]
2163    fn hostlib_run_command_prefers_exact_mock_over_process_exec_alias() {
2164        reset_host_state();
2165        let mut params = crate::value::DictMap::new();
2166        params.put(
2167            "argv",
2168            VmValue::List(Arc::new(vec![
2169                VmValue::string("npm"),
2170                VmValue::string("test"),
2171            ])),
2172        );
2173        push_host_mock(HostMock {
2174            capability: "process".to_string(),
2175            operation: "exec".to_string(),
2176            params: Some(params.clone()),
2177            result: Some(VmValue::String(arcstr::ArcStr::from("legacy"))),
2178            error: None,
2179            unregistered_ok: false,
2180        });
2181        push_host_mock(HostMock {
2182            capability: "tools".to_string(),
2183            operation: "run_command".to_string(),
2184            params: Some(params.clone()),
2185            result: Some(VmValue::String(arcstr::ArcStr::from("direct"))),
2186            error: None,
2187            unregistered_ok: false,
2188        });
2189
2190        let value = dispatch_mock_hostlib_call("tools", "run_command", &params)
2191            .expect("expected exact hostlib mock")
2192            .expect("exact mock should succeed");
2193        assert_eq!(value.display(), "direct");
2194        reset_host_state();
2195    }
2196
2197    #[derive(Default)]
2198    struct TestHostToolBridge;
2199
2200    impl HostCallBridge for TestHostToolBridge {
2201        fn dispatch(
2202            &self,
2203            _capability: &str,
2204            _operation: &str,
2205            _params: &crate::value::DictMap,
2206        ) -> Result<Option<VmValue>, VmError> {
2207            Ok(None)
2208        }
2209
2210        fn list_tools(&self) -> Result<Option<VmValue>, VmError> {
2211            let tool = VmValue::dict(crate::value::DictMap::from_iter([
2212                (
2213                    crate::value::intern_key("name"),
2214                    VmValue::String(arcstr::ArcStr::from("Read".to_string())),
2215                ),
2216                (
2217                    crate::value::intern_key("description"),
2218                    VmValue::String(arcstr::ArcStr::from(
2219                        "Read a file from the host".to_string(),
2220                    )),
2221                ),
2222                (
2223                    crate::value::intern_key("schema"),
2224                    VmValue::dict(crate::value::DictMap::from_iter([(
2225                        crate::value::intern_key("type"),
2226                        VmValue::String(arcstr::ArcStr::from("object".to_string())),
2227                    )])),
2228                ),
2229                (crate::value::intern_key("deprecated"), VmValue::Bool(false)),
2230            ]));
2231            Ok(Some(VmValue::List(std::sync::Arc::new(vec![tool]))))
2232        }
2233
2234        fn call_tool(&self, name: &str, args: &VmValue) -> Result<Option<VmValue>, VmError> {
2235            if name != "Read" {
2236                return Ok(None);
2237            }
2238            let path = args
2239                .as_dict()
2240                .and_then(|dict| dict.get("path"))
2241                .map(|value| value.display())
2242                .unwrap_or_default();
2243            Ok(Some(VmValue::String(arcstr::ArcStr::from(format!(
2244                "read:{path}"
2245            )))))
2246        }
2247    }
2248
2249    struct CountingProcessExecBridge {
2250        calls: Arc<AtomicUsize>,
2251    }
2252
2253    impl HostCallBridge for CountingProcessExecBridge {
2254        fn dispatch(
2255            &self,
2256            capability: &str,
2257            operation: &str,
2258            _params: &crate::value::DictMap,
2259        ) -> Result<Option<VmValue>, VmError> {
2260            if (capability, operation) != ("process", "exec") {
2261                return Ok(None);
2262            }
2263            self.calls.fetch_add(1, Ordering::SeqCst);
2264            Ok(Some(VmValue::dict(crate::value::DictMap::from_iter([
2265                (
2266                    crate::value::intern_key("status"),
2267                    VmValue::String(arcstr::ArcStr::from("completed".to_string())),
2268                ),
2269                (crate::value::intern_key("exit_code"), VmValue::Int(0)),
2270                (crate::value::intern_key("success"), VmValue::Bool(true)),
2271            ]))))
2272        }
2273    }
2274
2275    fn run_host_async_test<F, Fut>(test: F)
2276    where
2277        F: FnOnce() -> Fut,
2278        Fut: std::future::Future<Output = ()>,
2279    {
2280        let rt = tokio::runtime::Builder::new_current_thread()
2281            .enable_all()
2282            .build()
2283            .expect("runtime");
2284        rt.block_on(async {
2285            let local = tokio::task::LocalSet::new();
2286            local.run_until(test()).await;
2287        });
2288    }
2289
2290    #[test]
2291    fn host_tool_list_uses_installed_host_call_bridge() {
2292        run_host_async_test(|| async {
2293            reset_host_state();
2294            set_host_call_bridge(Arc::new(TestHostToolBridge));
2295            let tools = dispatch_host_tool_list().await.expect("tool list");
2296            clear_host_call_bridge();
2297
2298            let VmValue::List(items) = tools else {
2299                panic!("expected tool list");
2300            };
2301            assert_eq!(items.len(), 1);
2302            let tool = items[0].as_dict().expect("tool dict");
2303            assert_eq!(tool.get("name").unwrap().display(), "Read");
2304            assert_eq!(tool.get("deprecated").unwrap().display(), "false");
2305        });
2306    }
2307
2308    #[test]
2309    fn host_tool_call_uses_installed_host_call_bridge() {
2310        run_host_async_test(|| async {
2311            set_host_call_bridge(Arc::new(TestHostToolBridge));
2312            let args = VmValue::dict(crate::value::DictMap::from_iter([(
2313                crate::value::intern_key("path"),
2314                VmValue::String(arcstr::ArcStr::from("README.md".to_string())),
2315            )]));
2316            let value = dispatch_host_tool_call("Read", &args)
2317                .await
2318                .expect("tool call");
2319            clear_host_call_bridge();
2320            assert_eq!(value.display(), "read:README.md");
2321        });
2322    }
2323
2324    #[test]
2325    fn process_exec_bridge_is_gated_by_command_policy() {
2326        run_host_async_test(|| async {
2327            crate::orchestration::clear_command_policies();
2328            let calls = Arc::new(AtomicUsize::new(0));
2329            set_host_call_bridge(Arc::new(CountingProcessExecBridge {
2330                calls: calls.clone(),
2331            }));
2332            crate::orchestration::push_command_policy(crate::orchestration::CommandPolicy {
2333                tools: vec!["run".to_string()],
2334                workspace_roots: Vec::new(),
2335                default_shell_mode: "shell".to_string(),
2336                deny_patterns: vec!["cat *".to_string()],
2337                require_approval: Default::default(),
2338                deny_labels: Default::default(),
2339                pre: None,
2340                post: None,
2341                consent: None,
2342                allow_recursive: false,
2343            });
2344
2345            let result = dispatch_host_operation(
2346                "process",
2347                "exec",
2348                &crate::value::DictMap::from_iter([
2349                    (
2350                        crate::value::intern_key("mode"),
2351                        VmValue::String(arcstr::ArcStr::from("shell")),
2352                    ),
2353                    (
2354                        crate::value::intern_key("command"),
2355                        VmValue::String(arcstr::ArcStr::from("cat Cargo.toml")),
2356                    ),
2357                ]),
2358            )
2359            .await
2360            .expect("process.exec result");
2361
2362            crate::orchestration::clear_command_policies();
2363            clear_host_call_bridge();
2364
2365            assert_eq!(
2366                calls.load(Ordering::SeqCst),
2367                0,
2368                "blocked command must not reach host bridge"
2369            );
2370            let result = result.as_dict().expect("blocked result dict");
2371            assert_eq!(result.get("status").unwrap().display(), "blocked");
2372            assert!(
2373                result
2374                    .get("reason")
2375                    .map(VmValue::display)
2376                    .unwrap_or_default()
2377                    .contains("cat *"),
2378                "blocked result should name the matched policy pattern"
2379            );
2380        });
2381    }
2382
2383    #[cfg(unix)]
2384    async fn process_exec_env_probe(env: VmValue, env_mode: Option<&str>) -> (String, String) {
2385        // Run `sh -c 'printf "%s|%s" "$PARENT_VAR" "$CHILD_VAR"'` so we can
2386        // observe whether an inherited parent var survives alongside the
2387        // explicitly-provided child var. The parent var is set on this
2388        // process's environment immediately before the spawn.
2389        std::env::set_var("PARENT_VAR", "inherited");
2390        let mut params = crate::value::DictMap::from_iter([
2391            (
2392                crate::value::intern_key("mode"),
2393                VmValue::String(arcstr::ArcStr::from("argv")),
2394            ),
2395            (
2396                crate::value::intern_key("argv"),
2397                VmValue::List(std::sync::Arc::new(vec![
2398                    // Absolute path so the spawn does not depend on PATH,
2399                    // which the `replace` case intentionally clears.
2400                    VmValue::String(arcstr::ArcStr::from("/bin/sh")),
2401                    VmValue::String(arcstr::ArcStr::from("-c")),
2402                    VmValue::String(arcstr::ArcStr::from(
2403                        "printf '%s|%s' \"$PARENT_VAR\" \"$CHILD_VAR\"",
2404                    )),
2405                ])),
2406            ),
2407            (crate::value::intern_key("env"), env),
2408        ]);
2409        if let Some(mode) = env_mode {
2410            params.put_str("env_mode", mode);
2411        }
2412        let result = super::dispatch_process_exec(&params, serde_json::Value::Null)
2413            .await
2414            .expect("process.exec result");
2415        let dict = result.as_dict().expect("result dict");
2416        let stdout = dict.get("stdout").map(VmValue::display).unwrap_or_default();
2417        std::env::remove_var("PARENT_VAR");
2418        let (parent, child) = stdout.split_once('|').unwrap_or((&stdout, ""));
2419        (parent.to_string(), child.to_string())
2420    }
2421
2422    #[cfg(unix)]
2423    #[test]
2424    fn process_exec_env_default_merges_with_parent() {
2425        run_host_async_test(|| async {
2426            // No `env_mode`: the provided key must be added WITHOUT clearing
2427            // the inherited parent environment (the env-clear footgun fix).
2428            let child_env = VmValue::dict(crate::value::DictMap::from_iter([(
2429                crate::value::intern_key("CHILD_VAR"),
2430                VmValue::String(arcstr::ArcStr::from("provided")),
2431            )]));
2432            let (parent, child) = process_exec_env_probe(child_env, None).await;
2433            assert_eq!(
2434                parent, "inherited",
2435                "default env_mode must inherit parent env"
2436            );
2437            assert_eq!(
2438                child, "provided",
2439                "default env_mode must apply provided keys"
2440            );
2441        });
2442    }
2443
2444    #[cfg(unix)]
2445    #[test]
2446    fn process_exec_env_mode_replace_clears_parent() {
2447        run_host_async_test(|| async {
2448            // Explicit `replace`: the inherited parent var must be gone and
2449            // only the provided key survives. This preserves the ability to
2450            // fully replace the environment when intentionally requested.
2451            let child_env = VmValue::dict(crate::value::DictMap::from_iter([(
2452                crate::value::intern_key("CHILD_VAR"),
2453                VmValue::String(arcstr::ArcStr::from("provided")),
2454            )]));
2455            let (parent, child) = process_exec_env_probe(child_env, Some("replace")).await;
2456            assert_eq!(parent, "", "explicit replace must clear parent env");
2457            assert_eq!(
2458                child, "provided",
2459                "explicit replace must keep provided keys"
2460            );
2461        });
2462    }
2463
2464    #[cfg(unix)]
2465    #[test]
2466    fn process_exec_env_mode_unknown_is_rejected() {
2467        run_host_async_test(|| async {
2468            let params = crate::value::DictMap::from_iter([
2469                (
2470                    crate::value::intern_key("mode"),
2471                    VmValue::String(arcstr::ArcStr::from("argv")),
2472                ),
2473                (
2474                    crate::value::intern_key("argv"),
2475                    VmValue::List(std::sync::Arc::new(vec![VmValue::String(
2476                        arcstr::ArcStr::from("true"),
2477                    )])),
2478                ),
2479                (
2480                    crate::value::intern_key("env"),
2481                    VmValue::dict(crate::value::DictMap::from_iter([(
2482                        crate::value::intern_key("CHILD_VAR"),
2483                        VmValue::String(arcstr::ArcStr::from("x")),
2484                    )])),
2485                ),
2486                (
2487                    crate::value::intern_key("env_mode"),
2488                    VmValue::String(arcstr::ArcStr::from("bogus")),
2489                ),
2490            ]);
2491            let err = super::dispatch_process_exec(&params, serde_json::Value::Null)
2492                .await
2493                .expect_err("unknown env_mode must error");
2494            assert!(
2495                format!("{err:?}").contains("env_mode"),
2496                "error should name env_mode, got {err:?}"
2497            );
2498        });
2499    }
2500
2501    // Drive the real `host_call("process","exec")` builder under a restricted
2502    // policy and read back the `$TMPDIR` the child actually saw. This is the
2503    // agent-facing path; the assertion is OS-independent (it observes the
2504    // injected env, not OS-sandbox enforcement), so it pins the mechanism on
2505    // every CI host while the live OS-level link proof runs on tornadough.
2506    #[cfg(unix)]
2507    async fn process_exec_tmpdir_probe(
2508        workspace: &std::path::Path,
2509        caller_env: Option<VmValue>,
2510    ) -> String {
2511        let mut env_pairs = vec![(
2512            crate::value::intern_key("mode"),
2513            VmValue::String(arcstr::ArcStr::from("argv")),
2514        )];
2515        env_pairs.push((
2516            crate::value::intern_key("argv"),
2517            VmValue::List(std::sync::Arc::new(vec![
2518                VmValue::String(arcstr::ArcStr::from("/bin/sh")),
2519                VmValue::String(arcstr::ArcStr::from("-c")),
2520                VmValue::String(arcstr::ArcStr::from("printf '%s' \"$TMPDIR\"")),
2521            ])),
2522        ));
2523        if let Some(env) = caller_env {
2524            env_pairs.push((crate::value::intern_key("env"), env));
2525        }
2526        let params = crate::value::DictMap::from_iter(env_pairs);
2527
2528        crate::orchestration::push_execution_policy(crate::orchestration::CapabilityPolicy {
2529            sandbox_profile: crate::orchestration::SandboxProfile::Worktree,
2530            workspace_roots: vec![workspace.to_string_lossy().into_owned()],
2531            // Keep OS confinement out of this unit assertion regardless of host
2532            // Landlock/seatbelt availability; we are pinning the env injection,
2533            // not OS enforcement (which the tornadough run proves end-to-end).
2534            ..crate::orchestration::CapabilityPolicy::default()
2535        });
2536        std::env::set_var("HARN_HANDLER_SANDBOX", "off");
2537        let result = super::dispatch_process_exec(&params, serde_json::Value::Null)
2538            .await
2539            .expect("process.exec result");
2540        std::env::remove_var("HARN_HANDLER_SANDBOX");
2541        crate::orchestration::pop_execution_policy();
2542        result
2543            .as_dict()
2544            .and_then(|d| d.get("stdout"))
2545            .map(VmValue::display)
2546            .unwrap_or_default()
2547    }
2548
2549    #[cfg(unix)]
2550    #[test]
2551    fn process_exec_injects_workspace_local_tmpdir() {
2552        run_host_async_test(|| async {
2553            let workspace = tempfile::tempdir().expect("workspace");
2554            let tmpdir = process_exec_tmpdir_probe(workspace.path(), None).await;
2555
2556            assert!(
2557                !tmpdir.is_empty(),
2558                "sandboxed child must receive a non-empty TMPDIR"
2559            );
2560            let tmpdir_path = std::path::PathBuf::from(&tmpdir);
2561            let canonical_tmpdir = std::fs::canonicalize(&tmpdir_path)
2562                .expect("workspace-local TMPDIR should canonicalize");
2563            let canonical_workspace =
2564                std::fs::canonicalize(workspace.path()).expect("workspace should canonicalize");
2565            assert!(
2566                canonical_tmpdir.starts_with(&canonical_workspace),
2567                "child TMPDIR {tmpdir:?} must live inside the workspace {:?}",
2568                workspace.path()
2569            );
2570            assert!(
2571                tmpdir_path.ends_with(".harn-tmp"),
2572                "child TMPDIR {tmpdir:?} must be the workspace-local .harn-tmp dir"
2573            );
2574            assert!(
2575                tmpdir_path.is_dir(),
2576                "the workspace-local TMPDIR must have been created on disk"
2577            );
2578        });
2579    }
2580
2581    #[cfg(unix)]
2582    #[test]
2583    fn process_exec_respects_caller_pinned_tmpdir() {
2584        run_host_async_test(|| async {
2585            let workspace = tempfile::tempdir().expect("workspace");
2586            let caller_tmp = workspace.path().join("caller-chosen");
2587            std::fs::create_dir_all(&caller_tmp).unwrap();
2588            let caller_env = VmValue::dict(crate::value::DictMap::from_iter([(
2589                crate::value::intern_key("TMPDIR"),
2590                VmValue::String(arcstr::ArcStr::from(
2591                    caller_tmp.to_string_lossy().into_owned(),
2592                )),
2593            )]));
2594
2595            let tmpdir = process_exec_tmpdir_probe(workspace.path(), Some(caller_env)).await;
2596
2597            assert_eq!(
2598                std::path::PathBuf::from(&tmpdir),
2599                caller_tmp,
2600                "an explicit caller TMPDIR must override the workspace-local default"
2601            );
2602        });
2603    }
2604
2605    #[test]
2606    fn host_tool_list_is_empty_without_bridge() {
2607        run_host_async_test(|| async {
2608            clear_host_call_bridge();
2609            let tools = dispatch_host_tool_list().await.expect("tool list");
2610            let VmValue::List(items) = tools else {
2611                panic!("expected tool list");
2612            };
2613            assert!(items.is_empty());
2614        });
2615    }
2616}