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