Skip to main content

harn_vm/stdlib/
process.rs

1use std::cell::RefCell;
2use std::collections::BTreeMap;
3use std::path::PathBuf;
4use std::rc::Rc;
5
6use crate::orchestration::RunExecutionRecord;
7use crate::value::{VmError, VmValue};
8use crate::vm::Vm;
9
10const HARN_REPLAY_ENV: &str = "HARN_REPLAY";
11
12thread_local! {
13    pub(crate) static VM_SOURCE_DIR: RefCell<Option<PathBuf>> = const { RefCell::new(None) };
14    static VM_EXECUTION_CONTEXT: RefCell<Option<RunExecutionRecord>> = const { RefCell::new(None) };
15}
16
17/// Set the source directory for the current thread (called by VM on file execution).
18pub(crate) fn set_thread_source_dir(dir: &std::path::Path) {
19    VM_SOURCE_DIR.with(|sd| *sd.borrow_mut() = Some(normalize_context_path(dir)));
20}
21
22pub(crate) fn normalize_context_path(path: &std::path::Path) -> PathBuf {
23    if path.is_absolute() {
24        return path.to_path_buf();
25    }
26    std::env::current_dir()
27        .map(|cwd| cwd.join(path))
28        .unwrap_or_else(|_| path.to_path_buf())
29}
30
31pub fn set_thread_execution_context(context: Option<RunExecutionRecord>) {
32    VM_EXECUTION_CONTEXT.with(|current| *current.borrow_mut() = context);
33}
34
35pub(crate) fn current_execution_context() -> Option<RunExecutionRecord> {
36    VM_EXECUTION_CONTEXT.with(|current| current.borrow().clone())
37}
38
39/// Reset thread-local process state (for test isolation).
40pub(crate) fn reset_process_state() {
41    VM_SOURCE_DIR.with(|sd| *sd.borrow_mut() = None);
42    VM_EXECUTION_CONTEXT.with(|current| *current.borrow_mut() = None);
43}
44
45pub fn execution_root_path() -> PathBuf {
46    current_execution_context()
47        .and_then(|context| context.cwd.map(PathBuf::from))
48        .or_else(|| std::env::current_dir().ok())
49        .unwrap_or_else(|| PathBuf::from("."))
50}
51
52pub fn source_root_path() -> PathBuf {
53    VM_SOURCE_DIR
54        .with(|sd| sd.borrow().clone())
55        .or_else(|| {
56            current_execution_context().and_then(|context| context.source_dir.map(PathBuf::from))
57        })
58        .or_else(|| current_execution_context().and_then(|context| context.cwd.map(PathBuf::from)))
59        .or_else(|| std::env::current_dir().ok())
60        .unwrap_or_else(|| PathBuf::from("."))
61}
62
63pub fn asset_root_path() -> PathBuf {
64    source_root_path()
65}
66
67fn env_override(name: &str) -> Option<String> {
68    (name == HARN_REPLAY_ENV && crate::triggers::dispatcher::current_dispatch_is_replay())
69        .then(|| "1".to_string())
70}
71
72fn read_env_value(name: &str) -> Option<String> {
73    env_override(name)
74        .or_else(|| current_execution_context().and_then(|context| context.env.get(name).cloned()))
75        .or_else(|| std::env::var(name).ok())
76}
77
78pub fn runtime_root_base() -> PathBuf {
79    find_project_root(&execution_root_path())
80        .or_else(|| find_project_root(&source_root_path()))
81        .unwrap_or_else(source_root_path)
82}
83
84pub fn resolve_source_relative_path(path: &str) -> PathBuf {
85    let candidate = PathBuf::from(path);
86    if candidate.is_absolute() {
87        return candidate;
88    }
89    execution_root_path().join(candidate)
90}
91
92pub fn resolve_source_asset_path(path: &str) -> PathBuf {
93    let candidate = PathBuf::from(path);
94    if candidate.is_absolute() {
95        return candidate;
96    }
97    asset_root_path().join(candidate)
98}
99
100pub(crate) fn register_process_builtins(vm: &mut Vm) {
101    vm.register_builtin("env", |args, _out| {
102        let name = args.first().map(|a| a.display()).unwrap_or_default();
103        if let Some(value) = read_env_value(&name) {
104            return Ok(VmValue::String(Rc::from(value)));
105        }
106        Ok(VmValue::Nil)
107    });
108
109    vm.register_builtin("env_or", |args, _out| {
110        let name = args.first().map(|a| a.display()).unwrap_or_default();
111        let default = args.get(1).cloned().unwrap_or(VmValue::Nil);
112        if let Some(value) = read_env_value(&name) {
113            return Ok(VmValue::String(Rc::from(value)));
114        }
115        Ok(default)
116    });
117
118    // `timestamp` / `elapsed` are now registered by clock.rs so they
119    // honor mock_time / advance_time. Don't register here.
120
121    vm.register_builtin("exit", |args, _out| {
122        let code = args.first().and_then(|a| a.as_int()).unwrap_or(0);
123        std::process::exit(code as i32);
124    });
125
126    vm.register_builtin("exec", |args, _out| {
127        if args.is_empty() {
128            return Err(VmError::Thrown(VmValue::String(Rc::from(
129                "exec: command is required",
130            ))));
131        }
132        let cmd = args[0].display();
133        let cmd_args: Vec<String> = args[1..].iter().map(|a| a.display()).collect();
134        let output = exec_command(None, &cmd, &cmd_args)?;
135        Ok(vm_output_to_value(output))
136    });
137
138    vm.register_builtin("shell", |args, _out| {
139        let cmd = args.first().map(|a| a.display()).unwrap_or_default();
140        if cmd.is_empty() {
141            return Err(VmError::Thrown(VmValue::String(Rc::from(
142                "shell: command string is required",
143            ))));
144        }
145        let invocation = crate::shells::default_shell_invocation(&cmd)
146            .map_err(|error| VmError::Runtime(format!("shell: {error}")))?;
147        let output = exec_shell_args(None, &invocation.program, &invocation.args)?;
148        Ok(vm_output_to_value(output))
149    });
150
151    vm.register_builtin("exec_at", |args, _out| {
152        if args.len() < 2 {
153            return Err(VmError::Thrown(VmValue::String(Rc::from(
154                "exec_at: directory and command are required",
155            ))));
156        }
157        let dir = args[0].display();
158        let cmd = args[1].display();
159        let cmd_args: Vec<String> = args[2..].iter().map(|a| a.display()).collect();
160        let output = exec_command(Some(dir.as_str()), &cmd, &cmd_args)?;
161        Ok(vm_output_to_value(output))
162    });
163
164    vm.register_builtin("shell_at", |args, _out| {
165        if args.len() < 2 {
166            return Err(VmError::Thrown(VmValue::String(Rc::from(
167                "shell_at: directory and command string are required",
168            ))));
169        }
170        let dir = args[0].display();
171        let cmd = args[1].display();
172        if cmd.is_empty() {
173            return Err(VmError::Thrown(VmValue::String(Rc::from(
174                "shell_at: command string is required",
175            ))));
176        }
177        let invocation = crate::shells::default_shell_invocation(&cmd)
178            .map_err(|error| VmError::Runtime(format!("shell_at: {error}")))?;
179        let output = exec_shell_args(Some(dir.as_str()), &invocation.program, &invocation.args)?;
180        Ok(vm_output_to_value(output))
181    });
182
183    // `elapsed` registered by clock.rs (mockable). See note above.
184
185    vm.register_builtin("username", |_args, _out| {
186        let user = std::env::var("USER")
187            .or_else(|_| std::env::var("USERNAME"))
188            .unwrap_or_default();
189        Ok(VmValue::String(Rc::from(user)))
190    });
191
192    vm.register_builtin("hostname", |_args, _out| {
193        let name = std::env::var("HOSTNAME")
194            .or_else(|_| std::env::var("COMPUTERNAME"))
195            .or_else(|_| {
196                std::process::Command::new("hostname")
197                    .output()
198                    .ok()
199                    .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
200                    .ok_or(std::env::VarError::NotPresent)
201            })
202            .unwrap_or_default();
203        Ok(VmValue::String(Rc::from(name)))
204    });
205
206    vm.register_builtin("platform", |_args, _out| {
207        let os = if cfg!(target_os = "macos") {
208            "darwin"
209        } else if cfg!(target_os = "linux") {
210            "linux"
211        } else if cfg!(target_os = "windows") {
212            "windows"
213        } else {
214            std::env::consts::OS
215        };
216        Ok(VmValue::String(Rc::from(os)))
217    });
218
219    vm.register_builtin("arch", |_args, _out| {
220        Ok(VmValue::String(Rc::from(std::env::consts::ARCH)))
221    });
222
223    vm.register_builtin("home_dir", |_args, _out| {
224        let home = std::env::var("HOME")
225            .or_else(|_| std::env::var("USERPROFILE"))
226            .unwrap_or_default();
227        Ok(VmValue::String(Rc::from(home)))
228    });
229
230    vm.register_builtin("pid", |_args, _out| {
231        Ok(VmValue::Int(std::process::id() as i64))
232    });
233
234    vm.register_builtin("date_iso", |_args, _out| {
235        // `date_iso` reads the OS wall clock directly (it predates the
236        // unified `clock_mock`). Routing through `leak_audit::wall_now`
237        // keeps the production behavior unchanged but surfaces the call
238        // in `testbench_clock_leaks()` whenever a script invokes it
239        // under a paused testbench session, so fidelity hazards are
240        // visible instead of silently corrupting tapes.
241        let now = crate::clock_mock::leak_audit::wall_now("stdlib/date_iso");
242        let dt: chrono::DateTime<chrono::Utc> = now.into();
243        Ok(VmValue::String(Rc::from(
244            dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
245        )))
246    });
247
248    vm.register_builtin("cwd", |_args, _out| {
249        let dir = current_execution_context()
250            .and_then(|context| context.cwd)
251            .or_else(|| {
252                std::env::current_dir()
253                    .ok()
254                    .map(|p| p.to_string_lossy().into_owned())
255            })
256            .unwrap_or_default();
257        Ok(VmValue::String(Rc::from(dir)))
258    });
259
260    vm.register_builtin("execution_root", |_args, _out| {
261        Ok(VmValue::String(Rc::from(
262            execution_root_path().to_string_lossy().into_owned(),
263        )))
264    });
265
266    vm.register_builtin("asset_root", |_args, _out| {
267        Ok(VmValue::String(Rc::from(
268            asset_root_path().to_string_lossy().into_owned(),
269        )))
270    });
271
272    vm.register_builtin("runtime_paths", |_args, _out| {
273        let runtime_base = runtime_root_base();
274        let mut paths = BTreeMap::new();
275        paths.insert(
276            "execution_root".to_string(),
277            VmValue::String(Rc::from(
278                execution_root_path().to_string_lossy().into_owned(),
279            )),
280        );
281        paths.insert(
282            "asset_root".to_string(),
283            VmValue::String(Rc::from(asset_root_path().to_string_lossy().into_owned())),
284        );
285        paths.insert(
286            "state_root".to_string(),
287            VmValue::String(Rc::from(
288                crate::runtime_paths::state_root(&runtime_base)
289                    .to_string_lossy()
290                    .into_owned(),
291            )),
292        );
293        paths.insert(
294            "run_root".to_string(),
295            VmValue::String(Rc::from(
296                crate::runtime_paths::run_root(&runtime_base)
297                    .to_string_lossy()
298                    .into_owned(),
299            )),
300        );
301        paths.insert(
302            "worktree_root".to_string(),
303            VmValue::String(Rc::from(
304                crate::runtime_paths::worktree_root(&runtime_base)
305                    .to_string_lossy()
306                    .into_owned(),
307            )),
308        );
309        Ok(VmValue::Dict(Rc::new(paths)))
310    });
311}
312
313/// Find the project root by walking up from a base directory looking for harn.toml.
314pub fn find_project_root(base: &std::path::Path) -> Option<std::path::PathBuf> {
315    let mut dir = base.to_path_buf();
316    loop {
317        if dir.join("harn.toml").exists() {
318            return Some(dir);
319        }
320        if !dir.pop() {
321            return None;
322        }
323    }
324}
325
326/// Register builtins that depend on source directory context.
327pub(crate) fn register_path_builtins(vm: &mut Vm) {
328    vm.register_builtin("source_dir", |_args, _out| {
329        let dir = VM_SOURCE_DIR.with(|sd| sd.borrow().clone());
330        match dir {
331            Some(d) => Ok(VmValue::String(Rc::from(d.to_string_lossy().into_owned()))),
332            None => {
333                let cwd = std::env::current_dir()
334                    .map(|p| p.to_string_lossy().into_owned())
335                    .unwrap_or_default();
336                Ok(VmValue::String(Rc::from(cwd)))
337            }
338        }
339    });
340
341    vm.register_builtin("project_root", |_args, _out| {
342        let base = current_execution_context()
343            .and_then(|context| context.cwd.map(PathBuf::from))
344            .or_else(|| VM_SOURCE_DIR.with(|sd| sd.borrow().clone()))
345            .or_else(|| std::env::current_dir().ok())
346            .unwrap_or_else(|| PathBuf::from("."));
347        match find_project_root(&base) {
348            Some(root) => Ok(VmValue::String(Rc::from(
349                root.to_string_lossy().into_owned(),
350            ))),
351            None => Ok(VmValue::Nil),
352        }
353    });
354}
355
356fn vm_output_to_value(output: std::process::Output) -> VmValue {
357    let mut result = BTreeMap::new();
358    result.insert(
359        "stdout".to_string(),
360        VmValue::String(Rc::from(
361            String::from_utf8_lossy(&output.stdout).to_string().as_str(),
362        )),
363    );
364    result.insert(
365        "stderr".to_string(),
366        VmValue::String(Rc::from(
367            String::from_utf8_lossy(&output.stderr).to_string().as_str(),
368        )),
369    );
370    result.insert(
371        "status".to_string(),
372        VmValue::Int(output.status.code().unwrap_or(-1) as i64),
373    );
374    result.insert(
375        "success".to_string(),
376        VmValue::Bool(output.status.success()),
377    );
378    VmValue::Dict(Rc::new(result))
379}
380
381fn exec_command(
382    dir: Option<&str>,
383    cmd: &str,
384    args: &[String],
385) -> Result<std::process::Output, VmError> {
386    let config = process_command_config(dir)?;
387    crate::stdlib::sandbox::command_output(cmd, args, &config)
388        .map_err(|error| prefix_process_error(error, "exec"))
389}
390
391#[cfg(test)]
392fn exec_shell(
393    dir: Option<&str>,
394    shell: &str,
395    flag: &str,
396    script: &str,
397) -> Result<std::process::Output, VmError> {
398    let args = vec![flag.to_string(), script.to_string()];
399    exec_shell_args(dir, shell, &args)
400}
401
402fn exec_shell_args(
403    dir: Option<&str>,
404    shell: &str,
405    args: &[String],
406) -> Result<std::process::Output, VmError> {
407    let config = process_command_config(dir)?;
408    crate::stdlib::sandbox::command_output(shell, args, &config)
409        .map_err(|error| prefix_process_error(error, "shell"))
410}
411
412fn process_command_config(
413    dir: Option<&str>,
414) -> Result<crate::stdlib::sandbox::ProcessCommandConfig, VmError> {
415    let mut config = crate::stdlib::sandbox::ProcessCommandConfig {
416        stdin_null: true,
417        ..Default::default()
418    };
419    if let Some(dir) = dir {
420        let resolved = resolve_command_dir(dir);
421        crate::stdlib::sandbox::enforce_process_cwd(&resolved)?;
422        config.cwd = Some(resolved);
423    } else if let Some(context) = current_execution_context() {
424        if let Some(cwd) = context.cwd.filter(|cwd| !cwd.is_empty()) {
425            crate::stdlib::sandbox::enforce_process_cwd(std::path::Path::new(&cwd))?;
426            config.cwd = Some(std::path::PathBuf::from(cwd));
427        }
428        if !context.env.is_empty() {
429            config.env.extend(context.env);
430        }
431    }
432    if let Some(value) = env_override(HARN_REPLAY_ENV) {
433        config.env.push((HARN_REPLAY_ENV.to_string(), value));
434    }
435    Ok(config)
436}
437
438fn prefix_process_error(error: VmError, prefix: &str) -> VmError {
439    match error {
440        VmError::Thrown(VmValue::String(message)) => VmError::Thrown(VmValue::String(Rc::from(
441            format!("{prefix} failed: {message}"),
442        ))),
443        other => other,
444    }
445}
446
447fn resolve_command_dir(dir: &str) -> PathBuf {
448    let candidate = PathBuf::from(dir);
449    if candidate.is_absolute() {
450        return candidate;
451    }
452    if let Some(cwd) = current_execution_context().and_then(|context| context.cwd) {
453        return PathBuf::from(cwd).join(candidate);
454    }
455    if let Some(source_dir) = VM_SOURCE_DIR.with(|sd| sd.borrow().clone()) {
456        return source_dir.join(candidate);
457    }
458    candidate
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464
465    #[test]
466    fn resolve_source_relative_path_ignores_thread_source_dir_without_execution_context() {
467        let dir = std::env::temp_dir().join(format!("harn-process-{}", uuid::Uuid::now_v7()));
468        std::fs::create_dir_all(&dir).unwrap();
469        let current_dir = std::env::current_dir().unwrap();
470        set_thread_source_dir(&dir);
471        let resolved = resolve_source_relative_path("templates/prompt.txt");
472        assert_eq!(resolved, current_dir.join("templates/prompt.txt"));
473        reset_process_state();
474        let _ = std::fs::remove_dir_all(&dir);
475    }
476
477    #[test]
478    fn resolve_source_relative_path_prefers_execution_cwd_over_source_dir() {
479        let cwd = std::env::temp_dir().join(format!("harn-process-cwd-{}", uuid::Uuid::now_v7()));
480        let source_dir =
481            std::env::temp_dir().join(format!("harn-process-source-{}", uuid::Uuid::now_v7()));
482        std::fs::create_dir_all(&cwd).unwrap();
483        std::fs::create_dir_all(&source_dir).unwrap();
484        set_thread_source_dir(&source_dir);
485        set_thread_execution_context(Some(crate::orchestration::RunExecutionRecord {
486            cwd: Some(cwd.to_string_lossy().into_owned()),
487            source_dir: Some(source_dir.to_string_lossy().into_owned()),
488            env: BTreeMap::new(),
489            adapter: None,
490            repo_path: None,
491            worktree_path: None,
492            branch: None,
493            base_ref: None,
494            cleanup: None,
495        }));
496        let resolved = resolve_source_relative_path("templates/prompt.txt");
497        assert_eq!(resolved, cwd.join("templates/prompt.txt"));
498        reset_process_state();
499        let _ = std::fs::remove_dir_all(&cwd);
500        let _ = std::fs::remove_dir_all(&source_dir);
501    }
502
503    #[test]
504    fn resolve_source_asset_path_prefers_execution_source_dir_over_cwd() {
505        let cwd = std::env::temp_dir().join(format!("harn-asset-cwd-{}", uuid::Uuid::now_v7()));
506        let source_dir =
507            std::env::temp_dir().join(format!("harn-asset-source-{}", uuid::Uuid::now_v7()));
508        std::fs::create_dir_all(&cwd).unwrap();
509        std::fs::create_dir_all(&source_dir).unwrap();
510        set_thread_source_dir(&source_dir);
511        set_thread_execution_context(Some(crate::orchestration::RunExecutionRecord {
512            cwd: Some(cwd.to_string_lossy().into_owned()),
513            source_dir: Some(source_dir.to_string_lossy().into_owned()),
514            env: BTreeMap::new(),
515            adapter: None,
516            repo_path: None,
517            worktree_path: None,
518            branch: None,
519            base_ref: None,
520            cleanup: None,
521        }));
522        let resolved = resolve_source_asset_path("templates/prompt.txt");
523        assert_eq!(resolved, source_dir.join("templates/prompt.txt"));
524        reset_process_state();
525        let _ = std::fs::remove_dir_all(&cwd);
526        let _ = std::fs::remove_dir_all(&source_dir);
527    }
528
529    #[test]
530    fn set_thread_source_dir_absolutizes_relative_paths() {
531        reset_process_state();
532        let current_dir = std::env::current_dir().unwrap();
533        set_thread_source_dir(std::path::Path::new("scripts"));
534        assert_eq!(source_root_path(), current_dir.join("scripts"));
535        reset_process_state();
536    }
537
538    #[test]
539    fn exec_context_sets_default_cwd_and_env() {
540        let dir = std::env::temp_dir().join(format!("harn-process-ctx-{}", uuid::Uuid::now_v7()));
541        std::fs::create_dir_all(&dir).unwrap();
542        std::fs::write(dir.join("marker.txt"), "ok").unwrap();
543        set_thread_execution_context(Some(RunExecutionRecord {
544            cwd: Some(dir.to_string_lossy().into_owned()),
545            env: BTreeMap::from([("HARN_PROCESS_TEST".to_string(), "present".to_string())]),
546            ..Default::default()
547        }));
548        let output = exec_shell(
549            None,
550            "sh",
551            "-c",
552            "printf '%s:' \"$HARN_PROCESS_TEST\" && test -f marker.txt",
553        )
554        .unwrap();
555        assert!(output.status.success());
556        assert_eq!(String::from_utf8_lossy(&output.stdout), "present:");
557        reset_process_state();
558        let _ = std::fs::remove_dir_all(&dir);
559    }
560
561    #[test]
562    fn exec_at_resolves_relative_to_execution_cwd() {
563        let dir = std::env::temp_dir().join(format!("harn-process-rel-{}", uuid::Uuid::now_v7()));
564        std::fs::create_dir_all(dir.join("nested")).unwrap();
565        std::fs::write(dir.join("nested").join("marker.txt"), "ok").unwrap();
566        set_thread_execution_context(Some(RunExecutionRecord {
567            cwd: Some(dir.to_string_lossy().into_owned()),
568            ..Default::default()
569        }));
570        let output = exec_shell(Some("nested"), "sh", "-c", "test -f marker.txt").unwrap();
571        assert!(output.status.success());
572        reset_process_state();
573        let _ = std::fs::remove_dir_all(&dir);
574    }
575
576    #[test]
577    fn runtime_paths_uses_configurable_state_roots() {
578        let base =
579            std::env::temp_dir().join(format!("harn-process-runtime-{}", uuid::Uuid::now_v7()));
580        std::fs::create_dir_all(&base).unwrap();
581        std::env::set_var(crate::runtime_paths::HARN_STATE_DIR_ENV, ".custom-harn");
582        std::env::set_var(crate::runtime_paths::HARN_RUN_DIR_ENV, ".custom-runs");
583        std::env::set_var(
584            crate::runtime_paths::HARN_WORKTREE_DIR_ENV,
585            ".custom-worktrees",
586        );
587        set_thread_execution_context(Some(RunExecutionRecord {
588            cwd: Some(base.to_string_lossy().into_owned()),
589            ..Default::default()
590        }));
591
592        let mut vm = crate::vm::Vm::new();
593        register_process_builtins(&mut vm);
594        let mut out = String::new();
595        let builtin = vm
596            .builtins
597            .get("runtime_paths")
598            .expect("runtime_paths builtin");
599        let paths = match builtin(&[], &mut out).unwrap() {
600            VmValue::Dict(map) => map,
601            other => panic!("expected dict, got {other:?}"),
602        };
603        assert_eq!(
604            paths.get("state_root").unwrap().display(),
605            base.join(".custom-harn").display().to_string()
606        );
607        assert_eq!(
608            paths.get("run_root").unwrap().display(),
609            base.join(".custom-runs").display().to_string()
610        );
611        assert_eq!(
612            paths.get("worktree_root").unwrap().display(),
613            base.join(".custom-worktrees").display().to_string()
614        );
615
616        reset_process_state();
617        std::env::remove_var(crate::runtime_paths::HARN_STATE_DIR_ENV);
618        std::env::remove_var(crate::runtime_paths::HARN_RUN_DIR_ENV);
619        std::env::remove_var(crate::runtime_paths::HARN_WORKTREE_DIR_ENV);
620        let _ = std::fs::remove_dir_all(&base);
621    }
622}