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