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}