1use std::cell::RefCell;
2use std::collections::BTreeMap;
3use std::io::Write as _;
4use std::path::PathBuf;
5use std::process::Stdio;
6use std::sync::mpsc;
7use std::time::{Duration, Instant};
8
9use crate::orchestration::RunExecutionRecord;
10use crate::stdlib::macros::{harn_builtin, VmBuiltinDef};
11use crate::value::{VmError, VmValue};
12use crate::vm::Vm;
13
14const HARN_REPLAY_ENV: &str = "HARN_REPLAY";
15
16thread_local! {
17 pub(crate) static VM_SOURCE_DIR: RefCell<Option<PathBuf>> = const { RefCell::new(None) };
18 static VM_EXECUTION_CONTEXT: RefCell<Option<RunExecutionRecord>> = const { RefCell::new(None) };
19}
20
21pub(crate) fn set_thread_source_dir(dir: &std::path::Path) {
23 VM_SOURCE_DIR.with(|sd| *sd.borrow_mut() = Some(normalize_context_path(dir)));
24}
25
26pub(crate) fn normalize_context_path(path: &std::path::Path) -> PathBuf {
27 if path.is_absolute() {
28 return path.to_path_buf();
29 }
30 std::env::current_dir()
31 .map(|cwd| cwd.join(path))
32 .unwrap_or_else(|_| path.to_path_buf())
33}
34
35pub fn set_thread_execution_context(context: Option<RunExecutionRecord>) {
36 VM_EXECUTION_CONTEXT.with(|current| *current.borrow_mut() = context);
37}
38
39pub(crate) fn current_execution_context() -> Option<RunExecutionRecord> {
40 VM_EXECUTION_CONTEXT.with(|current| current.borrow().clone())
41}
42
43pub(crate) fn reset_process_state() {
45 VM_SOURCE_DIR.with(|sd| *sd.borrow_mut() = None);
46 VM_EXECUTION_CONTEXT.with(|current| *current.borrow_mut() = None);
47}
48
49pub fn execution_root_path() -> PathBuf {
50 current_execution_context()
51 .and_then(|context| context.cwd.map(PathBuf::from))
52 .or_else(|| std::env::current_dir().ok())
53 .unwrap_or_else(|| PathBuf::from("."))
54}
55
56pub fn source_root_path() -> PathBuf {
57 VM_SOURCE_DIR
58 .with(|sd| sd.borrow().clone())
59 .or_else(|| {
60 current_execution_context().and_then(|context| context.source_dir.map(PathBuf::from))
61 })
62 .or_else(|| current_execution_context().and_then(|context| context.cwd.map(PathBuf::from)))
63 .or_else(|| std::env::current_dir().ok())
64 .unwrap_or_else(|| PathBuf::from("."))
65}
66
67pub fn asset_root_path() -> PathBuf {
68 source_root_path()
69}
70
71fn env_override(name: &str) -> Option<String> {
72 (name == HARN_REPLAY_ENV && crate::triggers::dispatcher::current_dispatch_is_replay())
73 .then(|| "1".to_string())
74}
75
76pub(crate) fn read_env_value(name: &str) -> Option<String> {
77 env_override(name)
78 .or_else(|| current_execution_context().and_then(|context| context.env.get(name).cloned()))
79 .or_else(|| std::env::var(name).ok())
80}
81
82pub fn runtime_root_base() -> PathBuf {
83 find_project_root(&execution_root_path())
84 .or_else(|| find_project_root(&source_root_path()))
85 .unwrap_or_else(source_root_path)
86}
87
88fn lexically_collapse(path: &std::path::Path) -> Option<PathBuf> {
93 use std::path::Component;
94 let mut out: Vec<Component> = Vec::new();
95 for component in path.components() {
96 match component {
97 Component::CurDir => {}
98 Component::ParentDir => {
99 let popped = out.pop();
100 if !matches!(popped, Some(Component::Normal(_))) {
101 return None;
102 }
103 }
104 other => out.push(other),
105 }
106 }
107 Some(out.iter().collect())
108}
109
110pub fn resolve_source_relative_path(path: &str) -> PathBuf {
111 let candidate = PathBuf::from(path);
112 if candidate.is_absolute() {
113 return candidate;
114 }
115 let root = execution_root_path();
116 let joined = root.join(&candidate);
117 if path_escapes_project_root(&joined) {
124 return root.join("__harn_rejected_parent_dir_traversal__");
125 }
126 joined
127}
128
129pub fn resolve_source_asset_path(path: &str) -> PathBuf {
130 let candidate = PathBuf::from(path);
131 if candidate.is_absolute() {
132 return candidate;
133 }
134 let root = asset_root_path();
135 let joined = root.join(&candidate);
136 if path_escapes_project_root(&joined) {
137 return root.join("__harn_rejected_parent_dir_traversal__");
138 }
139 joined
140}
141
142fn path_escapes_project_root(joined: &std::path::Path) -> bool {
156 lexically_collapse(joined).is_none()
157}
158
159pub(crate) fn register_process_builtins(vm: &mut Vm) {
160 for def in PROCESS_BUILTINS {
161 vm.register_builtin_def(def);
162 }
163}
164
165#[harn_builtin(sig = "env(name: string) -> string?", category = "process")]
166fn env_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
167 let name = args.first().map(|a| a.display()).unwrap_or_default();
168 if let Some(value) = read_env_value(&name) {
169 return Ok(VmValue::String(std::sync::Arc::from(value)));
170 }
171 Ok(VmValue::Nil)
172}
173
174#[harn_builtin(
175 sig = "env_or(name: string, default: any) -> any",
176 category = "process"
177)]
178fn env_or_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
179 let name = args.first().map(|a| a.display()).unwrap_or_default();
180 let default = args.get(1).cloned().unwrap_or(VmValue::Nil);
181 if let Some(value) = read_env_value(&name) {
182 return Ok(VmValue::String(std::sync::Arc::from(value)));
183 }
184 Ok(default)
185}
186
187#[harn_builtin(sig = "exit(code?: int) -> never", category = "process")]
188fn exit_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
189 let code = args.first().and_then(|a| a.as_int()).unwrap_or(0);
190 std::process::exit(code as i32);
191}
192
193#[harn_builtin(sig = "exec(...command: string) -> dict", category = "process")]
194fn exec_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
195 if args.is_empty() {
196 return Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
197 "exec: command is required",
198 ))));
199 }
200 let cmd = args[0].display();
201 let cmd_args: Vec<String> = args[1..].iter().map(|a| a.display()).collect();
202 let output = exec_command(None, &cmd, &cmd_args)?;
203 Ok(vm_output_to_value(output))
204}
205
206#[harn_builtin(sig = "shell(command: string) -> dict", category = "process")]
207fn shell_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
208 let cmd = args.first().map(|a| a.display()).unwrap_or_default();
209 if cmd.is_empty() {
210 return Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
211 "shell: command string is required",
212 ))));
213 }
214 let invocation = crate::shells::default_shell_invocation(&cmd)
215 .map_err(|error| VmError::Runtime(format!("shell: {error}")))?;
216 let output = exec_shell_args(None, &invocation.program, &invocation.args)?;
217 Ok(vm_output_to_value(output))
218}
219
220#[harn_builtin(
221 sig = "exec_at(dir: string, ...command: string) -> dict",
222 category = "process"
223)]
224fn exec_at_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
225 if args.len() < 2 {
226 return Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
227 "exec_at: directory and command are required",
228 ))));
229 }
230 let dir = args[0].display();
231 let cmd = args[1].display();
232 let cmd_args: Vec<String> = args[2..].iter().map(|a| a.display()).collect();
233 let output = exec_command(Some(dir.as_str()), &cmd, &cmd_args)?;
234 Ok(vm_output_to_value(output))
235}
236
237#[harn_builtin(
238 sig = "shell_at(dir: string, command: string) -> dict",
239 category = "process"
240)]
241fn shell_at_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
242 if args.len() < 2 {
243 return Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
244 "shell_at: directory and command string are required",
245 ))));
246 }
247 let dir = args[0].display();
248 let cmd = args[1].display();
249 if cmd.is_empty() {
250 return Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
251 "shell_at: command string is required",
252 ))));
253 }
254 let invocation = crate::shells::default_shell_invocation(&cmd)
255 .map_err(|error| VmError::Runtime(format!("shell_at: {error}")))?;
256 let output = exec_shell_args(Some(dir.as_str()), &invocation.program, &invocation.args)?;
257 Ok(vm_output_to_value(output))
258}
259
260#[harn_builtin(sig = "username(...args: any) -> string", category = "process")]
261fn username_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
262 let user = std::env::var("USER")
263 .or_else(|_| std::env::var("USERNAME"))
264 .unwrap_or_default();
265 Ok(VmValue::String(std::sync::Arc::from(user)))
266}
267
268#[harn_builtin(sig = "hostname() -> string", category = "process")]
269fn hostname_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
270 let name = std::env::var("HOSTNAME")
271 .or_else(|_| std::env::var("COMPUTERNAME"))
272 .or_else(|_| {
273 std::process::Command::new("hostname")
274 .output()
275 .ok()
276 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
277 .ok_or(std::env::VarError::NotPresent)
278 })
279 .unwrap_or_default();
280 Ok(VmValue::String(std::sync::Arc::from(name)))
281}
282
283#[harn_builtin(sig = "platform(...args: any) -> string", category = "process")]
284fn platform_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
285 let os = if cfg!(target_os = "macos") {
286 "darwin"
287 } else if cfg!(target_os = "linux") {
288 "linux"
289 } else if cfg!(target_os = "windows") {
290 "windows"
291 } else {
292 std::env::consts::OS
293 };
294 Ok(VmValue::String(std::sync::Arc::from(os)))
295}
296
297#[harn_builtin(sig = "arch() -> string", category = "process")]
298fn arch_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
299 Ok(VmValue::String(std::sync::Arc::from(
300 std::env::consts::ARCH,
301 )))
302}
303
304#[harn_builtin(sig = "home_dir() -> string", category = "process")]
305fn home_dir_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
306 let home = std::env::var("HOME")
307 .or_else(|_| std::env::var("USERPROFILE"))
308 .unwrap_or_default();
309 Ok(VmValue::String(std::sync::Arc::from(home)))
310}
311
312#[harn_builtin(sig = "pid(...args: any) -> int", category = "process")]
313fn pid_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
314 Ok(VmValue::Int(std::process::id() as i64))
315}
316
317#[harn_builtin(sig = "date_iso() -> string", category = "process")]
318fn date_iso_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
319 let now = crate::clock_mock::leak_audit::wall_now("stdlib/date_iso");
326 let dt: chrono::DateTime<chrono::Utc> = now.into();
327 Ok(VmValue::String(std::sync::Arc::from(
328 dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
329 )))
330}
331
332#[harn_builtin(sig = "cwd() -> string", category = "process")]
333fn cwd_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
334 let dir = current_execution_context()
335 .and_then(|context| context.cwd)
336 .or_else(|| {
337 std::env::current_dir()
338 .ok()
339 .map(|p| p.to_string_lossy().into_owned())
340 })
341 .unwrap_or_default();
342 Ok(VmValue::String(std::sync::Arc::from(dir)))
343}
344
345#[harn_builtin(sig = "execution_root() -> string", category = "process")]
346fn execution_root_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
347 Ok(VmValue::String(std::sync::Arc::from(
348 execution_root_path().to_string_lossy().into_owned(),
349 )))
350}
351
352#[harn_builtin(sig = "asset_root() -> string", category = "process")]
353fn asset_root_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
354 Ok(VmValue::String(std::sync::Arc::from(
355 asset_root_path().to_string_lossy().into_owned(),
356 )))
357}
358
359#[harn_builtin(sig = "runtime_paths() -> dict", category = "process")]
360fn runtime_paths_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
361 let runtime_base = runtime_root_base();
362 let mut paths = BTreeMap::new();
363 paths.insert(
364 "execution_root".to_string(),
365 VmValue::String(std::sync::Arc::from(
366 execution_root_path().to_string_lossy().into_owned(),
367 )),
368 );
369 paths.insert(
370 "asset_root".to_string(),
371 VmValue::String(std::sync::Arc::from(
372 asset_root_path().to_string_lossy().into_owned(),
373 )),
374 );
375 paths.insert(
376 "state_root".to_string(),
377 VmValue::String(std::sync::Arc::from(
378 crate::runtime_paths::state_root(&runtime_base)
379 .to_string_lossy()
380 .into_owned(),
381 )),
382 );
383 paths.insert(
384 "run_root".to_string(),
385 VmValue::String(std::sync::Arc::from(
386 crate::runtime_paths::run_root(&runtime_base)
387 .to_string_lossy()
388 .into_owned(),
389 )),
390 );
391 paths.insert(
392 "worktree_root".to_string(),
393 VmValue::String(std::sync::Arc::from(
394 crate::runtime_paths::worktree_root(&runtime_base)
395 .to_string_lossy()
396 .into_owned(),
397 )),
398 );
399 Ok(VmValue::Dict(std::sync::Arc::new(paths)))
400}
401
402#[harn_builtin(sig = "spawn_captured(opts: dict) -> dict", category = "process")]
403fn spawn_captured_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
404 spawn_captured_value(args)
405}
406
407#[harn_builtin(sig = "term_width() -> int", category = "process")]
417fn term_width_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
418 Ok(VmValue::Int(crate::term::width() as i64))
419}
420
421#[harn_builtin(sig = "term_height() -> int", category = "process")]
422fn term_height_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
423 Ok(VmValue::Int(crate::term::height() as i64))
424}
425
426const PROCESS_BUILTINS: &[&VmBuiltinDef] = &[
427 &ENV_IMPL_DEF,
428 &ENV_OR_IMPL_DEF,
429 &EXIT_IMPL_DEF,
430 &EXEC_IMPL_DEF,
431 &SHELL_IMPL_DEF,
432 &EXEC_AT_IMPL_DEF,
433 &SHELL_AT_IMPL_DEF,
434 &USERNAME_IMPL_DEF,
435 &HOSTNAME_IMPL_DEF,
436 &PLATFORM_IMPL_DEF,
437 &ARCH_IMPL_DEF,
438 &HOME_DIR_IMPL_DEF,
439 &PID_IMPL_DEF,
440 &DATE_ISO_IMPL_DEF,
441 &CWD_IMPL_DEF,
442 &EXECUTION_ROOT_IMPL_DEF,
443 &ASSET_ROOT_IMPL_DEF,
444 &RUNTIME_PATHS_IMPL_DEF,
445 &SPAWN_CAPTURED_IMPL_DEF,
446 &TERM_WIDTH_IMPL_DEF,
447 &TERM_HEIGHT_IMPL_DEF,
448];
449
450pub(crate) fn spawn_captured_value(args: &[VmValue]) -> Result<VmValue, VmError> {
455 let opts = match args.first() {
456 Some(VmValue::Dict(opts)) => opts.clone(),
457 _ => {
458 return Err(VmError::Runtime(
459 "spawn_captured: options dict is required".to_string(),
460 ));
461 }
462 };
463 let cmd = match opts.get("cmd").map(|v| v.display()).unwrap_or_default() {
464 s if s.is_empty() => {
465 return Err(VmError::Runtime(
466 "spawn_captured: opts.cmd is required".to_string(),
467 ));
468 }
469 s => s,
470 };
471 let cmd_args: Vec<String> = match opts.get("args") {
472 Some(VmValue::List(items)) => items.iter().map(|v| v.display()).collect(),
473 None | Some(VmValue::Nil) => Vec::new(),
474 Some(other) => {
475 return Err(VmError::Runtime(format!(
476 "spawn_captured: opts.args must be a list of strings, got {}",
477 other.type_name()
478 )));
479 }
480 };
481 let cwd = opts
482 .get("cwd")
483 .map(|v| v.display())
484 .filter(|s| !s.is_empty());
485 let env_overrides: Vec<(String, String)> = match opts.get("env") {
486 Some(VmValue::Dict(env)) => env.iter().map(|(k, v)| (k.clone(), v.display())).collect(),
487 None | Some(VmValue::Nil) => Vec::new(),
488 Some(other) => {
489 return Err(VmError::Runtime(format!(
490 "spawn_captured: opts.env must be a dict, got {}",
491 other.type_name()
492 )));
493 }
494 };
495 let stdin_bytes: Option<Vec<u8>> = match opts.get("stdin") {
496 Some(VmValue::Bytes(bytes)) => Some(bytes.as_slice().to_vec()),
497 Some(VmValue::String(s)) => Some(s.as_bytes().to_vec()),
498 None | Some(VmValue::Nil) => None,
499 Some(other) => {
500 return Err(VmError::Runtime(format!(
501 "spawn_captured: opts.stdin must be string or bytes, got {}",
502 other.type_name()
503 )));
504 }
505 };
506 let timeout = opts
507 .get("timeout_ms")
508 .and_then(|v| v.as_int())
509 .filter(|n| *n > 0)
510 .map(|n| Duration::from_millis(n as u64));
511
512 let mut command = std::process::Command::new(&cmd);
513 command.args(&cmd_args);
514 if let Some(cwd) = cwd.as_ref() {
515 command.current_dir(cwd);
516 }
517 for (key, value) in &env_overrides {
518 command.env(key, value);
519 }
520 command.stdout(Stdio::piped()).stderr(Stdio::piped());
521 if stdin_bytes.is_some() {
522 command.stdin(Stdio::piped());
523 } else {
524 command.stdin(Stdio::null());
525 }
526
527 let started = Instant::now();
528 let mut child = command.spawn().map_err(|error| {
529 VmError::Thrown(VmValue::String(std::sync::Arc::from(format!(
530 "spawn_captured: failed to spawn '{cmd}': {error}"
531 ))))
532 })?;
533
534 if let (Some(payload), Some(mut stdin)) = (stdin_bytes, child.stdin.take()) {
535 let _ = stdin.write_all(&payload);
537 }
538
539 let (output, timed_out) = match timeout {
540 None => match child.wait_with_output() {
541 Ok(output) => (output, false),
542 Err(error) => {
543 return Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
544 format!("spawn_captured: wait failed: {error}"),
545 ))));
546 }
547 },
548 Some(limit) => {
549 let deadline = started + limit;
550 let mut timed_out = false;
551 loop {
552 match child.try_wait() {
553 Ok(Some(_)) => break,
554 Ok(None) => {
555 if Instant::now() >= deadline {
556 let _ = child.kill();
557 let _ = child.wait();
558 timed_out = true;
559 break;
560 }
561 std::thread::sleep(Duration::from_millis(10));
562 }
563 Err(error) => {
564 return Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
565 format!("spawn_captured: poll failed: {error}"),
566 ))));
567 }
568 }
569 }
570 if timed_out {
571 let stdout_handle = child.stdout.take();
572 let stderr_handle = child.stderr.take();
573 let (tx_out, rx_out) = mpsc::channel::<Vec<u8>>();
574 let (tx_err, rx_err) = mpsc::channel::<Vec<u8>>();
575 if let Some(mut s) = stdout_handle {
576 std::thread::spawn(move || {
577 use std::io::Read as _;
578 let mut buf = Vec::new();
579 let _ = s.read_to_end(&mut buf);
580 let _ = tx_out.send(buf);
581 });
582 }
583 if let Some(mut s) = stderr_handle {
584 std::thread::spawn(move || {
585 use std::io::Read as _;
586 let mut buf = Vec::new();
587 let _ = s.read_to_end(&mut buf);
588 let _ = tx_err.send(buf);
589 });
590 }
591 let stdout = rx_out
592 .recv_timeout(Duration::from_millis(100))
593 .unwrap_or_default();
594 let stderr = rx_err
595 .recv_timeout(Duration::from_millis(100))
596 .unwrap_or_default();
597 (
598 std::process::Output {
599 status: std::process::ExitStatus::default(),
600 stdout,
601 stderr,
602 },
603 true,
604 )
605 } else {
606 match child.wait_with_output() {
607 Ok(output) => (output, false),
608 Err(error) => {
609 return Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
610 format!("spawn_captured: wait failed: {error}"),
611 ))));
612 }
613 }
614 }
615 }
616 };
617
618 let duration_ms = started.elapsed().as_millis() as i64;
619 let exit_code = if timed_out {
620 -1
621 } else {
622 output.status.code().unwrap_or(-1) as i64
623 };
624 let success = if timed_out {
625 false
626 } else {
627 output.status.success()
628 };
629 let mut result = BTreeMap::new();
630 result.insert("exit_code".to_string(), VmValue::Int(exit_code));
631 result.insert(
632 "stdout".to_string(),
633 VmValue::String(std::sync::Arc::from(
634 String::from_utf8_lossy(&output.stdout).as_ref(),
635 )),
636 );
637 result.insert(
638 "stderr".to_string(),
639 VmValue::String(std::sync::Arc::from(
640 String::from_utf8_lossy(&output.stderr).as_ref(),
641 )),
642 );
643 result.insert("duration_ms".to_string(), VmValue::Int(duration_ms));
644 result.insert("success".to_string(), VmValue::Bool(success));
645 result.insert("timed_out".to_string(), VmValue::Bool(timed_out));
646 Ok(VmValue::Dict(std::sync::Arc::new(result)))
647}
648
649pub fn find_project_root(base: &std::path::Path) -> Option<std::path::PathBuf> {
651 let mut dir = base.to_path_buf();
652 loop {
653 if dir.join("harn.toml").exists() {
654 return Some(dir);
655 }
656 if !dir.pop() {
657 return None;
658 }
659 }
660}
661
662pub(crate) fn register_path_builtins(vm: &mut Vm) {
664 for def in PATH_BUILTINS {
665 vm.register_builtin_def(def);
666 }
667}
668
669#[harn_builtin(sig = "source_dir(...args: any) -> string", category = "process")]
670fn source_dir_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
671 let dir = VM_SOURCE_DIR.with(|sd| sd.borrow().clone());
672 match dir {
673 Some(d) => Ok(VmValue::String(std::sync::Arc::from(
674 d.to_string_lossy().into_owned(),
675 ))),
676 None => {
677 let cwd = std::env::current_dir()
678 .map(|p| p.to_string_lossy().into_owned())
679 .unwrap_or_default();
680 Ok(VmValue::String(std::sync::Arc::from(cwd)))
681 }
682 }
683}
684
685#[harn_builtin(sig = "project_root() -> string?", category = "process")]
686fn project_root_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
687 let base = current_execution_context()
688 .and_then(|context| context.cwd.map(PathBuf::from))
689 .or_else(|| VM_SOURCE_DIR.with(|sd| sd.borrow().clone()))
690 .or_else(|| std::env::current_dir().ok())
691 .unwrap_or_else(|| PathBuf::from("."));
692 match find_project_root(&base) {
693 Some(root) => Ok(VmValue::String(std::sync::Arc::from(
694 root.to_string_lossy().into_owned(),
695 ))),
696 None => Ok(VmValue::Nil),
697 }
698}
699
700const PATH_BUILTINS: &[&VmBuiltinDef] = &[&SOURCE_DIR_IMPL_DEF, &PROJECT_ROOT_IMPL_DEF];
701
702fn vm_output_to_value(output: std::process::Output) -> VmValue {
703 let mut result = BTreeMap::new();
704 result.insert(
705 "stdout".to_string(),
706 VmValue::String(std::sync::Arc::from(
707 String::from_utf8_lossy(&output.stdout).as_ref(),
708 )),
709 );
710 result.insert(
711 "stderr".to_string(),
712 VmValue::String(std::sync::Arc::from(
713 String::from_utf8_lossy(&output.stderr).as_ref(),
714 )),
715 );
716 result.insert(
717 "status".to_string(),
718 VmValue::Int(output.status.code().unwrap_or(-1) as i64),
719 );
720 result.insert(
721 "success".to_string(),
722 VmValue::Bool(output.status.success()),
723 );
724 VmValue::Dict(std::sync::Arc::new(result))
725}
726
727fn exec_command(
728 dir: Option<&str>,
729 cmd: &str,
730 args: &[String],
731) -> Result<std::process::Output, VmError> {
732 let config = process_command_config(dir)?;
733 crate::stdlib::sandbox::command_output(cmd, args, &config)
734 .map_err(|error| prefix_process_error(error, "exec"))
735}
736
737#[cfg(test)]
738fn exec_shell(
739 dir: Option<&str>,
740 shell: &str,
741 flag: &str,
742 script: &str,
743) -> Result<std::process::Output, VmError> {
744 let args = vec![flag.to_string(), script.to_string()];
745 exec_shell_args(dir, shell, &args)
746}
747
748fn exec_shell_args(
749 dir: Option<&str>,
750 shell: &str,
751 args: &[String],
752) -> Result<std::process::Output, VmError> {
753 let config = process_command_config(dir)?;
754 crate::stdlib::sandbox::command_output(shell, args, &config)
755 .map_err(|error| prefix_process_error(error, "shell"))
756}
757
758fn process_command_config(
759 dir: Option<&str>,
760) -> Result<crate::stdlib::sandbox::ProcessCommandConfig, VmError> {
761 let mut config = crate::stdlib::sandbox::ProcessCommandConfig {
762 stdin_null: true,
763 ..Default::default()
764 };
765 if let Some(dir) = dir {
766 let resolved = resolve_command_dir(dir);
767 crate::stdlib::sandbox::enforce_process_cwd(&resolved)?;
768 config.cwd = Some(resolved);
769 } else if let Some(context) = current_execution_context() {
770 if let Some(cwd) = context.cwd.filter(|cwd| !cwd.is_empty()) {
771 crate::stdlib::sandbox::enforce_process_cwd(std::path::Path::new(&cwd))?;
772 config.cwd = Some(std::path::PathBuf::from(cwd));
773 }
774 if !context.env.is_empty() {
775 config.env.extend(context.env);
776 }
777 }
778 if let Some(value) = env_override(HARN_REPLAY_ENV) {
779 config.env.push((HARN_REPLAY_ENV.to_string(), value));
780 }
781 Ok(config)
782}
783
784fn prefix_process_error(error: VmError, prefix: &str) -> VmError {
785 match error {
786 VmError::Thrown(VmValue::String(message)) => VmError::Thrown(VmValue::String(
787 std::sync::Arc::from(format!("{prefix} failed: {message}")),
788 )),
789 other => other,
790 }
791}
792
793fn resolve_command_dir(dir: &str) -> PathBuf {
794 let candidate = PathBuf::from(dir);
795 if candidate.is_absolute() {
796 return candidate;
797 }
798 if let Some(cwd) = current_execution_context().and_then(|context| context.cwd) {
799 return PathBuf::from(cwd).join(candidate);
800 }
801 if let Some(source_dir) = VM_SOURCE_DIR.with(|sd| sd.borrow().clone()) {
802 return source_dir.join(candidate);
803 }
804 candidate
805}
806
807#[cfg(test)]
808mod tests {
809 use super::*;
810
811 struct RuntimePathsEnvGuard {
812 state: Option<String>,
813 run: Option<String>,
814 worktree: Option<String>,
815 }
816
817 impl RuntimePathsEnvGuard {
818 fn capture() -> Self {
819 Self {
820 state: std::env::var(crate::runtime_paths::HARN_STATE_DIR_ENV).ok(),
821 run: std::env::var(crate::runtime_paths::HARN_RUN_DIR_ENV).ok(),
822 worktree: std::env::var(crate::runtime_paths::HARN_WORKTREE_DIR_ENV).ok(),
823 }
824 }
825 }
826
827 impl Drop for RuntimePathsEnvGuard {
828 fn drop(&mut self) {
829 match self.state.as_deref() {
830 Some(value) => std::env::set_var(crate::runtime_paths::HARN_STATE_DIR_ENV, value),
831 None => std::env::remove_var(crate::runtime_paths::HARN_STATE_DIR_ENV),
832 }
833 match self.run.as_deref() {
834 Some(value) => std::env::set_var(crate::runtime_paths::HARN_RUN_DIR_ENV, value),
835 None => std::env::remove_var(crate::runtime_paths::HARN_RUN_DIR_ENV),
836 }
837 match self.worktree.as_deref() {
838 Some(value) => {
839 std::env::set_var(crate::runtime_paths::HARN_WORKTREE_DIR_ENV, value);
840 }
841 None => std::env::remove_var(crate::runtime_paths::HARN_WORKTREE_DIR_ENV),
842 }
843 }
844 }
845
846 #[test]
847 fn lexically_collapse_resolves_sibling_walk() {
848 let path = PathBuf::from("/tmp/project/tests/../fixtures/x.json");
849 let collapsed = lexically_collapse(&path).expect("sibling walk");
850 assert_eq!(collapsed, PathBuf::from("/tmp/project/fixtures/x.json"));
851 }
852
853 #[test]
854 fn lexically_collapse_blocks_escape_past_root() {
855 let path = PathBuf::from("/app/../../etc/passwd");
858 assert!(lexically_collapse(&path).is_none());
859 }
860
861 #[test]
862 fn lexically_collapse_strips_curdir() {
863 let path = PathBuf::from("/app/./logs/today.txt");
864 let collapsed = lexically_collapse(&path).expect("curdir is benign");
865 assert_eq!(collapsed, PathBuf::from("/app/logs/today.txt"));
866 }
867
868 #[test]
869 fn resolve_source_relative_path_blocks_obvious_escape() {
870 let dir =
871 std::env::temp_dir().join(format!("harn-process-escape-{}", uuid::Uuid::now_v7()));
872 std::fs::create_dir_all(&dir).unwrap();
873 set_thread_source_dir(&dir);
874 set_thread_execution_context(Some(crate::orchestration::RunExecutionRecord {
875 cwd: Some(dir.to_string_lossy().into_owned()),
876 source_dir: Some(dir.to_string_lossy().into_owned()),
877 env: BTreeMap::new(),
878 adapter: None,
879 repo_path: None,
880 worktree_path: None,
881 branch: None,
882 base_ref: None,
883 cleanup: None,
884 }));
885 let resolved = resolve_source_relative_path("../../../../../../../../etc/passwd");
889 assert!(
890 resolved
891 .to_string_lossy()
892 .contains("__harn_rejected_parent_dir_traversal__"),
893 "expected rejection sentinel, got {resolved:?}"
894 );
895 reset_process_state();
896 let _ = std::fs::remove_dir_all(&dir);
897 }
898
899 #[test]
900 fn resolve_source_relative_path_ignores_thread_source_dir_without_execution_context() {
901 let dir = std::env::temp_dir().join(format!("harn-process-{}", uuid::Uuid::now_v7()));
902 std::fs::create_dir_all(&dir).unwrap();
903 let current_dir = std::env::current_dir().unwrap();
904 set_thread_source_dir(&dir);
905 let resolved = resolve_source_relative_path("templates/prompt.txt");
906 assert_eq!(resolved, current_dir.join("templates/prompt.txt"));
907 reset_process_state();
908 let _ = std::fs::remove_dir_all(&dir);
909 }
910
911 #[test]
912 fn resolve_source_relative_path_prefers_execution_cwd_over_source_dir() {
913 let cwd = std::env::temp_dir().join(format!("harn-process-cwd-{}", uuid::Uuid::now_v7()));
914 let source_dir =
915 std::env::temp_dir().join(format!("harn-process-source-{}", uuid::Uuid::now_v7()));
916 std::fs::create_dir_all(&cwd).unwrap();
917 std::fs::create_dir_all(&source_dir).unwrap();
918 set_thread_source_dir(&source_dir);
919 set_thread_execution_context(Some(crate::orchestration::RunExecutionRecord {
920 cwd: Some(cwd.to_string_lossy().into_owned()),
921 source_dir: Some(source_dir.to_string_lossy().into_owned()),
922 env: BTreeMap::new(),
923 adapter: None,
924 repo_path: None,
925 worktree_path: None,
926 branch: None,
927 base_ref: None,
928 cleanup: None,
929 }));
930 let resolved = resolve_source_relative_path("templates/prompt.txt");
931 assert_eq!(resolved, cwd.join("templates/prompt.txt"));
932 reset_process_state();
933 let _ = std::fs::remove_dir_all(&cwd);
934 let _ = std::fs::remove_dir_all(&source_dir);
935 }
936
937 #[test]
938 fn resolve_source_asset_path_prefers_execution_source_dir_over_cwd() {
939 let cwd = std::env::temp_dir().join(format!("harn-asset-cwd-{}", uuid::Uuid::now_v7()));
940 let source_dir =
941 std::env::temp_dir().join(format!("harn-asset-source-{}", uuid::Uuid::now_v7()));
942 std::fs::create_dir_all(&cwd).unwrap();
943 std::fs::create_dir_all(&source_dir).unwrap();
944 set_thread_source_dir(&source_dir);
945 set_thread_execution_context(Some(crate::orchestration::RunExecutionRecord {
946 cwd: Some(cwd.to_string_lossy().into_owned()),
947 source_dir: Some(source_dir.to_string_lossy().into_owned()),
948 env: BTreeMap::new(),
949 adapter: None,
950 repo_path: None,
951 worktree_path: None,
952 branch: None,
953 base_ref: None,
954 cleanup: None,
955 }));
956 let resolved = resolve_source_asset_path("templates/prompt.txt");
957 assert_eq!(resolved, source_dir.join("templates/prompt.txt"));
958 reset_process_state();
959 let _ = std::fs::remove_dir_all(&cwd);
960 let _ = std::fs::remove_dir_all(&source_dir);
961 }
962
963 #[test]
964 fn set_thread_source_dir_absolutizes_relative_paths() {
965 reset_process_state();
966 let current_dir = std::env::current_dir().unwrap();
967 set_thread_source_dir(std::path::Path::new("scripts"));
968 assert_eq!(source_root_path(), current_dir.join("scripts"));
969 reset_process_state();
970 }
971
972 #[test]
973 fn exec_context_sets_default_cwd_and_env() {
974 let dir = std::env::temp_dir().join(format!("harn-process-ctx-{}", uuid::Uuid::now_v7()));
975 std::fs::create_dir_all(&dir).unwrap();
976 std::fs::write(dir.join("marker.txt"), "ok").unwrap();
977 set_thread_execution_context(Some(RunExecutionRecord {
978 cwd: Some(dir.to_string_lossy().into_owned()),
979 env: BTreeMap::from([("HARN_PROCESS_TEST".to_string(), "present".to_string())]),
980 ..Default::default()
981 }));
982 let output = exec_shell(
983 None,
984 "sh",
985 "-c",
986 "printf '%s:' \"$HARN_PROCESS_TEST\" && test -f marker.txt",
987 )
988 .unwrap();
989 assert!(output.status.success());
990 assert_eq!(String::from_utf8_lossy(&output.stdout), "present:");
991 reset_process_state();
992 let _ = std::fs::remove_dir_all(&dir);
993 }
994
995 #[test]
996 fn exec_at_resolves_relative_to_execution_cwd() {
997 let dir = std::env::temp_dir().join(format!("harn-process-rel-{}", uuid::Uuid::now_v7()));
998 std::fs::create_dir_all(dir.join("nested")).unwrap();
999 std::fs::write(dir.join("nested").join("marker.txt"), "ok").unwrap();
1000 set_thread_execution_context(Some(RunExecutionRecord {
1001 cwd: Some(dir.to_string_lossy().into_owned()),
1002 ..Default::default()
1003 }));
1004 let output = exec_shell(Some("nested"), "sh", "-c", "test -f marker.txt").unwrap();
1005 assert!(output.status.success());
1006 reset_process_state();
1007 let _ = std::fs::remove_dir_all(&dir);
1008 }
1009
1010 #[test]
1011 fn runtime_paths_uses_configurable_state_roots() {
1012 let _runtime_paths_env_lock = crate::runtime_paths::test_env_lock()
1013 .lock()
1014 .expect("runtime paths env lock");
1015 let _env_guard = RuntimePathsEnvGuard::capture();
1016 let base =
1017 std::env::temp_dir().join(format!("harn-process-runtime-{}", uuid::Uuid::now_v7()));
1018 std::fs::create_dir_all(&base).unwrap();
1019 std::env::set_var(crate::runtime_paths::HARN_STATE_DIR_ENV, ".custom-harn");
1020 std::env::set_var(crate::runtime_paths::HARN_RUN_DIR_ENV, ".custom-runs");
1021 std::env::set_var(
1022 crate::runtime_paths::HARN_WORKTREE_DIR_ENV,
1023 ".custom-worktrees",
1024 );
1025 set_thread_execution_context(Some(RunExecutionRecord {
1026 cwd: Some(base.to_string_lossy().into_owned()),
1027 ..Default::default()
1028 }));
1029
1030 let mut vm = crate::vm::Vm::new();
1031 register_process_builtins(&mut vm);
1032 let mut out = String::new();
1033 let builtin = vm
1034 .builtins
1035 .get("runtime_paths")
1036 .expect("runtime_paths builtin");
1037 let paths = match builtin(&[], &mut out).unwrap() {
1038 VmValue::Dict(map) => map,
1039 other => panic!("expected dict, got {other:?}"),
1040 };
1041 assert_eq!(
1042 paths.get("state_root").unwrap().display(),
1043 base.join(".custom-harn").display().to_string()
1044 );
1045 assert_eq!(
1046 paths.get("run_root").unwrap().display(),
1047 base.join(".custom-runs").display().to_string()
1048 );
1049 assert_eq!(
1050 paths.get("worktree_root").unwrap().display(),
1051 base.join(".custom-worktrees").display().to_string()
1052 );
1053
1054 reset_process_state();
1055 let _ = std::fs::remove_dir_all(&base);
1056 }
1057}