Skip to main content

rch_common/
capability_probe.rs

1//! Exact user/path capability probes for runtimes, toolchains, and targets
2//! (bd-session-history-remediation-ocv9i.12.2).
3//!
4//! A capability probe runs **as the configured remote user, using the exact
5//! executable paths RCH will actually invoke** (not whatever a login shell's
6//! PATH happens to resolve), so the facts reflect what an offloaded build will
7//! really see. The probe is a single shell script that prints `RCH_FACT k=v`
8//! lines; [`parse_capability_probe`] turns that output into [`ProbedFacts`],
9//! and [`assess_admissibility`] decides whether the worker is admissible for a
10//! given [`CapabilityRequirement`].
11//!
12//! Crucially this distinguishes **SSH reachability** (did the script run at
13//! all?) from **capability admissibility** (it ran, but lacks a needed target /
14//! the binary at the exact path is broken / the protocol is stale) — the two
15//! failure classes operators kept conflating.
16
17use crate::incident::IncidentReasonCode;
18use crate::worker_facts::{
19    DiskRootFacts, RuntimeFacts, RustFacts, WorkerBinaryFacts, derive_target_triple,
20};
21
22/// Sentinel prefix every probe fact line carries, so probe output is easy to
23/// separate from incidental stdout.
24pub const FACT_PREFIX: &str = "RCH_FACT ";
25
26/// Why an operator-declared tool probe was refused before it ever ran.
27#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
28pub enum ToolProbeError {
29    /// The name cannot survive the fact wire format or is empty.
30    #[error("tool name {name:?} is invalid: {reason}")]
31    InvalidName {
32        /// The rejected name.
33        name: String,
34        /// What is wrong with it.
35        reason: &'static str,
36    },
37    /// A probe with no argv can never prove anything.
38    #[error("tool {name:?} declares an empty probe command")]
39    EmptyCommand {
40        /// The tool whose command was empty.
41        name: String,
42    },
43}
44
45/// An operator-declared named tool probe: a FIXED argv, written in worker
46/// configuration, run as the configured remote user.
47///
48/// The argv is never caller-supplied. RCH deliberately exposes no general
49/// remote-shell probe API: a build that could ask a worker to run an arbitrary
50/// command "to check for a tool" would be a remote execution primitive wearing
51/// a capability-probe hat. Declaring the argv in operator config keeps the set
52/// of commands a worker will run for a probe finite and reviewable.
53///
54/// Verification is by EXIT STATUS: zero means present, anything else — missing
55/// binary, error exit, signal — means absent. Output is discarded so a chatty
56/// tool cannot corrupt the fact stream.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct NamedToolProbe {
59    /// The name selection and `--require-tool` refer to.
60    name: String,
61    /// The exact argv to run.
62    command: Vec<String>,
63}
64
65impl NamedToolProbe {
66    /// Declare a probe, rejecting names that cannot round-trip the fact wire
67    /// format (`RCH_FACT tool=<name>`): a name containing `=`, whitespace or a
68    /// control character would be parsed back as a different name, which is a
69    /// silent capability lie rather than a loud config error.
70    ///
71    /// # Errors
72    /// [`ToolProbeError`] when the name is unusable or the command is empty.
73    pub fn new(
74        name: impl Into<String>,
75        command: impl IntoIterator<Item = impl Into<String>>,
76    ) -> Result<Self, ToolProbeError> {
77        let name = name.into();
78        let command: Vec<String> = command.into_iter().map(Into::into).collect();
79        let invalid = |reason| ToolProbeError::InvalidName {
80            name: name.clone(),
81            reason,
82        };
83        if name.is_empty() {
84            return Err(invalid("it is empty"));
85        }
86        if !name
87            .bytes()
88            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'+'))
89        {
90            return Err(invalid(
91                "only ASCII letters, digits and `-` `_` `.` `+` are allowed",
92            ));
93        }
94        if command.is_empty() {
95            return Err(ToolProbeError::EmptyCommand { name });
96        }
97        Ok(Self { name, command })
98    }
99
100    /// The declared name.
101    #[must_use]
102    pub fn name(&self) -> &str {
103        &self.name
104    }
105
106    /// The declared argv.
107    #[must_use]
108    pub fn command(&self) -> &[String] {
109        &self.command
110    }
111}
112
113impl TryFrom<&crate::types::WorkerToolProbe> for NamedToolProbe {
114    type Error = ToolProbeError;
115
116    /// Validate a `workers.toml` entry. This is the single point where a
117    /// malformed declaration is refused, so a bad name fails config load
118    /// loudly rather than becoming a worker that quietly advertises nothing.
119    fn try_from(entry: &crate::types::WorkerToolProbe) -> Result<Self, Self::Error> {
120        Self::new(entry.name.clone(), entry.command.iter().cloned())
121    }
122}
123
124/// Exact paths/identity the probe must use (never PATH-resolved).
125#[derive(Debug, Clone)]
126pub struct ProbeSpec {
127    /// SSH login user the probe runs as.
128    pub remote_user: String,
129    /// Exact path to the `rch-wkr` binary RCH will invoke.
130    pub rch_wkr_path: String,
131    /// Exact path to `cargo`, if known (else the probe tries `command -v`).
132    pub cargo_path: Option<String>,
133    /// Exact path to `rustup`, if known.
134    pub rustup_path: Option<String>,
135    /// Disk roots whose capacity to report (temp root, build roots, cargo home).
136    pub disk_roots: Vec<String>,
137    /// Operator-declared named tool probes for this worker.
138    pub tools: Vec<NamedToolProbe>,
139}
140
141impl ProbeSpec {
142    #[must_use]
143    pub fn new(remote_user: impl Into<String>, rch_wkr_path: impl Into<String>) -> Self {
144        Self {
145            remote_user: remote_user.into(),
146            rch_wkr_path: rch_wkr_path.into(),
147            cargo_path: None,
148            rustup_path: None,
149            disk_roots: Vec::new(),
150            tools: Vec::new(),
151        }
152    }
153
154    /// Declare the operator-configured tool probes (builder style).
155    #[must_use]
156    pub fn with_tools(mut self, tools: impl IntoIterator<Item = NamedToolProbe>) -> Self {
157        self.tools = tools.into_iter().collect();
158        self
159    }
160}
161
162/// Shell-quote a value for safe single-argument embedding.
163fn shq(s: &str) -> String {
164    format!("'{}'", s.replace('\'', "'\\''"))
165}
166
167/// The canonical ABSOLUTE path to the deployed `rch-wkr` binary for a remote
168/// user and host OS. Unix workers use `/root` or `/home/<user>`; Windows workers
169/// run under Git Bash and use `/c/Users/<user>` plus the native `.exe` suffix.
170/// The probe script shell-quotes this path, so it MUST be absolute: a literal
171/// `~/.local/bin/...` would be single-quoted and never expand, making `[ -x ... ]`
172/// fail and every worker look like it has a missing binary. Single source of
173/// truth for both the daemon bypass-recovery prober and the
174/// `rch self-test --smoke` capability scenario.
175#[must_use]
176pub fn remote_worker_binary_path(user: &str, declared_os: Option<&str>) -> String {
177    if declared_os.is_some_and(|os| os.eq_ignore_ascii_case("windows")) {
178        return format!("/c/Users/{user}/.local/bin/rch-wkr.exe");
179    }
180
181    let home = if user == "root" {
182        "/root".to_string()
183    } else {
184        format!("/home/{user}")
185    };
186    format!("{home}/.local/bin/rch-wkr")
187}
188
189/// Build the capability-probe shell script. It is intentionally fail-soft:
190/// every probe that errors simply omits its fact line, so a missing rustup or a
191/// broken binary shows up as *absent facts* (capability), never as a script
192/// crash (reachability).
193#[must_use]
194pub fn build_capability_probe_script(spec: &ProbeSpec) -> String {
195    let wkr = shq(&spec.rch_wkr_path);
196    let cargo = spec
197        .cargo_path
198        .as_deref()
199        .map_or_else(|| "cargo".to_string(), shq);
200    let rustup = spec
201        .rustup_path
202        .as_deref()
203        .map_or_else(|| "rustup".to_string(), shq);
204
205    let mut s = String::new();
206    s.push_str("set -u; P='RCH_FACT '; ");
207    // Host facts.
208    s.push_str("printf '%sos=%s\\n' \"$P\" \"$(uname -s | tr 'A-Z' 'a-z')\"; ");
209    s.push_str("printf '%sarch=%s\\n' \"$P\" \"$(uname -m)\"; ");
210    s.push_str("printf '%suser=%s\\n' \"$P\" \"$(id -un 2>/dev/null)\"; ");
211    // Worker binary at the EXACT path (version + protocol). Absent => broken.
212    s.push_str(&format!("printf '%srch_wkr_path=%s\\n' \"$P\" {wkr}; "));
213    s.push_str(&format!(
214        "if [ -x {wkr} ]; then v=$({wkr} --version 2>/dev/null) && printf '%sworker_version=%s\\n' \"$P\" \"$v\"; \
215         pr=$({wkr} --protocol-version 2>/dev/null) && printf '%sworker_protocol=%s\\n' \"$P\" \"$pr\"; fi; "
216    ));
217    // Cargo / rust.
218    s.push_str(&format!(
219        "cv=$({cargo} --version 2>/dev/null) && printf '%scargo_version=%s\\n' \"$P\" \"$cv\"; "
220    ));
221    // Toolchains + installed targets via rustup (each on its own fact line).
222    s.push_str(&format!(
223        "{rustup} toolchain list 2>/dev/null | awk -v p=\"$P\" '{{print p\"toolchain=\"$1}}'; "
224    ));
225    s.push_str(&format!(
226        "{rustup} target list --installed 2>/dev/null | awk -v p=\"$P\" '{{print p\"target=\"$1}}'; "
227    ));
228    // Installed components, reported PER TOOLCHAIN as `component=<toolchain>:<name>`.
229    //
230    // Per-toolchain is the whole point, not extra precision: components are
231    // installed against a specific toolchain, and consuming projects pin their
232    // own via `rust-toolchain.toml`. A worker can hold `clippy` for `stable` and
233    // still be unable to run `cargo clippy` for a pinned nightly, which is
234    // exactly the state that let 4 of 12 workers report healthy while silently
235    // voiding a mandated lint gate (bd-vc61a). Probing only the default
236    // toolchain would have reported OK for every one of them.
237    //
238    // `rustup toolchain list` prints the active toolchain with a ` (default)` /
239    // ` (override)` suffix, so `$1` is taken as the name. Each inner listing is
240    // best-effort: a toolchain that cannot be queried simply contributes no
241    // component facts, which reads downstream as "not known to have it" rather
242    // than as "known to lack it".
243    s.push_str(&format!(
244        "for tc in $({rustup} toolchain list 2>/dev/null | awk '{{print $1}}'); do \
245           rh=$({rustup} run \"$tc\" rustc -vV 2>/dev/null | awk '$1 == \"host:\" {{print $2; exit}}'); \
246           {rustup} component list --installed --toolchain \"$tc\" 2>/dev/null \
247             | awk -v p=\"$P\" -v t=\"$tc\" -v h=\"$rh\" \
248                 '{{n=$1; if (h != \"\") sub(\"-\" h \"$\", \"\", n); print p\"component=\"t\":\"n}}'; \
249         done; "
250    ));
251    // JS runtimes (PATH-resolved is acceptable for these advisory facts).
252    s.push_str("bv=$(bun --version 2>/dev/null) && printf '%sbun_version=%s\\n' \"$P\" \"$bv\"; ");
253    s.push_str(
254        "nv=$(node --version 2>/dev/null) && printf '%snode_version=%s\\n' \"$P\" \"$nv\"; ",
255    );
256    s.push_str(
257        "npmv=$(npm --version 2>/dev/null) && printf '%snpm_version=%s\\n' \"$P\" \"$npmv\"; ",
258    );
259    // Go toolchain (PATH-resolved; advisory fact gating go build/test/vet routing).
260    s.push_str("gov=$(go version 2>/dev/null) && printf '%sgo_version=%s\\n' \"$P\" \"$gov\"; ");
261    // Zig + cargo-zigbuild (PATH-resolved; gate `cargo zigbuild` cross-compile
262    // routing). BOTH are required: cargo-zigbuild drives the build, zig is the C
263    // compiler/cross-linker it shells out to. Reported as separate facts.
264    s.push_str("zv=$(zig version 2>/dev/null) && printf '%szig_version=%s\\n' \"$P\" \"$zv\"; ");
265    s.push_str(
266        "czv=$(cargo-zigbuild --version 2>/dev/null) && printf '%scargo_zigbuild_version=%s\\n' \"$P\" \"$czv\"; ",
267    );
268    // Nix: only report a version when a populated `/nix/store` also exists, since
269    // a `nix` binary without a store cannot build derivations (gates nix routing).
270    s.push_str(
271        "if [ -d /nix/store ] && [ -n \"$(ls -A /nix/store 2>/dev/null | head -n1)\" ]; then \
272           nixv=$(nix --version 2>/dev/null) && printf '%snix_version=%s\\n' \"$P\" \"$nixv\"; \
273         fi; ",
274    );
275    // Operator-declared named tools. Each is the exact argv from worker config,
276    // run as this (already configured) user with output discarded — verification
277    // is the EXIT STATUS, and a chatty tool must not be able to inject fact
278    // lines. Both outcomes are reported: `tool=` for present, `tool_absent=` for
279    // a declared probe that failed. The difference matters operationally — "the
280    // fleet never declared clang" and "this worker declares clang but the probe
281    // fails" have different fixes, and a facts stream that only carried
282    // successes could not tell them apart.
283    for tool in &spec.tools {
284        let argv = tool
285            .command()
286            .iter()
287            .map(|part| shq(part))
288            .collect::<Vec<_>>()
289            .join(" ");
290        let name = shq(tool.name());
291        s.push_str(&format!(
292            "if {argv} >/dev/null 2>&1; then printf '%stool=%s\\n' \"$P\" {name}; \
293             else printf '%stool_absent=%s\\n' \"$P\" {name}; fi; "
294        ));
295    }
296    // Disk roots: path;total_kb;avail_kb;avail_inodes (df -Pk and df -Pi).
297    // A configured future build directory may not exist yet. Measure its
298    // closest existing ancestor, preserving the requested root in the fact,
299    // so disk recovery can require evidence for every configured location.
300    for root in &spec.disk_roots {
301        let q = shq(root);
302        s.push_str(&format!(
303            "rp={q}; \
304             while [ ! -e \"$rp\" ] && [ ! -L \"$rp\" ]; do \
305               parent=$(dirname -- \"$rp\") || break; \
306               [ \"$parent\" != \"$rp\" ] || break; rp=$parent; \
307             done; \
308             if [ -d \"$rp\" ]; then \
309               b=$(df -Pk \"$rp\" 2>/dev/null | awk 'NR==2{{print $2\";\"$4}}'); \
310               i=$(df -Pi \"$rp\" 2>/dev/null | awk 'NR==2{{print $4}}'); \
311               printf '%sdisk=%s;%s;%s\\n' \"$P\" {q} \"$b\" \"$i\"; \
312             fi; "
313        ));
314    }
315    s
316}
317
318/// Structured facts parsed from probe output. Sub-facts are `None`/empty when
319/// the corresponding probe produced no line (i.e. the capability is absent).
320#[derive(Debug, Clone, Default, PartialEq, Eq)]
321pub struct ProbedFacts {
322    /// Probed host OS, normalized by [`normalize_probed_os`].
323    pub os: Option<String>,
324    pub arch: Option<String>,
325    pub probed_user: Option<String>,
326    pub rch_wkr_path: Option<String>,
327    pub worker: Option<WorkerBinaryFacts>,
328    pub rust: RustFacts,
329    pub runtimes: RuntimeFacts,
330    pub disk_roots: Vec<DiskRootFacts>,
331    /// Operator-declared tools whose probe exited zero on this worker.
332    pub tools_present: Vec<String>,
333    /// Operator-declared tools whose probe ran and did NOT exit zero.
334    pub tools_absent: Vec<String>,
335    /// Raw worker_version line (kept even if protocol was missing).
336    worker_version: Option<String>,
337    worker_protocol: Option<u32>,
338}
339
340impl ProbedFacts {
341    /// The worker's derived target triple from probed os/arch (libc unknown here
342    /// — defaults to gnu on linux; collectors that know musl override on the
343    /// assembled [`crate::worker_facts::HostFacts`]).
344    #[must_use]
345    pub fn target_triple(&self) -> Option<String> {
346        match (&self.os, &self.arch) {
347            (Some(os), Some(arch)) => Some(derive_target_triple(os, arch, None)),
348            _ => None,
349        }
350    }
351
352    /// Whether a named tool was VERIFIED present on this worker. Absence of
353    /// evidence reads as absence: a tool nobody declared a probe for is not
354    /// present, which is what keeps an unknown `--require-tool` name
355    /// inadmissible everywhere instead of silently unconstrained.
356    #[must_use]
357    pub fn has_tool(&self, name: &str) -> bool {
358        self.tools_present.iter().any(|t| t == name)
359    }
360
361    /// Whether this worker DECLARED a probe for the tool (either outcome).
362    /// Distinguishes "the fleet never declared it" from "declared, probe fails".
363    #[must_use]
364    pub fn declares_tool(&self, name: &str) -> bool {
365        self.has_tool(name) || self.tools_absent.iter().any(|t| t == name)
366    }
367}
368
369/// Collapse the many shapes `uname -s` takes on Windows into plain `windows`.
370///
371/// The probe is a POSIX script, so on a Windows worker it runs under MSYS2,
372/// Git-Bash or Cygwin, each of which reports a versioned kernel name —
373/// `mingw64_nt-10.0-26100`, `msys_nt-10.0-26100`, `cygwin_nt-10.0`. Left raw,
374/// none of them compare equal to the `windows` an operator writes in config or
375/// a `*-pc-windows-msvc` triple implies. Everything else passes through
376/// untouched.
377#[must_use]
378pub fn normalize_probed_os(raw: &str) -> String {
379    let lower = raw.trim().to_ascii_lowercase();
380    for prefix in ["mingw32_nt", "mingw64_nt", "msys_nt", "cygwin_nt"] {
381        if lower.starts_with(prefix) {
382            return "windows".to_string();
383        }
384    }
385    lower
386}
387
388/// Parse `RCH_FACT k=v` probe output into [`ProbedFacts`]. Lines without the
389/// prefix are ignored, so incidental stdout never corrupts the parse.
390#[must_use]
391pub fn parse_capability_probe(stdout: &str) -> ProbedFacts {
392    let mut f = ProbedFacts::default();
393    for line in stdout.lines() {
394        let Some(kv) = line.trim().strip_prefix(FACT_PREFIX) else {
395            continue;
396        };
397        let Some((key, value)) = kv.split_once('=') else {
398            continue;
399        };
400        let value = value.trim();
401        match key {
402            "os" => f.os = Some(normalize_probed_os(value)),
403            "arch" => f.arch = Some(value.to_string()),
404            "user" => f.probed_user = Some(value.to_string()),
405            "rch_wkr_path" => f.rch_wkr_path = Some(value.to_string()),
406            "worker_version" => f.worker_version = Some(value.to_string()),
407            "worker_protocol" => f.worker_protocol = value.parse::<u32>().ok(),
408            "cargo_version" => f.rust.rustc_version = Some(value.to_string()),
409            "toolchain" => f.rust.toolchains.push(value.to_string()),
410            "target" => f.rust.targets.push(value.to_string()),
411            // `component=<toolchain>:<name>`. A value without a separator is
412            // dropped rather than stored under an empty toolchain: an entry that
413            // cannot say WHICH toolchain owns the component would answer
414            // `has_component` for every toolchain, which is the fail-open
415            // direction this fact exists to close.
416            "component" => {
417                if let Some((toolchain, component)) = value.split_once(':') {
418                    let toolchain = toolchain.trim();
419                    let component = component.trim();
420                    if !toolchain.is_empty() && !component.is_empty() {
421                        f.rust.components.push(format!("{toolchain}:{component}"));
422                    }
423                }
424            }
425            "tool" => {
426                if !value.is_empty() && !f.tools_present.iter().any(|t| t == value) {
427                    f.tools_present.push(value.to_string());
428                }
429            }
430            "tool_absent" => {
431                if !value.is_empty() && !f.tools_absent.iter().any(|t| t == value) {
432                    f.tools_absent.push(value.to_string());
433                }
434            }
435            "bun_version" => f.runtimes.bun_version = Some(value.to_string()),
436            "node_version" => f.runtimes.node_version = Some(value.to_string()),
437            "npm_version" => f.runtimes.npm_version = Some(value.to_string()),
438            "nix_version" => f.runtimes.nix_version = Some(value.to_string()),
439            "go_version" => f.runtimes.go_version = Some(value.to_string()),
440            "zig_version" => f.runtimes.zig_version = Some(value.to_string()),
441            "cargo_zigbuild_version" => f.runtimes.cargo_zigbuild_version = Some(value.to_string()),
442            "disk" => {
443                // path;total_kb;avail_kb;avail_inodes
444                let parts: Vec<&str> = value.split(';').collect();
445                if parts.len() == 4 {
446                    let kb = |s: &str| s.parse::<u64>().unwrap_or(0).saturating_mul(1024);
447                    f.disk_roots.push(DiskRootFacts {
448                        path: parts[0].to_string(),
449                        total_bytes: kb(parts[1]),
450                        available_bytes: kb(parts[2]),
451                        available_inodes: parts[3].parse::<u64>().unwrap_or(0),
452                    });
453                }
454            }
455            _ => {}
456        }
457    }
458    // Assemble the worker binary facts only if a version was reported (i.e. the
459    // binary at the exact path actually ran).
460    if let Some(version) = f.worker_version.clone() {
461        f.worker = Some(WorkerBinaryFacts {
462            rch_wkr_path: f.rch_wkr_path.clone().unwrap_or_default(),
463            version,
464            protocol_version: f.worker_protocol.unwrap_or(0),
465        });
466    }
467    f
468}
469
470/// What a command needs from a worker before it is admissible.
471#[derive(Debug, Clone, Default)]
472pub struct CapabilityRequirement {
473    /// rustup targets the build needs (e.g. `wasm32-unknown-unknown`).
474    pub needs_targets: Vec<String>,
475    /// Minimum acceptable worker wire protocol.
476    pub min_protocol: u32,
477    /// Whether a working cargo is required.
478    pub needs_cargo: bool,
479    /// Required host OS for the produced artifact (e.g. `linux`, `darwin`).
480    /// Matched case-insensitively against the worker's probed `os`.
481    pub needs_os: Option<String>,
482    /// Required host arch (e.g. `x86_64`, `aarch64`), matched case-insensitively.
483    pub needs_arch: Option<String>,
484    /// Whether a working `bun` runtime is required.
485    pub needs_bun: bool,
486    /// Whether a working `node` runtime is required.
487    pub needs_node: bool,
488    /// Whether a working Go toolchain is required.
489    pub needs_go: bool,
490    /// Whether `zig` + `cargo-zigbuild` are required (a `cargo zigbuild`
491    /// cross-compile). Enforced ON TOP OF `needs_cargo` and `needs_targets`.
492    pub needs_zig: bool,
493    /// Whether a working `nix` (binary + populated `/nix/store`) is required.
494    pub needs_nix: bool,
495    /// Specific rustup toolchains the build needs (prefix-matched against the
496    /// worker's installed toolchains, e.g. `nightly-2025-11-01` matches
497    /// `nightly-2025-11-01-x86_64-unknown-linux-gnu`).
498    pub needs_toolchains: Vec<String>,
499    /// Rustup components the build needs, each as `"<toolchain>:<component>"`
500    /// (e.g. `"nightly:clippy"`). The toolchain half is prefix-matched exactly
501    /// like [`Self::needs_toolchains`]; the component half must match exactly.
502    ///
503    /// Components are toolchain-scoped on purpose: a worker holding `clippy`
504    /// for `stable` cannot lint a project that pins a nightly, and without this
505    /// the scheduler routed lint work to such a worker and surfaced
506    /// `error: 'cargo-clippy' is not installed for the toolchain '...'` as a
507    /// plain exit 1 — indistinguishable from a real lint failure (bd-vc61a).
508    ///
509    /// Empty by default, so this constrains nothing until a caller opts in.
510    pub needs_components: Vec<String>,
511    /// Operator-declared named tools the work needs verified on the worker
512    /// (`rch exec --job --require-tool NAME`, or a project's
513    /// `jobs.required_tools`).
514    ///
515    /// Empty by default: no requirement means no gate. A name no worker
516    /// declares a probe for is inadmissible EVERYWHERE rather than ignored —
517    /// a typo that silently disabled the gate would be worse than a refusal,
518    /// because the caller asked for the gate precisely because the job cannot
519    /// run without the tool.
520    pub needs_tools: Vec<String>,
521}
522
523impl CapabilityRequirement {
524    /// A bare Rust build requirement (cargo, given wire protocol).
525    #[must_use]
526    pub fn rust(min_protocol: u32) -> Self {
527        Self {
528            min_protocol,
529            needs_cargo: true,
530            ..Self::default()
531        }
532    }
533
534    /// Require these rustup targets (builder style).
535    #[must_use]
536    pub fn with_targets<I, S>(mut self, targets: I) -> Self
537    where
538        I: IntoIterator<Item = S>,
539        S: Into<String>,
540    {
541        self.needs_targets = targets.into_iter().map(Into::into).collect();
542        self
543    }
544
545    /// Require a host OS (e.g. `linux` / `darwin`).
546    #[must_use]
547    pub fn with_os(mut self, os: impl Into<String>) -> Self {
548        self.needs_os = Some(os.into());
549        self
550    }
551
552    /// Require a host arch (e.g. `x86_64` / `aarch64`).
553    #[must_use]
554    pub fn with_arch(mut self, arch: impl Into<String>) -> Self {
555        self.needs_arch = Some(arch.into());
556        self
557    }
558
559    /// Require verified named tools (builder style).
560    #[must_use]
561    pub fn with_tools<I, S>(mut self, tools: I) -> Self
562    where
563        I: IntoIterator<Item = S>,
564        S: Into<String>,
565    {
566        self.needs_tools = tools.into_iter().map(Into::into).collect();
567        self
568    }
569
570    /// Require specific rustup toolchains (builder style).
571    #[must_use]
572    pub fn with_toolchains<I, S>(mut self, toolchains: I) -> Self
573    where
574        I: IntoIterator<Item = S>,
575        S: Into<String>,
576    {
577        self.needs_toolchains = toolchains.into_iter().map(Into::into).collect();
578        self
579    }
580}
581
582/// Outcome of an admissibility assessment. Reachability is the caller's concern
583/// (did the SSH probe run?); this answers "it ran — is it usable?".
584#[derive(Debug, Clone, PartialEq, Eq)]
585pub enum CapabilityVerdict {
586    /// Worker is admissible for the requirement.
587    Admissible,
588    /// Worker ran the probe but is not admissible, with the incident reason.
589    Rejected {
590        reason: IncidentReasonCode,
591        detail: String,
592    },
593}
594
595/// Assess admissibility of probed facts against a requirement. Assumes the
596/// probe was reachable (non-empty facts); a fully empty parse should be treated
597/// as unreachable by the caller, not passed here.
598#[must_use]
599pub fn assess_admissibility(facts: &ProbedFacts, req: &CapabilityRequirement) -> CapabilityVerdict {
600    // The exact-path worker binary must have produced a version (root-good /
601    // user-broken: the binary at the configured path is unusable for this user).
602    let Some(worker) = &facts.worker else {
603        return CapabilityVerdict::Rejected {
604            reason: IncidentReasonCode::WrongUserPathWorkerBinary,
605            detail: format!(
606                "rch-wkr at {} did not report a version as the configured user",
607                facts.rch_wkr_path.as_deref().unwrap_or("<unknown path>")
608            ),
609        };
610    };
611    if worker.protocol_version < req.min_protocol {
612        return CapabilityVerdict::Rejected {
613            reason: IncidentReasonCode::WrongUserPathWorkerBinary,
614            detail: format!(
615                "worker protocol {} < required {}",
616                worker.protocol_version, req.min_protocol
617            ),
618        };
619    }
620    // Host OS/arch must match the required output triple — a worker that ran the
621    // probe fine can still produce the wrong-platform artifact.
622    if let Some(needed_os) = &req.needs_os {
623        let matches = facts
624            .os
625            .as_deref()
626            .is_some_and(|os| os.eq_ignore_ascii_case(needed_os));
627        if !matches {
628            return CapabilityVerdict::Rejected {
629                reason: IncidentReasonCode::OsArchMismatch,
630                detail: format!(
631                    "worker OS {} does not satisfy required {}",
632                    facts.os.as_deref().unwrap_or("<unknown>"),
633                    needed_os
634                ),
635            };
636        }
637    }
638    if let Some(needed_arch) = &req.needs_arch {
639        let matches = facts
640            .arch
641            .as_deref()
642            .is_some_and(|arch| arch.eq_ignore_ascii_case(needed_arch));
643        if !matches {
644            return CapabilityVerdict::Rejected {
645                reason: IncidentReasonCode::OsArchMismatch,
646                detail: format!(
647                    "worker arch {} does not satisfy required {}",
648                    facts.arch.as_deref().unwrap_or("<unknown>"),
649                    needed_arch
650                ),
651            };
652        }
653    }
654    if req.needs_cargo && facts.rust.rustc_version.is_none() {
655        return CapabilityVerdict::Rejected {
656            reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
657            detail: "cargo/rust toolchain not found at the configured user/path".to_string(),
658        };
659    }
660    for needed in &req.needs_toolchains {
661        // Match the exact name OR a `-`-suffixed host-triple form, so
662        // `nightly-2025-11-01` satisfies
663        // `nightly-2025-11-01-x86_64-unknown-linux-gnu` but NOT
664        // `nightly-2025-11-010` (bd-review-toolchain-prefix).
665        if !facts
666            .rust
667            .toolchains
668            .iter()
669            .any(|t| t == needed || t.starts_with(&format!("{needed}-")))
670        {
671            return CapabilityVerdict::Rejected {
672                reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
673                detail: format!("missing rustup toolchain {needed}"),
674            };
675        }
676    }
677    for needed in &req.needs_targets {
678        if !facts.rust.targets.iter().any(|t| t == needed) {
679            return CapabilityVerdict::Rejected {
680                reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
681                detail: format!("missing rustup target {needed}"),
682            };
683        }
684    }
685    // Toolchain-scoped components. Rejecting here turns "this worker cannot run
686    // the command" into a routing decision, so the caller sees a capability
687    // rejection (and, if every candidate is rejected, the same local fallback a
688    // missing target produces) instead of a 2-second exit 1 that reads like a
689    // lint failure (bd-vc61a).
690    for needed in &req.needs_components {
691        let Some((toolchain, component)) = needed.split_once(':') else {
692            return CapabilityVerdict::Rejected {
693                reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
694                detail: format!(
695                    "malformed component requirement {needed}; expected <toolchain>:<component>"
696                ),
697            };
698        };
699        let prefix = format!("{toolchain}-");
700        let present = facts.rust.components.iter().any(|entry| {
701            entry.split_once(':').is_some_and(|(owner, name)| {
702                name == component && (owner == toolchain || owner.starts_with(&prefix))
703            })
704        });
705        if !present {
706            return CapabilityVerdict::Rejected {
707                reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
708                detail: format!("missing rustup component {component} for toolchain {toolchain}"),
709            };
710        }
711    }
712    // Operator-declared named tools. Rejecting here — before any slot is
713    // reserved — is the point: a job that needs a tool this worker lacks would
714    // otherwise hold a reservation only to fail on the worker, and the failure
715    // would be indistinguishable from the job's own nonzero exit (job mode
716    // surfaces remote status verbatim).
717    for needed in &req.needs_tools {
718        if !facts.has_tool(needed) {
719            return CapabilityVerdict::Rejected {
720                reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
721                detail: if facts.declares_tool(needed) {
722                    format!(
723                        "required tool `{needed}` is declared for this worker but its probe did not succeed"
724                    )
725                } else {
726                    format!("required tool `{needed}` has no declared probe on this worker")
727                },
728            };
729        }
730    }
731    if req.needs_bun && facts.runtimes.bun_version.is_none() {
732        return CapabilityVerdict::Rejected {
733            reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
734            detail: "bun runtime not found at the configured user/path".to_string(),
735        };
736    }
737    if req.needs_node && facts.runtimes.node_version.is_none() {
738        return CapabilityVerdict::Rejected {
739            reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
740            detail: "node runtime not found at the configured user/path".to_string(),
741        };
742    }
743    if req.needs_nix && facts.runtimes.nix_version.is_none() {
744        return CapabilityVerdict::Rejected {
745            reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
746            detail: "nix (binary + populated /nix/store) not found on the worker".to_string(),
747        };
748    }
749    if req.needs_go && facts.runtimes.go_version.is_none() {
750        return CapabilityVerdict::Rejected {
751            reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
752            detail: "go toolchain not found at the configured user/path".to_string(),
753        };
754    }
755    // Zig cross-build needs BOTH zig and cargo-zigbuild (the requested rustup
756    // target std is enforced by the needs_targets loop above). Missing either →
757    // this worker can't run it; if every candidate is rejected the build falls
758    // back to local, exactly like a missing rustup target.
759    if req.needs_zig
760        && (facts.runtimes.zig_version.is_none() || facts.runtimes.cargo_zigbuild_version.is_none())
761    {
762        return CapabilityVerdict::Rejected {
763            reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
764            detail: "zig + cargo-zigbuild not found on the worker".to_string(),
765        };
766    }
767    CapabilityVerdict::Admissible
768}
769
770/// A worker's live admission state, separate from its (structural) capability.
771/// Capability says *can this worker ever run the command*; liveness says *is it
772/// usable right now*. Keeping them distinct is what lets selection report a
773/// missing-capability reason instead of conflating it with an unhealthy or busy
774/// worker.
775#[derive(Debug, Clone, Copy, PartialEq, Eq)]
776pub struct WorkerLiveness {
777    /// The worker's capability facts are too stale to trust.
778    pub telemetry_stale: bool,
779    /// The worker is healthy (circuit closed, reachable).
780    pub healthy: bool,
781    /// The worker has at least one free build slot.
782    pub has_free_slot: bool,
783}
784
785impl WorkerLiveness {
786    /// A fresh, healthy, idle worker.
787    #[must_use]
788    pub fn ready() -> Self {
789        Self {
790            telemetry_stale: false,
791            healthy: true,
792            has_free_slot: true,
793        }
794    }
795}
796
797/// The distinct eligibility outcomes selection must tell apart, each with its
798/// own incident reason — so an agent learns *why* a worker was not chosen
799/// (missing capability vs unhealthy vs busy vs stale facts) rather than a single
800/// opaque "no admissible workers".
801#[derive(Debug, Clone, PartialEq, Eq)]
802pub enum EligibilityVerdict {
803    /// Capable, fresh, healthy, and has a free slot.
804    Eligible,
805    /// The worker structurally cannot run this command.
806    MissingCapability {
807        reason: IncidentReasonCode,
808        detail: String,
809    },
810    /// Capability is unknowable because the facts are stale.
811    StaleTelemetry,
812    /// Capable but unhealthy (circuit open / unreachable).
813    Unhealthy,
814    /// Capable and healthy but at capacity right now.
815    Busy,
816}
817
818impl EligibilityVerdict {
819    /// The incident reason for this verdict (`None` when eligible).
820    #[must_use]
821    pub fn reason(&self) -> Option<IncidentReasonCode> {
822        match self {
823            EligibilityVerdict::Eligible => None,
824            EligibilityVerdict::MissingCapability { reason, .. } => Some(*reason),
825            EligibilityVerdict::StaleTelemetry => Some(IncidentReasonCode::TelemetryStale),
826            EligibilityVerdict::Unhealthy => Some(IncidentReasonCode::CircuitOpen),
827            EligibilityVerdict::Busy => Some(IncidentReasonCode::InsufficientSlots),
828        }
829    }
830
831    /// Whether the worker is eligible to run the command.
832    #[must_use]
833    pub fn is_eligible(&self) -> bool {
834        matches!(self, EligibilityVerdict::Eligible)
835    }
836}
837
838/// Assess a worker's full eligibility for a command, distinguishing missing
839/// capability from an unhealthy or busy worker. Pure and total.
840///
841/// Precedence: stale facts (can't trust capability) first; then a structural
842/// capability rejection (no point waiting for a slot it can never use); then
843/// health; then capacity. Capacity/health are transient, so they only matter
844/// once the worker is known capable on fresh facts.
845#[must_use]
846pub fn assess_worker_eligibility(
847    facts: &ProbedFacts,
848    req: &CapabilityRequirement,
849    liveness: &WorkerLiveness,
850) -> EligibilityVerdict {
851    if liveness.telemetry_stale {
852        return EligibilityVerdict::StaleTelemetry;
853    }
854    if let CapabilityVerdict::Rejected { reason, detail } = assess_admissibility(facts, req) {
855        return EligibilityVerdict::MissingCapability { reason, detail };
856    }
857    if !liveness.healthy {
858        return EligibilityVerdict::Unhealthy;
859    }
860    if !liveness.has_free_slot {
861        return EligibilityVerdict::Busy;
862    }
863    EligibilityVerdict::Eligible
864}
865
866#[cfg(test)]
867mod tests {
868    use super::*;
869
870    #[test]
871    fn test_normalize_probed_os_collapses_windows_shells() {
872        // The probe is a POSIX script, so on Windows it runs under MSYS2 /
873        // Git-Bash / Cygwin and `uname -s` names the emulation layer, not the OS.
874        for raw in [
875            "MINGW64_NT-10.0-26100",
876            "mingw32_nt-10.0-22631",
877            "MSYS_NT-10.0-26100",
878            "CYGWIN_NT-10.0",
879        ] {
880            assert_eq!(normalize_probed_os(raw), "windows", "raw={raw}");
881        }
882    }
883
884    #[test]
885    fn test_normalize_probed_os_passes_through_unix() {
886        assert_eq!(normalize_probed_os("Linux"), "linux");
887        assert_eq!(normalize_probed_os("Darwin"), "darwin");
888        assert_eq!(normalize_probed_os(" linux "), "linux");
889    }
890
891    #[test]
892    fn test_parse_normalizes_windows_os_fact() {
893        let facts =
894            parse_capability_probe("RCH_FACT os=MINGW64_NT-10.0-26100\nRCH_FACT arch=x86_64\n");
895        assert_eq!(facts.os.as_deref(), Some("windows"));
896    }
897
898    fn spec() -> ProbeSpec {
899        let mut s = ProbeSpec::new("rch", "/home/rch/.local/bin/rch-wkr");
900        s.cargo_path = Some("/home/rch/.cargo/bin/cargo".to_string());
901        s.disk_roots = vec!["/data/tmp".to_string()];
902        s
903    }
904
905    fn tool(name: &str, argv: &[&str]) -> NamedToolProbe {
906        NamedToolProbe::new(name, argv.iter().copied()).expect("valid probe")
907    }
908
909    #[test]
910    fn named_tool_probe_refuses_names_that_cannot_survive_the_wire_format() {
911        // A fact line is `RCH_FACT tool=<name>`, parsed on the FIRST `=`. A name
912        // carrying `=`, whitespace or a control character would parse back as a
913        // different name — a silent capability lie. Refuse at declaration.
914        for bad in [
915            "",
916            "clang=1",
917            "two words",
918            "new\nline",
919            "tab\there",
920            "sh;rm",
921        ] {
922            assert!(
923                NamedToolProbe::new(bad, ["true"]).is_err(),
924                "{bad:?} must be refused"
925            );
926        }
927        for good in [
928            "clang",
929            "ld.lld",
930            "cargo-nextest",
931            "gcc-14",
932            "llvm_cov",
933            "g++",
934        ] {
935            assert!(
936                NamedToolProbe::new(good, ["true"]).is_ok(),
937                "{good:?} must be accepted"
938            );
939        }
940        // A probe with no argv can never prove anything.
941        assert!(matches!(
942            NamedToolProbe::new("clang", Vec::<String>::new()),
943            Err(ToolProbeError::EmptyCommand { .. })
944        ));
945    }
946
947    #[test]
948    fn script_probes_declared_tools_by_exit_status_and_reports_both_outcomes() {
949        let spec = spec().with_tools([
950            tool("clang", &["clang", "--version"]),
951            tool("ld.lld", &["/usr/bin/ld.lld", "--version"]),
952        ]);
953        let script = build_capability_probe_script(&spec);
954        // The operator's exact argv, shell-quoted, with output discarded: the
955        // verification is the exit status, and a chatty tool must not be able to
956        // inject its own RCH_FACT lines into the stream.
957        assert!(script.contains("if 'clang' '--version' >/dev/null 2>&1;"));
958        assert!(script.contains("if '/usr/bin/ld.lld' '--version' >/dev/null 2>&1;"));
959        assert!(script.contains("tool=%s"));
960        assert!(script.contains("tool_absent=%s"));
961        // No tools declared => no tool probes at all (no requirement, no gate).
962        assert!(!build_capability_probe_script(&self::spec()).contains("tool="));
963    }
964
965    #[test]
966    fn parsed_tool_facts_separate_verified_from_declared_but_failing() {
967        let facts = parse_capability_probe(
968            "RCH_FACT os=linux\nRCH_FACT tool=clang\nRCH_FACT tool_absent=ld.lld\nRCH_FACT tool=clang\n",
969        );
970        assert_eq!(facts.tools_present, vec!["clang".to_string()], "deduped");
971        assert_eq!(facts.tools_absent, vec!["ld.lld".to_string()]);
972        assert!(facts.has_tool("clang"));
973        assert!(!facts.has_tool("ld.lld"));
974        // Declared-but-failing is NOT the same as never declared.
975        assert!(facts.declares_tool("ld.lld"));
976        assert!(!facts.declares_tool("wild"));
977    }
978
979    #[test]
980    fn tool_requirements_gate_admissibility_and_unknown_names_are_inadmissible() {
981        let facts = parse_capability_probe(
982            "RCH_FACT os=linux\nRCH_FACT arch=x86_64\nRCH_FACT rch_wkr_path=/w\n\
983             RCH_FACT worker_version=1\nRCH_FACT worker_protocol=9\n\
984             RCH_FACT cargo_version=cargo 1.99\nRCH_FACT tool=clang\nRCH_FACT tool_absent=ld.lld\n",
985        );
986        let base = CapabilityRequirement::rust(1);
987        // No requirement => no gate.
988        assert_eq!(
989            assess_admissibility(&facts, &base),
990            CapabilityVerdict::Admissible
991        );
992        // Verified tool => admissible.
993        assert_eq!(
994            assess_admissibility(&facts, &base.clone().with_tools(["clang"])),
995            CapabilityVerdict::Admissible
996        );
997        // Declared but failing probe => rejected, and the detail says so.
998        let CapabilityVerdict::Rejected { reason, detail } =
999            assess_admissibility(&facts, &base.clone().with_tools(["ld.lld"]))
1000        else {
1001            panic!("a failing probe must not be admissible");
1002        };
1003        assert_eq!(reason, IncidentReasonCode::MissingRuntimeToolchainTarget);
1004        assert!(detail.contains("did not succeed"), "{detail}");
1005        // A name NOBODY declared (typo, or a tool the fleet never probed) is
1006        // inadmissible rather than silently unconstrained — the caller asked
1007        // for the gate because the job cannot run without the tool.
1008        let CapabilityVerdict::Rejected { detail, .. } =
1009            assess_admissibility(&facts, &base.clone().with_tools(["clanggg"]))
1010        else {
1011            panic!("an undeclared tool must not be admissible");
1012        };
1013        assert!(detail.contains("no declared probe"), "{detail}");
1014        // Every required tool must hold, not just one.
1015        assert!(matches!(
1016            assess_admissibility(&facts, &base.with_tools(["clang", "ld.lld"])),
1017            CapabilityVerdict::Rejected { .. }
1018        ));
1019    }
1020
1021    #[test]
1022    fn script_uses_exact_paths_and_probes_all_component_facts() {
1023        let script = build_capability_probe_script(&spec());
1024        // Exact rch-wkr path is embedded (not PATH-resolved).
1025        assert!(script.contains("/home/rch/.local/bin/rch-wkr"));
1026        assert!(script.contains("/home/rch/.cargo/bin/cargo"));
1027        // Probes every required capability dimension.
1028        assert!(script.contains("--version"));
1029        assert!(script.contains("--protocol-version"));
1030        assert!(script.contains("toolchain list"));
1031        assert!(script.contains("target list --installed"));
1032        // Components must be enumerated PER toolchain, not for the default one:
1033        // a worker holding clippy for `stable` still cannot lint a pinned
1034        // nightly, and probing only the default would report it healthy
1035        // (bd-vc61a).
1036        assert!(script.contains("component list --installed --toolchain"));
1037        assert!(script.contains("rustc -vV"));
1038        assert!(script.contains("sub(\"-\" h \"$\""));
1039        assert!(
1040            script.contains("for tc in"),
1041            "component probe must loop over every installed toolchain"
1042        );
1043        assert!(script.contains("bun --version"));
1044        assert!(script.contains("df -Pk"));
1045        assert!(script.contains("df -Pi"));
1046        // Quoted disk root.
1047        assert!(script.contains("'/data/tmp'"));
1048    }
1049
1050    #[cfg(unix)]
1051    #[test]
1052    fn live_disk_probe_measures_future_roots_without_creating_them() {
1053        let root = tempfile::tempdir().unwrap();
1054        let parent = root.path().join("worker's build volume");
1055        std::fs::create_dir(&parent).unwrap();
1056        let future = parent.join("not-created/cargo-target");
1057        let not_directory = root.path().join("file");
1058        std::fs::write(&not_directory, b"not a build root").unwrap();
1059        let mut spec = ProbeSpec::new("probe", "/not-installed/rch-wkr");
1060        spec.disk_roots = vec![
1061            parent.to_str().unwrap().to_owned(),
1062            future.to_str().unwrap().to_owned(),
1063            not_directory.to_str().unwrap().to_owned(),
1064        ];
1065        let output = std::process::Command::new("sh")
1066            .arg("-c")
1067            .arg(build_capability_probe_script(&spec))
1068            .output()
1069            .unwrap();
1070        assert!(
1071            output.status.success(),
1072            "{}",
1073            String::from_utf8_lossy(&output.stderr)
1074        );
1075        let facts = parse_capability_probe(std::str::from_utf8(&output.stdout).unwrap());
1076        assert_eq!(facts.disk_roots.len(), 2, "{:?}", facts.disk_roots);
1077        assert_eq!(facts.disk_roots[0].path, spec.disk_roots[0]);
1078        assert_eq!(facts.disk_roots[1].path, spec.disk_roots[1]);
1079        assert!(facts.disk_roots[0].total_bytes > 0);
1080        assert_eq!(
1081            facts.disk_roots[0].total_bytes,
1082            facts.disk_roots[1].total_bytes
1083        );
1084        assert!(
1085            !future.exists(),
1086            "a read-only disk probe must not create build directories"
1087        );
1088        assert_eq!(std::fs::read(&not_directory).unwrap(), b"not a build root");
1089    }
1090
1091    fn good_output() -> &'static str {
1092        "RCH_FACT os=linux\n\
1093         RCH_FACT arch=x86_64\n\
1094         RCH_FACT user=rch\n\
1095         RCH_FACT rch_wkr_path=/home/rch/.local/bin/rch-wkr\n\
1096         RCH_FACT worker_version=1.0.41\n\
1097         RCH_FACT worker_protocol=3\n\
1098         RCH_FACT cargo_version=cargo 1.98.0-nightly\n\
1099         RCH_FACT toolchain=stable\n\
1100         RCH_FACT toolchain=nightly-2026-05-22\n\
1101         RCH_FACT target=x86_64-unknown-linux-gnu\n\
1102         RCH_FACT target=wasm32-unknown-unknown\n\
1103         RCH_FACT component=stable:clippy\n\
1104         RCH_FACT component=stable:rustfmt\n\
1105         RCH_FACT component=nightly-2026-05-22:rustfmt\n\
1106         RCH_FACT component=malformed-without-separator\n\
1107         RCH_FACT bun_version=1.1.0\n\
1108         RCH_FACT disk=/data/tmp;1048576;524288;900000\n\
1109         incidental noise line that must be ignored\n"
1110    }
1111
1112    #[test]
1113    fn parses_full_probe_output() {
1114        let f = parse_capability_probe(good_output());
1115        assert_eq!(f.os.as_deref(), Some("linux"));
1116        assert_eq!(f.arch.as_deref(), Some("x86_64"));
1117        assert_eq!(f.probed_user.as_deref(), Some("rch"));
1118        let w = f.worker.as_ref().unwrap();
1119        assert_eq!(w.version, "1.0.41");
1120        assert_eq!(w.protocol_version, 3);
1121        assert_eq!(w.rch_wkr_path, "/home/rch/.local/bin/rch-wkr");
1122        assert_eq!(f.rust.toolchains, vec!["stable", "nightly-2026-05-22"]);
1123        assert!(f.rust.targets.iter().any(|t| t == "wasm32-unknown-unknown"));
1124        // Components stay toolchain-qualified, and the malformed line carrying
1125        // no `<toolchain>:<name>` separator is dropped rather than stored under
1126        // an empty owner -- an unqualified entry would answer `has_component`
1127        // for EVERY toolchain, which is the fail-open direction.
1128        assert_eq!(
1129            f.rust.components,
1130            vec![
1131                "stable:clippy",
1132                "stable:rustfmt",
1133                "nightly-2026-05-22:rustfmt"
1134            ]
1135        );
1136        assert_eq!(f.runtimes.bun_version.as_deref(), Some("1.1.0"));
1137        assert_eq!(f.disk_roots.len(), 1);
1138        assert_eq!(f.disk_roots[0].path, "/data/tmp");
1139        assert_eq!(f.disk_roots[0].available_bytes, 524288 * 1024);
1140        assert_eq!(f.disk_roots[0].available_inodes, 900000);
1141        assert_eq!(
1142            f.target_triple().as_deref(),
1143            Some("x86_64-unknown-linux-gnu")
1144        );
1145    }
1146
1147    fn req_wasm() -> CapabilityRequirement {
1148        CapabilityRequirement {
1149            needs_targets: vec!["wasm32-unknown-unknown".to_string()],
1150            min_protocol: 3,
1151            needs_cargo: true,
1152            ..CapabilityRequirement::default()
1153        }
1154    }
1155
1156    #[test]
1157    fn admissible_when_all_capabilities_present() {
1158        let f = parse_capability_probe(good_output());
1159        assert_eq!(
1160            assess_admissibility(&f, &req_wasm()),
1161            CapabilityVerdict::Admissible
1162        );
1163    }
1164
1165    #[test]
1166    fn component_requirement_is_toolchain_scoped() {
1167        let f = parse_capability_probe(good_output());
1168        let with_components = |needed: &[&str]| CapabilityRequirement {
1169            needs_components: needed.iter().map(|s| (*s).to_string()).collect(),
1170            ..req_wasm()
1171        };
1172
1173        // Present for the toolchain that actually owns it, including the
1174        // `-`-suffixed pinned form.
1175        assert_eq!(
1176            assess_admissibility(&f, &with_components(&["stable:clippy"])),
1177            CapabilityVerdict::Admissible
1178        );
1179        assert_eq!(
1180            assess_admissibility(&f, &with_components(&["nightly:rustfmt"])),
1181            CapabilityVerdict::Admissible
1182        );
1183
1184        // The regression this exists to catch: the worker HAS clippy, but for
1185        // `stable` only. A pinned-nightly lint must not be routed here.
1186        match assess_admissibility(&f, &with_components(&["nightly:clippy"])) {
1187            CapabilityVerdict::Rejected { reason, detail } => {
1188                assert_eq!(reason, IncidentReasonCode::MissingRuntimeToolchainTarget);
1189                assert!(
1190                    detail.contains("clippy") && detail.contains("nightly"),
1191                    "rejection must name the component and toolchain: {detail}"
1192                );
1193            }
1194            other => panic!("expected rejection, got {other:?}"),
1195        }
1196
1197        // A requirement that cannot say which toolchain it means is rejected
1198        // rather than matched loosely.
1199        match assess_admissibility(&f, &with_components(&["clippy"])) {
1200            CapabilityVerdict::Rejected { detail, .. } => {
1201                assert!(detail.contains("malformed"), "got {detail}");
1202            }
1203            other => panic!("expected rejection, got {other:?}"),
1204        }
1205    }
1206
1207    #[test]
1208    fn no_component_requirement_constrains_nothing() {
1209        // Default is empty, so every existing caller keeps its current routing.
1210        // A worker reporting no components at all stays admissible.
1211        let out = "RCH_FACT os=linux\nRCH_FACT arch=x86_64\nRCH_FACT user=rch\n\
1212                   RCH_FACT rch_wkr_path=/home/rch/.local/bin/rch-wkr\n\
1213                   RCH_FACT worker_version=1.0.41\nRCH_FACT worker_protocol=3\n\
1214                   RCH_FACT cargo_version=cargo 1.98.0-nightly\n\
1215                   RCH_FACT target=wasm32-unknown-unknown\n";
1216        let f = parse_capability_probe(out);
1217        assert!(f.rust.components.is_empty());
1218        assert_eq!(
1219            assess_admissibility(&f, &req_wasm()),
1220            CapabilityVerdict::Admissible
1221        );
1222    }
1223
1224    #[test]
1225    fn user_broken_binary_rejected_distinct_from_unreachable() {
1226        // The probe RAN (host facts present) but the exact-path binary produced
1227        // no version — root-good / user-broken. This is capability rejection,
1228        // not unreachability.
1229        let out = "RCH_FACT os=linux\nRCH_FACT arch=x86_64\n\
1230                   RCH_FACT rch_wkr_path=/home/rch/.local/bin/rch-wkr\n";
1231        let f = parse_capability_probe(out);
1232        assert!(f.worker.is_none());
1233        match assess_admissibility(&f, &req_wasm()) {
1234            CapabilityVerdict::Rejected { reason, .. } => {
1235                assert_eq!(reason, IncidentReasonCode::WrongUserPathWorkerBinary);
1236            }
1237            other => panic!("expected rejection, got {other:?}"),
1238        }
1239    }
1240
1241    #[test]
1242    fn missing_rustup_rejected_for_cargo_requirement() {
1243        // Worker fine, but no cargo/toolchains were probed.
1244        let out = "RCH_FACT os=linux\nRCH_FACT arch=x86_64\n\
1245                   RCH_FACT worker_version=1.0.41\nRCH_FACT worker_protocol=3\n";
1246        let f = parse_capability_probe(out);
1247        match assess_admissibility(&f, &req_wasm()) {
1248            CapabilityVerdict::Rejected { reason, detail } => {
1249                assert_eq!(reason, IncidentReasonCode::MissingRuntimeToolchainTarget);
1250                assert!(detail.contains("cargo"));
1251            }
1252            other => panic!("expected rejection, got {other:?}"),
1253        }
1254    }
1255
1256    #[test]
1257    fn missing_wasm_target_rejected() {
1258        // Has cargo + protocol, but lacks the wasm target.
1259        let out = "RCH_FACT worker_version=1.0.41\nRCH_FACT worker_protocol=3\n\
1260                   RCH_FACT cargo_version=cargo 1.98\nRCH_FACT target=x86_64-unknown-linux-gnu\n";
1261        let f = parse_capability_probe(out);
1262        match assess_admissibility(&f, &req_wasm()) {
1263            CapabilityVerdict::Rejected { reason, detail } => {
1264                assert_eq!(reason, IncidentReasonCode::MissingRuntimeToolchainTarget);
1265                assert!(detail.contains("wasm32-unknown-unknown"));
1266            }
1267            other => panic!("expected rejection, got {other:?}"),
1268        }
1269    }
1270
1271    #[test]
1272    fn stale_worker_protocol_rejected() {
1273        let out = "RCH_FACT worker_version=0.9.0\nRCH_FACT worker_protocol=1\n\
1274                   RCH_FACT cargo_version=cargo 1.98\nRCH_FACT target=wasm32-unknown-unknown\n";
1275        let f = parse_capability_probe(out);
1276        match assess_admissibility(&f, &req_wasm()) {
1277            CapabilityVerdict::Rejected { reason, detail } => {
1278                assert_eq!(reason, IncidentReasonCode::WrongUserPathWorkerBinary);
1279                assert!(detail.contains("protocol"));
1280            }
1281            other => panic!("expected rejection, got {other:?}"),
1282        }
1283    }
1284
1285    #[test]
1286    fn empty_output_parses_empty() {
1287        // A fully empty parse is the caller's signal of unreachability.
1288        let f = parse_capability_probe("");
1289        assert_eq!(f, ProbedFacts::default());
1290        assert!(f.worker.is_none());
1291        assert!(f.os.is_none());
1292    }
1293
1294    // --- 12.3: OS/arch, runtime, toolchain capability dimensions -----------
1295
1296    #[test]
1297    fn os_mismatch_rejected_with_osarch_reason() {
1298        // A linux worker cannot produce a darwin artifact.
1299        let f = parse_capability_probe(good_output());
1300        let req = CapabilityRequirement::rust(3).with_os("darwin");
1301        match assess_admissibility(&f, &req) {
1302            CapabilityVerdict::Rejected { reason, detail } => {
1303                assert_eq!(reason, IncidentReasonCode::OsArchMismatch);
1304                assert!(detail.contains("darwin"));
1305            }
1306            other => panic!("expected OS-mismatch rejection, got {other:?}"),
1307        }
1308        // The matching OS is admissible.
1309        assert_eq!(
1310            assess_admissibility(&f, &CapabilityRequirement::rust(3).with_os("linux")),
1311            CapabilityVerdict::Admissible
1312        );
1313    }
1314
1315    #[test]
1316    fn arch_mismatch_rejected_with_osarch_reason() {
1317        let f = parse_capability_probe(good_output());
1318        let req = CapabilityRequirement::rust(3).with_arch("aarch64");
1319        match assess_admissibility(&f, &req) {
1320            CapabilityVerdict::Rejected { reason, .. } => {
1321                assert_eq!(reason, IncidentReasonCode::OsArchMismatch);
1322            }
1323            other => panic!("expected arch-mismatch rejection, got {other:?}"),
1324        }
1325    }
1326
1327    #[test]
1328    fn missing_node_runtime_rejected() {
1329        // good_output has bun but no node.
1330        let f = parse_capability_probe(good_output());
1331        let mut req = CapabilityRequirement::rust(3);
1332        req.needs_node = true;
1333        match assess_admissibility(&f, &req) {
1334            CapabilityVerdict::Rejected { reason, detail } => {
1335                assert_eq!(reason, IncidentReasonCode::MissingRuntimeToolchainTarget);
1336                assert!(detail.contains("node"));
1337            }
1338            other => panic!("expected node rejection, got {other:?}"),
1339        }
1340        // bun IS present, so a bun requirement is admissible.
1341        let mut bun_req = CapabilityRequirement::rust(3);
1342        bun_req.needs_bun = true;
1343        assert_eq!(
1344            assess_admissibility(&f, &bun_req),
1345            CapabilityVerdict::Admissible
1346        );
1347    }
1348
1349    #[test]
1350    fn specific_toolchain_prefix_matched() {
1351        let f = parse_capability_probe(good_output());
1352        // Installed nightly-2026-05-22 satisfies the bare prefix.
1353        let ok = CapabilityRequirement::rust(3).with_toolchains(["nightly-2026-05-22"]);
1354        assert_eq!(assess_admissibility(&f, &ok), CapabilityVerdict::Admissible);
1355        // A different pinned nightly is missing.
1356        let missing = CapabilityRequirement::rust(3).with_toolchains(["nightly-2025-01-01"]);
1357        match assess_admissibility(&f, &missing) {
1358            CapabilityVerdict::Rejected { reason, detail } => {
1359                assert_eq!(reason, IncidentReasonCode::MissingRuntimeToolchainTarget);
1360                assert!(detail.contains("nightly-2025-01-01"));
1361            }
1362            other => panic!("expected toolchain rejection, got {other:?}"),
1363        }
1364    }
1365
1366    // --- 12.3: eligibility distinguishes missing-capability / unhealthy /
1367    //           busy / stale ------------------------------------------------
1368
1369    #[test]
1370    fn eligible_when_capable_fresh_healthy_idle() {
1371        let f = parse_capability_probe(good_output());
1372        let v = assess_worker_eligibility(&f, &req_wasm(), &WorkerLiveness::ready());
1373        assert_eq!(v, EligibilityVerdict::Eligible);
1374        assert!(v.is_eligible());
1375        assert_eq!(v.reason(), None);
1376    }
1377
1378    #[test]
1379    fn no_worker_with_runtime_is_missing_capability_not_busy() {
1380        // A worker without cargo, even if idle+healthy, is missing-capability —
1381        // distinct from a busy or unhealthy worker.
1382        let out = "RCH_FACT os=linux\nRCH_FACT arch=x86_64\n\
1383                   RCH_FACT worker_version=1.0.41\nRCH_FACT worker_protocol=3\n";
1384        let f = parse_capability_probe(out);
1385        let v = assess_worker_eligibility(&f, &req_wasm(), &WorkerLiveness::ready());
1386        assert_eq!(
1387            v.reason(),
1388            Some(IncidentReasonCode::MissingRuntimeToolchainTarget)
1389        );
1390        assert!(matches!(v, EligibilityVerdict::MissingCapability { .. }));
1391    }
1392
1393    #[test]
1394    fn os_arch_mismatch_surfaces_as_missing_capability() {
1395        let f = parse_capability_probe(good_output());
1396        let req = CapabilityRequirement::rust(3).with_os("darwin");
1397        let v = assess_worker_eligibility(&f, &req, &WorkerLiveness::ready());
1398        assert_eq!(v.reason(), Some(IncidentReasonCode::OsArchMismatch));
1399    }
1400
1401    #[test]
1402    fn unhealthy_and_busy_are_distinct_from_missing_capability() {
1403        let f = parse_capability_probe(good_output());
1404        // Capable but circuit-open => Unhealthy.
1405        let unhealthy = WorkerLiveness {
1406            telemetry_stale: false,
1407            healthy: false,
1408            has_free_slot: true,
1409        };
1410        assert_eq!(
1411            assess_worker_eligibility(&f, &req_wasm(), &unhealthy).reason(),
1412            Some(IncidentReasonCode::CircuitOpen)
1413        );
1414        // Capable + healthy but no slot => Busy.
1415        let busy = WorkerLiveness {
1416            telemetry_stale: false,
1417            healthy: true,
1418            has_free_slot: false,
1419        };
1420        assert_eq!(
1421            assess_worker_eligibility(&f, &req_wasm(), &busy).reason(),
1422            Some(IncidentReasonCode::InsufficientSlots)
1423        );
1424    }
1425
1426    #[test]
1427    fn stale_capability_cache_is_distinct_reason() {
1428        let f = parse_capability_probe(good_output());
1429        let stale = WorkerLiveness {
1430            telemetry_stale: true,
1431            healthy: true,
1432            has_free_slot: true,
1433        };
1434        let v = assess_worker_eligibility(&f, &req_wasm(), &stale);
1435        assert_eq!(v, EligibilityVerdict::StaleTelemetry);
1436        assert_eq!(v.reason(), Some(IncidentReasonCode::TelemetryStale));
1437    }
1438
1439    #[test]
1440    fn capability_refresh_changes_eligibility() {
1441        // Before refresh: worker lacks the wasm target => missing capability.
1442        let before = parse_capability_probe(
1443            "RCH_FACT os=linux\nRCH_FACT arch=x86_64\n\
1444             RCH_FACT worker_version=1.0.41\nRCH_FACT worker_protocol=3\n\
1445             RCH_FACT cargo_version=cargo 1.98\nRCH_FACT target=x86_64-unknown-linux-gnu\n",
1446        );
1447        let v_before = assess_worker_eligibility(&before, &req_wasm(), &WorkerLiveness::ready());
1448        assert!(matches!(
1449            v_before,
1450            EligibilityVerdict::MissingCapability { .. }
1451        ));
1452
1453        // After refresh: the wasm target was installed => now eligible.
1454        let after = parse_capability_probe(good_output());
1455        let v_after = assess_worker_eligibility(&after, &req_wasm(), &WorkerLiveness::ready());
1456        assert_eq!(v_after, EligibilityVerdict::Eligible);
1457    }
1458
1459    #[test]
1460    fn remote_worker_binary_path_is_absolute_per_user() {
1461        assert_eq!(
1462            remote_worker_binary_path("root", None),
1463            "/root/.local/bin/rch-wkr"
1464        );
1465        assert_eq!(
1466            remote_worker_binary_path("ubuntu", Some("linux")),
1467            "/home/ubuntu/.local/bin/rch-wkr"
1468        );
1469        // The path must be absolute: the probe script single-quotes it, so a
1470        // literal `~` would never expand and `[ -x ... ]` would always fail.
1471        for user in ["root", "rch", "ubuntu"] {
1472            let path = remote_worker_binary_path(user, None);
1473            assert!(!path.contains('~'), "{path} must not contain a tilde");
1474            assert!(path.starts_with('/'), "{path} must be absolute");
1475            let spec = ProbeSpec::new(user, path);
1476            let script = build_capability_probe_script(&spec);
1477            // The shell-quoted binary path embedded in the script is absolute,
1478            // so `[ -x ... ]` resolves on the real worker.
1479            assert!(script.contains("/.local/bin/rch-wkr'"));
1480            assert!(!script.contains("'~/.local/bin/rch-wkr'"));
1481        }
1482    }
1483
1484    #[test]
1485    fn remote_worker_binary_path_uses_git_bash_home_for_windows() {
1486        assert_eq!(
1487            remote_worker_binary_path("jeffr", Some("windows")),
1488            "/c/Users/jeffr/.local/bin/rch-wkr.exe"
1489        );
1490        assert_eq!(
1491            remote_worker_binary_path("jeffr", Some("WINDOWS")),
1492            "/c/Users/jeffr/.local/bin/rch-wkr.exe"
1493        );
1494    }
1495}