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 POSIX shell every setup step spawns through.
109///
110/// `RK_SH_BIN` substitutes it, which is also what keeps tests hermetic on
111/// a host whose `sh` is not the one under test.
112#[must_use]
113pub fn sh_bin() -> std::ffi::OsString {
114    std::env::var_os("RK_SH_BIN").unwrap_or_else(|| "sh".into())
115}
116
117/// Run the whole catalog, in its stable order.
118#[must_use]
119pub fn run_all() -> Vec<ProbeResult> {
120    vec![
121        shell(),
122        git(),
123        state_root(),
124        skill_roots(),
125        skill_gate(),
126        skill_payload(),
127        git_remote(),
128        forge_cli(
129            "gh-auth",
130            "RK_GH_BIN",
131            "gh",
132            "the GitHub CLI",
133            "gh auth login",
134            // `gh auth status` fails when any stored account is broken,
135            // even while the active one works; `--active` judges only the
136            // credential this tool would use. Older gh lacks the flag, so
137            // the bare form is the fallback.
138            &[&["auth", "status", "--active"], &["auth", "status"]],
139        ),
140        forge_cli(
141            "glab-auth",
142            "RK_GLAB_BIN",
143            "glab",
144            "the GitLab CLI",
145            "glab auth login",
146            &[&["auth", "status"]],
147        ),
148        tool(
149            "openssl",
150            "RK_OPENSSL_BIN",
151            "openssl",
152            "OpenSSL; install-bot signs the App JWT with it",
153            &["version"],
154        ),
155        tool(
156            "curl",
157            "RK_CURL_BIN",
158            "curl",
159            "curl; install-bot reads the installation and rk versions --check fetches with it",
160            &["--version"],
161        ),
162        tool(
163            "cosign",
164            "RK_COSIGN_BIN",
165            "cosign",
166            "cosign; the release verify step checks a GitLab provenance bundle with it",
167            &["version"],
168        ),
169        tool(
170            "pypi-attestations",
171            "RK_PYPI_ATTESTATIONS_BIN",
172            "pypi-attestations",
173            "pypi-attestations; the release verify step checks a PyPI distribution's attestations with it",
174            &["--help"],
175        ),
176    ]
177}
178
179/// A helper binary answers its version call. `env_override` names the
180/// substitute, which is also what keeps tests hermetic; presence is the
181/// whole question, because the tools here take no configuration.
182fn tool(
183    id: &'static str,
184    env_override: &str,
185    default_bin: &str,
186    label: &str,
187    args: &[&str],
188) -> ProbeResult {
189    let bin = std::env::var(env_override).unwrap_or_else(|_| default_bin.to_owned());
190    match Command::new(&bin).args(args).output() {
191        Ok(out) if out.status.success() => {
192            ProbeResult::ok(id, ProbeClass::Soft, format!("{default_bin} runs"))
193        }
194        Ok(_) => ProbeResult::failed(
195            id,
196            ProbeClass::Soft,
197            format!("{default_bin} does not answer {}", args.join(" ")),
198            format!("repair {label}"),
199        ),
200        Err(_) => ProbeResult::failed(
201            id,
202            ProbeClass::Soft,
203            format!("{default_bin} is not on PATH"),
204            format!("install {label}"),
205        ),
206    }
207}
208
209/// A POSIX shell runs; every setup step spawns through it.
210fn shell() -> ProbeResult {
211    let id = "sh";
212    match Command::new(sh_bin()).args(["-c", "exit 0"]).status() {
213        Ok(status) if status.success() => ProbeResult::ok(id, ProbeClass::Hard, "sh runs"),
214        Ok(status) => ProbeResult::failed(
215            id,
216            ProbeClass::Hard,
217            format!("sh exited {status}"),
218            "repair the POSIX shell on PATH",
219        ),
220        Err(source) => ProbeResult::failed(
221            id,
222            ProbeClass::Hard,
223            format!("sh does not spawn: {source}"),
224            "install a POSIX shell on PATH",
225        ),
226    }
227}
228
229/// Version control answers; every branch, worktree, landing, and setup
230/// verb launches it.
231fn git() -> ProbeResult {
232    let id = "git";
233    match Command::new(git_bin()).arg("--version").output() {
234        Ok(out) if out.status.success() => ProbeResult::ok(id, ProbeClass::Hard, "git runs"),
235        Ok(_) => ProbeResult::failed(
236            id,
237            ProbeClass::Hard,
238            "git does not answer --version",
239            "repair the git on PATH, or point RK_GIT_BIN at a working one",
240        ),
241        Err(_) => ProbeResult::failed(id, ProbeClass::Hard, "git is not on PATH", "install git"),
242    }
243}
244
245/// The XDG state root accepts writes; the log and every run journal live
246/// under it.
247fn state_root() -> ProbeResult {
248    let id = "state-root";
249    let Some(root) = crate::applog::state_root() else {
250        return ProbeResult::failed(
251            id,
252            ProbeClass::Hard,
253            "neither XDG_STATE_HOME nor HOME is set",
254            "export HOME, or XDG_STATE_HOME",
255        );
256    };
257    let display = root.display().to_string();
258    let probe = root.join(format!(".probe-{}", std::process::id()));
259    let written = std::fs::create_dir_all(&root).and_then(|()| std::fs::write(&probe, b"probe"));
260    let _ = std::fs::remove_file(&probe);
261    match written {
262        Ok(()) => ProbeResult::ok(id, ProbeClass::Hard, format!("{display} is writable")),
263        Err(source) => ProbeResult::failed(
264            id,
265            ProbeClass::Hard,
266            format!("{display} is not writable: {source}"),
267            format!("make {display} writable"),
268        ),
269    }
270}
271
272/// The destinations `rk skill install` writes accept writes: the two agent
273/// roots and the shared root, all under the invoking user's home.
274///
275/// A root can exist and still refuse, which is what a read-only bind of an
276/// agent directory produces, so what is tested is the nearest existing
277/// ancestor — the directory an install would actually have to write
278/// through. The probe creates nothing: a preview must still be able to
279/// report a root as absent, and a probe that made it exist would take that
280/// answer away.
281fn skill_roots() -> ProbeResult {
282    let id = SKILL_PROBES[0];
283    let Ok(home) = crate::skills::home() else {
284        return ProbeResult::failed(
285            id,
286            ProbeClass::Soft,
287            "neither HOME nor USERPROFILE is set, so no skill root resolves",
288            "export HOME",
289        );
290    };
291    let mut refused = Vec::new();
292    for root in [CLAUDE_ROOT, AGENTS_ROOT, SHARED_ROOT] {
293        let root = home.join(root);
294        let Some(existing) = nearest_existing(&root) else {
295            refused.push(format!("no ancestor of {root} exists"));
296            continue;
297        };
298        if let Err(source) = accepts_a_write(&existing) {
299            refused.push(format!("{existing} is not writable: {source}"));
300        }
301    }
302    if refused.is_empty() {
303        ProbeResult::ok(
304            id,
305            ProbeClass::Soft,
306            format!("the skill roots under {home} accept writes"),
307        )
308    } else {
309        ProbeResult::failed(
310            id,
311            ProbeClass::Soft,
312            refused.join("; "),
313            format!("make the skill roots under {home} writable"),
314        )
315    }
316}
317
318/// The artifacts every skill shares are installed, and are this binary's.
319///
320/// This is the probe that answers the one failure a shared home produces.
321/// The agent roots and the shared root are separate directories, so a
322/// container, a sandbox, or a sync that carries one and not the other
323/// leaves every skill resolvable by name and unable to read the gates it is
324/// told to read first. A skill that cannot read them runs neither its
325/// pre-flight nor its plan phase, which is the whole reason they are files
326/// rather than prose.
327fn skill_gate() -> ProbeResult {
328    let id = SKILL_PROBES[1];
329    let Ok(home) = crate::skills::home() else {
330        return ProbeResult::failed(
331            id,
332            ProbeClass::Soft,
333            "neither HOME nor USERPROFILE is set, so the shared root does not resolve",
334            "export HOME",
335        );
336    };
337    let root = home.join(SHARED_ROOT);
338    let record = Record::load(&home.join(RECORD_PATH));
339    let planned: Vec<(Utf8PathBuf, &'static [u8])> = crate::skills::shared()
340        .into_iter()
341        .map(|artifact| (root.join(&artifact.path), artifact.bytes))
342        .collect();
343    let found = judge(planned, &record);
344    if let Some(first) = found.missing.first() {
345        return ProbeResult::failed(
346            id,
347            ProbeClass::Soft,
348            format!("a shared artifact every skill reads before acting is not installed: {first}"),
349            "rk skill install --apply",
350        );
351    }
352    if !found.differing.is_empty() {
353        return ProbeResult::failed(
354            id,
355            ProbeClass::Soft,
356            format!(
357                "{} shared artifact(s) under {root} are not this binary's",
358                found.differing.len()
359            ),
360            reinstall(found.all_recorded),
361        );
362    }
363    ProbeResult::ok(
364        id,
365        ProbeClass::Soft,
366        format!("{root} holds this binary's shared artifacts"),
367    )
368}
369
370/// The skills installed under this home are the ones this binary carries.
371///
372/// One binary serves every repository, so a skill under an agent root and
373/// the `rk` on PATH are two artifacts that can be updated apart: a home
374/// shared with a container, a sandbox, or another machine can hold skills
375/// some other build installed. The probe names that drift rather than
376/// leaving an agent to follow instructions the binary no longer answers.
377fn skill_payload() -> ProbeResult {
378    let id = SKILL_PROBES[2];
379    let Ok(home) = crate::skills::home() else {
380        return ProbeResult::failed(
381            id,
382            ProbeClass::Soft,
383            "neither HOME nor USERPROFILE is set, so no agent root resolves",
384            "export HOME",
385        );
386    };
387    let Ok(skills) = crate::skills::all() else {
388        return ProbeResult::failed(
389            id,
390            ProbeClass::Soft,
391            "this binary's embedded skills do not read",
392            "reinstall rk; the payload it was built from is defective",
393        );
394    };
395    let record = Record::load(&home.join(RECORD_PATH));
396    let mut planned = Vec::new();
397    for root in [CLAUDE_ROOT, AGENTS_ROOT] {
398        let root = home.join(root);
399        // An absent agent root is a choice, not a defect: `--agent` selects
400        // one family and leaves the other's root untouched.
401        if !root.is_dir() {
402            continue;
403        }
404        for skill in &skills {
405            planned.push((
406                root.join(&skill.name).join("SKILL.md"),
407                skill.text.as_bytes(),
408            ));
409        }
410    }
411    if planned.is_empty() {
412        return ProbeResult::failed(
413            id,
414            ProbeClass::Soft,
415            format!("no agent skill root exists under {home}"),
416            "rk skill install --apply",
417        );
418    }
419    let found = judge(planned, &record);
420    if let Some(first) = found.missing.first() {
421        return ProbeResult::failed(
422            id,
423            ProbeClass::Soft,
424            format!(
425                "{} of this binary's skills are not installed, the first at {first}",
426                found.missing.len()
427            ),
428            "rk skill install --apply",
429        );
430    }
431    if !found.differing.is_empty() {
432        return ProbeResult::failed(
433            id,
434            ProbeClass::Soft,
435            format!(
436                "{} installed skill(s) are not this binary's; rk is {}",
437                found.differing.len(),
438                env!("CARGO_PKG_VERSION")
439            ),
440            reinstall(found.all_recorded),
441        );
442    }
443    ProbeResult::ok(
444        id,
445        ProbeClass::Soft,
446        format!(
447            "{} installed skill destination(s) are this binary's",
448            found.matching
449        ),
450    )
451}
452
453/// What sits at each destination the payload names.
454struct Installed {
455    /// Destinations the payload names that hold no readable file.
456    missing: Vec<Utf8PathBuf>,
457    /// Destinations holding bytes that are not this binary's.
458    differing: Vec<Utf8PathBuf>,
459    /// How many destinations hold exactly this binary's bytes.
460    matching: usize,
461    /// Whether the record vouches for every differing destination, which
462    /// makes the difference a stale install rather than the operator's own
463    /// edit — and decides whether the fix needs `--force`.
464    all_recorded: bool,
465}
466
467/// Judge each destination the payload names against what sits on disk.
468fn judge(planned: Vec<(Utf8PathBuf, &'static [u8])>, record: &Record) -> Installed {
469    let mut found = Installed {
470        missing: Vec::new(),
471        differing: Vec::new(),
472        matching: 0,
473        all_recorded: true,
474    };
475    for (destination, bytes) in planned {
476        match std::fs::read(&destination) {
477            Ok(held) if held == bytes => found.matching += 1,
478            Ok(held) => {
479                if !record.wrote(&destination, &Digest::of(&held)) {
480                    found.all_recorded = false;
481                }
482                found.differing.push(destination);
483            }
484            Err(_) => found.missing.push(destination),
485        }
486    }
487    found
488}
489
490/// The install that corrects a difference. Bytes the record vouches for are
491/// an older release's and go without asking; bytes it cannot account for are
492/// the operator's own, and overwriting those is what `--force` is.
493const fn reinstall(all_recorded: bool) -> &'static str {
494    if all_recorded {
495        "rk skill install --apply"
496    } else {
497        "rk skill install --apply --force"
498    }
499}
500
501/// The nearest ancestor of `path`, itself included, that exists as a
502/// directory.
503fn nearest_existing(path: &Utf8Path) -> Option<Utf8PathBuf> {
504    let mut current = Some(path);
505    while let Some(dir) = current {
506        if dir.is_dir() {
507            return Some(dir.to_owned());
508        }
509        current = dir.parent();
510    }
511    None
512}
513
514/// A directory accepts a write, leaving nothing behind.
515fn accepts_a_write(dir: &Utf8Path) -> std::io::Result<()> {
516    let probe = dir.join(format!(".rk-probe-{}", std::process::id()));
517    let written = std::fs::write(&probe, b"probe");
518    let _ = std::fs::remove_file(&probe);
519    written
520}
521
522/// The working directory's `origin` remote parses to a host, which is
523/// what forge and slug detection read.
524fn git_remote() -> ProbeResult {
525    let id = "git-remote";
526    let out = Command::new(git_bin())
527        .args(["remote", "get-url", "origin"])
528        .output();
529    let url = match out {
530        Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_owned(),
531        _ => {
532            return ProbeResult::failed(
533                id,
534                ProbeClass::Soft,
535                "the working directory has no origin remote",
536                "pass --repo <owner/name> where a command needs the slug",
537            );
538        }
539    };
540    // The raw remote never reaches the message: a malformed URL can carry
541    // userinfo — `https://user:token@…` — and a probe result lands in
542    // captured output and CI logs, where a credential must never appear.
543    remote_host(&url).map_or_else(
544        || {
545            ProbeResult::failed(
546                id,
547                ProbeClass::Soft,
548                "the origin remote does not parse to a host",
549                "pass --repo <owner/name> where a command needs the slug",
550            )
551        },
552        |host| ProbeResult::ok(id, ProbeClass::Soft, format!("origin resolves to {host}")),
553    )
554}
555
556/// The host in a git remote URL, for the `scp`-like and URL forms.
557fn remote_host(url: &str) -> Option<String> {
558    if let Some(rest) = url.split_once("://").map(|(_, rest)| rest) {
559        let authority = rest.split('/').next()?;
560        let host = authority
561            .rsplit_once('@')
562            .map_or(authority, |(_, host)| host);
563        let host = host.split(':').next()?;
564        return (!host.is_empty()).then(|| host.to_owned());
565    }
566    let (authority, path) = url.split_once(':')?;
567    let host = authority
568        .rsplit_once('@')
569        .map_or(authority, |(_, host)| host);
570    (!host.is_empty() && !path.is_empty()).then(|| host.to_owned())
571}
572
573/// A forge CLI is present and authenticated. `env_override` names the
574/// variable that substitutes the binary, which is also what keeps tests
575/// hermetic. `attempts` is tried in order and the first success wins, so
576/// a probe can prefer a sharper flag and still work where the CLI
577/// predates it.
578fn forge_cli(
579    id: &'static str,
580    env_override: &str,
581    default_bin: &str,
582    label: &str,
583    login: &str,
584    attempts: &[&[&str]],
585) -> ProbeResult {
586    let bin = std::env::var(env_override).unwrap_or_else(|_| default_bin.to_owned());
587    let mut spawned = false;
588    for args in attempts {
589        match Command::new(&bin).args(*args).output() {
590            Ok(out) if out.status.success() => {
591                return ProbeResult::ok(
592                    id,
593                    ProbeClass::Soft,
594                    format!("{default_bin} is authenticated"),
595                );
596            }
597            Ok(_) => spawned = true,
598            Err(_) => {}
599        }
600    }
601    if spawned {
602        ProbeResult::failed(
603            id,
604            ProbeClass::Soft,
605            format!("{default_bin} is not authenticated"),
606            format!("run {login}"),
607        )
608    } else {
609        ProbeResult::failed(
610            id,
611            ProbeClass::Soft,
612            format!("{default_bin} is not on PATH"),
613            format!("install {label}"),
614        )
615    }
616}
617
618#[cfg(test)]
619mod tests {
620    use super::remote_host;
621
622    #[test]
623    fn a_remote_host_parses_from_both_url_forms() {
624        assert_eq!(
625            remote_host("https://github.com/owner/name.git").as_deref(),
626            Some("github.com")
627        );
628        assert_eq!(
629            remote_host("git@gitlab.com:group/sub/name.git").as_deref(),
630            Some("gitlab.com")
631        );
632        assert_eq!(
633            remote_host("ssh://git@github.com:22/owner/name.git").as_deref(),
634            Some("github.com")
635        );
636        assert_eq!(remote_host("not a url"), None);
637    }
638}