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