Skip to main content

harn_vm/stdlib/
process.rs

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