Skip to main content

harn_vm/stdlib/
process.rs

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