Skip to main content

harn_vm/stdlib/
host.rs

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