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