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    std::thread::spawn(move || {
141        // A current-thread runtime with no ambient runtime present only fails on
142        // OS resource exhaustion, at which point the spawn itself is doomed
143        // (mirrors `RealScriptIo::client`'s `.expect`).
144        let rt = tokio::runtime::Builder::new_current_thread()
145            .enable_all()
146            .build()
147            .expect("current-thread runtime for a seed command always builds");
148        rt.block_on(async move {
149            match tokio::time::timeout(timeout, cmd.output()).await {
150                Ok(Ok(output)) => {
151                    let combined =
152                        cap_script_io(combine_shell_output(&output.stdout, &output.stderr));
153                    if output.status.success() {
154                        Ok(combined)
155                    } else {
156                        Err(format!(
157                            "seed command exited with {}: {}",
158                            output.status,
159                            combined.trim()
160                        ))
161                    }
162                }
163                Ok(Err(e)) => Err(format!("failed to spawn seed command: {e}")),
164                Err(_) => Err(format!(
165                    "seed command timed out after {}s",
166                    timeout.as_secs()
167                )),
168            }
169        })
170    })
171    .join()
172    // The closure above has no fallible unwraps, so it cannot unwind.
173    .expect("seed command thread does not panic")
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn disabled_policy_refuses_to_run() {
182        let policy = SeedCommandPolicy::disabled();
183        assert!(!policy.allowed);
184        let err = policy.run("echo hi", Path::new(".")).unwrap_err();
185        assert!(err.contains("disabled"), "got: {err}");
186    }
187
188    #[test]
189    fn debug_impl_reports_the_switches() {
190        let policy = SeedCommandPolicy::new(true, Duration::from_secs(7), None);
191        let rendered = format!("{policy:?}");
192        assert!(rendered.contains("allowed: true"), "got: {rendered}");
193        assert!(rendered.contains('7'), "got: {rendered}");
194    }
195
196    #[test]
197    fn injected_runner_is_used_and_receives_the_policy_timeout() {
198        let policy = SeedCommandPolicy {
199            allowed: true,
200            timeout: Duration::from_secs(3),
201            runner: Arc::new(|command, workdir, timeout| {
202                Ok(format!(
203                    "{command}|{}|{}",
204                    workdir.display(),
205                    timeout.as_secs()
206                ))
207            }),
208        };
209        assert_eq!(
210            policy.run("ls", Path::new("/w")).unwrap(),
211            "ls|/w|3".to_string()
212        );
213    }
214
215    /// The real runner, end-to-end, on a command that exists on every platform
216    /// (`echo` is a builtin of both `/bin/sh` and `cmd.exe`).
217    #[test]
218    fn real_runner_captures_stdout() {
219        let policy = SeedCommandPolicy::new(true, Duration::from_secs(30), None);
220        let out = policy
221            .run("echo leviath-seed-ok", &std::env::temp_dir())
222            .unwrap();
223        assert!(out.contains("leviath-seed-ok"), "got: {out}");
224    }
225
226    /// A non-zero exit is an error, and its output is reported as a diagnostic
227    /// rather than handed back as the seed value.
228    #[test]
229    fn real_runner_treats_a_non_zero_exit_as_an_error() {
230        let policy = SeedCommandPolicy::new(true, Duration::from_secs(30), None);
231        // `exit 3` after printing: portable across sh and cmd.exe.
232        let err = policy
233            .run("echo before-failure && exit 3", &std::env::temp_dir())
234            .unwrap_err();
235        assert!(err.contains("exited with"), "got: {err}");
236        // The output is preserved in the message so the warning is diagnosable.
237        assert!(err.contains("before-failure"), "got: {err}");
238    }
239
240    /// The real motivating case: `git ls-files` outside a repository. Its
241    /// `fatal: not a git repository` must never become the region's content.
242    #[test]
243    fn real_runner_rejects_git_ls_files_outside_a_repository() {
244        let outside = tempfile::tempdir().unwrap();
245        let policy = SeedCommandPolicy::new(true, Duration::from_secs(30), None);
246        // A bare temp dir may still sit under a repo on some machines; force the
247        // failure deterministically by pointing git at a nonexistent work tree.
248        let err = policy
249            .run(
250                "git --git-dir=./definitely-not-a-repo ls-files",
251                outside.path(),
252            )
253            .unwrap_err();
254        assert!(err.contains("exited with"), "got: {err}");
255    }
256
257    /// The timeout arm kills the child rather than hanging the spawn.
258    #[test]
259    fn real_runner_times_out_a_long_command() {
260        let policy = SeedCommandPolicy::new(true, Duration::from_millis(150), None);
261        // Each platform's own idiom for "sleep". `#[cfg]` rather than `cfg!` so
262        // only the arm for THIS platform is compiled - the other would otherwise
263        // count as unreachable code against the coverage gate.
264        #[cfg(windows)]
265        let long = "ping -n 30 127.0.0.1 > NUL";
266        #[cfg(not(windows))]
267        let long = "sleep 30";
268        let err = policy.run(long, &std::env::temp_dir()).unwrap_err();
269        assert!(err.contains("timed out"), "got: {err}");
270    }
271
272    /// A command the shell cannot run is an error (the shell exits non-zero),
273    /// reported with its diagnostic rather than blowing up the spawn.
274    #[test]
275    fn real_runner_surfaces_a_missing_program() {
276        let policy = SeedCommandPolicy::new(true, Duration::from_secs(30), None);
277        let err = policy
278            .run("leviath-no-such-program-xyz", &std::env::temp_dir())
279            .unwrap_err();
280        assert!(err.contains("exited with"), "got: {err}");
281    }
282
283    /// Without a sandbox the command goes straight to the platform shell.
284    #[test]
285    fn an_unsandboxed_seed_command_uses_the_platform_shell() {
286        let cmd = build_seed_command(None, "echo hi", Path::new("/w"));
287        assert_eq!(cmd.as_std().get_program(), default_shell().0);
288    }
289
290    /// With a sandbox attached the command is built BY the manager rather than
291    /// straight onto the host - the same routing the built-in `shell` tool uses,
292    /// so a seed can't escape the isolation the entry stage declared.
293    ///
294    /// This asserts the routing, not the execution: whether a namespace is
295    /// actually usable varies by machine (and CI runners probe as supporting
296    /// them while refusing the uid_map write), so running the command here would
297    /// be testing the kernel, not this code.
298    #[test]
299    fn a_sandboxed_seed_command_is_built_through_the_manager() {
300        let by_index = vec![leviath_core::ToolSandboxConfig {
301            kind: leviath_core::SandboxKind::Namespace,
302            on_unavailable: leviath_core::OnUnavailable::Warn,
303            ..Default::default()
304        }];
305        let manager = SandboxManager::build("seed-test", by_index, "/w", 0)
306            .expect("a warn-fallback namespace sandbox always builds")
307            .expect("an active sandbox config yields a manager");
308
309        let cmd = build_seed_command(Some(&manager), "echo hi", Path::new("/w"));
310        // Where namespaces work this is the namespace binary; where they don't
311        // the manager falls back to the shell. Either way the manager built it.
312        assert!(!cmd.as_std().get_program().is_empty());
313    }
314
315    /// The `Ok(Err(_))` arm: the shell binary itself cannot be spawned. Built
316    /// directly (not via `default_shell`) so it is reachable on every platform.
317    #[test]
318    fn run_seed_command_reports_a_spawn_failure() {
319        let mut cmd = TokioCommand::new("leviath-definitely-not-a-shell-xyz");
320        cmd.arg("-c").arg("echo hi");
321        let err = run_seed_command(cmd, Duration::from_secs(5)).unwrap_err();
322        assert!(err.contains("failed to spawn seed command"), "got: {err}");
323    }
324}