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