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