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_ENVIRONMENT_CONTEXT: RefCell<Option<crate::security::SessionEnvironment>> =
24 const { RefCell::new(None) };
25}
26
27pub(crate) fn set_thread_source_dir(dir: &std::path::Path) {
29 set_thread_source_dir_option(Some(dir));
30}
31
32pub(crate) fn set_thread_source_dir_option(dir: Option<&std::path::Path>) {
33 VM_SOURCE_DIR.with(|current| {
34 *current.borrow_mut() = dir.map(normalize_context_path);
35 });
36}
37
38pub(crate) fn normalize_context_path(path: &std::path::Path) -> PathBuf {
39 if path.is_absolute() {
40 return path.to_path_buf();
41 }
42 std::env::current_dir()
43 .map(|cwd| cwd.join(path))
44 .unwrap_or_else(|_| path.to_path_buf())
45}
46
47pub fn set_thread_execution_context(context: Option<RunExecutionRecord>) {
48 VM_EXECUTION_CONTEXT.with(|current| *current.borrow_mut() = context);
49}
50
51pub(crate) fn current_execution_context() -> Option<RunExecutionRecord> {
52 VM_EXECUTION_CONTEXT.with(|current| current.borrow().clone())
53}
54
55pub fn set_session_environment(environment: Option<crate::security::SessionEnvironment>) {
59 SESSION_ENVIRONMENT_CONTEXT.with(|current| *current.borrow_mut() = environment);
60}
61
62pub(crate) fn current_session_environment() -> Option<crate::security::SessionEnvironment> {
65 SESSION_ENVIRONMENT_CONTEXT.with(|current| current.borrow().clone())
66}
67
68pub(crate) fn swap_session_environment(
74 next: Option<crate::security::SessionEnvironment>,
75) -> Option<crate::security::SessionEnvironment> {
76 SESSION_ENVIRONMENT_CONTEXT.with(|current| std::mem::replace(&mut *current.borrow_mut(), next))
77}
78
79pub(crate) fn swap_thread_execution_context(
87 next: Option<RunExecutionRecord>,
88) -> Option<RunExecutionRecord> {
89 VM_EXECUTION_CONTEXT.with(|current| std::mem::replace(&mut *current.borrow_mut(), next))
90}
91
92pub(crate) fn swap_source_dir(next: Option<PathBuf>) -> Option<PathBuf> {
96 VM_SOURCE_DIR.with(|current| std::mem::replace(&mut *current.borrow_mut(), next))
97}
98
99pub(crate) struct SourceDirGuard {
112 previous: Option<PathBuf>,
113}
114
115impl SourceDirGuard {
116 pub(crate) fn capture() -> Self {
118 Self {
119 previous: VM_SOURCE_DIR.with(|sd| sd.borrow().clone()),
120 }
121 }
122}
123
124impl Drop for SourceDirGuard {
125 fn drop(&mut self) {
126 let previous = self.previous.take();
127 VM_SOURCE_DIR.with(|sd| *sd.borrow_mut() = previous);
128 }
129}
130
131pub(crate) fn reset_process_state() {
133 VM_SOURCE_DIR.with(|sd| *sd.borrow_mut() = None);
134 VM_EXECUTION_CONTEXT.with(|current| *current.borrow_mut() = None);
135}
136
137pub fn execution_root_path() -> PathBuf {
138 current_execution_context()
139 .and_then(|context| context.cwd.map(PathBuf::from))
140 .or_else(|| std::env::current_dir().ok())
141 .unwrap_or_else(|| PathBuf::from("."))
142}
143
144pub fn project_root_path() -> Option<PathBuf> {
145 current_execution_context().and_then(|context| {
146 let project_root = context.project_root?;
147 if project_root.trim().is_empty() {
148 return None;
149 }
150 let path = PathBuf::from(project_root);
151 if path.is_absolute() {
152 Some(path)
153 } else if let Some(cwd) = context.cwd {
154 Some(PathBuf::from(cwd).join(path))
155 } else {
156 Some(normalize_context_path(&path))
157 }
158 })
159}
160
161pub fn source_root_path() -> PathBuf {
162 VM_SOURCE_DIR
163 .with(|sd| sd.borrow().clone())
164 .or_else(|| {
165 current_execution_context().and_then(|context| context.source_dir.map(PathBuf::from))
166 })
167 .or_else(|| current_execution_context().and_then(|context| context.cwd.map(PathBuf::from)))
168 .or_else(|| std::env::current_dir().ok())
169 .unwrap_or_else(|| PathBuf::from("."))
170}
171
172pub fn asset_root_path() -> PathBuf {
173 source_root_path()
174}
175
176fn env_override(name: &str) -> Option<String> {
177 (name == HARN_REPLAY_ENV && crate::triggers::dispatcher::current_dispatch_is_replay())
178 .then(|| "1".to_string())
179}
180
181pub(crate) fn read_env_value(name: &str) -> Option<String> {
182 env_override(name)
183 .or_else(|| current_execution_context().and_then(|context| context.env.get(name).cloned()))
184 .or_else(|| session_env_var(name).ok().flatten())
185}
186
187pub fn runtime_root_base() -> PathBuf {
188 project_root_path()
189 .or_else(|| find_project_root(&execution_root_path()))
190 .or_else(|| find_project_root(&source_root_path()))
191 .unwrap_or_else(source_root_path)
192}
193
194fn lexically_collapse(path: &std::path::Path) -> Option<PathBuf> {
199 use std::path::Component;
200 let mut out: Vec<Component> = Vec::new();
201 for component in path.components() {
202 match component {
203 Component::CurDir => {}
204 Component::ParentDir => {
205 let popped = out.pop();
206 if !matches!(popped, Some(Component::Normal(_))) {
207 return None;
208 }
209 }
210 other => out.push(other),
211 }
212 }
213 Some(out.iter().collect())
214}
215
216pub fn resolve_source_relative_path(path: &str) -> PathBuf {
217 let candidate = PathBuf::from(path);
218 if candidate.is_absolute() {
219 return candidate;
220 }
221 let root = execution_root_path();
222 let joined = root.join(&candidate);
223 if path_escapes_project_root(&joined) {
230 return root.join("__harn_rejected_parent_dir_traversal__");
231 }
232 joined
233}
234
235pub fn resolve_source_asset_path(path: &str) -> PathBuf {
236 let candidate = PathBuf::from(path);
237 if candidate.is_absolute() {
238 return candidate;
239 }
240 let root = asset_root_path();
241 let joined = root.join(&candidate);
242 if path_escapes_project_root(&joined) {
243 return root.join("__harn_rejected_parent_dir_traversal__");
244 }
245 joined
246}
247
248fn path_escapes_project_root(joined: &std::path::Path) -> bool {
262 lexically_collapse(joined).is_none()
263}
264
265pub(crate) fn register_process_builtins(vm: &mut Vm) {
266 for def in PROCESS_BUILTINS {
267 vm.register_builtin_def(def);
268 }
269}
270
271#[harn_builtin(sig = "env(name: string) -> string?", category = "process")]
272fn env_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
273 let name = args.first().map(|a| a.display()).unwrap_or_default();
274 if let Some(value) = read_env_value(&name) {
275 return Ok(VmValue::String(arcstr::ArcStr::from(value)));
276 }
277 Ok(VmValue::Nil)
278}
279
280#[harn_builtin(
281 sig = "env_or(name: string, default: any) -> any",
282 category = "process"
283)]
284fn env_or_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
285 let name = args.first().map(|a| a.display()).unwrap_or_default();
286 let default = args.get(1).cloned().unwrap_or(VmValue::Nil);
287 if let Some(value) = read_env_value(&name) {
288 return Ok(VmValue::String(arcstr::ArcStr::from(value)));
289 }
290 Ok(default)
291}
292
293#[harn_builtin(sig = "exit(code?: int) -> never", category = "process")]
294fn exit_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
295 let code = args.first().and_then(|a| a.as_int()).unwrap_or(0);
296 Err(VmError::ProcessExit(code as i32))
297}
298
299#[harn_builtin(sig = "exec(...command: string) -> dict", category = "process")]
300fn exec_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
301 if args.is_empty() {
302 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
303 "exec: command is required",
304 ))));
305 }
306 let cmd = args[0].display();
307 let cmd_args: Vec<String> = args[1..].iter().map(|a| a.display()).collect();
308 let output = exec_command(None, &cmd, &cmd_args)?;
309 Ok(vm_output_to_value(output))
310}
311
312#[harn_builtin(sig = "shell(command: string) -> dict", category = "process")]
313fn shell_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
314 let cmd = args.first().map(|a| a.display()).unwrap_or_default();
315 if cmd.is_empty() {
316 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
317 "shell: command string is required",
318 ))));
319 }
320 let invocation = crate::shells::default_shell_invocation(&cmd)
321 .map_err(|error| VmError::Runtime(format!("shell: {error}")))?;
322 let output = exec_shell_args(None, &invocation.program, &invocation.args)?;
323 Ok(vm_output_to_value(output))
324}
325
326#[harn_builtin(
327 sig = "exec_at(dir: string, ...command: string) -> dict",
328 category = "process"
329)]
330fn exec_at_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
331 if args.len() < 2 {
332 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
333 "exec_at: directory and command are required",
334 ))));
335 }
336 let dir = args[0].display();
337 let cmd = args[1].display();
338 let cmd_args: Vec<String> = args[2..].iter().map(|a| a.display()).collect();
339 let output = exec_command(Some(dir.as_str()), &cmd, &cmd_args)?;
340 Ok(vm_output_to_value(output))
341}
342
343#[harn_builtin(
344 sig = "shell_at(dir: string, command: string) -> dict",
345 category = "process"
346)]
347fn shell_at_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
348 if args.len() < 2 {
349 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
350 "shell_at: directory and command string are required",
351 ))));
352 }
353 let dir = args[0].display();
354 let cmd = args[1].display();
355 if cmd.is_empty() {
356 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
357 "shell_at: command string is required",
358 ))));
359 }
360 let invocation = crate::shells::default_shell_invocation(&cmd)
361 .map_err(|error| VmError::Runtime(format!("shell_at: {error}")))?;
362 let output = exec_shell_args(Some(dir.as_str()), &invocation.program, &invocation.args)?;
363 Ok(vm_output_to_value(output))
364}
365
366#[harn_builtin(sig = "username(...args: any) -> string", category = "process")]
367fn username_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
368 let user = std::env::var("USER")
369 .or_else(|_| std::env::var("USERNAME"))
370 .unwrap_or_default();
371 Ok(VmValue::String(arcstr::ArcStr::from(user)))
372}
373
374#[harn_builtin(sig = "hostname() -> string", category = "process")]
375fn hostname_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
376 let name = std::env::var("HOSTNAME")
377 .or_else(|_| std::env::var("COMPUTERNAME"))
378 .or_else(|_| {
379 std::process::Command::new("hostname")
380 .output()
381 .ok()
382 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
383 .ok_or(std::env::VarError::NotPresent)
384 })
385 .unwrap_or_default();
386 Ok(VmValue::String(arcstr::ArcStr::from(name)))
387}
388
389#[harn_builtin(sig = "platform(...args: any) -> string", category = "process")]
390fn platform_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
391 let os = if cfg!(target_os = "macos") {
392 "darwin"
393 } else if cfg!(target_os = "linux") {
394 "linux"
395 } else if cfg!(target_os = "windows") {
396 "windows"
397 } else {
398 std::env::consts::OS
399 };
400 Ok(VmValue::String(arcstr::ArcStr::from(os)))
401}
402
403#[harn_builtin(sig = "arch() -> string", category = "process")]
404fn arch_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
405 Ok(VmValue::String(arcstr::ArcStr::from(
406 std::env::consts::ARCH,
407 )))
408}
409
410#[harn_builtin(sig = "home_dir() -> string", category = "process")]
411fn home_dir_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
412 let home = crate::user_dirs::home_dir()
413 .map(|home| home.to_string_lossy().into_owned())
414 .unwrap_or_default();
415 Ok(VmValue::String(arcstr::ArcStr::from(home)))
416}
417
418#[harn_builtin(sig = "pid(...args: any) -> int", category = "process")]
419fn pid_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
420 Ok(VmValue::Int(std::process::id() as i64))
421}
422
423#[harn_builtin(sig = "date_iso() -> string", category = "process")]
424fn date_iso_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
425 let now = crate::clock_mock::leak_audit::wall_now("stdlib/date_iso");
432 let dt: chrono::DateTime<chrono::Utc> = now.into();
433 Ok(VmValue::String(arcstr::ArcStr::from(
434 dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
435 )))
436}
437
438#[harn_builtin(sig = "cwd() -> string", category = "process")]
439fn cwd_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
440 let dir = current_execution_context()
441 .and_then(|context| context.cwd)
442 .or_else(|| {
443 std::env::current_dir()
444 .ok()
445 .map(|p| p.to_string_lossy().into_owned())
446 })
447 .unwrap_or_default();
448 Ok(VmValue::String(arcstr::ArcStr::from(dir)))
449}
450
451#[harn_builtin(sig = "execution_root() -> string", category = "process")]
452fn execution_root_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
453 Ok(VmValue::String(arcstr::ArcStr::from(
454 execution_root_path().to_string_lossy().into_owned(),
455 )))
456}
457
458#[harn_builtin(sig = "asset_root() -> string", category = "process")]
459fn asset_root_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
460 Ok(VmValue::String(arcstr::ArcStr::from(
461 asset_root_path().to_string_lossy().into_owned(),
462 )))
463}
464
465#[harn_builtin(
479 sig = "runtime_paths() -> {execution_root: string, asset_root: string, state_root: string, run_root: string, worktree_root: string}",
480 category = "process"
481)]
482fn runtime_paths_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
483 let runtime_base = runtime_root_base();
484 let mut paths = BTreeMap::new();
485 paths.put_str("execution_root", execution_root_path().to_string_lossy());
486 paths.put_str("asset_root", asset_root_path().to_string_lossy());
487 paths.put_str(
488 "state_root",
489 crate::runtime_paths::state_root(&runtime_base).to_string_lossy(),
490 );
491 paths.put_str(
492 "run_root",
493 crate::runtime_paths::run_root(&runtime_base).to_string_lossy(),
494 );
495 paths.put_str(
496 "worktree_root",
497 crate::runtime_paths::worktree_root(&runtime_base).to_string_lossy(),
498 );
499 Ok(VmValue::dict(paths))
500}
501
502#[harn_builtin(sig = "spawn_captured(opts: dict) -> dict", category = "process")]
503fn spawn_captured_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
504 spawn_captured_value(args)
505}
506
507#[harn_builtin(sig = "term_width() -> int", category = "process")]
517fn term_width_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
518 Ok(VmValue::Int(crate::term::width() as i64))
519}
520
521#[harn_builtin(sig = "term_height() -> int", category = "process")]
522fn term_height_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
523 Ok(VmValue::Int(crate::term::height() as i64))
524}
525
526const PROCESS_BUILTINS: &[&VmBuiltinDef] = &[
527 &ENV_IMPL_DEF,
528 &ENV_OR_IMPL_DEF,
529 &EXIT_IMPL_DEF,
530 &EXEC_IMPL_DEF,
531 &EXEC_OPTS_IMPL_DEF,
532 &SHELL_IMPL_DEF,
533 &EXEC_AT_IMPL_DEF,
534 &EXEC_AT_OPTS_IMPL_DEF,
535 &SHELL_AT_IMPL_DEF,
536 &USERNAME_IMPL_DEF,
537 &HOSTNAME_IMPL_DEF,
538 &PLATFORM_IMPL_DEF,
539 &ARCH_IMPL_DEF,
540 &HOME_DIR_IMPL_DEF,
541 &PID_IMPL_DEF,
542 &DATE_ISO_IMPL_DEF,
543 &CWD_IMPL_DEF,
544 &EXECUTION_ROOT_IMPL_DEF,
545 &ASSET_ROOT_IMPL_DEF,
546 &RUNTIME_PATHS_IMPL_DEF,
547 &SPAWN_CAPTURED_IMPL_DEF,
548 &TERM_WIDTH_IMPL_DEF,
549 &TERM_HEIGHT_IMPL_DEF,
550];
551
552pub(crate) fn spawn_captured_value(args: &[VmValue]) -> Result<VmValue, VmError> {
557 let opts = match args.first() {
558 Some(VmValue::Dict(opts)) => opts.clone(),
559 _ => {
560 return Err(VmError::Runtime(
561 "spawn_captured: options dict is required".to_string(),
562 ));
563 }
564 };
565 let cmd = match opts.get("cmd").map(|v| v.display()).unwrap_or_default() {
566 s if s.is_empty() => {
567 return Err(VmError::Runtime(
568 "spawn_captured: opts.cmd is required".to_string(),
569 ));
570 }
571 s => s,
572 };
573 let cmd_args: Vec<String> = match opts.get("args") {
574 Some(VmValue::List(items)) => items.iter().map(|v| v.display()).collect(),
575 None | Some(VmValue::Nil) => Vec::new(),
576 Some(other) => {
577 return Err(VmError::Runtime(format!(
578 "spawn_captured: opts.args must be a list of strings, got {}",
579 other.type_name()
580 )));
581 }
582 };
583 let cwd = opts
584 .get("cwd")
585 .map(|v| v.display())
586 .filter(|s| !s.is_empty());
587 let env_overrides: Vec<(String, String)> = match opts.get("env") {
588 Some(VmValue::Dict(env)) => env
589 .iter()
590 .map(|(k, v)| (k.to_string(), v.display()))
591 .collect(),
592 None | Some(VmValue::Nil) => Vec::new(),
593 Some(other) => {
594 return Err(VmError::Runtime(format!(
595 "spawn_captured: opts.env must be a dict, got {}",
596 other.type_name()
597 )));
598 }
599 };
600 let stdin_bytes: Option<Vec<u8>> = match opts.get("stdin") {
601 Some(VmValue::Bytes(bytes)) => Some(bytes.as_slice().to_vec()),
602 Some(VmValue::String(s)) => Some(s.as_bytes().to_vec()),
603 None | Some(VmValue::Nil) => None,
604 Some(other) => {
605 return Err(VmError::Runtime(format!(
606 "spawn_captured: opts.stdin must be string or bytes, got {}",
607 other.type_name()
608 )));
609 }
610 };
611 let timeout = opts
612 .get("timeout_ms")
613 .and_then(|v| v.as_int())
614 .filter(|n| *n > 0)
615 .map(|n| Duration::from_millis(n as u64));
616
617 let spawn = CapturedSpawn {
618 label: "spawn_captured",
619 cmd: &cmd,
620 args: &cmd_args,
621 cwd: cwd.as_deref(),
622 env: &env_overrides,
623 env_clear: false,
627 stdin: stdin_bytes,
628 timeout,
629 };
630 let CapturedRun {
631 output,
632 timed_out,
633 interrupted,
634 duration_ms,
635 } = run_captured_spawn(spawn)?;
636
637 let exit_code = if timed_out || interrupted {
638 -1
639 } else {
640 output.status.code().unwrap_or(-1) as i64
641 };
642 let success = if timed_out || interrupted {
643 false
644 } else {
645 output.status.success()
646 };
647 let mut result = BTreeMap::new();
648 result.insert("exit_code".to_string(), VmValue::Int(exit_code));
649 result.put_str("stdout", String::from_utf8_lossy(&output.stdout).as_ref());
650 result.put_str("stderr", String::from_utf8_lossy(&output.stderr).as_ref());
651 result.insert("duration_ms".to_string(), VmValue::Int(duration_ms));
652 result.insert("success".to_string(), VmValue::Bool(success));
653 result.insert("timed_out".to_string(), VmValue::Bool(timed_out));
654 Ok(VmValue::dict(result))
655}
656
657struct CapturedSpawn<'a> {
662 label: &'static str,
663 cmd: &'a str,
664 args: &'a [String],
665 cwd: Option<&'a str>,
666 env: &'a [(String, String)],
667 env_clear: bool,
668 stdin: Option<Vec<u8>>,
669 timeout: Option<Duration>,
670}
671
672struct CapturedRun {
674 output: std::process::Output,
675 timed_out: bool,
676 interrupted: bool,
677 duration_ms: i64,
678}
679
680fn run_captured_spawn(spec: CapturedSpawn<'_>) -> Result<CapturedRun, VmError> {
692 let label = spec.label;
693 let mut command = std::process::Command::new(spec.cmd);
694 command.args(spec.args);
695 if let Some(cwd) = spec.cwd {
696 command.current_dir(cwd);
697 }
698 let resolved_environment = if spec.env_clear {
704 None
705 } else {
706 session_closed_env_for_command(spec.cmd, spec.env.iter().cloned())?
707 };
708 if spec.env_clear || resolved_environment.is_some() {
709 command.env_clear();
710 }
711 for (key, value) in resolved_environment.as_deref().unwrap_or(spec.env) {
712 command.env(key, value);
713 }
714 command.stdout(Stdio::piped()).stderr(Stdio::piped());
715 if spec.stdin.is_some() {
716 command.stdin(Stdio::piped());
717 } else {
718 command.stdin(Stdio::null());
719 }
720 crate::op_interrupt::configure_kill_group(&mut command);
721 let cleanup_token = crate::op_interrupt::new_process_cleanup_token();
722 command.env(
723 crate::op_interrupt::PROCESS_CLEANUP_TOKEN_ENV,
724 &cleanup_token,
725 );
726
727 let started = Instant::now();
728 let cmd = spec.cmd;
729 let mut child = command.spawn().map_err(|error| {
730 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
731 "{label}: failed to spawn '{cmd}': {error}"
732 ))))
733 })?;
734
735 if let (Some(payload), Some(mut stdin)) = (spec.stdin, child.stdin.take()) {
736 let _ = stdin.write_all(&payload);
738 }
739
740 let rx_out = child
744 .stdout
745 .take()
746 .map(crate::op_interrupt::spawn_pipe_drain);
747 let rx_err = child
748 .stderr
749 .take()
750 .map(crate::op_interrupt::spawn_pipe_drain);
751
752 let child_pid = child.id();
753 let wait_end = crate::op_interrupt::wait_child_interruptible_with_cleanup_token(
754 &mut child,
755 spec.timeout,
756 Some(&cleanup_token),
757 )
758 .map_err(|error| {
759 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
760 "{label}: wait failed: {error}"
761 ))))
762 })?;
763 let (status, timed_out, interrupted, killed) = match wait_end {
764 crate::op_interrupt::ChildWait::Exited(status) => (status, false, false, false),
765 crate::op_interrupt::ChildWait::TimedOut(_) => {
766 (std::process::ExitStatus::default(), true, false, true)
767 }
768 crate::op_interrupt::ChildWait::Interrupted(status, _) => {
772 (status.unwrap_or_default(), false, true, true)
773 }
774 };
775
776 let stdout = rx_out
777 .map(|rx| crate::op_interrupt::drain_captured_pipe(&rx, killed, child_pid))
778 .unwrap_or_default();
779 let stderr = rx_err
780 .map(|rx| crate::op_interrupt::drain_captured_pipe(&rx, killed, child_pid))
781 .unwrap_or_default();
782
783 Ok(CapturedRun {
784 output: std::process::Output {
785 status,
786 stdout,
787 stderr,
788 },
789 timed_out,
790 interrupted,
791 duration_ms: started.elapsed().as_millis() as i64,
792 })
793}
794
795#[derive(Default)]
798struct ExecOptions {
799 env: Vec<(String, String)>,
800 env_clear: bool,
801 cwd: Option<String>,
802 timeout: Option<Duration>,
803}
804
805fn exec_options(label: &str, options: Option<&VmValue>) -> Result<ExecOptions, VmError> {
814 let opts = match options {
815 None | Some(VmValue::Nil) => return Ok(ExecOptions::default()),
816 Some(VmValue::Dict(opts)) => opts.clone(),
817 Some(other) => {
818 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
819 format!("{label}: options must be a dict, got {}", other.type_name()),
820 ))));
821 }
822 };
823 let env: Vec<(String, String)> = match opts.get("env") {
824 Some(VmValue::Dict(env)) => env
825 .iter()
826 .map(|(k, v)| (k.to_string(), v.display()))
827 .collect(),
828 None | Some(VmValue::Nil) => Vec::new(),
829 Some(other) => {
830 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
831 format!(
832 "{label}: options.env must be a dict, got {}",
833 other.type_name()
834 ),
835 ))));
836 }
837 };
838 let env_clear = match opts.get("env_mode").map(|v| v.display()).as_deref() {
839 None | Some("merge") => false,
840 Some("replace") => true,
841 Some(other) => {
842 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
843 format!(
844 "{label}: options.env_mode must be \"merge\" or \"replace\", got {other:?}"
845 ),
846 ))));
847 }
848 };
849 let cwd = opts
850 .get("cwd")
851 .map(|v| v.display())
852 .filter(|s| !s.is_empty());
853 let timeout = opts
856 .get("timeout")
857 .or_else(|| opts.get("timeout_ms"))
858 .and_then(|v| v.as_int())
859 .filter(|n| *n > 0)
860 .map(|n| Duration::from_millis(n as u64));
861 Ok(ExecOptions {
862 env,
863 env_clear,
864 cwd,
865 timeout,
866 })
867}
868
869fn captured_run_to_value(run: &CapturedRun) -> VmValue {
873 let status = if run.timed_out || run.interrupted {
874 -1
875 } else {
876 run.output.status.code().unwrap_or(-1) as i64
877 };
878 let success = !run.timed_out && !run.interrupted && run.output.status.success();
879 let mut result = BTreeMap::new();
880 result.put_str(
881 "stdout",
882 String::from_utf8_lossy(&run.output.stdout).as_ref(),
883 );
884 result.put_str(
885 "stderr",
886 String::from_utf8_lossy(&run.output.stderr).as_ref(),
887 );
888 result.insert("status".to_string(), VmValue::Int(status));
889 result.insert("success".to_string(), VmValue::Bool(success));
890 result.insert("timed_out".to_string(), VmValue::Bool(run.timed_out));
891 result.insert("duration_ms".to_string(), VmValue::Int(run.duration_ms));
892 VmValue::dict(result)
893}
894
895#[harn_builtin(
896 sig = "exec_opts(command: list, options: dict?) -> dict",
897 category = "process"
898)]
899fn exec_opts_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
900 let command = exec_opts_command("exec_opts", args.first())?;
901 let opts = exec_options("exec_opts", args.get(1))?;
902 let run = run_captured_spawn(CapturedSpawn {
903 label: "exec_opts",
904 cmd: &command[0],
905 args: &command[1..],
906 cwd: opts.cwd.as_deref(),
907 env: &opts.env,
908 env_clear: opts.env_clear,
909 stdin: None,
910 timeout: opts.timeout,
911 })?;
912 Ok(captured_run_to_value(&run))
913}
914
915#[harn_builtin(
916 sig = "exec_at_opts(dir: string, command: list, options: dict?) -> dict",
917 category = "process"
918)]
919fn exec_at_opts_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
920 let dir = match args.first() {
921 Some(value) if !value.display().is_empty() => value.display(),
922 _ => {
923 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
924 "exec_at_opts: directory is required",
925 ))));
926 }
927 };
928 let command = exec_opts_command("exec_at_opts", args.get(1))?;
929 let opts = exec_options("exec_at_opts", args.get(2))?;
930 let resolved_cwd = opts.cwd.unwrap_or(dir);
933 let run = run_captured_spawn(CapturedSpawn {
934 label: "exec_at_opts",
935 cmd: &command[0],
936 args: &command[1..],
937 cwd: Some(resolved_cwd.as_str()),
938 env: &opts.env,
939 env_clear: opts.env_clear,
940 stdin: None,
941 timeout: opts.timeout,
942 })?;
943 Ok(captured_run_to_value(&run))
944}
945
946fn exec_opts_command(label: &str, value: Option<&VmValue>) -> Result<Vec<String>, VmError> {
949 let items = match value {
950 Some(VmValue::List(items)) => items,
951 _ => {
952 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
953 format!("{label}: command must be a non-empty list of strings"),
954 ))));
955 }
956 };
957 let command: Vec<String> = items.iter().map(|v| v.display()).collect();
958 if command.is_empty() || command[0].is_empty() {
959 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
960 format!("{label}: command must be a non-empty list of strings"),
961 ))));
962 }
963 Ok(command)
964}
965
966pub fn find_project_root(base: &std::path::Path) -> Option<std::path::PathBuf> {
970 harn_modules::manifest_walk::find_project_root(base)
971}
972
973pub(crate) fn register_path_builtins(vm: &mut Vm) {
975 for def in PATH_BUILTINS {
976 vm.register_builtin_def(def);
977 }
978}
979
980#[harn_builtin(sig = "source_dir(...args: any) -> string", category = "process")]
981fn source_dir_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
982 let dir = VM_SOURCE_DIR.with(|sd| sd.borrow().clone());
983 match dir {
984 Some(d) => Ok(VmValue::String(arcstr::ArcStr::from(
985 d.to_string_lossy().into_owned(),
986 ))),
987 None => {
988 let cwd = std::env::current_dir()
989 .map(|p| p.to_string_lossy().into_owned())
990 .unwrap_or_default();
991 Ok(VmValue::String(arcstr::ArcStr::from(cwd)))
992 }
993 }
994}
995
996#[harn_builtin(sig = "project_root() -> string?", category = "process")]
997fn project_root_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
998 if let Some(root) = project_root_path() {
999 return Ok(VmValue::String(arcstr::ArcStr::from(
1000 root.to_string_lossy().as_ref(),
1001 )));
1002 }
1003 let base = current_execution_context()
1004 .and_then(|context| context.cwd.map(PathBuf::from))
1005 .or_else(|| VM_SOURCE_DIR.with(|sd| sd.borrow().clone()))
1006 .or_else(|| std::env::current_dir().ok())
1007 .unwrap_or_else(|| PathBuf::from("."));
1008 match find_project_root(&base) {
1009 Some(root) => Ok(VmValue::String(arcstr::ArcStr::from(
1010 root.to_string_lossy().into_owned(),
1011 ))),
1012 None => Ok(VmValue::Nil),
1013 }
1014}
1015
1016const PATH_BUILTINS: &[&VmBuiltinDef] = &[&SOURCE_DIR_IMPL_DEF, &PROJECT_ROOT_IMPL_DEF];
1017
1018fn vm_output_to_value(output: std::process::Output) -> VmValue {
1019 let mut result = BTreeMap::new();
1020 result.put_str("stdout", String::from_utf8_lossy(&output.stdout).as_ref());
1021 result.put_str("stderr", String::from_utf8_lossy(&output.stderr).as_ref());
1022 result.insert(
1023 "status".to_string(),
1024 VmValue::Int(output.status.code().unwrap_or(-1) as i64),
1025 );
1026 result.insert(
1027 "success".to_string(),
1028 VmValue::Bool(output.status.success()),
1029 );
1030 VmValue::dict(result)
1031}
1032
1033fn exec_command(
1034 dir: Option<&str>,
1035 cmd: &str,
1036 args: &[String],
1037) -> Result<std::process::Output, VmError> {
1038 let config = process_command_config(dir)?;
1039 crate::stdlib::sandbox::command_output(cmd, args, &config)
1040 .map_err(|error| prefix_process_error(error, "exec"))
1041}
1042
1043fn exec_shell_args(
1044 dir: Option<&str>,
1045 shell: &str,
1046 args: &[String],
1047) -> Result<std::process::Output, VmError> {
1048 let config = process_command_config(dir)?;
1049 crate::stdlib::sandbox::command_output(shell, args, &config)
1050 .map_err(|error| prefix_process_error(error, "shell"))
1051}
1052
1053fn process_command_config(
1054 dir: Option<&str>,
1055) -> Result<crate::stdlib::sandbox::ProcessCommandConfig, VmError> {
1056 let mut config = crate::stdlib::sandbox::ProcessCommandConfig {
1057 stdin_null: true,
1058 ..Default::default()
1059 };
1060 if let Some(dir) = dir {
1061 let resolved = resolve_command_dir(dir);
1062 crate::stdlib::sandbox::enforce_process_cwd(&resolved)?;
1063 config.cwd = Some(resolved);
1064 } else if let Some(context) = current_execution_context() {
1065 if let Some(cwd) = context.cwd.filter(|cwd| !cwd.is_empty()) {
1066 crate::stdlib::sandbox::enforce_process_cwd(std::path::Path::new(&cwd))?;
1067 config.cwd = Some(std::path::PathBuf::from(cwd));
1068 }
1069 if !context.env.is_empty() {
1070 config.env.extend(context.env);
1071 }
1072 }
1073 if let Some(value) = env_override(HARN_REPLAY_ENV) {
1074 config.env.push((HARN_REPLAY_ENV.to_string(), value));
1075 }
1076 if let Some(env) = session_closed_env(config.env.iter().cloned())? {
1080 config.env = env;
1081 config.closed_env = true;
1082 }
1083 Ok(config)
1084}
1085
1086pub(crate) fn session_closed_env(
1106 overlay: impl Iterator<Item = (String, String)>,
1107) -> Result<Option<Vec<(String, String)>>, VmError> {
1108 let Some(mut env) = session_env()? else {
1109 return Ok(None);
1110 };
1111 env.extend(overlay);
1112 Ok(Some(env.into_iter().collect()))
1113}
1114
1115pub(crate) fn session_closed_env_for_command(
1120 program: &str,
1121 overlay: impl Iterator<Item = (String, String)>,
1122) -> Result<Option<Vec<(String, String)>>, VmError> {
1123 let Some(mut env) = session_env_for_command(program)? else {
1124 return Ok(None);
1125 };
1126 env.extend(overlay);
1127 Ok(Some(env.into_iter().collect()))
1128}
1129
1130pub(crate) fn session_env() -> Result<Option<BTreeMap<String, String>>, VmError> {
1134 session_env_with(
1135 |grant| grant.for_command().is_none(),
1136 |environment, lookup| {
1137 crate::security::resolve_env(environment, lookup, &resolve_grant_secret)
1138 },
1139 )
1140}
1141
1142pub(crate) fn session_env_for_command(
1145 program: &str,
1146) -> Result<Option<BTreeMap<String, String>>, VmError> {
1147 let basename = crate::security::command_basename(program).to_string();
1148 session_env_with(
1149 move |grant| match grant.for_command() {
1150 None => true,
1151 Some(expected) => expected == basename,
1152 },
1153 |environment, lookup| {
1154 crate::security::resolve_env_for_command(
1155 environment,
1156 program,
1157 lookup,
1158 &resolve_grant_secret,
1159 )
1160 },
1161 )
1162}
1163
1164fn session_env_with(
1165 grant_owns_key: impl Fn(&crate::security::SessionGrant) -> bool,
1166 resolve: impl FnOnce(
1167 &crate::security::SessionEnvironment,
1168 &dyn Fn(&str) -> Option<String>,
1169 )
1170 -> Result<BTreeMap<String, String>, crate::security::EnvironmentPolicyError>,
1171) -> Result<Option<BTreeMap<String, String>>, VmError> {
1172 let Some(environment) = current_session_environment() else {
1173 return Ok(None);
1174 };
1175 let workspace_defaults = workspace_env_defaults();
1176 let mut env =
1177 resolve(&environment, &session_env_lookup(&workspace_defaults)).map_err(grant_env_error)?;
1178 for (key, value) in workspace_defaults {
1181 let grant_owns = environment
1182 .grants()
1183 .iter()
1184 .any(|grant| grant.exposed_env_var() == Some(key.as_str()) && grant_owns_key(grant));
1185 if !grant_owns {
1186 env.insert(key, value);
1187 }
1188 }
1189 Ok(Some(env))
1190}
1191
1192pub(crate) fn session_env_var(name: &str) -> Result<Option<String>, VmError> {
1207 let Some(environment) = current_session_environment() else {
1208 return Ok(std::env::var(name).ok());
1209 };
1210 let workspace_defaults = workspace_env_defaults();
1211 let is_grant_target = environment
1212 .grants()
1213 .iter()
1214 .any(|grant| grant.exposed_env_var() == Some(name));
1215 if !is_grant_target {
1216 if let Some(value) = workspace_defaults.get(name) {
1217 return Ok(Some(value.clone()));
1218 }
1219 }
1220 let resolved = crate::security::lookup_env(
1221 &environment,
1222 name,
1223 &session_env_lookup(&workspace_defaults),
1224 &resolve_grant_secret,
1225 )
1226 .map_err(grant_env_error)?;
1227 Ok(resolved)
1228}
1229
1230pub(crate) fn session_env_value(name: &str) -> Option<String> {
1234 if current_session_environment().is_none() {
1235 return crate::test_env::env_var_seamed(name);
1236 }
1237 session_env_var(name).ok().flatten()
1238}
1239
1240fn workspace_env_defaults() -> BTreeMap<String, String> {
1245 crate::process_sandbox::active_workspace_process_env()
1246 .into_iter()
1247 .collect()
1248}
1249
1250fn session_env_lookup(
1253 workspace_defaults: &BTreeMap<String, String>,
1254) -> impl Fn(&str) -> Option<String> + '_ {
1255 |name: &str| {
1256 workspace_defaults
1257 .get(name)
1258 .cloned()
1259 .or_else(|| std::env::var(name).ok())
1260 }
1261}
1262
1263fn grant_env_error(error: crate::security::EnvironmentPolicyError) -> VmError {
1264 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
1265 "session grant env resolution failed: {error}"
1266 ))))
1267}
1268
1269fn resolve_grant_secret(account: &str, key: &str) -> Option<String> {
1276 let reference = format!("{}{}/{}", crate::secrets::SECRET_REF_SCHEME, account, key);
1277 crate::secrets::resolve_secret_ref_to_string(&reference)
1278 .ok()
1279 .flatten()
1280}
1281
1282fn prefix_process_error(error: VmError, prefix: &str) -> VmError {
1283 match error {
1284 VmError::Thrown(VmValue::String(message)) => VmError::Thrown(VmValue::String(
1285 arcstr::ArcStr::from(format!("{prefix} failed: {message}")),
1286 )),
1287 VmError::Thrown(VmValue::Dict(fields))
1288 if matches!(
1289 fields.get("error"),
1290 Some(VmValue::String(family)) if family.as_str() == "io_error"
1291 ) =>
1292 {
1293 let mut prefixed = (*fields).clone();
1294 if let Some(VmValue::String(message)) = fields.get("message") {
1295 prefixed.put_str("message", format!("{prefix} failed: {message}"));
1296 }
1297 VmError::Thrown(VmValue::dict(prefixed))
1298 }
1299 other => other,
1300 }
1301}
1302
1303fn resolve_command_dir(dir: &str) -> PathBuf {
1304 let candidate = PathBuf::from(dir);
1305 if candidate.is_absolute() {
1306 return candidate;
1307 }
1308 if let Some(cwd) = current_execution_context().and_then(|context| context.cwd) {
1309 return PathBuf::from(cwd).join(candidate);
1310 }
1311 if let Some(source_dir) = VM_SOURCE_DIR.with(|sd| sd.borrow().clone()) {
1312 return source_dir.join(candidate);
1313 }
1314 candidate
1315}
1316
1317#[cfg(test)]
1318#[path = "process_tests.rs"]
1319mod tests;