Skip to main content

harn_vm/stdlib/
process.rs

1use crate::value::VmDictExt;
2use std::cell::RefCell;
3use std::collections::BTreeMap;
4use std::io::Write as _;
5use std::path::PathBuf;
6use std::process::Stdio;
7use std::time::{Duration, Instant};
8
9use crate::orchestration::RunExecutionRecord;
10use crate::stdlib::macros::{harn_builtin, VmBuiltinDef};
11use crate::value::{VmError, VmValue};
12use crate::vm::Vm;
13
14const HARN_REPLAY_ENV: &str = "HARN_REPLAY";
15
16thread_local! {
17    pub(crate) static VM_SOURCE_DIR: RefCell<Option<PathBuf>> = const { RefCell::new(None) };
18    static VM_EXECUTION_CONTEXT: RefCell<Option<RunExecutionRecord>> = const { RefCell::new(None) };
19    /// The resolved environment for the current launched session. `None` means
20    /// this thread is outside a session boundary. Held across a worker's `.await`s and
21    /// so swapped per-task by the ambient scope; its `_CONTEXT` suffix enrolls it
22    /// in the ambient-thread-local drift guard.
23    static SESSION_ENVIRONMENT_CONTEXT: RefCell<Option<crate::security::SessionEnvironment>> =
24        const { RefCell::new(None) };
25}
26
27/// Set the source directory for the current thread (called by VM on file execution).
28pub(crate) fn set_thread_source_dir(dir: &std::path::Path) {
29    set_thread_source_dir_option(Some(dir));
30}
31
32pub(crate) fn set_thread_source_dir_option(dir: Option<&std::path::Path>) {
33    VM_SOURCE_DIR.with(|current| {
34        *current.borrow_mut() = dir.map(normalize_context_path);
35    });
36}
37
38pub(crate) fn normalize_context_path(path: &std::path::Path) -> PathBuf {
39    if path.is_absolute() {
40        return path.to_path_buf();
41    }
42    std::env::current_dir()
43        .map(|cwd| cwd.join(path))
44        .unwrap_or_else(|_| path.to_path_buf())
45}
46
47pub fn set_thread_execution_context(context: Option<RunExecutionRecord>) {
48    VM_EXECUTION_CONTEXT.with(|current| *current.borrow_mut() = context);
49}
50
51pub(crate) fn current_execution_context() -> Option<RunExecutionRecord> {
52    VM_EXECUTION_CONTEXT.with(|current| current.borrow().clone())
53}
54
55/// Install (or clear) the environment policy the current session runs under.
56/// Called at the session launch boundary once the declared policy
57/// and grants have been resolved into a [`crate::security::SessionEnvironment`].
58pub fn set_session_environment(environment: Option<crate::security::SessionEnvironment>) {
59    SESSION_ENVIRONMENT_CONTEXT.with(|current| *current.borrow_mut() = environment);
60}
61
62/// The environment policy governing subprocess env construction for the current
63/// task, or `None` on the legacy non-session path.
64pub(crate) fn current_session_environment() -> Option<crate::security::SessionEnvironment> {
65    SESSION_ENVIRONMENT_CONTEXT.with(|current| current.borrow().clone())
66}
67
68/// Per-task ambient-scope swap of the session environment. Same rationale as
69/// [`swap_thread_execution_context`]: a fan-out worker holds its session's
70/// environment across `.await`s, so it must keep its own copy rather than read a
71/// cooperatively-scheduled sibling's. `pub(crate)` — only the ambient combinator
72/// moves whole environments; launch code uses [`set_session_environment`].
73pub(crate) fn swap_session_environment(
74    next: Option<crate::security::SessionEnvironment>,
75) -> Option<crate::security::SessionEnvironment> {
76    SESSION_ENVIRONMENT_CONTEXT.with(|current| std::mem::replace(&mut *current.borrow_mut(), next))
77}
78
79/// Per-task ambient-scope swap of the thread execution context. See
80/// `orchestration::ambient_scope`: the execution context carries the running
81/// task's cwd/env/source-dir AND anchors the capability path-scope workspace
82/// root, so a worker holding it across an `.await` must keep its OWN copy rather
83/// than read whatever a cooperatively-scheduled fan-out sibling left behind. The
84/// helper is `pub(crate)` — only the ambient combinator moves whole contexts;
85/// ordinary code uses `set_thread_execution_context`/`current_execution_context`.
86pub(crate) fn swap_thread_execution_context(
87    next: Option<RunExecutionRecord>,
88) -> Option<RunExecutionRecord> {
89    VM_EXECUTION_CONTEXT.with(|current| std::mem::replace(&mut *current.borrow_mut(), next))
90}
91
92/// Per-task ambient-scope swap of the VM source directory. Same rationale as
93/// [`swap_thread_execution_context`]: it anchors source-relative path
94/// resolution for the running task, so it must follow that task across `.await`.
95pub(crate) fn swap_source_dir(next: Option<PathBuf>) -> Option<PathBuf> {
96    VM_SOURCE_DIR.with(|current| std::mem::replace(&mut *current.borrow_mut(), next))
97}
98
99/// RAII guard that snapshots the thread-local VM source dir on creation and
100/// restores it on drop.
101///
102/// Out-of-band module loads — a connector contract load, a dependency package
103/// load — spin up their own isolated `Vm` but call `Vm::set_source_dir`, which
104/// unconditionally writes the *shared* thread-local `VM_SOURCE_DIR`. Left
105/// unrestored, that leaves the caller's resting source-dir context pointing at
106/// the loaded dependency, so a subsequent top-level `render("@alias/...")` /
107/// `render("relative/...")` in the entry module resolves against the
108/// dependency's `harn.toml` instead of the project root. Holding this guard
109/// across such a load keeps the load invisible to the caller's source-dir
110/// context — mirroring the per-frame save/restore discipline in `vm::execution`.
111pub(crate) struct SourceDirGuard {
112    previous: Option<PathBuf>,
113}
114
115impl SourceDirGuard {
116    /// Snapshot the current thread-local source dir.
117    pub(crate) fn capture() -> Self {
118        Self {
119            previous: VM_SOURCE_DIR.with(|sd| sd.borrow().clone()),
120        }
121    }
122}
123
124impl Drop for SourceDirGuard {
125    fn drop(&mut self) {
126        let previous = self.previous.take();
127        VM_SOURCE_DIR.with(|sd| *sd.borrow_mut() = previous);
128    }
129}
130
131/// Reset thread-local process state (for test isolation).
132pub(crate) fn reset_process_state() {
133    VM_SOURCE_DIR.with(|sd| *sd.borrow_mut() = None);
134    VM_EXECUTION_CONTEXT.with(|current| *current.borrow_mut() = None);
135}
136
137pub fn execution_root_path() -> PathBuf {
138    current_execution_context()
139        .and_then(|context| context.cwd.map(PathBuf::from))
140        .or_else(|| std::env::current_dir().ok())
141        .unwrap_or_else(|| PathBuf::from("."))
142}
143
144pub fn project_root_path() -> Option<PathBuf> {
145    current_execution_context().and_then(|context| {
146        let project_root = context.project_root?;
147        if project_root.trim().is_empty() {
148            return None;
149        }
150        let path = PathBuf::from(project_root);
151        if path.is_absolute() {
152            Some(path)
153        } else if let Some(cwd) = context.cwd {
154            Some(PathBuf::from(cwd).join(path))
155        } else {
156            Some(normalize_context_path(&path))
157        }
158    })
159}
160
161pub fn source_root_path() -> PathBuf {
162    VM_SOURCE_DIR
163        .with(|sd| sd.borrow().clone())
164        .or_else(|| {
165            current_execution_context().and_then(|context| context.source_dir.map(PathBuf::from))
166        })
167        .or_else(|| current_execution_context().and_then(|context| context.cwd.map(PathBuf::from)))
168        .or_else(|| std::env::current_dir().ok())
169        .unwrap_or_else(|| PathBuf::from("."))
170}
171
172pub fn asset_root_path() -> PathBuf {
173    source_root_path()
174}
175
176fn env_override(name: &str) -> Option<String> {
177    (name == HARN_REPLAY_ENV && crate::triggers::dispatcher::current_dispatch_is_replay())
178        .then(|| "1".to_string())
179}
180
181pub(crate) fn read_env_value(name: &str) -> Option<String> {
182    env_override(name)
183        .or_else(|| current_execution_context().and_then(|context| context.env.get(name).cloned()))
184        .or_else(|| session_env_var(name).ok().flatten())
185}
186
187pub fn runtime_root_base() -> PathBuf {
188    project_root_path()
189        .or_else(|| find_project_root(&execution_root_path()))
190        .or_else(|| find_project_root(&source_root_path()))
191        .unwrap_or_else(source_root_path)
192}
193
194/// Lexically collapse `..` components in `path`. Returns `None` if a
195/// `..` would pop a non-Normal component (i.e. the path tries to walk
196/// above its root anchor). This is a pure-string canonicalization that
197/// does NOT hit the filesystem — symlinks are not followed.
198fn lexically_collapse(path: &std::path::Path) -> Option<PathBuf> {
199    use std::path::Component;
200    let mut out: Vec<Component> = Vec::new();
201    for component in path.components() {
202        match component {
203            Component::CurDir => {}
204            Component::ParentDir => {
205                let popped = out.pop();
206                if !matches!(popped, Some(Component::Normal(_))) {
207                    return None;
208                }
209            }
210            other => out.push(other),
211        }
212    }
213    Some(out.iter().collect())
214}
215
216pub fn resolve_source_relative_path(path: &str) -> PathBuf {
217    let candidate = PathBuf::from(path);
218    if candidate.is_absolute() {
219        return candidate;
220    }
221    let root = execution_root_path();
222    let joined = root.join(&candidate);
223    // Defense-in-depth path-traversal check (paired with the deferred
224    // F3 sandbox-by-default fix): refuse to resolve a path that
225    // escapes the project root via `..` components. We anchor against
226    // `runtime_root_base()` (the project root), which is broader than
227    // `execution_root_path()` and lets benign sibling-dir walks like
228    // `read_file("../fixtures/payload.json")` from `tests/` succeed.
229    if path_escapes_project_root(&joined) {
230        return root.join("__harn_rejected_parent_dir_traversal__");
231    }
232    joined
233}
234
235pub fn resolve_source_asset_path(path: &str) -> PathBuf {
236    let candidate = PathBuf::from(path);
237    if candidate.is_absolute() {
238        return candidate;
239    }
240    let root = asset_root_path();
241    let joined = root.join(&candidate);
242    if path_escapes_project_root(&joined) {
243        return root.join("__harn_rejected_parent_dir_traversal__");
244    }
245    joined
246}
247
248/// Returns `true` when `joined` (which may contain raw `..`
249/// components) cannot be lexically collapsed without popping past its
250/// root component — i.e. the relative input had more `..` than the
251/// joined depth allows, escaping the filesystem root.
252///
253/// This is intentionally a narrow check: it doesn't try to enforce
254/// that the path stays inside a logical "project root", because the
255/// project root isn't always reliably resolvable (and benign uses
256/// like `../fixtures/x.json` from a `tests/` subdir are legitimate).
257/// The sandbox layer remains the authoritative defense for arbitrary
258/// `..` traversal; this guard plugs the most egregious escapes
259/// (`../../../../etc/passwd`) for the no-sandbox-by-default
260/// `harn run` path.
261fn path_escapes_project_root(joined: &std::path::Path) -> bool {
262    lexically_collapse(joined).is_none()
263}
264
265pub(crate) fn register_process_builtins(vm: &mut Vm) {
266    for def in PROCESS_BUILTINS {
267        vm.register_builtin_def(def);
268    }
269}
270
271#[harn_builtin(sig = "env(name: string) -> string?", category = "process")]
272fn env_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
273    let name = args.first().map(|a| a.display()).unwrap_or_default();
274    if let Some(value) = read_env_value(&name) {
275        return Ok(VmValue::String(arcstr::ArcStr::from(value)));
276    }
277    Ok(VmValue::Nil)
278}
279
280#[harn_builtin(
281    sig = "env_or(name: string, default: any) -> any",
282    category = "process"
283)]
284fn env_or_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
285    let name = args.first().map(|a| a.display()).unwrap_or_default();
286    let default = args.get(1).cloned().unwrap_or(VmValue::Nil);
287    if let Some(value) = read_env_value(&name) {
288        return Ok(VmValue::String(arcstr::ArcStr::from(value)));
289    }
290    Ok(default)
291}
292
293#[harn_builtin(sig = "exit(code?: int) -> never", category = "process")]
294fn exit_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
295    let code = args.first().and_then(|a| a.as_int()).unwrap_or(0);
296    Err(VmError::ProcessExit(code as i32))
297}
298
299#[harn_builtin(sig = "exec(...command: string) -> dict", category = "process")]
300fn exec_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
301    if args.is_empty() {
302        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
303            "exec: command is required",
304        ))));
305    }
306    let cmd = args[0].display();
307    let cmd_args: Vec<String> = args[1..].iter().map(|a| a.display()).collect();
308    let output = exec_command(None, &cmd, &cmd_args)?;
309    Ok(vm_output_to_value(output))
310}
311
312#[harn_builtin(sig = "shell(command: string) -> dict", category = "process")]
313fn shell_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
314    let cmd = args.first().map(|a| a.display()).unwrap_or_default();
315    if cmd.is_empty() {
316        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
317            "shell: command string is required",
318        ))));
319    }
320    let invocation = crate::shells::default_shell_invocation(&cmd)
321        .map_err(|error| VmError::Runtime(format!("shell: {error}")))?;
322    let output = exec_shell_args(None, &invocation.program, &invocation.args)?;
323    Ok(vm_output_to_value(output))
324}
325
326#[harn_builtin(
327    sig = "exec_at(dir: string, ...command: string) -> dict",
328    category = "process"
329)]
330fn exec_at_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
331    if args.len() < 2 {
332        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
333            "exec_at: directory and command are required",
334        ))));
335    }
336    let dir = args[0].display();
337    let cmd = args[1].display();
338    let cmd_args: Vec<String> = args[2..].iter().map(|a| a.display()).collect();
339    let output = exec_command(Some(dir.as_str()), &cmd, &cmd_args)?;
340    Ok(vm_output_to_value(output))
341}
342
343#[harn_builtin(
344    sig = "shell_at(dir: string, command: string) -> dict",
345    category = "process"
346)]
347fn shell_at_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
348    if args.len() < 2 {
349        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
350            "shell_at: directory and command string are required",
351        ))));
352    }
353    let dir = args[0].display();
354    let cmd = args[1].display();
355    if cmd.is_empty() {
356        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
357            "shell_at: command string is required",
358        ))));
359    }
360    let invocation = crate::shells::default_shell_invocation(&cmd)
361        .map_err(|error| VmError::Runtime(format!("shell_at: {error}")))?;
362    let output = exec_shell_args(Some(dir.as_str()), &invocation.program, &invocation.args)?;
363    Ok(vm_output_to_value(output))
364}
365
366#[harn_builtin(sig = "username(...args: any) -> string", category = "process")]
367fn username_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
368    let user = std::env::var("USER")
369        .or_else(|_| std::env::var("USERNAME"))
370        .unwrap_or_default();
371    Ok(VmValue::String(arcstr::ArcStr::from(user)))
372}
373
374#[harn_builtin(sig = "hostname() -> string", category = "process")]
375fn hostname_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
376    let name = std::env::var("HOSTNAME")
377        .or_else(|_| std::env::var("COMPUTERNAME"))
378        .or_else(|_| {
379            std::process::Command::new("hostname")
380                .output()
381                .ok()
382                .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
383                .ok_or(std::env::VarError::NotPresent)
384        })
385        .unwrap_or_default();
386    Ok(VmValue::String(arcstr::ArcStr::from(name)))
387}
388
389#[harn_builtin(sig = "platform(...args: any) -> string", category = "process")]
390fn platform_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
391    let os = if cfg!(target_os = "macos") {
392        "darwin"
393    } else if cfg!(target_os = "linux") {
394        "linux"
395    } else if cfg!(target_os = "windows") {
396        "windows"
397    } else {
398        std::env::consts::OS
399    };
400    Ok(VmValue::String(arcstr::ArcStr::from(os)))
401}
402
403#[harn_builtin(sig = "arch() -> string", category = "process")]
404fn arch_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
405    Ok(VmValue::String(arcstr::ArcStr::from(
406        std::env::consts::ARCH,
407    )))
408}
409
410#[harn_builtin(sig = "home_dir() -> string", category = "process")]
411fn home_dir_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
412    let home = crate::user_dirs::home_dir()
413        .map(|home| home.to_string_lossy().into_owned())
414        .unwrap_or_default();
415    Ok(VmValue::String(arcstr::ArcStr::from(home)))
416}
417
418#[harn_builtin(sig = "pid(...args: any) -> int", category = "process")]
419fn pid_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
420    Ok(VmValue::Int(std::process::id() as i64))
421}
422
423#[harn_builtin(sig = "date_iso() -> string", category = "process")]
424fn date_iso_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
425    // `date_iso` reads the OS wall clock directly (it predates the
426    // unified `clock_mock`). Routing through `leak_audit::wall_now`
427    // keeps the production behavior unchanged but surfaces the call
428    // in `testbench_clock_leaks()` whenever a script invokes it
429    // under a paused testbench session, so fidelity hazards are
430    // visible instead of silently corrupting tapes.
431    let now = crate::clock_mock::leak_audit::wall_now("stdlib/date_iso");
432    let dt: chrono::DateTime<chrono::Utc> = now.into();
433    Ok(VmValue::String(arcstr::ArcStr::from(
434        dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
435    )))
436}
437
438#[harn_builtin(sig = "cwd() -> string", category = "process")]
439fn cwd_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
440    let dir = current_execution_context()
441        .and_then(|context| context.cwd)
442        .or_else(|| {
443            std::env::current_dir()
444                .ok()
445                .map(|p| p.to_string_lossy().into_owned())
446        })
447        .unwrap_or_default();
448    Ok(VmValue::String(arcstr::ArcStr::from(dir)))
449}
450
451#[harn_builtin(sig = "execution_root() -> string", category = "process")]
452fn execution_root_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
453    Ok(VmValue::String(arcstr::ArcStr::from(
454        execution_root_path().to_string_lossy().into_owned(),
455    )))
456}
457
458#[harn_builtin(sig = "asset_root() -> string", category = "process")]
459fn asset_root_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
460    Ok(VmValue::String(arcstr::ArcStr::from(
461        asset_root_path().to_string_lossy().into_owned(),
462    )))
463}
464
465#[harn_builtin(sig = "runtime_paths() -> dict", category = "process")]
466fn runtime_paths_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
467    let runtime_base = runtime_root_base();
468    let mut paths = BTreeMap::new();
469    paths.put_str("execution_root", execution_root_path().to_string_lossy());
470    paths.put_str("asset_root", asset_root_path().to_string_lossy());
471    paths.put_str(
472        "state_root",
473        crate::runtime_paths::state_root(&runtime_base).to_string_lossy(),
474    );
475    paths.put_str(
476        "run_root",
477        crate::runtime_paths::run_root(&runtime_base).to_string_lossy(),
478    );
479    paths.put_str(
480        "worktree_root",
481        crate::runtime_paths::worktree_root(&runtime_base).to_string_lossy(),
482    );
483    Ok(VmValue::dict(paths))
484}
485
486#[harn_builtin(sig = "spawn_captured(opts: dict) -> dict", category = "process")]
487fn spawn_captured_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
488    spawn_captured_value(args)
489}
490
491// `term_width()` / `term_height()` return the current terminal
492// dimensions in columns and rows. Reads `COLUMNS` / `LINES` env vars
493// first (so test harnesses can pin a value), falls back to the
494// platform `ioctl` size, and finally defaults to 80x24 when neither
495// is available (e.g. when stdout is not a TTY). These are the
496// free-builtin aliases for `harness.term.width()` /
497// `harness.term.height()`. `std/tui` already exposes
498// `__tui_terminal_width` for its renderer; these aliases keep
499// ported subcommands working without importing the tui module.
500#[harn_builtin(sig = "term_width() -> int", category = "process")]
501fn term_width_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
502    Ok(VmValue::Int(crate::term::width() as i64))
503}
504
505#[harn_builtin(sig = "term_height() -> int", category = "process")]
506fn term_height_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
507    Ok(VmValue::Int(crate::term::height() as i64))
508}
509
510const PROCESS_BUILTINS: &[&VmBuiltinDef] = &[
511    &ENV_IMPL_DEF,
512    &ENV_OR_IMPL_DEF,
513    &EXIT_IMPL_DEF,
514    &EXEC_IMPL_DEF,
515    &EXEC_OPTS_IMPL_DEF,
516    &SHELL_IMPL_DEF,
517    &EXEC_AT_IMPL_DEF,
518    &EXEC_AT_OPTS_IMPL_DEF,
519    &SHELL_AT_IMPL_DEF,
520    &USERNAME_IMPL_DEF,
521    &HOSTNAME_IMPL_DEF,
522    &PLATFORM_IMPL_DEF,
523    &ARCH_IMPL_DEF,
524    &HOME_DIR_IMPL_DEF,
525    &PID_IMPL_DEF,
526    &DATE_ISO_IMPL_DEF,
527    &CWD_IMPL_DEF,
528    &EXECUTION_ROOT_IMPL_DEF,
529    &ASSET_ROOT_IMPL_DEF,
530    &RUNTIME_PATHS_IMPL_DEF,
531    &SPAWN_CAPTURED_IMPL_DEF,
532    &TERM_WIDTH_IMPL_DEF,
533    &TERM_HEIGHT_IMPL_DEF,
534];
535
536/// Run an external command synchronously and return captured output.
537///
538/// Shared by the legacy free builtin and `harness.process.spawn_captured` so
539/// subprocess capture has one implementation and one result shape.
540pub(crate) fn spawn_captured_value(args: &[VmValue]) -> Result<VmValue, VmError> {
541    let opts = match args.first() {
542        Some(VmValue::Dict(opts)) => opts.clone(),
543        _ => {
544            return Err(VmError::Runtime(
545                "spawn_captured: options dict is required".to_string(),
546            ));
547        }
548    };
549    let cmd = match opts.get("cmd").map(|v| v.display()).unwrap_or_default() {
550        s if s.is_empty() => {
551            return Err(VmError::Runtime(
552                "spawn_captured: opts.cmd is required".to_string(),
553            ));
554        }
555        s => s,
556    };
557    let cmd_args: Vec<String> = match opts.get("args") {
558        Some(VmValue::List(items)) => items.iter().map(|v| v.display()).collect(),
559        None | Some(VmValue::Nil) => Vec::new(),
560        Some(other) => {
561            return Err(VmError::Runtime(format!(
562                "spawn_captured: opts.args must be a list of strings, got {}",
563                other.type_name()
564            )));
565        }
566    };
567    let cwd = opts
568        .get("cwd")
569        .map(|v| v.display())
570        .filter(|s| !s.is_empty());
571    let env_overrides: Vec<(String, String)> = match opts.get("env") {
572        Some(VmValue::Dict(env)) => env
573            .iter()
574            .map(|(k, v)| (k.to_string(), v.display()))
575            .collect(),
576        None | Some(VmValue::Nil) => Vec::new(),
577        Some(other) => {
578            return Err(VmError::Runtime(format!(
579                "spawn_captured: opts.env must be a dict, got {}",
580                other.type_name()
581            )));
582        }
583    };
584    let stdin_bytes: Option<Vec<u8>> = match opts.get("stdin") {
585        Some(VmValue::Bytes(bytes)) => Some(bytes.as_slice().to_vec()),
586        Some(VmValue::String(s)) => Some(s.as_bytes().to_vec()),
587        None | Some(VmValue::Nil) => None,
588        Some(other) => {
589            return Err(VmError::Runtime(format!(
590                "spawn_captured: opts.stdin must be string or bytes, got {}",
591                other.type_name()
592            )));
593        }
594    };
595    let timeout = opts
596        .get("timeout_ms")
597        .and_then(|v| v.as_int())
598        .filter(|n| *n > 0)
599        .map(|n| Duration::from_millis(n as u64));
600
601    let spawn = CapturedSpawn {
602        label: "spawn_captured",
603        cmd: &cmd,
604        args: &cmd_args,
605        cwd: cwd.as_deref(),
606        env: &env_overrides,
607        // `spawn_captured` layers `env` over the ambient environment rather
608        // than replacing it. Under a session environment that ambient base is the
609        // resolver's allowlist + grants, not the raw parent env.
610        env_clear: false,
611        stdin: stdin_bytes,
612        timeout,
613    };
614    let CapturedRun {
615        output,
616        timed_out,
617        interrupted,
618        duration_ms,
619    } = run_captured_spawn(spawn)?;
620
621    let exit_code = if timed_out || interrupted {
622        -1
623    } else {
624        output.status.code().unwrap_or(-1) as i64
625    };
626    let success = if timed_out || interrupted {
627        false
628    } else {
629        output.status.success()
630    };
631    let mut result = BTreeMap::new();
632    result.insert("exit_code".to_string(), VmValue::Int(exit_code));
633    result.put_str("stdout", String::from_utf8_lossy(&output.stdout).as_ref());
634    result.put_str("stderr", String::from_utf8_lossy(&output.stderr).as_ref());
635    result.insert("duration_ms".to_string(), VmValue::Int(duration_ms));
636    result.insert("success".to_string(), VmValue::Bool(success));
637    result.insert("timed_out".to_string(), VmValue::Bool(timed_out));
638    Ok(VmValue::dict(result))
639}
640
641/// Parameters for [`run_captured_spawn`]: a single synchronous subprocess
642/// spawn that captures stdout/stderr, optionally feeds stdin, optionally
643/// enforces a wall-clock timeout, and either merges (`env_clear == false`)
644/// or replaces (`env_clear == true`) the parent environment with `env`.
645struct CapturedSpawn<'a> {
646    label: &'static str,
647    cmd: &'a str,
648    args: &'a [String],
649    cwd: Option<&'a str>,
650    env: &'a [(String, String)],
651    env_clear: bool,
652    stdin: Option<Vec<u8>>,
653    timeout: Option<Duration>,
654}
655
656/// Result of [`run_captured_spawn`].
657struct CapturedRun {
658    output: std::process::Output,
659    timed_out: bool,
660    interrupted: bool,
661    duration_ms: i64,
662}
663
664/// Shared synchronous spawn-and-capture core used by `spawn_captured` and the
665/// `exec_opts`/`exec_at_opts` convenience builtins. Honors cwd, an env
666/// overlay (merge or replace via `env_clear`), the live session environment's
667/// closed environment, optional stdin, and an optional wall-clock timeout
668/// (after which the child is killed and `timed_out` is set).
669///
670/// The child runs in its own process group and the wait polls
671/// [`crate::op_interrupt::requested`], so scope cancellation, `deadline`
672/// expiry, and VM drop gracefully terminate the whole child tree
673/// (SIGTERM, grace, SIGKILL) instead of orphaning it. See
674/// `crate::op_interrupt` for the mechanism.
675fn run_captured_spawn(spec: CapturedSpawn<'_>) -> Result<CapturedRun, VmError> {
676    let label = spec.label;
677    let mut command = std::process::Command::new(spec.cmd);
678    command.args(spec.args);
679    if let Some(cwd) = spec.cwd {
680        command.current_dir(cwd);
681    }
682    // A `replace` request (`env_clear`) is already closed — only the caller's
683    // keys survive — so it needs no further narrowing. A `merge` request would
684    // otherwise inherit the parent environment wholesale, which under a session
685    // environment means credentials crossing into the child. Route it through the
686    // same resolver `process_command_config` uses so both seams close together.
687    let resolved_environment = if spec.env_clear {
688        None
689    } else {
690        session_closed_env(spec.env.iter().cloned())?
691    };
692    if spec.env_clear || resolved_environment.is_some() {
693        command.env_clear();
694    }
695    for (key, value) in resolved_environment.as_deref().unwrap_or(spec.env) {
696        command.env(key, value);
697    }
698    command.stdout(Stdio::piped()).stderr(Stdio::piped());
699    if spec.stdin.is_some() {
700        command.stdin(Stdio::piped());
701    } else {
702        command.stdin(Stdio::null());
703    }
704    crate::op_interrupt::configure_kill_group(&mut command);
705    let cleanup_token = crate::op_interrupt::new_process_cleanup_token();
706    command.env(
707        crate::op_interrupt::PROCESS_CLEANUP_TOKEN_ENV,
708        &cleanup_token,
709    );
710
711    let started = Instant::now();
712    let cmd = spec.cmd;
713    let mut child = command.spawn().map_err(|error| {
714        VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
715            "{label}: failed to spawn '{cmd}': {error}"
716        ))))
717    })?;
718
719    if let (Some(payload), Some(mut stdin)) = (spec.stdin, child.stdin.take()) {
720        // Children may close stdin early while still producing useful output.
721        let _ = stdin.write_all(&payload);
722    }
723
724    // Drain pipes on dedicated threads so >64 KB of output never deadlocks
725    // the wait loop below (which must keep polling for interrupts instead of
726    // blocking in `wait_with_output`).
727    let rx_out = child
728        .stdout
729        .take()
730        .map(crate::op_interrupt::spawn_pipe_drain);
731    let rx_err = child
732        .stderr
733        .take()
734        .map(crate::op_interrupt::spawn_pipe_drain);
735
736    let child_pid = child.id();
737    let wait_end = crate::op_interrupt::wait_child_interruptible_with_cleanup_token(
738        &mut child,
739        spec.timeout,
740        Some(&cleanup_token),
741    )
742    .map_err(|error| {
743        VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
744            "{label}: wait failed: {error}"
745        ))))
746    })?;
747    let (status, timed_out, interrupted, killed) = match wait_end {
748        crate::op_interrupt::ChildWait::Exited(status) => (status, false, false, false),
749        crate::op_interrupt::ChildWait::TimedOut(_) => {
750            (std::process::ExitStatus::default(), true, false, true)
751        }
752        // Interrupted: the reaped status (or a synthetic fallback) is
753        // returned so the builtin completes; the VM raises the pending
754        // cancellation / deadline error at the next op boundary.
755        crate::op_interrupt::ChildWait::Interrupted(status, _) => {
756            (status.unwrap_or_default(), false, true, true)
757        }
758    };
759
760    let stdout = rx_out
761        .map(|rx| crate::op_interrupt::drain_captured_pipe(&rx, killed, child_pid))
762        .unwrap_or_default();
763    let stderr = rx_err
764        .map(|rx| crate::op_interrupt::drain_captured_pipe(&rx, killed, child_pid))
765        .unwrap_or_default();
766
767    Ok(CapturedRun {
768        output: std::process::Output {
769            status,
770            stdout,
771            stderr,
772        },
773        timed_out,
774        interrupted,
775        duration_ms: started.elapsed().as_millis() as i64,
776    })
777}
778
779/// Parsed `exec_opts` / `exec_at_opts` options, ready to populate a
780/// [`CapturedSpawn`].
781#[derive(Default)]
782struct ExecOptions {
783    env: Vec<(String, String)>,
784    env_clear: bool,
785    cwd: Option<String>,
786    timeout: Option<Duration>,
787}
788
789/// Extract `exec_opts` / `exec_at_opts` options into an [`ExecOptions`].
790///
791/// `env_mode` mirrors the `process.exec` host op (and the env-clear footgun
792/// fix): the default is `"merge"` (overlay `env` keys on the ambient
793/// environment, keeping PATH/HOME/etc.); `"replace"` clears that environment
794/// first so only the provided keys remain. Under a session environment the ambient
795/// base a `"merge"` sees is the resolver's allowlist + grants, not the raw
796/// parent env — see [`session_closed_env`].
797fn exec_options(label: &str, options: Option<&VmValue>) -> Result<ExecOptions, VmError> {
798    let opts = match options {
799        None | Some(VmValue::Nil) => return Ok(ExecOptions::default()),
800        Some(VmValue::Dict(opts)) => opts.clone(),
801        Some(other) => {
802            return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
803                format!("{label}: options must be a dict, got {}", other.type_name()),
804            ))));
805        }
806    };
807    let env: Vec<(String, String)> = match opts.get("env") {
808        Some(VmValue::Dict(env)) => env
809            .iter()
810            .map(|(k, v)| (k.to_string(), v.display()))
811            .collect(),
812        None | Some(VmValue::Nil) => Vec::new(),
813        Some(other) => {
814            return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
815                format!(
816                    "{label}: options.env must be a dict, got {}",
817                    other.type_name()
818                ),
819            ))));
820        }
821    };
822    let env_clear = match opts.get("env_mode").map(|v| v.display()).as_deref() {
823        None | Some("merge") => false,
824        Some("replace") => true,
825        Some(other) => {
826            return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
827                format!(
828                    "{label}: options.env_mode must be \"merge\" or \"replace\", got {other:?}"
829                ),
830            ))));
831        }
832    };
833    let cwd = opts
834        .get("cwd")
835        .map(|v| v.display())
836        .filter(|s| !s.is_empty());
837    // Accept both `timeout` and `timeout_ms` (millis), matching the
838    // `process.exec` host op's tolerance.
839    let timeout = opts
840        .get("timeout")
841        .or_else(|| opts.get("timeout_ms"))
842        .and_then(|v| v.as_int())
843        .filter(|n| *n > 0)
844        .map(|n| Duration::from_millis(n as u64));
845    Ok(ExecOptions {
846        env,
847        env_clear,
848        cwd,
849        timeout,
850    })
851}
852
853/// Build the `exec`-shaped result dict (`stdout`/`stderr`/`status`/`success`)
854/// and additionally surface `timed_out` so options-form callers can detect a
855/// timeout kill without inspecting the exit status.
856fn captured_run_to_value(run: &CapturedRun) -> VmValue {
857    let status = if run.timed_out || run.interrupted {
858        -1
859    } else {
860        run.output.status.code().unwrap_or(-1) as i64
861    };
862    let success = !run.timed_out && !run.interrupted && run.output.status.success();
863    let mut result = BTreeMap::new();
864    result.put_str(
865        "stdout",
866        String::from_utf8_lossy(&run.output.stdout).as_ref(),
867    );
868    result.put_str(
869        "stderr",
870        String::from_utf8_lossy(&run.output.stderr).as_ref(),
871    );
872    result.insert("status".to_string(), VmValue::Int(status));
873    result.insert("success".to_string(), VmValue::Bool(success));
874    result.insert("timed_out".to_string(), VmValue::Bool(run.timed_out));
875    result.insert("duration_ms".to_string(), VmValue::Int(run.duration_ms));
876    VmValue::dict(result)
877}
878
879#[harn_builtin(
880    sig = "exec_opts(command: list, options: dict?) -> dict",
881    category = "process"
882)]
883fn exec_opts_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
884    let command = exec_opts_command("exec_opts", args.first())?;
885    let opts = exec_options("exec_opts", args.get(1))?;
886    let run = run_captured_spawn(CapturedSpawn {
887        label: "exec_opts",
888        cmd: &command[0],
889        args: &command[1..],
890        cwd: opts.cwd.as_deref(),
891        env: &opts.env,
892        env_clear: opts.env_clear,
893        stdin: None,
894        timeout: opts.timeout,
895    })?;
896    Ok(captured_run_to_value(&run))
897}
898
899#[harn_builtin(
900    sig = "exec_at_opts(dir: string, command: list, options: dict?) -> dict",
901    category = "process"
902)]
903fn exec_at_opts_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
904    let dir = match args.first() {
905        Some(value) if !value.display().is_empty() => value.display(),
906        _ => {
907            return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
908                "exec_at_opts: directory is required",
909            ))));
910        }
911    };
912    let command = exec_opts_command("exec_at_opts", args.get(1))?;
913    let opts = exec_options("exec_at_opts", args.get(2))?;
914    // The positional `dir` argument is the working directory; an explicit
915    // `options.cwd` (rare) overrides it so callers retain full control.
916    let resolved_cwd = opts.cwd.unwrap_or(dir);
917    let run = run_captured_spawn(CapturedSpawn {
918        label: "exec_at_opts",
919        cmd: &command[0],
920        args: &command[1..],
921        cwd: Some(resolved_cwd.as_str()),
922        env: &opts.env,
923        env_clear: opts.env_clear,
924        stdin: None,
925        timeout: opts.timeout,
926    })?;
927    Ok(captured_run_to_value(&run))
928}
929
930/// Validate the `command` argument shared by `exec_opts`/`exec_at_opts`: a
931/// non-empty list whose first element is a non-empty program name.
932fn exec_opts_command(label: &str, value: Option<&VmValue>) -> Result<Vec<String>, VmError> {
933    let items = match value {
934        Some(VmValue::List(items)) => items,
935        _ => {
936            return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
937                format!("{label}: command must be a non-empty list of strings"),
938            ))));
939        }
940    };
941    let command: Vec<String> = items.iter().map(|v| v.display()).collect();
942    if command.is_empty() || command[0].is_empty() {
943        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
944            format!("{label}: command must be a non-empty list of strings"),
945        ))));
946    }
947    Ok(command)
948}
949
950/// Find the project root by walking up from a base directory looking for
951/// `harn.toml`, via the shared `harn-modules` walk so the in-VM resolver agrees
952/// with the CLI and LSP on where a project starts.
953pub fn find_project_root(base: &std::path::Path) -> Option<std::path::PathBuf> {
954    harn_modules::manifest_walk::find_project_root(base)
955}
956
957/// Register builtins that depend on source directory context.
958pub(crate) fn register_path_builtins(vm: &mut Vm) {
959    for def in PATH_BUILTINS {
960        vm.register_builtin_def(def);
961    }
962}
963
964#[harn_builtin(sig = "source_dir(...args: any) -> string", category = "process")]
965fn source_dir_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
966    let dir = VM_SOURCE_DIR.with(|sd| sd.borrow().clone());
967    match dir {
968        Some(d) => Ok(VmValue::String(arcstr::ArcStr::from(
969            d.to_string_lossy().into_owned(),
970        ))),
971        None => {
972            let cwd = std::env::current_dir()
973                .map(|p| p.to_string_lossy().into_owned())
974                .unwrap_or_default();
975            Ok(VmValue::String(arcstr::ArcStr::from(cwd)))
976        }
977    }
978}
979
980#[harn_builtin(sig = "project_root() -> string?", category = "process")]
981fn project_root_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
982    if let Some(root) = project_root_path() {
983        return Ok(VmValue::String(arcstr::ArcStr::from(
984            root.to_string_lossy().as_ref(),
985        )));
986    }
987    let base = current_execution_context()
988        .and_then(|context| context.cwd.map(PathBuf::from))
989        .or_else(|| VM_SOURCE_DIR.with(|sd| sd.borrow().clone()))
990        .or_else(|| std::env::current_dir().ok())
991        .unwrap_or_else(|| PathBuf::from("."));
992    match find_project_root(&base) {
993        Some(root) => Ok(VmValue::String(arcstr::ArcStr::from(
994            root.to_string_lossy().into_owned(),
995        ))),
996        None => Ok(VmValue::Nil),
997    }
998}
999
1000const PATH_BUILTINS: &[&VmBuiltinDef] = &[&SOURCE_DIR_IMPL_DEF, &PROJECT_ROOT_IMPL_DEF];
1001
1002fn vm_output_to_value(output: std::process::Output) -> VmValue {
1003    let mut result = BTreeMap::new();
1004    result.put_str("stdout", String::from_utf8_lossy(&output.stdout).as_ref());
1005    result.put_str("stderr", String::from_utf8_lossy(&output.stderr).as_ref());
1006    result.insert(
1007        "status".to_string(),
1008        VmValue::Int(output.status.code().unwrap_or(-1) as i64),
1009    );
1010    result.insert(
1011        "success".to_string(),
1012        VmValue::Bool(output.status.success()),
1013    );
1014    VmValue::dict(result)
1015}
1016
1017fn exec_command(
1018    dir: Option<&str>,
1019    cmd: &str,
1020    args: &[String],
1021) -> Result<std::process::Output, VmError> {
1022    let config = process_command_config(dir)?;
1023    crate::stdlib::sandbox::command_output(cmd, args, &config)
1024        .map_err(|error| prefix_process_error(error, "exec"))
1025}
1026
1027fn exec_shell_args(
1028    dir: Option<&str>,
1029    shell: &str,
1030    args: &[String],
1031) -> Result<std::process::Output, VmError> {
1032    let config = process_command_config(dir)?;
1033    crate::stdlib::sandbox::command_output(shell, args, &config)
1034        .map_err(|error| prefix_process_error(error, "shell"))
1035}
1036
1037fn process_command_config(
1038    dir: Option<&str>,
1039) -> Result<crate::stdlib::sandbox::ProcessCommandConfig, VmError> {
1040    let mut config = crate::stdlib::sandbox::ProcessCommandConfig {
1041        stdin_null: true,
1042        ..Default::default()
1043    };
1044    if let Some(dir) = dir {
1045        let resolved = resolve_command_dir(dir);
1046        crate::stdlib::sandbox::enforce_process_cwd(&resolved)?;
1047        config.cwd = Some(resolved);
1048    } else if let Some(context) = current_execution_context() {
1049        if let Some(cwd) = context.cwd.filter(|cwd| !cwd.is_empty()) {
1050            crate::stdlib::sandbox::enforce_process_cwd(std::path::Path::new(&cwd))?;
1051            config.cwd = Some(std::path::PathBuf::from(cwd));
1052        }
1053        if !context.env.is_empty() {
1054            config.env.extend(context.env);
1055        }
1056    }
1057    if let Some(value) = env_override(HARN_REPLAY_ENV) {
1058        config.env.push((HARN_REPLAY_ENV.to_string(), value));
1059    }
1060    // `iter().cloned()`, not `drain(..)`: `Drain`'s destructor removes the
1061    // range even when the iterator is never consumed, so draining here would
1062    // silently empty `config.env` on the non-session path.
1063    if let Some(env) = session_closed_env(config.env.iter().cloned())? {
1064        config.env = env;
1065        config.closed_env = true;
1066    }
1067    Ok(config)
1068}
1069
1070/// The single place a live session environment becomes a child environment.
1071///
1072/// Returns `None` when no session environment governs this session — the legacy path, where
1073/// children inherit the parent environment and `overlay` is layered on top.
1074/// Returns `Some(env)` when a session environment is active: `resolve_env` composes the
1075/// allowlisted subset of the parent env plus the policy's granted exposure,
1076/// and `overlay` (worktree paths, `HARN_REPLAY`, caller-supplied `env`, ...)
1077/// wins over that base. The caller must hand the result to the child with the
1078/// inherited environment CLEARED — a closed env is only closed if nothing
1079/// leaks in behind it. An isolated policy contributes no grants, so its child
1080/// sees the allowlist alone.
1081///
1082/// Every spawn seam must route through here. `resolve_env` is documented as the
1083/// single environment builder, and a seam that skips it silently reopens the
1084/// credential boundary the profile exists to close (harn#5011).
1085pub(crate) fn session_closed_env(
1086    overlay: impl Iterator<Item = (String, String)>,
1087) -> Result<Option<Vec<(String, String)>>, VmError> {
1088    let Some(mut env) = session_env()? else {
1089        return Ok(None);
1090    };
1091    env.extend(overlay);
1092    Ok(Some(env.into_iter().collect()))
1093}
1094
1095/// The current session's whole effective environment, or `None` on the legacy
1096/// non-session path. The base [`session_closed_env`] layers a caller overlay onto.
1097pub(crate) fn session_env() -> Result<Option<BTreeMap<String, String>>, VmError> {
1098    let Some(environment) = current_session_environment() else {
1099        return Ok(None);
1100    };
1101    let workspace_defaults = workspace_env_defaults();
1102    let mut env = crate::security::resolve_env(
1103        &environment,
1104        &session_env_lookup(&workspace_defaults),
1105        &resolve_grant_secret,
1106    )
1107    .map_err(grant_env_error)?;
1108    for (key, value) in workspace_defaults {
1109        if !environment
1110            .grants()
1111            .iter()
1112            .any(|grant| grant.exposed_env_var() == Some(key.as_str()))
1113        {
1114            env.insert(key, value);
1115        }
1116    }
1117    Ok(Some(env))
1118}
1119
1120/// The session-governed value of one environment variable.
1121///
1122/// This is the in-process counterpart of [`session_closed_env`], and it is the
1123/// read every *credential* consumer inside harn's own process must use. Reading
1124/// `std::env::var` for a provider key instead would make an isolated session
1125/// isolated only for its children while harn itself still saw the launcher's
1126/// key, and would leave a granted policy unable to use the very credential it was granted
1127/// (harn#4992). `security::lookup_env` gives the same answer `resolve_env` puts
1128/// in the map, so the in-process and subprocess views cannot drift.
1129///
1130/// With no session environment installed this is exactly `std::env::var(name).ok()` — the
1131/// legacy path, unchanged. Folding that fallback in here rather than at each
1132/// call site means a caller cannot accidentally keep reading the raw
1133/// environment when a profile *is* active.
1134pub(crate) fn session_env_var(name: &str) -> Result<Option<String>, VmError> {
1135    let Some(environment) = current_session_environment() else {
1136        return Ok(std::env::var(name).ok());
1137    };
1138    let workspace_defaults = workspace_env_defaults();
1139    let is_grant_target = environment
1140        .grants()
1141        .iter()
1142        .any(|grant| grant.exposed_env_var() == Some(name));
1143    if !is_grant_target {
1144        if let Some(value) = workspace_defaults.get(name) {
1145            return Ok(Some(value.clone()));
1146        }
1147    }
1148    let resolved = crate::security::lookup_env(
1149        &environment,
1150        name,
1151        &session_env_lookup(&workspace_defaults),
1152        &resolve_grant_secret,
1153    )
1154    .map_err(grant_env_error)?;
1155    Ok(resolved)
1156}
1157
1158/// Best-effort adapter for configuration paths whose existing API is
1159/// infallible. Execution paths that can surface a policy error should call
1160/// [`session_env_var`] directly.
1161pub(crate) fn session_env_value(name: &str) -> Option<String> {
1162    if current_session_environment().is_none() {
1163        return crate::test_env::env_var_seamed(name);
1164    }
1165    session_env_var(name).ok().flatten()
1166}
1167
1168/// Sandbox-preset environment defaults for the active workspace. These are
1169/// process-shaping facts set by the run's own policy, never launcher
1170/// credentials. They override the launch snapshot, while an explicit grant
1171/// targeting the same name remains the highest-precedence value.
1172fn workspace_env_defaults() -> BTreeMap<String, String> {
1173    crate::process_sandbox::active_workspace_process_env()
1174        .into_iter()
1175        .collect()
1176}
1177
1178/// The session-environment reader resolve against: the
1179/// workspace preset first, then the real process environment.
1180fn session_env_lookup(
1181    workspace_defaults: &BTreeMap<String, String>,
1182) -> impl Fn(&str) -> Option<String> + '_ {
1183    |name: &str| {
1184        workspace_defaults
1185            .get(name)
1186            .cloned()
1187            .or_else(|| std::env::var(name).ok())
1188    }
1189}
1190
1191fn grant_env_error(error: crate::security::EnvironmentPolicyError) -> VmError {
1192    VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
1193        "session grant env resolution failed: {error}"
1194    ))))
1195}
1196
1197/// Resolve a `secret_store` grant pointer to its value through the crate's
1198/// configured secret chain. Env-snapshot grants never reach this — their value
1199/// was captured at launch — so this only runs for a granted policy that exposes a
1200/// `secret_store` grant to the process env. Any resolution failure yields `None`,
1201/// which surfaces as a loud `MissingSecret` at the spawn boundary rather than a
1202/// silently-empty credential.
1203fn resolve_grant_secret(account: &str, key: &str) -> Option<String> {
1204    let reference = format!("{}{}/{}", crate::secrets::SECRET_REF_SCHEME, account, key);
1205    crate::secrets::resolve_secret_ref_to_string(&reference)
1206        .ok()
1207        .flatten()
1208}
1209
1210fn prefix_process_error(error: VmError, prefix: &str) -> VmError {
1211    match error {
1212        VmError::Thrown(VmValue::String(message)) => VmError::Thrown(VmValue::String(
1213            arcstr::ArcStr::from(format!("{prefix} failed: {message}")),
1214        )),
1215        other => other,
1216    }
1217}
1218
1219fn resolve_command_dir(dir: &str) -> PathBuf {
1220    let candidate = PathBuf::from(dir);
1221    if candidate.is_absolute() {
1222        return candidate;
1223    }
1224    if let Some(cwd) = current_execution_context().and_then(|context| context.cwd) {
1225        return PathBuf::from(cwd).join(candidate);
1226    }
1227    if let Some(source_dir) = VM_SOURCE_DIR.with(|sd| sd.borrow().clone()) {
1228        return source_dir.join(candidate);
1229    }
1230    candidate
1231}
1232
1233#[cfg(test)]
1234#[path = "process_tests.rs"]
1235mod tests;