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