1use crate::value::VmDictExt;
2use std::cell::RefCell;
3use std::collections::BTreeMap;
4use std::io::Write as _;
5use std::path::PathBuf;
6use std::process::Stdio;
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 swap_thread_execution_context(
51 next: Option<RunExecutionRecord>,
52) -> Option<RunExecutionRecord> {
53 VM_EXECUTION_CONTEXT.with(|current| std::mem::replace(&mut *current.borrow_mut(), next))
54}
55
56pub(crate) fn swap_source_dir(next: Option<PathBuf>) -> Option<PathBuf> {
60 VM_SOURCE_DIR.with(|current| std::mem::replace(&mut *current.borrow_mut(), next))
61}
62
63pub(crate) struct SourceDirGuard {
76 previous: Option<PathBuf>,
77}
78
79impl SourceDirGuard {
80 pub(crate) fn capture() -> Self {
82 Self {
83 previous: VM_SOURCE_DIR.with(|sd| sd.borrow().clone()),
84 }
85 }
86}
87
88impl Drop for SourceDirGuard {
89 fn drop(&mut self) {
90 let previous = self.previous.take();
91 VM_SOURCE_DIR.with(|sd| *sd.borrow_mut() = previous);
92 }
93}
94
95pub(crate) fn reset_process_state() {
97 VM_SOURCE_DIR.with(|sd| *sd.borrow_mut() = None);
98 VM_EXECUTION_CONTEXT.with(|current| *current.borrow_mut() = None);
99}
100
101pub fn execution_root_path() -> PathBuf {
102 current_execution_context()
103 .and_then(|context| context.cwd.map(PathBuf::from))
104 .or_else(|| std::env::current_dir().ok())
105 .unwrap_or_else(|| PathBuf::from("."))
106}
107
108pub fn source_root_path() -> PathBuf {
109 VM_SOURCE_DIR
110 .with(|sd| sd.borrow().clone())
111 .or_else(|| {
112 current_execution_context().and_then(|context| context.source_dir.map(PathBuf::from))
113 })
114 .or_else(|| current_execution_context().and_then(|context| context.cwd.map(PathBuf::from)))
115 .or_else(|| std::env::current_dir().ok())
116 .unwrap_or_else(|| PathBuf::from("."))
117}
118
119pub fn asset_root_path() -> PathBuf {
120 source_root_path()
121}
122
123fn env_override(name: &str) -> Option<String> {
124 (name == HARN_REPLAY_ENV && crate::triggers::dispatcher::current_dispatch_is_replay())
125 .then(|| "1".to_string())
126}
127
128pub(crate) fn read_env_value(name: &str) -> Option<String> {
129 env_override(name)
130 .or_else(|| current_execution_context().and_then(|context| context.env.get(name).cloned()))
131 .or_else(|| std::env::var(name).ok())
132}
133
134pub fn runtime_root_base() -> PathBuf {
135 find_project_root(&execution_root_path())
136 .or_else(|| find_project_root(&source_root_path()))
137 .unwrap_or_else(source_root_path)
138}
139
140fn lexically_collapse(path: &std::path::Path) -> Option<PathBuf> {
145 use std::path::Component;
146 let mut out: Vec<Component> = Vec::new();
147 for component in path.components() {
148 match component {
149 Component::CurDir => {}
150 Component::ParentDir => {
151 let popped = out.pop();
152 if !matches!(popped, Some(Component::Normal(_))) {
153 return None;
154 }
155 }
156 other => out.push(other),
157 }
158 }
159 Some(out.iter().collect())
160}
161
162pub fn resolve_source_relative_path(path: &str) -> PathBuf {
163 let candidate = PathBuf::from(path);
164 if candidate.is_absolute() {
165 return candidate;
166 }
167 let root = execution_root_path();
168 let joined = root.join(&candidate);
169 if path_escapes_project_root(&joined) {
176 return root.join("__harn_rejected_parent_dir_traversal__");
177 }
178 joined
179}
180
181pub fn resolve_source_asset_path(path: &str) -> PathBuf {
182 let candidate = PathBuf::from(path);
183 if candidate.is_absolute() {
184 return candidate;
185 }
186 let root = asset_root_path();
187 let joined = root.join(&candidate);
188 if path_escapes_project_root(&joined) {
189 return root.join("__harn_rejected_parent_dir_traversal__");
190 }
191 joined
192}
193
194fn path_escapes_project_root(joined: &std::path::Path) -> bool {
208 lexically_collapse(joined).is_none()
209}
210
211pub(crate) fn register_process_builtins(vm: &mut Vm) {
212 for def in PROCESS_BUILTINS {
213 vm.register_builtin_def(def);
214 }
215}
216
217#[harn_builtin(sig = "env(name: string) -> string?", category = "process")]
218fn env_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
219 let name = args.first().map(|a| a.display()).unwrap_or_default();
220 if let Some(value) = read_env_value(&name) {
221 return Ok(VmValue::String(arcstr::ArcStr::from(value)));
222 }
223 Ok(VmValue::Nil)
224}
225
226#[harn_builtin(
227 sig = "env_or(name: string, default: any) -> any",
228 category = "process"
229)]
230fn env_or_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
231 let name = args.first().map(|a| a.display()).unwrap_or_default();
232 let default = args.get(1).cloned().unwrap_or(VmValue::Nil);
233 if let Some(value) = read_env_value(&name) {
234 return Ok(VmValue::String(arcstr::ArcStr::from(value)));
235 }
236 Ok(default)
237}
238
239#[harn_builtin(sig = "exit(code?: int) -> never", category = "process")]
240fn exit_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
241 let code = args.first().and_then(|a| a.as_int()).unwrap_or(0);
242 std::process::exit(code as i32);
243}
244
245#[harn_builtin(sig = "exec(...command: string) -> dict", category = "process")]
246fn exec_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
247 if args.is_empty() {
248 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
249 "exec: command is required",
250 ))));
251 }
252 let cmd = args[0].display();
253 let cmd_args: Vec<String> = args[1..].iter().map(|a| a.display()).collect();
254 let output = exec_command(None, &cmd, &cmd_args)?;
255 Ok(vm_output_to_value(output))
256}
257
258#[harn_builtin(sig = "shell(command: string) -> dict", category = "process")]
259fn shell_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
260 let cmd = args.first().map(|a| a.display()).unwrap_or_default();
261 if cmd.is_empty() {
262 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
263 "shell: command string is required",
264 ))));
265 }
266 let invocation = crate::shells::default_shell_invocation(&cmd)
267 .map_err(|error| VmError::Runtime(format!("shell: {error}")))?;
268 let output = exec_shell_args(None, &invocation.program, &invocation.args)?;
269 Ok(vm_output_to_value(output))
270}
271
272#[harn_builtin(
273 sig = "exec_at(dir: string, ...command: string) -> dict",
274 category = "process"
275)]
276fn exec_at_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
277 if args.len() < 2 {
278 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
279 "exec_at: directory and command are required",
280 ))));
281 }
282 let dir = args[0].display();
283 let cmd = args[1].display();
284 let cmd_args: Vec<String> = args[2..].iter().map(|a| a.display()).collect();
285 let output = exec_command(Some(dir.as_str()), &cmd, &cmd_args)?;
286 Ok(vm_output_to_value(output))
287}
288
289#[harn_builtin(
290 sig = "shell_at(dir: string, command: string) -> dict",
291 category = "process"
292)]
293fn shell_at_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
294 if args.len() < 2 {
295 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
296 "shell_at: directory and command string are required",
297 ))));
298 }
299 let dir = args[0].display();
300 let cmd = args[1].display();
301 if cmd.is_empty() {
302 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
303 "shell_at: command string is required",
304 ))));
305 }
306 let invocation = crate::shells::default_shell_invocation(&cmd)
307 .map_err(|error| VmError::Runtime(format!("shell_at: {error}")))?;
308 let output = exec_shell_args(Some(dir.as_str()), &invocation.program, &invocation.args)?;
309 Ok(vm_output_to_value(output))
310}
311
312#[harn_builtin(sig = "username(...args: any) -> string", category = "process")]
313fn username_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
314 let user = std::env::var("USER")
315 .or_else(|_| std::env::var("USERNAME"))
316 .unwrap_or_default();
317 Ok(VmValue::String(arcstr::ArcStr::from(user)))
318}
319
320#[harn_builtin(sig = "hostname() -> string", category = "process")]
321fn hostname_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
322 let name = std::env::var("HOSTNAME")
323 .or_else(|_| std::env::var("COMPUTERNAME"))
324 .or_else(|_| {
325 std::process::Command::new("hostname")
326 .output()
327 .ok()
328 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
329 .ok_or(std::env::VarError::NotPresent)
330 })
331 .unwrap_or_default();
332 Ok(VmValue::String(arcstr::ArcStr::from(name)))
333}
334
335#[harn_builtin(sig = "platform(...args: any) -> string", category = "process")]
336fn platform_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
337 let os = if cfg!(target_os = "macos") {
338 "darwin"
339 } else if cfg!(target_os = "linux") {
340 "linux"
341 } else if cfg!(target_os = "windows") {
342 "windows"
343 } else {
344 std::env::consts::OS
345 };
346 Ok(VmValue::String(arcstr::ArcStr::from(os)))
347}
348
349#[harn_builtin(sig = "arch() -> string", category = "process")]
350fn arch_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
351 Ok(VmValue::String(arcstr::ArcStr::from(
352 std::env::consts::ARCH,
353 )))
354}
355
356#[harn_builtin(sig = "home_dir() -> string", category = "process")]
357fn home_dir_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
358 let home = crate::user_dirs::home_dir()
359 .map(|home| home.to_string_lossy().into_owned())
360 .unwrap_or_default();
361 Ok(VmValue::String(arcstr::ArcStr::from(home)))
362}
363
364#[harn_builtin(sig = "pid(...args: any) -> int", category = "process")]
365fn pid_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
366 Ok(VmValue::Int(std::process::id() as i64))
367}
368
369#[harn_builtin(sig = "date_iso() -> string", category = "process")]
370fn date_iso_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
371 let now = crate::clock_mock::leak_audit::wall_now("stdlib/date_iso");
378 let dt: chrono::DateTime<chrono::Utc> = now.into();
379 Ok(VmValue::String(arcstr::ArcStr::from(
380 dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
381 )))
382}
383
384#[harn_builtin(sig = "cwd() -> string", category = "process")]
385fn cwd_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
386 let dir = current_execution_context()
387 .and_then(|context| context.cwd)
388 .or_else(|| {
389 std::env::current_dir()
390 .ok()
391 .map(|p| p.to_string_lossy().into_owned())
392 })
393 .unwrap_or_default();
394 Ok(VmValue::String(arcstr::ArcStr::from(dir)))
395}
396
397#[harn_builtin(sig = "execution_root() -> string", category = "process")]
398fn execution_root_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
399 Ok(VmValue::String(arcstr::ArcStr::from(
400 execution_root_path().to_string_lossy().into_owned(),
401 )))
402}
403
404#[harn_builtin(sig = "asset_root() -> string", category = "process")]
405fn asset_root_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
406 Ok(VmValue::String(arcstr::ArcStr::from(
407 asset_root_path().to_string_lossy().into_owned(),
408 )))
409}
410
411#[harn_builtin(sig = "runtime_paths() -> dict", category = "process")]
412fn runtime_paths_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
413 let runtime_base = runtime_root_base();
414 let mut paths = BTreeMap::new();
415 paths.put_str("execution_root", execution_root_path().to_string_lossy());
416 paths.put_str("asset_root", asset_root_path().to_string_lossy());
417 paths.put_str(
418 "state_root",
419 crate::runtime_paths::state_root(&runtime_base).to_string_lossy(),
420 );
421 paths.put_str(
422 "run_root",
423 crate::runtime_paths::run_root(&runtime_base).to_string_lossy(),
424 );
425 paths.put_str(
426 "worktree_root",
427 crate::runtime_paths::worktree_root(&runtime_base).to_string_lossy(),
428 );
429 Ok(VmValue::dict(paths))
430}
431
432#[harn_builtin(sig = "spawn_captured(opts: dict) -> dict", category = "process")]
433fn spawn_captured_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
434 spawn_captured_value(args)
435}
436
437#[harn_builtin(sig = "term_width() -> int", category = "process")]
447fn term_width_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
448 Ok(VmValue::Int(crate::term::width() as i64))
449}
450
451#[harn_builtin(sig = "term_height() -> int", category = "process")]
452fn term_height_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
453 Ok(VmValue::Int(crate::term::height() as i64))
454}
455
456const PROCESS_BUILTINS: &[&VmBuiltinDef] = &[
457 &ENV_IMPL_DEF,
458 &ENV_OR_IMPL_DEF,
459 &EXIT_IMPL_DEF,
460 &EXEC_IMPL_DEF,
461 &EXEC_OPTS_IMPL_DEF,
462 &SHELL_IMPL_DEF,
463 &EXEC_AT_IMPL_DEF,
464 &EXEC_AT_OPTS_IMPL_DEF,
465 &SHELL_AT_IMPL_DEF,
466 &USERNAME_IMPL_DEF,
467 &HOSTNAME_IMPL_DEF,
468 &PLATFORM_IMPL_DEF,
469 &ARCH_IMPL_DEF,
470 &HOME_DIR_IMPL_DEF,
471 &PID_IMPL_DEF,
472 &DATE_ISO_IMPL_DEF,
473 &CWD_IMPL_DEF,
474 &EXECUTION_ROOT_IMPL_DEF,
475 &ASSET_ROOT_IMPL_DEF,
476 &RUNTIME_PATHS_IMPL_DEF,
477 &SPAWN_CAPTURED_IMPL_DEF,
478 &TERM_WIDTH_IMPL_DEF,
479 &TERM_HEIGHT_IMPL_DEF,
480];
481
482pub(crate) fn spawn_captured_value(args: &[VmValue]) -> Result<VmValue, VmError> {
487 let opts = match args.first() {
488 Some(VmValue::Dict(opts)) => opts.clone(),
489 _ => {
490 return Err(VmError::Runtime(
491 "spawn_captured: options dict is required".to_string(),
492 ));
493 }
494 };
495 let cmd = match opts.get("cmd").map(|v| v.display()).unwrap_or_default() {
496 s if s.is_empty() => {
497 return Err(VmError::Runtime(
498 "spawn_captured: opts.cmd is required".to_string(),
499 ));
500 }
501 s => s,
502 };
503 let cmd_args: Vec<String> = match opts.get("args") {
504 Some(VmValue::List(items)) => items.iter().map(|v| v.display()).collect(),
505 None | Some(VmValue::Nil) => Vec::new(),
506 Some(other) => {
507 return Err(VmError::Runtime(format!(
508 "spawn_captured: opts.args must be a list of strings, got {}",
509 other.type_name()
510 )));
511 }
512 };
513 let cwd = opts
514 .get("cwd")
515 .map(|v| v.display())
516 .filter(|s| !s.is_empty());
517 let env_overrides: Vec<(String, String)> = match opts.get("env") {
518 Some(VmValue::Dict(env)) => env
519 .iter()
520 .map(|(k, v)| (k.to_string(), v.display()))
521 .collect(),
522 None | Some(VmValue::Nil) => Vec::new(),
523 Some(other) => {
524 return Err(VmError::Runtime(format!(
525 "spawn_captured: opts.env must be a dict, got {}",
526 other.type_name()
527 )));
528 }
529 };
530 let stdin_bytes: Option<Vec<u8>> = match opts.get("stdin") {
531 Some(VmValue::Bytes(bytes)) => Some(bytes.as_slice().to_vec()),
532 Some(VmValue::String(s)) => Some(s.as_bytes().to_vec()),
533 None | Some(VmValue::Nil) => None,
534 Some(other) => {
535 return Err(VmError::Runtime(format!(
536 "spawn_captured: opts.stdin must be string or bytes, got {}",
537 other.type_name()
538 )));
539 }
540 };
541 let timeout = opts
542 .get("timeout_ms")
543 .and_then(|v| v.as_int())
544 .filter(|n| *n > 0)
545 .map(|n| Duration::from_millis(n as u64));
546
547 let spawn = CapturedSpawn {
548 label: "spawn_captured",
549 cmd: &cmd,
550 args: &cmd_args,
551 cwd: cwd.as_deref(),
552 env: &env_overrides,
553 env_clear: false,
556 stdin: stdin_bytes,
557 timeout,
558 };
559 let CapturedRun {
560 output,
561 timed_out,
562 interrupted,
563 duration_ms,
564 } = run_captured_spawn(spawn)?;
565
566 let exit_code = if timed_out || interrupted {
567 -1
568 } else {
569 output.status.code().unwrap_or(-1) as i64
570 };
571 let success = if timed_out || interrupted {
572 false
573 } else {
574 output.status.success()
575 };
576 let mut result = BTreeMap::new();
577 result.insert("exit_code".to_string(), VmValue::Int(exit_code));
578 result.put_str("stdout", String::from_utf8_lossy(&output.stdout).as_ref());
579 result.put_str("stderr", String::from_utf8_lossy(&output.stderr).as_ref());
580 result.insert("duration_ms".to_string(), VmValue::Int(duration_ms));
581 result.insert("success".to_string(), VmValue::Bool(success));
582 result.insert("timed_out".to_string(), VmValue::Bool(timed_out));
583 Ok(VmValue::dict(result))
584}
585
586struct CapturedSpawn<'a> {
591 label: &'static str,
592 cmd: &'a str,
593 args: &'a [String],
594 cwd: Option<&'a str>,
595 env: &'a [(String, String)],
596 env_clear: bool,
597 stdin: Option<Vec<u8>>,
598 timeout: Option<Duration>,
599}
600
601struct CapturedRun {
603 output: std::process::Output,
604 timed_out: bool,
605 interrupted: bool,
606 duration_ms: i64,
607}
608
609fn run_captured_spawn(spec: CapturedSpawn<'_>) -> Result<CapturedRun, VmError> {
620 let label = spec.label;
621 let mut command = std::process::Command::new(spec.cmd);
622 command.args(spec.args);
623 if let Some(cwd) = spec.cwd {
624 command.current_dir(cwd);
625 }
626 if spec.env_clear {
627 command.env_clear();
628 }
629 for (key, value) in spec.env {
630 command.env(key, value);
631 }
632 command.stdout(Stdio::piped()).stderr(Stdio::piped());
633 if spec.stdin.is_some() {
634 command.stdin(Stdio::piped());
635 } else {
636 command.stdin(Stdio::null());
637 }
638 crate::op_interrupt::configure_kill_group(&mut command);
639 let cleanup_token = crate::op_interrupt::new_process_cleanup_token();
640 command.env(
641 crate::op_interrupt::PROCESS_CLEANUP_TOKEN_ENV,
642 &cleanup_token,
643 );
644
645 let started = Instant::now();
646 let cmd = spec.cmd;
647 let mut child = command.spawn().map_err(|error| {
648 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
649 "{label}: failed to spawn '{cmd}': {error}"
650 ))))
651 })?;
652
653 if let (Some(payload), Some(mut stdin)) = (spec.stdin, child.stdin.take()) {
654 let _ = stdin.write_all(&payload);
656 }
657
658 let rx_out = child
662 .stdout
663 .take()
664 .map(crate::op_interrupt::spawn_pipe_drain);
665 let rx_err = child
666 .stderr
667 .take()
668 .map(crate::op_interrupt::spawn_pipe_drain);
669
670 let child_pid = child.id();
671 let wait_end = crate::op_interrupt::wait_child_interruptible_with_cleanup_token(
672 &mut child,
673 spec.timeout,
674 Some(&cleanup_token),
675 )
676 .map_err(|error| {
677 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
678 "{label}: wait failed: {error}"
679 ))))
680 })?;
681 let (status, timed_out, interrupted, killed) = match wait_end {
682 crate::op_interrupt::ChildWait::Exited(status) => (status, false, false, false),
683 crate::op_interrupt::ChildWait::TimedOut(_) => {
684 (std::process::ExitStatus::default(), true, false, true)
685 }
686 crate::op_interrupt::ChildWait::Interrupted(status, _) => {
690 (status.unwrap_or_default(), false, true, true)
691 }
692 };
693
694 let stdout = rx_out
695 .map(|rx| crate::op_interrupt::drain_captured_pipe(&rx, killed, child_pid))
696 .unwrap_or_default();
697 let stderr = rx_err
698 .map(|rx| crate::op_interrupt::drain_captured_pipe(&rx, killed, child_pid))
699 .unwrap_or_default();
700
701 Ok(CapturedRun {
702 output: std::process::Output {
703 status,
704 stdout,
705 stderr,
706 },
707 timed_out,
708 interrupted,
709 duration_ms: started.elapsed().as_millis() as i64,
710 })
711}
712
713#[derive(Default)]
716struct ExecOptions {
717 env: Vec<(String, String)>,
718 env_clear: bool,
719 cwd: Option<String>,
720 timeout: Option<Duration>,
721}
722
723fn exec_options(label: &str, options: Option<&VmValue>) -> Result<ExecOptions, VmError> {
730 let opts = match options {
731 None | Some(VmValue::Nil) => return Ok(ExecOptions::default()),
732 Some(VmValue::Dict(opts)) => opts.clone(),
733 Some(other) => {
734 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
735 format!("{label}: options must be a dict, got {}", other.type_name()),
736 ))));
737 }
738 };
739 let env: Vec<(String, String)> = match opts.get("env") {
740 Some(VmValue::Dict(env)) => env
741 .iter()
742 .map(|(k, v)| (k.to_string(), v.display()))
743 .collect(),
744 None | Some(VmValue::Nil) => Vec::new(),
745 Some(other) => {
746 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
747 format!(
748 "{label}: options.env must be a dict, got {}",
749 other.type_name()
750 ),
751 ))));
752 }
753 };
754 let env_clear = match opts.get("env_mode").map(|v| v.display()).as_deref() {
755 None | Some("merge") => false,
756 Some("replace") => true,
757 Some(other) => {
758 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
759 format!(
760 "{label}: options.env_mode must be \"merge\" or \"replace\", got {other:?}"
761 ),
762 ))));
763 }
764 };
765 let cwd = opts
766 .get("cwd")
767 .map(|v| v.display())
768 .filter(|s| !s.is_empty());
769 let timeout = opts
772 .get("timeout")
773 .or_else(|| opts.get("timeout_ms"))
774 .and_then(|v| v.as_int())
775 .filter(|n| *n > 0)
776 .map(|n| Duration::from_millis(n as u64));
777 Ok(ExecOptions {
778 env,
779 env_clear,
780 cwd,
781 timeout,
782 })
783}
784
785fn captured_run_to_value(run: &CapturedRun) -> VmValue {
789 let status = if run.timed_out || run.interrupted {
790 -1
791 } else {
792 run.output.status.code().unwrap_or(-1) as i64
793 };
794 let success = !run.timed_out && !run.interrupted && run.output.status.success();
795 let mut result = BTreeMap::new();
796 result.put_str(
797 "stdout",
798 String::from_utf8_lossy(&run.output.stdout).as_ref(),
799 );
800 result.put_str(
801 "stderr",
802 String::from_utf8_lossy(&run.output.stderr).as_ref(),
803 );
804 result.insert("status".to_string(), VmValue::Int(status));
805 result.insert("success".to_string(), VmValue::Bool(success));
806 result.insert("timed_out".to_string(), VmValue::Bool(run.timed_out));
807 result.insert("duration_ms".to_string(), VmValue::Int(run.duration_ms));
808 VmValue::dict(result)
809}
810
811#[harn_builtin(
812 sig = "exec_opts(command: list, options: dict?) -> dict",
813 category = "process"
814)]
815fn exec_opts_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
816 let command = exec_opts_command("exec_opts", args.first())?;
817 let opts = exec_options("exec_opts", args.get(1))?;
818 let run = run_captured_spawn(CapturedSpawn {
819 label: "exec_opts",
820 cmd: &command[0],
821 args: &command[1..],
822 cwd: opts.cwd.as_deref(),
823 env: &opts.env,
824 env_clear: opts.env_clear,
825 stdin: None,
826 timeout: opts.timeout,
827 })?;
828 Ok(captured_run_to_value(&run))
829}
830
831#[harn_builtin(
832 sig = "exec_at_opts(dir: string, command: list, options: dict?) -> dict",
833 category = "process"
834)]
835fn exec_at_opts_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
836 let dir = match args.first() {
837 Some(value) if !value.display().is_empty() => value.display(),
838 _ => {
839 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
840 "exec_at_opts: directory is required",
841 ))));
842 }
843 };
844 let command = exec_opts_command("exec_at_opts", args.get(1))?;
845 let opts = exec_options("exec_at_opts", args.get(2))?;
846 let resolved_cwd = opts.cwd.unwrap_or(dir);
849 let run = run_captured_spawn(CapturedSpawn {
850 label: "exec_at_opts",
851 cmd: &command[0],
852 args: &command[1..],
853 cwd: Some(resolved_cwd.as_str()),
854 env: &opts.env,
855 env_clear: opts.env_clear,
856 stdin: None,
857 timeout: opts.timeout,
858 })?;
859 Ok(captured_run_to_value(&run))
860}
861
862fn exec_opts_command(label: &str, value: Option<&VmValue>) -> Result<Vec<String>, VmError> {
865 let items = match value {
866 Some(VmValue::List(items)) => items,
867 _ => {
868 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
869 format!("{label}: command must be a non-empty list of strings"),
870 ))));
871 }
872 };
873 let command: Vec<String> = items.iter().map(|v| v.display()).collect();
874 if command.is_empty() || command[0].is_empty() {
875 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
876 format!("{label}: command must be a non-empty list of strings"),
877 ))));
878 }
879 Ok(command)
880}
881
882pub fn find_project_root(base: &std::path::Path) -> Option<std::path::PathBuf> {
884 let mut dir = base.to_path_buf();
885 loop {
886 if dir.join("harn.toml").exists() {
887 return Some(dir);
888 }
889 if !dir.pop() {
890 return None;
891 }
892 }
893}
894
895pub(crate) fn register_path_builtins(vm: &mut Vm) {
897 for def in PATH_BUILTINS {
898 vm.register_builtin_def(def);
899 }
900}
901
902#[harn_builtin(sig = "source_dir(...args: any) -> string", category = "process")]
903fn source_dir_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
904 let dir = VM_SOURCE_DIR.with(|sd| sd.borrow().clone());
905 match dir {
906 Some(d) => Ok(VmValue::String(arcstr::ArcStr::from(
907 d.to_string_lossy().into_owned(),
908 ))),
909 None => {
910 let cwd = std::env::current_dir()
911 .map(|p| p.to_string_lossy().into_owned())
912 .unwrap_or_default();
913 Ok(VmValue::String(arcstr::ArcStr::from(cwd)))
914 }
915 }
916}
917
918#[harn_builtin(sig = "project_root() -> string?", category = "process")]
919fn project_root_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
920 let base = current_execution_context()
921 .and_then(|context| context.cwd.map(PathBuf::from))
922 .or_else(|| VM_SOURCE_DIR.with(|sd| sd.borrow().clone()))
923 .or_else(|| std::env::current_dir().ok())
924 .unwrap_or_else(|| PathBuf::from("."));
925 match find_project_root(&base) {
926 Some(root) => Ok(VmValue::String(arcstr::ArcStr::from(
927 root.to_string_lossy().into_owned(),
928 ))),
929 None => Ok(VmValue::Nil),
930 }
931}
932
933const PATH_BUILTINS: &[&VmBuiltinDef] = &[&SOURCE_DIR_IMPL_DEF, &PROJECT_ROOT_IMPL_DEF];
934
935fn vm_output_to_value(output: std::process::Output) -> VmValue {
936 let mut result = BTreeMap::new();
937 result.put_str("stdout", String::from_utf8_lossy(&output.stdout).as_ref());
938 result.put_str("stderr", String::from_utf8_lossy(&output.stderr).as_ref());
939 result.insert(
940 "status".to_string(),
941 VmValue::Int(output.status.code().unwrap_or(-1) as i64),
942 );
943 result.insert(
944 "success".to_string(),
945 VmValue::Bool(output.status.success()),
946 );
947 VmValue::dict(result)
948}
949
950fn exec_command(
951 dir: Option<&str>,
952 cmd: &str,
953 args: &[String],
954) -> Result<std::process::Output, VmError> {
955 let config = process_command_config(dir)?;
956 crate::stdlib::sandbox::command_output(cmd, args, &config)
957 .map_err(|error| prefix_process_error(error, "exec"))
958}
959
960#[cfg(test)]
961fn exec_shell(
962 dir: Option<&str>,
963 shell: &str,
964 flag: &str,
965 script: &str,
966) -> Result<std::process::Output, VmError> {
967 let args = vec![flag.to_string(), script.to_string()];
968 exec_shell_args(dir, shell, &args)
969}
970
971fn exec_shell_args(
972 dir: Option<&str>,
973 shell: &str,
974 args: &[String],
975) -> Result<std::process::Output, VmError> {
976 let config = process_command_config(dir)?;
977 crate::stdlib::sandbox::command_output(shell, args, &config)
978 .map_err(|error| prefix_process_error(error, "shell"))
979}
980
981fn process_command_config(
982 dir: Option<&str>,
983) -> Result<crate::stdlib::sandbox::ProcessCommandConfig, VmError> {
984 let mut config = crate::stdlib::sandbox::ProcessCommandConfig {
985 stdin_null: true,
986 ..Default::default()
987 };
988 if let Some(dir) = dir {
989 let resolved = resolve_command_dir(dir);
990 crate::stdlib::sandbox::enforce_process_cwd(&resolved)?;
991 config.cwd = Some(resolved);
992 } else if let Some(context) = current_execution_context() {
993 if let Some(cwd) = context.cwd.filter(|cwd| !cwd.is_empty()) {
994 crate::stdlib::sandbox::enforce_process_cwd(std::path::Path::new(&cwd))?;
995 config.cwd = Some(std::path::PathBuf::from(cwd));
996 }
997 if !context.env.is_empty() {
998 config.env.extend(context.env);
999 }
1000 }
1001 if let Some(value) = env_override(HARN_REPLAY_ENV) {
1002 config.env.push((HARN_REPLAY_ENV.to_string(), value));
1003 }
1004 Ok(config)
1005}
1006
1007fn prefix_process_error(error: VmError, prefix: &str) -> VmError {
1008 match error {
1009 VmError::Thrown(VmValue::String(message)) => VmError::Thrown(VmValue::String(
1010 arcstr::ArcStr::from(format!("{prefix} failed: {message}")),
1011 )),
1012 other => other,
1013 }
1014}
1015
1016fn resolve_command_dir(dir: &str) -> PathBuf {
1017 let candidate = PathBuf::from(dir);
1018 if candidate.is_absolute() {
1019 return candidate;
1020 }
1021 if let Some(cwd) = current_execution_context().and_then(|context| context.cwd) {
1022 return PathBuf::from(cwd).join(candidate);
1023 }
1024 if let Some(source_dir) = VM_SOURCE_DIR.with(|sd| sd.borrow().clone()) {
1025 return source_dir.join(candidate);
1026 }
1027 candidate
1028}
1029
1030#[cfg(test)]
1031mod tests {
1032 use super::*;
1033
1034 struct RuntimePathsEnvGuard {
1035 state: Option<String>,
1036 run: Option<String>,
1037 worktree: Option<String>,
1038 }
1039
1040 impl RuntimePathsEnvGuard {
1041 fn capture() -> Self {
1042 Self {
1043 state: std::env::var(crate::runtime_paths::HARN_STATE_DIR_ENV).ok(),
1044 run: std::env::var(crate::runtime_paths::HARN_RUN_DIR_ENV).ok(),
1045 worktree: std::env::var(crate::runtime_paths::HARN_WORKTREE_DIR_ENV).ok(),
1046 }
1047 }
1048 }
1049
1050 impl Drop for RuntimePathsEnvGuard {
1051 fn drop(&mut self) {
1052 match self.state.as_deref() {
1053 Some(value) => std::env::set_var(crate::runtime_paths::HARN_STATE_DIR_ENV, value),
1054 None => std::env::remove_var(crate::runtime_paths::HARN_STATE_DIR_ENV),
1055 }
1056 match self.run.as_deref() {
1057 Some(value) => std::env::set_var(crate::runtime_paths::HARN_RUN_DIR_ENV, value),
1058 None => std::env::remove_var(crate::runtime_paths::HARN_RUN_DIR_ENV),
1059 }
1060 match self.worktree.as_deref() {
1061 Some(value) => {
1062 std::env::set_var(crate::runtime_paths::HARN_WORKTREE_DIR_ENV, value);
1063 }
1064 None => std::env::remove_var(crate::runtime_paths::HARN_WORKTREE_DIR_ENV),
1065 }
1066 }
1067 }
1068
1069 #[test]
1070 fn lexically_collapse_resolves_sibling_walk() {
1071 let path = PathBuf::from("/tmp/project/tests/../fixtures/x.json");
1072 let collapsed = lexically_collapse(&path).expect("sibling walk");
1073 assert_eq!(collapsed, PathBuf::from("/tmp/project/fixtures/x.json"));
1074 }
1075
1076 #[test]
1077 fn lexically_collapse_blocks_escape_past_root() {
1078 let path = PathBuf::from("/app/../../etc/passwd");
1081 assert!(lexically_collapse(&path).is_none());
1082 }
1083
1084 #[test]
1085 fn lexically_collapse_strips_curdir() {
1086 let path = PathBuf::from("/app/./logs/today.txt");
1087 let collapsed = lexically_collapse(&path).expect("curdir is benign");
1088 assert_eq!(collapsed, PathBuf::from("/app/logs/today.txt"));
1089 }
1090
1091 #[test]
1092 fn resolve_source_relative_path_blocks_obvious_escape() {
1093 let dir =
1094 std::env::temp_dir().join(format!("harn-process-escape-{}", uuid::Uuid::now_v7()));
1095 std::fs::create_dir_all(&dir).unwrap();
1096 set_thread_source_dir(&dir);
1097 set_thread_execution_context(Some(crate::orchestration::RunExecutionRecord {
1098 cwd: Some(dir.to_string_lossy().into_owned()),
1099 source_dir: Some(dir.to_string_lossy().into_owned()),
1100 env: BTreeMap::new(),
1101 adapter: None,
1102 repo_path: None,
1103 worktree_path: None,
1104 branch: None,
1105 base_ref: None,
1106 cleanup: None,
1107 }));
1108 let resolved = resolve_source_relative_path("../../../../../../../../etc/passwd");
1112 assert!(
1113 resolved
1114 .to_string_lossy()
1115 .contains("__harn_rejected_parent_dir_traversal__"),
1116 "expected rejection sentinel, got {resolved:?}"
1117 );
1118 reset_process_state();
1119 let _ = std::fs::remove_dir_all(&dir);
1120 }
1121
1122 #[test]
1123 fn resolve_source_relative_path_ignores_thread_source_dir_without_execution_context() {
1124 let dir = std::env::temp_dir().join(format!("harn-process-{}", uuid::Uuid::now_v7()));
1125 std::fs::create_dir_all(&dir).unwrap();
1126 let current_dir = std::env::current_dir().unwrap();
1127 set_thread_source_dir(&dir);
1128 let resolved = resolve_source_relative_path("templates/prompt.txt");
1129 assert_eq!(resolved, current_dir.join("templates/prompt.txt"));
1130 reset_process_state();
1131 let _ = std::fs::remove_dir_all(&dir);
1132 }
1133
1134 #[test]
1135 fn resolve_source_relative_path_prefers_execution_cwd_over_source_dir() {
1136 let cwd = std::env::temp_dir().join(format!("harn-process-cwd-{}", uuid::Uuid::now_v7()));
1137 let source_dir =
1138 std::env::temp_dir().join(format!("harn-process-source-{}", uuid::Uuid::now_v7()));
1139 std::fs::create_dir_all(&cwd).unwrap();
1140 std::fs::create_dir_all(&source_dir).unwrap();
1141 set_thread_source_dir(&source_dir);
1142 set_thread_execution_context(Some(crate::orchestration::RunExecutionRecord {
1143 cwd: Some(cwd.to_string_lossy().into_owned()),
1144 source_dir: Some(source_dir.to_string_lossy().into_owned()),
1145 env: BTreeMap::new(),
1146 adapter: None,
1147 repo_path: None,
1148 worktree_path: None,
1149 branch: None,
1150 base_ref: None,
1151 cleanup: None,
1152 }));
1153 let resolved = resolve_source_relative_path("templates/prompt.txt");
1154 assert_eq!(resolved, cwd.join("templates/prompt.txt"));
1155 reset_process_state();
1156 let _ = std::fs::remove_dir_all(&cwd);
1157 let _ = std::fs::remove_dir_all(&source_dir);
1158 }
1159
1160 #[test]
1161 fn resolve_source_asset_path_prefers_execution_source_dir_over_cwd() {
1162 let cwd = std::env::temp_dir().join(format!("harn-asset-cwd-{}", uuid::Uuid::now_v7()));
1163 let source_dir =
1164 std::env::temp_dir().join(format!("harn-asset-source-{}", uuid::Uuid::now_v7()));
1165 std::fs::create_dir_all(&cwd).unwrap();
1166 std::fs::create_dir_all(&source_dir).unwrap();
1167 set_thread_source_dir(&source_dir);
1168 set_thread_execution_context(Some(crate::orchestration::RunExecutionRecord {
1169 cwd: Some(cwd.to_string_lossy().into_owned()),
1170 source_dir: Some(source_dir.to_string_lossy().into_owned()),
1171 env: BTreeMap::new(),
1172 adapter: None,
1173 repo_path: None,
1174 worktree_path: None,
1175 branch: None,
1176 base_ref: None,
1177 cleanup: None,
1178 }));
1179 let resolved = resolve_source_asset_path("templates/prompt.txt");
1180 assert_eq!(resolved, source_dir.join("templates/prompt.txt"));
1181 reset_process_state();
1182 let _ = std::fs::remove_dir_all(&cwd);
1183 let _ = std::fs::remove_dir_all(&source_dir);
1184 }
1185
1186 #[test]
1187 fn set_thread_source_dir_absolutizes_relative_paths() {
1188 reset_process_state();
1189 let current_dir = std::env::current_dir().unwrap();
1190 set_thread_source_dir(std::path::Path::new("scripts"));
1191 assert_eq!(source_root_path(), current_dir.join("scripts"));
1192 reset_process_state();
1193 }
1194
1195 #[test]
1196 fn exec_context_sets_default_cwd_and_env() {
1197 let dir = std::env::temp_dir().join(format!("harn-process-ctx-{}", uuid::Uuid::now_v7()));
1198 std::fs::create_dir_all(&dir).unwrap();
1199 std::fs::write(dir.join("marker.txt"), "ok").unwrap();
1200 set_thread_execution_context(Some(RunExecutionRecord {
1201 cwd: Some(dir.to_string_lossy().into_owned()),
1202 env: BTreeMap::from([("HARN_PROCESS_TEST".to_string(), "present".to_string())]),
1203 ..Default::default()
1204 }));
1205 let output = exec_shell(
1206 None,
1207 "sh",
1208 "-c",
1209 "printf '%s:' \"$HARN_PROCESS_TEST\" && test -f marker.txt",
1210 )
1211 .unwrap();
1212 assert!(output.status.success());
1213 assert_eq!(String::from_utf8_lossy(&output.stdout), "present:");
1214 reset_process_state();
1215 let _ = std::fs::remove_dir_all(&dir);
1216 }
1217
1218 #[test]
1219 fn exec_at_resolves_relative_to_execution_cwd() {
1220 let dir = std::env::temp_dir().join(format!("harn-process-rel-{}", uuid::Uuid::now_v7()));
1221 std::fs::create_dir_all(dir.join("nested")).unwrap();
1222 std::fs::write(dir.join("nested").join("marker.txt"), "ok").unwrap();
1223 set_thread_execution_context(Some(RunExecutionRecord {
1224 cwd: Some(dir.to_string_lossy().into_owned()),
1225 ..Default::default()
1226 }));
1227 let output = exec_shell(Some("nested"), "sh", "-c", "test -f marker.txt").unwrap();
1228 assert!(output.status.success());
1229 reset_process_state();
1230 let _ = std::fs::remove_dir_all(&dir);
1231 }
1232
1233 #[test]
1234 fn runtime_paths_uses_configurable_state_roots() {
1235 let _runtime_paths_env_lock = crate::runtime_paths::test_env_lock()
1236 .lock()
1237 .unwrap_or_else(|poisoned| poisoned.into_inner());
1238 let _env_guard = RuntimePathsEnvGuard::capture();
1239 let base =
1240 std::env::temp_dir().join(format!("harn-process-runtime-{}", uuid::Uuid::now_v7()));
1241 std::fs::create_dir_all(&base).unwrap();
1242 std::env::set_var(crate::runtime_paths::HARN_STATE_DIR_ENV, ".custom-harn");
1243 std::env::set_var(crate::runtime_paths::HARN_RUN_DIR_ENV, ".custom-runs");
1244 std::env::set_var(
1245 crate::runtime_paths::HARN_WORKTREE_DIR_ENV,
1246 ".custom-worktrees",
1247 );
1248 set_thread_execution_context(Some(RunExecutionRecord {
1249 cwd: Some(base.to_string_lossy().into_owned()),
1250 ..Default::default()
1251 }));
1252
1253 let mut vm = crate::vm::Vm::new();
1254 register_process_builtins(&mut vm);
1255 let mut out = String::new();
1256 let builtin = vm
1257 .builtins
1258 .get("runtime_paths")
1259 .expect("runtime_paths builtin");
1260 let paths = match builtin(&[], &mut out).unwrap() {
1261 VmValue::Dict(map) => map,
1262 other => panic!("expected dict, got {other:?}"),
1263 };
1264 assert_eq!(
1265 paths.get("state_root").unwrap().display(),
1266 base.join(".custom-harn").display().to_string()
1267 );
1268 assert_eq!(
1269 paths.get("run_root").unwrap().display(),
1270 base.join(".custom-runs").display().to_string()
1271 );
1272 assert_eq!(
1273 paths.get("worktree_root").unwrap().display(),
1274 base.join(".custom-worktrees").display().to_string()
1275 );
1276
1277 reset_process_state();
1278 let _ = std::fs::remove_dir_all(&base);
1279 }
1280
1281 #[cfg(unix)]
1282 fn exec_opts_list(items: &[&str]) -> VmValue {
1283 VmValue::List(std::sync::Arc::new(
1284 items
1285 .iter()
1286 .map(|s| VmValue::String(arcstr::ArcStr::from(*s)))
1287 .collect(),
1288 ))
1289 }
1290
1291 #[cfg(unix)]
1292 fn exec_opts_dict(pairs: &[(&str, VmValue)]) -> VmValue {
1293 VmValue::dict(
1294 pairs
1295 .iter()
1296 .map(|(k, v)| (crate::value::intern_key(k), v.clone()))
1297 .collect::<crate::value::DictMap>(),
1298 )
1299 }
1300
1301 #[cfg(unix)]
1302 #[test]
1303 fn exec_opts_merges_env_with_parent_by_default() {
1304 std::env::set_var("HARN_EXEC_OPTS_PARENT", "from-parent");
1305 let env = exec_opts_dict(&[("CHILD", VmValue::String(arcstr::ArcStr::from("from-child")))]);
1306 let args = vec![
1307 exec_opts_list(&[
1308 "/bin/sh",
1309 "-c",
1310 "printf '%s|%s' \"$HARN_EXEC_OPTS_PARENT\" \"$CHILD\"",
1311 ]),
1312 exec_opts_dict(&[("env", env)]),
1313 ];
1314 let mut out = String::new();
1315 let result = exec_opts_impl(&args, &mut out).expect("exec_opts result");
1316 let dict = result.as_dict().expect("dict");
1317 assert_eq!(
1318 dict.get("stdout").unwrap().display(),
1319 "from-parent|from-child"
1320 );
1321 assert!(matches!(dict.get("success"), Some(VmValue::Bool(true))));
1322 std::env::remove_var("HARN_EXEC_OPTS_PARENT");
1323 }
1324
1325 #[cfg(unix)]
1326 #[test]
1327 fn exec_opts_replace_env_clears_parent() {
1328 std::env::set_var("HARN_EXEC_OPTS_PARENT2", "from-parent");
1329 let env = exec_opts_dict(&[("CHILD", VmValue::String(arcstr::ArcStr::from("from-child")))]);
1330 let args = vec![
1331 exec_opts_list(&[
1332 "/bin/sh",
1333 "-c",
1334 "printf '%s|%s' \"$HARN_EXEC_OPTS_PARENT2\" \"$CHILD\"",
1335 ]),
1336 exec_opts_dict(&[
1337 ("env", env),
1338 ("env_mode", VmValue::String(arcstr::ArcStr::from("replace"))),
1339 ]),
1340 ];
1341 let mut out = String::new();
1342 let result = exec_opts_impl(&args, &mut out).expect("exec_opts result");
1343 let dict = result.as_dict().expect("dict");
1344 assert_eq!(dict.get("stdout").unwrap().display(), "|from-child");
1345 std::env::remove_var("HARN_EXEC_OPTS_PARENT2");
1346 }
1347
1348 #[cfg(unix)]
1349 #[test]
1350 fn exec_at_opts_honors_directory() {
1351 let dir = std::env::temp_dir().join(format!("harn-exec-opts-cwd-{}", uuid::Uuid::now_v7()));
1352 std::fs::create_dir_all(&dir).unwrap();
1353 let args = vec![
1354 VmValue::String(arcstr::ArcStr::from(dir.to_string_lossy().into_owned())),
1355 exec_opts_list(&["/bin/sh", "-c", "pwd -P"]),
1356 ];
1357 let mut out = String::new();
1358 let result = exec_at_opts_impl(&args, &mut out).expect("exec_at_opts result");
1359 let dict = result.as_dict().expect("dict");
1360 let want = std::fs::canonicalize(&dir).unwrap();
1362 let got = dict.get("stdout").unwrap().display();
1363 assert_eq!(got.trim(), want.to_string_lossy());
1364 let _ = std::fs::remove_dir_all(&dir);
1365 }
1366
1367 #[cfg(unix)]
1368 #[test]
1369 fn exec_opts_enforces_timeout() {
1370 let args = vec![
1371 exec_opts_list(&["/bin/sh", "-c", "sleep 5"]),
1372 exec_opts_dict(&[("timeout", VmValue::Int(50))]),
1373 ];
1374 let mut out = String::new();
1375 let result = exec_opts_impl(&args, &mut out).expect("exec_opts result");
1376 let dict = result.as_dict().expect("dict");
1377 assert!(
1378 matches!(dict.get("timed_out"), Some(VmValue::Bool(true))),
1379 "command exceeding timeout must report timed_out"
1380 );
1381 assert!(matches!(dict.get("success"), Some(VmValue::Bool(false))));
1382 }
1383
1384 #[cfg(unix)]
1385 #[test]
1386 fn exec_opts_rejects_empty_command() {
1387 let args = vec![exec_opts_list(&[])];
1388 let mut out = String::new();
1389 assert!(exec_opts_impl(&args, &mut out).is_err());
1390 let bad = vec![VmValue::String(arcstr::ArcStr::from("not-a-list"))];
1391 assert!(exec_opts_impl(&bad, &mut out).is_err());
1392 }
1393
1394 #[cfg(unix)]
1395 #[test]
1396 fn exec_opts_interrupt_kills_child_process_group() {
1397 use std::sync::atomic::AtomicBool;
1398 use std::sync::Arc;
1399
1400 let cancel = Arc::new(AtomicBool::new(false));
1404 let _guard = crate::op_interrupt::install(Some(Arc::clone(&cancel)), None);
1405 let flipper = {
1406 let cancel = Arc::clone(&cancel);
1407 std::thread::spawn(move || {
1408 std::thread::sleep(Duration::from_millis(200));
1409 cancel.store(true, std::sync::atomic::Ordering::SeqCst);
1410 })
1411 };
1412
1413 let started = Instant::now();
1414 let args = vec![exec_opts_list(&[
1415 "/bin/sh",
1416 "-c",
1417 "echo started; sleep 30 & wait",
1419 ])];
1420 let mut out = String::new();
1421 let result = exec_opts_impl(&args, &mut out).expect("exec_opts result");
1422 flipper.join().unwrap();
1423
1424 assert!(
1425 started.elapsed() < Duration::from_secs(10),
1426 "interrupt must preempt the 30s child, took {:?}",
1427 started.elapsed()
1428 );
1429 let dict = result.as_dict().expect("dict");
1430 assert!(matches!(dict.get("success"), Some(VmValue::Bool(false))));
1431 assert!(matches!(dict.get("timed_out"), Some(VmValue::Bool(false))));
1432 }
1433}