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