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