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