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 project_root_path() -> Option<PathBuf> {
145 current_execution_context().and_then(|context| {
146 let project_root = context.project_root?;
147 if project_root.trim().is_empty() {
148 return None;
149 }
150 let path = PathBuf::from(project_root);
151 if path.is_absolute() {
152 Some(path)
153 } else if let Some(cwd) = context.cwd {
154 Some(PathBuf::from(cwd).join(path))
155 } else {
156 Some(normalize_context_path(&path))
157 }
158 })
159}
160
161pub fn source_root_path() -> PathBuf {
162 VM_SOURCE_DIR
163 .with(|sd| sd.borrow().clone())
164 .or_else(|| {
165 current_execution_context().and_then(|context| context.source_dir.map(PathBuf::from))
166 })
167 .or_else(|| current_execution_context().and_then(|context| context.cwd.map(PathBuf::from)))
168 .or_else(|| std::env::current_dir().ok())
169 .unwrap_or_else(|| PathBuf::from("."))
170}
171
172pub fn asset_root_path() -> PathBuf {
173 source_root_path()
174}
175
176fn env_override(name: &str) -> Option<String> {
177 (name == HARN_REPLAY_ENV && crate::triggers::dispatcher::current_dispatch_is_replay())
178 .then(|| "1".to_string())
179}
180
181pub(crate) fn read_env_value(name: &str) -> Option<String> {
182 env_override(name)
183 .or_else(|| current_execution_context().and_then(|context| context.env.get(name).cloned()))
184 .or_else(|| session_env_var(name).ok().flatten())
185}
186
187pub fn runtime_root_base() -> PathBuf {
188 project_root_path()
189 .or_else(|| find_project_root(&execution_root_path()))
190 .or_else(|| find_project_root(&source_root_path()))
191 .unwrap_or_else(source_root_path)
192}
193
194fn lexically_collapse(path: &std::path::Path) -> Option<PathBuf> {
199 use std::path::Component;
200 let mut out: Vec<Component> = Vec::new();
201 for component in path.components() {
202 match component {
203 Component::CurDir => {}
204 Component::ParentDir => {
205 let popped = out.pop();
206 if !matches!(popped, Some(Component::Normal(_))) {
207 return None;
208 }
209 }
210 other => out.push(other),
211 }
212 }
213 Some(out.iter().collect())
214}
215
216pub fn resolve_source_relative_path(path: &str) -> PathBuf {
217 let candidate = PathBuf::from(path);
218 if candidate.is_absolute() {
219 return candidate;
220 }
221 let root = execution_root_path();
222 let joined = root.join(&candidate);
223 if path_escapes_project_root(&joined) {
230 return root.join("__harn_rejected_parent_dir_traversal__");
231 }
232 joined
233}
234
235pub fn resolve_source_asset_path(path: &str) -> PathBuf {
236 let candidate = PathBuf::from(path);
237 if candidate.is_absolute() {
238 return candidate;
239 }
240 let root = asset_root_path();
241 let joined = root.join(&candidate);
242 if path_escapes_project_root(&joined) {
243 return root.join("__harn_rejected_parent_dir_traversal__");
244 }
245 joined
246}
247
248fn path_escapes_project_root(joined: &std::path::Path) -> bool {
262 lexically_collapse(joined).is_none()
263}
264
265pub(crate) fn register_process_builtins(vm: &mut Vm) {
266 for def in PROCESS_BUILTINS {
267 vm.register_builtin_def(def);
268 }
269}
270
271#[harn_builtin(sig = "env(name: string) -> string?", category = "process")]
272fn env_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
273 let name = args.first().map(|a| a.display()).unwrap_or_default();
274 if let Some(value) = read_env_value(&name) {
275 return Ok(VmValue::String(arcstr::ArcStr::from(value)));
276 }
277 Ok(VmValue::Nil)
278}
279
280#[harn_builtin(
281 sig = "env_or(name: string, default: any) -> any",
282 category = "process"
283)]
284fn env_or_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
285 let name = args.first().map(|a| a.display()).unwrap_or_default();
286 let default = args.get(1).cloned().unwrap_or(VmValue::Nil);
287 if let Some(value) = read_env_value(&name) {
288 return Ok(VmValue::String(arcstr::ArcStr::from(value)));
289 }
290 Ok(default)
291}
292
293#[harn_builtin(sig = "exit(code?: int) -> never", category = "process")]
294fn exit_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
295 let code = args.first().and_then(|a| a.as_int()).unwrap_or(0);
296 Err(VmError::ProcessExit(code as i32))
297}
298
299#[harn_builtin(sig = "exec(...command: string) -> dict", category = "process")]
300fn exec_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
301 if args.is_empty() {
302 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
303 "exec: command is required",
304 ))));
305 }
306 let cmd = args[0].display();
307 let cmd_args: Vec<String> = args[1..].iter().map(|a| a.display()).collect();
308 let output = exec_command(None, &cmd, &cmd_args)?;
309 Ok(vm_output_to_value(output))
310}
311
312#[harn_builtin(sig = "shell(command: string) -> dict", category = "process")]
313fn shell_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
314 let cmd = args.first().map(|a| a.display()).unwrap_or_default();
315 if cmd.is_empty() {
316 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
317 "shell: command string is required",
318 ))));
319 }
320 let invocation = crate::shells::default_shell_invocation(&cmd)
321 .map_err(|error| VmError::Runtime(format!("shell: {error}")))?;
322 let output = exec_shell_args(None, &invocation.program, &invocation.args)?;
323 Ok(vm_output_to_value(output))
324}
325
326#[harn_builtin(
327 sig = "exec_at(dir: string, ...command: string) -> dict",
328 category = "process"
329)]
330fn exec_at_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
331 if args.len() < 2 {
332 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
333 "exec_at: directory and command are required",
334 ))));
335 }
336 let dir = args[0].display();
337 let cmd = args[1].display();
338 let cmd_args: Vec<String> = args[2..].iter().map(|a| a.display()).collect();
339 let output = exec_command(Some(dir.as_str()), &cmd, &cmd_args)?;
340 Ok(vm_output_to_value(output))
341}
342
343#[harn_builtin(
344 sig = "shell_at(dir: string, command: string) -> dict",
345 category = "process"
346)]
347fn shell_at_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
348 if args.len() < 2 {
349 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
350 "shell_at: directory and command string are required",
351 ))));
352 }
353 let dir = args[0].display();
354 let cmd = args[1].display();
355 if cmd.is_empty() {
356 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
357 "shell_at: command string is required",
358 ))));
359 }
360 let invocation = crate::shells::default_shell_invocation(&cmd)
361 .map_err(|error| VmError::Runtime(format!("shell_at: {error}")))?;
362 let output = exec_shell_args(Some(dir.as_str()), &invocation.program, &invocation.args)?;
363 Ok(vm_output_to_value(output))
364}
365
366#[harn_builtin(sig = "username(...args: any) -> string", category = "process")]
367fn username_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
368 let user = std::env::var("USER")
369 .or_else(|_| std::env::var("USERNAME"))
370 .unwrap_or_default();
371 Ok(VmValue::String(arcstr::ArcStr::from(user)))
372}
373
374#[harn_builtin(sig = "hostname() -> string", category = "process")]
375fn hostname_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
376 let name = std::env::var("HOSTNAME")
377 .or_else(|_| std::env::var("COMPUTERNAME"))
378 .or_else(|_| {
379 std::process::Command::new("hostname")
380 .output()
381 .ok()
382 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
383 .ok_or(std::env::VarError::NotPresent)
384 })
385 .unwrap_or_default();
386 Ok(VmValue::String(arcstr::ArcStr::from(name)))
387}
388
389#[harn_builtin(sig = "platform(...args: any) -> string", category = "process")]
390fn platform_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
391 let os = if cfg!(target_os = "macos") {
392 "darwin"
393 } else if cfg!(target_os = "linux") {
394 "linux"
395 } else if cfg!(target_os = "windows") {
396 "windows"
397 } else {
398 std::env::consts::OS
399 };
400 Ok(VmValue::String(arcstr::ArcStr::from(os)))
401}
402
403#[harn_builtin(sig = "arch() -> string", category = "process")]
404fn arch_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
405 Ok(VmValue::String(arcstr::ArcStr::from(
406 std::env::consts::ARCH,
407 )))
408}
409
410#[harn_builtin(sig = "home_dir() -> string", category = "process")]
411fn home_dir_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
412 let home = crate::user_dirs::home_dir()
413 .map(|home| home.to_string_lossy().into_owned())
414 .unwrap_or_default();
415 Ok(VmValue::String(arcstr::ArcStr::from(home)))
416}
417
418#[harn_builtin(sig = "pid(...args: any) -> int", category = "process")]
419fn pid_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
420 Ok(VmValue::Int(std::process::id() as i64))
421}
422
423#[harn_builtin(sig = "date_iso() -> string", category = "process")]
424fn date_iso_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
425 let now = crate::clock_mock::leak_audit::wall_now("stdlib/date_iso");
432 let dt: chrono::DateTime<chrono::Utc> = now.into();
433 Ok(VmValue::String(arcstr::ArcStr::from(
434 dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
435 )))
436}
437
438#[harn_builtin(sig = "cwd() -> string", category = "process")]
439fn cwd_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
440 let dir = current_execution_context()
441 .and_then(|context| context.cwd)
442 .or_else(|| {
443 std::env::current_dir()
444 .ok()
445 .map(|p| p.to_string_lossy().into_owned())
446 })
447 .unwrap_or_default();
448 Ok(VmValue::String(arcstr::ArcStr::from(dir)))
449}
450
451#[harn_builtin(sig = "execution_root() -> string", category = "process")]
452fn execution_root_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
453 Ok(VmValue::String(arcstr::ArcStr::from(
454 execution_root_path().to_string_lossy().into_owned(),
455 )))
456}
457
458#[harn_builtin(sig = "asset_root() -> string", category = "process")]
459fn asset_root_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
460 Ok(VmValue::String(arcstr::ArcStr::from(
461 asset_root_path().to_string_lossy().into_owned(),
462 )))
463}
464
465#[harn_builtin(sig = "runtime_paths() -> dict", category = "process")]
466fn runtime_paths_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
467 let runtime_base = runtime_root_base();
468 let mut paths = BTreeMap::new();
469 paths.put_str("execution_root", execution_root_path().to_string_lossy());
470 paths.put_str("asset_root", asset_root_path().to_string_lossy());
471 paths.put_str(
472 "state_root",
473 crate::runtime_paths::state_root(&runtime_base).to_string_lossy(),
474 );
475 paths.put_str(
476 "run_root",
477 crate::runtime_paths::run_root(&runtime_base).to_string_lossy(),
478 );
479 paths.put_str(
480 "worktree_root",
481 crate::runtime_paths::worktree_root(&runtime_base).to_string_lossy(),
482 );
483 Ok(VmValue::dict(paths))
484}
485
486#[harn_builtin(sig = "spawn_captured(opts: dict) -> dict", category = "process")]
487fn spawn_captured_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
488 spawn_captured_value(args)
489}
490
491#[harn_builtin(sig = "term_width() -> int", category = "process")]
501fn term_width_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
502 Ok(VmValue::Int(crate::term::width() as i64))
503}
504
505#[harn_builtin(sig = "term_height() -> int", category = "process")]
506fn term_height_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
507 Ok(VmValue::Int(crate::term::height() as i64))
508}
509
510const PROCESS_BUILTINS: &[&VmBuiltinDef] = &[
511 &ENV_IMPL_DEF,
512 &ENV_OR_IMPL_DEF,
513 &EXIT_IMPL_DEF,
514 &EXEC_IMPL_DEF,
515 &EXEC_OPTS_IMPL_DEF,
516 &SHELL_IMPL_DEF,
517 &EXEC_AT_IMPL_DEF,
518 &EXEC_AT_OPTS_IMPL_DEF,
519 &SHELL_AT_IMPL_DEF,
520 &USERNAME_IMPL_DEF,
521 &HOSTNAME_IMPL_DEF,
522 &PLATFORM_IMPL_DEF,
523 &ARCH_IMPL_DEF,
524 &HOME_DIR_IMPL_DEF,
525 &PID_IMPL_DEF,
526 &DATE_ISO_IMPL_DEF,
527 &CWD_IMPL_DEF,
528 &EXECUTION_ROOT_IMPL_DEF,
529 &ASSET_ROOT_IMPL_DEF,
530 &RUNTIME_PATHS_IMPL_DEF,
531 &SPAWN_CAPTURED_IMPL_DEF,
532 &TERM_WIDTH_IMPL_DEF,
533 &TERM_HEIGHT_IMPL_DEF,
534];
535
536pub(crate) fn spawn_captured_value(args: &[VmValue]) -> Result<VmValue, VmError> {
541 let opts = match args.first() {
542 Some(VmValue::Dict(opts)) => opts.clone(),
543 _ => {
544 return Err(VmError::Runtime(
545 "spawn_captured: options dict is required".to_string(),
546 ));
547 }
548 };
549 let cmd = match opts.get("cmd").map(|v| v.display()).unwrap_or_default() {
550 s if s.is_empty() => {
551 return Err(VmError::Runtime(
552 "spawn_captured: opts.cmd is required".to_string(),
553 ));
554 }
555 s => s,
556 };
557 let cmd_args: Vec<String> = match opts.get("args") {
558 Some(VmValue::List(items)) => items.iter().map(|v| v.display()).collect(),
559 None | Some(VmValue::Nil) => Vec::new(),
560 Some(other) => {
561 return Err(VmError::Runtime(format!(
562 "spawn_captured: opts.args must be a list of strings, got {}",
563 other.type_name()
564 )));
565 }
566 };
567 let cwd = opts
568 .get("cwd")
569 .map(|v| v.display())
570 .filter(|s| !s.is_empty());
571 let env_overrides: Vec<(String, String)> = match opts.get("env") {
572 Some(VmValue::Dict(env)) => env
573 .iter()
574 .map(|(k, v)| (k.to_string(), v.display()))
575 .collect(),
576 None | Some(VmValue::Nil) => Vec::new(),
577 Some(other) => {
578 return Err(VmError::Runtime(format!(
579 "spawn_captured: opts.env must be a dict, got {}",
580 other.type_name()
581 )));
582 }
583 };
584 let stdin_bytes: Option<Vec<u8>> = match opts.get("stdin") {
585 Some(VmValue::Bytes(bytes)) => Some(bytes.as_slice().to_vec()),
586 Some(VmValue::String(s)) => Some(s.as_bytes().to_vec()),
587 None | Some(VmValue::Nil) => None,
588 Some(other) => {
589 return Err(VmError::Runtime(format!(
590 "spawn_captured: opts.stdin must be string or bytes, got {}",
591 other.type_name()
592 )));
593 }
594 };
595 let timeout = opts
596 .get("timeout_ms")
597 .and_then(|v| v.as_int())
598 .filter(|n| *n > 0)
599 .map(|n| Duration::from_millis(n as u64));
600
601 let spawn = CapturedSpawn {
602 label: "spawn_captured",
603 cmd: &cmd,
604 args: &cmd_args,
605 cwd: cwd.as_deref(),
606 env: &env_overrides,
607 env_clear: false,
611 stdin: stdin_bytes,
612 timeout,
613 };
614 let CapturedRun {
615 output,
616 timed_out,
617 interrupted,
618 duration_ms,
619 } = run_captured_spawn(spawn)?;
620
621 let exit_code = if timed_out || interrupted {
622 -1
623 } else {
624 output.status.code().unwrap_or(-1) as i64
625 };
626 let success = if timed_out || interrupted {
627 false
628 } else {
629 output.status.success()
630 };
631 let mut result = BTreeMap::new();
632 result.insert("exit_code".to_string(), VmValue::Int(exit_code));
633 result.put_str("stdout", String::from_utf8_lossy(&output.stdout).as_ref());
634 result.put_str("stderr", String::from_utf8_lossy(&output.stderr).as_ref());
635 result.insert("duration_ms".to_string(), VmValue::Int(duration_ms));
636 result.insert("success".to_string(), VmValue::Bool(success));
637 result.insert("timed_out".to_string(), VmValue::Bool(timed_out));
638 Ok(VmValue::dict(result))
639}
640
641struct CapturedSpawn<'a> {
646 label: &'static str,
647 cmd: &'a str,
648 args: &'a [String],
649 cwd: Option<&'a str>,
650 env: &'a [(String, String)],
651 env_clear: bool,
652 stdin: Option<Vec<u8>>,
653 timeout: Option<Duration>,
654}
655
656struct CapturedRun {
658 output: std::process::Output,
659 timed_out: bool,
660 interrupted: bool,
661 duration_ms: i64,
662}
663
664fn run_captured_spawn(spec: CapturedSpawn<'_>) -> Result<CapturedRun, VmError> {
676 let label = spec.label;
677 let mut command = std::process::Command::new(spec.cmd);
678 command.args(spec.args);
679 if let Some(cwd) = spec.cwd {
680 command.current_dir(cwd);
681 }
682 let resolved_environment = if spec.env_clear {
688 None
689 } else {
690 session_closed_env(spec.env.iter().cloned())?
691 };
692 if spec.env_clear || resolved_environment.is_some() {
693 command.env_clear();
694 }
695 for (key, value) in resolved_environment.as_deref().unwrap_or(spec.env) {
696 command.env(key, value);
697 }
698 command.stdout(Stdio::piped()).stderr(Stdio::piped());
699 if spec.stdin.is_some() {
700 command.stdin(Stdio::piped());
701 } else {
702 command.stdin(Stdio::null());
703 }
704 crate::op_interrupt::configure_kill_group(&mut command);
705 let cleanup_token = crate::op_interrupt::new_process_cleanup_token();
706 command.env(
707 crate::op_interrupt::PROCESS_CLEANUP_TOKEN_ENV,
708 &cleanup_token,
709 );
710
711 let started = Instant::now();
712 let cmd = spec.cmd;
713 let mut child = command.spawn().map_err(|error| {
714 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
715 "{label}: failed to spawn '{cmd}': {error}"
716 ))))
717 })?;
718
719 if let (Some(payload), Some(mut stdin)) = (spec.stdin, child.stdin.take()) {
720 let _ = stdin.write_all(&payload);
722 }
723
724 let rx_out = child
728 .stdout
729 .take()
730 .map(crate::op_interrupt::spawn_pipe_drain);
731 let rx_err = child
732 .stderr
733 .take()
734 .map(crate::op_interrupt::spawn_pipe_drain);
735
736 let child_pid = child.id();
737 let wait_end = crate::op_interrupt::wait_child_interruptible_with_cleanup_token(
738 &mut child,
739 spec.timeout,
740 Some(&cleanup_token),
741 )
742 .map_err(|error| {
743 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
744 "{label}: wait failed: {error}"
745 ))))
746 })?;
747 let (status, timed_out, interrupted, killed) = match wait_end {
748 crate::op_interrupt::ChildWait::Exited(status) => (status, false, false, false),
749 crate::op_interrupt::ChildWait::TimedOut(_) => {
750 (std::process::ExitStatus::default(), true, false, true)
751 }
752 crate::op_interrupt::ChildWait::Interrupted(status, _) => {
756 (status.unwrap_or_default(), false, true, true)
757 }
758 };
759
760 let stdout = rx_out
761 .map(|rx| crate::op_interrupt::drain_captured_pipe(&rx, killed, child_pid))
762 .unwrap_or_default();
763 let stderr = rx_err
764 .map(|rx| crate::op_interrupt::drain_captured_pipe(&rx, killed, child_pid))
765 .unwrap_or_default();
766
767 Ok(CapturedRun {
768 output: std::process::Output {
769 status,
770 stdout,
771 stderr,
772 },
773 timed_out,
774 interrupted,
775 duration_ms: started.elapsed().as_millis() as i64,
776 })
777}
778
779#[derive(Default)]
782struct ExecOptions {
783 env: Vec<(String, String)>,
784 env_clear: bool,
785 cwd: Option<String>,
786 timeout: Option<Duration>,
787}
788
789fn exec_options(label: &str, options: Option<&VmValue>) -> Result<ExecOptions, VmError> {
798 let opts = match options {
799 None | Some(VmValue::Nil) => return Ok(ExecOptions::default()),
800 Some(VmValue::Dict(opts)) => opts.clone(),
801 Some(other) => {
802 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
803 format!("{label}: options must be a dict, got {}", other.type_name()),
804 ))));
805 }
806 };
807 let env: Vec<(String, String)> = match opts.get("env") {
808 Some(VmValue::Dict(env)) => env
809 .iter()
810 .map(|(k, v)| (k.to_string(), v.display()))
811 .collect(),
812 None | Some(VmValue::Nil) => Vec::new(),
813 Some(other) => {
814 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
815 format!(
816 "{label}: options.env must be a dict, got {}",
817 other.type_name()
818 ),
819 ))));
820 }
821 };
822 let env_clear = match opts.get("env_mode").map(|v| v.display()).as_deref() {
823 None | Some("merge") => false,
824 Some("replace") => true,
825 Some(other) => {
826 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
827 format!(
828 "{label}: options.env_mode must be \"merge\" or \"replace\", got {other:?}"
829 ),
830 ))));
831 }
832 };
833 let cwd = opts
834 .get("cwd")
835 .map(|v| v.display())
836 .filter(|s| !s.is_empty());
837 let timeout = opts
840 .get("timeout")
841 .or_else(|| opts.get("timeout_ms"))
842 .and_then(|v| v.as_int())
843 .filter(|n| *n > 0)
844 .map(|n| Duration::from_millis(n as u64));
845 Ok(ExecOptions {
846 env,
847 env_clear,
848 cwd,
849 timeout,
850 })
851}
852
853fn captured_run_to_value(run: &CapturedRun) -> VmValue {
857 let status = if run.timed_out || run.interrupted {
858 -1
859 } else {
860 run.output.status.code().unwrap_or(-1) as i64
861 };
862 let success = !run.timed_out && !run.interrupted && run.output.status.success();
863 let mut result = BTreeMap::new();
864 result.put_str(
865 "stdout",
866 String::from_utf8_lossy(&run.output.stdout).as_ref(),
867 );
868 result.put_str(
869 "stderr",
870 String::from_utf8_lossy(&run.output.stderr).as_ref(),
871 );
872 result.insert("status".to_string(), VmValue::Int(status));
873 result.insert("success".to_string(), VmValue::Bool(success));
874 result.insert("timed_out".to_string(), VmValue::Bool(run.timed_out));
875 result.insert("duration_ms".to_string(), VmValue::Int(run.duration_ms));
876 VmValue::dict(result)
877}
878
879#[harn_builtin(
880 sig = "exec_opts(command: list, options: dict?) -> dict",
881 category = "process"
882)]
883fn exec_opts_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
884 let command = exec_opts_command("exec_opts", args.first())?;
885 let opts = exec_options("exec_opts", args.get(1))?;
886 let run = run_captured_spawn(CapturedSpawn {
887 label: "exec_opts",
888 cmd: &command[0],
889 args: &command[1..],
890 cwd: opts.cwd.as_deref(),
891 env: &opts.env,
892 env_clear: opts.env_clear,
893 stdin: None,
894 timeout: opts.timeout,
895 })?;
896 Ok(captured_run_to_value(&run))
897}
898
899#[harn_builtin(
900 sig = "exec_at_opts(dir: string, command: list, options: dict?) -> dict",
901 category = "process"
902)]
903fn exec_at_opts_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
904 let dir = match args.first() {
905 Some(value) if !value.display().is_empty() => value.display(),
906 _ => {
907 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
908 "exec_at_opts: directory is required",
909 ))));
910 }
911 };
912 let command = exec_opts_command("exec_at_opts", args.get(1))?;
913 let opts = exec_options("exec_at_opts", args.get(2))?;
914 let resolved_cwd = opts.cwd.unwrap_or(dir);
917 let run = run_captured_spawn(CapturedSpawn {
918 label: "exec_at_opts",
919 cmd: &command[0],
920 args: &command[1..],
921 cwd: Some(resolved_cwd.as_str()),
922 env: &opts.env,
923 env_clear: opts.env_clear,
924 stdin: None,
925 timeout: opts.timeout,
926 })?;
927 Ok(captured_run_to_value(&run))
928}
929
930fn exec_opts_command(label: &str, value: Option<&VmValue>) -> Result<Vec<String>, VmError> {
933 let items = match value {
934 Some(VmValue::List(items)) => items,
935 _ => {
936 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
937 format!("{label}: command must be a non-empty list of strings"),
938 ))));
939 }
940 };
941 let command: Vec<String> = items.iter().map(|v| v.display()).collect();
942 if command.is_empty() || command[0].is_empty() {
943 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
944 format!("{label}: command must be a non-empty list of strings"),
945 ))));
946 }
947 Ok(command)
948}
949
950pub fn find_project_root(base: &std::path::Path) -> Option<std::path::PathBuf> {
954 harn_modules::manifest_walk::find_project_root(base)
955}
956
957pub(crate) fn register_path_builtins(vm: &mut Vm) {
959 for def in PATH_BUILTINS {
960 vm.register_builtin_def(def);
961 }
962}
963
964#[harn_builtin(sig = "source_dir(...args: any) -> string", category = "process")]
965fn source_dir_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
966 let dir = VM_SOURCE_DIR.with(|sd| sd.borrow().clone());
967 match dir {
968 Some(d) => Ok(VmValue::String(arcstr::ArcStr::from(
969 d.to_string_lossy().into_owned(),
970 ))),
971 None => {
972 let cwd = std::env::current_dir()
973 .map(|p| p.to_string_lossy().into_owned())
974 .unwrap_or_default();
975 Ok(VmValue::String(arcstr::ArcStr::from(cwd)))
976 }
977 }
978}
979
980#[harn_builtin(sig = "project_root() -> string?", category = "process")]
981fn project_root_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
982 if let Some(root) = project_root_path() {
983 return Ok(VmValue::String(arcstr::ArcStr::from(
984 root.to_string_lossy().as_ref(),
985 )));
986 }
987 let base = current_execution_context()
988 .and_then(|context| context.cwd.map(PathBuf::from))
989 .or_else(|| VM_SOURCE_DIR.with(|sd| sd.borrow().clone()))
990 .or_else(|| std::env::current_dir().ok())
991 .unwrap_or_else(|| PathBuf::from("."));
992 match find_project_root(&base) {
993 Some(root) => Ok(VmValue::String(arcstr::ArcStr::from(
994 root.to_string_lossy().into_owned(),
995 ))),
996 None => Ok(VmValue::Nil),
997 }
998}
999
1000const PATH_BUILTINS: &[&VmBuiltinDef] = &[&SOURCE_DIR_IMPL_DEF, &PROJECT_ROOT_IMPL_DEF];
1001
1002fn vm_output_to_value(output: std::process::Output) -> VmValue {
1003 let mut result = BTreeMap::new();
1004 result.put_str("stdout", String::from_utf8_lossy(&output.stdout).as_ref());
1005 result.put_str("stderr", String::from_utf8_lossy(&output.stderr).as_ref());
1006 result.insert(
1007 "status".to_string(),
1008 VmValue::Int(output.status.code().unwrap_or(-1) as i64),
1009 );
1010 result.insert(
1011 "success".to_string(),
1012 VmValue::Bool(output.status.success()),
1013 );
1014 VmValue::dict(result)
1015}
1016
1017fn exec_command(
1018 dir: Option<&str>,
1019 cmd: &str,
1020 args: &[String],
1021) -> Result<std::process::Output, VmError> {
1022 let config = process_command_config(dir)?;
1023 crate::stdlib::sandbox::command_output(cmd, args, &config)
1024 .map_err(|error| prefix_process_error(error, "exec"))
1025}
1026
1027fn exec_shell_args(
1028 dir: Option<&str>,
1029 shell: &str,
1030 args: &[String],
1031) -> Result<std::process::Output, VmError> {
1032 let config = process_command_config(dir)?;
1033 crate::stdlib::sandbox::command_output(shell, args, &config)
1034 .map_err(|error| prefix_process_error(error, "shell"))
1035}
1036
1037fn process_command_config(
1038 dir: Option<&str>,
1039) -> Result<crate::stdlib::sandbox::ProcessCommandConfig, VmError> {
1040 let mut config = crate::stdlib::sandbox::ProcessCommandConfig {
1041 stdin_null: true,
1042 ..Default::default()
1043 };
1044 if let Some(dir) = dir {
1045 let resolved = resolve_command_dir(dir);
1046 crate::stdlib::sandbox::enforce_process_cwd(&resolved)?;
1047 config.cwd = Some(resolved);
1048 } else if let Some(context) = current_execution_context() {
1049 if let Some(cwd) = context.cwd.filter(|cwd| !cwd.is_empty()) {
1050 crate::stdlib::sandbox::enforce_process_cwd(std::path::Path::new(&cwd))?;
1051 config.cwd = Some(std::path::PathBuf::from(cwd));
1052 }
1053 if !context.env.is_empty() {
1054 config.env.extend(context.env);
1055 }
1056 }
1057 if let Some(value) = env_override(HARN_REPLAY_ENV) {
1058 config.env.push((HARN_REPLAY_ENV.to_string(), value));
1059 }
1060 if let Some(env) = session_closed_env(config.env.iter().cloned())? {
1064 config.env = env;
1065 config.closed_env = true;
1066 }
1067 Ok(config)
1068}
1069
1070pub(crate) fn session_closed_env(
1086 overlay: impl Iterator<Item = (String, String)>,
1087) -> Result<Option<Vec<(String, String)>>, VmError> {
1088 let Some(mut env) = session_env()? else {
1089 return Ok(None);
1090 };
1091 env.extend(overlay);
1092 Ok(Some(env.into_iter().collect()))
1093}
1094
1095pub(crate) fn session_env() -> Result<Option<BTreeMap<String, String>>, VmError> {
1098 let Some(environment) = current_session_environment() else {
1099 return Ok(None);
1100 };
1101 let workspace_defaults = workspace_env_defaults();
1102 let mut env = crate::security::resolve_env(
1103 &environment,
1104 &session_env_lookup(&workspace_defaults),
1105 &resolve_grant_secret,
1106 )
1107 .map_err(grant_env_error)?;
1108 for (key, value) in workspace_defaults {
1109 if !environment
1110 .grants()
1111 .iter()
1112 .any(|grant| grant.exposed_env_var() == Some(key.as_str()))
1113 {
1114 env.insert(key, value);
1115 }
1116 }
1117 Ok(Some(env))
1118}
1119
1120pub(crate) fn session_env_var(name: &str) -> Result<Option<String>, VmError> {
1135 let Some(environment) = current_session_environment() else {
1136 return Ok(std::env::var(name).ok());
1137 };
1138 let workspace_defaults = workspace_env_defaults();
1139 let is_grant_target = environment
1140 .grants()
1141 .iter()
1142 .any(|grant| grant.exposed_env_var() == Some(name));
1143 if !is_grant_target {
1144 if let Some(value) = workspace_defaults.get(name) {
1145 return Ok(Some(value.clone()));
1146 }
1147 }
1148 let resolved = crate::security::lookup_env(
1149 &environment,
1150 name,
1151 &session_env_lookup(&workspace_defaults),
1152 &resolve_grant_secret,
1153 )
1154 .map_err(grant_env_error)?;
1155 Ok(resolved)
1156}
1157
1158pub(crate) fn session_env_value(name: &str) -> Option<String> {
1162 if current_session_environment().is_none() {
1163 return crate::test_env::env_var_seamed(name);
1164 }
1165 session_env_var(name).ok().flatten()
1166}
1167
1168fn workspace_env_defaults() -> BTreeMap<String, String> {
1173 crate::process_sandbox::active_workspace_process_env()
1174 .into_iter()
1175 .collect()
1176}
1177
1178fn session_env_lookup(
1181 workspace_defaults: &BTreeMap<String, String>,
1182) -> impl Fn(&str) -> Option<String> + '_ {
1183 |name: &str| {
1184 workspace_defaults
1185 .get(name)
1186 .cloned()
1187 .or_else(|| std::env::var(name).ok())
1188 }
1189}
1190
1191fn grant_env_error(error: crate::security::EnvironmentPolicyError) -> VmError {
1192 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
1193 "session grant env resolution failed: {error}"
1194 ))))
1195}
1196
1197fn resolve_grant_secret(account: &str, key: &str) -> Option<String> {
1204 let reference = format!("{}{}/{}", crate::secrets::SECRET_REF_SCHEME, account, key);
1205 crate::secrets::resolve_secret_ref_to_string(&reference)
1206 .ok()
1207 .flatten()
1208}
1209
1210fn prefix_process_error(error: VmError, prefix: &str) -> VmError {
1211 match error {
1212 VmError::Thrown(VmValue::String(message)) => VmError::Thrown(VmValue::String(
1213 arcstr::ArcStr::from(format!("{prefix} failed: {message}")),
1214 )),
1215 other => other,
1216 }
1217}
1218
1219fn resolve_command_dir(dir: &str) -> PathBuf {
1220 let candidate = PathBuf::from(dir);
1221 if candidate.is_absolute() {
1222 return candidate;
1223 }
1224 if let Some(cwd) = current_execution_context().and_then(|context| context.cwd) {
1225 return PathBuf::from(cwd).join(candidate);
1226 }
1227 if let Some(source_dir) = VM_SOURCE_DIR.with(|sd| sd.borrow().clone()) {
1228 return source_dir.join(candidate);
1229 }
1230 candidate
1231}
1232
1233#[cfg(test)]
1234#[path = "process_tests.rs"]
1235mod tests;