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 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(arcstr::ArcStr::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(arcstr::ArcStr::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(arcstr::ArcStr::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(arcstr::ArcStr::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(arcstr::ArcStr::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(arcstr::ArcStr::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(arcstr::ArcStr::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(arcstr::ArcStr::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(arcstr::ArcStr::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(arcstr::ArcStr::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(arcstr::ArcStr::from(
301 std::env::consts::ARCH,
302 )))
303}
304
305#[harn_builtin(sig = "home_dir() -> string", category = "process")]
306fn home_dir_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
307 let home = crate::user_dirs::home_dir()
308 .map(|home| home.to_string_lossy().into_owned())
309 .unwrap_or_default();
310 Ok(VmValue::String(arcstr::ArcStr::from(home)))
311}
312
313#[harn_builtin(sig = "pid(...args: any) -> int", category = "process")]
314fn pid_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
315 Ok(VmValue::Int(std::process::id() as i64))
316}
317
318#[harn_builtin(sig = "date_iso() -> string", category = "process")]
319fn date_iso_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
320 let now = crate::clock_mock::leak_audit::wall_now("stdlib/date_iso");
327 let dt: chrono::DateTime<chrono::Utc> = now.into();
328 Ok(VmValue::String(arcstr::ArcStr::from(
329 dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
330 )))
331}
332
333#[harn_builtin(sig = "cwd() -> string", category = "process")]
334fn cwd_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
335 let dir = current_execution_context()
336 .and_then(|context| context.cwd)
337 .or_else(|| {
338 std::env::current_dir()
339 .ok()
340 .map(|p| p.to_string_lossy().into_owned())
341 })
342 .unwrap_or_default();
343 Ok(VmValue::String(arcstr::ArcStr::from(dir)))
344}
345
346#[harn_builtin(sig = "execution_root() -> string", category = "process")]
347fn execution_root_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
348 Ok(VmValue::String(arcstr::ArcStr::from(
349 execution_root_path().to_string_lossy().into_owned(),
350 )))
351}
352
353#[harn_builtin(sig = "asset_root() -> string", category = "process")]
354fn asset_root_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
355 Ok(VmValue::String(arcstr::ArcStr::from(
356 asset_root_path().to_string_lossy().into_owned(),
357 )))
358}
359
360#[harn_builtin(sig = "runtime_paths() -> dict", category = "process")]
361fn runtime_paths_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
362 let runtime_base = runtime_root_base();
363 let mut paths = BTreeMap::new();
364 paths.put_str("execution_root", execution_root_path().to_string_lossy());
365 paths.put_str("asset_root", asset_root_path().to_string_lossy());
366 paths.put_str(
367 "state_root",
368 crate::runtime_paths::state_root(&runtime_base).to_string_lossy(),
369 );
370 paths.put_str(
371 "run_root",
372 crate::runtime_paths::run_root(&runtime_base).to_string_lossy(),
373 );
374 paths.put_str(
375 "worktree_root",
376 crate::runtime_paths::worktree_root(&runtime_base).to_string_lossy(),
377 );
378 Ok(VmValue::dict(paths))
379}
380
381#[harn_builtin(sig = "spawn_captured(opts: dict) -> dict", category = "process")]
382fn spawn_captured_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
383 spawn_captured_value(args)
384}
385
386#[harn_builtin(sig = "term_width() -> int", category = "process")]
396fn term_width_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
397 Ok(VmValue::Int(crate::term::width() as i64))
398}
399
400#[harn_builtin(sig = "term_height() -> int", category = "process")]
401fn term_height_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
402 Ok(VmValue::Int(crate::term::height() as i64))
403}
404
405const PROCESS_BUILTINS: &[&VmBuiltinDef] = &[
406 &ENV_IMPL_DEF,
407 &ENV_OR_IMPL_DEF,
408 &EXIT_IMPL_DEF,
409 &EXEC_IMPL_DEF,
410 &EXEC_OPTS_IMPL_DEF,
411 &SHELL_IMPL_DEF,
412 &EXEC_AT_IMPL_DEF,
413 &EXEC_AT_OPTS_IMPL_DEF,
414 &SHELL_AT_IMPL_DEF,
415 &USERNAME_IMPL_DEF,
416 &HOSTNAME_IMPL_DEF,
417 &PLATFORM_IMPL_DEF,
418 &ARCH_IMPL_DEF,
419 &HOME_DIR_IMPL_DEF,
420 &PID_IMPL_DEF,
421 &DATE_ISO_IMPL_DEF,
422 &CWD_IMPL_DEF,
423 &EXECUTION_ROOT_IMPL_DEF,
424 &ASSET_ROOT_IMPL_DEF,
425 &RUNTIME_PATHS_IMPL_DEF,
426 &SPAWN_CAPTURED_IMPL_DEF,
427 &TERM_WIDTH_IMPL_DEF,
428 &TERM_HEIGHT_IMPL_DEF,
429];
430
431pub(crate) fn spawn_captured_value(args: &[VmValue]) -> Result<VmValue, VmError> {
436 let opts = match args.first() {
437 Some(VmValue::Dict(opts)) => opts.clone(),
438 _ => {
439 return Err(VmError::Runtime(
440 "spawn_captured: options dict is required".to_string(),
441 ));
442 }
443 };
444 let cmd = match opts.get("cmd").map(|v| v.display()).unwrap_or_default() {
445 s if s.is_empty() => {
446 return Err(VmError::Runtime(
447 "spawn_captured: opts.cmd is required".to_string(),
448 ));
449 }
450 s => s,
451 };
452 let cmd_args: Vec<String> = match opts.get("args") {
453 Some(VmValue::List(items)) => items.iter().map(|v| v.display()).collect(),
454 None | Some(VmValue::Nil) => Vec::new(),
455 Some(other) => {
456 return Err(VmError::Runtime(format!(
457 "spawn_captured: opts.args must be a list of strings, got {}",
458 other.type_name()
459 )));
460 }
461 };
462 let cwd = opts
463 .get("cwd")
464 .map(|v| v.display())
465 .filter(|s| !s.is_empty());
466 let env_overrides: Vec<(String, String)> = match opts.get("env") {
467 Some(VmValue::Dict(env)) => env
468 .iter()
469 .map(|(k, v)| (k.to_string(), v.display()))
470 .collect(),
471 None | Some(VmValue::Nil) => Vec::new(),
472 Some(other) => {
473 return Err(VmError::Runtime(format!(
474 "spawn_captured: opts.env must be a dict, got {}",
475 other.type_name()
476 )));
477 }
478 };
479 let stdin_bytes: Option<Vec<u8>> = match opts.get("stdin") {
480 Some(VmValue::Bytes(bytes)) => Some(bytes.as_slice().to_vec()),
481 Some(VmValue::String(s)) => Some(s.as_bytes().to_vec()),
482 None | Some(VmValue::Nil) => None,
483 Some(other) => {
484 return Err(VmError::Runtime(format!(
485 "spawn_captured: opts.stdin must be string or bytes, got {}",
486 other.type_name()
487 )));
488 }
489 };
490 let timeout = opts
491 .get("timeout_ms")
492 .and_then(|v| v.as_int())
493 .filter(|n| *n > 0)
494 .map(|n| Duration::from_millis(n as u64));
495
496 let spawn = CapturedSpawn {
497 label: "spawn_captured",
498 cmd: &cmd,
499 args: &cmd_args,
500 cwd: cwd.as_deref(),
501 env: &env_overrides,
502 env_clear: false,
505 stdin: stdin_bytes,
506 timeout,
507 };
508 let CapturedRun {
509 output,
510 timed_out,
511 duration_ms,
512 } = run_captured_spawn(spawn)?;
513
514 let exit_code = if timed_out {
515 -1
516 } else {
517 output.status.code().unwrap_or(-1) as i64
518 };
519 let success = if timed_out {
520 false
521 } else {
522 output.status.success()
523 };
524 let mut result = BTreeMap::new();
525 result.insert("exit_code".to_string(), VmValue::Int(exit_code));
526 result.put_str("stdout", String::from_utf8_lossy(&output.stdout).as_ref());
527 result.put_str("stderr", String::from_utf8_lossy(&output.stderr).as_ref());
528 result.insert("duration_ms".to_string(), VmValue::Int(duration_ms));
529 result.insert("success".to_string(), VmValue::Bool(success));
530 result.insert("timed_out".to_string(), VmValue::Bool(timed_out));
531 Ok(VmValue::dict(result))
532}
533
534struct CapturedSpawn<'a> {
539 label: &'static str,
540 cmd: &'a str,
541 args: &'a [String],
542 cwd: Option<&'a str>,
543 env: &'a [(String, String)],
544 env_clear: bool,
545 stdin: Option<Vec<u8>>,
546 timeout: Option<Duration>,
547}
548
549struct CapturedRun {
551 output: std::process::Output,
552 timed_out: bool,
553 duration_ms: i64,
554}
555
556fn run_captured_spawn(spec: CapturedSpawn<'_>) -> Result<CapturedRun, VmError> {
561 let label = spec.label;
562 let mut command = std::process::Command::new(spec.cmd);
563 command.args(spec.args);
564 if let Some(cwd) = spec.cwd {
565 command.current_dir(cwd);
566 }
567 if spec.env_clear {
568 command.env_clear();
569 }
570 for (key, value) in spec.env {
571 command.env(key, value);
572 }
573 command.stdout(Stdio::piped()).stderr(Stdio::piped());
574 if spec.stdin.is_some() {
575 command.stdin(Stdio::piped());
576 } else {
577 command.stdin(Stdio::null());
578 }
579
580 let started = Instant::now();
581 let cmd = spec.cmd;
582 let mut child = command.spawn().map_err(|error| {
583 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
584 "{label}: failed to spawn '{cmd}': {error}"
585 ))))
586 })?;
587
588 if let (Some(payload), Some(mut stdin)) = (spec.stdin, child.stdin.take()) {
589 let _ = stdin.write_all(&payload);
591 }
592
593 let (output, timed_out) = match spec.timeout {
594 None => match child.wait_with_output() {
595 Ok(output) => (output, false),
596 Err(error) => {
597 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
598 format!("{label}: wait failed: {error}"),
599 ))));
600 }
601 },
602 Some(limit) => {
603 let deadline = started + limit;
604 let mut timed_out = false;
605 loop {
606 match child.try_wait() {
607 Ok(Some(_)) => break,
608 Ok(None) => {
609 if Instant::now() >= deadline {
610 let _ = child.kill();
611 let _ = child.wait();
612 timed_out = true;
613 break;
614 }
615 std::thread::sleep(Duration::from_millis(10));
616 }
617 Err(error) => {
618 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
619 format!("{label}: poll failed: {error}"),
620 ))));
621 }
622 }
623 }
624 if timed_out {
625 let stdout_handle = child.stdout.take();
626 let stderr_handle = child.stderr.take();
627 let (tx_out, rx_out) = mpsc::channel::<Vec<u8>>();
628 let (tx_err, rx_err) = mpsc::channel::<Vec<u8>>();
629 if let Some(mut s) = stdout_handle {
630 std::thread::spawn(move || {
631 use std::io::Read as _;
632 let mut buf = Vec::new();
633 let _ = s.read_to_end(&mut buf);
634 let _ = tx_out.send(buf);
635 });
636 }
637 if let Some(mut s) = stderr_handle {
638 std::thread::spawn(move || {
639 use std::io::Read as _;
640 let mut buf = Vec::new();
641 let _ = s.read_to_end(&mut buf);
642 let _ = tx_err.send(buf);
643 });
644 }
645 let stdout = rx_out
646 .recv_timeout(Duration::from_millis(100))
647 .unwrap_or_default();
648 let stderr = rx_err
649 .recv_timeout(Duration::from_millis(100))
650 .unwrap_or_default();
651 (
652 std::process::Output {
653 status: std::process::ExitStatus::default(),
654 stdout,
655 stderr,
656 },
657 true,
658 )
659 } else {
660 match child.wait_with_output() {
661 Ok(output) => (output, false),
662 Err(error) => {
663 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
664 format!("{label}: wait failed: {error}"),
665 ))));
666 }
667 }
668 }
669 }
670 };
671
672 Ok(CapturedRun {
673 output,
674 timed_out,
675 duration_ms: started.elapsed().as_millis() as i64,
676 })
677}
678
679#[derive(Default)]
682struct ExecOptions {
683 env: Vec<(String, String)>,
684 env_clear: bool,
685 cwd: Option<String>,
686 timeout: Option<Duration>,
687}
688
689fn exec_options(label: &str, options: Option<&VmValue>) -> Result<ExecOptions, VmError> {
696 let opts = match options {
697 None | Some(VmValue::Nil) => return Ok(ExecOptions::default()),
698 Some(VmValue::Dict(opts)) => opts.clone(),
699 Some(other) => {
700 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
701 format!("{label}: options must be a dict, got {}", other.type_name()),
702 ))));
703 }
704 };
705 let env: Vec<(String, String)> = match opts.get("env") {
706 Some(VmValue::Dict(env)) => env
707 .iter()
708 .map(|(k, v)| (k.to_string(), v.display()))
709 .collect(),
710 None | Some(VmValue::Nil) => Vec::new(),
711 Some(other) => {
712 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
713 format!(
714 "{label}: options.env must be a dict, got {}",
715 other.type_name()
716 ),
717 ))));
718 }
719 };
720 let env_clear = match opts.get("env_mode").map(|v| v.display()).as_deref() {
721 None | Some("merge") => false,
722 Some("replace") => true,
723 Some(other) => {
724 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
725 format!(
726 "{label}: options.env_mode must be \"merge\" or \"replace\", got {other:?}"
727 ),
728 ))));
729 }
730 };
731 let cwd = opts
732 .get("cwd")
733 .map(|v| v.display())
734 .filter(|s| !s.is_empty());
735 let timeout = opts
738 .get("timeout")
739 .or_else(|| opts.get("timeout_ms"))
740 .and_then(|v| v.as_int())
741 .filter(|n| *n > 0)
742 .map(|n| Duration::from_millis(n as u64));
743 Ok(ExecOptions {
744 env,
745 env_clear,
746 cwd,
747 timeout,
748 })
749}
750
751fn captured_run_to_value(run: &CapturedRun) -> VmValue {
755 let status = if run.timed_out {
756 -1
757 } else {
758 run.output.status.code().unwrap_or(-1) as i64
759 };
760 let success = !run.timed_out && run.output.status.success();
761 let mut result = BTreeMap::new();
762 result.put_str(
763 "stdout",
764 String::from_utf8_lossy(&run.output.stdout).as_ref(),
765 );
766 result.put_str(
767 "stderr",
768 String::from_utf8_lossy(&run.output.stderr).as_ref(),
769 );
770 result.insert("status".to_string(), VmValue::Int(status));
771 result.insert("success".to_string(), VmValue::Bool(success));
772 result.insert("timed_out".to_string(), VmValue::Bool(run.timed_out));
773 result.insert("duration_ms".to_string(), VmValue::Int(run.duration_ms));
774 VmValue::dict(result)
775}
776
777#[harn_builtin(
778 sig = "exec_opts(command: list, options: dict?) -> dict",
779 category = "process"
780)]
781fn exec_opts_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
782 let command = exec_opts_command("exec_opts", args.first())?;
783 let opts = exec_options("exec_opts", args.get(1))?;
784 let run = run_captured_spawn(CapturedSpawn {
785 label: "exec_opts",
786 cmd: &command[0],
787 args: &command[1..],
788 cwd: opts.cwd.as_deref(),
789 env: &opts.env,
790 env_clear: opts.env_clear,
791 stdin: None,
792 timeout: opts.timeout,
793 })?;
794 Ok(captured_run_to_value(&run))
795}
796
797#[harn_builtin(
798 sig = "exec_at_opts(dir: string, command: list, options: dict?) -> dict",
799 category = "process"
800)]
801fn exec_at_opts_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
802 let dir = match args.first() {
803 Some(value) if !value.display().is_empty() => value.display(),
804 _ => {
805 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
806 "exec_at_opts: directory is required",
807 ))));
808 }
809 };
810 let command = exec_opts_command("exec_at_opts", args.get(1))?;
811 let opts = exec_options("exec_at_opts", args.get(2))?;
812 let resolved_cwd = opts.cwd.unwrap_or(dir);
815 let run = run_captured_spawn(CapturedSpawn {
816 label: "exec_at_opts",
817 cmd: &command[0],
818 args: &command[1..],
819 cwd: Some(resolved_cwd.as_str()),
820 env: &opts.env,
821 env_clear: opts.env_clear,
822 stdin: None,
823 timeout: opts.timeout,
824 })?;
825 Ok(captured_run_to_value(&run))
826}
827
828fn exec_opts_command(label: &str, value: Option<&VmValue>) -> Result<Vec<String>, VmError> {
831 let items = match value {
832 Some(VmValue::List(items)) => items,
833 _ => {
834 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
835 format!("{label}: command must be a non-empty list of strings"),
836 ))));
837 }
838 };
839 let command: Vec<String> = items.iter().map(|v| v.display()).collect();
840 if command.is_empty() || command[0].is_empty() {
841 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
842 format!("{label}: command must be a non-empty list of strings"),
843 ))));
844 }
845 Ok(command)
846}
847
848pub fn find_project_root(base: &std::path::Path) -> Option<std::path::PathBuf> {
850 let mut dir = base.to_path_buf();
851 loop {
852 if dir.join("harn.toml").exists() {
853 return Some(dir);
854 }
855 if !dir.pop() {
856 return None;
857 }
858 }
859}
860
861pub(crate) fn register_path_builtins(vm: &mut Vm) {
863 for def in PATH_BUILTINS {
864 vm.register_builtin_def(def);
865 }
866}
867
868#[harn_builtin(sig = "source_dir(...args: any) -> string", category = "process")]
869fn source_dir_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
870 let dir = VM_SOURCE_DIR.with(|sd| sd.borrow().clone());
871 match dir {
872 Some(d) => Ok(VmValue::String(arcstr::ArcStr::from(
873 d.to_string_lossy().into_owned(),
874 ))),
875 None => {
876 let cwd = std::env::current_dir()
877 .map(|p| p.to_string_lossy().into_owned())
878 .unwrap_or_default();
879 Ok(VmValue::String(arcstr::ArcStr::from(cwd)))
880 }
881 }
882}
883
884#[harn_builtin(sig = "project_root() -> string?", category = "process")]
885fn project_root_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
886 let base = current_execution_context()
887 .and_then(|context| context.cwd.map(PathBuf::from))
888 .or_else(|| VM_SOURCE_DIR.with(|sd| sd.borrow().clone()))
889 .or_else(|| std::env::current_dir().ok())
890 .unwrap_or_else(|| PathBuf::from("."));
891 match find_project_root(&base) {
892 Some(root) => Ok(VmValue::String(arcstr::ArcStr::from(
893 root.to_string_lossy().into_owned(),
894 ))),
895 None => Ok(VmValue::Nil),
896 }
897}
898
899const PATH_BUILTINS: &[&VmBuiltinDef] = &[&SOURCE_DIR_IMPL_DEF, &PROJECT_ROOT_IMPL_DEF];
900
901fn vm_output_to_value(output: std::process::Output) -> VmValue {
902 let mut result = BTreeMap::new();
903 result.put_str("stdout", String::from_utf8_lossy(&output.stdout).as_ref());
904 result.put_str("stderr", String::from_utf8_lossy(&output.stderr).as_ref());
905 result.insert(
906 "status".to_string(),
907 VmValue::Int(output.status.code().unwrap_or(-1) as i64),
908 );
909 result.insert(
910 "success".to_string(),
911 VmValue::Bool(output.status.success()),
912 );
913 VmValue::dict(result)
914}
915
916fn exec_command(
917 dir: Option<&str>,
918 cmd: &str,
919 args: &[String],
920) -> Result<std::process::Output, VmError> {
921 let config = process_command_config(dir)?;
922 crate::stdlib::sandbox::command_output(cmd, args, &config)
923 .map_err(|error| prefix_process_error(error, "exec"))
924}
925
926#[cfg(test)]
927fn exec_shell(
928 dir: Option<&str>,
929 shell: &str,
930 flag: &str,
931 script: &str,
932) -> Result<std::process::Output, VmError> {
933 let args = vec![flag.to_string(), script.to_string()];
934 exec_shell_args(dir, shell, &args)
935}
936
937fn exec_shell_args(
938 dir: Option<&str>,
939 shell: &str,
940 args: &[String],
941) -> Result<std::process::Output, VmError> {
942 let config = process_command_config(dir)?;
943 crate::stdlib::sandbox::command_output(shell, args, &config)
944 .map_err(|error| prefix_process_error(error, "shell"))
945}
946
947fn process_command_config(
948 dir: Option<&str>,
949) -> Result<crate::stdlib::sandbox::ProcessCommandConfig, VmError> {
950 let mut config = crate::stdlib::sandbox::ProcessCommandConfig {
951 stdin_null: true,
952 ..Default::default()
953 };
954 if let Some(dir) = dir {
955 let resolved = resolve_command_dir(dir);
956 crate::stdlib::sandbox::enforce_process_cwd(&resolved)?;
957 config.cwd = Some(resolved);
958 } else if let Some(context) = current_execution_context() {
959 if let Some(cwd) = context.cwd.filter(|cwd| !cwd.is_empty()) {
960 crate::stdlib::sandbox::enforce_process_cwd(std::path::Path::new(&cwd))?;
961 config.cwd = Some(std::path::PathBuf::from(cwd));
962 }
963 if !context.env.is_empty() {
964 config.env.extend(context.env);
965 }
966 }
967 if let Some(value) = env_override(HARN_REPLAY_ENV) {
968 config.env.push((HARN_REPLAY_ENV.to_string(), value));
969 }
970 Ok(config)
971}
972
973fn prefix_process_error(error: VmError, prefix: &str) -> VmError {
974 match error {
975 VmError::Thrown(VmValue::String(message)) => VmError::Thrown(VmValue::String(
976 arcstr::ArcStr::from(format!("{prefix} failed: {message}")),
977 )),
978 other => other,
979 }
980}
981
982fn resolve_command_dir(dir: &str) -> PathBuf {
983 let candidate = PathBuf::from(dir);
984 if candidate.is_absolute() {
985 return candidate;
986 }
987 if let Some(cwd) = current_execution_context().and_then(|context| context.cwd) {
988 return PathBuf::from(cwd).join(candidate);
989 }
990 if let Some(source_dir) = VM_SOURCE_DIR.with(|sd| sd.borrow().clone()) {
991 return source_dir.join(candidate);
992 }
993 candidate
994}
995
996#[cfg(test)]
997mod tests {
998 use super::*;
999
1000 struct RuntimePathsEnvGuard {
1001 state: Option<String>,
1002 run: Option<String>,
1003 worktree: Option<String>,
1004 }
1005
1006 impl RuntimePathsEnvGuard {
1007 fn capture() -> Self {
1008 Self {
1009 state: std::env::var(crate::runtime_paths::HARN_STATE_DIR_ENV).ok(),
1010 run: std::env::var(crate::runtime_paths::HARN_RUN_DIR_ENV).ok(),
1011 worktree: std::env::var(crate::runtime_paths::HARN_WORKTREE_DIR_ENV).ok(),
1012 }
1013 }
1014 }
1015
1016 impl Drop for RuntimePathsEnvGuard {
1017 fn drop(&mut self) {
1018 match self.state.as_deref() {
1019 Some(value) => std::env::set_var(crate::runtime_paths::HARN_STATE_DIR_ENV, value),
1020 None => std::env::remove_var(crate::runtime_paths::HARN_STATE_DIR_ENV),
1021 }
1022 match self.run.as_deref() {
1023 Some(value) => std::env::set_var(crate::runtime_paths::HARN_RUN_DIR_ENV, value),
1024 None => std::env::remove_var(crate::runtime_paths::HARN_RUN_DIR_ENV),
1025 }
1026 match self.worktree.as_deref() {
1027 Some(value) => {
1028 std::env::set_var(crate::runtime_paths::HARN_WORKTREE_DIR_ENV, value);
1029 }
1030 None => std::env::remove_var(crate::runtime_paths::HARN_WORKTREE_DIR_ENV),
1031 }
1032 }
1033 }
1034
1035 #[test]
1036 fn lexically_collapse_resolves_sibling_walk() {
1037 let path = PathBuf::from("/tmp/project/tests/../fixtures/x.json");
1038 let collapsed = lexically_collapse(&path).expect("sibling walk");
1039 assert_eq!(collapsed, PathBuf::from("/tmp/project/fixtures/x.json"));
1040 }
1041
1042 #[test]
1043 fn lexically_collapse_blocks_escape_past_root() {
1044 let path = PathBuf::from("/app/../../etc/passwd");
1047 assert!(lexically_collapse(&path).is_none());
1048 }
1049
1050 #[test]
1051 fn lexically_collapse_strips_curdir() {
1052 let path = PathBuf::from("/app/./logs/today.txt");
1053 let collapsed = lexically_collapse(&path).expect("curdir is benign");
1054 assert_eq!(collapsed, PathBuf::from("/app/logs/today.txt"));
1055 }
1056
1057 #[test]
1058 fn resolve_source_relative_path_blocks_obvious_escape() {
1059 let dir =
1060 std::env::temp_dir().join(format!("harn-process-escape-{}", uuid::Uuid::now_v7()));
1061 std::fs::create_dir_all(&dir).unwrap();
1062 set_thread_source_dir(&dir);
1063 set_thread_execution_context(Some(crate::orchestration::RunExecutionRecord {
1064 cwd: Some(dir.to_string_lossy().into_owned()),
1065 source_dir: Some(dir.to_string_lossy().into_owned()),
1066 env: BTreeMap::new(),
1067 adapter: None,
1068 repo_path: None,
1069 worktree_path: None,
1070 branch: None,
1071 base_ref: None,
1072 cleanup: None,
1073 }));
1074 let resolved = resolve_source_relative_path("../../../../../../../../etc/passwd");
1078 assert!(
1079 resolved
1080 .to_string_lossy()
1081 .contains("__harn_rejected_parent_dir_traversal__"),
1082 "expected rejection sentinel, got {resolved:?}"
1083 );
1084 reset_process_state();
1085 let _ = std::fs::remove_dir_all(&dir);
1086 }
1087
1088 #[test]
1089 fn resolve_source_relative_path_ignores_thread_source_dir_without_execution_context() {
1090 let dir = std::env::temp_dir().join(format!("harn-process-{}", uuid::Uuid::now_v7()));
1091 std::fs::create_dir_all(&dir).unwrap();
1092 let current_dir = std::env::current_dir().unwrap();
1093 set_thread_source_dir(&dir);
1094 let resolved = resolve_source_relative_path("templates/prompt.txt");
1095 assert_eq!(resolved, current_dir.join("templates/prompt.txt"));
1096 reset_process_state();
1097 let _ = std::fs::remove_dir_all(&dir);
1098 }
1099
1100 #[test]
1101 fn resolve_source_relative_path_prefers_execution_cwd_over_source_dir() {
1102 let cwd = std::env::temp_dir().join(format!("harn-process-cwd-{}", uuid::Uuid::now_v7()));
1103 let source_dir =
1104 std::env::temp_dir().join(format!("harn-process-source-{}", uuid::Uuid::now_v7()));
1105 std::fs::create_dir_all(&cwd).unwrap();
1106 std::fs::create_dir_all(&source_dir).unwrap();
1107 set_thread_source_dir(&source_dir);
1108 set_thread_execution_context(Some(crate::orchestration::RunExecutionRecord {
1109 cwd: Some(cwd.to_string_lossy().into_owned()),
1110 source_dir: Some(source_dir.to_string_lossy().into_owned()),
1111 env: BTreeMap::new(),
1112 adapter: None,
1113 repo_path: None,
1114 worktree_path: None,
1115 branch: None,
1116 base_ref: None,
1117 cleanup: None,
1118 }));
1119 let resolved = resolve_source_relative_path("templates/prompt.txt");
1120 assert_eq!(resolved, cwd.join("templates/prompt.txt"));
1121 reset_process_state();
1122 let _ = std::fs::remove_dir_all(&cwd);
1123 let _ = std::fs::remove_dir_all(&source_dir);
1124 }
1125
1126 #[test]
1127 fn resolve_source_asset_path_prefers_execution_source_dir_over_cwd() {
1128 let cwd = std::env::temp_dir().join(format!("harn-asset-cwd-{}", uuid::Uuid::now_v7()));
1129 let source_dir =
1130 std::env::temp_dir().join(format!("harn-asset-source-{}", uuid::Uuid::now_v7()));
1131 std::fs::create_dir_all(&cwd).unwrap();
1132 std::fs::create_dir_all(&source_dir).unwrap();
1133 set_thread_source_dir(&source_dir);
1134 set_thread_execution_context(Some(crate::orchestration::RunExecutionRecord {
1135 cwd: Some(cwd.to_string_lossy().into_owned()),
1136 source_dir: Some(source_dir.to_string_lossy().into_owned()),
1137 env: BTreeMap::new(),
1138 adapter: None,
1139 repo_path: None,
1140 worktree_path: None,
1141 branch: None,
1142 base_ref: None,
1143 cleanup: None,
1144 }));
1145 let resolved = resolve_source_asset_path("templates/prompt.txt");
1146 assert_eq!(resolved, source_dir.join("templates/prompt.txt"));
1147 reset_process_state();
1148 let _ = std::fs::remove_dir_all(&cwd);
1149 let _ = std::fs::remove_dir_all(&source_dir);
1150 }
1151
1152 #[test]
1153 fn set_thread_source_dir_absolutizes_relative_paths() {
1154 reset_process_state();
1155 let current_dir = std::env::current_dir().unwrap();
1156 set_thread_source_dir(std::path::Path::new("scripts"));
1157 assert_eq!(source_root_path(), current_dir.join("scripts"));
1158 reset_process_state();
1159 }
1160
1161 #[test]
1162 fn exec_context_sets_default_cwd_and_env() {
1163 let dir = std::env::temp_dir().join(format!("harn-process-ctx-{}", uuid::Uuid::now_v7()));
1164 std::fs::create_dir_all(&dir).unwrap();
1165 std::fs::write(dir.join("marker.txt"), "ok").unwrap();
1166 set_thread_execution_context(Some(RunExecutionRecord {
1167 cwd: Some(dir.to_string_lossy().into_owned()),
1168 env: BTreeMap::from([("HARN_PROCESS_TEST".to_string(), "present".to_string())]),
1169 ..Default::default()
1170 }));
1171 let output = exec_shell(
1172 None,
1173 "sh",
1174 "-c",
1175 "printf '%s:' \"$HARN_PROCESS_TEST\" && test -f marker.txt",
1176 )
1177 .unwrap();
1178 assert!(output.status.success());
1179 assert_eq!(String::from_utf8_lossy(&output.stdout), "present:");
1180 reset_process_state();
1181 let _ = std::fs::remove_dir_all(&dir);
1182 }
1183
1184 #[test]
1185 fn exec_at_resolves_relative_to_execution_cwd() {
1186 let dir = std::env::temp_dir().join(format!("harn-process-rel-{}", uuid::Uuid::now_v7()));
1187 std::fs::create_dir_all(dir.join("nested")).unwrap();
1188 std::fs::write(dir.join("nested").join("marker.txt"), "ok").unwrap();
1189 set_thread_execution_context(Some(RunExecutionRecord {
1190 cwd: Some(dir.to_string_lossy().into_owned()),
1191 ..Default::default()
1192 }));
1193 let output = exec_shell(Some("nested"), "sh", "-c", "test -f marker.txt").unwrap();
1194 assert!(output.status.success());
1195 reset_process_state();
1196 let _ = std::fs::remove_dir_all(&dir);
1197 }
1198
1199 #[test]
1200 fn runtime_paths_uses_configurable_state_roots() {
1201 let _runtime_paths_env_lock = crate::runtime_paths::test_env_lock()
1202 .lock()
1203 .unwrap_or_else(|poisoned| poisoned.into_inner());
1204 let _env_guard = RuntimePathsEnvGuard::capture();
1205 let base =
1206 std::env::temp_dir().join(format!("harn-process-runtime-{}", uuid::Uuid::now_v7()));
1207 std::fs::create_dir_all(&base).unwrap();
1208 std::env::set_var(crate::runtime_paths::HARN_STATE_DIR_ENV, ".custom-harn");
1209 std::env::set_var(crate::runtime_paths::HARN_RUN_DIR_ENV, ".custom-runs");
1210 std::env::set_var(
1211 crate::runtime_paths::HARN_WORKTREE_DIR_ENV,
1212 ".custom-worktrees",
1213 );
1214 set_thread_execution_context(Some(RunExecutionRecord {
1215 cwd: Some(base.to_string_lossy().into_owned()),
1216 ..Default::default()
1217 }));
1218
1219 let mut vm = crate::vm::Vm::new();
1220 register_process_builtins(&mut vm);
1221 let mut out = String::new();
1222 let builtin = vm
1223 .builtins
1224 .get("runtime_paths")
1225 .expect("runtime_paths builtin");
1226 let paths = match builtin(&[], &mut out).unwrap() {
1227 VmValue::Dict(map) => map,
1228 other => panic!("expected dict, got {other:?}"),
1229 };
1230 assert_eq!(
1231 paths.get("state_root").unwrap().display(),
1232 base.join(".custom-harn").display().to_string()
1233 );
1234 assert_eq!(
1235 paths.get("run_root").unwrap().display(),
1236 base.join(".custom-runs").display().to_string()
1237 );
1238 assert_eq!(
1239 paths.get("worktree_root").unwrap().display(),
1240 base.join(".custom-worktrees").display().to_string()
1241 );
1242
1243 reset_process_state();
1244 let _ = std::fs::remove_dir_all(&base);
1245 }
1246
1247 #[cfg(unix)]
1248 fn exec_opts_list(items: &[&str]) -> VmValue {
1249 VmValue::List(std::sync::Arc::new(
1250 items
1251 .iter()
1252 .map(|s| VmValue::String(arcstr::ArcStr::from(*s)))
1253 .collect(),
1254 ))
1255 }
1256
1257 #[cfg(unix)]
1258 fn exec_opts_dict(pairs: &[(&str, VmValue)]) -> VmValue {
1259 VmValue::dict(
1260 pairs
1261 .iter()
1262 .map(|(k, v)| (crate::value::intern_key(k), v.clone()))
1263 .collect::<crate::value::DictMap>(),
1264 )
1265 }
1266
1267 #[cfg(unix)]
1268 #[test]
1269 fn exec_opts_merges_env_with_parent_by_default() {
1270 std::env::set_var("HARN_EXEC_OPTS_PARENT", "from-parent");
1271 let env = exec_opts_dict(&[("CHILD", VmValue::String(arcstr::ArcStr::from("from-child")))]);
1272 let args = vec![
1273 exec_opts_list(&[
1274 "/bin/sh",
1275 "-c",
1276 "printf '%s|%s' \"$HARN_EXEC_OPTS_PARENT\" \"$CHILD\"",
1277 ]),
1278 exec_opts_dict(&[("env", env)]),
1279 ];
1280 let mut out = String::new();
1281 let result = exec_opts_impl(&args, &mut out).expect("exec_opts result");
1282 let dict = result.as_dict().expect("dict");
1283 assert_eq!(
1284 dict.get("stdout").unwrap().display(),
1285 "from-parent|from-child"
1286 );
1287 assert!(matches!(dict.get("success"), Some(VmValue::Bool(true))));
1288 std::env::remove_var("HARN_EXEC_OPTS_PARENT");
1289 }
1290
1291 #[cfg(unix)]
1292 #[test]
1293 fn exec_opts_replace_env_clears_parent() {
1294 std::env::set_var("HARN_EXEC_OPTS_PARENT2", "from-parent");
1295 let env = exec_opts_dict(&[("CHILD", VmValue::String(arcstr::ArcStr::from("from-child")))]);
1296 let args = vec![
1297 exec_opts_list(&[
1298 "/bin/sh",
1299 "-c",
1300 "printf '%s|%s' \"$HARN_EXEC_OPTS_PARENT2\" \"$CHILD\"",
1301 ]),
1302 exec_opts_dict(&[
1303 ("env", env),
1304 ("env_mode", VmValue::String(arcstr::ArcStr::from("replace"))),
1305 ]),
1306 ];
1307 let mut out = String::new();
1308 let result = exec_opts_impl(&args, &mut out).expect("exec_opts result");
1309 let dict = result.as_dict().expect("dict");
1310 assert_eq!(dict.get("stdout").unwrap().display(), "|from-child");
1311 std::env::remove_var("HARN_EXEC_OPTS_PARENT2");
1312 }
1313
1314 #[cfg(unix)]
1315 #[test]
1316 fn exec_at_opts_honors_directory() {
1317 let dir = std::env::temp_dir().join(format!("harn-exec-opts-cwd-{}", uuid::Uuid::now_v7()));
1318 std::fs::create_dir_all(&dir).unwrap();
1319 let args = vec![
1320 VmValue::String(arcstr::ArcStr::from(dir.to_string_lossy().into_owned())),
1321 exec_opts_list(&["/bin/sh", "-c", "pwd -P"]),
1322 ];
1323 let mut out = String::new();
1324 let result = exec_at_opts_impl(&args, &mut out).expect("exec_at_opts result");
1325 let dict = result.as_dict().expect("dict");
1326 let want = std::fs::canonicalize(&dir).unwrap();
1328 let got = dict.get("stdout").unwrap().display();
1329 assert_eq!(got.trim(), want.to_string_lossy());
1330 let _ = std::fs::remove_dir_all(&dir);
1331 }
1332
1333 #[cfg(unix)]
1334 #[test]
1335 fn exec_opts_enforces_timeout() {
1336 let args = vec![
1337 exec_opts_list(&["/bin/sh", "-c", "sleep 5"]),
1338 exec_opts_dict(&[("timeout", VmValue::Int(50))]),
1339 ];
1340 let mut out = String::new();
1341 let result = exec_opts_impl(&args, &mut out).expect("exec_opts result");
1342 let dict = result.as_dict().expect("dict");
1343 assert!(
1344 matches!(dict.get("timed_out"), Some(VmValue::Bool(true))),
1345 "command exceeding timeout must report timed_out"
1346 );
1347 assert!(matches!(dict.get("success"), Some(VmValue::Bool(false))));
1348 }
1349
1350 #[cfg(unix)]
1351 #[test]
1352 fn exec_opts_rejects_empty_command() {
1353 let args = vec![exec_opts_list(&[])];
1354 let mut out = String::new();
1355 assert!(exec_opts_impl(&args, &mut out).is_err());
1356 let bad = vec![VmValue::String(arcstr::ArcStr::from("not-a-list"))];
1357 assert!(exec_opts_impl(&bad, &mut out).is_err());
1358 }
1359}