Skip to main content

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}