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