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