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) fn enter_frame_source_dir(module_dir: Option<&std::path::Path>) -> Option<PathBuf> {
112 let dir = module_dir?;
113 let previous = VM_SOURCE_DIR.with(|sd| sd.borrow().clone());
114 crate::stdlib::set_thread_source_dir(dir);
115 previous
116}
117
118pub(crate) struct SourceDirGuard {
131 previous: Option<PathBuf>,
132}
133
134impl SourceDirGuard {
135 pub(crate) fn capture() -> Self {
137 Self {
138 previous: VM_SOURCE_DIR.with(|sd| sd.borrow().clone()),
139 }
140 }
141}
142
143impl Drop for SourceDirGuard {
144 fn drop(&mut self) {
145 let previous = self.previous.take();
146 VM_SOURCE_DIR.with(|sd| *sd.borrow_mut() = previous);
147 }
148}
149
150pub(crate) fn reset_process_state() {
152 VM_SOURCE_DIR.with(|sd| *sd.borrow_mut() = None);
153 VM_EXECUTION_CONTEXT.with(|current| *current.borrow_mut() = None);
154}
155
156pub fn execution_root_path() -> PathBuf {
157 current_execution_context()
158 .and_then(|context| context.cwd.map(PathBuf::from))
159 .or_else(|| std::env::current_dir().ok())
160 .unwrap_or_else(|| PathBuf::from("."))
161}
162
163pub(crate) fn child_process_cwd(path: std::path::PathBuf) -> std::path::PathBuf {
179 match path.to_str().and_then(strip_windows_verbatim_prefix) {
180 Some(stripped) => std::path::PathBuf::from(stripped),
181 None => path,
182 }
183}
184
185#[expect(
200 clippy::string_slice,
201 reason = "the 4-byte prefix just matched is pure ASCII, so byte offset 4 is a guaranteed char boundary"
202)]
203fn strip_windows_verbatim_prefix(path: &str) -> Option<&str> {
204 let bytes = path.as_bytes();
205 let is_slash = |byte: u8| byte == b'\\' || byte == b'/';
206 if bytes.len() < 4
207 || !is_slash(bytes[0])
208 || !is_slash(bytes[1])
209 || bytes[2] != b'?'
210 || !is_slash(bytes[3])
211 {
212 return None;
213 }
214 let rest = &path[4..];
217 let mut chars = rest.chars();
218 if !chars.next()?.is_ascii_alphabetic() {
221 return None;
222 }
223 if chars.next()? != ':' {
224 return None;
225 }
226 match chars.next() {
227 Some('\\') | Some('/') | None => Some(rest),
228 Some(_) => None,
229 }
230}
231
232pub fn inherited_process_cwd() -> Result<PathBuf, VmError> {
239 if let Some((policy, _profile)) = crate::stdlib::sandbox::active_sandbox_policy() {
240 let preferred = current_execution_context()
241 .and_then(|context| context.cwd)
242 .filter(|cwd| !cwd.is_empty())
243 .map(PathBuf::from);
244 crate::stdlib::sandbox::policy_process_cwd(&policy, preferred.as_deref())
245 } else {
246 Ok(execution_root_path())
247 }
248}
249
250pub fn project_root_path() -> Option<PathBuf> {
251 current_execution_context().and_then(|context| {
252 let project_root = context.project_root?;
253 if project_root.trim().is_empty() {
254 return None;
255 }
256 let path = PathBuf::from(project_root);
257 if path.is_absolute() {
258 Some(path)
259 } else if let Some(cwd) = context.cwd {
260 Some(PathBuf::from(cwd).join(path))
261 } else {
262 Some(normalize_context_path(&path))
263 }
264 })
265}
266
267pub fn source_root_path() -> PathBuf {
268 VM_SOURCE_DIR
269 .with(|sd| sd.borrow().clone())
270 .or_else(|| {
271 current_execution_context().and_then(|context| context.source_dir.map(PathBuf::from))
272 })
273 .or_else(|| current_execution_context().and_then(|context| context.cwd.map(PathBuf::from)))
274 .or_else(|| std::env::current_dir().ok())
275 .unwrap_or_else(|| PathBuf::from("."))
276}
277
278pub fn asset_root_path() -> PathBuf {
279 source_root_path()
280}
281
282fn env_override(name: &str) -> Option<String> {
283 (name == HARN_REPLAY_ENV && crate::triggers::dispatcher::current_dispatch_is_replay())
284 .then(|| "1".to_string())
285}
286
287pub(crate) fn runtime_child_env_overlay() -> Vec<(String, String)> {
294 env_override(HARN_REPLAY_ENV)
295 .map(|value| (HARN_REPLAY_ENV.to_string(), value))
296 .into_iter()
297 .collect()
298}
299
300pub(crate) fn read_env_value(name: &str) -> Option<String> {
301 env_override(name)
302 .or_else(|| current_execution_context().and_then(|context| context.env.get(name).cloned()))
303 .or_else(|| session_env_var(name).ok().flatten())
304}
305
306pub fn runtime_root_base() -> PathBuf {
307 project_root_path()
308 .or_else(|| find_project_root(&execution_root_path()))
309 .or_else(|| find_project_root(&source_root_path()))
310 .unwrap_or_else(source_root_path)
311}
312
313fn lexically_collapse(path: &std::path::Path) -> Option<PathBuf> {
318 use std::path::Component;
319 let mut out: Vec<Component> = Vec::new();
320 for component in path.components() {
321 match component {
322 Component::CurDir => {}
323 Component::ParentDir => {
324 let popped = out.pop();
325 if !matches!(popped, Some(Component::Normal(_))) {
326 return None;
327 }
328 }
329 other => out.push(other),
330 }
331 }
332 Some(out.iter().collect())
333}
334
335pub fn resolve_source_relative_path(path: &str) -> PathBuf {
336 let candidate = PathBuf::from(path);
337 if candidate.is_absolute() {
338 return candidate;
339 }
340 let root = execution_root_path();
341 let joined = root.join(&candidate);
342 if path_escapes_project_root(&joined) {
349 return root.join("__harn_rejected_parent_dir_traversal__");
350 }
351 joined
352}
353
354pub fn resolve_source_asset_path(path: &str) -> PathBuf {
355 let candidate = PathBuf::from(path);
356 if candidate.is_absolute() {
357 return candidate;
358 }
359 let root = asset_root_path();
360 let joined = root.join(&candidate);
361 if path_escapes_project_root(&joined) {
362 return root.join("__harn_rejected_parent_dir_traversal__");
363 }
364 joined
365}
366
367fn path_escapes_project_root(joined: &std::path::Path) -> bool {
381 lexically_collapse(joined).is_none()
382}
383
384pub(crate) fn register_process_builtins(vm: &mut Vm) {
385 for def in PROCESS_BUILTINS {
386 vm.register_builtin_def(def);
387 }
388}
389
390#[harn_builtin(
391 exposure = "runtime_internal",
392 effects = [],
393 sig = "env(name: string) -> string?", category = "process"
394)]
395fn env_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
396 let name = args.first().map(|a| a.display()).unwrap_or_default();
397 if let Some(value) = read_env_value(&name) {
398 return Ok(VmValue::String(arcstr::ArcStr::from(value)));
399 }
400 Ok(VmValue::Nil)
401}
402
403#[harn_builtin(
404 exposure = "runtime_internal",
405 effects = [],
406 sig = "env_or(name: string, default: any) -> any",
407 category = "process"
408)]
409fn env_or_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
410 let name = args.first().map(|a| a.display()).unwrap_or_default();
411 let default = args.get(1).cloned().unwrap_or(VmValue::Nil);
412 if let Some(value) = read_env_value(&name) {
413 return Ok(VmValue::String(arcstr::ArcStr::from(value)));
414 }
415 Ok(default)
416}
417
418#[harn_builtin(
419 exposure = "runtime_internal",
420 effects = [],
421 sig = "exit(code?: int) -> never", category = "process"
422)]
423fn exit_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
424 let code = args.first().and_then(|a| a.as_int()).unwrap_or(0);
425 Err(VmError::ProcessExit(code as i32))
426}
427
428#[harn_builtin(
429 exposure = "runtime_internal",
430 effects = [],
431 sig = "exec(...command: string) -> dict", category = "process"
432)]
433fn exec_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
434 if args.is_empty() {
435 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
436 "exec: command is required",
437 ))));
438 }
439 let cmd = args[0].display();
440 let cmd_args: Vec<String> = args[1..].iter().map(|a| a.display()).collect();
441 let output = exec_command(None, &cmd, &cmd_args)?;
442 Ok(vm_output_to_value(output))
443}
444
445#[harn_builtin(
446 exposure = "runtime_internal",
447 effects = [],
448 sig = "shell(command: string) -> dict", category = "process"
449)]
450fn shell_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
451 let cmd = args.first().map(|a| a.display()).unwrap_or_default();
452 if cmd.is_empty() {
453 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
454 "shell: command string is required",
455 ))));
456 }
457 let invocation = crate::shells::default_shell_invocation(&cmd)
458 .map_err(|error| VmError::Runtime(format!("shell: {error}")))?;
459 let output = exec_shell_args(None, &invocation.program, &invocation.args)?;
460 Ok(vm_output_to_value(output))
461}
462
463#[harn_builtin(
464 exposure = "runtime_internal",
465 effects = [],
466 sig = "exec_at(dir: string, ...command: string) -> dict",
467 category = "process"
468)]
469fn exec_at_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
470 if args.len() < 2 {
471 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
472 "exec_at: directory and command are required",
473 ))));
474 }
475 let dir = args[0].display();
476 let cmd = args[1].display();
477 let cmd_args: Vec<String> = args[2..].iter().map(|a| a.display()).collect();
478 let output = exec_command(Some(dir.as_str()), &cmd, &cmd_args)?;
479 Ok(vm_output_to_value(output))
480}
481
482#[harn_builtin(
483 exposure = "runtime_internal",
484 effects = [],
485 sig = "shell_at(dir: string, command: string) -> dict",
486 category = "process"
487)]
488fn shell_at_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
489 if args.len() < 2 {
490 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
491 "shell_at: directory and command string are required",
492 ))));
493 }
494 let dir = args[0].display();
495 let cmd = args[1].display();
496 if cmd.is_empty() {
497 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
498 "shell_at: command string is required",
499 ))));
500 }
501 let invocation = crate::shells::default_shell_invocation(&cmd)
502 .map_err(|error| VmError::Runtime(format!("shell_at: {error}")))?;
503 let output = exec_shell_args(Some(dir.as_str()), &invocation.program, &invocation.args)?;
504 Ok(vm_output_to_value(output))
505}
506
507#[harn_builtin(
508 exposure = "runtime_internal",
509 effects = [],
510 sig = "username(...args: any) -> string", category = "process"
511)]
512fn username_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
513 let user = std::env::var("USER")
514 .or_else(|_| std::env::var("USERNAME"))
515 .unwrap_or_default();
516 Ok(VmValue::String(arcstr::ArcStr::from(user)))
517}
518
519#[harn_builtin(
520 exposure = "runtime_internal",
521 effects = [],
522 sig = "hostname() -> string", category = "process"
523)]
524fn hostname_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
525 let name = std::env::var("HOSTNAME")
526 .or_else(|_| std::env::var("COMPUTERNAME"))
527 .or_else(|_| {
528 std::process::Command::new("hostname")
529 .output()
530 .ok()
531 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
532 .ok_or(std::env::VarError::NotPresent)
533 })
534 .unwrap_or_default();
535 Ok(VmValue::String(arcstr::ArcStr::from(name)))
536}
537
538#[harn_builtin(
539 exposure = "runtime_internal",
540 effects = [],
541 sig = "platform(...args: any) -> string", category = "process"
542)]
543fn platform_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
544 let os = if cfg!(target_os = "macos") {
545 "darwin"
546 } else if cfg!(target_os = "linux") {
547 "linux"
548 } else if cfg!(target_os = "windows") {
549 "windows"
550 } else {
551 std::env::consts::OS
552 };
553 Ok(VmValue::String(arcstr::ArcStr::from(os)))
554}
555
556#[harn_builtin(
557 exposure = "runtime_internal",
558 effects = [],
559 sig = "arch() -> string", category = "process"
560)]
561fn arch_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
562 Ok(VmValue::String(arcstr::ArcStr::from(
563 std::env::consts::ARCH,
564 )))
565}
566
567#[harn_builtin(
568 exposure = "runtime_internal",
569 effects = [],
570 sig = "home_dir() -> string", category = "process"
571)]
572fn home_dir_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
573 let home = crate::user_dirs::home_dir()
574 .map(|home| home.to_string_lossy().into_owned())
575 .unwrap_or_default();
576 Ok(VmValue::String(arcstr::ArcStr::from(home)))
577}
578
579#[harn_builtin(
580 exposure = "runtime_internal",
581 effects = [],
582 sig = "pid(...args: any) -> int", category = "process"
583)]
584fn pid_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
585 Ok(VmValue::Int(std::process::id() as i64))
586}
587
588#[harn_builtin(
589 exposure = "runtime_internal",
590 effects = [],
591 sig = "date_iso() -> string", category = "process"
592)]
593fn date_iso_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
594 let now = crate::clock_mock::leak_audit::wall_now("stdlib/date_iso");
601 let dt: chrono::DateTime<chrono::Utc> = now.into();
602 Ok(VmValue::String(arcstr::ArcStr::from(
603 dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
604 )))
605}
606
607#[harn_builtin(
608 exposure = "runtime_internal",
609 effects = [],
610 sig = "cwd() -> string", category = "process"
611)]
612fn cwd_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
613 let dir = current_execution_context()
614 .and_then(|context| context.cwd)
615 .or_else(|| {
616 std::env::current_dir()
617 .ok()
618 .map(|p| p.to_string_lossy().into_owned())
619 })
620 .unwrap_or_default();
621 Ok(VmValue::String(arcstr::ArcStr::from(dir)))
622}
623
624#[harn_builtin(
625 exposure = "runtime_internal",
626 effects = [],
627 sig = "execution_root() -> string", category = "process"
628)]
629fn execution_root_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
630 Ok(VmValue::String(arcstr::ArcStr::from(
631 execution_root_path().to_string_lossy().into_owned(),
632 )))
633}
634
635#[harn_builtin(
636 exposure = "runtime_internal",
637 effects = [],
638 sig = "asset_root() -> string", category = "process"
639)]
640fn asset_root_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
641 Ok(VmValue::String(arcstr::ArcStr::from(
642 asset_root_path().to_string_lossy().into_owned(),
643 )))
644}
645
646#[harn_builtin(
660 exposure = "runtime_internal",
661 effects = [],
662 sig = "runtime_paths() -> {execution_root: string, asset_root: string, state_root: string, run_root: string, worktree_root: string}",
663 category = "process"
664)]
665fn runtime_paths_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
666 let runtime_base = runtime_root_base();
667 let mut paths = BTreeMap::new();
668 paths.put_str("execution_root", execution_root_path().to_string_lossy());
669 paths.put_str("asset_root", asset_root_path().to_string_lossy());
670 paths.put_str(
671 "state_root",
672 crate::runtime_paths::state_root(&runtime_base).to_string_lossy(),
673 );
674 paths.put_str(
675 "run_root",
676 crate::runtime_paths::run_root(&runtime_base).to_string_lossy(),
677 );
678 paths.put_str(
679 "worktree_root",
680 crate::runtime_paths::worktree_root(&runtime_base).to_string_lossy(),
681 );
682 Ok(VmValue::dict(paths))
683}
684
685#[harn_builtin(
695 exposure = "runtime_internal",
696 effects = [],
697 sig = "term_width() -> int", category = "process"
698)]
699fn term_width_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
700 Ok(VmValue::Int(crate::term::width() as i64))
701}
702
703#[harn_builtin(
704 exposure = "runtime_internal",
705 effects = [],
706 sig = "term_height() -> int", category = "process"
707)]
708fn term_height_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
709 Ok(VmValue::Int(crate::term::height() as i64))
710}
711
712const PROCESS_BUILTINS: &[&VmBuiltinDef] = &[
713 &ENV_IMPL_DEF,
714 &ENV_OR_IMPL_DEF,
715 &EXIT_IMPL_DEF,
716 &EXEC_IMPL_DEF,
717 &EXEC_OPTS_IMPL_DEF,
718 &SHELL_IMPL_DEF,
719 &EXEC_AT_IMPL_DEF,
720 &EXEC_AT_OPTS_IMPL_DEF,
721 &SHELL_AT_IMPL_DEF,
722 &USERNAME_IMPL_DEF,
723 &HOSTNAME_IMPL_DEF,
724 &PLATFORM_IMPL_DEF,
725 &ARCH_IMPL_DEF,
726 &HOME_DIR_IMPL_DEF,
727 &PID_IMPL_DEF,
728 &DATE_ISO_IMPL_DEF,
729 &CWD_IMPL_DEF,
730 &EXECUTION_ROOT_IMPL_DEF,
731 &ASSET_ROOT_IMPL_DEF,
732 &RUNTIME_PATHS_IMPL_DEF,
733 &TERM_WIDTH_IMPL_DEF,
734 &TERM_HEIGHT_IMPL_DEF,
735];
736
737struct CapturedSpawn<'a> {
742 label: &'static str,
743 cmd: &'a str,
744 args: &'a [String],
745 cwd: Option<&'a str>,
746 env: &'a [(String, String)],
747 env_clear: bool,
748 stdin: Option<Vec<u8>>,
749 timeout: Option<Duration>,
750}
751
752struct CapturedRun {
754 output: std::process::Output,
755 timed_out: bool,
756 interrupted: bool,
757 duration_ms: i64,
758}
759
760#[path = "process_program_resolution.rs"]
761mod program_resolution;
762pub(crate) use program_resolution::{resolve_program_path, resolve_program_path_for_spawn};
763
764fn run_captured_spawn(spec: CapturedSpawn<'_>) -> Result<CapturedRun, VmError> {
776 let label = spec.label;
777 let resolved_environment = if spec.env_clear {
783 None
784 } else {
785 session_closed_env_for_command(spec.cmd, spec.env.iter().cloned())?
786 };
787 let resolved_cmd =
790 resolve_program_path(spec.cmd, &resolved_environment, spec.env_clear, spec.env);
791 let mut command = std::process::Command::new(&resolved_cmd);
792 command.args(spec.args);
793 if let Some(cwd) = spec.cwd {
794 command.current_dir(child_process_cwd(PathBuf::from(cwd)));
801 }
802 if spec.env_clear || resolved_environment.is_some() {
803 command.env_clear();
804 }
805 for (key, value) in resolved_environment.as_deref().unwrap_or(spec.env) {
806 command.env(key, value);
807 }
808 command.stdout(Stdio::piped()).stderr(Stdio::piped());
809 if spec.stdin.is_some() {
810 command.stdin(Stdio::piped());
811 } else {
812 command.stdin(Stdio::null());
813 }
814 crate::op_interrupt::configure_kill_group(&mut command);
815 let cleanup_token = crate::op_interrupt::new_process_cleanup_token();
816 command.env(
817 crate::op_interrupt::PROCESS_CLEANUP_TOKEN_ENV,
818 &cleanup_token,
819 );
820 crate::op_interrupt::preserve_process_owner_token(&mut command);
821
822 let started = Instant::now();
823 let cmd = spec.cmd;
824 let mut child = command.spawn().map_err(|error| {
825 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
826 "{label}: failed to spawn '{cmd}': {error}"
827 ))))
828 })?;
829 if let Err(error) = crate::op_interrupt::record_current_process_owner_group(child.id()) {
830 let _ = crate::op_interrupt::terminate_child_group_with_cleanup_token_report(
831 &mut child,
832 Some(&cleanup_token),
833 );
834 return Err(VmError::Runtime(format!(
835 "{label}: record process owner group: {error}"
836 )));
837 }
838
839 if let (Some(payload), Some(mut stdin)) = (spec.stdin, child.stdin.take()) {
840 let _ = stdin.write_all(&payload);
842 }
843
844 let rx_out = child
848 .stdout
849 .take()
850 .map(crate::op_interrupt::spawn_pipe_drain);
851 let rx_err = child
852 .stderr
853 .take()
854 .map(crate::op_interrupt::spawn_pipe_drain);
855
856 let child_pid = child.id();
857 let wait_end = crate::op_interrupt::wait_child_interruptible_with_cleanup_token(
858 &mut child,
859 spec.timeout,
860 Some(&cleanup_token),
861 )
862 .map_err(|error| {
863 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
864 "{label}: wait failed: {error}"
865 ))))
866 })?;
867 let (status, timed_out, interrupted, killed) = match wait_end {
868 crate::op_interrupt::ChildWait::Exited(status) => (status, false, false, false),
869 crate::op_interrupt::ChildWait::TimedOut(_) => {
870 (std::process::ExitStatus::default(), true, false, true)
871 }
872 crate::op_interrupt::ChildWait::Interrupted(status, _) => {
876 (status.unwrap_or_default(), false, true, true)
877 }
878 };
879
880 let stdout = rx_out
881 .map(|rx| crate::op_interrupt::drain_captured_pipe(&rx, killed, child_pid))
882 .unwrap_or_default();
883 let stderr = rx_err
884 .map(|rx| crate::op_interrupt::drain_captured_pipe(&rx, killed, child_pid))
885 .unwrap_or_default();
886
887 Ok(CapturedRun {
888 output: std::process::Output {
889 status,
890 stdout,
891 stderr,
892 },
893 timed_out,
894 interrupted,
895 duration_ms: started.elapsed().as_millis() as i64,
896 })
897}
898
899#[derive(Default)]
902struct ExecOptions {
903 env: Vec<(String, String)>,
904 env_clear: bool,
905 cwd: Option<String>,
906 timeout: Option<Duration>,
907}
908
909fn exec_options(label: &str, options: Option<&VmValue>) -> Result<ExecOptions, VmError> {
918 let opts = match options {
919 None | Some(VmValue::Nil) => return Ok(ExecOptions::default()),
920 Some(VmValue::Dict(opts)) => opts.clone(),
921 Some(other) => {
922 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
923 format!("{label}: options must be a dict, got {}", other.type_name()),
924 ))));
925 }
926 };
927 let env: Vec<(String, String)> = match opts.get("env") {
928 Some(VmValue::Dict(env)) => env
929 .iter()
930 .map(|(k, v)| (k.to_string(), v.display()))
931 .collect(),
932 None | Some(VmValue::Nil) => Vec::new(),
933 Some(other) => {
934 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
935 format!(
936 "{label}: options.env must be a dict, got {}",
937 other.type_name()
938 ),
939 ))));
940 }
941 };
942 let env_clear = match opts.get("env_mode").map(|v| v.display()).as_deref() {
943 None | Some("merge") => false,
944 Some("replace") => true,
945 Some(other) => {
946 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
947 format!(
948 "{label}: options.env_mode must be \"merge\" or \"replace\", got {other:?}"
949 ),
950 ))));
951 }
952 };
953 let cwd = opts
954 .get("cwd")
955 .map(|v| v.display())
956 .filter(|s| !s.is_empty());
957 let timeout = opts
960 .get("timeout")
961 .or_else(|| opts.get("timeout_ms"))
962 .and_then(|v| v.as_int())
963 .filter(|n| *n > 0)
964 .map(|n| Duration::from_millis(n as u64));
965 Ok(ExecOptions {
966 env,
967 env_clear,
968 cwd,
969 timeout,
970 })
971}
972
973fn captured_run_to_value(run: &CapturedRun) -> VmValue {
977 let status = if run.timed_out || run.interrupted {
978 -1
979 } else {
980 run.output.status.code().unwrap_or(-1) as i64
981 };
982 let success = !run.timed_out && !run.interrupted && run.output.status.success();
983 let mut result = BTreeMap::new();
984 result.put_str(
985 "stdout",
986 String::from_utf8_lossy(&run.output.stdout).as_ref(),
987 );
988 result.put_str(
989 "stderr",
990 String::from_utf8_lossy(&run.output.stderr).as_ref(),
991 );
992 result.insert("status".to_string(), VmValue::Int(status));
993 result.insert("success".to_string(), VmValue::Bool(success));
994 result.insert("timed_out".to_string(), VmValue::Bool(run.timed_out));
995 result.insert("duration_ms".to_string(), VmValue::Int(run.duration_ms));
996 VmValue::dict(result)
997}
998
999#[harn_builtin(
1000 exposure = "runtime_internal",
1001 effects = [],
1002 sig = "exec_opts(command: list, options: dict?) -> dict",
1003 category = "process"
1004)]
1005fn exec_opts_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1006 let command = exec_opts_command("exec_opts", args.first())?;
1007 let opts = exec_options("exec_opts", args.get(1))?;
1008 let run = run_captured_spawn(CapturedSpawn {
1009 label: "exec_opts",
1010 cmd: &command[0],
1011 args: &command[1..],
1012 cwd: opts.cwd.as_deref(),
1013 env: &opts.env,
1014 env_clear: opts.env_clear,
1015 stdin: None,
1016 timeout: opts.timeout,
1017 })?;
1018 Ok(captured_run_to_value(&run))
1019}
1020
1021#[harn_builtin(
1022 exposure = "runtime_internal",
1023 effects = [],
1024 sig = "exec_at_opts(dir: string, command: list, options: dict?) -> dict",
1025 category = "process"
1026)]
1027fn exec_at_opts_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1028 let dir = match args.first() {
1029 Some(value) if !value.display().is_empty() => value.display(),
1030 _ => {
1031 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1032 "exec_at_opts: directory is required",
1033 ))));
1034 }
1035 };
1036 let command = exec_opts_command("exec_at_opts", args.get(1))?;
1037 let opts = exec_options("exec_at_opts", args.get(2))?;
1038 let resolved_cwd = opts.cwd.unwrap_or(dir);
1041 let run = run_captured_spawn(CapturedSpawn {
1042 label: "exec_at_opts",
1043 cmd: &command[0],
1044 args: &command[1..],
1045 cwd: Some(resolved_cwd.as_str()),
1046 env: &opts.env,
1047 env_clear: opts.env_clear,
1048 stdin: None,
1049 timeout: opts.timeout,
1050 })?;
1051 Ok(captured_run_to_value(&run))
1052}
1053
1054fn exec_opts_command(label: &str, value: Option<&VmValue>) -> Result<Vec<String>, VmError> {
1057 let items = match value {
1058 Some(VmValue::List(items)) => items,
1059 _ => {
1060 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1061 format!("{label}: command must be a non-empty list of strings"),
1062 ))));
1063 }
1064 };
1065 let command: Vec<String> = items.iter().map(|v| v.display()).collect();
1066 if command.is_empty() || command[0].is_empty() {
1067 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1068 format!("{label}: command must be a non-empty list of strings"),
1069 ))));
1070 }
1071 Ok(command)
1072}
1073
1074pub fn find_project_root(base: &std::path::Path) -> Option<std::path::PathBuf> {
1078 harn_modules::manifest_walk::find_project_root(base)
1079}
1080
1081pub(crate) fn register_path_builtins(vm: &mut Vm) {
1083 for def in PATH_BUILTINS {
1084 vm.register_builtin_def(def);
1085 }
1086}
1087
1088#[harn_builtin(
1089 exposure = "runtime_internal",
1090 effects = [],
1091 sig = "source_dir(...args: any) -> string", category = "process"
1092)]
1093fn source_dir_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1094 let dir = VM_SOURCE_DIR.with(|sd| sd.borrow().clone());
1095 match dir {
1096 Some(d) => Ok(VmValue::String(arcstr::ArcStr::from(
1097 d.to_string_lossy().into_owned(),
1098 ))),
1099 None => {
1100 let cwd = std::env::current_dir()
1101 .map(|p| p.to_string_lossy().into_owned())
1102 .unwrap_or_default();
1103 Ok(VmValue::String(arcstr::ArcStr::from(cwd)))
1104 }
1105 }
1106}
1107
1108#[harn_builtin(
1109 exposure = "runtime_internal",
1110 effects = [],
1111 sig = "project_root() -> string?", category = "process"
1112)]
1113fn project_root_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1114 if let Some(root) = project_root_path() {
1115 return Ok(VmValue::String(arcstr::ArcStr::from(
1116 root.to_string_lossy().as_ref(),
1117 )));
1118 }
1119 let base = current_execution_context()
1120 .and_then(|context| context.cwd.map(PathBuf::from))
1121 .or_else(|| VM_SOURCE_DIR.with(|sd| sd.borrow().clone()))
1122 .or_else(|| std::env::current_dir().ok())
1123 .unwrap_or_else(|| PathBuf::from("."));
1124 match find_project_root(&base) {
1125 Some(root) => Ok(VmValue::String(arcstr::ArcStr::from(
1126 root.to_string_lossy().into_owned(),
1127 ))),
1128 None => Ok(VmValue::Nil),
1129 }
1130}
1131
1132const PATH_BUILTINS: &[&VmBuiltinDef] = &[&SOURCE_DIR_IMPL_DEF, &PROJECT_ROOT_IMPL_DEF];
1133
1134fn vm_output_to_value(output: std::process::Output) -> VmValue {
1135 let mut result = BTreeMap::new();
1136 result.put_str("stdout", String::from_utf8_lossy(&output.stdout).as_ref());
1137 result.put_str("stderr", String::from_utf8_lossy(&output.stderr).as_ref());
1138 result.insert(
1139 "status".to_string(),
1140 VmValue::Int(output.status.code().unwrap_or(-1) as i64),
1141 );
1142 result.insert(
1143 "success".to_string(),
1144 VmValue::Bool(output.status.success()),
1145 );
1146 VmValue::dict(result)
1147}
1148
1149fn exec_command(
1150 dir: Option<&str>,
1151 cmd: &str,
1152 args: &[String],
1153) -> Result<std::process::Output, VmError> {
1154 let config = process_command_config(dir)?;
1155 crate::stdlib::sandbox::command_output(cmd, args, &config)
1156 .map_err(|error| prefix_process_error(error, "exec"))
1157}
1158
1159fn exec_shell_args(
1160 dir: Option<&str>,
1161 shell: &str,
1162 args: &[String],
1163) -> Result<std::process::Output, VmError> {
1164 let config = process_command_config(dir)?;
1165 crate::stdlib::sandbox::command_output(shell, args, &config)
1166 .map_err(|error| prefix_process_error(error, "shell"))
1167}
1168
1169fn process_command_config(
1170 dir: Option<&str>,
1171) -> Result<crate::stdlib::sandbox::ProcessCommandConfig, VmError> {
1172 let mut config = crate::stdlib::sandbox::ProcessCommandConfig::default();
1173 if let Some(dir) = dir {
1174 let resolved = resolve_command_dir(dir);
1175 crate::stdlib::sandbox::enforce_process_cwd(&resolved)?;
1176 config.cwd = Some(child_process_cwd(resolved));
1177 } else {
1178 config.cwd = Some(child_process_cwd(inherited_process_cwd()?));
1179 if let Some(context) = current_execution_context() {
1180 if !context.env.is_empty() {
1181 config.env.extend(context.env);
1182 }
1183 }
1184 }
1185 config.env.extend(runtime_child_env_overlay());
1186 if let Some(env) = session_closed_env(config.env.iter().cloned())? {
1190 config.env = env;
1191 config.closed_env = true;
1192 }
1193 Ok(config)
1194}
1195
1196pub(crate) fn session_closed_env(
1216 overlay: impl Iterator<Item = (String, String)>,
1217) -> Result<Option<Vec<(String, String)>>, VmError> {
1218 let Some(mut env) = session_env()? else {
1219 return Ok(None);
1220 };
1221 env.extend(overlay);
1222 Ok(Some(env.into_iter().collect()))
1223}
1224
1225pub(crate) fn session_closed_env_for_command(
1230 program: &str,
1231 overlay: impl Iterator<Item = (String, String)>,
1232) -> Result<Option<Vec<(String, String)>>, VmError> {
1233 let Some(mut env) = session_env_for_command(program)? else {
1234 return Ok(None);
1235 };
1236 env.extend(overlay);
1237 Ok(Some(env.into_iter().collect()))
1238}
1239
1240pub(crate) fn session_env() -> Result<Option<BTreeMap<String, String>>, VmError> {
1244 session_env_with(
1245 |grant| grant.for_command().is_none(),
1246 |environment, lookup| {
1247 crate::security::resolve_env(environment, lookup, &resolve_grant_secret)
1248 },
1249 )
1250}
1251
1252pub(crate) fn session_env_for_command(
1255 program: &str,
1256) -> Result<Option<BTreeMap<String, String>>, VmError> {
1257 let basename = crate::security::command_basename(program).to_string();
1258 session_env_with(
1259 move |grant| match grant.for_command() {
1260 None => true,
1261 Some(expected) => expected == basename,
1262 },
1263 |environment, lookup| {
1264 crate::security::resolve_env_for_command(
1265 environment,
1266 program,
1267 lookup,
1268 &resolve_grant_secret,
1269 )
1270 },
1271 )
1272}
1273
1274fn session_env_with(
1275 grant_owns_key: impl Fn(&crate::security::SessionGrant) -> bool,
1276 resolve: impl FnOnce(
1277 &crate::security::SessionEnvironment,
1278 &dyn Fn(&str) -> Option<String>,
1279 )
1280 -> Result<BTreeMap<String, String>, crate::security::EnvironmentPolicyError>,
1281) -> Result<Option<BTreeMap<String, String>>, VmError> {
1282 let Some(environment) = current_session_environment() else {
1283 return Ok(None);
1284 };
1285 let workspace_defaults = workspace_env_defaults();
1286 let mut env =
1287 resolve(&environment, &session_env_lookup(&workspace_defaults)).map_err(grant_env_error)?;
1288 for (key, value) in workspace_defaults {
1291 let grant_owns = environment
1292 .grants()
1293 .iter()
1294 .any(|grant| grant.exposed_env_var() == Some(key.as_str()) && grant_owns_key(grant));
1295 if !grant_owns {
1296 env.insert(key, value);
1297 }
1298 }
1299 Ok(Some(env))
1300}
1301
1302pub(crate) fn session_env_var(name: &str) -> Result<Option<String>, VmError> {
1317 let Some(environment) = current_session_environment() else {
1318 return Ok(std::env::var(name).ok());
1319 };
1320 let workspace_defaults = workspace_env_defaults();
1321 let is_grant_target = environment
1322 .grants()
1323 .iter()
1324 .any(|grant| grant.exposed_env_var() == Some(name));
1325 if !is_grant_target {
1326 if let Some(value) = workspace_defaults.get(name) {
1327 return Ok(Some(value.clone()));
1328 }
1329 }
1330 let resolved = crate::security::lookup_env(
1331 &environment,
1332 name,
1333 &session_env_lookup(&workspace_defaults),
1334 &resolve_grant_secret,
1335 )
1336 .map_err(grant_env_error)?;
1337 Ok(resolved)
1338}
1339
1340pub(crate) fn session_env_value(name: &str) -> Option<String> {
1344 if current_session_environment().is_none() {
1345 return crate::test_env::env_var_seamed(name);
1346 }
1347 session_env_var(name).ok().flatten()
1348}
1349
1350fn workspace_env_defaults() -> BTreeMap<String, String> {
1355 crate::process_sandbox::active_workspace_process_env()
1356 .into_iter()
1357 .collect()
1358}
1359
1360fn session_env_lookup(
1363 workspace_defaults: &BTreeMap<String, String>,
1364) -> impl Fn(&str) -> Option<String> + '_ {
1365 |name: &str| {
1366 workspace_defaults
1367 .get(name)
1368 .cloned()
1369 .or_else(|| std::env::var(name).ok())
1370 }
1371}
1372
1373fn grant_env_error(error: crate::security::EnvironmentPolicyError) -> VmError {
1374 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
1375 "session grant env resolution failed: {error}"
1376 ))))
1377}
1378
1379fn resolve_grant_secret(account: &str, key: &str) -> Option<String> {
1386 let reference = format!("{}{}/{}", crate::secrets::SECRET_REF_SCHEME, account, key);
1387 crate::secrets::resolve_secret_ref_to_string(&reference)
1388 .ok()
1389 .flatten()
1390}
1391
1392fn prefix_process_error(error: VmError, prefix: &str) -> VmError {
1393 match error {
1394 VmError::Thrown(VmValue::String(message)) => VmError::Thrown(VmValue::String(
1395 arcstr::ArcStr::from(format!("{prefix} failed: {message}")),
1396 )),
1397 VmError::Thrown(VmValue::Dict(fields))
1398 if matches!(
1399 fields.get("error"),
1400 Some(VmValue::String(family)) if family.as_str() == "io_error"
1401 ) =>
1402 {
1403 let mut prefixed = (*fields).clone();
1404 if let Some(VmValue::String(message)) = fields.get("message") {
1405 prefixed.put_str("message", format!("{prefix} failed: {message}"));
1406 }
1407 VmError::Thrown(VmValue::dict(prefixed))
1408 }
1409 other => other,
1410 }
1411}
1412
1413fn resolve_command_dir(dir: &str) -> PathBuf {
1414 let candidate = PathBuf::from(dir);
1415 if candidate.is_absolute() {
1416 return candidate;
1417 }
1418 if let Some(cwd) = current_execution_context().and_then(|context| context.cwd) {
1419 return PathBuf::from(cwd).join(candidate);
1420 }
1421 if let Some(source_dir) = VM_SOURCE_DIR.with(|sd| sd.borrow().clone()) {
1422 return source_dir.join(candidate);
1423 }
1424 candidate
1425}
1426
1427#[cfg(test)]
1428#[path = "process_tests.rs"]
1429mod tests;