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}