Skip to main content

leviath_cli/daemon/
seed_command.rs

1//! Execution of `seed = { command = "..." }` region seeds.
2//!
3//! A command seed runs a shell command in the run's workdir at spawn and puts
4//! its combined stdout/stderr into the region - but only when the command
5//! *succeeds*; a non-zero exit is reported as an error so a diagnostic never
6//! masquerades as data. It is the only seed source that *executes* anything, and
7//! it does so before the first inference - therefore before any tool-approval
8//! prompt - so it is deliberately hemmed in:
9//! -
10//! it is skipped entirely unless [`SeedCommandPolicy::allowed`] (the
11//!   `[security] allow_seed_commands` config switch and the `--no-seed-commands`
12//!   launch flag); -
13//! it runs inside the entry stage's sandbox when the agent declares one,
14//!   using the same [`ShellExecutor::build_command`] routing as the built-in
15//!   `shell` tool, so a seed can't escape the isolation the stage asked for; -
16//! it is capped in wall-clock time (`[limits] script_shell_timeout_secs`) and
17//!   in output size (`cap_script_io`); -
18//! it never runs on restart - [`crate::daemon::spawn`] only resolves seeds on
19//!   a fresh spawn.
20
21use std::path::Path;
22use std::sync::Arc;
23use std::time::Duration;
24
25use leviath_tools::ShellExecutor;
26use tokio::process::Command as TokioCommand;
27
28use crate::daemon::sandbox_manager::SandboxManager;
29use crate::daemon::script_host::{
30    cap_script_io, combine_shell_output, default_shell, host_shell_command,
31};
32
33/// Runs one seed command: `(command, workdir, timeout) -> combined output`.
34///
35/// Injected rather than called directly so the failure arms (timeout, spawn
36/// failure, non-zero exit) are testable without spawning real processes. The
37/// production implementation is built by [`SeedCommandPolicy::new`]. Mirrors
38/// the `BrowserOpener` seam.
39pub type SeedCommandRunner =
40    Arc<dyn Fn(&str, &Path, Duration) -> Result<String, String> + Send + Sync>;
41
42/// How command seeds are executed for one spawn.
43#[derive(Clone)]
44pub struct SeedCommandPolicy {
45    /// Whether command seeds may run at all. `false` makes every command seed a
46    /// no-op (a warning, or a hard error when the region is `required`).
47    pub allowed: bool,
48    /// Wall-clock cap on a single seed command.
49    pub timeout: Duration,
50    /// The executor.
51    pub runner: SeedCommandRunner,
52}
53
54impl SeedCommandPolicy {
55    /// The production policy: run through `sandbox` when the agent declares one,
56    /// else on the host, both targeting the run's workdir.
57    pub fn new(allowed: bool, timeout: Duration, sandbox: Option<Arc<SandboxManager>>) -> Self {
58        Self {
59            allowed,
60            timeout,
61            runner: seed_command_runner(sandbox),
62        }
63    }
64
65    /// A policy that never runs anything - used on the reload/restore path and
66    /// wherever seeds are resolved without a live sandbox.
67    pub fn disabled() -> Self {
68        Self {
69            allowed: false,
70            timeout: Duration::from_secs(0),
71            runner: Arc::new(|_, _, _| Err("command seeds are disabled".to_string())),
72        }
73    }
74
75    /// Run `command` in `workdir` under this policy.
76    pub fn run(&self, command: &str, workdir: &Path) -> Result<String, String> {
77        (self.runner)(command, workdir, self.timeout)
78    }
79}
80
81impl std::fmt::Debug for SeedCommandPolicy {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        f.debug_struct("SeedCommandPolicy")
84            .field("allowed", &self.allowed)
85            .field("timeout", &self.timeout)
86            .finish_non_exhaustive()
87    }
88}
89
90/// Build the production [`SeedCommandRunner`], capturing the agent's sandbox
91/// manager (if any) so every seed command is routed exactly like the built-in
92/// `shell` tool would route it for the entry stage.
93fn seed_command_runner(sandbox: Option<Arc<SandboxManager>>) -> SeedCommandRunner {
94    Arc::new(move |command, workdir, timeout| {
95        run_seed_command(
96            build_seed_command(sandbox.as_deref(), command, workdir),
97            timeout,
98        )
99    })
100}
101
102/// Build the command for a seed: through the agent's sandbox when it has one,
103/// else straight onto the host - both targeting the run's workdir.
104///
105/// Split from execution so the routing decision is assertable without spawning
106/// anything (and without depending on whether the host's namespaces actually
107/// work, which varies by machine and by CI runner).
108fn build_seed_command(
109    sandbox: Option<&SandboxManager>,
110    command: &str,
111    workdir: &Path,
112) -> TokioCommand {
113    let (shell, flag) = default_shell();
114    match sandbox {
115        Some(sb) => sb.build_command(shell, flag, command, workdir),
116        None => host_shell_command(shell, flag, command, workdir),
117    }
118}
119
120/// Drive `cmd` to completion with a wall-clock cap, returning its combined
121/// stdout+stderr (capped by `cap_script_io`) on success.
122///
123/// **A non-zero exit is an error, not data.** The combined output of a failed
124/// command is a diagnostic - `git ls-files` outside a repository prints
125/// `fatal: not a git repository` - and returning it as the seed value would
126/// plant that text in a pinned region as though it were the file listing the
127/// blueprint promised. The caller logs it and leaves the region empty instead
128/// (or fails the spawn, when the region is `required`).
129///
130/// This runs on a freshly spawned OS thread with its own current-thread runtime
131/// rather than reusing the ambient one. `resolve_seeds` is a synchronous
132/// function called from an async context, so `Handle::current().block_on(...)` -
133/// the trick `RealScriptIo::run_shell` uses from its `spawn_blocking` thread -
134/// would panic here. A dedicated thread has no ambient runtime, and going
135/// through tokio (rather than `std::process`) buys a real timeout: dropping the
136/// `output()` future on expiry kills the child via `kill_on_drop`, instead of
137/// orphaning it.
138fn run_seed_command(mut cmd: TokioCommand, timeout: Duration) -> Result<String, String> {
139    cmd.kill_on_drop(true);
140    // One per seeded region at spawn, and `output()` pipes both streams, so
141    // there is no console for this to want on Windows.
142    leviath_tools::hide_console_window(&mut cmd);
143    std::thread::spawn(move || {
144        // A current-thread runtime with no ambient runtime present only fails on
145        // OS resource exhaustion, at which point the spawn itself is doomed
146        // (mirrors `RealScriptIo::client`'s `.expect`).
147        let rt = tokio::runtime::Builder::new_current_thread()
148            .enable_all()
149            .build()
150            .expect("current-thread runtime for a seed command always builds");
151        rt.block_on(async move {
152            match tokio::time::timeout(timeout, cmd.output()).await {
153                Ok(Ok(output)) => {
154                    let combined =
155                        cap_script_io(combine_shell_output(&output.stdout, &output.stderr));
156                    if output.status.success() {
157                        Ok(combined)
158                    } else {
159                        Err(format!(
160                            "seed command exited with {}: {}",
161                            output.status,
162                            combined.trim()
163                        ))
164                    }
165                }
166                Ok(Err(e)) => Err(format!("failed to spawn seed command: {e}")),
167                Err(_) => Err(format!(
168                    "seed command timed out after {}s",
169                    timeout.as_secs()
170                )),
171            }
172        })
173    })
174    .join()
175    // The closure above has no fallible unwraps, so it cannot unwind.
176    .expect("seed command thread does not panic")
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn disabled_policy_refuses_to_run() {
185        let policy = SeedCommandPolicy::disabled();
186        assert!(!policy.allowed);
187        let err = policy.run("echo hi", Path::new(".")).unwrap_err();
188        assert!(err.contains("disabled"), "got: {err}");
189    }
190
191    #[test]
192    fn debug_impl_reports_the_switches() {
193        let policy = SeedCommandPolicy::new(true, Duration::from_secs(7), None);
194        let rendered = format!("{policy:?}");
195        assert!(rendered.contains("allowed: true"), "got: {rendered}");
196        assert!(rendered.contains('7'), "got: {rendered}");
197    }
198
199    #[test]
200    fn injected_runner_is_used_and_receives_the_policy_timeout() {
201        let policy = SeedCommandPolicy {
202            allowed: true,
203            timeout: Duration::from_secs(3),
204            runner: Arc::new(|command, workdir, timeout| {
205                Ok(format!(
206                    "{command}|{}|{}",
207                    workdir.display(),
208                    timeout.as_secs()
209                ))
210            }),
211        };
212        assert_eq!(
213            policy.run("ls", Path::new("/w")).unwrap(),
214            "ls|/w|3".to_string()
215        );
216    }
217
218    /// The real runner, end-to-end, on a command that exists on every platform
219    /// (`echo` is a builtin of both `/bin/sh` and `cmd.exe`).
220    #[test]
221    fn real_runner_captures_stdout() {
222        let policy = SeedCommandPolicy::new(true, Duration::from_secs(30), None);
223        let out = policy
224            .run("echo leviath-seed-ok", &std::env::temp_dir())
225            .unwrap();
226        assert!(out.contains("leviath-seed-ok"), "got: {out}");
227    }
228
229    /// A non-zero exit is an error, and its output is reported as a diagnostic
230    /// rather than handed back as the seed value.
231    #[test]
232    fn real_runner_treats_a_non_zero_exit_as_an_error() {
233        let policy = SeedCommandPolicy::new(true, Duration::from_secs(30), None);
234        // `exit 3` after printing: portable across sh and cmd.exe.
235        let err = policy
236            .run("echo before-failure && exit 3", &std::env::temp_dir())
237            .unwrap_err();
238        assert!(err.contains("exited with"), "got: {err}");
239        // The output is preserved in the message so the warning is diagnosable.
240        assert!(err.contains("before-failure"), "got: {err}");
241    }
242
243    /// The real motivating case: `git ls-files` outside a repository. Its
244    /// `fatal: not a git repository` must never become the region's content.
245    #[test]
246    fn real_runner_rejects_git_ls_files_outside_a_repository() {
247        let outside = tempfile::tempdir().unwrap();
248        let policy = SeedCommandPolicy::new(true, Duration::from_secs(30), None);
249        // A bare temp dir may still sit under a repo on some machines; force the
250        // failure deterministically by pointing git at a nonexistent work tree.
251        let err = policy
252            .run(
253                "git --git-dir=./definitely-not-a-repo ls-files",
254                outside.path(),
255            )
256            .unwrap_err();
257        assert!(err.contains("exited with"), "got: {err}");
258    }
259
260    /// The timeout arm kills the child rather than hanging the spawn.
261    #[test]
262    fn real_runner_times_out_a_long_command() {
263        let policy = SeedCommandPolicy::new(true, Duration::from_millis(150), None);
264        // Each platform's own idiom for "sleep". `#[cfg]` rather than `cfg!` so
265        // only the arm for THIS platform is compiled - the other would otherwise
266        // count as unreachable code against the coverage gate.
267        #[cfg(windows)]
268        let long = "ping -n 30 127.0.0.1 > NUL";
269        #[cfg(not(windows))]
270        let long = "sleep 30";
271        let err = policy.run(long, &std::env::temp_dir()).unwrap_err();
272        assert!(err.contains("timed out"), "got: {err}");
273    }
274
275    /// A command the shell cannot run is an error (the shell exits non-zero),
276    /// reported with its diagnostic rather than blowing up the spawn.
277    #[test]
278    fn real_runner_surfaces_a_missing_program() {
279        let policy = SeedCommandPolicy::new(true, Duration::from_secs(30), None);
280        let err = policy
281            .run("leviath-no-such-program-xyz", &std::env::temp_dir())
282            .unwrap_err();
283        assert!(err.contains("exited with"), "got: {err}");
284    }
285
286    /// Without a sandbox the command goes straight to the platform shell.
287    #[test]
288    fn an_unsandboxed_seed_command_uses_the_platform_shell() {
289        let cmd = build_seed_command(None, "echo hi", Path::new("/w"));
290        assert_eq!(cmd.as_std().get_program(), default_shell().0);
291    }
292
293    /// With a sandbox attached the command is built BY the manager rather than
294    /// straight onto the host - the same routing the built-in `shell` tool uses,
295    /// so a seed can't escape the isolation the entry stage declared.
296    ///
297    /// This asserts the routing, not the execution: whether a namespace is
298    /// actually usable varies by machine (and CI runners probe as supporting
299    /// them while refusing the uid_map write), so running the command here would
300    /// be testing the kernel, not this code.
301    #[test]
302    fn a_sandboxed_seed_command_is_built_through_the_manager() {
303        let by_index = vec![leviath_core::ToolSandboxConfig {
304            kind: leviath_core::SandboxKind::Namespace,
305            on_unavailable: leviath_core::OnUnavailable::Warn,
306            ..Default::default()
307        }];
308        let manager = SandboxManager::build("seed-test", by_index, "/w", 0)
309            .expect("a warn-fallback namespace sandbox always builds")
310            .expect("an active sandbox config yields a manager");
311
312        let cmd = build_seed_command(Some(&manager), "echo hi", Path::new("/w"));
313        // Where namespaces work this is the namespace binary; where they don't
314        // the manager falls back to the shell. Either way the manager built it.
315        assert!(!cmd.as_std().get_program().is_empty());
316    }
317
318    /// The `Ok(Err(_))` arm: the shell binary itself cannot be spawned. Built
319    /// directly (not via `default_shell`) so it is reachable on every platform.
320    #[test]
321    fn run_seed_command_reports_a_spawn_failure() {
322        let mut cmd = TokioCommand::new("leviath-definitely-not-a-shell-xyz");
323        cmd.arg("-c").arg("echo hi");
324        let err = run_seed_command(cmd, Duration::from_secs(5)).unwrap_err();
325        assert!(err.contains("failed to spawn seed command"), "got: {err}");
326    }
327}