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}
20
21/// Set the source directory for the current thread (called by VM on file execution).
22pub(crate) fn set_thread_source_dir(dir: &std::path::Path) {
23    VM_SOURCE_DIR.with(|sd| *sd.borrow_mut() = Some(normalize_context_path(dir)));
24}
25
26pub(crate) fn normalize_context_path(path: &std::path::Path) -> PathBuf {
27    if path.is_absolute() {
28        return path.to_path_buf();
29    }
30    std::env::current_dir()
31        .map(|cwd| cwd.join(path))
32        .unwrap_or_else(|_| path.to_path_buf())
33}
34
35pub fn set_thread_execution_context(context: Option<RunExecutionRecord>) {
36    VM_EXECUTION_CONTEXT.with(|current| *current.borrow_mut() = context);
37}
38
39pub(crate) fn current_execution_context() -> Option<RunExecutionRecord> {
40    VM_EXECUTION_CONTEXT.with(|current| current.borrow().clone())
41}
42
43/// Per-task ambient-scope swap of the thread execution context. See
44/// `orchestration::ambient_scope`: the execution context carries the running
45/// task's cwd/env/source-dir AND anchors the capability path-scope workspace
46/// root, so a worker holding it across an `.await` must keep its OWN copy rather
47/// than read whatever a cooperatively-scheduled fan-out sibling left behind. The
48/// helper is `pub(crate)` — only the ambient combinator moves whole contexts;
49/// ordinary code uses `set_thread_execution_context`/`current_execution_context`.
50pub(crate) fn swap_thread_execution_context(
51    next: Option<RunExecutionRecord>,
52) -> Option<RunExecutionRecord> {
53    VM_EXECUTION_CONTEXT.with(|current| std::mem::replace(&mut *current.borrow_mut(), next))
54}
55
56/// Per-task ambient-scope swap of the VM source directory. Same rationale as
57/// [`swap_thread_execution_context`]: it anchors source-relative path
58/// resolution for the running task, so it must follow that task across `.await`.
59pub(crate) fn swap_source_dir(next: Option<PathBuf>) -> Option<PathBuf> {
60    VM_SOURCE_DIR.with(|current| std::mem::replace(&mut *current.borrow_mut(), next))
61}
62
63/// Reset thread-local process state (for test isolation).
64pub(crate) fn reset_process_state() {
65    VM_SOURCE_DIR.with(|sd| *sd.borrow_mut() = None);
66    VM_EXECUTION_CONTEXT.with(|current| *current.borrow_mut() = None);
67}
68
69pub fn execution_root_path() -> PathBuf {
70    current_execution_context()
71        .and_then(|context| context.cwd.map(PathBuf::from))
72        .or_else(|| std::env::current_dir().ok())
73        .unwrap_or_else(|| PathBuf::from("."))
74}
75
76pub fn source_root_path() -> PathBuf {
77    VM_SOURCE_DIR
78        .with(|sd| sd.borrow().clone())
79        .or_else(|| {
80            current_execution_context().and_then(|context| context.source_dir.map(PathBuf::from))
81        })
82        .or_else(|| current_execution_context().and_then(|context| context.cwd.map(PathBuf::from)))
83        .or_else(|| std::env::current_dir().ok())
84        .unwrap_or_else(|| PathBuf::from("."))
85}
86
87pub fn asset_root_path() -> PathBuf {
88    source_root_path()
89}
90
91fn env_override(name: &str) -> Option<String> {
92    (name == HARN_REPLAY_ENV && crate::triggers::dispatcher::current_dispatch_is_replay())
93        .then(|| "1".to_string())
94}
95
96pub(crate) fn read_env_value(name: &str) -> Option<String> {
97    env_override(name)
98        .or_else(|| current_execution_context().and_then(|context| context.env.get(name).cloned()))
99        .or_else(|| std::env::var(name).ok())
100}
101
102pub fn runtime_root_base() -> PathBuf {
103    find_project_root(&execution_root_path())
104        .or_else(|| find_project_root(&source_root_path()))
105        .unwrap_or_else(source_root_path)
106}
107
108/// Lexically collapse `..` components in `path`. Returns `None` if a
109/// `..` would pop a non-Normal component (i.e. the path tries to walk
110/// above its root anchor). This is a pure-string canonicalization that
111/// does NOT hit the filesystem — symlinks are not followed.
112fn lexically_collapse(path: &std::path::Path) -> Option<PathBuf> {
113    use std::path::Component;
114    let mut out: Vec<Component> = Vec::new();
115    for component in path.components() {
116        match component {
117            Component::CurDir => {}
118            Component::ParentDir => {
119                let popped = out.pop();
120                if !matches!(popped, Some(Component::Normal(_))) {
121                    return None;
122                }
123            }
124            other => out.push(other),
125        }
126    }
127    Some(out.iter().collect())
128}
129
130pub fn resolve_source_relative_path(path: &str) -> PathBuf {
131    let candidate = PathBuf::from(path);
132    if candidate.is_absolute() {
133        return candidate;
134    }
135    let root = execution_root_path();
136    let joined = root.join(&candidate);
137    // Defense-in-depth path-traversal check (paired with the deferred
138    // F3 sandbox-by-default fix): refuse to resolve a path that
139    // escapes the project root via `..` components. We anchor against
140    // `runtime_root_base()` (the project root), which is broader than
141    // `execution_root_path()` and lets benign sibling-dir walks like
142    // `read_file("../fixtures/payload.json")` from `tests/` succeed.
143    if path_escapes_project_root(&joined) {
144        return root.join("__harn_rejected_parent_dir_traversal__");
145    }
146    joined
147}
148
149pub fn resolve_source_asset_path(path: &str) -> PathBuf {
150    let candidate = PathBuf::from(path);
151    if candidate.is_absolute() {
152        return candidate;
153    }
154    let root = asset_root_path();
155    let joined = root.join(&candidate);
156    if path_escapes_project_root(&joined) {
157        return root.join("__harn_rejected_parent_dir_traversal__");
158    }
159    joined
160}
161
162/// Returns `true` when `joined` (which may contain raw `..`
163/// components) cannot be lexically collapsed without popping past its
164/// root component — i.e. the relative input had more `..` than the
165/// joined depth allows, escaping the filesystem root.
166///
167/// This is intentionally a narrow check: it doesn't try to enforce
168/// that the path stays inside a logical "project root", because the
169/// project root isn't always reliably resolvable (and benign uses
170/// like `../fixtures/x.json` from a `tests/` subdir are legitimate).
171/// The sandbox layer remains the authoritative defense for arbitrary
172/// `..` traversal; this guard plugs the most egregious escapes
173/// (`../../../../etc/passwd`) for the no-sandbox-by-default
174/// `harn run` path.
175fn path_escapes_project_root(joined: &std::path::Path) -> bool {
176    lexically_collapse(joined).is_none()
177}
178
179pub(crate) fn register_process_builtins(vm: &mut Vm) {
180    for def in PROCESS_BUILTINS {
181        vm.register_builtin_def(def);
182    }
183}
184
185#[harn_builtin(sig = "env(name: string) -> string?", category = "process")]
186fn env_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
187    let name = args.first().map(|a| a.display()).unwrap_or_default();
188    if let Some(value) = read_env_value(&name) {
189        return Ok(VmValue::String(arcstr::ArcStr::from(value)));
190    }
191    Ok(VmValue::Nil)
192}
193
194#[harn_builtin(
195    sig = "env_or(name: string, default: any) -> any",
196    category = "process"
197)]
198fn env_or_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
199    let name = args.first().map(|a| a.display()).unwrap_or_default();
200    let default = args.get(1).cloned().unwrap_or(VmValue::Nil);
201    if let Some(value) = read_env_value(&name) {
202        return Ok(VmValue::String(arcstr::ArcStr::from(value)));
203    }
204    Ok(default)
205}
206
207#[harn_builtin(sig = "exit(code?: int) -> never", category = "process")]
208fn exit_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
209    let code = args.first().and_then(|a| a.as_int()).unwrap_or(0);
210    std::process::exit(code as i32);
211}
212
213#[harn_builtin(sig = "exec(...command: string) -> dict", category = "process")]
214fn exec_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
215    if args.is_empty() {
216        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
217            "exec: command is required",
218        ))));
219    }
220    let cmd = args[0].display();
221    let cmd_args: Vec<String> = args[1..].iter().map(|a| a.display()).collect();
222    let output = exec_command(None, &cmd, &cmd_args)?;
223    Ok(vm_output_to_value(output))
224}
225
226#[harn_builtin(sig = "shell(command: string) -> dict", category = "process")]
227fn shell_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
228    let cmd = args.first().map(|a| a.display()).unwrap_or_default();
229    if cmd.is_empty() {
230        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
231            "shell: command string is required",
232        ))));
233    }
234    let invocation = crate::shells::default_shell_invocation(&cmd)
235        .map_err(|error| VmError::Runtime(format!("shell: {error}")))?;
236    let output = exec_shell_args(None, &invocation.program, &invocation.args)?;
237    Ok(vm_output_to_value(output))
238}
239
240#[harn_builtin(
241    sig = "exec_at(dir: string, ...command: string) -> dict",
242    category = "process"
243)]
244fn exec_at_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
245    if args.len() < 2 {
246        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
247            "exec_at: directory and command are required",
248        ))));
249    }
250    let dir = args[0].display();
251    let cmd = args[1].display();
252    let cmd_args: Vec<String> = args[2..].iter().map(|a| a.display()).collect();
253    let output = exec_command(Some(dir.as_str()), &cmd, &cmd_args)?;
254    Ok(vm_output_to_value(output))
255}
256
257#[harn_builtin(
258    sig = "shell_at(dir: string, command: string) -> dict",
259    category = "process"
260)]
261fn shell_at_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
262    if args.len() < 2 {
263        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
264            "shell_at: directory and command string are required",
265        ))));
266    }
267    let dir = args[0].display();
268    let cmd = args[1].display();
269    if cmd.is_empty() {
270        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
271            "shell_at: command string is required",
272        ))));
273    }
274    let invocation = crate::shells::default_shell_invocation(&cmd)
275        .map_err(|error| VmError::Runtime(format!("shell_at: {error}")))?;
276    let output = exec_shell_args(Some(dir.as_str()), &invocation.program, &invocation.args)?;
277    Ok(vm_output_to_value(output))
278}
279
280#[harn_builtin(sig = "username(...args: any) -> string", category = "process")]
281fn username_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
282    let user = std::env::var("USER")
283        .or_else(|_| std::env::var("USERNAME"))
284        .unwrap_or_default();
285    Ok(VmValue::String(arcstr::ArcStr::from(user)))
286}
287
288#[harn_builtin(sig = "hostname() -> string", category = "process")]
289fn hostname_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
290    let name = std::env::var("HOSTNAME")
291        .or_else(|_| std::env::var("COMPUTERNAME"))
292        .or_else(|_| {
293            std::process::Command::new("hostname")
294                .output()
295                .ok()
296                .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
297                .ok_or(std::env::VarError::NotPresent)
298        })
299        .unwrap_or_default();
300    Ok(VmValue::String(arcstr::ArcStr::from(name)))
301}
302
303#[harn_builtin(sig = "platform(...args: any) -> string", category = "process")]
304fn platform_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
305    let os = if cfg!(target_os = "macos") {
306        "darwin"
307    } else if cfg!(target_os = "linux") {
308        "linux"
309    } else if cfg!(target_os = "windows") {
310        "windows"
311    } else {
312        std::env::consts::OS
313    };
314    Ok(VmValue::String(arcstr::ArcStr::from(os)))
315}
316
317#[harn_builtin(sig = "arch() -> string", category = "process")]
318fn arch_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
319    Ok(VmValue::String(arcstr::ArcStr::from(
320        std::env::consts::ARCH,
321    )))
322}
323
324#[harn_builtin(sig = "home_dir() -> string", category = "process")]
325fn home_dir_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
326    let home = crate::user_dirs::home_dir()
327        .map(|home| home.to_string_lossy().into_owned())
328        .unwrap_or_default();
329    Ok(VmValue::String(arcstr::ArcStr::from(home)))
330}
331
332#[harn_builtin(sig = "pid(...args: any) -> int", category = "process")]
333fn pid_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
334    Ok(VmValue::Int(std::process::id() as i64))
335}
336
337#[harn_builtin(sig = "date_iso() -> string", category = "process")]
338fn date_iso_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
339    // `date_iso` reads the OS wall clock directly (it predates the
340    // unified `clock_mock`). Routing through `leak_audit::wall_now`
341    // keeps the production behavior unchanged but surfaces the call
342    // in `testbench_clock_leaks()` whenever a script invokes it
343    // under a paused testbench session, so fidelity hazards are
344    // visible instead of silently corrupting tapes.
345    let now = crate::clock_mock::leak_audit::wall_now("stdlib/date_iso");
346    let dt: chrono::DateTime<chrono::Utc> = now.into();
347    Ok(VmValue::String(arcstr::ArcStr::from(
348        dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
349    )))
350}
351
352#[harn_builtin(sig = "cwd() -> string", category = "process")]
353fn cwd_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
354    let dir = current_execution_context()
355        .and_then(|context| context.cwd)
356        .or_else(|| {
357            std::env::current_dir()
358                .ok()
359                .map(|p| p.to_string_lossy().into_owned())
360        })
361        .unwrap_or_default();
362    Ok(VmValue::String(arcstr::ArcStr::from(dir)))
363}
364
365#[harn_builtin(sig = "execution_root() -> string", category = "process")]
366fn execution_root_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
367    Ok(VmValue::String(arcstr::ArcStr::from(
368        execution_root_path().to_string_lossy().into_owned(),
369    )))
370}
371
372#[harn_builtin(sig = "asset_root() -> string", category = "process")]
373fn asset_root_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
374    Ok(VmValue::String(arcstr::ArcStr::from(
375        asset_root_path().to_string_lossy().into_owned(),
376    )))
377}
378
379#[harn_builtin(sig = "runtime_paths() -> dict", category = "process")]
380fn runtime_paths_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
381    let runtime_base = runtime_root_base();
382    let mut paths = BTreeMap::new();
383    paths.put_str("execution_root", execution_root_path().to_string_lossy());
384    paths.put_str("asset_root", asset_root_path().to_string_lossy());
385    paths.put_str(
386        "state_root",
387        crate::runtime_paths::state_root(&runtime_base).to_string_lossy(),
388    );
389    paths.put_str(
390        "run_root",
391        crate::runtime_paths::run_root(&runtime_base).to_string_lossy(),
392    );
393    paths.put_str(
394        "worktree_root",
395        crate::runtime_paths::worktree_root(&runtime_base).to_string_lossy(),
396    );
397    Ok(VmValue::dict(paths))
398}
399
400#[harn_builtin(sig = "spawn_captured(opts: dict) -> dict", category = "process")]
401fn spawn_captured_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
402    spawn_captured_value(args)
403}
404
405// `term_width()` / `term_height()` return the current terminal
406// dimensions in columns and rows. Reads `COLUMNS` / `LINES` env vars
407// first (so test harnesses can pin a value), falls back to the
408// platform `ioctl` size, and finally defaults to 80x24 when neither
409// is available (e.g. when stdout is not a TTY). These are the
410// free-builtin aliases for `harness.term.width()` /
411// `harness.term.height()`. `std/tui` already exposes
412// `__tui_terminal_width` for its renderer; these aliases keep
413// ported subcommands working without importing the tui module.
414#[harn_builtin(sig = "term_width() -> int", category = "process")]
415fn term_width_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
416    Ok(VmValue::Int(crate::term::width() as i64))
417}
418
419#[harn_builtin(sig = "term_height() -> int", category = "process")]
420fn term_height_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
421    Ok(VmValue::Int(crate::term::height() as i64))
422}
423
424const PROCESS_BUILTINS: &[&VmBuiltinDef] = &[
425    &ENV_IMPL_DEF,
426    &ENV_OR_IMPL_DEF,
427    &EXIT_IMPL_DEF,
428    &EXEC_IMPL_DEF,
429    &EXEC_OPTS_IMPL_DEF,
430    &SHELL_IMPL_DEF,
431    &EXEC_AT_IMPL_DEF,
432    &EXEC_AT_OPTS_IMPL_DEF,
433    &SHELL_AT_IMPL_DEF,
434    &USERNAME_IMPL_DEF,
435    &HOSTNAME_IMPL_DEF,
436    &PLATFORM_IMPL_DEF,
437    &ARCH_IMPL_DEF,
438    &HOME_DIR_IMPL_DEF,
439    &PID_IMPL_DEF,
440    &DATE_ISO_IMPL_DEF,
441    &CWD_IMPL_DEF,
442    &EXECUTION_ROOT_IMPL_DEF,
443    &ASSET_ROOT_IMPL_DEF,
444    &RUNTIME_PATHS_IMPL_DEF,
445    &SPAWN_CAPTURED_IMPL_DEF,
446    &TERM_WIDTH_IMPL_DEF,
447    &TERM_HEIGHT_IMPL_DEF,
448];
449
450/// Run an external command synchronously and return captured output.
451///
452/// Shared by the legacy free builtin and `harness.process.spawn_captured` so
453/// subprocess capture has one implementation and one result shape.
454pub(crate) fn spawn_captured_value(args: &[VmValue]) -> Result<VmValue, VmError> {
455    let opts = match args.first() {
456        Some(VmValue::Dict(opts)) => opts.clone(),
457        _ => {
458            return Err(VmError::Runtime(
459                "spawn_captured: options dict is required".to_string(),
460            ));
461        }
462    };
463    let cmd = match opts.get("cmd").map(|v| v.display()).unwrap_or_default() {
464        s if s.is_empty() => {
465            return Err(VmError::Runtime(
466                "spawn_captured: opts.cmd is required".to_string(),
467            ));
468        }
469        s => s,
470    };
471    let cmd_args: Vec<String> = match opts.get("args") {
472        Some(VmValue::List(items)) => items.iter().map(|v| v.display()).collect(),
473        None | Some(VmValue::Nil) => Vec::new(),
474        Some(other) => {
475            return Err(VmError::Runtime(format!(
476                "spawn_captured: opts.args must be a list of strings, got {}",
477                other.type_name()
478            )));
479        }
480    };
481    let cwd = opts
482        .get("cwd")
483        .map(|v| v.display())
484        .filter(|s| !s.is_empty());
485    let env_overrides: Vec<(String, String)> = match opts.get("env") {
486        Some(VmValue::Dict(env)) => env
487            .iter()
488            .map(|(k, v)| (k.to_string(), v.display()))
489            .collect(),
490        None | Some(VmValue::Nil) => Vec::new(),
491        Some(other) => {
492            return Err(VmError::Runtime(format!(
493                "spawn_captured: opts.env must be a dict, got {}",
494                other.type_name()
495            )));
496        }
497    };
498    let stdin_bytes: Option<Vec<u8>> = match opts.get("stdin") {
499        Some(VmValue::Bytes(bytes)) => Some(bytes.as_slice().to_vec()),
500        Some(VmValue::String(s)) => Some(s.as_bytes().to_vec()),
501        None | Some(VmValue::Nil) => None,
502        Some(other) => {
503            return Err(VmError::Runtime(format!(
504                "spawn_captured: opts.stdin must be string or bytes, got {}",
505                other.type_name()
506            )));
507        }
508    };
509    let timeout = opts
510        .get("timeout_ms")
511        .and_then(|v| v.as_int())
512        .filter(|n| *n > 0)
513        .map(|n| Duration::from_millis(n as u64));
514
515    let spawn = CapturedSpawn {
516        label: "spawn_captured",
517        cmd: &cmd,
518        args: &cmd_args,
519        cwd: cwd.as_deref(),
520        env: &env_overrides,
521        // `spawn_captured` has always layered `env` over the inherited
522        // parent environment, so keep that merge behavior.
523        env_clear: false,
524        stdin: stdin_bytes,
525        timeout,
526    };
527    let CapturedRun {
528        output,
529        timed_out,
530        duration_ms,
531    } = run_captured_spawn(spawn)?;
532
533    let exit_code = if timed_out {
534        -1
535    } else {
536        output.status.code().unwrap_or(-1) as i64
537    };
538    let success = if timed_out {
539        false
540    } else {
541        output.status.success()
542    };
543    let mut result = BTreeMap::new();
544    result.insert("exit_code".to_string(), VmValue::Int(exit_code));
545    result.put_str("stdout", String::from_utf8_lossy(&output.stdout).as_ref());
546    result.put_str("stderr", String::from_utf8_lossy(&output.stderr).as_ref());
547    result.insert("duration_ms".to_string(), VmValue::Int(duration_ms));
548    result.insert("success".to_string(), VmValue::Bool(success));
549    result.insert("timed_out".to_string(), VmValue::Bool(timed_out));
550    Ok(VmValue::dict(result))
551}
552
553/// Parameters for [`run_captured_spawn`]: a single synchronous subprocess
554/// spawn that captures stdout/stderr, optionally feeds stdin, optionally
555/// enforces a wall-clock timeout, and either merges (`env_clear == false`)
556/// or replaces (`env_clear == true`) the parent environment with `env`.
557struct CapturedSpawn<'a> {
558    label: &'static str,
559    cmd: &'a str,
560    args: &'a [String],
561    cwd: Option<&'a str>,
562    env: &'a [(String, String)],
563    env_clear: bool,
564    stdin: Option<Vec<u8>>,
565    timeout: Option<Duration>,
566}
567
568/// Result of [`run_captured_spawn`].
569struct CapturedRun {
570    output: std::process::Output,
571    timed_out: bool,
572    duration_ms: i64,
573}
574
575/// Shared synchronous spawn-and-capture core used by `spawn_captured` and the
576/// `exec_opts`/`exec_at_opts` convenience builtins. Honors cwd, an env
577/// overlay (merge or replace via `env_clear`), optional stdin, and an optional
578/// wall-clock timeout (after which the child is killed and `timed_out` is set).
579///
580/// The child runs in its own process group and the wait polls
581/// [`crate::op_interrupt::requested`], so scope cancellation, `deadline`
582/// expiry, and VM drop gracefully terminate the whole child tree
583/// (SIGTERM, grace, SIGKILL) instead of orphaning it. See
584/// `crate::op_interrupt` for the mechanism.
585fn run_captured_spawn(spec: CapturedSpawn<'_>) -> Result<CapturedRun, VmError> {
586    let label = spec.label;
587    let mut command = std::process::Command::new(spec.cmd);
588    command.args(spec.args);
589    if let Some(cwd) = spec.cwd {
590        command.current_dir(cwd);
591    }
592    if spec.env_clear {
593        command.env_clear();
594    }
595    for (key, value) in spec.env {
596        command.env(key, value);
597    }
598    command.stdout(Stdio::piped()).stderr(Stdio::piped());
599    if spec.stdin.is_some() {
600        command.stdin(Stdio::piped());
601    } else {
602        command.stdin(Stdio::null());
603    }
604    crate::op_interrupt::configure_kill_group(&mut command);
605
606    let started = Instant::now();
607    let cmd = spec.cmd;
608    let mut child = command.spawn().map_err(|error| {
609        VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
610            "{label}: failed to spawn '{cmd}': {error}"
611        ))))
612    })?;
613
614    if let (Some(payload), Some(mut stdin)) = (spec.stdin, child.stdin.take()) {
615        // Children may close stdin early while still producing useful output.
616        let _ = stdin.write_all(&payload);
617    }
618
619    // Drain pipes on dedicated threads so >64 KB of output never deadlocks
620    // the wait loop below (which must keep polling for interrupts instead of
621    // blocking in `wait_with_output`).
622    let rx_out = child
623        .stdout
624        .take()
625        .map(crate::op_interrupt::spawn_pipe_drain);
626    let rx_err = child
627        .stderr
628        .take()
629        .map(crate::op_interrupt::spawn_pipe_drain);
630
631    let child_pid = child.id();
632    let wait_end = crate::op_interrupt::wait_child_interruptible(&mut child, spec.timeout)
633        .map_err(|error| {
634            VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
635                "{label}: wait failed: {error}"
636            ))))
637        })?;
638    let (status, timed_out, killed) = match wait_end {
639        crate::op_interrupt::ChildWait::Exited(status) => (status, false, false),
640        crate::op_interrupt::ChildWait::TimedOut => {
641            (std::process::ExitStatus::default(), true, true)
642        }
643        // Interrupted: the reaped status (or a synthetic fallback) is
644        // returned so the builtin completes; the VM raises the pending
645        // cancellation / deadline error at the next op boundary.
646        crate::op_interrupt::ChildWait::Interrupted(status) => {
647            (status.unwrap_or_default(), false, true)
648        }
649    };
650
651    let stdout = rx_out
652        .map(|rx| crate::op_interrupt::drain_captured_pipe(&rx, killed, child_pid))
653        .unwrap_or_default();
654    let stderr = rx_err
655        .map(|rx| crate::op_interrupt::drain_captured_pipe(&rx, killed, child_pid))
656        .unwrap_or_default();
657
658    Ok(CapturedRun {
659        output: std::process::Output {
660            status,
661            stdout,
662            stderr,
663        },
664        timed_out,
665        duration_ms: started.elapsed().as_millis() as i64,
666    })
667}
668
669/// Parsed `exec_opts` / `exec_at_opts` options, ready to populate a
670/// [`CapturedSpawn`].
671#[derive(Default)]
672struct ExecOptions {
673    env: Vec<(String, String)>,
674    env_clear: bool,
675    cwd: Option<String>,
676    timeout: Option<Duration>,
677}
678
679/// Extract `exec_opts` / `exec_at_opts` options into an [`ExecOptions`].
680///
681/// `env_mode` mirrors the `process.exec` host op (and the env-clear footgun
682/// fix): the default is `"merge"` (overlay `env` keys on the inherited parent
683/// environment, keeping PATH/HOME/etc.); `"replace"` clears the parent
684/// environment first so only the provided keys remain.
685fn exec_options(label: &str, options: Option<&VmValue>) -> Result<ExecOptions, VmError> {
686    let opts = match options {
687        None | Some(VmValue::Nil) => return Ok(ExecOptions::default()),
688        Some(VmValue::Dict(opts)) => opts.clone(),
689        Some(other) => {
690            return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
691                format!("{label}: options must be a dict, got {}", other.type_name()),
692            ))));
693        }
694    };
695    let env: Vec<(String, String)> = match opts.get("env") {
696        Some(VmValue::Dict(env)) => env
697            .iter()
698            .map(|(k, v)| (k.to_string(), v.display()))
699            .collect(),
700        None | Some(VmValue::Nil) => Vec::new(),
701        Some(other) => {
702            return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
703                format!(
704                    "{label}: options.env must be a dict, got {}",
705                    other.type_name()
706                ),
707            ))));
708        }
709    };
710    let env_clear = match opts.get("env_mode").map(|v| v.display()).as_deref() {
711        None | Some("merge") => false,
712        Some("replace") => true,
713        Some(other) => {
714            return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
715                format!(
716                    "{label}: options.env_mode must be \"merge\" or \"replace\", got {other:?}"
717                ),
718            ))));
719        }
720    };
721    let cwd = opts
722        .get("cwd")
723        .map(|v| v.display())
724        .filter(|s| !s.is_empty());
725    // Accept both `timeout` and `timeout_ms` (millis), matching the
726    // `process.exec` host op's tolerance.
727    let timeout = opts
728        .get("timeout")
729        .or_else(|| opts.get("timeout_ms"))
730        .and_then(|v| v.as_int())
731        .filter(|n| *n > 0)
732        .map(|n| Duration::from_millis(n as u64));
733    Ok(ExecOptions {
734        env,
735        env_clear,
736        cwd,
737        timeout,
738    })
739}
740
741/// Build the `exec`-shaped result dict (`stdout`/`stderr`/`status`/`success`)
742/// and additionally surface `timed_out` so options-form callers can detect a
743/// timeout kill without inspecting the exit status.
744fn captured_run_to_value(run: &CapturedRun) -> VmValue {
745    let status = if run.timed_out {
746        -1
747    } else {
748        run.output.status.code().unwrap_or(-1) as i64
749    };
750    let success = !run.timed_out && run.output.status.success();
751    let mut result = BTreeMap::new();
752    result.put_str(
753        "stdout",
754        String::from_utf8_lossy(&run.output.stdout).as_ref(),
755    );
756    result.put_str(
757        "stderr",
758        String::from_utf8_lossy(&run.output.stderr).as_ref(),
759    );
760    result.insert("status".to_string(), VmValue::Int(status));
761    result.insert("success".to_string(), VmValue::Bool(success));
762    result.insert("timed_out".to_string(), VmValue::Bool(run.timed_out));
763    result.insert("duration_ms".to_string(), VmValue::Int(run.duration_ms));
764    VmValue::dict(result)
765}
766
767#[harn_builtin(
768    sig = "exec_opts(command: list, options: dict?) -> dict",
769    category = "process"
770)]
771fn exec_opts_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
772    let command = exec_opts_command("exec_opts", args.first())?;
773    let opts = exec_options("exec_opts", args.get(1))?;
774    let run = run_captured_spawn(CapturedSpawn {
775        label: "exec_opts",
776        cmd: &command[0],
777        args: &command[1..],
778        cwd: opts.cwd.as_deref(),
779        env: &opts.env,
780        env_clear: opts.env_clear,
781        stdin: None,
782        timeout: opts.timeout,
783    })?;
784    Ok(captured_run_to_value(&run))
785}
786
787#[harn_builtin(
788    sig = "exec_at_opts(dir: string, command: list, options: dict?) -> dict",
789    category = "process"
790)]
791fn exec_at_opts_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
792    let dir = match args.first() {
793        Some(value) if !value.display().is_empty() => value.display(),
794        _ => {
795            return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
796                "exec_at_opts: directory is required",
797            ))));
798        }
799    };
800    let command = exec_opts_command("exec_at_opts", args.get(1))?;
801    let opts = exec_options("exec_at_opts", args.get(2))?;
802    // The positional `dir` argument is the working directory; an explicit
803    // `options.cwd` (rare) overrides it so callers retain full control.
804    let resolved_cwd = opts.cwd.unwrap_or(dir);
805    let run = run_captured_spawn(CapturedSpawn {
806        label: "exec_at_opts",
807        cmd: &command[0],
808        args: &command[1..],
809        cwd: Some(resolved_cwd.as_str()),
810        env: &opts.env,
811        env_clear: opts.env_clear,
812        stdin: None,
813        timeout: opts.timeout,
814    })?;
815    Ok(captured_run_to_value(&run))
816}
817
818/// Validate the `command` argument shared by `exec_opts`/`exec_at_opts`: a
819/// non-empty list whose first element is a non-empty program name.
820fn exec_opts_command(label: &str, value: Option<&VmValue>) -> Result<Vec<String>, VmError> {
821    let items = match value {
822        Some(VmValue::List(items)) => items,
823        _ => {
824            return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
825                format!("{label}: command must be a non-empty list of strings"),
826            ))));
827        }
828    };
829    let command: Vec<String> = items.iter().map(|v| v.display()).collect();
830    if command.is_empty() || command[0].is_empty() {
831        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
832            format!("{label}: command must be a non-empty list of strings"),
833        ))));
834    }
835    Ok(command)
836}
837
838/// Find the project root by walking up from a base directory looking for harn.toml.
839pub fn find_project_root(base: &std::path::Path) -> Option<std::path::PathBuf> {
840    let mut dir = base.to_path_buf();
841    loop {
842        if dir.join("harn.toml").exists() {
843            return Some(dir);
844        }
845        if !dir.pop() {
846            return None;
847        }
848    }
849}
850
851/// Register builtins that depend on source directory context.
852pub(crate) fn register_path_builtins(vm: &mut Vm) {
853    for def in PATH_BUILTINS {
854        vm.register_builtin_def(def);
855    }
856}
857
858#[harn_builtin(sig = "source_dir(...args: any) -> string", category = "process")]
859fn source_dir_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
860    let dir = VM_SOURCE_DIR.with(|sd| sd.borrow().clone());
861    match dir {
862        Some(d) => Ok(VmValue::String(arcstr::ArcStr::from(
863            d.to_string_lossy().into_owned(),
864        ))),
865        None => {
866            let cwd = std::env::current_dir()
867                .map(|p| p.to_string_lossy().into_owned())
868                .unwrap_or_default();
869            Ok(VmValue::String(arcstr::ArcStr::from(cwd)))
870        }
871    }
872}
873
874#[harn_builtin(sig = "project_root() -> string?", category = "process")]
875fn project_root_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
876    let base = current_execution_context()
877        .and_then(|context| context.cwd.map(PathBuf::from))
878        .or_else(|| VM_SOURCE_DIR.with(|sd| sd.borrow().clone()))
879        .or_else(|| std::env::current_dir().ok())
880        .unwrap_or_else(|| PathBuf::from("."));
881    match find_project_root(&base) {
882        Some(root) => Ok(VmValue::String(arcstr::ArcStr::from(
883            root.to_string_lossy().into_owned(),
884        ))),
885        None => Ok(VmValue::Nil),
886    }
887}
888
889const PATH_BUILTINS: &[&VmBuiltinDef] = &[&SOURCE_DIR_IMPL_DEF, &PROJECT_ROOT_IMPL_DEF];
890
891fn vm_output_to_value(output: std::process::Output) -> VmValue {
892    let mut result = BTreeMap::new();
893    result.put_str("stdout", String::from_utf8_lossy(&output.stdout).as_ref());
894    result.put_str("stderr", String::from_utf8_lossy(&output.stderr).as_ref());
895    result.insert(
896        "status".to_string(),
897        VmValue::Int(output.status.code().unwrap_or(-1) as i64),
898    );
899    result.insert(
900        "success".to_string(),
901        VmValue::Bool(output.status.success()),
902    );
903    VmValue::dict(result)
904}
905
906fn exec_command(
907    dir: Option<&str>,
908    cmd: &str,
909    args: &[String],
910) -> Result<std::process::Output, VmError> {
911    let config = process_command_config(dir)?;
912    crate::stdlib::sandbox::command_output(cmd, args, &config)
913        .map_err(|error| prefix_process_error(error, "exec"))
914}
915
916#[cfg(test)]
917fn exec_shell(
918    dir: Option<&str>,
919    shell: &str,
920    flag: &str,
921    script: &str,
922) -> Result<std::process::Output, VmError> {
923    let args = vec![flag.to_string(), script.to_string()];
924    exec_shell_args(dir, shell, &args)
925}
926
927fn exec_shell_args(
928    dir: Option<&str>,
929    shell: &str,
930    args: &[String],
931) -> Result<std::process::Output, VmError> {
932    let config = process_command_config(dir)?;
933    crate::stdlib::sandbox::command_output(shell, args, &config)
934        .map_err(|error| prefix_process_error(error, "shell"))
935}
936
937fn process_command_config(
938    dir: Option<&str>,
939) -> Result<crate::stdlib::sandbox::ProcessCommandConfig, VmError> {
940    let mut config = crate::stdlib::sandbox::ProcessCommandConfig {
941        stdin_null: true,
942        ..Default::default()
943    };
944    if let Some(dir) = dir {
945        let resolved = resolve_command_dir(dir);
946        crate::stdlib::sandbox::enforce_process_cwd(&resolved)?;
947        config.cwd = Some(resolved);
948    } else if let Some(context) = current_execution_context() {
949        if let Some(cwd) = context.cwd.filter(|cwd| !cwd.is_empty()) {
950            crate::stdlib::sandbox::enforce_process_cwd(std::path::Path::new(&cwd))?;
951            config.cwd = Some(std::path::PathBuf::from(cwd));
952        }
953        if !context.env.is_empty() {
954            config.env.extend(context.env);
955        }
956    }
957    if let Some(value) = env_override(HARN_REPLAY_ENV) {
958        config.env.push((HARN_REPLAY_ENV.to_string(), value));
959    }
960    Ok(config)
961}
962
963fn prefix_process_error(error: VmError, prefix: &str) -> VmError {
964    match error {
965        VmError::Thrown(VmValue::String(message)) => VmError::Thrown(VmValue::String(
966            arcstr::ArcStr::from(format!("{prefix} failed: {message}")),
967        )),
968        other => other,
969    }
970}
971
972fn resolve_command_dir(dir: &str) -> PathBuf {
973    let candidate = PathBuf::from(dir);
974    if candidate.is_absolute() {
975        return candidate;
976    }
977    if let Some(cwd) = current_execution_context().and_then(|context| context.cwd) {
978        return PathBuf::from(cwd).join(candidate);
979    }
980    if let Some(source_dir) = VM_SOURCE_DIR.with(|sd| sd.borrow().clone()) {
981        return source_dir.join(candidate);
982    }
983    candidate
984}
985
986#[cfg(test)]
987mod tests {
988    use super::*;
989
990    struct RuntimePathsEnvGuard {
991        state: Option<String>,
992        run: Option<String>,
993        worktree: Option<String>,
994    }
995
996    impl RuntimePathsEnvGuard {
997        fn capture() -> Self {
998            Self {
999                state: std::env::var(crate::runtime_paths::HARN_STATE_DIR_ENV).ok(),
1000                run: std::env::var(crate::runtime_paths::HARN_RUN_DIR_ENV).ok(),
1001                worktree: std::env::var(crate::runtime_paths::HARN_WORKTREE_DIR_ENV).ok(),
1002            }
1003        }
1004    }
1005
1006    impl Drop for RuntimePathsEnvGuard {
1007        fn drop(&mut self) {
1008            match self.state.as_deref() {
1009                Some(value) => std::env::set_var(crate::runtime_paths::HARN_STATE_DIR_ENV, value),
1010                None => std::env::remove_var(crate::runtime_paths::HARN_STATE_DIR_ENV),
1011            }
1012            match self.run.as_deref() {
1013                Some(value) => std::env::set_var(crate::runtime_paths::HARN_RUN_DIR_ENV, value),
1014                None => std::env::remove_var(crate::runtime_paths::HARN_RUN_DIR_ENV),
1015            }
1016            match self.worktree.as_deref() {
1017                Some(value) => {
1018                    std::env::set_var(crate::runtime_paths::HARN_WORKTREE_DIR_ENV, value);
1019                }
1020                None => std::env::remove_var(crate::runtime_paths::HARN_WORKTREE_DIR_ENV),
1021            }
1022        }
1023    }
1024
1025    #[test]
1026    fn lexically_collapse_resolves_sibling_walk() {
1027        let path = PathBuf::from("/tmp/project/tests/../fixtures/x.json");
1028        let collapsed = lexically_collapse(&path).expect("sibling walk");
1029        assert_eq!(collapsed, PathBuf::from("/tmp/project/fixtures/x.json"));
1030    }
1031
1032    #[test]
1033    fn lexically_collapse_blocks_escape_past_root() {
1034        // `/app/../etc/passwd` would lexically resolve to `/etc/passwd`,
1035        // but the pop hits a RootDir which is not Normal — refuse.
1036        let path = PathBuf::from("/app/../../etc/passwd");
1037        assert!(lexically_collapse(&path).is_none());
1038    }
1039
1040    #[test]
1041    fn lexically_collapse_strips_curdir() {
1042        let path = PathBuf::from("/app/./logs/today.txt");
1043        let collapsed = lexically_collapse(&path).expect("curdir is benign");
1044        assert_eq!(collapsed, PathBuf::from("/app/logs/today.txt"));
1045    }
1046
1047    #[test]
1048    fn resolve_source_relative_path_blocks_obvious_escape() {
1049        let dir =
1050            std::env::temp_dir().join(format!("harn-process-escape-{}", uuid::Uuid::now_v7()));
1051        std::fs::create_dir_all(&dir).unwrap();
1052        set_thread_source_dir(&dir);
1053        set_thread_execution_context(Some(crate::orchestration::RunExecutionRecord {
1054            cwd: Some(dir.to_string_lossy().into_owned()),
1055            source_dir: Some(dir.to_string_lossy().into_owned()),
1056            env: BTreeMap::new(),
1057            adapter: None,
1058            repo_path: None,
1059            worktree_path: None,
1060            branch: None,
1061            base_ref: None,
1062            cleanup: None,
1063        }));
1064        // A long string of `..` should escape the temp-root and trip
1065        // the rejection sentinel, so the file read fails NotFound
1066        // instead of escaping to a different filesystem location.
1067        let resolved = resolve_source_relative_path("../../../../../../../../etc/passwd");
1068        assert!(
1069            resolved
1070                .to_string_lossy()
1071                .contains("__harn_rejected_parent_dir_traversal__"),
1072            "expected rejection sentinel, got {resolved:?}"
1073        );
1074        reset_process_state();
1075        let _ = std::fs::remove_dir_all(&dir);
1076    }
1077
1078    #[test]
1079    fn resolve_source_relative_path_ignores_thread_source_dir_without_execution_context() {
1080        let dir = std::env::temp_dir().join(format!("harn-process-{}", uuid::Uuid::now_v7()));
1081        std::fs::create_dir_all(&dir).unwrap();
1082        let current_dir = std::env::current_dir().unwrap();
1083        set_thread_source_dir(&dir);
1084        let resolved = resolve_source_relative_path("templates/prompt.txt");
1085        assert_eq!(resolved, current_dir.join("templates/prompt.txt"));
1086        reset_process_state();
1087        let _ = std::fs::remove_dir_all(&dir);
1088    }
1089
1090    #[test]
1091    fn resolve_source_relative_path_prefers_execution_cwd_over_source_dir() {
1092        let cwd = std::env::temp_dir().join(format!("harn-process-cwd-{}", uuid::Uuid::now_v7()));
1093        let source_dir =
1094            std::env::temp_dir().join(format!("harn-process-source-{}", uuid::Uuid::now_v7()));
1095        std::fs::create_dir_all(&cwd).unwrap();
1096        std::fs::create_dir_all(&source_dir).unwrap();
1097        set_thread_source_dir(&source_dir);
1098        set_thread_execution_context(Some(crate::orchestration::RunExecutionRecord {
1099            cwd: Some(cwd.to_string_lossy().into_owned()),
1100            source_dir: Some(source_dir.to_string_lossy().into_owned()),
1101            env: BTreeMap::new(),
1102            adapter: None,
1103            repo_path: None,
1104            worktree_path: None,
1105            branch: None,
1106            base_ref: None,
1107            cleanup: None,
1108        }));
1109        let resolved = resolve_source_relative_path("templates/prompt.txt");
1110        assert_eq!(resolved, cwd.join("templates/prompt.txt"));
1111        reset_process_state();
1112        let _ = std::fs::remove_dir_all(&cwd);
1113        let _ = std::fs::remove_dir_all(&source_dir);
1114    }
1115
1116    #[test]
1117    fn resolve_source_asset_path_prefers_execution_source_dir_over_cwd() {
1118        let cwd = std::env::temp_dir().join(format!("harn-asset-cwd-{}", uuid::Uuid::now_v7()));
1119        let source_dir =
1120            std::env::temp_dir().join(format!("harn-asset-source-{}", uuid::Uuid::now_v7()));
1121        std::fs::create_dir_all(&cwd).unwrap();
1122        std::fs::create_dir_all(&source_dir).unwrap();
1123        set_thread_source_dir(&source_dir);
1124        set_thread_execution_context(Some(crate::orchestration::RunExecutionRecord {
1125            cwd: Some(cwd.to_string_lossy().into_owned()),
1126            source_dir: Some(source_dir.to_string_lossy().into_owned()),
1127            env: BTreeMap::new(),
1128            adapter: None,
1129            repo_path: None,
1130            worktree_path: None,
1131            branch: None,
1132            base_ref: None,
1133            cleanup: None,
1134        }));
1135        let resolved = resolve_source_asset_path("templates/prompt.txt");
1136        assert_eq!(resolved, source_dir.join("templates/prompt.txt"));
1137        reset_process_state();
1138        let _ = std::fs::remove_dir_all(&cwd);
1139        let _ = std::fs::remove_dir_all(&source_dir);
1140    }
1141
1142    #[test]
1143    fn set_thread_source_dir_absolutizes_relative_paths() {
1144        reset_process_state();
1145        let current_dir = std::env::current_dir().unwrap();
1146        set_thread_source_dir(std::path::Path::new("scripts"));
1147        assert_eq!(source_root_path(), current_dir.join("scripts"));
1148        reset_process_state();
1149    }
1150
1151    #[test]
1152    fn exec_context_sets_default_cwd_and_env() {
1153        let dir = std::env::temp_dir().join(format!("harn-process-ctx-{}", uuid::Uuid::now_v7()));
1154        std::fs::create_dir_all(&dir).unwrap();
1155        std::fs::write(dir.join("marker.txt"), "ok").unwrap();
1156        set_thread_execution_context(Some(RunExecutionRecord {
1157            cwd: Some(dir.to_string_lossy().into_owned()),
1158            env: BTreeMap::from([("HARN_PROCESS_TEST".to_string(), "present".to_string())]),
1159            ..Default::default()
1160        }));
1161        let output = exec_shell(
1162            None,
1163            "sh",
1164            "-c",
1165            "printf '%s:' \"$HARN_PROCESS_TEST\" && test -f marker.txt",
1166        )
1167        .unwrap();
1168        assert!(output.status.success());
1169        assert_eq!(String::from_utf8_lossy(&output.stdout), "present:");
1170        reset_process_state();
1171        let _ = std::fs::remove_dir_all(&dir);
1172    }
1173
1174    #[test]
1175    fn exec_at_resolves_relative_to_execution_cwd() {
1176        let dir = std::env::temp_dir().join(format!("harn-process-rel-{}", uuid::Uuid::now_v7()));
1177        std::fs::create_dir_all(dir.join("nested")).unwrap();
1178        std::fs::write(dir.join("nested").join("marker.txt"), "ok").unwrap();
1179        set_thread_execution_context(Some(RunExecutionRecord {
1180            cwd: Some(dir.to_string_lossy().into_owned()),
1181            ..Default::default()
1182        }));
1183        let output = exec_shell(Some("nested"), "sh", "-c", "test -f marker.txt").unwrap();
1184        assert!(output.status.success());
1185        reset_process_state();
1186        let _ = std::fs::remove_dir_all(&dir);
1187    }
1188
1189    #[test]
1190    fn runtime_paths_uses_configurable_state_roots() {
1191        let _runtime_paths_env_lock = crate::runtime_paths::test_env_lock()
1192            .lock()
1193            .unwrap_or_else(|poisoned| poisoned.into_inner());
1194        let _env_guard = RuntimePathsEnvGuard::capture();
1195        let base =
1196            std::env::temp_dir().join(format!("harn-process-runtime-{}", uuid::Uuid::now_v7()));
1197        std::fs::create_dir_all(&base).unwrap();
1198        std::env::set_var(crate::runtime_paths::HARN_STATE_DIR_ENV, ".custom-harn");
1199        std::env::set_var(crate::runtime_paths::HARN_RUN_DIR_ENV, ".custom-runs");
1200        std::env::set_var(
1201            crate::runtime_paths::HARN_WORKTREE_DIR_ENV,
1202            ".custom-worktrees",
1203        );
1204        set_thread_execution_context(Some(RunExecutionRecord {
1205            cwd: Some(base.to_string_lossy().into_owned()),
1206            ..Default::default()
1207        }));
1208
1209        let mut vm = crate::vm::Vm::new();
1210        register_process_builtins(&mut vm);
1211        let mut out = String::new();
1212        let builtin = vm
1213            .builtins
1214            .get("runtime_paths")
1215            .expect("runtime_paths builtin");
1216        let paths = match builtin(&[], &mut out).unwrap() {
1217            VmValue::Dict(map) => map,
1218            other => panic!("expected dict, got {other:?}"),
1219        };
1220        assert_eq!(
1221            paths.get("state_root").unwrap().display(),
1222            base.join(".custom-harn").display().to_string()
1223        );
1224        assert_eq!(
1225            paths.get("run_root").unwrap().display(),
1226            base.join(".custom-runs").display().to_string()
1227        );
1228        assert_eq!(
1229            paths.get("worktree_root").unwrap().display(),
1230            base.join(".custom-worktrees").display().to_string()
1231        );
1232
1233        reset_process_state();
1234        let _ = std::fs::remove_dir_all(&base);
1235    }
1236
1237    #[cfg(unix)]
1238    fn exec_opts_list(items: &[&str]) -> VmValue {
1239        VmValue::List(std::sync::Arc::new(
1240            items
1241                .iter()
1242                .map(|s| VmValue::String(arcstr::ArcStr::from(*s)))
1243                .collect(),
1244        ))
1245    }
1246
1247    #[cfg(unix)]
1248    fn exec_opts_dict(pairs: &[(&str, VmValue)]) -> VmValue {
1249        VmValue::dict(
1250            pairs
1251                .iter()
1252                .map(|(k, v)| (crate::value::intern_key(k), v.clone()))
1253                .collect::<crate::value::DictMap>(),
1254        )
1255    }
1256
1257    #[cfg(unix)]
1258    #[test]
1259    fn exec_opts_merges_env_with_parent_by_default() {
1260        std::env::set_var("HARN_EXEC_OPTS_PARENT", "from-parent");
1261        let env = exec_opts_dict(&[("CHILD", VmValue::String(arcstr::ArcStr::from("from-child")))]);
1262        let args = vec![
1263            exec_opts_list(&[
1264                "/bin/sh",
1265                "-c",
1266                "printf '%s|%s' \"$HARN_EXEC_OPTS_PARENT\" \"$CHILD\"",
1267            ]),
1268            exec_opts_dict(&[("env", env)]),
1269        ];
1270        let mut out = String::new();
1271        let result = exec_opts_impl(&args, &mut out).expect("exec_opts result");
1272        let dict = result.as_dict().expect("dict");
1273        assert_eq!(
1274            dict.get("stdout").unwrap().display(),
1275            "from-parent|from-child"
1276        );
1277        assert!(matches!(dict.get("success"), Some(VmValue::Bool(true))));
1278        std::env::remove_var("HARN_EXEC_OPTS_PARENT");
1279    }
1280
1281    #[cfg(unix)]
1282    #[test]
1283    fn exec_opts_replace_env_clears_parent() {
1284        std::env::set_var("HARN_EXEC_OPTS_PARENT2", "from-parent");
1285        let env = exec_opts_dict(&[("CHILD", VmValue::String(arcstr::ArcStr::from("from-child")))]);
1286        let args = vec![
1287            exec_opts_list(&[
1288                "/bin/sh",
1289                "-c",
1290                "printf '%s|%s' \"$HARN_EXEC_OPTS_PARENT2\" \"$CHILD\"",
1291            ]),
1292            exec_opts_dict(&[
1293                ("env", env),
1294                ("env_mode", VmValue::String(arcstr::ArcStr::from("replace"))),
1295            ]),
1296        ];
1297        let mut out = String::new();
1298        let result = exec_opts_impl(&args, &mut out).expect("exec_opts result");
1299        let dict = result.as_dict().expect("dict");
1300        assert_eq!(dict.get("stdout").unwrap().display(), "|from-child");
1301        std::env::remove_var("HARN_EXEC_OPTS_PARENT2");
1302    }
1303
1304    #[cfg(unix)]
1305    #[test]
1306    fn exec_at_opts_honors_directory() {
1307        let dir = std::env::temp_dir().join(format!("harn-exec-opts-cwd-{}", uuid::Uuid::now_v7()));
1308        std::fs::create_dir_all(&dir).unwrap();
1309        let args = vec![
1310            VmValue::String(arcstr::ArcStr::from(dir.to_string_lossy().into_owned())),
1311            exec_opts_list(&["/bin/sh", "-c", "pwd -P"]),
1312        ];
1313        let mut out = String::new();
1314        let result = exec_at_opts_impl(&args, &mut out).expect("exec_at_opts result");
1315        let dict = result.as_dict().expect("dict");
1316        // macOS /tmp is a symlink to /private/tmp; canonicalize for comparison.
1317        let want = std::fs::canonicalize(&dir).unwrap();
1318        let got = dict.get("stdout").unwrap().display();
1319        assert_eq!(got.trim(), want.to_string_lossy());
1320        let _ = std::fs::remove_dir_all(&dir);
1321    }
1322
1323    #[cfg(unix)]
1324    #[test]
1325    fn exec_opts_enforces_timeout() {
1326        let args = vec![
1327            exec_opts_list(&["/bin/sh", "-c", "sleep 5"]),
1328            exec_opts_dict(&[("timeout", VmValue::Int(50))]),
1329        ];
1330        let mut out = String::new();
1331        let result = exec_opts_impl(&args, &mut out).expect("exec_opts result");
1332        let dict = result.as_dict().expect("dict");
1333        assert!(
1334            matches!(dict.get("timed_out"), Some(VmValue::Bool(true))),
1335            "command exceeding timeout must report timed_out"
1336        );
1337        assert!(matches!(dict.get("success"), Some(VmValue::Bool(false))));
1338    }
1339
1340    #[cfg(unix)]
1341    #[test]
1342    fn exec_opts_rejects_empty_command() {
1343        let args = vec![exec_opts_list(&[])];
1344        let mut out = String::new();
1345        assert!(exec_opts_impl(&args, &mut out).is_err());
1346        let bad = vec![VmValue::String(arcstr::ArcStr::from("not-a-list"))];
1347        assert!(exec_opts_impl(&bad, &mut out).is_err());
1348    }
1349
1350    #[cfg(unix)]
1351    #[test]
1352    fn exec_opts_interrupt_kills_child_process_group() {
1353        use std::sync::atomic::AtomicBool;
1354        use std::sync::Arc;
1355
1356        // An armed cancel token (the shape scope cancellation / deadline
1357        // expiry takes by the time the wait loop polls) must terminate the
1358        // child *and its grandchild* long before the command finishes.
1359        let cancel = Arc::new(AtomicBool::new(false));
1360        let _guard = crate::op_interrupt::install(Some(Arc::clone(&cancel)), None);
1361        let flipper = {
1362            let cancel = Arc::clone(&cancel);
1363            std::thread::spawn(move || {
1364                std::thread::sleep(Duration::from_millis(200));
1365                cancel.store(true, std::sync::atomic::Ordering::SeqCst);
1366            })
1367        };
1368
1369        let started = Instant::now();
1370        let args = vec![exec_opts_list(&[
1371            "/bin/sh",
1372            "-c",
1373            // Write the group id, spawn a grandchild, then block.
1374            "echo started; sleep 30 & wait",
1375        ])];
1376        let mut out = String::new();
1377        let result = exec_opts_impl(&args, &mut out).expect("exec_opts result");
1378        flipper.join().unwrap();
1379
1380        assert!(
1381            started.elapsed() < Duration::from_secs(10),
1382            "interrupt must preempt the 30s child, took {:?}",
1383            started.elapsed()
1384        );
1385        let dict = result.as_dict().expect("dict");
1386        assert!(matches!(dict.get("success"), Some(VmValue::Bool(false))));
1387        assert!(matches!(dict.get("timed_out"), Some(VmValue::Bool(false))));
1388    }
1389}