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 static SESSION_PROFILE_CONTEXT: RefCell<Option<crate::security::SessionProfile>> =
28 const { RefCell::new(None) };
29}
30
31pub(crate) fn set_thread_source_dir(dir: &std::path::Path) {
33 set_thread_source_dir_option(Some(dir));
34}
35
36pub(crate) fn set_thread_source_dir_option(dir: Option<&std::path::Path>) {
37 VM_SOURCE_DIR.with(|current| {
38 *current.borrow_mut() = dir.map(normalize_context_path);
39 });
40}
41
42pub(crate) fn normalize_context_path(path: &std::path::Path) -> PathBuf {
43 if path.is_absolute() {
44 return path.to_path_buf();
45 }
46 std::env::current_dir()
47 .map(|cwd| cwd.join(path))
48 .unwrap_or_else(|_| path.to_path_buf())
49}
50
51pub fn set_thread_execution_context(context: Option<RunExecutionRecord>) {
52 VM_EXECUTION_CONTEXT.with(|current| *current.borrow_mut() = context);
53}
54
55pub(crate) fn current_execution_context() -> Option<RunExecutionRecord> {
56 VM_EXECUTION_CONTEXT.with(|current| current.borrow().clone())
57}
58
59pub fn set_session_profile(profile: Option<crate::security::SessionProfile>) {
63 SESSION_PROFILE_CONTEXT.with(|current| *current.borrow_mut() = profile);
64}
65
66pub(crate) fn current_session_profile() -> Option<crate::security::SessionProfile> {
69 SESSION_PROFILE_CONTEXT.with(|current| current.borrow().clone())
70}
71
72pub(crate) fn swap_session_profile(
78 next: Option<crate::security::SessionProfile>,
79) -> Option<crate::security::SessionProfile> {
80 SESSION_PROFILE_CONTEXT.with(|current| std::mem::replace(&mut *current.borrow_mut(), next))
81}
82
83pub(crate) fn swap_thread_execution_context(
91 next: Option<RunExecutionRecord>,
92) -> Option<RunExecutionRecord> {
93 VM_EXECUTION_CONTEXT.with(|current| std::mem::replace(&mut *current.borrow_mut(), next))
94}
95
96pub(crate) fn swap_source_dir(next: Option<PathBuf>) -> Option<PathBuf> {
100 VM_SOURCE_DIR.with(|current| std::mem::replace(&mut *current.borrow_mut(), next))
101}
102
103pub(crate) struct SourceDirGuard {
116 previous: Option<PathBuf>,
117}
118
119impl SourceDirGuard {
120 pub(crate) fn capture() -> Self {
122 Self {
123 previous: VM_SOURCE_DIR.with(|sd| sd.borrow().clone()),
124 }
125 }
126}
127
128impl Drop for SourceDirGuard {
129 fn drop(&mut self) {
130 let previous = self.previous.take();
131 VM_SOURCE_DIR.with(|sd| *sd.borrow_mut() = previous);
132 }
133}
134
135pub(crate) fn reset_process_state() {
137 VM_SOURCE_DIR.with(|sd| *sd.borrow_mut() = None);
138 VM_EXECUTION_CONTEXT.with(|current| *current.borrow_mut() = None);
139}
140
141pub fn execution_root_path() -> PathBuf {
142 current_execution_context()
143 .and_then(|context| context.cwd.map(PathBuf::from))
144 .or_else(|| std::env::current_dir().ok())
145 .unwrap_or_else(|| PathBuf::from("."))
146}
147
148pub fn project_root_path() -> Option<PathBuf> {
149 current_execution_context().and_then(|context| {
150 let project_root = context.project_root?;
151 if project_root.trim().is_empty() {
152 return None;
153 }
154 let path = PathBuf::from(project_root);
155 if path.is_absolute() {
156 Some(path)
157 } else if let Some(cwd) = context.cwd {
158 Some(PathBuf::from(cwd).join(path))
159 } else {
160 Some(normalize_context_path(&path))
161 }
162 })
163}
164
165pub fn source_root_path() -> PathBuf {
166 VM_SOURCE_DIR
167 .with(|sd| sd.borrow().clone())
168 .or_else(|| {
169 current_execution_context().and_then(|context| context.source_dir.map(PathBuf::from))
170 })
171 .or_else(|| current_execution_context().and_then(|context| context.cwd.map(PathBuf::from)))
172 .or_else(|| std::env::current_dir().ok())
173 .unwrap_or_else(|| PathBuf::from("."))
174}
175
176pub fn asset_root_path() -> PathBuf {
177 source_root_path()
178}
179
180fn env_override(name: &str) -> Option<String> {
181 (name == HARN_REPLAY_ENV && crate::triggers::dispatcher::current_dispatch_is_replay())
182 .then(|| "1".to_string())
183}
184
185pub(crate) fn read_env_value(name: &str) -> Option<String> {
186 env_override(name)
187 .or_else(|| current_execution_context().and_then(|context| context.env.get(name).cloned()))
188 .or_else(|| std::env::var(name).ok())
189}
190
191pub fn runtime_root_base() -> PathBuf {
192 project_root_path()
193 .or_else(|| find_project_root(&execution_root_path()))
194 .or_else(|| find_project_root(&source_root_path()))
195 .unwrap_or_else(source_root_path)
196}
197
198fn lexically_collapse(path: &std::path::Path) -> Option<PathBuf> {
203 use std::path::Component;
204 let mut out: Vec<Component> = Vec::new();
205 for component in path.components() {
206 match component {
207 Component::CurDir => {}
208 Component::ParentDir => {
209 let popped = out.pop();
210 if !matches!(popped, Some(Component::Normal(_))) {
211 return None;
212 }
213 }
214 other => out.push(other),
215 }
216 }
217 Some(out.iter().collect())
218}
219
220pub fn resolve_source_relative_path(path: &str) -> PathBuf {
221 let candidate = PathBuf::from(path);
222 if candidate.is_absolute() {
223 return candidate;
224 }
225 let root = execution_root_path();
226 let joined = root.join(&candidate);
227 if path_escapes_project_root(&joined) {
234 return root.join("__harn_rejected_parent_dir_traversal__");
235 }
236 joined
237}
238
239pub fn resolve_source_asset_path(path: &str) -> PathBuf {
240 let candidate = PathBuf::from(path);
241 if candidate.is_absolute() {
242 return candidate;
243 }
244 let root = asset_root_path();
245 let joined = root.join(&candidate);
246 if path_escapes_project_root(&joined) {
247 return root.join("__harn_rejected_parent_dir_traversal__");
248 }
249 joined
250}
251
252fn path_escapes_project_root(joined: &std::path::Path) -> bool {
266 lexically_collapse(joined).is_none()
267}
268
269pub(crate) fn register_process_builtins(vm: &mut Vm) {
270 for def in PROCESS_BUILTINS {
271 vm.register_builtin_def(def);
272 }
273}
274
275#[harn_builtin(sig = "env(name: string) -> string?", category = "process")]
276fn env_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
277 let name = args.first().map(|a| a.display()).unwrap_or_default();
278 if let Some(value) = read_env_value(&name) {
279 return Ok(VmValue::String(arcstr::ArcStr::from(value)));
280 }
281 Ok(VmValue::Nil)
282}
283
284#[harn_builtin(
285 sig = "env_or(name: string, default: any) -> any",
286 category = "process"
287)]
288fn env_or_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
289 let name = args.first().map(|a| a.display()).unwrap_or_default();
290 let default = args.get(1).cloned().unwrap_or(VmValue::Nil);
291 if let Some(value) = read_env_value(&name) {
292 return Ok(VmValue::String(arcstr::ArcStr::from(value)));
293 }
294 Ok(default)
295}
296
297#[harn_builtin(sig = "exit(code?: int) -> never", category = "process")]
298fn exit_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
299 let code = args.first().and_then(|a| a.as_int()).unwrap_or(0);
300 Err(VmError::ProcessExit(code as i32))
301}
302
303#[harn_builtin(sig = "exec(...command: string) -> dict", category = "process")]
304fn exec_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
305 if args.is_empty() {
306 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
307 "exec: command is required",
308 ))));
309 }
310 let cmd = args[0].display();
311 let cmd_args: Vec<String> = args[1..].iter().map(|a| a.display()).collect();
312 let output = exec_command(None, &cmd, &cmd_args)?;
313 Ok(vm_output_to_value(output))
314}
315
316#[harn_builtin(sig = "shell(command: string) -> dict", category = "process")]
317fn shell_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
318 let cmd = args.first().map(|a| a.display()).unwrap_or_default();
319 if cmd.is_empty() {
320 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
321 "shell: command string is required",
322 ))));
323 }
324 let invocation = crate::shells::default_shell_invocation(&cmd)
325 .map_err(|error| VmError::Runtime(format!("shell: {error}")))?;
326 let output = exec_shell_args(None, &invocation.program, &invocation.args)?;
327 Ok(vm_output_to_value(output))
328}
329
330#[harn_builtin(
331 sig = "exec_at(dir: string, ...command: string) -> dict",
332 category = "process"
333)]
334fn exec_at_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
335 if args.len() < 2 {
336 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
337 "exec_at: directory and command are required",
338 ))));
339 }
340 let dir = args[0].display();
341 let cmd = args[1].display();
342 let cmd_args: Vec<String> = args[2..].iter().map(|a| a.display()).collect();
343 let output = exec_command(Some(dir.as_str()), &cmd, &cmd_args)?;
344 Ok(vm_output_to_value(output))
345}
346
347#[harn_builtin(
348 sig = "shell_at(dir: string, command: string) -> dict",
349 category = "process"
350)]
351fn shell_at_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
352 if args.len() < 2 {
353 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
354 "shell_at: directory and command string are required",
355 ))));
356 }
357 let dir = args[0].display();
358 let cmd = args[1].display();
359 if cmd.is_empty() {
360 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
361 "shell_at: command string is required",
362 ))));
363 }
364 let invocation = crate::shells::default_shell_invocation(&cmd)
365 .map_err(|error| VmError::Runtime(format!("shell_at: {error}")))?;
366 let output = exec_shell_args(Some(dir.as_str()), &invocation.program, &invocation.args)?;
367 Ok(vm_output_to_value(output))
368}
369
370#[harn_builtin(sig = "username(...args: any) -> string", category = "process")]
371fn username_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
372 let user = std::env::var("USER")
373 .or_else(|_| std::env::var("USERNAME"))
374 .unwrap_or_default();
375 Ok(VmValue::String(arcstr::ArcStr::from(user)))
376}
377
378#[harn_builtin(sig = "hostname() -> string", category = "process")]
379fn hostname_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
380 let name = std::env::var("HOSTNAME")
381 .or_else(|_| std::env::var("COMPUTERNAME"))
382 .or_else(|_| {
383 std::process::Command::new("hostname")
384 .output()
385 .ok()
386 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
387 .ok_or(std::env::VarError::NotPresent)
388 })
389 .unwrap_or_default();
390 Ok(VmValue::String(arcstr::ArcStr::from(name)))
391}
392
393#[harn_builtin(sig = "platform(...args: any) -> string", category = "process")]
394fn platform_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
395 let os = if cfg!(target_os = "macos") {
396 "darwin"
397 } else if cfg!(target_os = "linux") {
398 "linux"
399 } else if cfg!(target_os = "windows") {
400 "windows"
401 } else {
402 std::env::consts::OS
403 };
404 Ok(VmValue::String(arcstr::ArcStr::from(os)))
405}
406
407#[harn_builtin(sig = "arch() -> string", category = "process")]
408fn arch_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
409 Ok(VmValue::String(arcstr::ArcStr::from(
410 std::env::consts::ARCH,
411 )))
412}
413
414#[harn_builtin(sig = "home_dir() -> string", category = "process")]
415fn home_dir_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
416 let home = crate::user_dirs::home_dir()
417 .map(|home| home.to_string_lossy().into_owned())
418 .unwrap_or_default();
419 Ok(VmValue::String(arcstr::ArcStr::from(home)))
420}
421
422#[harn_builtin(sig = "pid(...args: any) -> int", category = "process")]
423fn pid_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
424 Ok(VmValue::Int(std::process::id() as i64))
425}
426
427#[harn_builtin(sig = "date_iso() -> string", category = "process")]
428fn date_iso_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
429 let now = crate::clock_mock::leak_audit::wall_now("stdlib/date_iso");
436 let dt: chrono::DateTime<chrono::Utc> = now.into();
437 Ok(VmValue::String(arcstr::ArcStr::from(
438 dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
439 )))
440}
441
442#[harn_builtin(sig = "cwd() -> string", category = "process")]
443fn cwd_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
444 let dir = current_execution_context()
445 .and_then(|context| context.cwd)
446 .or_else(|| {
447 std::env::current_dir()
448 .ok()
449 .map(|p| p.to_string_lossy().into_owned())
450 })
451 .unwrap_or_default();
452 Ok(VmValue::String(arcstr::ArcStr::from(dir)))
453}
454
455#[harn_builtin(sig = "execution_root() -> string", category = "process")]
456fn execution_root_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
457 Ok(VmValue::String(arcstr::ArcStr::from(
458 execution_root_path().to_string_lossy().into_owned(),
459 )))
460}
461
462#[harn_builtin(sig = "asset_root() -> string", category = "process")]
463fn asset_root_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
464 Ok(VmValue::String(arcstr::ArcStr::from(
465 asset_root_path().to_string_lossy().into_owned(),
466 )))
467}
468
469#[harn_builtin(sig = "runtime_paths() -> dict", category = "process")]
470fn runtime_paths_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
471 let runtime_base = runtime_root_base();
472 let mut paths = BTreeMap::new();
473 paths.put_str("execution_root", execution_root_path().to_string_lossy());
474 paths.put_str("asset_root", asset_root_path().to_string_lossy());
475 paths.put_str(
476 "state_root",
477 crate::runtime_paths::state_root(&runtime_base).to_string_lossy(),
478 );
479 paths.put_str(
480 "run_root",
481 crate::runtime_paths::run_root(&runtime_base).to_string_lossy(),
482 );
483 paths.put_str(
484 "worktree_root",
485 crate::runtime_paths::worktree_root(&runtime_base).to_string_lossy(),
486 );
487 Ok(VmValue::dict(paths))
488}
489
490#[harn_builtin(sig = "spawn_captured(opts: dict) -> dict", category = "process")]
491fn spawn_captured_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
492 spawn_captured_value(args)
493}
494
495#[harn_builtin(sig = "term_width() -> int", category = "process")]
505fn term_width_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
506 Ok(VmValue::Int(crate::term::width() as i64))
507}
508
509#[harn_builtin(sig = "term_height() -> int", category = "process")]
510fn term_height_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
511 Ok(VmValue::Int(crate::term::height() as i64))
512}
513
514const PROCESS_BUILTINS: &[&VmBuiltinDef] = &[
515 &ENV_IMPL_DEF,
516 &ENV_OR_IMPL_DEF,
517 &EXIT_IMPL_DEF,
518 &EXEC_IMPL_DEF,
519 &EXEC_OPTS_IMPL_DEF,
520 &SHELL_IMPL_DEF,
521 &EXEC_AT_IMPL_DEF,
522 &EXEC_AT_OPTS_IMPL_DEF,
523 &SHELL_AT_IMPL_DEF,
524 &USERNAME_IMPL_DEF,
525 &HOSTNAME_IMPL_DEF,
526 &PLATFORM_IMPL_DEF,
527 &ARCH_IMPL_DEF,
528 &HOME_DIR_IMPL_DEF,
529 &PID_IMPL_DEF,
530 &DATE_ISO_IMPL_DEF,
531 &CWD_IMPL_DEF,
532 &EXECUTION_ROOT_IMPL_DEF,
533 &ASSET_ROOT_IMPL_DEF,
534 &RUNTIME_PATHS_IMPL_DEF,
535 &SPAWN_CAPTURED_IMPL_DEF,
536 &TERM_WIDTH_IMPL_DEF,
537 &TERM_HEIGHT_IMPL_DEF,
538];
539
540pub(crate) fn spawn_captured_value(args: &[VmValue]) -> Result<VmValue, VmError> {
545 let opts = match args.first() {
546 Some(VmValue::Dict(opts)) => opts.clone(),
547 _ => {
548 return Err(VmError::Runtime(
549 "spawn_captured: options dict is required".to_string(),
550 ));
551 }
552 };
553 let cmd = match opts.get("cmd").map(|v| v.display()).unwrap_or_default() {
554 s if s.is_empty() => {
555 return Err(VmError::Runtime(
556 "spawn_captured: opts.cmd is required".to_string(),
557 ));
558 }
559 s => s,
560 };
561 let cmd_args: Vec<String> = match opts.get("args") {
562 Some(VmValue::List(items)) => items.iter().map(|v| v.display()).collect(),
563 None | Some(VmValue::Nil) => Vec::new(),
564 Some(other) => {
565 return Err(VmError::Runtime(format!(
566 "spawn_captured: opts.args must be a list of strings, got {}",
567 other.type_name()
568 )));
569 }
570 };
571 let cwd = opts
572 .get("cwd")
573 .map(|v| v.display())
574 .filter(|s| !s.is_empty());
575 let env_overrides: Vec<(String, String)> = match opts.get("env") {
576 Some(VmValue::Dict(env)) => env
577 .iter()
578 .map(|(k, v)| (k.to_string(), v.display()))
579 .collect(),
580 None | Some(VmValue::Nil) => Vec::new(),
581 Some(other) => {
582 return Err(VmError::Runtime(format!(
583 "spawn_captured: opts.env must be a dict, got {}",
584 other.type_name()
585 )));
586 }
587 };
588 let stdin_bytes: Option<Vec<u8>> = match opts.get("stdin") {
589 Some(VmValue::Bytes(bytes)) => Some(bytes.as_slice().to_vec()),
590 Some(VmValue::String(s)) => Some(s.as_bytes().to_vec()),
591 None | Some(VmValue::Nil) => None,
592 Some(other) => {
593 return Err(VmError::Runtime(format!(
594 "spawn_captured: opts.stdin must be string or bytes, got {}",
595 other.type_name()
596 )));
597 }
598 };
599 let timeout = opts
600 .get("timeout_ms")
601 .and_then(|v| v.as_int())
602 .filter(|n| *n > 0)
603 .map(|n| Duration::from_millis(n as u64));
604
605 let spawn = CapturedSpawn {
606 label: "spawn_captured",
607 cmd: &cmd,
608 args: &cmd_args,
609 cwd: cwd.as_deref(),
610 env: &env_overrides,
611 env_clear: false,
615 stdin: stdin_bytes,
616 timeout,
617 };
618 let CapturedRun {
619 output,
620 timed_out,
621 interrupted,
622 duration_ms,
623 } = run_captured_spawn(spawn)?;
624
625 let exit_code = if timed_out || interrupted {
626 -1
627 } else {
628 output.status.code().unwrap_or(-1) as i64
629 };
630 let success = if timed_out || interrupted {
631 false
632 } else {
633 output.status.success()
634 };
635 let mut result = BTreeMap::new();
636 result.insert("exit_code".to_string(), VmValue::Int(exit_code));
637 result.put_str("stdout", String::from_utf8_lossy(&output.stdout).as_ref());
638 result.put_str("stderr", String::from_utf8_lossy(&output.stderr).as_ref());
639 result.insert("duration_ms".to_string(), VmValue::Int(duration_ms));
640 result.insert("success".to_string(), VmValue::Bool(success));
641 result.insert("timed_out".to_string(), VmValue::Bool(timed_out));
642 Ok(VmValue::dict(result))
643}
644
645struct CapturedSpawn<'a> {
650 label: &'static str,
651 cmd: &'a str,
652 args: &'a [String],
653 cwd: Option<&'a str>,
654 env: &'a [(String, String)],
655 env_clear: bool,
656 stdin: Option<Vec<u8>>,
657 timeout: Option<Duration>,
658}
659
660struct CapturedRun {
662 output: std::process::Output,
663 timed_out: bool,
664 interrupted: bool,
665 duration_ms: i64,
666}
667
668fn run_captured_spawn(spec: CapturedSpawn<'_>) -> Result<CapturedRun, VmError> {
680 let label = spec.label;
681 let mut command = std::process::Command::new(spec.cmd);
682 command.args(spec.args);
683 if let Some(cwd) = spec.cwd {
684 command.current_dir(cwd);
685 }
686 let profile_env = if spec.env_clear {
692 None
693 } else {
694 session_closed_env(spec.env.iter().cloned())?
695 };
696 if spec.env_clear || profile_env.is_some() {
697 command.env_clear();
698 }
699 for (key, value) in profile_env.as_deref().unwrap_or(spec.env) {
700 command.env(key, value);
701 }
702 command.stdout(Stdio::piped()).stderr(Stdio::piped());
703 if spec.stdin.is_some() {
704 command.stdin(Stdio::piped());
705 } else {
706 command.stdin(Stdio::null());
707 }
708 crate::op_interrupt::configure_kill_group(&mut command);
709 let cleanup_token = crate::op_interrupt::new_process_cleanup_token();
710 command.env(
711 crate::op_interrupt::PROCESS_CLEANUP_TOKEN_ENV,
712 &cleanup_token,
713 );
714
715 let started = Instant::now();
716 let cmd = spec.cmd;
717 let mut child = command.spawn().map_err(|error| {
718 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
719 "{label}: failed to spawn '{cmd}': {error}"
720 ))))
721 })?;
722
723 if let (Some(payload), Some(mut stdin)) = (spec.stdin, child.stdin.take()) {
724 let _ = stdin.write_all(&payload);
726 }
727
728 let rx_out = child
732 .stdout
733 .take()
734 .map(crate::op_interrupt::spawn_pipe_drain);
735 let rx_err = child
736 .stderr
737 .take()
738 .map(crate::op_interrupt::spawn_pipe_drain);
739
740 let child_pid = child.id();
741 let wait_end = crate::op_interrupt::wait_child_interruptible_with_cleanup_token(
742 &mut child,
743 spec.timeout,
744 Some(&cleanup_token),
745 )
746 .map_err(|error| {
747 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
748 "{label}: wait failed: {error}"
749 ))))
750 })?;
751 let (status, timed_out, interrupted, killed) = match wait_end {
752 crate::op_interrupt::ChildWait::Exited(status) => (status, false, false, false),
753 crate::op_interrupt::ChildWait::TimedOut(_) => {
754 (std::process::ExitStatus::default(), true, false, true)
755 }
756 crate::op_interrupt::ChildWait::Interrupted(status, _) => {
760 (status.unwrap_or_default(), false, true, true)
761 }
762 };
763
764 let stdout = rx_out
765 .map(|rx| crate::op_interrupt::drain_captured_pipe(&rx, killed, child_pid))
766 .unwrap_or_default();
767 let stderr = rx_err
768 .map(|rx| crate::op_interrupt::drain_captured_pipe(&rx, killed, child_pid))
769 .unwrap_or_default();
770
771 Ok(CapturedRun {
772 output: std::process::Output {
773 status,
774 stdout,
775 stderr,
776 },
777 timed_out,
778 interrupted,
779 duration_ms: started.elapsed().as_millis() as i64,
780 })
781}
782
783#[derive(Default)]
786struct ExecOptions {
787 env: Vec<(String, String)>,
788 env_clear: bool,
789 cwd: Option<String>,
790 timeout: Option<Duration>,
791}
792
793fn exec_options(label: &str, options: Option<&VmValue>) -> Result<ExecOptions, VmError> {
802 let opts = match options {
803 None | Some(VmValue::Nil) => return Ok(ExecOptions::default()),
804 Some(VmValue::Dict(opts)) => opts.clone(),
805 Some(other) => {
806 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
807 format!("{label}: options must be a dict, got {}", other.type_name()),
808 ))));
809 }
810 };
811 let env: Vec<(String, String)> = match opts.get("env") {
812 Some(VmValue::Dict(env)) => env
813 .iter()
814 .map(|(k, v)| (k.to_string(), v.display()))
815 .collect(),
816 None | Some(VmValue::Nil) => Vec::new(),
817 Some(other) => {
818 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
819 format!(
820 "{label}: options.env must be a dict, got {}",
821 other.type_name()
822 ),
823 ))));
824 }
825 };
826 let env_clear = match opts.get("env_mode").map(|v| v.display()).as_deref() {
827 None | Some("merge") => false,
828 Some("replace") => true,
829 Some(other) => {
830 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
831 format!(
832 "{label}: options.env_mode must be \"merge\" or \"replace\", got {other:?}"
833 ),
834 ))));
835 }
836 };
837 let cwd = opts
838 .get("cwd")
839 .map(|v| v.display())
840 .filter(|s| !s.is_empty());
841 let timeout = opts
844 .get("timeout")
845 .or_else(|| opts.get("timeout_ms"))
846 .and_then(|v| v.as_int())
847 .filter(|n| *n > 0)
848 .map(|n| Duration::from_millis(n as u64));
849 Ok(ExecOptions {
850 env,
851 env_clear,
852 cwd,
853 timeout,
854 })
855}
856
857fn captured_run_to_value(run: &CapturedRun) -> VmValue {
861 let status = if run.timed_out || run.interrupted {
862 -1
863 } else {
864 run.output.status.code().unwrap_or(-1) as i64
865 };
866 let success = !run.timed_out && !run.interrupted && run.output.status.success();
867 let mut result = BTreeMap::new();
868 result.put_str(
869 "stdout",
870 String::from_utf8_lossy(&run.output.stdout).as_ref(),
871 );
872 result.put_str(
873 "stderr",
874 String::from_utf8_lossy(&run.output.stderr).as_ref(),
875 );
876 result.insert("status".to_string(), VmValue::Int(status));
877 result.insert("success".to_string(), VmValue::Bool(success));
878 result.insert("timed_out".to_string(), VmValue::Bool(run.timed_out));
879 result.insert("duration_ms".to_string(), VmValue::Int(run.duration_ms));
880 VmValue::dict(result)
881}
882
883#[harn_builtin(
884 sig = "exec_opts(command: list, options: dict?) -> dict",
885 category = "process"
886)]
887fn exec_opts_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
888 let command = exec_opts_command("exec_opts", args.first())?;
889 let opts = exec_options("exec_opts", args.get(1))?;
890 let run = run_captured_spawn(CapturedSpawn {
891 label: "exec_opts",
892 cmd: &command[0],
893 args: &command[1..],
894 cwd: opts.cwd.as_deref(),
895 env: &opts.env,
896 env_clear: opts.env_clear,
897 stdin: None,
898 timeout: opts.timeout,
899 })?;
900 Ok(captured_run_to_value(&run))
901}
902
903#[harn_builtin(
904 sig = "exec_at_opts(dir: string, command: list, options: dict?) -> dict",
905 category = "process"
906)]
907fn exec_at_opts_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
908 let dir = match args.first() {
909 Some(value) if !value.display().is_empty() => value.display(),
910 _ => {
911 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
912 "exec_at_opts: directory is required",
913 ))));
914 }
915 };
916 let command = exec_opts_command("exec_at_opts", args.get(1))?;
917 let opts = exec_options("exec_at_opts", args.get(2))?;
918 let resolved_cwd = opts.cwd.unwrap_or(dir);
921 let run = run_captured_spawn(CapturedSpawn {
922 label: "exec_at_opts",
923 cmd: &command[0],
924 args: &command[1..],
925 cwd: Some(resolved_cwd.as_str()),
926 env: &opts.env,
927 env_clear: opts.env_clear,
928 stdin: None,
929 timeout: opts.timeout,
930 })?;
931 Ok(captured_run_to_value(&run))
932}
933
934fn exec_opts_command(label: &str, value: Option<&VmValue>) -> Result<Vec<String>, VmError> {
937 let items = match value {
938 Some(VmValue::List(items)) => items,
939 _ => {
940 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
941 format!("{label}: command must be a non-empty list of strings"),
942 ))));
943 }
944 };
945 let command: Vec<String> = items.iter().map(|v| v.display()).collect();
946 if command.is_empty() || command[0].is_empty() {
947 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
948 format!("{label}: command must be a non-empty list of strings"),
949 ))));
950 }
951 Ok(command)
952}
953
954pub fn find_project_root(base: &std::path::Path) -> Option<std::path::PathBuf> {
956 let mut dir = base.to_path_buf();
957 loop {
958 if dir.join("harn.toml").exists() {
959 return Some(dir);
960 }
961 if !dir.pop() {
962 return None;
963 }
964 }
965}
966
967pub(crate) fn register_path_builtins(vm: &mut Vm) {
969 for def in PATH_BUILTINS {
970 vm.register_builtin_def(def);
971 }
972}
973
974#[harn_builtin(sig = "source_dir(...args: any) -> string", category = "process")]
975fn source_dir_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
976 let dir = VM_SOURCE_DIR.with(|sd| sd.borrow().clone());
977 match dir {
978 Some(d) => Ok(VmValue::String(arcstr::ArcStr::from(
979 d.to_string_lossy().into_owned(),
980 ))),
981 None => {
982 let cwd = std::env::current_dir()
983 .map(|p| p.to_string_lossy().into_owned())
984 .unwrap_or_default();
985 Ok(VmValue::String(arcstr::ArcStr::from(cwd)))
986 }
987 }
988}
989
990#[harn_builtin(sig = "project_root() -> string?", category = "process")]
991fn project_root_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
992 if let Some(root) = project_root_path() {
993 return Ok(VmValue::String(arcstr::ArcStr::from(
994 root.to_string_lossy().as_ref(),
995 )));
996 }
997 let base = current_execution_context()
998 .and_then(|context| context.cwd.map(PathBuf::from))
999 .or_else(|| VM_SOURCE_DIR.with(|sd| sd.borrow().clone()))
1000 .or_else(|| std::env::current_dir().ok())
1001 .unwrap_or_else(|| PathBuf::from("."));
1002 match find_project_root(&base) {
1003 Some(root) => Ok(VmValue::String(arcstr::ArcStr::from(
1004 root.to_string_lossy().into_owned(),
1005 ))),
1006 None => Ok(VmValue::Nil),
1007 }
1008}
1009
1010const PATH_BUILTINS: &[&VmBuiltinDef] = &[&SOURCE_DIR_IMPL_DEF, &PROJECT_ROOT_IMPL_DEF];
1011
1012fn vm_output_to_value(output: std::process::Output) -> VmValue {
1013 let mut result = BTreeMap::new();
1014 result.put_str("stdout", String::from_utf8_lossy(&output.stdout).as_ref());
1015 result.put_str("stderr", String::from_utf8_lossy(&output.stderr).as_ref());
1016 result.insert(
1017 "status".to_string(),
1018 VmValue::Int(output.status.code().unwrap_or(-1) as i64),
1019 );
1020 result.insert(
1021 "success".to_string(),
1022 VmValue::Bool(output.status.success()),
1023 );
1024 VmValue::dict(result)
1025}
1026
1027fn exec_command(
1028 dir: Option<&str>,
1029 cmd: &str,
1030 args: &[String],
1031) -> Result<std::process::Output, VmError> {
1032 let config = process_command_config(dir)?;
1033 crate::stdlib::sandbox::command_output(cmd, args, &config)
1034 .map_err(|error| prefix_process_error(error, "exec"))
1035}
1036
1037fn exec_shell_args(
1038 dir: Option<&str>,
1039 shell: &str,
1040 args: &[String],
1041) -> Result<std::process::Output, VmError> {
1042 let config = process_command_config(dir)?;
1043 crate::stdlib::sandbox::command_output(shell, args, &config)
1044 .map_err(|error| prefix_process_error(error, "shell"))
1045}
1046
1047fn process_command_config(
1048 dir: Option<&str>,
1049) -> Result<crate::stdlib::sandbox::ProcessCommandConfig, VmError> {
1050 let mut config = crate::stdlib::sandbox::ProcessCommandConfig {
1051 stdin_null: true,
1052 ..Default::default()
1053 };
1054 if let Some(dir) = dir {
1055 let resolved = resolve_command_dir(dir);
1056 crate::stdlib::sandbox::enforce_process_cwd(&resolved)?;
1057 config.cwd = Some(resolved);
1058 } else if let Some(context) = current_execution_context() {
1059 if let Some(cwd) = context.cwd.filter(|cwd| !cwd.is_empty()) {
1060 crate::stdlib::sandbox::enforce_process_cwd(std::path::Path::new(&cwd))?;
1061 config.cwd = Some(std::path::PathBuf::from(cwd));
1062 }
1063 if !context.env.is_empty() {
1064 config.env.extend(context.env);
1065 }
1066 }
1067 if let Some(value) = env_override(HARN_REPLAY_ENV) {
1068 config.env.push((HARN_REPLAY_ENV.to_string(), value));
1069 }
1070 if let Some(env) = session_closed_env(config.env.iter().cloned())? {
1074 config.env = env;
1075 config.closed_env = true;
1076 }
1077 Ok(config)
1078}
1079
1080pub(crate) fn session_closed_env(
1096 overlay: impl Iterator<Item = (String, String)>,
1097) -> Result<Option<Vec<(String, String)>>, VmError> {
1098 let Some(profile) = current_session_profile() else {
1099 return Ok(None);
1100 };
1101 let resolved = crate::security::resolve_env(
1102 &profile,
1103 &|name| std::env::var(name).ok(),
1104 &resolve_grant_secret,
1105 )
1106 .map_err(|error| {
1107 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
1108 "session grant env resolution failed: {error}"
1109 ))))
1110 })?;
1111 let mut env: BTreeMap<String, String> = resolved;
1112 env.extend(overlay);
1113 Ok(Some(env.into_iter().collect()))
1114}
1115
1116fn resolve_grant_secret(account: &str, key: &str) -> Option<String> {
1123 let reference = format!("{}{}/{}", crate::secrets::SECRET_REF_SCHEME, account, key);
1124 crate::secrets::resolve_secret_ref_to_string(&reference)
1125 .ok()
1126 .flatten()
1127}
1128
1129fn prefix_process_error(error: VmError, prefix: &str) -> VmError {
1130 match error {
1131 VmError::Thrown(VmValue::String(message)) => VmError::Thrown(VmValue::String(
1132 arcstr::ArcStr::from(format!("{prefix} failed: {message}")),
1133 )),
1134 other => other,
1135 }
1136}
1137
1138fn resolve_command_dir(dir: &str) -> PathBuf {
1139 let candidate = PathBuf::from(dir);
1140 if candidate.is_absolute() {
1141 return candidate;
1142 }
1143 if let Some(cwd) = current_execution_context().and_then(|context| context.cwd) {
1144 return PathBuf::from(cwd).join(candidate);
1145 }
1146 if let Some(source_dir) = VM_SOURCE_DIR.with(|sd| sd.borrow().clone()) {
1147 return source_dir.join(candidate);
1148 }
1149 candidate
1150}
1151
1152#[cfg(test)]
1153#[path = "process_tests.rs"]
1154mod tests;