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 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
313pub 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
326pub(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}