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("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 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 Ok(VmValue::String(Rc::from(
236 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
237 )))
238 });
239
240 vm.register_builtin("cwd", |_args, _out| {
241 let dir = current_execution_context()
242 .and_then(|context| context.cwd)
243 .or_else(|| {
244 std::env::current_dir()
245 .ok()
246 .map(|p| p.to_string_lossy().into_owned())
247 })
248 .unwrap_or_default();
249 Ok(VmValue::String(Rc::from(dir)))
250 });
251
252 vm.register_builtin("execution_root", |_args, _out| {
253 Ok(VmValue::String(Rc::from(
254 execution_root_path().to_string_lossy().into_owned(),
255 )))
256 });
257
258 vm.register_builtin("asset_root", |_args, _out| {
259 Ok(VmValue::String(Rc::from(
260 asset_root_path().to_string_lossy().into_owned(),
261 )))
262 });
263
264 vm.register_builtin("runtime_paths", |_args, _out| {
265 let runtime_base = runtime_root_base();
266 let mut paths = BTreeMap::new();
267 paths.insert(
268 "execution_root".to_string(),
269 VmValue::String(Rc::from(
270 execution_root_path().to_string_lossy().into_owned(),
271 )),
272 );
273 paths.insert(
274 "asset_root".to_string(),
275 VmValue::String(Rc::from(asset_root_path().to_string_lossy().into_owned())),
276 );
277 paths.insert(
278 "state_root".to_string(),
279 VmValue::String(Rc::from(
280 crate::runtime_paths::state_root(&runtime_base)
281 .to_string_lossy()
282 .into_owned(),
283 )),
284 );
285 paths.insert(
286 "run_root".to_string(),
287 VmValue::String(Rc::from(
288 crate::runtime_paths::run_root(&runtime_base)
289 .to_string_lossy()
290 .into_owned(),
291 )),
292 );
293 paths.insert(
294 "worktree_root".to_string(),
295 VmValue::String(Rc::from(
296 crate::runtime_paths::worktree_root(&runtime_base)
297 .to_string_lossy()
298 .into_owned(),
299 )),
300 );
301 Ok(VmValue::Dict(Rc::new(paths)))
302 });
303}
304
305pub fn find_project_root(base: &std::path::Path) -> Option<std::path::PathBuf> {
307 let mut dir = base.to_path_buf();
308 loop {
309 if dir.join("harn.toml").exists() {
310 return Some(dir);
311 }
312 if !dir.pop() {
313 return None;
314 }
315 }
316}
317
318pub(crate) fn register_path_builtins(vm: &mut Vm) {
320 vm.register_builtin("source_dir", |_args, _out| {
321 let dir = VM_SOURCE_DIR.with(|sd| sd.borrow().clone());
322 match dir {
323 Some(d) => Ok(VmValue::String(Rc::from(d.to_string_lossy().into_owned()))),
324 None => {
325 let cwd = std::env::current_dir()
326 .map(|p| p.to_string_lossy().into_owned())
327 .unwrap_or_default();
328 Ok(VmValue::String(Rc::from(cwd)))
329 }
330 }
331 });
332
333 vm.register_builtin("project_root", |_args, _out| {
334 let base = current_execution_context()
335 .and_then(|context| context.cwd.map(PathBuf::from))
336 .or_else(|| VM_SOURCE_DIR.with(|sd| sd.borrow().clone()))
337 .or_else(|| std::env::current_dir().ok())
338 .unwrap_or_else(|| PathBuf::from("."));
339 match find_project_root(&base) {
340 Some(root) => Ok(VmValue::String(Rc::from(
341 root.to_string_lossy().into_owned(),
342 ))),
343 None => Ok(VmValue::Nil),
344 }
345 });
346}
347
348fn vm_output_to_value(output: std::process::Output) -> VmValue {
349 let mut result = BTreeMap::new();
350 result.insert(
351 "stdout".to_string(),
352 VmValue::String(Rc::from(
353 String::from_utf8_lossy(&output.stdout).to_string().as_str(),
354 )),
355 );
356 result.insert(
357 "stderr".to_string(),
358 VmValue::String(Rc::from(
359 String::from_utf8_lossy(&output.stderr).to_string().as_str(),
360 )),
361 );
362 result.insert(
363 "status".to_string(),
364 VmValue::Int(output.status.code().unwrap_or(-1) as i64),
365 );
366 result.insert(
367 "success".to_string(),
368 VmValue::Bool(output.status.success()),
369 );
370 VmValue::Dict(Rc::new(result))
371}
372
373fn exec_command(
374 dir: Option<&str>,
375 cmd: &str,
376 args: &[String],
377) -> Result<std::process::Output, VmError> {
378 let config = process_command_config(dir)?;
379 crate::stdlib::sandbox::command_output(cmd, args, &config)
380 .map_err(|error| prefix_process_error(error, "exec"))
381}
382
383#[cfg(test)]
384fn exec_shell(
385 dir: Option<&str>,
386 shell: &str,
387 flag: &str,
388 script: &str,
389) -> Result<std::process::Output, VmError> {
390 let args = vec![flag.to_string(), script.to_string()];
391 exec_shell_args(dir, shell, &args)
392}
393
394fn exec_shell_args(
395 dir: Option<&str>,
396 shell: &str,
397 args: &[String],
398) -> Result<std::process::Output, VmError> {
399 let config = process_command_config(dir)?;
400 crate::stdlib::sandbox::command_output(shell, args, &config)
401 .map_err(|error| prefix_process_error(error, "shell"))
402}
403
404fn process_command_config(
405 dir: Option<&str>,
406) -> Result<crate::stdlib::sandbox::ProcessCommandConfig, VmError> {
407 let mut config = crate::stdlib::sandbox::ProcessCommandConfig {
408 stdin_null: true,
409 ..Default::default()
410 };
411 if let Some(dir) = dir {
412 let resolved = resolve_command_dir(dir);
413 crate::stdlib::sandbox::enforce_process_cwd(&resolved)?;
414 config.cwd = Some(resolved);
415 } else if let Some(context) = current_execution_context() {
416 if let Some(cwd) = context.cwd.filter(|cwd| !cwd.is_empty()) {
417 crate::stdlib::sandbox::enforce_process_cwd(std::path::Path::new(&cwd))?;
418 config.cwd = Some(std::path::PathBuf::from(cwd));
419 }
420 if !context.env.is_empty() {
421 config.env.extend(context.env);
422 }
423 }
424 if let Some(value) = env_override(HARN_REPLAY_ENV) {
425 config.env.push((HARN_REPLAY_ENV.to_string(), value));
426 }
427 Ok(config)
428}
429
430fn prefix_process_error(error: VmError, prefix: &str) -> VmError {
431 match error {
432 VmError::Thrown(VmValue::String(message)) => VmError::Thrown(VmValue::String(Rc::from(
433 format!("{prefix} failed: {message}"),
434 ))),
435 other => other,
436 }
437}
438
439fn resolve_command_dir(dir: &str) -> PathBuf {
440 let candidate = PathBuf::from(dir);
441 if candidate.is_absolute() {
442 return candidate;
443 }
444 if let Some(cwd) = current_execution_context().and_then(|context| context.cwd) {
445 return PathBuf::from(cwd).join(candidate);
446 }
447 if let Some(source_dir) = VM_SOURCE_DIR.with(|sd| sd.borrow().clone()) {
448 return source_dir.join(candidate);
449 }
450 candidate
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456
457 #[test]
458 fn resolve_source_relative_path_ignores_thread_source_dir_without_execution_context() {
459 let dir = std::env::temp_dir().join(format!("harn-process-{}", uuid::Uuid::now_v7()));
460 std::fs::create_dir_all(&dir).unwrap();
461 let current_dir = std::env::current_dir().unwrap();
462 set_thread_source_dir(&dir);
463 let resolved = resolve_source_relative_path("templates/prompt.txt");
464 assert_eq!(resolved, current_dir.join("templates/prompt.txt"));
465 reset_process_state();
466 let _ = std::fs::remove_dir_all(&dir);
467 }
468
469 #[test]
470 fn resolve_source_relative_path_prefers_execution_cwd_over_source_dir() {
471 let cwd = std::env::temp_dir().join(format!("harn-process-cwd-{}", uuid::Uuid::now_v7()));
472 let source_dir =
473 std::env::temp_dir().join(format!("harn-process-source-{}", uuid::Uuid::now_v7()));
474 std::fs::create_dir_all(&cwd).unwrap();
475 std::fs::create_dir_all(&source_dir).unwrap();
476 set_thread_source_dir(&source_dir);
477 set_thread_execution_context(Some(crate::orchestration::RunExecutionRecord {
478 cwd: Some(cwd.to_string_lossy().into_owned()),
479 source_dir: Some(source_dir.to_string_lossy().into_owned()),
480 env: BTreeMap::new(),
481 adapter: None,
482 repo_path: None,
483 worktree_path: None,
484 branch: None,
485 base_ref: None,
486 cleanup: None,
487 }));
488 let resolved = resolve_source_relative_path("templates/prompt.txt");
489 assert_eq!(resolved, cwd.join("templates/prompt.txt"));
490 reset_process_state();
491 let _ = std::fs::remove_dir_all(&cwd);
492 let _ = std::fs::remove_dir_all(&source_dir);
493 }
494
495 #[test]
496 fn resolve_source_asset_path_prefers_execution_source_dir_over_cwd() {
497 let cwd = std::env::temp_dir().join(format!("harn-asset-cwd-{}", uuid::Uuid::now_v7()));
498 let source_dir =
499 std::env::temp_dir().join(format!("harn-asset-source-{}", uuid::Uuid::now_v7()));
500 std::fs::create_dir_all(&cwd).unwrap();
501 std::fs::create_dir_all(&source_dir).unwrap();
502 set_thread_source_dir(&source_dir);
503 set_thread_execution_context(Some(crate::orchestration::RunExecutionRecord {
504 cwd: Some(cwd.to_string_lossy().into_owned()),
505 source_dir: Some(source_dir.to_string_lossy().into_owned()),
506 env: BTreeMap::new(),
507 adapter: None,
508 repo_path: None,
509 worktree_path: None,
510 branch: None,
511 base_ref: None,
512 cleanup: None,
513 }));
514 let resolved = resolve_source_asset_path("templates/prompt.txt");
515 assert_eq!(resolved, source_dir.join("templates/prompt.txt"));
516 reset_process_state();
517 let _ = std::fs::remove_dir_all(&cwd);
518 let _ = std::fs::remove_dir_all(&source_dir);
519 }
520
521 #[test]
522 fn set_thread_source_dir_absolutizes_relative_paths() {
523 reset_process_state();
524 let current_dir = std::env::current_dir().unwrap();
525 set_thread_source_dir(std::path::Path::new("scripts"));
526 assert_eq!(source_root_path(), current_dir.join("scripts"));
527 reset_process_state();
528 }
529
530 #[test]
531 fn exec_context_sets_default_cwd_and_env() {
532 let dir = std::env::temp_dir().join(format!("harn-process-ctx-{}", uuid::Uuid::now_v7()));
533 std::fs::create_dir_all(&dir).unwrap();
534 std::fs::write(dir.join("marker.txt"), "ok").unwrap();
535 set_thread_execution_context(Some(RunExecutionRecord {
536 cwd: Some(dir.to_string_lossy().into_owned()),
537 env: BTreeMap::from([("HARN_PROCESS_TEST".to_string(), "present".to_string())]),
538 ..Default::default()
539 }));
540 let output = exec_shell(
541 None,
542 "sh",
543 "-c",
544 "printf '%s:' \"$HARN_PROCESS_TEST\" && test -f marker.txt",
545 )
546 .unwrap();
547 assert!(output.status.success());
548 assert_eq!(String::from_utf8_lossy(&output.stdout), "present:");
549 reset_process_state();
550 let _ = std::fs::remove_dir_all(&dir);
551 }
552
553 #[test]
554 fn exec_at_resolves_relative_to_execution_cwd() {
555 let dir = std::env::temp_dir().join(format!("harn-process-rel-{}", uuid::Uuid::now_v7()));
556 std::fs::create_dir_all(dir.join("nested")).unwrap();
557 std::fs::write(dir.join("nested").join("marker.txt"), "ok").unwrap();
558 set_thread_execution_context(Some(RunExecutionRecord {
559 cwd: Some(dir.to_string_lossy().into_owned()),
560 ..Default::default()
561 }));
562 let output = exec_shell(Some("nested"), "sh", "-c", "test -f marker.txt").unwrap();
563 assert!(output.status.success());
564 reset_process_state();
565 let _ = std::fs::remove_dir_all(&dir);
566 }
567
568 #[test]
569 fn runtime_paths_uses_configurable_state_roots() {
570 let base =
571 std::env::temp_dir().join(format!("harn-process-runtime-{}", uuid::Uuid::now_v7()));
572 std::fs::create_dir_all(&base).unwrap();
573 std::env::set_var(crate::runtime_paths::HARN_STATE_DIR_ENV, ".custom-harn");
574 std::env::set_var(crate::runtime_paths::HARN_RUN_DIR_ENV, ".custom-runs");
575 std::env::set_var(
576 crate::runtime_paths::HARN_WORKTREE_DIR_ENV,
577 ".custom-worktrees",
578 );
579 set_thread_execution_context(Some(RunExecutionRecord {
580 cwd: Some(base.to_string_lossy().into_owned()),
581 ..Default::default()
582 }));
583
584 let mut vm = crate::vm::Vm::new();
585 register_process_builtins(&mut vm);
586 let mut out = String::new();
587 let builtin = vm
588 .builtins
589 .get("runtime_paths")
590 .expect("runtime_paths builtin");
591 let paths = match builtin(&[], &mut out).unwrap() {
592 VmValue::Dict(map) => map,
593 other => panic!("expected dict, got {other:?}"),
594 };
595 assert_eq!(
596 paths.get("state_root").unwrap().display(),
597 base.join(".custom-harn").display().to_string()
598 );
599 assert_eq!(
600 paths.get("run_root").unwrap().display(),
601 base.join(".custom-runs").display().to_string()
602 );
603 assert_eq!(
604 paths.get("worktree_root").unwrap().display(),
605 base.join(".custom-worktrees").display().to_string()
606 );
607
608 reset_process_state();
609 std::env::remove_var(crate::runtime_paths::HARN_STATE_DIR_ENV);
610 std::env::remove_var(crate::runtime_paths::HARN_RUN_DIR_ENV);
611 std::env::remove_var(crate::runtime_paths::HARN_WORKTREE_DIR_ENV);
612 let _ = std::fs::remove_dir_all(&base);
613 }
614}