kranz_engine/preflight.rs
1//! Environment preflight (roadmap M2) — extracted from `orchestrator.rs` in
2//! the monolith split (pure code motion, no behavior change). Advisory only:
3//! preflight never blocks a mission (the final contract gate stays
4//! authoritative); it surfaces missing prerequisites as a single
5//! `orchestrator.decision` at run start, and [`PREFLIGHT_CLEAR_SUMMARY`]
6//! durably supersedes an earlier warning once a run's probes come back clean.
7
8use crate::command_exec::{is_git_repo, tail_chars};
9use crate::contract_sweep;
10use crate::orchestrator::MissionEngine;
11use crate::paths::MissionPaths;
12use crate::types::*;
13use std::net::ToSocketAddrs;
14use std::time::Duration;
15
16/// One environment-preflight issue surfaced at run start (roadmap M2).
17///
18/// Preflight is advisory only: it never blocks a mission (the final contract
19/// gate stays authoritative). `severity` is `"warn"` for a probably-missing
20/// prerequisite and `"error"` for a hard environment defect (not a git repo,
21/// `.kranz` not writable) that will almost certainly break the run.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct PreflightIssue {
24 /// `"warn"` | `"error"`.
25 pub severity: &'static str,
26 pub message: String,
27}
28
29/// Durable marker emitted when a run's environment preflight is clean. A
30/// clean event is necessary to supersede an issue recorded by an earlier run.
31pub const PREFLIGHT_CLEAR_SUMMARY: &str = "preflight: clear — no advisory issues recorded";
32
33impl MissionEngine {
34 // -----------------------------------------------------------------------
35 // Environment preflight (roadmap M2)
36 // -----------------------------------------------------------------------
37
38 /// Best-effort check of obvious prerequisites of the validation contract's
39 /// `command` assertions, run once at the start of [`Self::run`] before the
40 /// first worker spawns (roadmap M2). Advisory only: the returned issues are
41 /// surfaced as a single `orchestrator.decision`, never as a block — the
42 /// contract gate at mission completion is still the authoritative check.
43 ///
44 /// For each `command` assertion the leading program token is extracted (the
45 /// interpreter for `sh -c` / `python3 -c` shapes, else the first word) and
46 /// probed on PATH; a clearly-missing program is a `warn`. Two hard
47 /// environment defects are `error`s: the repo not being a git repo, and
48 /// `.kranz` not being writable. The probe is intentionally lenient — only
49 /// programs that plainly do not resolve are flagged, so a shell builtin or
50 /// an odd-but-valid command never produces a false warning.
51 ///
52 /// Synchronous by design: `run_loop` futures are spawned (`tokio::spawn`,
53 /// so `Send`-bound), and an `async fn(&self)` here would hold
54 /// `&MissionEngine` — not `Sync`, via `Box<dyn AgentSession>` — across an
55 /// await, poisoning the whole `run()` future's `Send`. The sandbox
56 /// command probes still use the shared ASYNC bounded runner: they run it
57 /// on a dedicated thread owning a current-thread runtime (the
58 /// [`crate::command_exec::run_bounded_gate_command`] pattern), which also
59 /// keeps this callable from inside the ambient runtime without a nested
60 /// `block_on` panic.
61 pub fn preflight(&self) -> Vec<PreflightIssue> {
62 let mut issues = Vec::new();
63
64 // Hard defects first (an "error" severity): a run against a non-repo or
65 // a read-only .kranz is almost certainly doomed.
66 if !is_git_repo(self.paths.repo_root.as_path()) {
67 issues.push(PreflightIssue {
68 severity: "error",
69 message: format!("{} is not a git repository", self.paths.repo_root.display()),
70 });
71 }
72 if !kranz_dir_is_writable(&self.paths) {
73 issues.push(PreflightIssue {
74 severity: "error",
75 message: ".kranz directory is not writable".to_string(),
76 });
77 }
78
79 for role in [
80 Role::Orchestrator,
81 Role::Worker,
82 Role::ValidatorScrutiny,
83 Role::ValidatorFunctional,
84 ] {
85 let role_key = role_config_key(role);
86 match self.state.config.backend_kind(role) {
87 BackendKind::Codex => {
88 if let Err(err) = crate::backend_codex::discover_codex_binary(None) {
89 issues.push(PreflightIssue {
90 severity: "warn",
91 message: format!(
92 "{role_key}.backend is \"codex\" but no codex binary was found \
93 ({err}); that role will fall back to the claude backend"
94 ),
95 });
96 }
97 }
98 BackendKind::Droid => {
99 if let Err(err) = crate::backend_droid::discover_droid_binary(None) {
100 issues.push(PreflightIssue {
101 severity: "warn",
102 message: format!(
103 "{role_key}.backend is \"droid\" but no droid binary was found \
104 ({err}); that role will fall back to the claude backend"
105 ),
106 });
107 }
108 }
109 BackendKind::Kimi => {
110 if let Err(err) = crate::backend_kimi::discover_kimi_binary(None) {
111 issues.push(PreflightIssue {
112 severity: "warn",
113 message: format!(
114 "{role_key}.backend is \"kimi\" but no kimi binary was found \
115 ({err}); that role will fall back to the claude backend"
116 ),
117 });
118 }
119 }
120 BackendKind::Cursor => {
121 if let Err(err) = crate::backend_cursor::discover_cursor_binary(None) {
122 issues.push(PreflightIssue {
123 severity: "warn",
124 message: format!(
125 "{role_key}.backend is \"cursor\" but no cursor agent binary was \
126 found ({err}); that role will fall back to the claude backend"
127 ),
128 });
129 }
130 }
131 BackendKind::Claude => {}
132 BackendKind::Acp => {
133 // No cheap probe exists for an ACP executable (there is
134 // no `--version` convention); the initialize handshake
135 // at session start is the real probe, and a spawn
136 // failure surfaces there as an honest backend error.
137 }
138 BackendKind::Local => {
139 if let Some(base_url) = self.state.config.role(role).base_url.as_deref() {
140 if !probe_local_endpoint_reachable(base_url) {
141 issues.push(PreflightIssue {
142 severity: "warn",
143 message: format!(
144 "{role_key}.backend is \"local\" but {base_url} did not \
145 respond to a reachability probe; that role's HTTP calls \
146 may fail"
147 ),
148 });
149 }
150 }
151 }
152 }
153 }
154
155 // Contract command programs: probe the leading token of each distinct
156 // command, flagging only ones that clearly do not resolve on PATH.
157 // Pty-script assertions (ticket pty-functional-validation) probe the
158 // same way — an interactive target that does not resolve fails its
159 // validation round exactly like a missing command program, so the
160 // warning belongs at the same approve-time surface. (Execution
161 // probes below stay Command-only: an interactive target has no
162 // business running at preflight.)
163 let mut probed: std::collections::HashSet<String> = std::collections::HashSet::new();
164 for assertion in &self.state.mission.validation_contract {
165 let command = match assertion.check {
166 AssertionCheck::Command => assertion.command.as_deref(),
167 AssertionCheck::PtyScript => {
168 assertion.pty_script.as_ref().map(|s| s.command.as_str())
169 }
170 AssertionCheck::AgentJudgement => None,
171 };
172 let Some(command) = command else {
173 continue;
174 };
175 let Some(program) = leading_program(command) else {
176 continue;
177 };
178 if !probed.insert(program.clone()) {
179 continue; // already reported/checked this program
180 }
181 if !program_resolves(&program) {
182 let kind = if assertion.check == AssertionCheck::PtyScript {
183 "pty-script"
184 } else {
185 "command"
186 };
187 issues.push(PreflightIssue {
188 severity: "warn",
189 message: format!(
190 "{kind} assertion [{}] uses '{program}', which was not found on PATH",
191 assertion.id
192 ),
193 });
194 }
195 if assertion.check == AssertionCheck::Command
196 && !contract_sweep::cargo_test_has_anti_vacuity(command)
197 {
198 issues.push(PreflightIssue {
199 severity: "warn",
200 message: format!(
201 "command assertion [{}] runs `cargo test` without anti-vacuity \
202 (`ok. [1-9]`); a zero-test filter would pass vacuously",
203 assertion.id
204 ),
205 });
206 }
207 }
208
209 // Sandbox preflight (f-2-3/f-2-4): surface unsupported/missing
210 // sandbox tooling as a warning, and on macOS run each distinct
211 // contract `command` assertion under the generated worker Seatbelt
212 // profile. Best-effort and advisory only: never an `error`, never a
213 // block. The warn-only resolve below never executes anything, so a
214 // stand-in `session_cwd` is fine there; the command probes in
215 // `sandbox_command_preflight` resolve and run against a DISPOSABLE
216 // detached worktree, never the primary checkout (AGENTS.md rule 7).
217 if self.state.config.worker.sandbox.enforce != crate::types::SandboxEnforce::Off {
218 let mission_dir = self.paths.mission_dir();
219 let (_resolved, warn) = crate::sandbox::resolve_for_session(
220 &self.state.config.worker.sandbox,
221 self.paths.repo_root.as_path(),
222 &mission_dir,
223 );
224 if let Some(warn) = warn {
225 issues.push(PreflightIssue {
226 severity: "warn",
227 message: warn,
228 });
229 }
230 }
231 if matches!(
232 self.state.config.worker.sandbox.enforce,
233 crate::types::SandboxEnforce::Fs | crate::types::SandboxEnforce::FsNet
234 ) && cfg!(target_os = "macos")
235 {
236 issues.extend(self.sandbox_command_preflight());
237 }
238
239 issues
240 }
241
242 /// Run each distinct contract `command` assertion under the worker's
243 /// generated Seatbelt profile; a non-zero exit under the sandbox becomes a
244 /// `warn` `PreflightIssue` naming the assertion. Best-effort: any failure
245 /// to resolve the sandbox or write the profile file is silently skipped
246 /// (never escalated) rather than reported, since this probe must never
247 /// block or mislabel an environment problem as a sandbox problem.
248 ///
249 /// Probes run in a DISPOSABLE detached worktree at the mission's pinned
250 /// base (under the mission's gitignored `runs/` scratch), resolved as the
251 /// profile's `session_cwd` and used as the probe cwd — never the primary
252 /// checkout, which must stay byte-untouched across a run (AGENTS.md rule
253 /// 7). A worktree-creation failure is the one new failure mode here and
254 /// surfaces as a `warn` (the probes are then skipped).
255 ///
256 /// Execution goes through [`crate::command_exec::run_bounded_argv`], the
257 /// shared bounded runner (concurrent pipe drain, process-tree kill on
258 /// timeout), driven on a DEDICATED thread that owns a current-thread
259 /// runtime — the [`crate::command_exec::run_bounded_gate_command`]
260 /// pattern. `preflight()` is sync and called on the ambient tokio
261 /// runtime, where a nested `block_on` would panic; a raw
262 /// `std::thread::spawn` carries no runtime context, so the runner's
263 /// runtime is safe there. The disposable worktree outlives the thread
264 /// (joined before the guard drops).
265 fn sandbox_command_preflight(&self) -> Vec<PreflightIssue> {
266 let mission_dir = self.paths.mission_dir();
267 let worktree_path = self.paths.runs_dir().join("preflight-worktree");
268 let (resolved, _warn) = crate::sandbox::resolve_for_session(
269 &self.state.config.worker.sandbox,
270 &worktree_path,
271 &mission_dir,
272 );
273 let Some(resolved) = resolved else {
274 return Vec::new();
275 };
276 if resolved.backend != crate::sandbox::SandboxBackend::Seatbelt {
277 return Vec::new();
278 }
279
280 // The throwaway probe tree: detached at the pinned base (approval
281 // base_sha, falling back to the base branch for pre-pin missions),
282 // removed on guard drop however the probes end.
283 let base = self
284 .state
285 .mission
286 .base_sha
287 .clone()
288 .unwrap_or_else(|| self.state.mission.base_branch.clone());
289 let _worktree = match DisposableWorktree::create(&self.repo, &worktree_path, &base) {
290 Ok(guard) => guard,
291 Err(err) => {
292 return vec![PreflightIssue {
293 severity: "warn",
294 message: format!(
295 "sandbox command preflight skipped: could not create disposable \
296 worktree at {base}: {err}"
297 ),
298 }];
299 }
300 };
301
302 let profile = crate::sandbox::generate_profile(&resolved.inputs);
303 // The profile file is runtime scratch: keep it under the gitignored
304 // `runs/` dir (never the mission dir, whose unignored files would
305 // show up as untracked in the primary checkout's `git status`).
306 let profile_path =
307 match crate::sandbox::write_profile_file(&self.paths.runs_dir(), &profile)
308 .or_else(|_| crate::sandbox::write_profile_file(&resolved.inputs.tmpdir, &profile))
309 {
310 Ok(path) => path,
311 Err(_) => return Vec::new(),
312 };
313
314 // The same COMPLETE environment the final contract gate gives command
315 // assertions (minimal allowlist + scratch HOME + toolchain caches +
316 // any contractEnvPassthrough names): the probe measures what the gate
317 // will see, and ambient secrets never reach a contract command.
318 let env = crate::agent_env::contract_command_env(
319 &self.paths.runs_dir().join("contract-home"),
320 self.state.mission.base_sha.as_deref(),
321 &self.state.config.contract_env_passthrough,
322 );
323
324 // Probe selection (dedup, capped) happens here; execution moves to
325 // the probe thread below.
326 const MAX_PROBES: usize = 20;
327 const TIMEOUT: Duration = Duration::from_secs(5);
328 let mut probes: Vec<(String, std::path::PathBuf, Vec<String>)> = Vec::new();
329 let mut probed: std::collections::HashSet<&str> = std::collections::HashSet::new();
330 for assertion in &self.state.mission.validation_contract {
331 if probes.len() >= MAX_PROBES {
332 break;
333 }
334 if assertion.check != AssertionCheck::Command {
335 continue;
336 }
337 let Some(command) = assertion.command.as_deref() else {
338 continue;
339 };
340 if !probed.insert(command) {
341 continue; // already probed this exact command
342 }
343 let (program, args) = crate::backend_claude::sandbox_command(
344 &profile_path,
345 std::path::Path::new("/bin/sh"),
346 &["-c".to_string(), command.to_string()],
347 );
348 probes.push((assertion.id.clone(), program, args));
349 }
350 if probes.is_empty() {
351 return Vec::new();
352 }
353
354 let probe_cwd = worktree_path.clone();
355 let worker = std::thread::spawn(move || {
356 let runtime = tokio::runtime::Builder::new_current_thread()
357 .enable_all()
358 .build();
359 let Ok(runtime) = runtime else {
360 return Vec::new(); // best-effort: no runtime, no probes
361 };
362 runtime.block_on(async move {
363 let mut issues = Vec::new();
364 for (id, program, args) in probes {
365 match crate::command_exec::run_bounded_argv(
366 &probe_cwd, &program, &args, TIMEOUT, &env,
367 )
368 .await
369 {
370 // Only a real non-zero exit warns; a timeout/spawn
371 // failure (`None`) stays silent — the probe is
372 // advisory and a slow command is not a sandbox problem.
373 (Some(0), _) | (None, _) => {}
374 (Some(_), output) => {
375 let tail = tail_chars(&output, 200);
376 issues.push(PreflightIssue {
377 severity: "warn",
378 message: format!(
379 "command assertion [{id}] fails under the fs sandbox \
380 profile: {tail}"
381 ),
382 });
383 }
384 }
385 }
386 issues
387 })
388 });
389 // A panicked probe thread must never take preflight down with it.
390 worker.join().unwrap_or_default()
391 }
392}
393
394// ---------------------------------------------------------------------------
395// Preflight helpers (roadmap M2) — all pure/best-effort, no engine state
396// ---------------------------------------------------------------------------
397
398/// RAII guard for the disposable preflight worktree (P1, ticket
399/// preflight-in-disposable-worktree): a throwaway detached worktree the
400/// sandbox command probes run in so contract commands never execute against
401/// the primary checkout (AGENTS.md rule 7 — the primary must stay
402/// byte-untouched across a run). The worktree lives under the mission's own
403/// gitignored `runs/` scratch, not global temp.
404///
405/// Drop removes it best-effort — `git worktree remove --force` (which also
406/// deletes the directory), a dir sweep for anything git declined, and a
407/// prune of stale administrative entries — so even a probe failure or early
408/// return cannot leak it.
409struct DisposableWorktree {
410 repo: crate::git_ops::GitRepo,
411 path: std::path::PathBuf,
412}
413
414impl DisposableWorktree {
415 /// Create a detached worktree at `path` pinned to `base` (`git worktree
416 /// add --detach`). Idempotent against a stale leftover from a crashed
417 /// run: any prior worktree/dir at `path` is cleared first, mirroring
418 /// `setup_mission_worktree`'s crash sweep.
419 fn create(
420 repo: &crate::git_ops::GitRepo,
421 path: &std::path::Path,
422 base: &str,
423 ) -> crate::error::Result<Self> {
424 let _ = repo.remove_worktree(path);
425 let _ = std::fs::remove_dir_all(path);
426 if let Some(parent) = path.parent() {
427 std::fs::create_dir_all(parent).map_err(|e| {
428 crate::error::EngineError::Git(format!("create {}: {e}", parent.display()))
429 })?;
430 }
431 repo.add_detached_worktree(path, base)?;
432 Ok(Self {
433 repo: repo.clone(),
434 path: path.to_path_buf(),
435 })
436 }
437}
438
439impl Drop for DisposableWorktree {
440 fn drop(&mut self) {
441 let _ = self.repo.remove_worktree(&self.path);
442 let _ = std::fs::remove_dir_all(&self.path);
443 let _ = self.repo.prune_worktrees();
444 }
445}
446
447fn role_config_key(role: Role) -> &'static str {
448 match role {
449 Role::Orchestrator => "orchestrator",
450 Role::Worker => "worker",
451 Role::ValidatorScrutiny => "validatorScrutiny",
452 Role::ValidatorFunctional => "validatorFunctional",
453 }
454}
455
456/// Extract the leading program token of a contract `command` line for a PATH
457/// probe (roadmap M2 preflight). Best-effort by design:
458///
459/// - `sh -c '…'` / `bash -c '…'` / `python3 -c '…'`-style forms name the
460/// INTERPRETER as the program (the thing that must exist), so the first
461/// token is returned rather than trying to parse the embedded script.
462/// - Otherwise the first whitespace-delimited token is returned, with common
463/// leading `VAR=value` environment assignments skipped and a leading path
464/// (`./scripts/check.sh`) reduced to its final component only for the
465/// presence check semantics of [`program_resolves`].
466///
467/// Returns `None` when no plausible program token can be found (empty command,
468/// or a line that is only environment assignments) — the caller then skips the
469/// probe rather than emit a spurious warning.
470fn leading_program(command: &str) -> Option<String> {
471 // Skip leading `VAR=value` assignments ("FOO=bar cmd …" is common in
472 // contract lines); the program is the first token that is not an
473 // assignment.
474 let mut token = None;
475 for tok in command.split_whitespace() {
476 if is_env_assignment(tok) {
477 continue;
478 }
479 token = Some(tok);
480 break;
481 }
482 let token = token?;
483 if token.is_empty() {
484 return None;
485 }
486 Some(token.to_string())
487}
488
489/// A `VAR=value` leading environment assignment (`FOO=bar`): an identifier,
490/// then `=`. Used to skip past them when finding the program token.
491fn is_env_assignment(token: &str) -> bool {
492 match token.split_once('=') {
493 Some((name, _)) if !name.is_empty() => {
494 let mut chars = name.chars();
495 chars
496 .next()
497 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
498 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
499 }
500 _ => false,
501 }
502}
503
504/// Whether a program token plausibly resolves to something runnable
505/// (roadmap M2 preflight). LENIENT: this only ever produces a warning, so it
506/// errs heavily toward "resolves" to avoid false positives.
507///
508/// - A program containing a path separator is checked as a filesystem path
509/// (it names its own location; PATH does not apply).
510/// - A bare name is looked up across every `PATH` entry.
511/// - Common shell builtins that have no on-disk binary (`cd`, `:`, `true`,
512/// `false`, `echo`, `test`, `[`, `exit`) always resolve — a contract line
513/// like `cd . && …` must never warn. On Windows the shell is `cmd`, so its
514/// own builtins (`if`, `for`, `call`, …) resolve too: a portable contract
515/// line like `if exist X (exit 0) else (exit 1)` names no program at all.
516///
517/// On non-unix hosts the executable-bit check is skipped (mere existence in a
518/// PATH dir counts), and `.exe`/`.bat`/`.cmd` variants are also accepted.
519fn program_resolves(program: &str) -> bool {
520 // Shell builtins with no backing binary — never a missing prerequisite.
521 // `exit` belongs here for both shells: it is the portable way to write a
522 // deterministic success/failure assertion and has no binary anywhere.
523 const BUILTINS: &[&str] = &[
524 "cd", ":", "true", "false", "echo", "test", "[", "set", "export", "unset", "exit",
525 ];
526 // cmd.exe control-flow builtins. Deliberately excludes names that DO have
527 // a real Windows binary (`find`, `sort`, `more`), so a genuinely missing
528 // prerequisite is still flagged.
529 #[cfg(windows)]
530 const CMD_BUILTINS: &[&str] = &[
531 "if", "for", "call", "goto", "rem", "pushd", "popd", "ver", "type", "del", "copy", "move",
532 "md", "mkdir", "rd", "rmdir",
533 ];
534 if BUILTINS.contains(&program) {
535 return true;
536 }
537 // cmd resolves builtins case-insensitively; `sh` does not, so fold only
538 // where the shell actually would.
539 #[cfg(windows)]
540 {
541 let folded = program.to_ascii_lowercase();
542 if BUILTINS.contains(&folded.as_str()) || CMD_BUILTINS.contains(&folded.as_str()) {
543 return true;
544 }
545 }
546
547 // A path-bearing program names its own location; PATH does not apply.
548 if program.contains('/') || program.contains('\\') {
549 return path_is_executable(std::path::Path::new(program));
550 }
551
552 let Some(path) = std::env::var_os("PATH") else {
553 // No PATH to scan: cannot disprove existence, so do not warn.
554 return true;
555 };
556 for dir in std::env::split_paths(&path) {
557 if dir.as_os_str().is_empty() {
558 continue;
559 }
560 if path_is_executable(&dir.join(program)) {
561 return true;
562 }
563 // Windows: accept the usual executable extensions.
564 #[cfg(windows)]
565 for ext in ["exe", "bat", "cmd", "com"] {
566 if path_is_executable(&dir.join(format!("{program}.{ext}"))) {
567 return true;
568 }
569 }
570 }
571 false
572}
573
574/// Whether `path` is a regular file that is executable (unix: any execute bit;
575/// other platforms: mere existence as a file).
576fn path_is_executable(path: &std::path::Path) -> bool {
577 let Ok(meta) = std::fs::metadata(path) else {
578 return false;
579 };
580 if !meta.is_file() {
581 return false;
582 }
583 #[cfg(unix)]
584 {
585 use std::os::unix::fs::PermissionsExt;
586 meta.permissions().mode() & 0o111 != 0
587 }
588 #[cfg(not(unix))]
589 {
590 true
591 }
592}
593
594/// Best-effort, short-timeout (1.5s) reachability probe for a `backend =
595/// local` role's `base_url`: a raw TCP connect to the URL's host/port, since
596/// an OpenAI-compatible server's root path need not resolve to anything
597/// meaningful — any successful connection counts as "reachable"; only a
598/// connection-level failure (refused, timeout) does not. Never escalated past
599/// a `warn` `PreflightIssue`: this must never block a mission start.
600///
601/// Deliberately runtime-free (no `tokio::runtime::Builder`/`block_on`):
602/// `preflight()` runs synchronously inside the process's own tokio runtime
603/// (see `run_loop()`), and entering a nested runtime here panics
604/// unconditionally with "Cannot start a runtime from within a runtime". (The
605/// sandbox command probes avoid the same trap by owning a runtime on a
606/// DEDICATED thread — see `sandbox_command_preflight`.) Mirrors the
607/// proven-safe pattern in `backend_readiness::probe_local_reachability`.
608fn probe_local_endpoint_reachable(base_url: &str) -> bool {
609 let Ok(url) = reqwest::Url::parse(base_url) else {
610 return true; // can't probe; don't manufacture a false warning
611 };
612 let (Some(host), Some(port)) = (url.host_str(), url.port_or_known_default()) else {
613 return true;
614 };
615 let addr = match (host, port).to_socket_addrs() {
616 Ok(mut addrs) => addrs.next(),
617 Err(_) => None,
618 };
619 let Some(addr) = addr else {
620 return true; // unresolvable; don't manufacture a false warning
621 };
622 std::net::TcpStream::connect_timeout(&addr, Duration::from_millis(1500)).is_ok()
623}
624
625/// Whether the mission's `.kranz` directory is writable: create it if needed,
626/// then probe with a temp file. Conservative — any error other than a clean
627/// write is reported as "not writable".
628fn kranz_dir_is_writable(paths: &MissionPaths) -> bool {
629 let dir = paths.kranz_dir();
630 if std::fs::create_dir_all(&dir).is_err() {
631 return false;
632 }
633 let probe = dir.join(format!(".preflight-{}", uuid::Uuid::new_v4().simple()));
634 match std::fs::write(&probe, b"") {
635 Ok(()) => {
636 let _ = std::fs::remove_file(&probe);
637 true
638 }
639 Err(_) => false,
640 }
641}
642
643// ---------------------------------------------------------------------------
644// Test support — shared with orchestrator.rs's fallback tests
645// ---------------------------------------------------------------------------
646
647/// Serializes tests that mutate `KRANZ_DROID_BIN` so they don't race
648/// concurrently with each other (mirrors `CODEX_ENV_LOCK`; kept on its
649/// own dedicated mutex since it guards a different env var). Lives outside
650/// `mod tests` because `orchestrator.rs`'s `KRANZ_DROID_BIN`-mutating tests
651/// (`droid_absent_loud_fallback` via [`DroidEnvGuard`], and the unix
652/// `DroidStubEnvGuard` directly) engage this same lock.
653#[cfg(test)]
654pub(crate) static DROID_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
655
656/// RAII guard: points `KRANZ_DROID_BIN` at a path that cannot exist, so
657/// droid discovery misses deterministically. Restores the previous value
658/// on drop, including on panic.
659#[cfg(test)]
660pub(crate) struct DroidEnvGuard {
661 prev_bin: Option<std::ffi::OsString>,
662 _lock: std::sync::MutexGuard<'static, ()>,
663}
664
665#[cfg(test)]
666impl DroidEnvGuard {
667 pub(crate) fn engage() -> Self {
668 let lock = DROID_ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
669 let prev_bin = std::env::var_os("KRANZ_DROID_BIN");
670 std::env::set_var(
671 "KRANZ_DROID_BIN",
672 "/nonexistent/kranz-test-droid-binary-absent",
673 );
674 DroidEnvGuard {
675 prev_bin,
676 _lock: lock,
677 }
678 }
679}
680
681#[cfg(test)]
682impl Drop for DroidEnvGuard {
683 fn drop(&mut self) {
684 match self.prev_bin.take() {
685 Some(v) => std::env::set_var("KRANZ_DROID_BIN", v),
686 None => std::env::remove_var("KRANZ_DROID_BIN"),
687 }
688 }
689}
690
691#[cfg(test)]
692mod tests {
693 use super::*;
694 use crate::backend::AgentBackend;
695 use std::sync::Arc;
696
697 /// `validatorScrutiny.backend = "droid"` with no droid binary reachable:
698 /// preflight must warn and mention "droid".
699 #[test]
700 fn droid_preflight_warns_when_binary_absent() {
701 let dir = tempfile::tempdir().expect("tempdir");
702 let root = std::fs::canonicalize(dir.path()).unwrap_or_else(|_| dir.path().to_path_buf());
703 let _ = std::process::Command::new("git")
704 .args(["init", "-b", "main"])
705 .current_dir(&root)
706 .output();
707 let _ = std::process::Command::new("git")
708 .args(["config", "user.name", "test"])
709 .current_dir(&root)
710 .output();
711 let _ = std::process::Command::new("git")
712 .args(["config", "user.email", "test@example.com"])
713 .current_dir(&root)
714 .output();
715 std::fs::write(root.join("README.md"), "seed\n").unwrap();
716 let _ = std::process::Command::new("git")
717 .args(["add", "-A"])
718 .current_dir(&root)
719 .output();
720 let _ = std::process::Command::new("git")
721 .args(["commit", "-m", "seed"])
722 .current_dir(&root)
723 .output();
724
725 let mut cfg = MissionConfig::default();
726 cfg.validator_scrutiny.backend = Some("droid".to_string());
727
728 let backend: Arc<dyn AgentBackend> = Arc::new(crate::backend_mock::MockBackend::new());
729 let engine = MissionEngine::create(backend, &root, "goal", cfg).expect("create engine");
730
731 let env_guard = DroidEnvGuard::engage();
732 let issues = engine.preflight();
733 drop(env_guard);
734
735 assert!(
736 issues
737 .iter()
738 .any(|i| i.severity == "warn" && i.message.contains("droid")),
739 "expected a droid preflight warning, got {issues:?}"
740 );
741 }
742
743 /// `worker.backend = "local"` with an unreachable `base_url`: preflight
744 /// must surface exactly a `"warn"` issue (never `"error"`, never a
745 /// block) naming the endpoint.
746 #[test]
747 fn local_preflight_warns_when_base_url_unreachable() {
748 let dir = tempfile::tempdir().expect("tempdir");
749 let root = std::fs::canonicalize(dir.path()).unwrap_or_else(|_| dir.path().to_path_buf());
750 let _ = std::process::Command::new("git")
751 .args(["init", "-b", "main"])
752 .current_dir(&root)
753 .output();
754 let _ = std::process::Command::new("git")
755 .args(["config", "user.name", "test"])
756 .current_dir(&root)
757 .output();
758 let _ = std::process::Command::new("git")
759 .args(["config", "user.email", "test@example.com"])
760 .current_dir(&root)
761 .output();
762 std::fs::write(root.join("README.md"), "seed\n").unwrap();
763 let _ = std::process::Command::new("git")
764 .args(["add", "-A"])
765 .current_dir(&root)
766 .output();
767 let _ = std::process::Command::new("git")
768 .args(["commit", "-m", "seed"])
769 .current_dir(&root)
770 .output();
771
772 let mut cfg = MissionConfig::default();
773 cfg.worker.backend = Some("local".to_string());
774 // Port 0 never accepts connections; a fast, reliable "unreachable".
775 cfg.worker.base_url = Some("http://127.0.0.1:0/v1".to_string());
776 cfg.worker.context_budget = Some(8192);
777 cfg.allow_below_default_worker_model = true;
778
779 let backend: Arc<dyn AgentBackend> = Arc::new(crate::backend_mock::MockBackend::new());
780 let engine = MissionEngine::create(backend, &root, "goal", cfg).expect("create engine");
781
782 let issues = engine.preflight();
783
784 assert!(
785 issues
786 .iter()
787 .any(|i| i.severity == "warn" && i.message.contains("127.0.0.1:0")),
788 "expected a local preflight warning, got {issues:?}"
789 );
790 assert!(
791 !issues.iter().any(|i| i.severity == "error"),
792 "local reachability must never escalate to an error, got {issues:?}"
793 );
794 }
795
796 /// Regression test for the nested-runtime panic: `preflight()` must be
797 /// callable from *within* an already-running tokio runtime (as it is by
798 /// `run_loop()`) without `probe_local_endpoint_reachable` trying to spin
799 /// up its own nested `Runtime::block_on`, which panics unconditionally.
800 /// This test would fail (panic) if a nested `block_on` were ever
801 /// reintroduced.
802 #[tokio::test]
803 async fn local_preflight_warns_inside_runtime_without_panic() {
804 let dir = tempfile::tempdir().expect("tempdir");
805 let root = std::fs::canonicalize(dir.path()).unwrap_or_else(|_| dir.path().to_path_buf());
806 let _ = std::process::Command::new("git")
807 .args(["init", "-b", "main"])
808 .current_dir(&root)
809 .output();
810 let _ = std::process::Command::new("git")
811 .args(["config", "user.name", "test"])
812 .current_dir(&root)
813 .output();
814 let _ = std::process::Command::new("git")
815 .args(["config", "user.email", "test@example.com"])
816 .current_dir(&root)
817 .output();
818 std::fs::write(root.join("README.md"), "seed\n").unwrap();
819 let _ = std::process::Command::new("git")
820 .args(["add", "-A"])
821 .current_dir(&root)
822 .output();
823 let _ = std::process::Command::new("git")
824 .args(["commit", "-m", "seed"])
825 .current_dir(&root)
826 .output();
827
828 let mut cfg = MissionConfig::default();
829 cfg.worker.backend = Some("local".to_string());
830 // Port 0 never accepts connections; a fast, reliable "unreachable".
831 cfg.worker.base_url = Some("http://127.0.0.1:0/v1".to_string());
832 cfg.worker.context_budget = Some(8192);
833 cfg.allow_below_default_worker_model = true;
834
835 let backend: Arc<dyn AgentBackend> = Arc::new(crate::backend_mock::MockBackend::new());
836 let engine = MissionEngine::create(backend, &root, "goal", cfg).expect("create engine");
837
838 // Called from within this #[tokio::test]'s active runtime, exactly
839 // as it would be from the async run_loop(): must not panic.
840 let issues = engine.preflight();
841
842 assert!(
843 issues
844 .iter()
845 .any(|i| i.severity == "warn" && i.message.contains("127.0.0.1:0")),
846 "expected a local preflight warning, got {issues:?}"
847 );
848 assert!(
849 !issues.iter().any(|i| i.severity == "error"),
850 "local reachability must never escalate to an error, got {issues:?}"
851 );
852 }
853
854 // -----------------------------------------------------------------------
855 // Disposable-worktree sandbox probes (P1, ticket
856 // preflight-in-disposable-worktree). macOS-only: the Seatbelt probe path
857 // is the only one that executes contract commands.
858 // -----------------------------------------------------------------------
859
860 /// Init a throwaway repo with one seed commit; returns (tempdir guard,
861 /// canonical root, seed commit sha).
862 #[cfg(target_os = "macos")]
863 fn seeded_git_repo() -> (tempfile::TempDir, std::path::PathBuf, String) {
864 let dir = tempfile::tempdir().expect("tempdir");
865 let root = std::fs::canonicalize(dir.path()).unwrap_or_else(|_| dir.path().to_path_buf());
866 for args in [
867 vec!["init", "-b", "main"],
868 vec!["config", "user.name", "test"],
869 vec!["config", "user.email", "test@example.com"],
870 ] {
871 let _ = std::process::Command::new("git")
872 .args(&args)
873 .current_dir(&root)
874 .output();
875 }
876 std::fs::write(root.join("README.md"), "seed\n").unwrap();
877 let _ = std::process::Command::new("git")
878 .args(["add", "-A"])
879 .current_dir(&root)
880 .output();
881 let _ = std::process::Command::new("git")
882 .args(["commit", "-m", "seed"])
883 .current_dir(&root)
884 .output();
885 let sha = std::process::Command::new("git")
886 .args(["rev-parse", "HEAD"])
887 .current_dir(&root)
888 .output()
889 .expect("rev-parse HEAD");
890 let sha = String::from_utf8_lossy(&sha.stdout).trim().to_string();
891 (dir, root, sha)
892 }
893
894 #[cfg(target_os = "macos")]
895 fn command_assertion(id: &str, command: &str) -> Assertion {
896 Assertion {
897 id: id.to_string(),
898 statement: "the check passes".to_string(),
899 check: AssertionCheck::Command,
900 command: Some(command.to_string()),
901 negative_control: None,
902 pty_script: None,
903 }
904 }
905
906 #[cfg(target_os = "macos")]
907 fn git_status_porcelain(root: &std::path::Path) -> String {
908 let out = std::process::Command::new("git")
909 .args(["status", "--porcelain"])
910 .current_dir(root)
911 .output()
912 .expect("git status");
913 String::from_utf8_lossy(&out.stdout).into_owned()
914 }
915
916 /// Whether this host can APPLY a sandbox profile, not merely find
917 /// `sandbox-exec` on PATH: the preflight test drives REAL nested sandbox
918 /// application (the preflight wraps its contract probes in a generated
919 /// profile), and under the gate sandbox wrap (a wrapped `cargo test`
920 /// dogfooding this repo — ticket gate-sandbox-supervision-dogfood) that
921 /// nested apply is kernel-denied regardless of profile content:
922 /// re-applying the IDENTICAL label is a permitted no-op, anything else
923 /// is EPERM (probed 2026-08-05; no SBPL clause can allow it). The
924 /// smoke-apply makes the test skip with a detectable marker instead of
925 /// failing on the outer sandbox's presence — the same posture
926 /// `crate::sandbox`'s own enforcement tests take.
927 #[cfg(target_os = "macos")]
928 fn sandbox_exec_available() -> bool {
929 let found = std::process::Command::new("which")
930 .arg("sandbox-exec")
931 .output()
932 .map(|o| o.status.success())
933 .unwrap_or(false);
934 if !found {
935 crate::test_capability::skip(
936 crate::test_capability::capability::SANDBOX_EXEC,
937 "sandbox-exec not found on this host",
938 );
939 return false;
940 }
941 let smoke = std::process::Command::new("sandbox-exec")
942 .arg("-p")
943 .arg("(version 1)\n(allow default)\n")
944 .arg("/usr/bin/true")
945 .output();
946 match smoke {
947 Ok(output) if output.status.success() => true,
948 Ok(output) => {
949 eprintln!(
950 "SKIP-UNDER-WRAP (gate-sandbox-supervision-dogfood): \
951 sandbox-exec cannot apply a smoke profile here (nested apply is denied \
952 inside the gate sandbox wrap); skipping: {}",
953 String::from_utf8_lossy(&output.stderr)
954 );
955 false
956 }
957 Err(e) => {
958 eprintln!("sandbox-exec smoke probe failed; skipping: {e}");
959 false
960 }
961 }
962 }
963
964 /// The P1 regression test: with `worker.sandbox.enforce = fs`, the
965 /// contract-command probes must run in a DISPOSABLE worktree — proven by
966 /// a `pwd -P` assertion inside the probe — and the primary checkout must
967 /// be byte-identical (`git status --porcelain` unchanged, no marker file)
968 /// across a preflight whose contract command writes a file. The
969 /// disposable worktree is removed afterwards (success AND failing probe
970 /// alike; this run has both).
971 #[cfg(target_os = "macos")]
972 #[test]
973 fn sandbox_preflight_probes_disposable_worktree_not_primary() {
974 // The helper prints the precise reason (not found / nested apply
975 // denied under the gate wrap / probe error).
976 if !sandbox_exec_available() {
977 return;
978 }
979 let (_dir, root, sha) = seeded_git_repo();
980
981 let mut cfg = MissionConfig::default();
982 cfg.worker.sandbox.enforce = crate::types::SandboxEnforce::Fs;
983
984 let backend: Arc<dyn AgentBackend> = Arc::new(crate::backend_mock::MockBackend::new());
985 let mut engine = MissionEngine::create(backend, &root, "goal", cfg).expect("create engine");
986 engine.state.mission.base_sha = Some(sha);
987
988 let worktree_path = engine.paths.runs_dir().join("preflight-worktree");
989 let home_marker = format!("kranz_pf_{}", uuid::Uuid::new_v4());
990 // The REAL home is outside the generated allowlist (worktree /
991 // mission dir / tmpdir): bake it in literally, because the probe's
992 // contract env deliberately redefines $HOME to the writable scratch.
993 let real_home = std::env::var("HOME").expect("HOME must be set for this test");
994 engine.state.mission.validation_contract = vec![
995 // cwd-relative write: lands in the probe's cwd, must NOT warn…
996 command_assertion("a-rel-write", "echo x > preflight-marker.txt"),
997 // …and the probe's cwd must BE the disposable worktree.
998 command_assertion(
999 "a-cwd",
1000 &format!("[ \"$(pwd -P)\" = '{}' ]", worktree_path.display()),
1001 ),
1002 // A write outside the sandbox allowlist: must fail and warn —
1003 // also proves the probes really executed (anti-vacuity).
1004 command_assertion(
1005 "a-outside",
1006 &format!("echo x > '{real_home}/{home_marker}'"),
1007 ),
1008 ];
1009
1010 let status_before = git_status_porcelain(&root);
1011 let issues = engine.preflight();
1012
1013 // The failing probe warned; the in-worktree probes did not.
1014 assert!(
1015 issues.iter().any(|i| i.severity == "warn"
1016 && i.message.contains("[a-outside]")
1017 && i.message.contains("fs sandbox profile")),
1018 "expected a sandbox warn for the out-of-allowlist write: {issues:?}"
1019 );
1020 for id in ["a-rel-write", "a-cwd"] {
1021 let needle = format!("[{id}]");
1022 assert!(
1023 !issues.iter().any(|i| i.message.contains(&needle)),
1024 "{id} must not warn — probes run with cwd = the disposable worktree: {issues:?}"
1025 );
1026 }
1027 assert!(
1028 !issues.iter().any(|i| i.severity == "error"),
1029 "sandbox preflight must never escalate to error: {issues:?}"
1030 );
1031
1032 // AGENTS.md rule 7: the primary checkout is byte-untouched.
1033 assert_eq!(
1034 status_before,
1035 git_status_porcelain(&root),
1036 "primary checkout changed across preflight"
1037 );
1038 assert!(
1039 !root.join("preflight-marker.txt").exists(),
1040 "the probe's cwd-relative write landed in the primary checkout"
1041 );
1042
1043 // The disposable worktree is gone after the run (both the succeeding
1044 // and the failing probe used it).
1045 assert!(
1046 !worktree_path.exists(),
1047 "disposable preflight worktree leaked at {}",
1048 worktree_path.display()
1049 );
1050
1051 // Clean up in case the sandbox somehow did not block the $HOME write.
1052 if let Ok(home) = std::env::var("HOME") {
1053 let _ = std::fs::remove_file(std::path::Path::new(&home).join(&home_marker));
1054 }
1055 }
1056
1057 /// The new failure mode: a disposable worktree that cannot be created
1058 /// (here: a pinned base that does not resolve) becomes one advisory
1059 /// `warn` — never an error, never a panic, and no leftover tree.
1060 #[cfg(target_os = "macos")]
1061 #[test]
1062 fn sandbox_preflight_worktree_creation_failure_is_advisory() {
1063 if !sandbox_exec_available() {
1064 crate::test_capability::skip(
1065 crate::test_capability::capability::SANDBOX_EXEC,
1066 "sandbox-exec not found on this host",
1067 );
1068 return;
1069 }
1070 let (_dir, root, _sha) = seeded_git_repo();
1071
1072 let mut cfg = MissionConfig::default();
1073 cfg.worker.sandbox.enforce = crate::types::SandboxEnforce::Fs;
1074
1075 let backend: Arc<dyn AgentBackend> = Arc::new(crate::backend_mock::MockBackend::new());
1076 let mut engine = MissionEngine::create(backend, &root, "goal", cfg).expect("create engine");
1077 engine.state.mission.base_sha = Some("0".repeat(40));
1078 engine.state.mission.validation_contract = vec![command_assertion("a-1", "true")];
1079
1080 let issues = engine.sandbox_command_preflight();
1081
1082 assert_eq!(
1083 issues.len(),
1084 1,
1085 "exactly one advisory issue for the worktree failure: {issues:?}"
1086 );
1087 assert_eq!(issues[0].severity, "warn");
1088 assert!(
1089 issues[0]
1090 .message
1091 .contains("could not create disposable worktree"),
1092 "the warn names the worktree failure: {}",
1093 issues[0].message
1094 );
1095 assert!(
1096 !engine.paths.runs_dir().join("preflight-worktree").exists(),
1097 "a failed worktree creation must not leave a tree behind"
1098 );
1099 }
1100}