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