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    vm.register_builtin("timestamp", |_args, _out| {
119        use std::time::{SystemTime, UNIX_EPOCH};
120        let secs = SystemTime::now()
121            .duration_since(UNIX_EPOCH)
122            .map(|d| d.as_secs_f64())
123            .unwrap_or(0.0);
124        Ok(VmValue::Float(secs))
125    });
126
127    vm.register_builtin("exit", |args, _out| {
128        let code = args.first().and_then(|a| a.as_int()).unwrap_or(0);
129        std::process::exit(code as i32);
130    });
131
132    vm.register_builtin("exec", |args, _out| {
133        if args.is_empty() {
134            return Err(VmError::Thrown(VmValue::String(Rc::from(
135                "exec: command is required",
136            ))));
137        }
138        let cmd = args[0].display();
139        let cmd_args: Vec<String> = args[1..].iter().map(|a| a.display()).collect();
140        let output = exec_command(None, &cmd, &cmd_args)?;
141        Ok(vm_output_to_value(output))
142    });
143
144    vm.register_builtin("shell", |args, _out| {
145        let cmd = args.first().map(|a| a.display()).unwrap_or_default();
146        if cmd.is_empty() {
147            return Err(VmError::Thrown(VmValue::String(Rc::from(
148                "shell: command string is required",
149            ))));
150        }
151        let shell = if cfg!(target_os = "windows") {
152            "cmd"
153        } else {
154            "sh"
155        };
156        let flag = if cfg!(target_os = "windows") {
157            "/C"
158        } else {
159            "-c"
160        };
161        let output = exec_shell(None, shell, flag, &cmd)?;
162        Ok(vm_output_to_value(output))
163    });
164
165    vm.register_builtin("exec_at", |args, _out| {
166        if args.len() < 2 {
167            return Err(VmError::Thrown(VmValue::String(Rc::from(
168                "exec_at: directory and command are required",
169            ))));
170        }
171        let dir = args[0].display();
172        let cmd = args[1].display();
173        let cmd_args: Vec<String> = args[2..].iter().map(|a| a.display()).collect();
174        let output = exec_command(Some(dir.as_str()), &cmd, &cmd_args)?;
175        Ok(vm_output_to_value(output))
176    });
177
178    vm.register_builtin("shell_at", |args, _out| {
179        if args.len() < 2 {
180            return Err(VmError::Thrown(VmValue::String(Rc::from(
181                "shell_at: directory and command string are required",
182            ))));
183        }
184        let dir = args[0].display();
185        let cmd = args[1].display();
186        if cmd.is_empty() {
187            return Err(VmError::Thrown(VmValue::String(Rc::from(
188                "shell_at: command string is required",
189            ))));
190        }
191        let shell = if cfg!(target_os = "windows") {
192            "cmd"
193        } else {
194            "sh"
195        };
196        let flag = if cfg!(target_os = "windows") {
197            "/C"
198        } else {
199            "-c"
200        };
201        let output = exec_shell(Some(dir.as_str()), shell, flag, &cmd)?;
202        Ok(vm_output_to_value(output))
203    });
204
205    vm.register_builtin("elapsed", |_args, _out| {
206        static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
207        let start = START.get_or_init(std::time::Instant::now);
208        Ok(VmValue::Int(start.elapsed().as_millis() as i64))
209    });
210
211    vm.register_builtin("username", |_args, _out| {
212        let user = std::env::var("USER")
213            .or_else(|_| std::env::var("USERNAME"))
214            .unwrap_or_default();
215        Ok(VmValue::String(Rc::from(user)))
216    });
217
218    vm.register_builtin("hostname", |_args, _out| {
219        let name = std::env::var("HOSTNAME")
220            .or_else(|_| std::env::var("COMPUTERNAME"))
221            .or_else(|_| {
222                std::process::Command::new("hostname")
223                    .output()
224                    .ok()
225                    .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
226                    .ok_or(std::env::VarError::NotPresent)
227            })
228            .unwrap_or_default();
229        Ok(VmValue::String(Rc::from(name)))
230    });
231
232    vm.register_builtin("platform", |_args, _out| {
233        let os = if cfg!(target_os = "macos") {
234            "darwin"
235        } else if cfg!(target_os = "linux") {
236            "linux"
237        } else if cfg!(target_os = "windows") {
238            "windows"
239        } else {
240            std::env::consts::OS
241        };
242        Ok(VmValue::String(Rc::from(os)))
243    });
244
245    vm.register_builtin("arch", |_args, _out| {
246        Ok(VmValue::String(Rc::from(std::env::consts::ARCH)))
247    });
248
249    vm.register_builtin("home_dir", |_args, _out| {
250        let home = std::env::var("HOME")
251            .or_else(|_| std::env::var("USERPROFILE"))
252            .unwrap_or_default();
253        Ok(VmValue::String(Rc::from(home)))
254    });
255
256    vm.register_builtin("pid", |_args, _out| {
257        Ok(VmValue::Int(std::process::id() as i64))
258    });
259
260    vm.register_builtin("date_iso", |_args, _out| {
261        use crate::stdlib::datetime::vm_civil_from_timestamp;
262        let now = std::time::SystemTime::now()
263            .duration_since(std::time::UNIX_EPOCH)
264            .unwrap_or_default();
265        let total_secs = now.as_secs();
266        let millis = now.subsec_millis();
267        let (y, m, d, hour, minute, second, _) = vm_civil_from_timestamp(total_secs);
268        Ok(VmValue::String(Rc::from(format!(
269            "{y:04}-{m:02}-{d:02}T{hour:02}:{minute:02}:{second:02}.{millis:03}Z"
270        ))))
271    });
272
273    vm.register_builtin("cwd", |_args, _out| {
274        let dir = current_execution_context()
275            .and_then(|context| context.cwd)
276            .or_else(|| {
277                std::env::current_dir()
278                    .ok()
279                    .map(|p| p.to_string_lossy().into_owned())
280            })
281            .unwrap_or_default();
282        Ok(VmValue::String(Rc::from(dir)))
283    });
284
285    vm.register_builtin("execution_root", |_args, _out| {
286        Ok(VmValue::String(Rc::from(
287            execution_root_path().to_string_lossy().into_owned(),
288        )))
289    });
290
291    vm.register_builtin("asset_root", |_args, _out| {
292        Ok(VmValue::String(Rc::from(
293            asset_root_path().to_string_lossy().into_owned(),
294        )))
295    });
296
297    vm.register_builtin("runtime_paths", |_args, _out| {
298        let runtime_base = runtime_root_base();
299        let mut paths = BTreeMap::new();
300        paths.insert(
301            "execution_root".to_string(),
302            VmValue::String(Rc::from(
303                execution_root_path().to_string_lossy().into_owned(),
304            )),
305        );
306        paths.insert(
307            "asset_root".to_string(),
308            VmValue::String(Rc::from(asset_root_path().to_string_lossy().into_owned())),
309        );
310        paths.insert(
311            "state_root".to_string(),
312            VmValue::String(Rc::from(
313                crate::runtime_paths::state_root(&runtime_base)
314                    .to_string_lossy()
315                    .into_owned(),
316            )),
317        );
318        paths.insert(
319            "run_root".to_string(),
320            VmValue::String(Rc::from(
321                crate::runtime_paths::run_root(&runtime_base)
322                    .to_string_lossy()
323                    .into_owned(),
324            )),
325        );
326        paths.insert(
327            "worktree_root".to_string(),
328            VmValue::String(Rc::from(
329                crate::runtime_paths::worktree_root(&runtime_base)
330                    .to_string_lossy()
331                    .into_owned(),
332            )),
333        );
334        Ok(VmValue::Dict(Rc::new(paths)))
335    });
336}
337
338/// Find the project root by walking up from a base directory looking for harn.toml.
339pub fn find_project_root(base: &std::path::Path) -> Option<std::path::PathBuf> {
340    let mut dir = base.to_path_buf();
341    loop {
342        if dir.join("harn.toml").exists() {
343            return Some(dir);
344        }
345        if !dir.pop() {
346            return None;
347        }
348    }
349}
350
351/// Register builtins that depend on source directory context.
352pub(crate) fn register_path_builtins(vm: &mut Vm) {
353    vm.register_builtin("source_dir", |_args, _out| {
354        let dir = VM_SOURCE_DIR.with(|sd| sd.borrow().clone());
355        match dir {
356            Some(d) => Ok(VmValue::String(Rc::from(d.to_string_lossy().into_owned()))),
357            None => {
358                let cwd = std::env::current_dir()
359                    .map(|p| p.to_string_lossy().into_owned())
360                    .unwrap_or_default();
361                Ok(VmValue::String(Rc::from(cwd)))
362            }
363        }
364    });
365
366    vm.register_builtin("project_root", |_args, _out| {
367        let base = current_execution_context()
368            .and_then(|context| context.cwd.map(PathBuf::from))
369            .or_else(|| VM_SOURCE_DIR.with(|sd| sd.borrow().clone()))
370            .or_else(|| std::env::current_dir().ok())
371            .unwrap_or_else(|| PathBuf::from("."));
372        match find_project_root(&base) {
373            Some(root) => Ok(VmValue::String(Rc::from(
374                root.to_string_lossy().into_owned(),
375            ))),
376            None => Ok(VmValue::Nil),
377        }
378    });
379}
380
381fn vm_output_to_value(output: std::process::Output) -> VmValue {
382    let mut result = BTreeMap::new();
383    result.insert(
384        "stdout".to_string(),
385        VmValue::String(Rc::from(
386            String::from_utf8_lossy(&output.stdout).to_string().as_str(),
387        )),
388    );
389    result.insert(
390        "stderr".to_string(),
391        VmValue::String(Rc::from(
392            String::from_utf8_lossy(&output.stderr).to_string().as_str(),
393        )),
394    );
395    result.insert(
396        "status".to_string(),
397        VmValue::Int(output.status.code().unwrap_or(-1) as i64),
398    );
399    result.insert(
400        "success".to_string(),
401        VmValue::Bool(output.status.success()),
402    );
403    VmValue::Dict(Rc::new(result))
404}
405
406fn exec_command(
407    dir: Option<&str>,
408    cmd: &str,
409    args: &[String],
410) -> Result<std::process::Output, VmError> {
411    let mut command = crate::stdlib::sandbox::std_command_for(cmd, args)?;
412    apply_execution_context(&mut command, dir)?;
413    let output = command.output().map_err(|e| {
414        crate::stdlib::sandbox::process_spawn_error(&e).unwrap_or_else(|| {
415            VmError::Thrown(VmValue::String(Rc::from(format!("exec failed: {e}"))))
416        })
417    })?;
418    if let Some(error) = crate::stdlib::sandbox::process_violation_error(&output) {
419        return Err(error);
420    }
421    Ok(output)
422}
423
424fn exec_shell(
425    dir: Option<&str>,
426    shell: &str,
427    flag: &str,
428    script: &str,
429) -> Result<std::process::Output, VmError> {
430    let args = vec![flag.to_string(), script.to_string()];
431    let mut command = crate::stdlib::sandbox::std_command_for(shell, &args)?;
432    apply_execution_context(&mut command, dir)?;
433    let output = command.output().map_err(|e| {
434        crate::stdlib::sandbox::process_spawn_error(&e).unwrap_or_else(|| {
435            VmError::Thrown(VmValue::String(Rc::from(format!("shell failed: {e}"))))
436        })
437    })?;
438    if let Some(error) = crate::stdlib::sandbox::process_violation_error(&output) {
439        return Err(error);
440    }
441    Ok(output)
442}
443
444fn apply_execution_context(
445    command: &mut std::process::Command,
446    dir: Option<&str>,
447) -> Result<(), VmError> {
448    if let Some(dir) = dir {
449        let resolved = resolve_command_dir(dir);
450        crate::stdlib::sandbox::enforce_process_cwd(&resolved)?;
451        command.current_dir(resolved);
452    } else if let Some(context) = current_execution_context() {
453        if let Some(cwd) = context.cwd.filter(|cwd| !cwd.is_empty()) {
454            crate::stdlib::sandbox::enforce_process_cwd(std::path::Path::new(&cwd))?;
455            command.current_dir(cwd);
456        }
457        if !context.env.is_empty() {
458            command.envs(context.env);
459        }
460    }
461    if let Some(value) = env_override(HARN_REPLAY_ENV) {
462        command.env(HARN_REPLAY_ENV, value);
463    }
464    Ok(())
465}
466
467fn resolve_command_dir(dir: &str) -> PathBuf {
468    let candidate = PathBuf::from(dir);
469    if candidate.is_absolute() {
470        return candidate;
471    }
472    if let Some(cwd) = current_execution_context().and_then(|context| context.cwd) {
473        return PathBuf::from(cwd).join(candidate);
474    }
475    if let Some(source_dir) = VM_SOURCE_DIR.with(|sd| sd.borrow().clone()) {
476        return source_dir.join(candidate);
477    }
478    candidate
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484
485    #[test]
486    fn resolve_source_relative_path_ignores_thread_source_dir_without_execution_context() {
487        let dir = std::env::temp_dir().join(format!("harn-process-{}", uuid::Uuid::now_v7()));
488        std::fs::create_dir_all(&dir).unwrap();
489        let current_dir = std::env::current_dir().unwrap();
490        set_thread_source_dir(&dir);
491        let resolved = resolve_source_relative_path("templates/prompt.txt");
492        assert_eq!(resolved, current_dir.join("templates/prompt.txt"));
493        reset_process_state();
494        let _ = std::fs::remove_dir_all(&dir);
495    }
496
497    #[test]
498    fn resolve_source_relative_path_prefers_execution_cwd_over_source_dir() {
499        let cwd = std::env::temp_dir().join(format!("harn-process-cwd-{}", uuid::Uuid::now_v7()));
500        let source_dir =
501            std::env::temp_dir().join(format!("harn-process-source-{}", uuid::Uuid::now_v7()));
502        std::fs::create_dir_all(&cwd).unwrap();
503        std::fs::create_dir_all(&source_dir).unwrap();
504        set_thread_source_dir(&source_dir);
505        set_thread_execution_context(Some(crate::orchestration::RunExecutionRecord {
506            cwd: Some(cwd.to_string_lossy().into_owned()),
507            source_dir: Some(source_dir.to_string_lossy().into_owned()),
508            env: BTreeMap::new(),
509            adapter: None,
510            repo_path: None,
511            worktree_path: None,
512            branch: None,
513            base_ref: None,
514            cleanup: None,
515        }));
516        let resolved = resolve_source_relative_path("templates/prompt.txt");
517        assert_eq!(resolved, cwd.join("templates/prompt.txt"));
518        reset_process_state();
519        let _ = std::fs::remove_dir_all(&cwd);
520        let _ = std::fs::remove_dir_all(&source_dir);
521    }
522
523    #[test]
524    fn resolve_source_asset_path_prefers_execution_source_dir_over_cwd() {
525        let cwd = std::env::temp_dir().join(format!("harn-asset-cwd-{}", uuid::Uuid::now_v7()));
526        let source_dir =
527            std::env::temp_dir().join(format!("harn-asset-source-{}", uuid::Uuid::now_v7()));
528        std::fs::create_dir_all(&cwd).unwrap();
529        std::fs::create_dir_all(&source_dir).unwrap();
530        set_thread_source_dir(&source_dir);
531        set_thread_execution_context(Some(crate::orchestration::RunExecutionRecord {
532            cwd: Some(cwd.to_string_lossy().into_owned()),
533            source_dir: Some(source_dir.to_string_lossy().into_owned()),
534            env: BTreeMap::new(),
535            adapter: None,
536            repo_path: None,
537            worktree_path: None,
538            branch: None,
539            base_ref: None,
540            cleanup: None,
541        }));
542        let resolved = resolve_source_asset_path("templates/prompt.txt");
543        assert_eq!(resolved, source_dir.join("templates/prompt.txt"));
544        reset_process_state();
545        let _ = std::fs::remove_dir_all(&cwd);
546        let _ = std::fs::remove_dir_all(&source_dir);
547    }
548
549    #[test]
550    fn set_thread_source_dir_absolutizes_relative_paths() {
551        reset_process_state();
552        let current_dir = std::env::current_dir().unwrap();
553        set_thread_source_dir(std::path::Path::new("scripts"));
554        assert_eq!(source_root_path(), current_dir.join("scripts"));
555        reset_process_state();
556    }
557
558    #[test]
559    fn exec_context_sets_default_cwd_and_env() {
560        let dir = std::env::temp_dir().join(format!("harn-process-ctx-{}", uuid::Uuid::now_v7()));
561        std::fs::create_dir_all(&dir).unwrap();
562        std::fs::write(dir.join("marker.txt"), "ok").unwrap();
563        set_thread_execution_context(Some(RunExecutionRecord {
564            cwd: Some(dir.to_string_lossy().into_owned()),
565            env: BTreeMap::from([("HARN_PROCESS_TEST".to_string(), "present".to_string())]),
566            ..Default::default()
567        }));
568        let output = exec_shell(
569            None,
570            "sh",
571            "-c",
572            "printf '%s:' \"$HARN_PROCESS_TEST\" && test -f marker.txt",
573        )
574        .unwrap();
575        assert!(output.status.success());
576        assert_eq!(String::from_utf8_lossy(&output.stdout), "present:");
577        reset_process_state();
578        let _ = std::fs::remove_dir_all(&dir);
579    }
580
581    #[test]
582    fn exec_at_resolves_relative_to_execution_cwd() {
583        let dir = std::env::temp_dir().join(format!("harn-process-rel-{}", uuid::Uuid::now_v7()));
584        std::fs::create_dir_all(dir.join("nested")).unwrap();
585        std::fs::write(dir.join("nested").join("marker.txt"), "ok").unwrap();
586        set_thread_execution_context(Some(RunExecutionRecord {
587            cwd: Some(dir.to_string_lossy().into_owned()),
588            ..Default::default()
589        }));
590        let output = exec_shell(Some("nested"), "sh", "-c", "test -f marker.txt").unwrap();
591        assert!(output.status.success());
592        reset_process_state();
593        let _ = std::fs::remove_dir_all(&dir);
594    }
595
596    #[test]
597    fn runtime_paths_uses_configurable_state_roots() {
598        let base =
599            std::env::temp_dir().join(format!("harn-process-runtime-{}", uuid::Uuid::now_v7()));
600        std::fs::create_dir_all(&base).unwrap();
601        std::env::set_var(crate::runtime_paths::HARN_STATE_DIR_ENV, ".custom-harn");
602        std::env::set_var(crate::runtime_paths::HARN_RUN_DIR_ENV, ".custom-runs");
603        std::env::set_var(
604            crate::runtime_paths::HARN_WORKTREE_DIR_ENV,
605            ".custom-worktrees",
606        );
607        set_thread_execution_context(Some(RunExecutionRecord {
608            cwd: Some(base.to_string_lossy().into_owned()),
609            ..Default::default()
610        }));
611
612        let mut vm = crate::vm::Vm::new();
613        register_process_builtins(&mut vm);
614        let mut out = String::new();
615        let builtin = vm
616            .builtins
617            .get("runtime_paths")
618            .expect("runtime_paths builtin");
619        let paths = match builtin(&[], &mut out).unwrap() {
620            VmValue::Dict(map) => map,
621            other => panic!("expected dict, got {other:?}"),
622        };
623        assert_eq!(
624            paths.get("state_root").unwrap().display(),
625            base.join(".custom-harn").display().to_string()
626        );
627        assert_eq!(
628            paths.get("run_root").unwrap().display(),
629            base.join(".custom-runs").display().to_string()
630        );
631        assert_eq!(
632            paths.get("worktree_root").unwrap().display(),
633            base.join(".custom-worktrees").display().to_string()
634        );
635
636        reset_process_state();
637        std::env::remove_var(crate::runtime_paths::HARN_STATE_DIR_ENV);
638        std::env::remove_var(crate::runtime_paths::HARN_RUN_DIR_ENV);
639        std::env::remove_var(crate::runtime_paths::HARN_WORKTREE_DIR_ENV);
640        let _ = std::fs::remove_dir_all(&base);
641    }
642}