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 fn inherited_process_cwd() -> Result<PathBuf, VmError> {
170 if let Some((policy, _profile)) = crate::stdlib::sandbox::active_sandbox_policy() {
171 crate::stdlib::sandbox::policy_process_cwd(&policy)
172 } else {
173 Ok(execution_root_path())
174 }
175}
176
177pub fn project_root_path() -> Option<PathBuf> {
178 current_execution_context().and_then(|context| {
179 let project_root = context.project_root?;
180 if project_root.trim().is_empty() {
181 return None;
182 }
183 let path = PathBuf::from(project_root);
184 if path.is_absolute() {
185 Some(path)
186 } else if let Some(cwd) = context.cwd {
187 Some(PathBuf::from(cwd).join(path))
188 } else {
189 Some(normalize_context_path(&path))
190 }
191 })
192}
193
194pub fn source_root_path() -> PathBuf {
195 VM_SOURCE_DIR
196 .with(|sd| sd.borrow().clone())
197 .or_else(|| {
198 current_execution_context().and_then(|context| context.source_dir.map(PathBuf::from))
199 })
200 .or_else(|| current_execution_context().and_then(|context| context.cwd.map(PathBuf::from)))
201 .or_else(|| std::env::current_dir().ok())
202 .unwrap_or_else(|| PathBuf::from("."))
203}
204
205pub fn asset_root_path() -> PathBuf {
206 source_root_path()
207}
208
209fn env_override(name: &str) -> Option<String> {
210 (name == HARN_REPLAY_ENV && crate::triggers::dispatcher::current_dispatch_is_replay())
211 .then(|| "1".to_string())
212}
213
214pub(crate) fn runtime_child_env_overlay() -> Vec<(String, String)> {
221 env_override(HARN_REPLAY_ENV)
222 .map(|value| (HARN_REPLAY_ENV.to_string(), value))
223 .into_iter()
224 .collect()
225}
226
227pub(crate) fn read_env_value(name: &str) -> Option<String> {
228 env_override(name)
229 .or_else(|| current_execution_context().and_then(|context| context.env.get(name).cloned()))
230 .or_else(|| session_env_var(name).ok().flatten())
231}
232
233pub fn runtime_root_base() -> PathBuf {
234 project_root_path()
235 .or_else(|| find_project_root(&execution_root_path()))
236 .or_else(|| find_project_root(&source_root_path()))
237 .unwrap_or_else(source_root_path)
238}
239
240fn lexically_collapse(path: &std::path::Path) -> Option<PathBuf> {
245 use std::path::Component;
246 let mut out: Vec<Component> = Vec::new();
247 for component in path.components() {
248 match component {
249 Component::CurDir => {}
250 Component::ParentDir => {
251 let popped = out.pop();
252 if !matches!(popped, Some(Component::Normal(_))) {
253 return None;
254 }
255 }
256 other => out.push(other),
257 }
258 }
259 Some(out.iter().collect())
260}
261
262pub fn resolve_source_relative_path(path: &str) -> PathBuf {
263 let candidate = PathBuf::from(path);
264 if candidate.is_absolute() {
265 return candidate;
266 }
267 let root = execution_root_path();
268 let joined = root.join(&candidate);
269 if path_escapes_project_root(&joined) {
276 return root.join("__harn_rejected_parent_dir_traversal__");
277 }
278 joined
279}
280
281pub fn resolve_source_asset_path(path: &str) -> PathBuf {
282 let candidate = PathBuf::from(path);
283 if candidate.is_absolute() {
284 return candidate;
285 }
286 let root = asset_root_path();
287 let joined = root.join(&candidate);
288 if path_escapes_project_root(&joined) {
289 return root.join("__harn_rejected_parent_dir_traversal__");
290 }
291 joined
292}
293
294fn path_escapes_project_root(joined: &std::path::Path) -> bool {
308 lexically_collapse(joined).is_none()
309}
310
311pub(crate) fn register_process_builtins(vm: &mut Vm) {
312 for def in PROCESS_BUILTINS {
313 vm.register_builtin_def(def);
314 }
315}
316
317#[harn_builtin(
318 exposure = "runtime_internal",
319 effects = [],
320 sig = "env(name: string) -> string?", category = "process"
321)]
322fn env_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
323 let name = args.first().map(|a| a.display()).unwrap_or_default();
324 if let Some(value) = read_env_value(&name) {
325 return Ok(VmValue::String(arcstr::ArcStr::from(value)));
326 }
327 Ok(VmValue::Nil)
328}
329
330#[harn_builtin(
331 exposure = "runtime_internal",
332 effects = [],
333 sig = "env_or(name: string, default: any) -> any",
334 category = "process"
335)]
336fn env_or_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
337 let name = args.first().map(|a| a.display()).unwrap_or_default();
338 let default = args.get(1).cloned().unwrap_or(VmValue::Nil);
339 if let Some(value) = read_env_value(&name) {
340 return Ok(VmValue::String(arcstr::ArcStr::from(value)));
341 }
342 Ok(default)
343}
344
345#[harn_builtin(
346 exposure = "runtime_internal",
347 effects = [],
348 sig = "exit(code?: int) -> never", category = "process"
349)]
350fn exit_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
351 let code = args.first().and_then(|a| a.as_int()).unwrap_or(0);
352 Err(VmError::ProcessExit(code as i32))
353}
354
355#[harn_builtin(
356 exposure = "runtime_internal",
357 effects = [],
358 sig = "exec(...command: string) -> dict", category = "process"
359)]
360fn exec_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
361 if args.is_empty() {
362 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
363 "exec: command is required",
364 ))));
365 }
366 let cmd = args[0].display();
367 let cmd_args: Vec<String> = args[1..].iter().map(|a| a.display()).collect();
368 let output = exec_command(None, &cmd, &cmd_args)?;
369 Ok(vm_output_to_value(output))
370}
371
372#[harn_builtin(
373 exposure = "runtime_internal",
374 effects = [],
375 sig = "shell(command: string) -> dict", category = "process"
376)]
377fn shell_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
378 let cmd = args.first().map(|a| a.display()).unwrap_or_default();
379 if cmd.is_empty() {
380 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
381 "shell: command string is required",
382 ))));
383 }
384 let invocation = crate::shells::default_shell_invocation(&cmd)
385 .map_err(|error| VmError::Runtime(format!("shell: {error}")))?;
386 let output = exec_shell_args(None, &invocation.program, &invocation.args)?;
387 Ok(vm_output_to_value(output))
388}
389
390#[harn_builtin(
391 exposure = "runtime_internal",
392 effects = [],
393 sig = "exec_at(dir: string, ...command: string) -> dict",
394 category = "process"
395)]
396fn exec_at_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
397 if args.len() < 2 {
398 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
399 "exec_at: directory and command are required",
400 ))));
401 }
402 let dir = args[0].display();
403 let cmd = args[1].display();
404 let cmd_args: Vec<String> = args[2..].iter().map(|a| a.display()).collect();
405 let output = exec_command(Some(dir.as_str()), &cmd, &cmd_args)?;
406 Ok(vm_output_to_value(output))
407}
408
409#[harn_builtin(
410 exposure = "runtime_internal",
411 effects = [],
412 sig = "shell_at(dir: string, command: string) -> dict",
413 category = "process"
414)]
415fn shell_at_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
416 if args.len() < 2 {
417 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
418 "shell_at: directory and command string are required",
419 ))));
420 }
421 let dir = args[0].display();
422 let cmd = args[1].display();
423 if cmd.is_empty() {
424 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
425 "shell_at: command string is required",
426 ))));
427 }
428 let invocation = crate::shells::default_shell_invocation(&cmd)
429 .map_err(|error| VmError::Runtime(format!("shell_at: {error}")))?;
430 let output = exec_shell_args(Some(dir.as_str()), &invocation.program, &invocation.args)?;
431 Ok(vm_output_to_value(output))
432}
433
434#[harn_builtin(
435 exposure = "runtime_internal",
436 effects = [],
437 sig = "username(...args: any) -> string", category = "process"
438)]
439fn username_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
440 let user = std::env::var("USER")
441 .or_else(|_| std::env::var("USERNAME"))
442 .unwrap_or_default();
443 Ok(VmValue::String(arcstr::ArcStr::from(user)))
444}
445
446#[harn_builtin(
447 exposure = "runtime_internal",
448 effects = [],
449 sig = "hostname() -> string", category = "process"
450)]
451fn hostname_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
452 let name = std::env::var("HOSTNAME")
453 .or_else(|_| std::env::var("COMPUTERNAME"))
454 .or_else(|_| {
455 std::process::Command::new("hostname")
456 .output()
457 .ok()
458 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
459 .ok_or(std::env::VarError::NotPresent)
460 })
461 .unwrap_or_default();
462 Ok(VmValue::String(arcstr::ArcStr::from(name)))
463}
464
465#[harn_builtin(
466 exposure = "runtime_internal",
467 effects = [],
468 sig = "platform(...args: any) -> string", category = "process"
469)]
470fn platform_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
471 let os = if cfg!(target_os = "macos") {
472 "darwin"
473 } else if cfg!(target_os = "linux") {
474 "linux"
475 } else if cfg!(target_os = "windows") {
476 "windows"
477 } else {
478 std::env::consts::OS
479 };
480 Ok(VmValue::String(arcstr::ArcStr::from(os)))
481}
482
483#[harn_builtin(
484 exposure = "runtime_internal",
485 effects = [],
486 sig = "arch() -> string", category = "process"
487)]
488fn arch_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
489 Ok(VmValue::String(arcstr::ArcStr::from(
490 std::env::consts::ARCH,
491 )))
492}
493
494#[harn_builtin(
495 exposure = "runtime_internal",
496 effects = [],
497 sig = "home_dir() -> string", category = "process"
498)]
499fn home_dir_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
500 let home = crate::user_dirs::home_dir()
501 .map(|home| home.to_string_lossy().into_owned())
502 .unwrap_or_default();
503 Ok(VmValue::String(arcstr::ArcStr::from(home)))
504}
505
506#[harn_builtin(
507 exposure = "runtime_internal",
508 effects = [],
509 sig = "pid(...args: any) -> int", category = "process"
510)]
511fn pid_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
512 Ok(VmValue::Int(std::process::id() as i64))
513}
514
515#[harn_builtin(
516 exposure = "runtime_internal",
517 effects = [],
518 sig = "date_iso() -> string", category = "process"
519)]
520fn date_iso_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
521 let now = crate::clock_mock::leak_audit::wall_now("stdlib/date_iso");
528 let dt: chrono::DateTime<chrono::Utc> = now.into();
529 Ok(VmValue::String(arcstr::ArcStr::from(
530 dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
531 )))
532}
533
534#[harn_builtin(
535 exposure = "runtime_internal",
536 effects = [],
537 sig = "cwd() -> string", category = "process"
538)]
539fn cwd_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
540 let dir = current_execution_context()
541 .and_then(|context| context.cwd)
542 .or_else(|| {
543 std::env::current_dir()
544 .ok()
545 .map(|p| p.to_string_lossy().into_owned())
546 })
547 .unwrap_or_default();
548 Ok(VmValue::String(arcstr::ArcStr::from(dir)))
549}
550
551#[harn_builtin(
552 exposure = "runtime_internal",
553 effects = [],
554 sig = "execution_root() -> string", category = "process"
555)]
556fn execution_root_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
557 Ok(VmValue::String(arcstr::ArcStr::from(
558 execution_root_path().to_string_lossy().into_owned(),
559 )))
560}
561
562#[harn_builtin(
563 exposure = "runtime_internal",
564 effects = [],
565 sig = "asset_root() -> string", category = "process"
566)]
567fn asset_root_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
568 Ok(VmValue::String(arcstr::ArcStr::from(
569 asset_root_path().to_string_lossy().into_owned(),
570 )))
571}
572
573#[harn_builtin(
587 exposure = "runtime_internal",
588 effects = [],
589 sig = "runtime_paths() -> {execution_root: string, asset_root: string, state_root: string, run_root: string, worktree_root: string}",
590 category = "process"
591)]
592fn runtime_paths_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
593 let runtime_base = runtime_root_base();
594 let mut paths = BTreeMap::new();
595 paths.put_str("execution_root", execution_root_path().to_string_lossy());
596 paths.put_str("asset_root", asset_root_path().to_string_lossy());
597 paths.put_str(
598 "state_root",
599 crate::runtime_paths::state_root(&runtime_base).to_string_lossy(),
600 );
601 paths.put_str(
602 "run_root",
603 crate::runtime_paths::run_root(&runtime_base).to_string_lossy(),
604 );
605 paths.put_str(
606 "worktree_root",
607 crate::runtime_paths::worktree_root(&runtime_base).to_string_lossy(),
608 );
609 Ok(VmValue::dict(paths))
610}
611
612#[harn_builtin(
622 exposure = "runtime_internal",
623 effects = [],
624 sig = "term_width() -> int", category = "process"
625)]
626fn term_width_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
627 Ok(VmValue::Int(crate::term::width() as i64))
628}
629
630#[harn_builtin(
631 exposure = "runtime_internal",
632 effects = [],
633 sig = "term_height() -> int", category = "process"
634)]
635fn term_height_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
636 Ok(VmValue::Int(crate::term::height() as i64))
637}
638
639const PROCESS_BUILTINS: &[&VmBuiltinDef] = &[
640 &ENV_IMPL_DEF,
641 &ENV_OR_IMPL_DEF,
642 &EXIT_IMPL_DEF,
643 &EXEC_IMPL_DEF,
644 &EXEC_OPTS_IMPL_DEF,
645 &SHELL_IMPL_DEF,
646 &EXEC_AT_IMPL_DEF,
647 &EXEC_AT_OPTS_IMPL_DEF,
648 &SHELL_AT_IMPL_DEF,
649 &USERNAME_IMPL_DEF,
650 &HOSTNAME_IMPL_DEF,
651 &PLATFORM_IMPL_DEF,
652 &ARCH_IMPL_DEF,
653 &HOME_DIR_IMPL_DEF,
654 &PID_IMPL_DEF,
655 &DATE_ISO_IMPL_DEF,
656 &CWD_IMPL_DEF,
657 &EXECUTION_ROOT_IMPL_DEF,
658 &ASSET_ROOT_IMPL_DEF,
659 &RUNTIME_PATHS_IMPL_DEF,
660 &TERM_WIDTH_IMPL_DEF,
661 &TERM_HEIGHT_IMPL_DEF,
662];
663
664struct CapturedSpawn<'a> {
669 label: &'static str,
670 cmd: &'a str,
671 args: &'a [String],
672 cwd: Option<&'a str>,
673 env: &'a [(String, String)],
674 env_clear: bool,
675 stdin: Option<Vec<u8>>,
676 timeout: Option<Duration>,
677}
678
679struct CapturedRun {
681 output: std::process::Output,
682 timed_out: bool,
683 interrupted: bool,
684 duration_ms: i64,
685}
686
687fn run_captured_spawn(spec: CapturedSpawn<'_>) -> Result<CapturedRun, VmError> {
699 let label = spec.label;
700 let mut command = std::process::Command::new(spec.cmd);
701 command.args(spec.args);
702 if let Some(cwd) = spec.cwd {
703 command.current_dir(cwd);
704 }
705 let resolved_environment = if spec.env_clear {
711 None
712 } else {
713 session_closed_env_for_command(spec.cmd, spec.env.iter().cloned())?
714 };
715 if spec.env_clear || resolved_environment.is_some() {
716 command.env_clear();
717 }
718 for (key, value) in resolved_environment.as_deref().unwrap_or(spec.env) {
719 command.env(key, value);
720 }
721 command.stdout(Stdio::piped()).stderr(Stdio::piped());
722 if spec.stdin.is_some() {
723 command.stdin(Stdio::piped());
724 } else {
725 command.stdin(Stdio::null());
726 }
727 crate::op_interrupt::configure_kill_group(&mut command);
728 let cleanup_token = crate::op_interrupt::new_process_cleanup_token();
729 command.env(
730 crate::op_interrupt::PROCESS_CLEANUP_TOKEN_ENV,
731 &cleanup_token,
732 );
733 crate::op_interrupt::preserve_process_owner_token(&mut command);
734
735 let started = Instant::now();
736 let cmd = spec.cmd;
737 let mut child = command.spawn().map_err(|error| {
738 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
739 "{label}: failed to spawn '{cmd}': {error}"
740 ))))
741 })?;
742 if let Err(error) = crate::op_interrupt::record_current_process_owner_group(child.id()) {
743 let _ = crate::op_interrupt::terminate_child_group_with_cleanup_token_report(
744 &mut child,
745 Some(&cleanup_token),
746 );
747 return Err(VmError::Runtime(format!(
748 "{label}: record process owner group: {error}"
749 )));
750 }
751
752 if let (Some(payload), Some(mut stdin)) = (spec.stdin, child.stdin.take()) {
753 let _ = stdin.write_all(&payload);
755 }
756
757 let rx_out = child
761 .stdout
762 .take()
763 .map(crate::op_interrupt::spawn_pipe_drain);
764 let rx_err = child
765 .stderr
766 .take()
767 .map(crate::op_interrupt::spawn_pipe_drain);
768
769 let child_pid = child.id();
770 let wait_end = crate::op_interrupt::wait_child_interruptible_with_cleanup_token(
771 &mut child,
772 spec.timeout,
773 Some(&cleanup_token),
774 )
775 .map_err(|error| {
776 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
777 "{label}: wait failed: {error}"
778 ))))
779 })?;
780 let (status, timed_out, interrupted, killed) = match wait_end {
781 crate::op_interrupt::ChildWait::Exited(status) => (status, false, false, false),
782 crate::op_interrupt::ChildWait::TimedOut(_) => {
783 (std::process::ExitStatus::default(), true, false, true)
784 }
785 crate::op_interrupt::ChildWait::Interrupted(status, _) => {
789 (status.unwrap_or_default(), false, true, true)
790 }
791 };
792
793 let stdout = rx_out
794 .map(|rx| crate::op_interrupt::drain_captured_pipe(&rx, killed, child_pid))
795 .unwrap_or_default();
796 let stderr = rx_err
797 .map(|rx| crate::op_interrupt::drain_captured_pipe(&rx, killed, child_pid))
798 .unwrap_or_default();
799
800 Ok(CapturedRun {
801 output: std::process::Output {
802 status,
803 stdout,
804 stderr,
805 },
806 timed_out,
807 interrupted,
808 duration_ms: started.elapsed().as_millis() as i64,
809 })
810}
811
812#[derive(Default)]
815struct ExecOptions {
816 env: Vec<(String, String)>,
817 env_clear: bool,
818 cwd: Option<String>,
819 timeout: Option<Duration>,
820}
821
822fn exec_options(label: &str, options: Option<&VmValue>) -> Result<ExecOptions, VmError> {
831 let opts = match options {
832 None | Some(VmValue::Nil) => return Ok(ExecOptions::default()),
833 Some(VmValue::Dict(opts)) => opts.clone(),
834 Some(other) => {
835 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
836 format!("{label}: options must be a dict, got {}", other.type_name()),
837 ))));
838 }
839 };
840 let env: Vec<(String, String)> = match opts.get("env") {
841 Some(VmValue::Dict(env)) => env
842 .iter()
843 .map(|(k, v)| (k.to_string(), v.display()))
844 .collect(),
845 None | Some(VmValue::Nil) => Vec::new(),
846 Some(other) => {
847 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
848 format!(
849 "{label}: options.env must be a dict, got {}",
850 other.type_name()
851 ),
852 ))));
853 }
854 };
855 let env_clear = match opts.get("env_mode").map(|v| v.display()).as_deref() {
856 None | Some("merge") => false,
857 Some("replace") => true,
858 Some(other) => {
859 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
860 format!(
861 "{label}: options.env_mode must be \"merge\" or \"replace\", got {other:?}"
862 ),
863 ))));
864 }
865 };
866 let cwd = opts
867 .get("cwd")
868 .map(|v| v.display())
869 .filter(|s| !s.is_empty());
870 let timeout = opts
873 .get("timeout")
874 .or_else(|| opts.get("timeout_ms"))
875 .and_then(|v| v.as_int())
876 .filter(|n| *n > 0)
877 .map(|n| Duration::from_millis(n as u64));
878 Ok(ExecOptions {
879 env,
880 env_clear,
881 cwd,
882 timeout,
883 })
884}
885
886fn captured_run_to_value(run: &CapturedRun) -> VmValue {
890 let status = if run.timed_out || run.interrupted {
891 -1
892 } else {
893 run.output.status.code().unwrap_or(-1) as i64
894 };
895 let success = !run.timed_out && !run.interrupted && run.output.status.success();
896 let mut result = BTreeMap::new();
897 result.put_str(
898 "stdout",
899 String::from_utf8_lossy(&run.output.stdout).as_ref(),
900 );
901 result.put_str(
902 "stderr",
903 String::from_utf8_lossy(&run.output.stderr).as_ref(),
904 );
905 result.insert("status".to_string(), VmValue::Int(status));
906 result.insert("success".to_string(), VmValue::Bool(success));
907 result.insert("timed_out".to_string(), VmValue::Bool(run.timed_out));
908 result.insert("duration_ms".to_string(), VmValue::Int(run.duration_ms));
909 VmValue::dict(result)
910}
911
912#[harn_builtin(
913 exposure = "runtime_internal",
914 effects = [],
915 sig = "exec_opts(command: list, options: dict?) -> dict",
916 category = "process"
917)]
918fn exec_opts_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
919 let command = exec_opts_command("exec_opts", args.first())?;
920 let opts = exec_options("exec_opts", args.get(1))?;
921 let run = run_captured_spawn(CapturedSpawn {
922 label: "exec_opts",
923 cmd: &command[0],
924 args: &command[1..],
925 cwd: opts.cwd.as_deref(),
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
934#[harn_builtin(
935 exposure = "runtime_internal",
936 effects = [],
937 sig = "exec_at_opts(dir: string, command: list, options: dict?) -> dict",
938 category = "process"
939)]
940fn exec_at_opts_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
941 let dir = match args.first() {
942 Some(value) if !value.display().is_empty() => value.display(),
943 _ => {
944 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
945 "exec_at_opts: directory is required",
946 ))));
947 }
948 };
949 let command = exec_opts_command("exec_at_opts", args.get(1))?;
950 let opts = exec_options("exec_at_opts", args.get(2))?;
951 let resolved_cwd = opts.cwd.unwrap_or(dir);
954 let run = run_captured_spawn(CapturedSpawn {
955 label: "exec_at_opts",
956 cmd: &command[0],
957 args: &command[1..],
958 cwd: Some(resolved_cwd.as_str()),
959 env: &opts.env,
960 env_clear: opts.env_clear,
961 stdin: None,
962 timeout: opts.timeout,
963 })?;
964 Ok(captured_run_to_value(&run))
965}
966
967fn exec_opts_command(label: &str, value: Option<&VmValue>) -> Result<Vec<String>, VmError> {
970 let items = match value {
971 Some(VmValue::List(items)) => items,
972 _ => {
973 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
974 format!("{label}: command must be a non-empty list of strings"),
975 ))));
976 }
977 };
978 let command: Vec<String> = items.iter().map(|v| v.display()).collect();
979 if command.is_empty() || command[0].is_empty() {
980 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
981 format!("{label}: command must be a non-empty list of strings"),
982 ))));
983 }
984 Ok(command)
985}
986
987pub fn find_project_root(base: &std::path::Path) -> Option<std::path::PathBuf> {
991 harn_modules::manifest_walk::find_project_root(base)
992}
993
994pub(crate) fn register_path_builtins(vm: &mut Vm) {
996 for def in PATH_BUILTINS {
997 vm.register_builtin_def(def);
998 }
999}
1000
1001#[harn_builtin(
1002 exposure = "runtime_internal",
1003 effects = [],
1004 sig = "source_dir(...args: any) -> string", category = "process"
1005)]
1006fn source_dir_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1007 let dir = VM_SOURCE_DIR.with(|sd| sd.borrow().clone());
1008 match dir {
1009 Some(d) => Ok(VmValue::String(arcstr::ArcStr::from(
1010 d.to_string_lossy().into_owned(),
1011 ))),
1012 None => {
1013 let cwd = std::env::current_dir()
1014 .map(|p| p.to_string_lossy().into_owned())
1015 .unwrap_or_default();
1016 Ok(VmValue::String(arcstr::ArcStr::from(cwd)))
1017 }
1018 }
1019}
1020
1021#[harn_builtin(
1022 exposure = "runtime_internal",
1023 effects = [],
1024 sig = "project_root() -> string?", category = "process"
1025)]
1026fn project_root_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1027 if let Some(root) = project_root_path() {
1028 return Ok(VmValue::String(arcstr::ArcStr::from(
1029 root.to_string_lossy().as_ref(),
1030 )));
1031 }
1032 let base = current_execution_context()
1033 .and_then(|context| context.cwd.map(PathBuf::from))
1034 .or_else(|| VM_SOURCE_DIR.with(|sd| sd.borrow().clone()))
1035 .or_else(|| std::env::current_dir().ok())
1036 .unwrap_or_else(|| PathBuf::from("."));
1037 match find_project_root(&base) {
1038 Some(root) => Ok(VmValue::String(arcstr::ArcStr::from(
1039 root.to_string_lossy().into_owned(),
1040 ))),
1041 None => Ok(VmValue::Nil),
1042 }
1043}
1044
1045const PATH_BUILTINS: &[&VmBuiltinDef] = &[&SOURCE_DIR_IMPL_DEF, &PROJECT_ROOT_IMPL_DEF];
1046
1047fn vm_output_to_value(output: std::process::Output) -> VmValue {
1048 let mut result = BTreeMap::new();
1049 result.put_str("stdout", String::from_utf8_lossy(&output.stdout).as_ref());
1050 result.put_str("stderr", String::from_utf8_lossy(&output.stderr).as_ref());
1051 result.insert(
1052 "status".to_string(),
1053 VmValue::Int(output.status.code().unwrap_or(-1) as i64),
1054 );
1055 result.insert(
1056 "success".to_string(),
1057 VmValue::Bool(output.status.success()),
1058 );
1059 VmValue::dict(result)
1060}
1061
1062fn exec_command(
1063 dir: Option<&str>,
1064 cmd: &str,
1065 args: &[String],
1066) -> Result<std::process::Output, VmError> {
1067 let config = process_command_config(dir)?;
1068 crate::stdlib::sandbox::command_output(cmd, args, &config)
1069 .map_err(|error| prefix_process_error(error, "exec"))
1070}
1071
1072fn exec_shell_args(
1073 dir: Option<&str>,
1074 shell: &str,
1075 args: &[String],
1076) -> Result<std::process::Output, VmError> {
1077 let config = process_command_config(dir)?;
1078 crate::stdlib::sandbox::command_output(shell, args, &config)
1079 .map_err(|error| prefix_process_error(error, "shell"))
1080}
1081
1082fn process_command_config(
1083 dir: Option<&str>,
1084) -> Result<crate::stdlib::sandbox::ProcessCommandConfig, VmError> {
1085 let mut config = crate::stdlib::sandbox::ProcessCommandConfig {
1086 stdin_null: true,
1087 ..Default::default()
1088 };
1089 if let Some(dir) = dir {
1090 let resolved = resolve_command_dir(dir);
1091 crate::stdlib::sandbox::enforce_process_cwd(&resolved)?;
1092 config.cwd = Some(resolved);
1093 } else if let Some(context) = current_execution_context() {
1094 if let Some(cwd) = context.cwd.filter(|cwd| !cwd.is_empty()) {
1095 crate::stdlib::sandbox::enforce_process_cwd(std::path::Path::new(&cwd))?;
1096 config.cwd = Some(std::path::PathBuf::from(cwd));
1097 }
1098 if !context.env.is_empty() {
1099 config.env.extend(context.env);
1100 }
1101 }
1102 config.env.extend(runtime_child_env_overlay());
1103 if let Some(env) = session_closed_env(config.env.iter().cloned())? {
1107 config.env = env;
1108 config.closed_env = true;
1109 }
1110 Ok(config)
1111}
1112
1113pub(crate) fn session_closed_env(
1133 overlay: impl Iterator<Item = (String, String)>,
1134) -> Result<Option<Vec<(String, String)>>, VmError> {
1135 let Some(mut env) = session_env()? else {
1136 return Ok(None);
1137 };
1138 env.extend(overlay);
1139 Ok(Some(env.into_iter().collect()))
1140}
1141
1142pub(crate) fn session_closed_env_for_command(
1147 program: &str,
1148 overlay: impl Iterator<Item = (String, String)>,
1149) -> Result<Option<Vec<(String, String)>>, VmError> {
1150 let Some(mut env) = session_env_for_command(program)? else {
1151 return Ok(None);
1152 };
1153 env.extend(overlay);
1154 Ok(Some(env.into_iter().collect()))
1155}
1156
1157pub(crate) fn session_env() -> Result<Option<BTreeMap<String, String>>, VmError> {
1161 session_env_with(
1162 |grant| grant.for_command().is_none(),
1163 |environment, lookup| {
1164 crate::security::resolve_env(environment, lookup, &resolve_grant_secret)
1165 },
1166 )
1167}
1168
1169pub(crate) fn session_env_for_command(
1172 program: &str,
1173) -> Result<Option<BTreeMap<String, String>>, VmError> {
1174 let basename = crate::security::command_basename(program).to_string();
1175 session_env_with(
1176 move |grant| match grant.for_command() {
1177 None => true,
1178 Some(expected) => expected == basename,
1179 },
1180 |environment, lookup| {
1181 crate::security::resolve_env_for_command(
1182 environment,
1183 program,
1184 lookup,
1185 &resolve_grant_secret,
1186 )
1187 },
1188 )
1189}
1190
1191fn session_env_with(
1192 grant_owns_key: impl Fn(&crate::security::SessionGrant) -> bool,
1193 resolve: impl FnOnce(
1194 &crate::security::SessionEnvironment,
1195 &dyn Fn(&str) -> Option<String>,
1196 )
1197 -> Result<BTreeMap<String, String>, crate::security::EnvironmentPolicyError>,
1198) -> Result<Option<BTreeMap<String, String>>, VmError> {
1199 let Some(environment) = current_session_environment() else {
1200 return Ok(None);
1201 };
1202 let workspace_defaults = workspace_env_defaults();
1203 let mut env =
1204 resolve(&environment, &session_env_lookup(&workspace_defaults)).map_err(grant_env_error)?;
1205 for (key, value) in workspace_defaults {
1208 let grant_owns = environment
1209 .grants()
1210 .iter()
1211 .any(|grant| grant.exposed_env_var() == Some(key.as_str()) && grant_owns_key(grant));
1212 if !grant_owns {
1213 env.insert(key, value);
1214 }
1215 }
1216 Ok(Some(env))
1217}
1218
1219pub(crate) fn session_env_var(name: &str) -> Result<Option<String>, VmError> {
1234 let Some(environment) = current_session_environment() else {
1235 return Ok(std::env::var(name).ok());
1236 };
1237 let workspace_defaults = workspace_env_defaults();
1238 let is_grant_target = environment
1239 .grants()
1240 .iter()
1241 .any(|grant| grant.exposed_env_var() == Some(name));
1242 if !is_grant_target {
1243 if let Some(value) = workspace_defaults.get(name) {
1244 return Ok(Some(value.clone()));
1245 }
1246 }
1247 let resolved = crate::security::lookup_env(
1248 &environment,
1249 name,
1250 &session_env_lookup(&workspace_defaults),
1251 &resolve_grant_secret,
1252 )
1253 .map_err(grant_env_error)?;
1254 Ok(resolved)
1255}
1256
1257pub(crate) fn session_env_value(name: &str) -> Option<String> {
1261 if current_session_environment().is_none() {
1262 return crate::test_env::env_var_seamed(name);
1263 }
1264 session_env_var(name).ok().flatten()
1265}
1266
1267fn workspace_env_defaults() -> BTreeMap<String, String> {
1272 crate::process_sandbox::active_workspace_process_env()
1273 .into_iter()
1274 .collect()
1275}
1276
1277fn session_env_lookup(
1280 workspace_defaults: &BTreeMap<String, String>,
1281) -> impl Fn(&str) -> Option<String> + '_ {
1282 |name: &str| {
1283 workspace_defaults
1284 .get(name)
1285 .cloned()
1286 .or_else(|| std::env::var(name).ok())
1287 }
1288}
1289
1290fn grant_env_error(error: crate::security::EnvironmentPolicyError) -> VmError {
1291 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
1292 "session grant env resolution failed: {error}"
1293 ))))
1294}
1295
1296fn resolve_grant_secret(account: &str, key: &str) -> Option<String> {
1303 let reference = format!("{}{}/{}", crate::secrets::SECRET_REF_SCHEME, account, key);
1304 crate::secrets::resolve_secret_ref_to_string(&reference)
1305 .ok()
1306 .flatten()
1307}
1308
1309fn prefix_process_error(error: VmError, prefix: &str) -> VmError {
1310 match error {
1311 VmError::Thrown(VmValue::String(message)) => VmError::Thrown(VmValue::String(
1312 arcstr::ArcStr::from(format!("{prefix} failed: {message}")),
1313 )),
1314 VmError::Thrown(VmValue::Dict(fields))
1315 if matches!(
1316 fields.get("error"),
1317 Some(VmValue::String(family)) if family.as_str() == "io_error"
1318 ) =>
1319 {
1320 let mut prefixed = (*fields).clone();
1321 if let Some(VmValue::String(message)) = fields.get("message") {
1322 prefixed.put_str("message", format!("{prefix} failed: {message}"));
1323 }
1324 VmError::Thrown(VmValue::dict(prefixed))
1325 }
1326 other => other,
1327 }
1328}
1329
1330fn resolve_command_dir(dir: &str) -> PathBuf {
1331 let candidate = PathBuf::from(dir);
1332 if candidate.is_absolute() {
1333 return candidate;
1334 }
1335 if let Some(cwd) = current_execution_context().and_then(|context| context.cwd) {
1336 return PathBuf::from(cwd).join(candidate);
1337 }
1338 if let Some(source_dir) = VM_SOURCE_DIR.with(|sd| sd.borrow().clone()) {
1339 return source_dir.join(candidate);
1340 }
1341 candidate
1342}
1343
1344#[cfg(test)]
1345#[path = "process_tests.rs"]
1346mod tests;