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        // Share exact-version semantics with the daemon's live selection gate.
719        // A versioned requirement cannot fall back to an operator-named label.
720        if let Some(check) = crate::tool_requirement::check_exact_runtime(
721            needed,
722            facts.runtimes.node_version.as_deref(),
723            facts.runtimes.bun_version.as_deref(),
724        ) {
725            if let Err(detail) = check {
726                return CapabilityVerdict::Rejected {
727                    reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
728                    detail,
729                };
730            }
731            continue;
732        }
733        if !facts.has_tool(needed) {
734            return CapabilityVerdict::Rejected {
735                reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
736                detail: if facts.declares_tool(needed) {
737                    format!(
738                        "required tool `{needed}` is declared for this worker but its probe did not succeed"
739                    )
740                } else {
741                    format!("required tool `{needed}` has no declared probe on this worker")
742                },
743            };
744        }
745    }
746    if req.needs_bun && facts.runtimes.bun_version.is_none() {
747        return CapabilityVerdict::Rejected {
748            reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
749            detail: "bun runtime not found at the configured user/path".to_string(),
750        };
751    }
752    if req.needs_node && facts.runtimes.node_version.is_none() {
753        return CapabilityVerdict::Rejected {
754            reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
755            detail: "node runtime not found at the configured user/path".to_string(),
756        };
757    }
758    if req.needs_nix && facts.runtimes.nix_version.is_none() {
759        return CapabilityVerdict::Rejected {
760            reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
761            detail: "nix (binary + populated /nix/store) not found on the worker".to_string(),
762        };
763    }
764    if req.needs_go && facts.runtimes.go_version.is_none() {
765        return CapabilityVerdict::Rejected {
766            reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
767            detail: "go toolchain not found at the configured user/path".to_string(),
768        };
769    }
770    // Zig cross-build needs BOTH zig and cargo-zigbuild (the requested rustup
771    // target std is enforced by the needs_targets loop above). Missing either →
772    // this worker can't run it; if every candidate is rejected the build falls
773    // back to local, exactly like a missing rustup target.
774    if req.needs_zig
775        && (facts.runtimes.zig_version.is_none() || facts.runtimes.cargo_zigbuild_version.is_none())
776    {
777        return CapabilityVerdict::Rejected {
778            reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
779            detail: "zig + cargo-zigbuild not found on the worker".to_string(),
780        };
781    }
782    CapabilityVerdict::Admissible
783}
784
785/// A worker's live admission state, separate from its (structural) capability.
786/// Capability says *can this worker ever run the command*; liveness says *is it
787/// usable right now*. Keeping them distinct is what lets selection report a
788/// missing-capability reason instead of conflating it with an unhealthy or busy
789/// worker.
790#[derive(Debug, Clone, Copy, PartialEq, Eq)]
791pub struct WorkerLiveness {
792    /// The worker's capability facts are too stale to trust.
793    pub telemetry_stale: bool,
794    /// The worker is healthy (circuit closed, reachable).
795    pub healthy: bool,
796    /// The worker has at least one free build slot.
797    pub has_free_slot: bool,
798}
799
800impl WorkerLiveness {
801    /// A fresh, healthy, idle worker.
802    #[must_use]
803    pub fn ready() -> Self {
804        Self {
805            telemetry_stale: false,
806            healthy: true,
807            has_free_slot: true,
808        }
809    }
810}
811
812/// The distinct eligibility outcomes selection must tell apart, each with its
813/// own incident reason — so an agent learns *why* a worker was not chosen
814/// (missing capability vs unhealthy vs busy vs stale facts) rather than a single
815/// opaque "no admissible workers".
816#[derive(Debug, Clone, PartialEq, Eq)]
817pub enum EligibilityVerdict {
818    /// Capable, fresh, healthy, and has a free slot.
819    Eligible,
820    /// The worker structurally cannot run this command.
821    MissingCapability {
822        reason: IncidentReasonCode,
823        detail: String,
824    },
825    /// Capability is unknowable because the facts are stale.
826    StaleTelemetry,
827    /// Capable but unhealthy (circuit open / unreachable).
828    Unhealthy,
829    /// Capable and healthy but at capacity right now.
830    Busy,
831}
832
833impl EligibilityVerdict {
834    /// The incident reason for this verdict (`None` when eligible).
835    #[must_use]
836    pub fn reason(&self) -> Option<IncidentReasonCode> {
837        match self {
838            EligibilityVerdict::Eligible => None,
839            EligibilityVerdict::MissingCapability { reason, .. } => Some(*reason),
840            EligibilityVerdict::StaleTelemetry => Some(IncidentReasonCode::TelemetryStale),
841            EligibilityVerdict::Unhealthy => Some(IncidentReasonCode::CircuitOpen),
842            EligibilityVerdict::Busy => Some(IncidentReasonCode::InsufficientSlots),
843        }
844    }
845
846    /// Whether the worker is eligible to run the command.
847    #[must_use]
848    pub fn is_eligible(&self) -> bool {
849        matches!(self, EligibilityVerdict::Eligible)
850    }
851}
852
853/// Assess a worker's full eligibility for a command, distinguishing missing
854/// capability from an unhealthy or busy worker. Pure and total.
855///
856/// Precedence: stale facts (can't trust capability) first; then a structural
857/// capability rejection (no point waiting for a slot it can never use); then
858/// health; then capacity. Capacity/health are transient, so they only matter
859/// once the worker is known capable on fresh facts.
860#[must_use]
861pub fn assess_worker_eligibility(
862    facts: &ProbedFacts,
863    req: &CapabilityRequirement,
864    liveness: &WorkerLiveness,
865) -> EligibilityVerdict {
866    if liveness.telemetry_stale {
867        return EligibilityVerdict::StaleTelemetry;
868    }
869    if let CapabilityVerdict::Rejected { reason, detail } = assess_admissibility(facts, req) {
870        return EligibilityVerdict::MissingCapability { reason, detail };
871    }
872    if !liveness.healthy {
873        return EligibilityVerdict::Unhealthy;
874    }
875    if !liveness.has_free_slot {
876        return EligibilityVerdict::Busy;
877    }
878    EligibilityVerdict::Eligible
879}
880
881#[cfg(test)]
882mod tests {
883    use super::*;
884
885    #[test]
886    fn test_normalize_probed_os_collapses_windows_shells() {
887        // The probe is a POSIX script, so on Windows it runs under MSYS2 /
888        // Git-Bash / Cygwin and `uname -s` names the emulation layer, not the OS.
889        for raw in [
890            "MINGW64_NT-10.0-26100",
891            "mingw32_nt-10.0-22631",
892            "MSYS_NT-10.0-26100",
893            "CYGWIN_NT-10.0",
894        ] {
895            assert_eq!(normalize_probed_os(raw), "windows", "raw={raw}");
896        }
897    }
898
899    #[test]
900    fn test_normalize_probed_os_passes_through_unix() {
901        assert_eq!(normalize_probed_os("Linux"), "linux");
902        assert_eq!(normalize_probed_os("Darwin"), "darwin");
903        assert_eq!(normalize_probed_os(" linux "), "linux");
904    }
905
906    #[test]
907    fn test_parse_normalizes_windows_os_fact() {
908        let facts =
909            parse_capability_probe("RCH_FACT os=MINGW64_NT-10.0-26100\nRCH_FACT arch=x86_64\n");
910        assert_eq!(facts.os.as_deref(), Some("windows"));
911    }
912
913    fn spec() -> ProbeSpec {
914        let mut s = ProbeSpec::new("rch", "/home/rch/.local/bin/rch-wkr");
915        s.cargo_path = Some("/home/rch/.cargo/bin/cargo".to_string());
916        s.disk_roots = vec!["/data/tmp".to_string()];
917        s
918    }
919
920    fn tool(name: &str, argv: &[&str]) -> NamedToolProbe {
921        NamedToolProbe::new(name, argv.iter().copied()).expect("valid probe")
922    }
923
924    #[test]
925    fn named_tool_probe_refuses_names_that_cannot_survive_the_wire_format() {
926        // A fact line is `RCH_FACT tool=<name>`, parsed on the FIRST `=`. A name
927        // carrying `=`, whitespace or a control character would parse back as a
928        // different name — a silent capability lie. Refuse at declaration.
929        for bad in [
930            "",
931            "clang=1",
932            "two words",
933            "new\nline",
934            "tab\there",
935            "sh;rm",
936        ] {
937            assert!(
938                NamedToolProbe::new(bad, ["true"]).is_err(),
939                "{bad:?} must be refused"
940            );
941        }
942        for good in [
943            "clang",
944            "ld.lld",
945            "cargo-nextest",
946            "gcc-14",
947            "llvm_cov",
948            "g++",
949        ] {
950            assert!(
951                NamedToolProbe::new(good, ["true"]).is_ok(),
952                "{good:?} must be accepted"
953            );
954        }
955        // A probe with no argv can never prove anything.
956        assert!(matches!(
957            NamedToolProbe::new("clang", Vec::<String>::new()),
958            Err(ToolProbeError::EmptyCommand { .. })
959        ));
960    }
961
962    #[test]
963    fn script_probes_declared_tools_by_exit_status_and_reports_both_outcomes() {
964        let spec = spec().with_tools([
965            tool("clang", &["clang", "--version"]),
966            tool("ld.lld", &["/usr/bin/ld.lld", "--version"]),
967        ]);
968        let script = build_capability_probe_script(&spec);
969        // The operator's exact argv, shell-quoted, with output discarded: the
970        // verification is the exit status, and a chatty tool must not be able to
971        // inject its own RCH_FACT lines into the stream.
972        assert!(script.contains("if 'clang' '--version' >/dev/null 2>&1;"));
973        assert!(script.contains("if '/usr/bin/ld.lld' '--version' >/dev/null 2>&1;"));
974        assert!(script.contains("tool=%s"));
975        assert!(script.contains("tool_absent=%s"));
976        // No tools declared => no tool probes at all (no requirement, no gate).
977        assert!(!build_capability_probe_script(&self::spec()).contains("tool="));
978    }
979
980    #[test]
981    fn parsed_tool_facts_separate_verified_from_declared_but_failing() {
982        let facts = parse_capability_probe(
983            "RCH_FACT os=linux\nRCH_FACT tool=clang\nRCH_FACT tool_absent=ld.lld\nRCH_FACT tool=clang\n",
984        );
985        assert_eq!(facts.tools_present, vec!["clang".to_string()], "deduped");
986        assert_eq!(facts.tools_absent, vec!["ld.lld".to_string()]);
987        assert!(facts.has_tool("clang"));
988        assert!(!facts.has_tool("ld.lld"));
989        // Declared-but-failing is NOT the same as never declared.
990        assert!(facts.declares_tool("ld.lld"));
991        assert!(!facts.declares_tool("wild"));
992    }
993
994    #[test]
995    fn tool_requirements_gate_admissibility_and_unknown_names_are_inadmissible() {
996        let facts = parse_capability_probe(
997            "RCH_FACT os=linux\nRCH_FACT arch=x86_64\nRCH_FACT rch_wkr_path=/w\n\
998             RCH_FACT worker_version=1\nRCH_FACT worker_protocol=9\n\
999             RCH_FACT cargo_version=cargo 1.99\nRCH_FACT tool=clang\nRCH_FACT tool_absent=ld.lld\n",
1000        );
1001        let base = CapabilityRequirement::rust(1);
1002        // No requirement => no gate.
1003        assert_eq!(
1004            assess_admissibility(&facts, &base),
1005            CapabilityVerdict::Admissible
1006        );
1007        // Verified tool => admissible.
1008        assert_eq!(
1009            assess_admissibility(&facts, &base.clone().with_tools(["clang"])),
1010            CapabilityVerdict::Admissible
1011        );
1012        // Declared but failing probe => rejected, and the detail says so.
1013        let CapabilityVerdict::Rejected { reason, detail } =
1014            assess_admissibility(&facts, &base.clone().with_tools(["ld.lld"]))
1015        else {
1016            panic!("a failing probe must not be admissible");
1017        };
1018        assert_eq!(reason, IncidentReasonCode::MissingRuntimeToolchainTarget);
1019        assert!(detail.contains("did not succeed"), "{detail}");
1020        // A name NOBODY declared (typo, or a tool the fleet never probed) is
1021        // inadmissible rather than silently unconstrained — the caller asked
1022        // for the gate because the job cannot run without the tool.
1023        let CapabilityVerdict::Rejected { detail, .. } =
1024            assess_admissibility(&facts, &base.clone().with_tools(["clanggg"]))
1025        else {
1026            panic!("an undeclared tool must not be admissible");
1027        };
1028        assert!(detail.contains("no declared probe"), "{detail}");
1029        // Every required tool must hold, not just one.
1030        assert!(matches!(
1031            assess_admissibility(&facts, &base.with_tools(["clang", "ld.lld"])),
1032            CapabilityVerdict::Rejected { .. }
1033        ));
1034    }
1035
1036    #[test]
1037    fn script_uses_exact_paths_and_probes_all_component_facts() {
1038        let script = build_capability_probe_script(&spec());
1039        // Exact rch-wkr path is embedded (not PATH-resolved).
1040        assert!(script.contains("/home/rch/.local/bin/rch-wkr"));
1041        assert!(script.contains("/home/rch/.cargo/bin/cargo"));
1042        // Probes every required capability dimension.
1043        assert!(script.contains("--version"));
1044        assert!(script.contains("--protocol-version"));
1045        assert!(script.contains("toolchain list"));
1046        assert!(script.contains("target list --installed"));
1047        // Components must be enumerated PER toolchain, not for the default one:
1048        // a worker holding clippy for `stable` still cannot lint a pinned
1049        // nightly, and probing only the default would report it healthy
1050        // (bd-vc61a).
1051        assert!(script.contains("component list --installed --toolchain"));
1052        assert!(script.contains("rustc -vV"));
1053        assert!(script.contains("sub(\"-\" h \"$\""));
1054        assert!(
1055            script.contains("for tc in"),
1056            "component probe must loop over every installed toolchain"
1057        );
1058        assert!(script.contains("bun --version"));
1059        assert!(script.contains("df -Pk"));
1060        assert!(script.contains("df -Pi"));
1061        // Quoted disk root.
1062        assert!(script.contains("'/data/tmp'"));
1063    }
1064
1065    #[cfg(unix)]
1066    #[test]
1067    fn live_disk_probe_measures_future_roots_without_creating_them() {
1068        let root = tempfile::tempdir().unwrap();
1069        let parent = root.path().join("worker's build volume");
1070        std::fs::create_dir(&parent).unwrap();
1071        let future = parent.join("not-created/cargo-target");
1072        let not_directory = root.path().join("file");
1073        std::fs::write(&not_directory, b"not a build root").unwrap();
1074        let mut spec = ProbeSpec::new("probe", "/not-installed/rch-wkr");
1075        spec.disk_roots = vec![
1076            parent.to_str().unwrap().to_owned(),
1077            future.to_str().unwrap().to_owned(),
1078            not_directory.to_str().unwrap().to_owned(),
1079        ];
1080        let output = std::process::Command::new("sh")
1081            .arg("-c")
1082            .arg(build_capability_probe_script(&spec))
1083            .output()
1084            .unwrap();
1085        assert!(
1086            output.status.success(),
1087            "{}",
1088            String::from_utf8_lossy(&output.stderr)
1089        );
1090        let facts = parse_capability_probe(std::str::from_utf8(&output.stdout).unwrap());
1091        assert_eq!(facts.disk_roots.len(), 2, "{:?}", facts.disk_roots);
1092        assert_eq!(facts.disk_roots[0].path, spec.disk_roots[0]);
1093        assert_eq!(facts.disk_roots[1].path, spec.disk_roots[1]);
1094        assert!(facts.disk_roots[0].total_bytes > 0);
1095        assert_eq!(
1096            facts.disk_roots[0].total_bytes,
1097            facts.disk_roots[1].total_bytes
1098        );
1099        assert!(
1100            !future.exists(),
1101            "a read-only disk probe must not create build directories"
1102        );
1103        assert_eq!(std::fs::read(&not_directory).unwrap(), b"not a build root");
1104    }
1105
1106    fn good_output() -> &'static str {
1107        "RCH_FACT os=linux\n\
1108         RCH_FACT arch=x86_64\n\
1109         RCH_FACT user=rch\n\
1110         RCH_FACT rch_wkr_path=/home/rch/.local/bin/rch-wkr\n\
1111         RCH_FACT worker_version=1.0.41\n\
1112         RCH_FACT worker_protocol=3\n\
1113         RCH_FACT cargo_version=cargo 1.98.0-nightly\n\
1114         RCH_FACT toolchain=stable\n\
1115         RCH_FACT toolchain=nightly-2026-05-22\n\
1116         RCH_FACT target=x86_64-unknown-linux-gnu\n\
1117         RCH_FACT target=wasm32-unknown-unknown\n\
1118         RCH_FACT component=stable:clippy\n\
1119         RCH_FACT component=stable:rustfmt\n\
1120         RCH_FACT component=nightly-2026-05-22:rustfmt\n\
1121         RCH_FACT component=malformed-without-separator\n\
1122         RCH_FACT bun_version=1.1.0\n\
1123         RCH_FACT disk=/data/tmp;1048576;524288;900000\n\
1124         incidental noise line that must be ignored\n"
1125    }
1126
1127    #[test]
1128    fn parses_full_probe_output() {
1129        let f = parse_capability_probe(good_output());
1130        assert_eq!(f.os.as_deref(), Some("linux"));
1131        assert_eq!(f.arch.as_deref(), Some("x86_64"));
1132        assert_eq!(f.probed_user.as_deref(), Some("rch"));
1133        let w = f.worker.as_ref().unwrap();
1134        assert_eq!(w.version, "1.0.41");
1135        assert_eq!(w.protocol_version, 3);
1136        assert_eq!(w.rch_wkr_path, "/home/rch/.local/bin/rch-wkr");
1137        assert_eq!(f.rust.toolchains, vec!["stable", "nightly-2026-05-22"]);
1138        assert!(f.rust.targets.iter().any(|t| t == "wasm32-unknown-unknown"));
1139        // Components stay toolchain-qualified, and the malformed line carrying
1140        // no `<toolchain>:<name>` separator is dropped rather than stored under
1141        // an empty owner -- an unqualified entry would answer `has_component`
1142        // for EVERY toolchain, which is the fail-open direction.
1143        assert_eq!(
1144            f.rust.components,
1145            vec![
1146                "stable:clippy",
1147                "stable:rustfmt",
1148                "nightly-2026-05-22:rustfmt"
1149            ]
1150        );
1151        assert_eq!(f.runtimes.bun_version.as_deref(), Some("1.1.0"));
1152        assert_eq!(f.disk_roots.len(), 1);
1153        assert_eq!(f.disk_roots[0].path, "/data/tmp");
1154        assert_eq!(f.disk_roots[0].available_bytes, 524288 * 1024);
1155        assert_eq!(f.disk_roots[0].available_inodes, 900000);
1156        assert_eq!(
1157            f.target_triple().as_deref(),
1158            Some("x86_64-unknown-linux-gnu")
1159        );
1160    }
1161
1162    fn req_wasm() -> CapabilityRequirement {
1163        CapabilityRequirement {
1164            needs_targets: vec!["wasm32-unknown-unknown".to_string()],
1165            min_protocol: 3,
1166            needs_cargo: true,
1167            ..CapabilityRequirement::default()
1168        }
1169    }
1170
1171    #[test]
1172    fn admissible_when_all_capabilities_present() {
1173        let f = parse_capability_probe(good_output());
1174        assert_eq!(
1175            assess_admissibility(&f, &req_wasm()),
1176            CapabilityVerdict::Admissible
1177        );
1178    }
1179
1180    #[test]
1181    fn component_requirement_is_toolchain_scoped() {
1182        let f = parse_capability_probe(good_output());
1183        let with_components = |needed: &[&str]| CapabilityRequirement {
1184            needs_components: needed.iter().map(|s| (*s).to_string()).collect(),
1185            ..req_wasm()
1186        };
1187
1188        // Present for the toolchain that actually owns it, including the
1189        // `-`-suffixed pinned form.
1190        assert_eq!(
1191            assess_admissibility(&f, &with_components(&["stable:clippy"])),
1192            CapabilityVerdict::Admissible
1193        );
1194        assert_eq!(
1195            assess_admissibility(&f, &with_components(&["nightly:rustfmt"])),
1196            CapabilityVerdict::Admissible
1197        );
1198
1199        // The regression this exists to catch: the worker HAS clippy, but for
1200        // `stable` only. A pinned-nightly lint must not be routed here.
1201        match assess_admissibility(&f, &with_components(&["nightly:clippy"])) {
1202            CapabilityVerdict::Rejected { reason, detail } => {
1203                assert_eq!(reason, IncidentReasonCode::MissingRuntimeToolchainTarget);
1204                assert!(
1205                    detail.contains("clippy") && detail.contains("nightly"),
1206                    "rejection must name the component and toolchain: {detail}"
1207                );
1208            }
1209            other => panic!("expected rejection, got {other:?}"),
1210        }
1211
1212        // A requirement that cannot say which toolchain it means is rejected
1213        // rather than matched loosely.
1214        match assess_admissibility(&f, &with_components(&["clippy"])) {
1215            CapabilityVerdict::Rejected { detail, .. } => {
1216                assert!(detail.contains("malformed"), "got {detail}");
1217            }
1218            other => panic!("expected rejection, got {other:?}"),
1219        }
1220    }
1221
1222    #[test]
1223    fn no_component_requirement_constrains_nothing() {
1224        // Default is empty, so every existing caller keeps its current routing.
1225        // A worker reporting no components at all stays admissible.
1226        let out = "RCH_FACT os=linux\nRCH_FACT arch=x86_64\nRCH_FACT user=rch\n\
1227                   RCH_FACT rch_wkr_path=/home/rch/.local/bin/rch-wkr\n\
1228                   RCH_FACT worker_version=1.0.41\nRCH_FACT worker_protocol=3\n\
1229                   RCH_FACT cargo_version=cargo 1.98.0-nightly\n\
1230                   RCH_FACT target=wasm32-unknown-unknown\n";
1231        let f = parse_capability_probe(out);
1232        assert!(f.rust.components.is_empty());
1233        assert_eq!(
1234            assess_admissibility(&f, &req_wasm()),
1235            CapabilityVerdict::Admissible
1236        );
1237    }
1238
1239    #[test]
1240    fn user_broken_binary_rejected_distinct_from_unreachable() {
1241        // The probe RAN (host facts present) but the exact-path binary produced
1242        // no version — root-good / user-broken. This is capability rejection,
1243        // not unreachability.
1244        let out = "RCH_FACT os=linux\nRCH_FACT arch=x86_64\n\
1245                   RCH_FACT rch_wkr_path=/home/rch/.local/bin/rch-wkr\n";
1246        let f = parse_capability_probe(out);
1247        assert!(f.worker.is_none());
1248        match assess_admissibility(&f, &req_wasm()) {
1249            CapabilityVerdict::Rejected { reason, .. } => {
1250                assert_eq!(reason, IncidentReasonCode::WrongUserPathWorkerBinary);
1251            }
1252            other => panic!("expected rejection, got {other:?}"),
1253        }
1254    }
1255
1256    #[test]
1257    fn missing_rustup_rejected_for_cargo_requirement() {
1258        // Worker fine, but no cargo/toolchains were probed.
1259        let out = "RCH_FACT os=linux\nRCH_FACT arch=x86_64\n\
1260                   RCH_FACT worker_version=1.0.41\nRCH_FACT worker_protocol=3\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("cargo"));
1266            }
1267            other => panic!("expected rejection, got {other:?}"),
1268        }
1269    }
1270
1271    #[test]
1272    fn missing_wasm_target_rejected() {
1273        // Has cargo + protocol, but lacks the wasm target.
1274        let out = "RCH_FACT worker_version=1.0.41\nRCH_FACT worker_protocol=3\n\
1275                   RCH_FACT cargo_version=cargo 1.98\nRCH_FACT target=x86_64-unknown-linux-gnu\n";
1276        let f = parse_capability_probe(out);
1277        match assess_admissibility(&f, &req_wasm()) {
1278            CapabilityVerdict::Rejected { reason, detail } => {
1279                assert_eq!(reason, IncidentReasonCode::MissingRuntimeToolchainTarget);
1280                assert!(detail.contains("wasm32-unknown-unknown"));
1281            }
1282            other => panic!("expected rejection, got {other:?}"),
1283        }
1284    }
1285
1286    #[test]
1287    fn stale_worker_protocol_rejected() {
1288        let out = "RCH_FACT worker_version=0.9.0\nRCH_FACT worker_protocol=1\n\
1289                   RCH_FACT cargo_version=cargo 1.98\nRCH_FACT target=wasm32-unknown-unknown\n";
1290        let f = parse_capability_probe(out);
1291        match assess_admissibility(&f, &req_wasm()) {
1292            CapabilityVerdict::Rejected { reason, detail } => {
1293                assert_eq!(reason, IncidentReasonCode::WrongUserPathWorkerBinary);
1294                assert!(detail.contains("protocol"));
1295            }
1296            other => panic!("expected rejection, got {other:?}"),
1297        }
1298    }
1299
1300    #[test]
1301    fn empty_output_parses_empty() {
1302        // A fully empty parse is the caller's signal of unreachability.
1303        let f = parse_capability_probe("");
1304        assert_eq!(f, ProbedFacts::default());
1305        assert!(f.worker.is_none());
1306        assert!(f.os.is_none());
1307    }
1308
1309    // --- 12.3: OS/arch, runtime, toolchain capability dimensions -----------
1310
1311    #[test]
1312    fn os_mismatch_rejected_with_osarch_reason() {
1313        // A linux worker cannot produce a darwin artifact.
1314        let f = parse_capability_probe(good_output());
1315        let req = CapabilityRequirement::rust(3).with_os("darwin");
1316        match assess_admissibility(&f, &req) {
1317            CapabilityVerdict::Rejected { reason, detail } => {
1318                assert_eq!(reason, IncidentReasonCode::OsArchMismatch);
1319                assert!(detail.contains("darwin"));
1320            }
1321            other => panic!("expected OS-mismatch rejection, got {other:?}"),
1322        }
1323        // The matching OS is admissible.
1324        assert_eq!(
1325            assess_admissibility(&f, &CapabilityRequirement::rust(3).with_os("linux")),
1326            CapabilityVerdict::Admissible
1327        );
1328    }
1329
1330    #[test]
1331    fn arch_mismatch_rejected_with_osarch_reason() {
1332        let f = parse_capability_probe(good_output());
1333        let req = CapabilityRequirement::rust(3).with_arch("aarch64");
1334        match assess_admissibility(&f, &req) {
1335            CapabilityVerdict::Rejected { reason, .. } => {
1336                assert_eq!(reason, IncidentReasonCode::OsArchMismatch);
1337            }
1338            other => panic!("expected arch-mismatch rejection, got {other:?}"),
1339        }
1340    }
1341
1342    #[test]
1343    fn missing_node_runtime_rejected() {
1344        // good_output has bun but no node.
1345        let f = parse_capability_probe(good_output());
1346        let mut req = CapabilityRequirement::rust(3);
1347        req.needs_node = true;
1348        match assess_admissibility(&f, &req) {
1349            CapabilityVerdict::Rejected { reason, detail } => {
1350                assert_eq!(reason, IncidentReasonCode::MissingRuntimeToolchainTarget);
1351                assert!(detail.contains("node"));
1352            }
1353            other => panic!("expected node rejection, got {other:?}"),
1354        }
1355        // bun IS present, so a bun requirement is admissible.
1356        let mut bun_req = CapabilityRequirement::rust(3);
1357        bun_req.needs_bun = true;
1358        assert_eq!(
1359            assess_admissibility(&f, &bun_req),
1360            CapabilityVerdict::Admissible
1361        );
1362    }
1363
1364    #[test]
1365    fn specific_toolchain_prefix_matched() {
1366        let f = parse_capability_probe(good_output());
1367        // Installed nightly-2026-05-22 satisfies the bare prefix.
1368        let ok = CapabilityRequirement::rust(3).with_toolchains(["nightly-2026-05-22"]);
1369        assert_eq!(assess_admissibility(&f, &ok), CapabilityVerdict::Admissible);
1370        // A different pinned nightly is missing.
1371        let missing = CapabilityRequirement::rust(3).with_toolchains(["nightly-2025-01-01"]);
1372        match assess_admissibility(&f, &missing) {
1373            CapabilityVerdict::Rejected { reason, detail } => {
1374                assert_eq!(reason, IncidentReasonCode::MissingRuntimeToolchainTarget);
1375                assert!(detail.contains("nightly-2025-01-01"));
1376            }
1377            other => panic!("expected toolchain rejection, got {other:?}"),
1378        }
1379    }
1380
1381    // --- 12.3: eligibility distinguishes missing-capability / unhealthy /
1382    //           busy / stale ------------------------------------------------
1383
1384    #[test]
1385    fn eligible_when_capable_fresh_healthy_idle() {
1386        let f = parse_capability_probe(good_output());
1387        let v = assess_worker_eligibility(&f, &req_wasm(), &WorkerLiveness::ready());
1388        assert_eq!(v, EligibilityVerdict::Eligible);
1389        assert!(v.is_eligible());
1390        assert_eq!(v.reason(), None);
1391    }
1392
1393    #[test]
1394    fn no_worker_with_runtime_is_missing_capability_not_busy() {
1395        // A worker without cargo, even if idle+healthy, is missing-capability —
1396        // distinct from a busy or unhealthy worker.
1397        let out = "RCH_FACT os=linux\nRCH_FACT arch=x86_64\n\
1398                   RCH_FACT worker_version=1.0.41\nRCH_FACT worker_protocol=3\n";
1399        let f = parse_capability_probe(out);
1400        let v = assess_worker_eligibility(&f, &req_wasm(), &WorkerLiveness::ready());
1401        assert_eq!(
1402            v.reason(),
1403            Some(IncidentReasonCode::MissingRuntimeToolchainTarget)
1404        );
1405        assert!(matches!(v, EligibilityVerdict::MissingCapability { .. }));
1406    }
1407
1408    #[test]
1409    fn os_arch_mismatch_surfaces_as_missing_capability() {
1410        let f = parse_capability_probe(good_output());
1411        let req = CapabilityRequirement::rust(3).with_os("darwin");
1412        let v = assess_worker_eligibility(&f, &req, &WorkerLiveness::ready());
1413        assert_eq!(v.reason(), Some(IncidentReasonCode::OsArchMismatch));
1414    }
1415
1416    #[test]
1417    fn unhealthy_and_busy_are_distinct_from_missing_capability() {
1418        let f = parse_capability_probe(good_output());
1419        // Capable but circuit-open => Unhealthy.
1420        let unhealthy = WorkerLiveness {
1421            telemetry_stale: false,
1422            healthy: false,
1423            has_free_slot: true,
1424        };
1425        assert_eq!(
1426            assess_worker_eligibility(&f, &req_wasm(), &unhealthy).reason(),
1427            Some(IncidentReasonCode::CircuitOpen)
1428        );
1429        // Capable + healthy but no slot => Busy.
1430        let busy = WorkerLiveness {
1431            telemetry_stale: false,
1432            healthy: true,
1433            has_free_slot: false,
1434        };
1435        assert_eq!(
1436            assess_worker_eligibility(&f, &req_wasm(), &busy).reason(),
1437            Some(IncidentReasonCode::InsufficientSlots)
1438        );
1439    }
1440
1441    #[test]
1442    fn stale_capability_cache_is_distinct_reason() {
1443        let f = parse_capability_probe(good_output());
1444        let stale = WorkerLiveness {
1445            telemetry_stale: true,
1446            healthy: true,
1447            has_free_slot: true,
1448        };
1449        let v = assess_worker_eligibility(&f, &req_wasm(), &stale);
1450        assert_eq!(v, EligibilityVerdict::StaleTelemetry);
1451        assert_eq!(v.reason(), Some(IncidentReasonCode::TelemetryStale));
1452    }
1453
1454    #[test]
1455    fn capability_refresh_changes_eligibility() {
1456        // Before refresh: worker lacks the wasm target => missing capability.
1457        let before = parse_capability_probe(
1458            "RCH_FACT os=linux\nRCH_FACT arch=x86_64\n\
1459             RCH_FACT worker_version=1.0.41\nRCH_FACT worker_protocol=3\n\
1460             RCH_FACT cargo_version=cargo 1.98\nRCH_FACT target=x86_64-unknown-linux-gnu\n",
1461        );
1462        let v_before = assess_worker_eligibility(&before, &req_wasm(), &WorkerLiveness::ready());
1463        assert!(matches!(
1464            v_before,
1465            EligibilityVerdict::MissingCapability { .. }
1466        ));
1467
1468        // After refresh: the wasm target was installed => now eligible.
1469        let after = parse_capability_probe(good_output());
1470        let v_after = assess_worker_eligibility(&after, &req_wasm(), &WorkerLiveness::ready());
1471        assert_eq!(v_after, EligibilityVerdict::Eligible);
1472    }
1473
1474    #[test]
1475    fn remote_worker_binary_path_is_absolute_per_user() {
1476        assert_eq!(
1477            remote_worker_binary_path("root", None),
1478            "/root/.local/bin/rch-wkr"
1479        );
1480        assert_eq!(
1481            remote_worker_binary_path("ubuntu", Some("linux")),
1482            "/home/ubuntu/.local/bin/rch-wkr"
1483        );
1484        // The path must be absolute: the probe script single-quotes it, so a
1485        // literal `~` would never expand and `[ -x ... ]` would always fail.
1486        for user in ["root", "rch", "ubuntu"] {
1487            let path = remote_worker_binary_path(user, None);
1488            assert!(!path.contains('~'), "{path} must not contain a tilde");
1489            assert!(path.starts_with('/'), "{path} must be absolute");
1490            let spec = ProbeSpec::new(user, path);
1491            let script = build_capability_probe_script(&spec);
1492            // The shell-quoted binary path embedded in the script is absolute,
1493            // so `[ -x ... ]` resolves on the real worker.
1494            assert!(script.contains("/.local/bin/rch-wkr'"));
1495            assert!(!script.contains("'~/.local/bin/rch-wkr'"));
1496        }
1497    }
1498
1499    #[test]
1500    fn remote_worker_binary_path_uses_git_bash_home_for_windows() {
1501        assert_eq!(
1502            remote_worker_binary_path("jeffr", Some("windows")),
1503            "/c/Users/jeffr/.local/bin/rch-wkr.exe"
1504        );
1505        assert_eq!(
1506            remote_worker_binary_path("jeffr", Some("WINDOWS")),
1507            "/c/Users/jeffr/.local/bin/rch-wkr.exe"
1508        );
1509    }
1510}