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