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
17pub(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
39pub(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
338pub 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
351pub(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}