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