Skip to main content

orchestrator_runner/runner/
spawn.rs

1use super::policy::enforce_runner_policy;
2use super::profile::ResolvedExecutionProfile;
3use super::sandbox::{build_command_for_profile, classify_sandbox_spawn_error};
4use crate::output_capture::{OutputCaptureHandles, spawn_sanitized_output_capture};
5use anyhow::{Context, Result};
6use orchestrator_config::config::{RunnerConfig, RunnerExecutorKind, RunnerPolicy};
7use std::fs::File;
8use std::path::Path;
9use std::process::Stdio;
10
11/// Groups the inputs required to spawn a runner command.
12pub struct SpawnParams<'a> {
13    /// Runner configuration describing shell and policy settings.
14    pub runner: &'a RunnerConfig,
15    /// Command string to execute.
16    pub command: &'a str,
17    /// Working directory for the spawned process.
18    pub cwd: &'a Path,
19    /// StdIO wiring strategy to apply to the child process.
20    pub stdio_mode: RunnerStdioMode,
21    /// Extra environment variables resolved for the selected agent.
22    pub extra_env: &'a std::collections::HashMap<String, String>,
23    /// Whether stdin should be piped to the child.
24    pub pipe_stdin: bool,
25    /// Resolved execution profile controlling sandbox behavior.
26    pub execution_profile: &'a ResolvedExecutionProfile,
27}
28
29/// Selects how the runner child's stdout and stderr are wired.
30pub enum RunnerStdioMode {
31    /// Redirects stdout and stderr into provided files.
32    Files {
33        /// File receiving stdout bytes.
34        stdout: File,
35        /// File receiving stderr bytes.
36        stderr: File,
37    },
38    /// Captures stdout and stderr through Tokio pipes.
39    Piped,
40}
41
42/// Abstraction over runner process spawning backends.
43pub trait RunnerExecutor {
44    /// Spawns a runner child process using the supplied parameters.
45    fn spawn(&self, params: SpawnParams<'_>) -> Result<tokio::process::Child>;
46}
47
48#[derive(Debug, Default)]
49/// Default runner executor that shells out through the configured shell binary.
50pub struct ShellRunnerExecutor;
51
52impl RunnerExecutor for ShellRunnerExecutor {
53    fn spawn(&self, params: SpawnParams<'_>) -> Result<tokio::process::Child> {
54        let SpawnParams {
55            runner,
56            command,
57            cwd,
58            stdio_mode,
59            extra_env,
60            pipe_stdin,
61            execution_profile,
62        } = params;
63
64        enforce_runner_policy(runner, command)?;
65
66        // In self-referential workspaces, guard against commands that would
67        // kill the daemon process.  The presence of ORCHESTRATOR_DAEMON_PID in
68        // extra_env signals that the self-referential guard is active.
69        if let Some(pid_str) = extra_env.get("ORCHESTRATOR_DAEMON_PID") {
70            if let Ok(daemon_pid) = pid_str.parse::<u32>() {
71                super::policy::guard_daemon_pid_kill(command, daemon_pid)?;
72            }
73        }
74
75        let mut cmd = build_command_for_profile(runner, command, cwd, execution_profile)?;
76
77        match stdio_mode {
78            RunnerStdioMode::Files { stdout, stderr } => {
79                cmd.stdout(Stdio::from(stdout)).stderr(Stdio::from(stderr));
80            }
81            RunnerStdioMode::Piped => {
82                cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
83            }
84        }
85
86        cmd.kill_on_drop(true);
87
88        if pipe_stdin {
89            cmd.stdin(Stdio::piped());
90        }
91
92        #[cfg(unix)]
93        {
94            use super::resource_limits::apply_unix_resource_limits_to_command;
95            cmd.process_group(0); // child becomes its own process group leader
96            apply_unix_resource_limits_to_command(&mut cmd, execution_profile)?;
97        }
98
99        if runner.policy == RunnerPolicy::Allowlist {
100            cmd.env_clear();
101            for key in &runner.env_allowlist {
102                if let Ok(value) = std::env::var(key) {
103                    cmd.env(key, value);
104                }
105            }
106        }
107
108        // Inject agent-specific extra env vars (from EnvStore/SecretStore/direct)
109        for (k, v) in extra_env {
110            cmd.env(k, v);
111        }
112
113        // Remove CLAUDECODE env var so spawned `claude -p` processes don't
114        // refuse to start due to nested session detection.
115        cmd.env_remove("CLAUDECODE");
116
117        match cmd.spawn() {
118            Ok(child) => Ok(child),
119            Err(err) => {
120                if let Some(sandbox_err) = classify_sandbox_spawn_error(execution_profile, &err) {
121                    return Err(sandbox_err.into());
122                }
123                Err(err).with_context(|| {
124                    format!(
125                        "failed to spawn runner shell={} shell_arg={}",
126                        runner.shell, runner.shell_arg
127                    )
128                })
129            }
130        }
131    }
132}
133
134#[allow(clippy::too_many_arguments)]
135/// Spawns a runner process and routes output directly to files.
136pub fn spawn_with_runner(
137    runner: &RunnerConfig,
138    command: &str,
139    cwd: &Path,
140    stdout: File,
141    stderr: File,
142    extra_env: &std::collections::HashMap<String, String>,
143    pipe_stdin: bool,
144    execution_profile: &ResolvedExecutionProfile,
145) -> Result<tokio::process::Child> {
146    match runner.executor {
147        RunnerExecutorKind::Shell => ShellRunnerExecutor.spawn(SpawnParams {
148            runner,
149            command,
150            cwd,
151            stdio_mode: RunnerStdioMode::Files { stdout, stderr },
152            extra_env,
153            pipe_stdin,
154            execution_profile,
155        }),
156    }
157}
158
159/// Bundles a spawned child process with its asynchronous output capture handles.
160pub struct CapturedChild {
161    /// Spawned child process.
162    pub child: tokio::process::Child,
163    /// Background tasks that sanitize and persist captured output streams.
164    pub output_capture: OutputCaptureHandles,
165}
166
167#[allow(clippy::too_many_arguments)]
168/// Spawns a runner process with piped output and starts redacted output capture.
169pub fn spawn_with_runner_and_capture(
170    runner: &RunnerConfig,
171    command: &str,
172    cwd: &Path,
173    stdout: File,
174    stderr: File,
175    redaction_patterns: Vec<String>,
176    extra_env: &std::collections::HashMap<String, String>,
177    pipe_stdin: bool,
178    execution_profile: &ResolvedExecutionProfile,
179) -> Result<CapturedChild> {
180    let mut child = match runner.executor {
181        RunnerExecutorKind::Shell => ShellRunnerExecutor.spawn(SpawnParams {
182            runner,
183            command,
184            cwd,
185            stdio_mode: RunnerStdioMode::Piped,
186            extra_env,
187            pipe_stdin,
188            execution_profile,
189        })?,
190    };
191    let child_stdout = child
192        .stdout
193        .take()
194        .context("captured runner child missing stdout pipe")?;
195    let child_stderr = child
196        .stderr
197        .take()
198        .context("captured runner child missing stderr pipe")?;
199    let output_capture = spawn_sanitized_output_capture(
200        child_stdout,
201        child_stderr,
202        stdout,
203        stderr,
204        redaction_patterns,
205    );
206    Ok(CapturedChild {
207        child,
208        output_capture,
209    })
210}
211
212/// Kill the entire process group rooted at the child process.
213///
214/// Because we spawn children with `process_group(0)`, the child PID equals
215/// its PGID.  Sending `SIGKILL` to the negated PID kills every process in
216/// that group (child + all descendants).  On non-Unix platforms we fall back
217/// to the regular per-process kill.
218pub async fn kill_child_process_group(child: &mut tokio::process::Child) {
219    if let Some(pid) = child.id() {
220        #[cfg(unix)]
221        {
222            // SAFETY: kill(-pid, SIGKILL) is a POSIX syscall that sends a
223            // signal to a process group.  The pid was obtained from a child we
224            // spawned, so the group exists and belongs to us.
225            unsafe {
226                libc::kill(-(pid as i32), libc::SIGKILL);
227            }
228        }
229        #[cfg(not(unix))]
230        {
231            let _ = child.kill().await;
232        }
233    } else {
234        let _ = child.kill().await;
235    }
236}