Skip to main content

release_kit/
probes.rs

1//! The environment probe catalog.
2//!
3//! One catalog, read by every caller that needs to know whether this host
4//! is ready: `rk doctor` runs it whole, and a mutating command guards the
5//! subset it depends on at entry, so the per-command guards and the
6//! doctor cannot drift apart. Each probe answers with a status, a
7//! message, and — on failure — the remediation printed verbatim wherever
8//! the probe is consulted.
9
10use std::process::Command;
11
12use camino::{Utf8Path, Utf8PathBuf};
13use serde::Serialize;
14
15use crate::skills::record::{RECORD_PATH, Record};
16use crate::skills::{AGENTS_ROOT, CLAUDE_ROOT, Digest, SHARED_ROOT};
17
18/// How a failure weighs at the doctor level.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
20#[serde(rename_all = "kebab-case")]
21pub enum ProbeClass {
22    /// No mutating command can work without this.
23    Hard,
24    /// Needed only by some commands or some forges.
25    Soft,
26}
27
28/// What a probe found.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
30#[serde(rename_all = "kebab-case")]
31pub enum ProbeStatus {
32    /// The probe passed.
33    Ok,
34    /// The probe failed; the remediation says what fixes it.
35    Failed,
36}
37
38/// One probe's answer.
39#[derive(Debug, Serialize)]
40pub struct ProbeResult {
41    /// The probe's stable name.
42    pub id: &'static str,
43    /// How the failure weighs.
44    pub class: ProbeClass,
45    /// What was found.
46    pub status: ProbeStatus,
47    /// What was found, one line.
48    pub message: String,
49    /// The exact fix, when the probe failed.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub remediation: Option<String>,
52}
53
54impl ProbeResult {
55    fn ok(id: &'static str, class: ProbeClass, message: impl Into<String>) -> Self {
56        Self {
57            id,
58            class,
59            status: ProbeStatus::Ok,
60            message: message.into(),
61            remediation: None,
62        }
63    }
64
65    fn failed(
66        id: &'static str,
67        class: ProbeClass,
68        message: impl Into<String>,
69        remediation: impl Into<String>,
70    ) -> Self {
71        Self {
72            id,
73            class,
74            status: ProbeStatus::Failed,
75            message: message.into(),
76            remediation: Some(remediation.into()),
77        }
78    }
79}
80
81/// The probes judging the skill installation itself, in catalog order.
82///
83/// Declared here rather than derived by running the catalog: the shared plan
84/// gate's pre-flight phase must name each of these, and the test holding it to
85/// that must not have to spawn a forge CLI or write into the operator's home
86/// to learn what they are.
87pub const SKILL_PROBES: [&str; 3] = ["skill-roots", "skill-gate", "skill-payload"];
88
89/// Every executable a Hard probe requires, paired with the nixpkgs package
90/// whose `bin/` supplies it in the installed package's wrapper.
91///
92/// `nix/package.nix` mirrors this list by hand — Nix cannot read this
93/// registry, and generating one list from the other is more machinery than
94/// two entries earn — so the mirror test in `tests/cli.rs` holds the two
95/// lists to agreement and a divergence fails by name instead of shipping.
96pub const HARD_RUNTIME_TOOLS: [(&str, &str); 2] = [("git", "git"), ("sh", "bash")];
97
98/// One owner for the git binary every production launcher spawns.
99///
100/// `RK_GIT_BIN` substitutes it — the same contract every soft tool's
101/// override states, and what lets an operator's own git win over the one
102/// the installed package's wrapper supplies.
103#[must_use]
104pub fn git_bin() -> std::ffi::OsString {
105    std::env::var_os("RK_GIT_BIN").unwrap_or_else(|| "git".into())
106}
107
108/// One owner for the nix binary every devshell launch spawns: the lock
109/// refresh, the system probe, and the build. `RK_NIX_BIN` substitutes it.
110#[must_use]
111pub fn nix_bin() -> std::ffi::OsString {
112    std::env::var_os("RK_NIX_BIN").unwrap_or_else(|| "nix".into())
113}
114
115/// One owner for the direnv binary, which nothing here spawns but the
116/// doctor probes: it is what loads a consumer's devshell on entry.
117/// `RK_DIRENV_BIN` substitutes it.
118#[must_use]
119pub fn direnv_bin() -> std::ffi::OsString {
120    std::env::var_os("RK_DIRENV_BIN").unwrap_or_else(|| "direnv".into())
121}
122
123/// Nix answers; `rk devshell sync` updates and builds the pinned devshell
124/// with it. Soft, and deliberately outside the wrapper: wrapping nix
125/// would put it inside the package's own closure on every host.
126#[must_use]
127pub fn nix() -> ProbeResult {
128    tool(
129        "nix",
130        "RK_NIX_BIN",
131        "nix",
132        "Nix; rk devshell sync updates and builds the pinned devshell with it",
133        &["--version"],
134    )
135}
136
137/// direnv answers; it loads the devshell on directory entry.
138#[must_use]
139pub fn direnv() -> ProbeResult {
140    tool(
141        "direnv",
142        "RK_DIRENV_BIN",
143        "direnv",
144        "direnv; it loads the devshell on directory entry",
145        &["version"],
146    )
147}
148
149/// One owner for the POSIX shell every setup step spawns through.
150///
151/// `RK_SH_BIN` substitutes it, which is also what keeps tests hermetic on
152/// a host whose `sh` is not the one under test.
153#[must_use]
154pub fn sh_bin() -> std::ffi::OsString {
155    std::env::var_os("RK_SH_BIN").unwrap_or_else(|| "sh".into())
156}
157
158/// Run the whole catalog, in its stable order.
159#[must_use]
160pub fn run_all() -> Vec<ProbeResult> {
161    vec![
162        shell(),
163        git(),
164        state_root(),
165        skill_roots(),
166        skill_gate(),
167        skill_payload(),
168        git_remote(),
169        forge_cli(
170            "gh-auth",
171            "RK_GH_BIN",
172            "gh",
173            "the GitHub CLI",
174            "gh auth login",
175            // `gh auth status` fails when any stored account is broken,
176            // even while the active one works; `--active` judges only the
177            // credential this tool would use. Older gh lacks the flag, so
178            // the bare form is the fallback.
179            &[&["auth", "status", "--active"], &["auth", "status"]],
180        ),
181        forge_cli(
182            "glab-auth",
183            "RK_GLAB_BIN",
184            "glab",
185            "the GitLab CLI",
186            "glab auth login",
187            &[&["auth", "status"]],
188        ),
189        tool(
190            "openssl",
191            "RK_OPENSSL_BIN",
192            "openssl",
193            "OpenSSL; install-bot signs the App JWT with it",
194            &["version"],
195        ),
196        tool(
197            "curl",
198            "RK_CURL_BIN",
199            "curl",
200            "curl; install-bot reads the installation, and rk versions --check and rk devshell sync fetch with it",
201            &["--version"],
202        ),
203        nix(),
204        direnv(),
205        tool(
206            "cosign",
207            "RK_COSIGN_BIN",
208            "cosign",
209            "cosign; the release verify step checks a GitLab provenance bundle with it",
210            &["version"],
211        ),
212        tool(
213            "pypi-attestations",
214            "RK_PYPI_ATTESTATIONS_BIN",
215            "pypi-attestations",
216            "pypi-attestations; the release verify step checks a PyPI distribution's attestations with it",
217            &["--help"],
218        ),
219    ]
220}
221
222/// A helper binary answers its version call. `env_override` names the
223/// substitute, which is also what keeps tests hermetic; presence is the
224/// whole question, because the tools here take no configuration.
225fn tool(
226    id: &'static str,
227    env_override: &str,
228    default_bin: &str,
229    label: &str,
230    args: &[&str],
231) -> ProbeResult {
232    let bin = std::env::var(env_override).unwrap_or_else(|_| default_bin.to_owned());
233    match Command::new(&bin).args(args).output() {
234        Ok(out) if out.status.success() => {
235            ProbeResult::ok(id, ProbeClass::Soft, format!("{default_bin} runs"))
236        }
237        Ok(_) => ProbeResult::failed(
238            id,
239            ProbeClass::Soft,
240            format!("{default_bin} does not answer {}", args.join(" ")),
241            format!("repair {label}"),
242        ),
243        Err(_) => ProbeResult::failed(
244            id,
245            ProbeClass::Soft,
246            format!("{default_bin} is not on PATH"),
247            format!("install {label}"),
248        ),
249    }
250}
251
252/// A POSIX shell runs; every setup step spawns through it.
253fn shell() -> ProbeResult {
254    let id = "sh";
255    match Command::new(sh_bin()).args(["-c", "exit 0"]).status() {
256        Ok(status) if status.success() => ProbeResult::ok(id, ProbeClass::Hard, "sh runs"),
257        Ok(status) => ProbeResult::failed(
258            id,
259            ProbeClass::Hard,
260            format!("sh exited {status}"),
261            "repair the POSIX shell on PATH",
262        ),
263        Err(source) => ProbeResult::failed(
264            id,
265            ProbeClass::Hard,
266            format!("sh does not spawn: {source}"),
267            "install a POSIX shell on PATH",
268        ),
269    }
270}
271
272/// Version control answers; every branch, worktree, landing, and setup
273/// verb launches it.
274fn git() -> ProbeResult {
275    let id = "git";
276    match Command::new(git_bin()).arg("--version").output() {
277        Ok(out) if out.status.success() => ProbeResult::ok(id, ProbeClass::Hard, "git runs"),
278        Ok(_) => ProbeResult::failed(
279            id,
280            ProbeClass::Hard,
281            "git does not answer --version",
282            "repair the git on PATH, or point RK_GIT_BIN at a working one",
283        ),
284        Err(_) => ProbeResult::failed(id, ProbeClass::Hard, "git is not on PATH", "install git"),
285    }
286}
287
288/// The XDG state root accepts writes; the log and every run journal live
289/// under it.
290fn state_root() -> ProbeResult {
291    let id = "state-root";
292    let Some(root) = crate::applog::state_root() else {
293        return ProbeResult::failed(
294            id,
295            ProbeClass::Hard,
296            "neither XDG_STATE_HOME nor HOME is set",
297            "export HOME, or XDG_STATE_HOME",
298        );
299    };
300    let display = root.display().to_string();
301    let probe = root.join(format!(".probe-{}", std::process::id()));
302    let written = std::fs::create_dir_all(&root).and_then(|()| std::fs::write(&probe, b"probe"));
303    let _ = std::fs::remove_file(&probe);
304    match written {
305        Ok(()) => ProbeResult::ok(id, ProbeClass::Hard, format!("{display} is writable")),
306        Err(source) => ProbeResult::failed(
307            id,
308            ProbeClass::Hard,
309            format!("{display} is not writable: {source}"),
310            format!("make {display} writable"),
311        ),
312    }
313}
314
315/// The destinations `rk skill install` writes accept writes: the two agent
316/// roots and the shared root, all under the invoking user's home.
317///
318/// A root can exist and still refuse, which is what a read-only bind of an
319/// agent directory produces, so what is tested is the nearest existing
320/// ancestor — the directory an install would actually have to write
321/// through. The probe creates nothing: a preview must still be able to
322/// report a root as absent, and a probe that made it exist would take that
323/// answer away.
324fn skill_roots() -> ProbeResult {
325    let id = SKILL_PROBES[0];
326    let Ok(home) = crate::skills::home() else {
327        return ProbeResult::failed(
328            id,
329            ProbeClass::Soft,
330            "neither HOME nor USERPROFILE is set, so no skill root resolves",
331            "export HOME",
332        );
333    };
334    let mut refused = Vec::new();
335    for root in [CLAUDE_ROOT, AGENTS_ROOT, SHARED_ROOT] {
336        let root = home.join(root);
337        let Some(existing) = nearest_existing(&root) else {
338            refused.push(format!("no ancestor of {root} exists"));
339            continue;
340        };
341        if let Err(source) = accepts_a_write(&existing) {
342            refused.push(format!("{existing} is not writable: {source}"));
343        }
344    }
345    if refused.is_empty() {
346        ProbeResult::ok(
347            id,
348            ProbeClass::Soft,
349            format!("the skill roots under {home} accept writes"),
350        )
351    } else {
352        ProbeResult::failed(
353            id,
354            ProbeClass::Soft,
355            refused.join("; "),
356            format!("make the skill roots under {home} writable"),
357        )
358    }
359}
360
361/// The artifacts every skill shares are installed, and are this binary's.
362///
363/// This is the probe that answers the one failure a shared home produces.
364/// The agent roots and the shared root are separate directories, so a
365/// container, a sandbox, or a sync that carries one and not the other
366/// leaves every skill resolvable by name and unable to read the gates it is
367/// told to read first. A skill that cannot read them runs neither its
368/// pre-flight nor its plan phase, which is the whole reason they are files
369/// rather than prose.
370fn skill_gate() -> ProbeResult {
371    let id = SKILL_PROBES[1];
372    let Ok(home) = crate::skills::home() else {
373        return ProbeResult::failed(
374            id,
375            ProbeClass::Soft,
376            "neither HOME nor USERPROFILE is set, so the shared root does not resolve",
377            "export HOME",
378        );
379    };
380    let root = home.join(SHARED_ROOT);
381    let record = Record::load(&home.join(RECORD_PATH));
382    let planned: Vec<(Utf8PathBuf, &'static [u8])> = crate::skills::shared()
383        .into_iter()
384        .map(|artifact| (root.join(&artifact.path), artifact.bytes))
385        .collect();
386    let found = judge(planned, &record);
387    if let Some(first) = found.missing.first() {
388        return ProbeResult::failed(
389            id,
390            ProbeClass::Soft,
391            format!("a shared artifact every skill reads before acting is not installed: {first}"),
392            "rk skill install --apply",
393        );
394    }
395    if !found.differing.is_empty() {
396        return ProbeResult::failed(
397            id,
398            ProbeClass::Soft,
399            format!(
400                "{} shared artifact(s) under {root} are not this binary's",
401                found.differing.len()
402            ),
403            reinstall(found.all_recorded),
404        );
405    }
406    ProbeResult::ok(
407        id,
408        ProbeClass::Soft,
409        format!("{root} holds this binary's shared artifacts"),
410    )
411}
412
413/// The skills installed under this home are the ones this binary carries.
414///
415/// One binary serves every repository, so a skill under an agent root and
416/// the `rk` on PATH are two artifacts that can be updated apart: a home
417/// shared with a container, a sandbox, or another machine can hold skills
418/// some other build installed. The probe names that drift rather than
419/// leaving an agent to follow instructions the binary no longer answers.
420fn skill_payload() -> ProbeResult {
421    let id = SKILL_PROBES[2];
422    let Ok(home) = crate::skills::home() else {
423        return ProbeResult::failed(
424            id,
425            ProbeClass::Soft,
426            "neither HOME nor USERPROFILE is set, so no agent root resolves",
427            "export HOME",
428        );
429    };
430    let Ok(skills) = crate::skills::all() else {
431        return ProbeResult::failed(
432            id,
433            ProbeClass::Soft,
434            "this binary's embedded skills do not read",
435            "reinstall rk; the payload it was built from is defective",
436        );
437    };
438    let record = Record::load(&home.join(RECORD_PATH));
439    let mut planned = Vec::new();
440    for root in [CLAUDE_ROOT, AGENTS_ROOT] {
441        let root = home.join(root);
442        // An absent agent root is a choice, not a defect: `--agent` selects
443        // one family and leaves the other's root untouched.
444        if !root.is_dir() {
445            continue;
446        }
447        for skill in &skills {
448            planned.push((
449                root.join(&skill.name).join("SKILL.md"),
450                skill.text.as_bytes(),
451            ));
452        }
453    }
454    if planned.is_empty() {
455        return ProbeResult::failed(
456            id,
457            ProbeClass::Soft,
458            format!("no agent skill root exists under {home}"),
459            "rk skill install --apply",
460        );
461    }
462    let found = judge(planned, &record);
463    if let Some(first) = found.missing.first() {
464        return ProbeResult::failed(
465            id,
466            ProbeClass::Soft,
467            format!(
468                "{} of this binary's skills are not installed, the first at {first}",
469                found.missing.len()
470            ),
471            "rk skill install --apply",
472        );
473    }
474    if !found.differing.is_empty() {
475        return ProbeResult::failed(
476            id,
477            ProbeClass::Soft,
478            format!(
479                "{} installed skill(s) are not this binary's; rk is {}",
480                found.differing.len(),
481                env!("CARGO_PKG_VERSION")
482            ),
483            reinstall(found.all_recorded),
484        );
485    }
486    ProbeResult::ok(
487        id,
488        ProbeClass::Soft,
489        format!(
490            "{} installed skill destination(s) are this binary's",
491            found.matching
492        ),
493    )
494}
495
496/// What sits at each destination the payload names.
497struct Installed {
498    /// Destinations the payload names that hold no readable file.
499    missing: Vec<Utf8PathBuf>,
500    /// Destinations holding bytes that are not this binary's.
501    differing: Vec<Utf8PathBuf>,
502    /// How many destinations hold exactly this binary's bytes.
503    matching: usize,
504    /// Whether the record vouches for every differing destination, which
505    /// makes the difference a stale install rather than the operator's own
506    /// edit — and decides whether the fix needs `--force`.
507    all_recorded: bool,
508}
509
510/// Judge each destination the payload names against what sits on disk.
511fn judge(planned: Vec<(Utf8PathBuf, &'static [u8])>, record: &Record) -> Installed {
512    let mut found = Installed {
513        missing: Vec::new(),
514        differing: Vec::new(),
515        matching: 0,
516        all_recorded: true,
517    };
518    for (destination, bytes) in planned {
519        match std::fs::read(&destination) {
520            Ok(held) if held == bytes => found.matching += 1,
521            Ok(held) => {
522                if !record.wrote(&destination, &Digest::of(&held)) {
523                    found.all_recorded = false;
524                }
525                found.differing.push(destination);
526            }
527            Err(_) => found.missing.push(destination),
528        }
529    }
530    found
531}
532
533/// The install that corrects a difference. Bytes the record vouches for are
534/// an older release's and go without asking; bytes it cannot account for are
535/// the operator's own, and overwriting those is what `--force` is.
536const fn reinstall(all_recorded: bool) -> &'static str {
537    if all_recorded {
538        "rk skill install --apply"
539    } else {
540        "rk skill install --apply --force"
541    }
542}
543
544/// The nearest ancestor of `path`, itself included, that exists as a
545/// directory.
546fn nearest_existing(path: &Utf8Path) -> Option<Utf8PathBuf> {
547    let mut current = Some(path);
548    while let Some(dir) = current {
549        if dir.is_dir() {
550            return Some(dir.to_owned());
551        }
552        current = dir.parent();
553    }
554    None
555}
556
557/// A directory accepts a write, leaving nothing behind.
558fn accepts_a_write(dir: &Utf8Path) -> std::io::Result<()> {
559    let probe = dir.join(format!(".rk-probe-{}", std::process::id()));
560    let written = std::fs::write(&probe, b"probe");
561    let _ = std::fs::remove_file(&probe);
562    written
563}
564
565/// The working directory's `origin` remote parses to a host, which is
566/// what forge and slug detection read.
567fn git_remote() -> ProbeResult {
568    let id = "git-remote";
569    let out = Command::new(git_bin())
570        .args(["remote", "get-url", "origin"])
571        .output();
572    let url = match out {
573        Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_owned(),
574        _ => {
575            return ProbeResult::failed(
576                id,
577                ProbeClass::Soft,
578                "the working directory has no origin remote",
579                "pass --repo <owner/name> where a command needs the slug",
580            );
581        }
582    };
583    // The raw remote never reaches the message: a malformed URL can carry
584    // userinfo — `https://user:token@…` — and a probe result lands in
585    // captured output and CI logs, where a credential must never appear.
586    remote_host(&url).map_or_else(
587        || {
588            ProbeResult::failed(
589                id,
590                ProbeClass::Soft,
591                "the origin remote does not parse to a host",
592                "pass --repo <owner/name> where a command needs the slug",
593            )
594        },
595        |host| ProbeResult::ok(id, ProbeClass::Soft, format!("origin resolves to {host}")),
596    )
597}
598
599/// The host in a git remote URL, for the `scp`-like and URL forms.
600fn remote_host(url: &str) -> Option<String> {
601    if let Some(rest) = url.split_once("://").map(|(_, rest)| rest) {
602        let authority = rest.split('/').next()?;
603        let host = authority
604            .rsplit_once('@')
605            .map_or(authority, |(_, host)| host);
606        let host = host.split(':').next()?;
607        return (!host.is_empty()).then(|| host.to_owned());
608    }
609    let (authority, path) = url.split_once(':')?;
610    let host = authority
611        .rsplit_once('@')
612        .map_or(authority, |(_, host)| host);
613    (!host.is_empty() && !path.is_empty()).then(|| host.to_owned())
614}
615
616/// A forge CLI is present and authenticated. `env_override` names the
617/// variable that substitutes the binary, which is also what keeps tests
618/// hermetic. `attempts` is tried in order and the first success wins, so
619/// a probe can prefer a sharper flag and still work where the CLI
620/// predates it.
621fn forge_cli(
622    id: &'static str,
623    env_override: &str,
624    default_bin: &str,
625    label: &str,
626    login: &str,
627    attempts: &[&[&str]],
628) -> ProbeResult {
629    let bin = std::env::var(env_override).unwrap_or_else(|_| default_bin.to_owned());
630    let mut spawned = false;
631    for args in attempts {
632        match Command::new(&bin).args(*args).output() {
633            Ok(out) if out.status.success() => {
634                return ProbeResult::ok(
635                    id,
636                    ProbeClass::Soft,
637                    format!("{default_bin} is authenticated"),
638                );
639            }
640            Ok(_) => spawned = true,
641            Err(_) => {}
642        }
643    }
644    if spawned {
645        ProbeResult::failed(
646            id,
647            ProbeClass::Soft,
648            format!("{default_bin} is not authenticated"),
649            format!("run {login}"),
650        )
651    } else {
652        ProbeResult::failed(
653            id,
654            ProbeClass::Soft,
655            format!("{default_bin} is not on PATH"),
656            format!("install {label}"),
657        )
658    }
659}
660
661#[cfg(test)]
662mod tests {
663    use super::remote_host;
664
665    #[test]
666    fn a_remote_host_parses_from_both_url_forms() {
667        assert_eq!(
668            remote_host("https://github.com/owner/name.git").as_deref(),
669            Some("github.com")
670        );
671        assert_eq!(
672            remote_host("git@gitlab.com:group/sub/name.git").as_deref(),
673            Some("gitlab.com")
674        );
675        assert_eq!(
676            remote_host("ssh://git@github.com:22/owner/name.git").as_deref(),
677            Some("github.com")
678        );
679        assert_eq!(remote_host("not a url"), None);
680    }
681}