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