Skip to main content

mj_controller/
doctor.rs

1//! Actionable host and configuration prerequisite checks.
2
3use std::io::Write;
4use std::path::{Path, PathBuf};
5use std::time::{Duration, SystemTime, UNIX_EPOCH};
6
7use anyhow::Result;
8use serde::Serialize;
9
10use crate::controller::{WorkerBinaryAvailability, worker_binary_prerequisite_for_arch};
11use crate::setup::{
12    DiscoveredHome, discover_harness_homes_with_executor, harness_is_authenticated_with_executor,
13};
14use crate::targets::{
15    BoundedProcessExecutor, CommandExecutor, CommandSpec, CommandTimedOut,
16    ContainerTemplate as RuntimeContainerTemplate, DockerUnavailable, PODMAN_DOCUMENTATION_URL,
17    PodmanPostcondition, ProcessExecutor, SshTarget as RuntimeSshTarget,
18    TargetTemplate as RuntimeTargetTemplate, failed_podman_postcondition, podman_probe_observation,
19    run_setup_smoke_test, ssh_command, ssh_connectivity_probe, ssh_validation_command,
20    verify_local_docker, verify_local_podman, verify_ssh_docker, verify_ssh_podman,
21};
22use mj_core::config::{
23    Config, ContainerTemplate, HarnessKind, HarnessProfile, TargetTemplate, config_path,
24};
25use mj_core::credentials::login_command;
26
27// Only the image for the Apple container smoke test when the config has no
28// apple-container target. This intentionally stays a small stock image rather
29// than setup::DEFAULT_IMAGE: the check just proves the runtime can start a
30// container, and pulling the multi-gigabyte agent-dev image to do that would be
31// a poor trade.
32const DEFAULT_CONTAINER_IMAGE: &str = "ubuntu:24.04";
33const APPLE_CONTAINER_INSTALL_URL: &str = "https://github.com/apple/container#initial-install";
34
35/// How long a single prerequisite probe may take before doctor reports it as a
36/// fixable check instead of waiting for it.
37///
38/// Every probe outside the opt-in smoke tests is a local or short network call,
39/// so this only ever fires for a wedged runtime socket, a blackholed network,
40/// or a credential helper waiting on something that will never arrive.
41pub const PROBE_TIMEOUT: Duration = Duration::from_secs(15);
42
43/// The executor `mj doctor` and `mj setup` run their prerequisite probes
44/// through: one deadline per probe, so a wedged runtime cannot hang the run.
45pub const fn probe_executor() -> BoundedProcessExecutor {
46    BoundedProcessExecutor::new(PROBE_TIMEOUT)
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
50#[serde(rename_all = "lowercase")]
51pub enum CheckStatus {
52    Ready,
53    Warning,
54    Fixable,
55    Unsupported,
56}
57
58impl CheckStatus {
59    pub const fn label(self) -> &'static str {
60        match self {
61            Self::Ready => "ready",
62            Self::Warning => "warning",
63            Self::Fixable => "fixable",
64            Self::Unsupported => "unsupported",
65        }
66    }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
70pub struct DoctorCheck {
71    pub id: String,
72    pub title: String,
73    pub status: CheckStatus,
74    pub detail: String,
75    pub remediation: Option<String>,
76}
77
78impl DoctorCheck {
79    fn ready(id: impl Into<String>, title: impl Into<String>, detail: impl Into<String>) -> Self {
80        Self {
81            id: id.into(),
82            title: title.into(),
83            status: CheckStatus::Ready,
84            detail: detail.into(),
85            remediation: None,
86        }
87    }
88
89    fn warning(
90        id: impl Into<String>,
91        title: impl Into<String>,
92        detail: impl Into<String>,
93        remediation: impl Into<String>,
94    ) -> Self {
95        Self {
96            id: id.into(),
97            title: title.into(),
98            status: CheckStatus::Warning,
99            detail: detail.into(),
100            remediation: Some(remediation.into()),
101        }
102    }
103
104    pub(crate) fn fixable(
105        id: impl Into<String>,
106        title: impl Into<String>,
107        detail: impl Into<String>,
108        remediation: impl Into<String>,
109    ) -> Self {
110        Self {
111            id: id.into(),
112            title: title.into(),
113            status: CheckStatus::Fixable,
114            detail: detail.into(),
115            remediation: Some(remediation.into()),
116        }
117    }
118
119    fn unsupported(
120        id: impl Into<String>,
121        title: impl Into<String>,
122        detail: impl Into<String>,
123    ) -> Self {
124        Self {
125            id: id.into(),
126            title: title.into(),
127            status: CheckStatus::Unsupported,
128            detail: detail.into(),
129            remediation: None,
130        }
131    }
132}
133
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub struct DoctorOptions {
136    pub smoke: bool,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub enum ApplePlatform {
141    Linux,
142    Macos {
143        architecture: String,
144        major_version: u32,
145    },
146    Other(String),
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub enum InstructionsPlatform {
151    Linux,
152    Macos,
153}
154
155pub fn run_current(options: DoctorOptions) -> Vec<DoctorCheck> {
156    if options.smoke {
157        // A smoke test may legitimately pull a multi-gigabyte image, which no
158        // probe deadline could tell apart from a hung runtime, so an opt-in
159        // `--smoke` run keeps waiting for its commands.
160        return run_with(
161            &ProcessExecutor,
162            current_apple_platform(&ProcessExecutor),
163            options,
164        );
165    }
166    let executor = probe_executor();
167    run_with(&executor, current_apple_platform(&executor), options)
168}
169
170pub fn run_with(
171    executor: &impl CommandExecutor,
172    apple_platform: ApplePlatform,
173    options: DoctorOptions,
174) -> Vec<DoctorCheck> {
175    run_with_config_path(&config_path(), executor, apple_platform, options)
176}
177
178/// The same checks as [`run_with`], against an explicit configuration file.
179///
180/// `mj setup` uses this to report on the configuration it just wrote, so a
181/// first run ends with exactly the summary and remediations `mj doctor`
182/// would print.
183pub fn run_with_config_path(
184    config_path: &Path,
185    executor: &impl CommandExecutor,
186    apple_platform: ApplePlatform,
187    options: DoctorOptions,
188) -> Vec<DoctorCheck> {
189    let (loaded, mut checks) = configuration_checks(config_path);
190    let config: ConfigStatus<'_> = loaded.as_ref().map_err(|gap| *gap);
191    checks.push(harness_discovery_check(config, executor));
192    checks.extend(harness_checks(config, executor));
193    checks.extend(subagent_eligibility_checks(config));
194    let podman = podman_checks(config, executor, options.smoke);
195    let docker = docker_checks(config, executor, options.smoke);
196    let apple_container = apple_container_check(
197        &apple_platform,
198        executor,
199        options.smoke,
200        apple_container_image(config),
201    );
202    let offered = config.map(|config| {
203        offered_targets(
204            config,
205            podman.iter().chain(&docker).chain([&apple_container]),
206        )
207    });
208    let offered = offered.as_ref().map_err(|gap| *gap);
209    checks.extend(podman);
210    checks.extend(docker);
211    checks.extend(ssh_bare_checks(config, executor));
212    checks.extend(ssh_podman_checks(config, executor, options.smoke));
213    checks.extend(ssh_docker_checks(config, executor, options.smoke));
214    checks.extend(build_cache_checks(offered, executor));
215    checks.extend(aws_checks(config, executor));
216    checks.extend(worker_binary_checks(offered));
217    checks.push(daemon_build_check());
218    checks.extend(worker_freshness_checks(offered));
219    checks.extend(review_residue_checks(config));
220    // Reported last, where it has always been.
221    checks.push(apple_container);
222    checks
223}
224
225fn build_cache_checks(
226    config: ConfigStatus<'_>,
227    executor: &impl CommandExecutor,
228) -> Vec<DoctorCheck> {
229    let Ok(config) = config else {
230        return Vec::new();
231    };
232    crate::controller::doctor_host_mbx(config, executor)
233        .into_iter()
234        .map(|host| {
235            let id = format!("build-cache.{}", host.host);
236            let title = format!("Build cache on {}", host.host);
237            let targets = host.targets.join(", ");
238            match host.status {
239                crate::controller::DoctorHostMbxStatus::Absent => DoctorCheck::ready(
240                    id,
241                    title,
242                    format!(
243                        "No native mbx is installed; targets {targets} can use Mjolnir's mbx {}.",
244                        crate::controller::MBX_VERSION
245                    ),
246                ),
247                crate::controller::DoctorHostMbxStatus::Compatible(version) => DoctorCheck::ready(
248                    id,
249                    title,
250                    format!(
251                        "Host mbx {version} is compatible with Mjolnir's mbx {} for targets {targets}.",
252                        crate::controller::MBX_VERSION
253                    ),
254                ),
255                crate::controller::DoctorHostMbxStatus::TooOld(version) => DoctorCheck::warning(
256                    id,
257                    title,
258                    format!(
259                        "Host mbx {version} is older than Mjolnir's mbx {}; sessions on targets {targets} run without the shared build cache.",
260                        crate::controller::MBX_VERSION
261                    ),
262                    format!(
263                        "Upgrade mbx on {} to {} or newer, then rerun `mj doctor`.",
264                        host.host,
265                        crate::controller::MBX_VERSION
266                    ),
267                ),
268                crate::controller::DoctorHostMbxStatus::Unknown(error) => DoctorCheck::warning(
269                    id,
270                    title,
271                    format!("Could not check host mbx for targets {targets}: {error}"),
272                    format!(
273                        "Check access to {} and run `mbx --version` there, then rerun `mj doctor`.",
274                        host.host
275                    ),
276                ),
277            }
278        })
279        .collect()
280}
281
282fn harness_discovery_check(
283    config: ConfigStatus<'_>,
284    executor: &impl CommandExecutor,
285) -> DoctorCheck {
286    let home = dirs::home_dir();
287    let overrides = HarnessKind::ALL.into_iter().filter_map(|kind| {
288        std::env::var_os(kind.home_env()).map(|path| (kind, kind.home_from_environment(path)))
289    });
290    let discovered = discover_harness_homes_with_executor(home.as_deref(), overrides, executor);
291    harness_discovery_check_from(
292        &discovered,
293        config.is_ok_and(|config| !config.profiles.is_empty()),
294        &settings_key(config.ok()),
295    )
296}
297
298fn harness_discovery_check_from(
299    discovered: &[DiscoveredHome],
300    has_configured_profiles: bool,
301    settings_key: &str,
302) -> DoctorCheck {
303    if discovered.is_empty() {
304        return if has_configured_profiles {
305            DoctorCheck::ready(
306                "harness.discovery",
307                "Harness home discovery",
308                "No default or environment-overridden harness homes were found; configured profile homes are checked below.",
309            )
310        } else {
311            DoctorCheck::fixable(
312                "harness.discovery",
313                "Harness home discovery",
314                format!(
315                    "No {} home was found in the default or environment-overridden locations.",
316                    HarnessKind::every_display_name_or()
317                ),
318                format!(
319                    "Install and sign in to a supported harness, then open Mjolnir, press {settings_key} for Settings, and choose Agent Profiles."
320                ),
321            )
322        };
323    }
324
325    let homes = discovered
326        .iter()
327        .map(|home| {
328            let authentication = if home.authenticated {
329                "authenticated"
330            } else {
331                "not authenticated"
332            };
333            format!(
334                "{} at {} ({authentication})",
335                home.kind.display_name(),
336                home.path.display()
337            )
338        })
339        .collect::<Vec<_>>()
340        .join("; ");
341    DoctorCheck::ready(
342        "harness.discovery",
343        "Harness home discovery",
344        format!("Discovered {homes}. Configured profile authentication is checked below."),
345    )
346}
347
348/// The key that opens Settings, as the help overlay labels it (`ctrl+b s`
349/// by default). Without a readable configuration the default bindings apply.
350fn settings_key(config: Option<&Config>) -> String {
351    let keybinds = config.map_or_else(mj_core::config::Keybinds::default, Config::keybinds);
352    keybinds
353        .labels(mj_core::config::KeyAction::OpenSettings)
354        .into_iter()
355        .next()
356        .unwrap_or_else(|| "the Settings command in the command palette".to_owned())
357}
358
359pub fn all_ready(checks: &[DoctorCheck]) -> bool {
360    checks
361        .iter()
362        .all(|check| check.status != CheckStatus::Fixable)
363}
364
365pub fn render_human(checks: &[DoctorCheck], output: &mut impl Write) -> Result<()> {
366    for check in checks {
367        writeln!(
368            output,
369            "{} {}: {}",
370            check.status.label(),
371            check.title,
372            check.detail
373        )?;
374        if let Some(remediation) = &check.remediation {
375            writeln!(output, "  remediation: {remediation}")?;
376        }
377    }
378    Ok(())
379}
380
381pub fn setup_instructions(platform: InstructionsPlatform) -> String {
382    match platform {
383        InstructionsPlatform::Linux => format!(
384            "# Mjolnir setup instructions for Linux\n\n\
385This page is self-contained. Follow this exact loop as the user who will run `mj`:\n\n\
3861. Run `mj doctor --json`.\n\
3872. Follow every `fixable` remediation from its JSON output.\n\
3883. Run `mj doctor --json` again. Repeat until no check is `fixable`.\n\
3894. Finish with `mj doctor --json --smoke` to verify every configured container\n\
390   image end to end, and resolve anything it reports as `fixable`.\n\n\
391For a coding-agent handoff, provide this entire instructions page together with\n\
392the latest `mj doctor --json` output.\n\n\
393## Local bare runtime\n\n\
394A local bare runtime runs the agent directly on this machine. It needs the\n\
395native `mj-worker` installed beside `mj`; the release installer and the npm\n\
396package include it. The worker installs the pinned harness version itself.\n\
397Codex and Claude need Node.js 22 or newer and npm on `PATH`. Kimi and Grok\n\
398need curl and Bash. Muse needs curl and tar. Mjolnir does not install these\n\
399prerequisites.\n\n\
400## Linux container-runtime postconditions\n\n{}\n\n{}",
401            crate::targets::PODMAN_DOCUMENTATION,
402            crate::targets::DOCKER_DOCUMENTATION
403        ),
404        InstructionsPlatform::Macos => format!(
405            "# Mjolnir setup instructions for macOS\n\n\
406This page is self-contained. Follow this exact loop as the user who will run `mj`:\n\n\
4071. Run `mj doctor --json`.\n\
4082. Follow every `fixable` remediation from its JSON output.\n\
4093. Run `mj doctor --json` again. Repeat until no check is `fixable`.\n\n\
410For a coding-agent handoff, provide this entire instructions page together with\n\
411the latest `mj doctor --json` output.\n\n\
412## Local bare runtime\n\n\
413A local bare runtime runs the agent directly on this machine. It needs the\n\
414native `mj-worker` installed beside `mj`; the release installer and the npm\n\
415package include it. The worker installs the pinned harness version itself.\n\
416Codex and Claude need Node.js 22 or newer and npm on `PATH`. Kimi and Grok\n\
417need curl and Bash. Muse needs curl and tar. Mjolnir does not install these\n\
418prerequisites.\n\n\
419## Apple container runtime\n\n\
420Mjolnir's Apple container target requires Apple silicon and macOS 26 or newer.\n\
421On an Intel Mac or an older macOS release, the target is unsupported; use the\n\
422local bare runtime, an SSH target, or an AWS target instead.\n\n\
423If the `container` command is absent, install only the official signed package:\n\n\
424<https://github.com/apple/container#initial-install>\n\n\
425Mjolnir never downloads or installs that package. If doctor reports a stopped\n\
426daemon, run exactly:\n\n```console\ncontainer system start\n```\n\n\
427Finish with the opt-in disposable runtime test in JSON mode:\n\n```console\nmj doctor --json --smoke\n```\n\n\
428Apple container is ready only when that smoke test creates a disposable\n\
429container, executes `true` in it, and removes it successfully. Use the image\n\
430configured by an `apple-container` target; without one, doctor uses\n\
431`{DEFAULT_CONTAINER_IMAGE}` for the smoke test.\n\n\
432## Shared Mjolnir prerequisites\n\n\
433`mj doctor --json` also checks the configuration, each configured harness home\n\
434and authentication marker, selected container worker binaries, and any relevant\n\
435Podman prerequisites. Resolve every `fixable` status before starting a session.\n"
436        ),
437    }
438}
439
440/// Why `mj doctor` has no configuration for the checks that need one.
441///
442/// The cases call for different advice, so every dependent check is told
443/// which one it is: a file this build cannot read because a newer Mjolnir
444/// wrote it is not broken, and telling the user to fix or replace it would
445/// destroy that build's settings; a file that does not exist yet has nothing
446/// to fix (R14-3).
447#[derive(Debug, Clone, Copy, PartialEq, Eq)]
448enum ConfigGap {
449    /// A newer Mjolnir wrote the file; the value is the version it carries.
450    NewerVersion(u32),
451    /// There is no file yet. The first `mj` that adds an agent profile, or
452    /// `mj setup`, writes it.
453    Missing,
454    /// The file is not valid Mjolnir TOML.
455    Unreadable,
456}
457
458impl ConfigGap {
459    /// What a dependent check waits for, completing "cannot be checked
460    /// until ...".
461    fn awaited(self) -> &'static str {
462        match self {
463            ConfigGap::NewerVersion(_) => "this Mjolnir can read config.toml",
464            ConfigGap::Missing => "config.toml exists",
465            ConfigGap::Unreadable => "config.toml is valid",
466        }
467    }
468
469    /// The fix for a check that waits for a configuration it can read.
470    ///
471    /// It names plain `mj doctor`: the checks are the same in the human and
472    /// the JSON report, and a person reading the human one has no use for
473    /// `--json` (R14-3).
474    fn remediation(self) -> &'static str {
475        match self {
476            ConfigGap::NewerVersion(_) => {
477                "Update Mjolnir to the build that wrote config.toml or newer, then rerun `mj doctor`."
478            }
479            ConfigGap::Missing => {
480                "Run `mj` and add an agent profile, or run `mj setup`; either one writes config.toml. Then rerun `mj doctor`."
481            }
482            ConfigGap::Unreadable => "Fix config.toml, then rerun `mj doctor`.",
483        }
484    }
485}
486
487/// What a dependent check works from: the loaded configuration, or why there
488/// is none.
489type ConfigStatus<'a> = std::result::Result<&'a Config, ConfigGap>;
490
491/// What a check reports when the configuration came from a newer Mjolnir.
492///
493/// Nothing about the check can be evaluated, and nothing the user does to
494/// `config.toml` would help, so the check skips and names the one real fix.
495fn newer_config_skip(id: &str, title: &str, version: u32) -> DoctorCheck {
496    DoctorCheck::unsupported(
497        id,
498        title,
499        format!(
500            "Skipped: config.toml was written by a newer Mjolnir (config version {version}; this build supports {}). Update Mjolnir to that build or newer.",
501            mj_core::config::CONFIG_VERSION
502        ),
503    )
504}
505
506fn configuration_checks(path: &Path) -> (std::result::Result<Config, ConfigGap>, Vec<DoctorCheck>) {
507    if !path.exists() {
508        return (
509            Err(ConfigGap::Missing),
510            vec![DoctorCheck::fixable(
511                "config",
512                "Mjolnir configuration",
513                format!("{} does not exist", path.display()),
514                format!(
515                    "Open Mjolnir and press {} for Settings to add an agent profile.",
516                    settings_key(None)
517                ),
518            )],
519        );
520    }
521    // A config a newer build wrote is not broken TOML: replacing it with
522    // `mj setup` would discard that build's settings. Say what is actually
523    // wrong before the load below reports it as invalid.
524    if let Some(found) = mj_core::config::newer_version_on_disk(path) {
525        return (
526            Err(ConfigGap::NewerVersion(found)),
527            vec![DoctorCheck::fixable(
528                "config",
529                "Mjolnir configuration",
530                format!(
531                    "{} was written by a newer Mjolnir (config version {found}; this build supports {})",
532                    path.display(),
533                    mj_core::config::CONFIG_VERSION
534                ),
535                "Update Mjolnir to that build or newer. Do not lower the version value by hand or replace the file.",
536            )],
537        );
538    }
539    match Config::load_from(path) {
540        Ok(config) => {
541            let mut checks = vec![DoctorCheck::ready(
542                "config",
543                "Mjolnir configuration",
544                format!("{} is valid", path.display()),
545            )];
546            // A bundle only names a set of repositories to start from; a
547            // session can start from any project directory without one, so
548            // only the missing profile keeps sessions from starting.
549            if config.enabled_profiles().next().is_none() {
550                checks.push(DoctorCheck::fixable(
551                    "config.session-prerequisites",
552                    "Session configuration",
553                    "No agent profile is enabled, so no session can start. Local targets are supplied automatically.",
554                    format!(
555                        "Open Mjolnir and press {} for Settings to add or enable an agent profile.",
556                        settings_key(Some(&config))
557                    ),
558                ));
559            } else if config.bundles.is_empty() {
560                checks.push(DoctorCheck::ready(
561                    "config.session-prerequisites",
562                    "Session configuration",
563                    "An agent profile is enabled. No project bundle is configured; sessions start from a project directory, and a bundle is only needed to start from a saved set of repositories.",
564                ));
565            } else {
566                checks.push(DoctorCheck::ready(
567                    "config.session-prerequisites",
568                    "Session configuration",
569                    "At least one profile, bundle, and target are configured.",
570                ));
571            }
572            (Ok(config), checks)
573        }
574        Err(error) => (
575            Err(ConfigGap::Unreadable),
576            vec![DoctorCheck::fixable(
577                "config",
578                "Mjolnir configuration",
579                format!("{} is invalid: {error:#}", path.display()),
580                "Fix the reported TOML error in config.toml, or run `mj setup` to replace it.",
581            )],
582        ),
583    }
584}
585
586fn harness_checks(config: ConfigStatus<'_>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
587    let config = match config {
588        Ok(config) => config,
589        Err(ConfigGap::NewerVersion(version)) => {
590            return vec![newer_config_skip(
591                "harness.profiles",
592                "Harness profiles",
593                version,
594            )];
595        }
596        Err(gap @ (ConfigGap::Missing | ConfigGap::Unreadable)) => {
597            return vec![DoctorCheck::fixable(
598                "harness.profiles",
599                "Harness profiles",
600                format!("Harness homes cannot be checked until {}.", gap.awaited()),
601                gap.remediation(),
602            )];
603        }
604    };
605    if config.profiles.is_empty() {
606        return vec![DoctorCheck::fixable(
607            "harness.profiles",
608            "Harness profiles",
609            "No harness profiles are configured.",
610            format!(
611                "Open Mjolnir, press {} for Settings, and choose Agent Profiles to detect accounts or add a profile.",
612                settings_key(Some(config))
613            ),
614        )];
615    }
616    config
617        .profiles
618        .iter()
619        .map(|(id, profile)| {
620            let mut check = harness_profile_check(id, profile, executor);
621            check.detail = format!("{} {}", profile_summary(config, id, profile), check.detail);
622            check
623        })
624        .collect()
625}
626
627/// The readiness of one profile: its home, and whether it can authenticate.
628fn harness_profile_check(
629    id: &str,
630    profile: &HarnessProfile,
631    executor: &impl CommandExecutor,
632) -> DoctorCheck {
633    let title = format!("Harness profile {id}");
634    if !profile.enabled {
635        return DoctorCheck::ready(
636            format!("harness.{id}"),
637            title,
638            "Profile is disabled; home and authentication checks were skipped.",
639        );
640    }
641    if !profile.home.is_dir() {
642        return DoctorCheck::fixable(
643            format!("harness.{id}"),
644            title,
645            format!("{} does not exist", profile.home.display()),
646            format!(
647                "{} If this profile should use an existing installation, select its home in Setup.",
648                harness_login_remediation(id, profile)
649            ),
650        );
651    }
652    if !harness_is_authenticated_with_executor(profile, executor) {
653        return DoctorCheck::fixable(
654            format!("harness.{id}"),
655            title,
656            format!(
657                "No usable authentication was detected for {}",
658                profile.home.display()
659            ),
660            harness_login_remediation(id, profile),
661        );
662    }
663    DoctorCheck::ready(
664        format!("harness.{id}"),
665        title,
666        format!(
667            "{} is present and authentication is available",
668            profile.home.display()
669        ),
670    )
671}
672
673/// One sentence saying what a profile is: its harness, where its quota comes
674/// from, and whether other sessions' sub-agents may use it. Quota ranking and
675/// delegation both depend on these, and none of them shows in the profile's
676/// own table in config.toml.
677fn profile_summary(config: &Config, id: &str, profile: &HarnessProfile) -> String {
678    let delegation = if config
679        .subagents
680        .eligible_profiles
681        .get(id)
682        .copied()
683        .unwrap_or(false)
684    {
685        "any session's sub-agents may use it"
686    } else {
687        "only its own sessions' sub-agents may use it"
688    };
689    format!(
690        "{}; {}; {delegation}.",
691        profile.kind.display_name(),
692        profile_quota_source(profile)
693    )
694}
695
696/// Where a profile's quota report comes from, in the terms the quota refresh
697/// uses: a Codex profile is read through its custom provider only when that
698/// provider's API key is in the profile's environment.
699fn profile_quota_source(profile: &HarnessProfile) -> String {
700    match profile.kind {
701        HarnessKind::Claude => "Claude subscription quota".to_owned(),
702        HarnessKind::Codex => match crate::quota::provider_credential(profile) {
703            Some(provider) if crate::zai_usage::serves_quota(&provider.host) => {
704                format!("quota from {}", provider.host)
705            }
706            Some(provider) => format!(
707                "pay-per-use through {}, counted as 100% left when choosing a sub-agent's profile",
708                provider.host
709            ),
710            None => match profile.codex_provider() {
711                Ok(None) => "ChatGPT subscription quota".to_owned(),
712                Ok(Some(provider)) => format!(
713                    "no quota report, because custom provider {:?} has no API key in this profile's environment",
714                    provider.id
715                ),
716                Err(error) => format!("its Codex config.toml could not be read ({error:#})"),
717            },
718        },
719        kind => format!("quota as {} reports it", kind.display_name()),
720    }
721}
722
723/// The sub-agent policy in one line, then a warning for each profile that is
724/// both listed for sub-agent use and disabled.
725///
726/// The daemon keeps running with such a profile and simply does not offer it
727/// to a parent, because the delegation candidates and the spawn gate both
728/// require an enabled profile. This surfaces the contradiction so the eligible
729/// list and the profile's `enabled` flag can be reconciled. An eligible id
730/// that names no profile never reaches here: the configuration fails to load,
731/// and the configuration check reports it.
732fn subagent_eligibility_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
733    let Ok(config) = config else {
734        return Vec::new();
735    };
736    let mut checks = vec![subagent_policy_check(config)];
737    checks.extend(
738        config
739            .subagents
740            .eligible_profiles
741            .iter()
742            .filter(|(_, eligible)| **eligible)
743            .filter_map(|(id, _)| match config.profiles.get(id) {
744                Some(profile) if !profile.enabled => Some(DoctorCheck::warning(
745                    format!("subagents.{id}"),
746                    format!("Sub-agent profile {id}"),
747                    format!(
748                        "Profile {id:?} is listed in [subagents.eligible_profiles] but is disabled, so it is not offered for sub-agent use."
749                    ),
750                    format!(
751                        "Re-enable profile {id:?}, or remove it from [subagents.eligible_profiles]."
752                    ),
753                )),
754                _ => None,
755            }),
756    );
757    checks
758}
759
760/// How many sub-agents a session may start, and on which profiles. Whether a
761/// given session uses Mjolnir sub-agents at all is a per-session choice, not
762/// a global policy, so this check only describes the shared limits.
763fn subagent_policy_check(config: &Config) -> DoctorCheck {
764    let subagents = &config.subagents;
765    let eligible = subagents
766        .eligible_profiles
767        .iter()
768        .filter(|(_, eligible)| **eligible)
769        .map(|(id, _)| id.as_str())
770        .collect::<Vec<_>>();
771    let others = if eligible.is_empty() {
772        "no other profile".to_owned()
773    } else {
774        eligible.join(", ")
775    };
776    let detail = format!(
777        "Claude and Codex sessions may opt in, up to {} sub-agents at once per session. A session's sub-agents may use its own profile and: {others}.",
778        subagents.max_concurrent
779    );
780    DoctorCheck::ready("subagents.policy", "Sub-agent policy", detail)
781}
782
783/// Point an unauthenticated profile at `mj login`, which already knows how to
784/// sign each harness in.
785///
786/// The underlying command is named only for the reader's benefit; it comes from
787/// [`login_command`], the one place that tracks what each harness CLI actually
788/// accepts, so this text cannot drift away from what `mj login` runs.
789fn harness_login_remediation(id: &str, profile: &HarnessProfile) -> String {
790    let (program, arguments) = match login_command(profile) {
791        Ok(command) => command,
792        // An API-key profile has no login to recommend; say what is missing
793        // instead. The authentication gate normally passes such a profile, so
794        // this text appears only when its configuration file is absent.
795        Err(error) => return format!("{error} Check {}.", profile.home.display()),
796    };
797    format!(
798        "Run `mj login --profile {id}`; it runs `{program} {}` against {}.",
799        arguments.join(" "),
800        profile.home.display()
801    )
802}
803
804/// Host Podman prerequisites, then one image check per `local-podman` target.
805///
806/// The image checks run only after the host preflight passes, because a broken
807/// Podman installation already reports its own actionable check.
808fn podman_checks(
809    config: ConfigStatus<'_>,
810    executor: &impl CommandExecutor,
811    smoke: bool,
812) -> Vec<DoctorCheck> {
813    let effective = config.map(|config| config.clone().with_local_targets());
814    let effective = effective.as_ref().map_err(|gap| *gap);
815    let explicit = config.is_ok_and(|config| {
816        local_podman_targets(config)
817            .iter()
818            .any(|(id, _)| config.configures_target(id))
819    });
820    let preflight = builtin_target_availability(
821        podman_check(effective, executor),
822        explicit,
823        "Podman",
824        "podman",
825    );
826    let preflight_passed = preflight.status == CheckStatus::Ready;
827    let mut checks = vec![preflight];
828    if preflight_passed {
829        checks.extend(
830            podman_image_checks(effective, executor, smoke)
831                .into_iter()
832                .map(|(id, check)| builtin_image_check(config, &id, check)),
833        );
834    }
835    checks
836}
837
838/// An image check for a standard local target the user never configured.
839/// The dashboard downloads that image itself when it starts, so a missing
840/// image is a warning rather than a fault.
841fn builtin_image_check(
842    config: ConfigStatus<'_>,
843    target_id: &str,
844    check: DoctorCheck,
845) -> DoctorCheck {
846    let explicit = config.is_ok_and(|config| config.configures_target(target_id));
847    if explicit || check.status != CheckStatus::Fixable {
848        return check;
849    }
850    DoctorCheck::warning(
851        check.id,
852        check.title,
853        format!(
854            "{} (built-in `{target_id}` target; the dashboard downloads its image when it starts)",
855            check.detail
856        ),
857        check.remediation.unwrap_or_default(),
858    )
859}
860
861/// Doctor checks the same target set the dashboard lists: the configured
862/// targets plus the standard local ones [`Config::with_local_targets`]
863/// supplies whether or not their engine is installed. A standard target whose
864/// engine is missing or not running is reported as unavailable, as the
865/// dashboard's Targets pane marks it, rather than as a fault to fix: nobody
866/// asked for it. A target the user configured keeps the fixable result; a
867/// block that only repeats a standard target does not count as configured
868/// ([`Config::configures_target`]).
869fn builtin_target_availability(
870    check: DoctorCheck,
871    explicit: bool,
872    engine: &str,
873    target_id: &str,
874) -> DoctorCheck {
875    if explicit || check.status != CheckStatus::Fixable {
876        return check;
877    }
878    DoctorCheck::unsupported(
879        check.id,
880        check.title,
881        format!(
882            "{engine} is not available, so the built-in `{target_id}` target is marked unavailable: {}",
883            check.detail
884        ),
885    )
886}
887
888fn podman_check(config: ConfigStatus<'_>, executor: &impl CommandExecutor) -> DoctorCheck {
889    let config = match config {
890        Ok(config) => config,
891        Err(ConfigGap::NewerVersion(version)) => {
892            return newer_config_skip("runtime.podman", "Rootless Podman", version);
893        }
894        Err(gap @ (ConfigGap::Missing | ConfigGap::Unreadable)) => {
895            return DoctorCheck::unsupported(
896                "runtime.podman",
897                "Rootless Podman",
898                format!(
899                    "Podman prerequisites cannot be evaluated until {}.",
900                    gap.awaited()
901                ),
902            );
903        }
904    };
905    if local_podman_targets(config).is_empty() {
906        return DoctorCheck::unsupported(
907            "runtime.podman",
908            "Rootless Podman",
909            "No local-podman target is configured.",
910        );
911    }
912    local_podman_runtime_check(executor)
913}
914
915/// Probe the local rootless Podman prerequisites and phrase the result as a
916/// doctor check.
917///
918/// This is the single source of truth for Podman availability wording and
919/// remediation. `mj setup` calls it directly so its runtime list reports the
920/// same detail and fix that `mj doctor` would.
921pub fn local_podman_runtime_check(executor: &impl CommandExecutor) -> DoctorCheck {
922    match verify_local_podman(executor) {
923        Ok(preflight) => DoctorCheck::ready(
924            "runtime.podman",
925            "Rootless Podman",
926            format!("Podman {} has a valid rootless UID map.", preflight.version),
927        ),
928        Err(error) => DoctorCheck::fixable(
929            "runtime.podman",
930            "Rootless Podman",
931            podman_failure_detail(&error),
932            podman_remediation(&error),
933        ),
934    }
935}
936
937fn local_podman_targets(config: &Config) -> Vec<(&String, &ContainerTemplate)> {
938    config
939        .targets
940        .iter()
941        .filter_map(|(id, target)| match target {
942            TargetTemplate::LocalPodman { container } => Some((id, container)),
943            _ => None,
944        })
945        .collect()
946}
947
948fn podman_image_checks(
949    config: ConfigStatus<'_>,
950    executor: &impl CommandExecutor,
951    smoke: bool,
952) -> Vec<(String, DoctorCheck)> {
953    let Ok(config) = config else {
954        return Vec::new();
955    };
956    local_podman_targets(config)
957        .into_iter()
958        .map(|(id, container)| {
959            (
960                id.clone(),
961                podman_image_check(id, &container.image, executor, smoke),
962            )
963        })
964        .collect()
965}
966
967fn podman_image_check(
968    id: &str,
969    image: &str,
970    executor: &impl CommandExecutor,
971    smoke: bool,
972) -> DoctorCheck {
973    let check_id = format!("runtime.podman.image.{id}");
974    let title = format!("Podman image for target {id}");
975    if smoke {
976        let target = RuntimeTargetTemplate::LocalPodman(RuntimeContainerTemplate {
977            build_cache: None,
978            image: image.to_owned(),
979            pull_policy: Default::default(),
980            extra_run_args: vec![],
981            workspace_storage: Default::default(),
982        });
983        return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
984            Ok(()) => DoctorCheck::ready(
985                check_id,
986                title,
987                format!("Disposable run/exec/remove smoke test passed for image {image}."),
988            ),
989            Err(error) => DoctorCheck::fixable(
990                check_id,
991                title,
992                format!(
993                    "Disposable run/exec/remove smoke test failed for image {image}: {error:#}"
994                ),
995                "Fix the configured image or Podman runtime, then run `mj doctor --json --smoke` again.",
996            ),
997        };
998    }
999
1000    let command = CommandSpec::new("podman", ["image", "exists", image])
1001        .purpose("check Podman image presence");
1002    match executor.execute(&command) {
1003        Ok(output) if output.status == 0 => DoctorCheck::ready(
1004            check_id,
1005            title,
1006            format!("Image {image} is present in local Podman storage."),
1007        ),
1008        Ok(_) => DoctorCheck::fixable(
1009            check_id,
1010            title,
1011            format!("Image {image} is not present in local Podman storage."),
1012            missing_image_remediation(image),
1013        ),
1014        Err(error) => DoctorCheck::fixable(
1015            check_id,
1016            title,
1017            format!(
1018                "Could not check whether image {image} is present in local Podman storage: {error}"
1019            ),
1020            missing_image_remediation(image),
1021        ),
1022    }
1023}
1024
1025fn missing_image_remediation(image: &str) -> String {
1026    format!(
1027        "Pull it with `podman pull {image}`, build it from containers/Containerfile.agent-dev, or run `mj doctor --json --smoke` to verify the full pull-and-run path."
1028    )
1029}
1030
1031/// Host Docker prerequisites, then one image check per `local-docker` target.
1032fn docker_checks(
1033    config: ConfigStatus<'_>,
1034    executor: &impl CommandExecutor,
1035    smoke: bool,
1036) -> Vec<DoctorCheck> {
1037    let config = match config {
1038        Ok(config) => config,
1039        Err(ConfigGap::NewerVersion(version)) => {
1040            return vec![newer_config_skip("runtime.docker", "Docker", version)];
1041        }
1042        Err(gap @ (ConfigGap::Missing | ConfigGap::Unreadable)) => {
1043            return vec![DoctorCheck::unsupported(
1044                "runtime.docker",
1045                "Docker",
1046                format!(
1047                    "Docker prerequisites cannot be evaluated until {}.",
1048                    gap.awaited()
1049                ),
1050            )];
1051        }
1052    };
1053    let explicit = local_docker_targets(config)
1054        .iter()
1055        .any(|(id, _)| config.configures_target(id));
1056    let effective = config.clone().with_local_targets();
1057    let targets = local_docker_targets(&effective);
1058    if targets.is_empty() {
1059        return vec![DoctorCheck::unsupported(
1060            "runtime.docker",
1061            "Docker",
1062            "No local-docker target is configured.",
1063        )];
1064    }
1065    let preflight = builtin_target_availability(
1066        local_docker_runtime_check(executor),
1067        explicit,
1068        "Docker",
1069        "docker",
1070    );
1071    if preflight.status != CheckStatus::Ready {
1072        return vec![preflight];
1073    }
1074    let mut checks = vec![preflight];
1075    checks.extend(targets.into_iter().map(|(id, container)| {
1076        builtin_image_check(
1077            Ok(config),
1078            id,
1079            docker_image_check(id, &container.image, executor, smoke),
1080        )
1081    }));
1082    checks
1083}
1084
1085pub fn local_docker_runtime_check(executor: &impl CommandExecutor) -> DoctorCheck {
1086    match verify_local_docker(executor) {
1087        Ok(preflight) => DoctorCheck::ready(
1088            "runtime.docker",
1089            "Docker",
1090            format!(
1091                "Docker {} is connected to a Linux daemon.",
1092                preflight.version
1093            ),
1094        ),
1095        Err(error) => match error.downcast_ref::<DockerUnavailable>() {
1096            Some(problem) => DoctorCheck::fixable(
1097                "runtime.docker",
1098                "Docker",
1099                problem.to_string(),
1100                problem.remediation(),
1101            ),
1102            None => DoctorCheck::fixable(
1103                "runtime.docker",
1104                "Docker",
1105                format!("{error:#}"),
1106                "Install and start Docker, then make sure `docker info` succeeds as the user running mj.",
1107            ),
1108        },
1109    }
1110}
1111
1112fn local_docker_targets(config: &Config) -> Vec<(&String, &ContainerTemplate)> {
1113    config
1114        .targets
1115        .iter()
1116        .filter_map(|(id, target)| match target {
1117            TargetTemplate::LocalDocker { container } => Some((id, container)),
1118            _ => None,
1119        })
1120        .collect()
1121}
1122
1123fn docker_image_check(
1124    id: &str,
1125    image: &str,
1126    executor: &impl CommandExecutor,
1127    smoke: bool,
1128) -> DoctorCheck {
1129    let check_id = format!("runtime.docker.image.{id}");
1130    let title = format!("Docker image for target {id}");
1131    if smoke {
1132        let target = RuntimeTargetTemplate::LocalDocker(RuntimeContainerTemplate {
1133            build_cache: None,
1134            image: image.to_owned(),
1135            pull_policy: Default::default(),
1136            extra_run_args: vec![],
1137            workspace_storage: Default::default(),
1138        });
1139        return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1140            Ok(()) => DoctorCheck::ready(
1141                check_id,
1142                title,
1143                format!(
1144                    "Disposable run/exec/remove and OverlayFS attachment smoke test passed for image {image}."
1145                ),
1146            ),
1147            Err(error) => DoctorCheck::fixable(
1148                check_id,
1149                title,
1150                format!(
1151                    "Disposable run/exec/remove smoke test failed for image {image}: {error:#}"
1152                ),
1153                "Fix the configured image or Docker runtime, then run `mj doctor --json --smoke` again.",
1154            ),
1155        };
1156    }
1157    let command = CommandSpec::new("docker", ["image", "inspect", image])
1158        .purpose("check Docker image presence");
1159    match executor.execute(&command) {
1160        Ok(output) if output.status == 0 => DoctorCheck::ready(
1161            check_id,
1162            title,
1163            format!("Image {image} is present in Docker storage."),
1164        ),
1165        Ok(_) => DoctorCheck::fixable(
1166            check_id,
1167            title,
1168            format!("Image {image} is not present in Docker storage."),
1169            format!("Pull it with `docker pull {image}`, or run `mj doctor --json --smoke`."),
1170        ),
1171        Err(error) => DoctorCheck::fixable(
1172            check_id,
1173            title,
1174            format!("Could not inspect Docker image {image}: {error}"),
1175            format!("Make sure `docker info` succeeds, then run `docker pull {image}`."),
1176        ),
1177    }
1178}
1179
1180/// The outcome of the shared SSH connectivity probe.
1181///
1182/// Both SSH-backed checks run this first: an unreachable host makes every
1183/// later probe fail with a misleading message.
1184enum SshConnectivity {
1185    Reachable,
1186    Failed { detail: String, remediation: String },
1187}
1188
1189/// Probe `ssh <destination> true` and map any failure to a copy-paste fix.
1190///
1191/// Hel never generates keys, runs `ssh-copy-id`, or accepts a host key on the
1192/// user's behalf; it only says exactly which command would fix the failure.
1193fn ssh_connectivity(ssh: &RuntimeSshTarget, executor: &impl CommandExecutor) -> SshConnectivity {
1194    let destination = &ssh.destination;
1195    let command = ssh_connectivity_probe(ssh);
1196    match executor.execute(&command) {
1197        Err(error) => SshConnectivity::Failed {
1198            detail: format!("Could not run `ssh {destination} true`: {error:#}"),
1199            remediation: ssh_launch_failure_remediation(&error, ssh),
1200        },
1201        Ok(output) if output.status != 0 => {
1202            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
1203            SshConnectivity::Failed {
1204                detail: format!("`ssh {destination} true` failed: {stderr}"),
1205                remediation: ssh_failure_remediation(&stderr, ssh),
1206            }
1207        }
1208        Ok(_) => SshConnectivity::Reachable,
1209    }
1210}
1211
1212const SSH_MISSING_REMEDIATION: &str = "Install an OpenSSH client and put `ssh` on PATH: `sudo apt update && sudo apt install -y openssh-client` (Debian/Ubuntu) or `sudo dnf install -y openssh-clients` (Fedora).";
1213
1214/// What OpenSSH reported, as far as doctor needs to tell the cases apart.
1215///
1216/// OpenSSH is an external tool, so its wording is the only signal available.
1217/// This is the one place in doctor that reads it; everything downstream works
1218/// from the classification rather than the text.
1219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1220enum SshFailure {
1221    UntrustedHostKey,
1222    Unauthenticated,
1223    ClientMissing,
1224    /// The host answered nothing at all.
1225    Unreachable,
1226    Unrecognized,
1227}
1228
1229fn classify_ssh_stderr(stderr: &str) -> SshFailure {
1230    const UNTRUSTED_HOST_KEY: [&str; 3] = [
1231        "Host key verification failed",
1232        "No ECDSA host key is known",
1233        "REMOTE HOST IDENTIFICATION HAS CHANGED",
1234    ];
1235    const UNAUTHENTICATED: [&str; 4] = [
1236        "Permission denied",
1237        "Too many authentication failures",
1238        "no matching host key",
1239        "Authentication failed",
1240    ];
1241    const CLIENT_MISSING: [&str; 2] = ["ssh: command not found", "No such file or directory"];
1242    const UNREACHABLE: [&str; 3] = [
1243        "Connection timed out",
1244        "No route to host",
1245        "Network is unreachable",
1246    ];
1247
1248    let reported = |signatures: &[&str]| signatures.iter().any(|text| stderr.contains(text));
1249    if reported(&UNTRUSTED_HOST_KEY) {
1250        SshFailure::UntrustedHostKey
1251    } else if reported(&UNAUTHENTICATED) {
1252        SshFailure::Unauthenticated
1253    } else if reported(&CLIENT_MISSING) {
1254        SshFailure::ClientMissing
1255    } else if reported(&UNREACHABLE) {
1256        SshFailure::Unreachable
1257    } else {
1258        SshFailure::Unrecognized
1259    }
1260}
1261
1262/// Map a failure to run `ssh` at all (as opposed to `ssh` exiting nonzero)
1263/// to the command that fixes it.
1264fn ssh_launch_failure_remediation(error: &anyhow::Error, ssh: &RuntimeSshTarget) -> String {
1265    if error.downcast_ref::<CommandTimedOut>().is_some() {
1266        return ssh_unreachable_remediation(ssh);
1267    }
1268    let missing_binary = error.chain().any(|cause| {
1269        cause
1270            .downcast_ref::<std::io::Error>()
1271            .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
1272    });
1273    if missing_binary {
1274        return SSH_MISSING_REMEDIATION.to_owned();
1275    }
1276    format!(
1277        "Run `ssh {} true` by hand and resolve the error it reports: {error:#}",
1278        ssh.destination
1279    )
1280}
1281
1282/// The host answered nothing: it is asleep, behind a down VPN, or the cloud
1283/// session that exposes it has expired.
1284fn ssh_unreachable_remediation(ssh: &RuntimeSshTarget) -> String {
1285    let host = ssh_host_only(&ssh.destination);
1286    format!(
1287        "Check that {host} is up and reachable from this machine: wake it, bring up the VPN, or refresh the cloud session that exposes it, then run `ssh {} true` by hand.",
1288        ssh.destination
1289    )
1290}
1291
1292/// Map `ssh -o BatchMode=yes` stderr to the command that fixes it.
1293fn ssh_failure_remediation(stderr: &str, ssh: &RuntimeSshTarget) -> String {
1294    let destination = &ssh.destination;
1295    match classify_ssh_stderr(stderr) {
1296        SshFailure::UntrustedHostKey => {
1297            let host = ssh_host_only(destination);
1298            format!(
1299                "Add the host key with `ssh-keyscan -H {host} >> ~/.ssh/known_hosts`. Verify the fingerprint out of band before trusting it; if the key changed, remove the stale entry with `ssh-keygen -R {host}` first."
1300            )
1301        }
1302        SshFailure::Unauthenticated => match ssh_identity_file(ssh) {
1303            Some(identity) => format!(
1304                "Install your public key on the host with `ssh-copy-id -i {identity}.pub {destination}`."
1305            ),
1306            None => {
1307                format!("Install your public key on the host with `ssh-copy-id {destination}`.")
1308            }
1309        },
1310        SshFailure::ClientMissing => SSH_MISSING_REMEDIATION.to_owned(),
1311        SshFailure::Unreachable => ssh_unreachable_remediation(ssh),
1312        SshFailure::Unrecognized => {
1313            format!(
1314                "Run `ssh {destination} true` by hand and resolve the error it reports: {stderr}"
1315            )
1316        }
1317    }
1318}
1319
1320/// The host part of an OpenSSH destination, without any `user@` prefix.
1321fn ssh_host_only(destination: &str) -> &str {
1322    destination
1323        .rsplit_once('@')
1324        .map_or(destination, |(_, host)| host)
1325}
1326
1327/// The identity file provisioning passes, recovered from the built ssh args.
1328fn ssh_identity_file(ssh: &RuntimeSshTarget) -> Option<&str> {
1329    let position = ssh.ssh_args.iter().position(|arg| arg == "-i")?;
1330    ssh.ssh_args.get(position + 1).map(String::as_str)
1331}
1332
1333/// One check per `ssh-bare` target: can Hel reach the host noninteractively?
1334fn ssh_bare_checks(config: ConfigStatus<'_>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
1335    let Ok(config) = config else {
1336        return Vec::new();
1337    };
1338    config
1339        .targets
1340        .iter()
1341        .filter_map(|(id, target)| match target {
1342            TargetTemplate::SshBare { ssh, .. } => {
1343                Some(ssh_bare_check(id, &RuntimeSshTarget::from(ssh), executor))
1344            }
1345            _ => None,
1346        })
1347        .collect()
1348}
1349
1350fn ssh_bare_check(
1351    id: &str,
1352    ssh: &RuntimeSshTarget,
1353    executor: &impl CommandExecutor,
1354) -> DoctorCheck {
1355    let check_id = format!("runtime.ssh-bare.{id}");
1356    let title = format!("SSH access for target {id}");
1357    match ssh_connectivity(ssh, executor) {
1358        SshConnectivity::Reachable => DoctorCheck::ready(
1359            check_id,
1360            title,
1361            format!(
1362                "`ssh {} true` succeeds noninteractively from this host.",
1363                ssh.destination
1364            ),
1365        ),
1366        SshConnectivity::Failed {
1367            detail,
1368            remediation,
1369        } => DoctorCheck::fixable(check_id, title, detail, remediation),
1370    }
1371}
1372
1373/// Two checks per `ssh-podman` target: the same Podman probes run over SSH,
1374/// then the host limits that only bite under provisioning load.
1375fn ssh_podman_checks(
1376    config: ConfigStatus<'_>,
1377    executor: &impl CommandExecutor,
1378    smoke: bool,
1379) -> Vec<DoctorCheck> {
1380    let Ok(config) = config else {
1381        return Vec::new();
1382    };
1383    config
1384        .targets
1385        .iter()
1386        .flat_map(|(id, target)| match target {
1387            TargetTemplate::SshPodman { ssh, container, .. } => {
1388                let ssh = RuntimeSshTarget::from(ssh);
1389                let (check, reachable) =
1390                    ssh_podman_check(id, &ssh, &container.image, executor, smoke);
1391                let mut checks = vec![check];
1392                // An unreachable host has one problem, not two.
1393                if reachable {
1394                    checks.push(ssh_podman_limits_check(id, &ssh, executor));
1395                }
1396                checks
1397            }
1398            _ => Vec::new(),
1399        })
1400        .collect()
1401}
1402
1403/// The Podman check for one target, paired with whether the host answered SSH
1404/// at all: the caller skips its follow-up probes when it did not.
1405fn ssh_podman_check(
1406    id: &str,
1407    ssh: &RuntimeSshTarget,
1408    image: &str,
1409    executor: &impl CommandExecutor,
1410    smoke: bool,
1411) -> (DoctorCheck, bool) {
1412    let check_id = format!("runtime.ssh-podman.{id}");
1413    let title = format!("Remote Podman for target {id}");
1414    // Connectivity first: a remote Podman probe on an unreachable host reports
1415    // a Podman problem the user does not have.
1416    if let SshConnectivity::Failed {
1417        detail,
1418        remediation,
1419    } = ssh_connectivity(ssh, executor)
1420    {
1421        return (
1422            DoctorCheck::fixable(check_id, title, detail, remediation),
1423            false,
1424        );
1425    }
1426    (
1427        ssh_podman_runtime_check(check_id, title, ssh, image, executor, smoke),
1428        true,
1429    )
1430}
1431
1432/// The Podman half of the target's checks, on a host already known reachable.
1433fn ssh_podman_runtime_check(
1434    check_id: String,
1435    title: String,
1436    ssh: &RuntimeSshTarget,
1437    image: &str,
1438    executor: &impl CommandExecutor,
1439    smoke: bool,
1440) -> DoctorCheck {
1441    let destination = &ssh.destination;
1442    let preflight = match verify_ssh_podman(ssh, executor) {
1443        Ok(preflight) => preflight,
1444        Err(error) => {
1445            let detail = podman_failure_detail(&error);
1446            let remediation = match podman_remediation_match(&error) {
1447                Some(remediation) => {
1448                    format!("On {destination}: {remediation} See {PODMAN_DOCUMENTATION_URL}.")
1449                }
1450                None => format!(
1451                    "Verify `ssh {destination}` succeeds noninteractively from this host, then install rootless Podman 4.3 or newer there. See {PODMAN_DOCUMENTATION_URL}."
1452                ),
1453            };
1454            return DoctorCheck::fixable(check_id, title, detail, remediation);
1455        }
1456    };
1457    let linger_warning = preflight.warnings.first();
1458    if !smoke && let Some(warning) = linger_warning {
1459        return DoctorCheck::warning(
1460            check_id,
1461            title,
1462            format!(
1463                "Remote rootless Podman {} is available via {destination}, but {}",
1464                preflight.version, warning.detail
1465            ),
1466            &warning.remediation,
1467        );
1468    }
1469    if !smoke {
1470        return DoctorCheck::ready(
1471            check_id,
1472            title,
1473            format!(
1474                "Remote rootless Podman {} is available via {destination}. Run `mj doctor --json --smoke` to verify the image end to end.",
1475                preflight.version
1476            ),
1477        );
1478    }
1479
1480    let target = RuntimeTargetTemplate::SshPodman {
1481        ssh: ssh.clone(),
1482        container: RuntimeContainerTemplate {
1483            build_cache: None,
1484            image: image.to_owned(),
1485            pull_policy: Default::default(),
1486            extra_run_args: vec![],
1487            workspace_storage: Default::default(),
1488        },
1489    };
1490    match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1491        Ok(()) => match linger_warning {
1492            Some(warning) => DoctorCheck::warning(
1493                check_id,
1494                title,
1495                format!(
1496                    "Disposable run/exec/remove smoke test passed for image {image} on {destination}, but {}",
1497                    warning.detail
1498                ),
1499                &warning.remediation,
1500            ),
1501            None => DoctorCheck::ready(
1502                check_id,
1503                title,
1504                format!(
1505                    "Disposable run/exec/remove smoke test passed for image {image} on {destination}."
1506                ),
1507            ),
1508        },
1509        Err(error) => DoctorCheck::fixable(
1510            check_id,
1511            title,
1512            format!(
1513                "Disposable run/exec/remove smoke test failed for image {image} on {destination}: {error:#}"
1514            ),
1515            format!(
1516                "Fix the configured image or Podman runtime on {destination}, then run `mj doctor --json --smoke` again."
1517            ),
1518        ),
1519    }
1520}
1521
1522/// Host limits that cause provisioning failures under load, read on their own SSH
1523/// round trip so the provisioning preflight never pays for them.
1524///
1525/// Every crun container takes a session keyring, so `podman run` fails with
1526/// `crun: create keyring` once the login user's keyring quota is exhausted, and
1527/// sshd refuses new connections past `MaxStartups`. `sshd -T` needs root, so the
1528/// directive is read from the config files instead; drop-ins may be unreadable,
1529/// which the script reports rather than guessing.
1530const SSH_PODMAN_HOST_LIMITS_SCRIPT: &str = r#"
1531if [ -r /proc/sys/kernel/keys/maxkeys ]; then
1532    printf 'keys.max=%s\n' "$(cat /proc/sys/kernel/keys/maxkeys)"
1533fi
1534if [ -r /proc/key-users ]; then
1535    awk -v uid="$(id -u)" '
1536        { user = $1; sub(/:$/, "", user) }
1537        user == uid {
1538            split($4, quota, "/")
1539            printf "keys.used=%s\nkeys.quota=%s\n", quota[1], quota[2]
1540        }
1541    ' /proc/key-users
1542fi
1543unreadable=0
1544maxstartups=
1545# A drop-in directory that cannot be listed hides any override it holds.
1546if [ -d /etc/ssh/sshd_config.d ] && ! [ -r /etc/ssh/sshd_config.d ]; then
1547    unreadable=1
1548fi
1549for file in /etc/ssh/sshd_config /etc/ssh/sshd_config.d/*.conf; do
1550    [ -e "$file" ] || continue
1551    if [ -r "$file" ]; then
1552        match=$(grep -i '^[[:space:]]*maxstartups[[:space:]]' "$file" 2>/dev/null | tail -n 1)
1553        [ -n "$match" ] && maxstartups=$(printf '%s\n' "$match" | awk '{ print $2 }')
1554    else
1555        unreadable=1
1556    fi
1557done
1558[ -n "$maxstartups" ] && printf 'maxstartups=%s\n' "$maxstartups"
1559[ "$unreadable" = 1 ] && printf 'maxstartups.unreadable=1\n'
1560exit 0
1561"#;
1562
1563/// Keyring use at or above this share of the quota is reported as a warning:
1564/// the remaining headroom is a few concurrent containers, not a comfortable
1565/// margin.
1566const KEYRING_PRESSURE_PERCENT: u64 = 80;
1567
1568/// What `SSH_PODMAN_HOST_LIMITS_SCRIPT` managed to read. Every field is
1569/// optional: an unreadable file is reported, never guessed at.
1570#[derive(Debug, Default, PartialEq, Eq)]
1571struct HostLimits {
1572    keys_used: Option<u64>,
1573    keys_quota: Option<u64>,
1574    keys_max: Option<u64>,
1575    max_startups: Option<String>,
1576    max_startups_unreadable: bool,
1577}
1578
1579fn parse_host_limits(stdout: &[u8]) -> HostLimits {
1580    let text = String::from_utf8_lossy(stdout);
1581    let mut limits = HostLimits::default();
1582    for line in text.lines() {
1583        let Some((name, value)) = line.split_once('=') else {
1584            continue;
1585        };
1586        let value = value.trim();
1587        match name.trim() {
1588            "keys.used" => limits.keys_used = value.parse().ok(),
1589            "keys.quota" => limits.keys_quota = value.parse().ok(),
1590            "keys.max" => limits.keys_max = value.parse().ok(),
1591            "maxstartups" if !value.is_empty() => limits.max_startups = Some(value.to_owned()),
1592            "maxstartups.unreadable" => limits.max_startups_unreadable = value == "1",
1593            _ => {}
1594        }
1595    }
1596    limits
1597}
1598
1599impl HostLimits {
1600    /// True when the script produced nothing a reader could act on.
1601    fn is_empty(&self) -> bool {
1602        self.keys_used.is_none()
1603            && self.keys_quota.is_none()
1604            && self.keys_max.is_none()
1605            && self.max_startups.is_none()
1606            && !self.max_startups_unreadable
1607    }
1608
1609    fn keyring_is_under_pressure(&self) -> bool {
1610        match (self.keys_used, self.keys_quota) {
1611            (Some(used), Some(quota)) if quota > 0 => {
1612                used.saturating_mul(100) >= quota.saturating_mul(KEYRING_PRESSURE_PERCENT)
1613            }
1614            _ => false,
1615        }
1616    }
1617
1618    fn keyring_sentence(&self, destination: &str) -> String {
1619        match (self.keys_used, self.keys_quota) {
1620            (Some(used), Some(quota)) => {
1621                let system = match self.keys_max {
1622                    Some(max) => format!(", and `kernel.keys.maxkeys` is {max}"),
1623                    None => String::new(),
1624                };
1625                format!(
1626                    "The login user on {destination} holds {used} of its {quota} kernel keyring quota{system}."
1627                )
1628            }
1629            _ => format!(
1630                "The kernel keyring quota for the login user on {destination} could not be read."
1631            ),
1632        }
1633    }
1634
1635    fn max_startups_sentence(&self) -> String {
1636        match (&self.max_startups, self.max_startups_unreadable) {
1637            (Some(value), _) => format!("sshd MaxStartups is {value}."),
1638            (None, true) => "sshd MaxStartups is not set in a readable sshd_config file, so sshd's default applies unless an unreadable drop-in overrides it.".to_owned(),
1639            (None, false) => {
1640                "sshd MaxStartups is not set in sshd_config, so sshd's default applies.".to_owned()
1641            }
1642        }
1643    }
1644}
1645
1646/// Report the two host limits that made provisioning fail under load. The
1647/// target still works when they cannot be read, so an unreadable host is a
1648/// warning with a manual command, never a `fixable` runtime failure.
1649fn ssh_podman_limits_check(
1650    id: &str,
1651    ssh: &RuntimeSshTarget,
1652    executor: &impl CommandExecutor,
1653) -> DoctorCheck {
1654    let check_id = format!("runtime.ssh-podman.{id}.limits");
1655    let title = format!("Host limits for target {id}");
1656    let destination = &ssh.destination;
1657    let manual = || {
1658        format!(
1659            "Read them by hand on {destination}: `cat /proc/key-users /proc/sys/kernel/keys/maxkeys` and `grep -ri maxstartups /etc/ssh/sshd_config /etc/ssh/sshd_config.d`."
1660        )
1661    };
1662    let command = ssh_validation_command(
1663        ssh,
1664        vec![
1665            "sh".to_owned(),
1666            "-c".to_owned(),
1667            SSH_PODMAN_HOST_LIMITS_SCRIPT.to_owned(),
1668        ],
1669        "read ssh-podman host limits",
1670    );
1671    let limits = match executor.execute(&command) {
1672        Ok(output) if output.status == 0 => parse_host_limits(&output.stdout),
1673        Ok(output) => {
1674            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
1675            return DoctorCheck::warning(
1676                check_id,
1677                title,
1678                format!(
1679                    "Could not read the kernel keyring quota or sshd MaxStartups from {destination}: {stderr}"
1680                ),
1681                manual(),
1682            );
1683        }
1684        Err(error) => {
1685            return DoctorCheck::warning(
1686                check_id,
1687                title,
1688                format!(
1689                    "Could not read the kernel keyring quota or sshd MaxStartups from {destination}: {error}"
1690                ),
1691                manual(),
1692            );
1693        }
1694    };
1695    if limits.is_empty() {
1696        return DoctorCheck::warning(
1697            check_id,
1698            title,
1699            format!("{destination} reported no readable kernel keyring or sshd limits."),
1700            manual(),
1701        );
1702    }
1703    let detail = format!(
1704        "{} {}",
1705        limits.keyring_sentence(destination),
1706        limits.max_startups_sentence()
1707    );
1708    if limits.keyring_is_under_pressure() {
1709        return DoctorCheck::warning(
1710            check_id,
1711            title,
1712            format!(
1713                "{detail} Every container takes a session keyring, so `podman run` fails with `crun: create keyring` once the quota is gone."
1714            ),
1715            format!(
1716                "Raise `kernel.keys.maxkeys` and `kernel.keys.maxbytes` with sysctl on {destination}, and close finished sessions promptly."
1717            ),
1718        );
1719    }
1720    DoctorCheck::ready(check_id, title, detail)
1721}
1722
1723/// One check per `ssh-docker` target: Docker daemon, image, and optional
1724/// remote OverlayFS smoke test, all executed on the SSH host.
1725fn ssh_docker_checks(
1726    config: ConfigStatus<'_>,
1727    executor: &impl CommandExecutor,
1728    smoke: bool,
1729) -> Vec<DoctorCheck> {
1730    let Ok(config) = config else {
1731        return Vec::new();
1732    };
1733    config
1734        .targets
1735        .iter()
1736        .filter_map(|(id, target)| match target {
1737            TargetTemplate::SshDocker { ssh, container } => Some(ssh_docker_check(
1738                id,
1739                &RuntimeSshTarget::from(ssh),
1740                &container.image,
1741                executor,
1742                smoke,
1743            )),
1744            _ => None,
1745        })
1746        .collect()
1747}
1748
1749fn ssh_docker_check(
1750    id: &str,
1751    ssh: &RuntimeSshTarget,
1752    image: &str,
1753    executor: &impl CommandExecutor,
1754    smoke: bool,
1755) -> DoctorCheck {
1756    let check_id = format!("runtime.ssh-docker.{id}");
1757    let title = format!("Remote Docker for target {id}");
1758    let destination = &ssh.destination;
1759    if let SshConnectivity::Failed {
1760        detail,
1761        remediation,
1762    } = ssh_connectivity(ssh, executor)
1763    {
1764        return DoctorCheck::fixable(check_id, title, detail, remediation);
1765    }
1766
1767    let preflight = match verify_ssh_docker(ssh, executor) {
1768        Ok(preflight) => preflight,
1769        Err(error) => {
1770            let detail = format!("{error:#}");
1771            return DoctorCheck::fixable(
1772                check_id,
1773                title,
1774                detail,
1775                format!(
1776                    "Verify `ssh {destination}` succeeds noninteractively from this host, then install and start Docker Engine there; make sure `docker info` succeeds for the configured SSH user."
1777                ),
1778            );
1779        }
1780    };
1781
1782    if smoke {
1783        let target = RuntimeTargetTemplate::SshDocker {
1784            ssh: ssh.clone(),
1785            container: RuntimeContainerTemplate {
1786                build_cache: None,
1787                image: image.to_owned(),
1788                pull_policy: Default::default(),
1789                extra_run_args: vec![],
1790                workspace_storage: Default::default(),
1791            },
1792        };
1793        return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1794            Ok(()) => DoctorCheck::ready(
1795                check_id,
1796                title,
1797                format!(
1798                    "Remote Docker {} is available via {destination}; disposable run/exec/remove and remote OverlayFS attachment smoke test passed for image {image}.",
1799                    preflight.version
1800                ),
1801            ),
1802            Err(error) => DoctorCheck::fixable(
1803                check_id,
1804                title,
1805                format!(
1806                    "Disposable run/exec/remove smoke test failed for image {image} on {destination}: {error:#}"
1807                ),
1808                format!(
1809                    "Fix the configured image or Docker runtime on {destination}, then run `mj doctor --json --smoke` again."
1810                ),
1811            ),
1812        };
1813    }
1814
1815    let image_command = ssh_command(
1816        ssh,
1817        [
1818            "docker".to_owned(),
1819            "image".to_owned(),
1820            "inspect".to_owned(),
1821            image.to_owned(),
1822        ]
1823        .to_vec(),
1824    )
1825    .purpose("check remote Docker image presence");
1826    match executor.execute(&image_command) {
1827        Ok(output) if output.status == 0 => DoctorCheck::ready(
1828            check_id,
1829            title,
1830            format!(
1831                "Remote Docker {} is available via {destination}; image {image} is present. Run `mj doctor --json --smoke` to verify remote OverlayFS attachments.",
1832                preflight.version
1833            ),
1834        ),
1835        Ok(output) => DoctorCheck::fixable(
1836            check_id,
1837            title,
1838            format!(
1839                "Image {image} is not present in remote Docker storage on {destination}: {}",
1840                String::from_utf8_lossy(&output.stderr).trim()
1841            ),
1842            format!(
1843                "Pull it on {destination} with `ssh {destination} docker pull {image}`, or run `mj doctor --json --smoke`."
1844            ),
1845        ),
1846        Err(error) => DoctorCheck::fixable(
1847            check_id,
1848            title,
1849            format!("Could not inspect remote Docker image {image} on {destination}: {error}"),
1850            format!(
1851                "Verify `ssh {destination} docker info` succeeds, then pull {image} on that host."
1852            ),
1853        ),
1854    }
1855}
1856
1857/// Shared disposable-container identity for every doctor smoke test.
1858fn doctor_smoke_id() -> String {
1859    format!(
1860        "doctor-{}-{:x}",
1861        std::process::id(),
1862        SystemTime::now()
1863            .duration_since(UNIX_EPOCH)
1864            .unwrap_or_default()
1865            .as_nanos()
1866    )
1867}
1868
1869fn podman_remediation(error: &anyhow::Error) -> String {
1870    let fix = podman_remediation_match(error).unwrap_or(
1871        "Install Podman with `sudo apt update && sudo apt install -y podman uidmap` (Debian/Ubuntu) or `sudo dnf install -y podman shadow-utils` (Fedora).",
1872    );
1873    format!("{fix} See {PODMAN_DOCUMENTATION_URL}.")
1874}
1875
1876/// A Podman failure without its fix, which the check reports separately.
1877fn podman_failure_detail(error: &anyhow::Error) -> String {
1878    podman_probe_observation(error).map_or_else(|| format!("{error:#}"), str::to_owned)
1879}
1880
1881/// Map a Podman preflight failure to its specific remediation, if one applies.
1882///
1883/// The preflight reports which postcondition failed on the error itself, so
1884/// the fix is chosen from that probe rather than by matching the message text
1885/// this repository just produced. A failure that is not a probe result, such
1886/// as an unreachable SSH host, has no specific fix here.
1887fn podman_remediation_match(error: &anyhow::Error) -> Option<&'static str> {
1888    failed_podman_postcondition(error).map(PodmanPostcondition::remediation)
1889}
1890
1891const AWS_CLI_INSTALL_URL: &str =
1892    "https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html";
1893
1894/// One check per `aws-ec2` target: the AWS CLI, its credentials, and the
1895/// configured launch template.
1896fn aws_checks(config: ConfigStatus<'_>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
1897    let Ok(config) = config else {
1898        return Vec::new();
1899    };
1900    config
1901        .targets
1902        .iter()
1903        .filter_map(|(id, target)| match target {
1904            TargetTemplate::AwsEc2 {
1905                aws_profile,
1906                region,
1907                launch_template,
1908                ..
1909            } => Some(aws_target_check(
1910                id,
1911                aws_profile.as_deref(),
1912                region,
1913                launch_template,
1914                executor,
1915            )),
1916            _ => None,
1917        })
1918        .collect()
1919}
1920
1921/// The profile and region every AWS probe carries, applied exactly the way
1922/// provisioning applies them in `targets`.
1923fn aws_global_args<'a>(profile: Option<&'a str>, region: &'a str) -> Vec<String> {
1924    vec![
1925        "--profile".to_owned(),
1926        profile.unwrap_or("default").to_owned(),
1927        "--region".to_owned(),
1928        region.to_owned(),
1929    ]
1930}
1931
1932fn aws_target_check(
1933    id: &str,
1934    profile: Option<&str>,
1935    region: &str,
1936    launch_template: &str,
1937    executor: &impl CommandExecutor,
1938) -> DoctorCheck {
1939    let check_id = format!("runtime.aws-ec2.{id}");
1940    let title = format!("AWS EC2 target {id}");
1941    let profile_label = profile.unwrap_or("default");
1942
1943    let version = CommandSpec::new("aws", ["--version"]).purpose("check AWS CLI installation");
1944    match executor.execute(&version) {
1945        Err(error) => {
1946            return DoctorCheck::fixable(
1947                check_id,
1948                title,
1949                format!("The `aws` command is not available: {error}"),
1950                format!("Install the AWS CLI and put `aws` on PATH: {AWS_CLI_INSTALL_URL}"),
1951            );
1952        }
1953        Ok(output) if output.status != 0 => {
1954            return DoctorCheck::fixable(
1955                check_id,
1956                title,
1957                format!(
1958                    "`aws --version` failed: {}",
1959                    String::from_utf8_lossy(&output.stderr).trim()
1960                ),
1961                format!("Reinstall the AWS CLI: {AWS_CLI_INSTALL_URL}"),
1962            );
1963        }
1964        Ok(_) => {}
1965    }
1966
1967    let mut identity_args = aws_global_args(profile, region);
1968    identity_args.extend(["sts".to_owned(), "get-caller-identity".to_owned()]);
1969    identity_args.extend(["--output".to_owned(), "json".to_owned()]);
1970    let identity =
1971        CommandSpec::new("aws", identity_args).purpose("check AWS credentials for a doctor target");
1972    match executor.execute(&identity) {
1973        Err(error) => {
1974            return DoctorCheck::fixable(
1975                check_id,
1976                title,
1977                format!("Could not run `aws sts get-caller-identity`: {error}"),
1978                format!(
1979                    "Configure credentials with `aws configure --profile {profile_label}`, or sign in with `aws sso login --profile {profile_label}`."
1980                ),
1981            );
1982        }
1983        Ok(output) if output.status != 0 => {
1984            return DoctorCheck::fixable(
1985                check_id,
1986                title,
1987                format!(
1988                    "AWS credentials for profile {profile_label} are not usable: {}",
1989                    String::from_utf8_lossy(&output.stderr).trim()
1990                ),
1991                format!(
1992                    "Configure credentials with `aws configure --profile {profile_label}`, or sign in with `aws sso login --profile {profile_label}`."
1993                ),
1994            );
1995        }
1996        Ok(_) => {}
1997    }
1998
1999    // Launch templates are addressed by id when they carry the `lt-` prefix
2000    // and by name otherwise, the same split provisioning uses.
2001    let by_id = launch_template.starts_with("lt-");
2002    let mut template_args = aws_global_args(profile, region);
2003    template_args.extend(["ec2".to_owned(), "describe-launch-templates".to_owned()]);
2004    template_args.extend([
2005        if by_id {
2006            "--launch-template-ids".to_owned()
2007        } else {
2008            "--launch-template-names".to_owned()
2009        },
2010        launch_template.to_owned(),
2011    ]);
2012    template_args.extend(["--output".to_owned(), "json".to_owned()]);
2013    let template =
2014        CommandSpec::new("aws", template_args).purpose("check the configured AWS launch template");
2015    let template_remediation = format!(
2016        "Create the launch template in {region}, or point this target at an existing one; `aws --profile {profile_label} --region {region} ec2 describe-launch-templates` lists them."
2017    );
2018    match executor.execute(&template) {
2019        Err(error) => DoctorCheck::fixable(
2020            check_id,
2021            title,
2022            format!("Could not query launch template {launch_template}: {error}"),
2023            template_remediation,
2024        ),
2025        Ok(output) if output.status != 0 => DoctorCheck::fixable(
2026            check_id,
2027            title,
2028            format!(
2029                "Launch template {launch_template} was not found in {region}: {}",
2030                String::from_utf8_lossy(&output.stderr).trim()
2031            ),
2032            template_remediation,
2033        ),
2034        Ok(_) => DoctorCheck::ready(
2035            check_id,
2036            title,
2037            format!(
2038                "The AWS CLI is installed, profile {profile_label} has valid credentials, and launch template {launch_template} exists in {region}."
2039            ),
2040        ),
2041    }
2042}
2043
2044/// Whether the running daemon is this build.
2045///
2046/// Two Mjolnir builds carry the same version string, so the version line in
2047/// `mj daemon status` cannot answer it. A daemon left over from before a
2048/// rebuild keeps serving the old code, and, when its executable was unlinked
2049/// by the rebuild, also loses every portable worker source it would have
2050/// pinned. Both are invisible without this check.
2051fn daemon_build_check() -> DoctorCheck {
2052    const ID: &str = "daemon.build";
2053    const TITLE: &str = "Daemon build";
2054    let Ok(metadata) = mj_client::daemon::read_metadata_any() else {
2055        return DoctorCheck::ready(
2056            ID,
2057            TITLE,
2058            "No Mjolnir daemon is running; the next command starts one from this build.",
2059        );
2060    };
2061    let pid = metadata.pid;
2062    match mj_client::executable::process_runs_this_executable(pid) {
2063        Ok(Some(true)) => DoctorCheck::ready(
2064            ID,
2065            TITLE,
2066            format!(
2067                "Daemon {pid} runs this build (version {}).",
2068                metadata.build_version
2069            ),
2070        ),
2071        Ok(Some(false)) => DoctorCheck::warning(
2072            ID,
2073            TITLE,
2074            format!(
2075                "{}. Two builds can report the same version, so the files are what tell them apart. Code rebuilt since that daemon started is not running.",
2076                mj_client::executable::describe_running_daemon_and_client_builds(
2077                    pid,
2078                    &metadata.build_version,
2079                ),
2080            ),
2081            "Run `mj daemon restart` from this build. It now fails rather than reporting success if another client's build wins.",
2082        ),
2083        Ok(None) => DoctorCheck::ready(
2084            ID,
2085            TITLE,
2086            format!(
2087                "Daemon {pid} is recorded but not running; the next command starts one from this build."
2088            ),
2089        ),
2090        Err(error) => DoctorCheck::warning(
2091            ID,
2092            TITLE,
2093            format!("Could not tell which build daemon {pid} runs: {error:#}"),
2094            "Run `mj daemon restart` from this build if rebuilt code is not taking effect.",
2095        ),
2096    }
2097}
2098
2099/// Whether a new session would run the worker binary as it is on disk now.
2100///
2101/// The daemon copies each worker it can find into a content-addressed cache
2102/// when it starts and serves that copy for the rest of its life, so rebuilding
2103/// `mj-worker` does not reach a running daemon. Nothing else reports this, and
2104/// the digests are what make it checkable at all: two worker builds differ by
2105/// content, not by name or version.
2106fn worker_freshness_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
2107    let mut checks = Vec::new();
2108    let daemon = mj_client::daemon::read_metadata_any()
2109        .ok()
2110        .filter(|metadata| mj_client::daemon::process_is_alive(metadata.pid));
2111    let pinned = pinned_worker_digests();
2112    let mut sources: Vec<(String, Result<WorkerBinaryAvailability>)> = vec![(
2113        "this host".to_owned(),
2114        crate::controller::native_worker_binary_prerequisite(),
2115    )];
2116    for arch in container_worker_architectures(config) {
2117        sources.push((
2118            format!("{arch} Linux targets"),
2119            worker_binary_prerequisite_for_arch(&arch),
2120        ));
2121    }
2122    for (label, availability) in sources {
2123        let id = format!("worker.freshness.{}", label.replace(' ', "-"));
2124        let title = format!("Worker binary for {label}");
2125        let path = match availability {
2126            Ok(WorkerBinaryAvailability::Local { path, .. }) => path,
2127            // A remote worker is fetched by digest when a target is
2128            // provisioned, so it cannot go stale behind a running daemon.
2129            Ok(WorkerBinaryAvailability::Remote { .. }) => continue,
2130            Err(error) => {
2131                checks.push(DoctorCheck::unsupported(
2132                    id,
2133                    title,
2134                    format!("No worker binary resolves for {label}: {error:#}"),
2135                ));
2136                continue;
2137            }
2138        };
2139        let digest = mj_core::worker_launch::worker_executable_digest(&path)
2140            .unwrap_or_else(|error| format!("unreadable ({error:#})"));
2141        let pinned_note = if pinned.is_empty() {
2142            "the daemon has pinned no worker".to_owned()
2143        } else if pinned.contains(&digest) {
2144            "this content is in the daemon's pinned worker cache".to_owned()
2145        } else {
2146            format!(
2147                "the pinned worker cache holds {} instead",
2148                pinned.join(", ")
2149            )
2150        };
2151        let detail = format!("{} has digest {digest}; {pinned_note}.", path.display());
2152        let Some(metadata) = daemon.as_ref() else {
2153            checks.push(DoctorCheck::ready(
2154                id,
2155                title,
2156                format!("{detail} No daemon is running, so the next session uses this file."),
2157            ));
2158            continue;
2159        };
2160        match worker_changed_since_daemon_start(&path, &metadata.started_at) {
2161            Ok(true) => checks.push(DoctorCheck::warning(
2162                id,
2163                title,
2164                format!("{detail} It was rebuilt after daemon {} started, which froze the copy it serves.", metadata.pid),
2165                "Run `mj daemon restart` so new sessions use the rebuilt worker. Sessions already running keep their worker until they are quiet enough to be upgraded.",
2166            )),
2167            Ok(false) => checks.push(DoctorCheck::ready(id, title, detail)),
2168            Err(error) => checks.push(DoctorCheck::warning(
2169                id,
2170                title,
2171                format!("{detail} Could not compare it with the daemon's start time: {error:#}"),
2172                "Run `mj daemon restart` if rebuilt worker code is not taking effect.",
2173            )),
2174        }
2175    }
2176    checks
2177}
2178
2179/// The architectures this configuration needs a portable Linux worker for.
2180fn container_worker_architectures(config: ConfigStatus<'_>) -> Vec<String> {
2181    let Ok(config) = config else {
2182        return Vec::new();
2183    };
2184    let mut architectures = Vec::new();
2185    for target in config.targets.values() {
2186        let container = match target {
2187            TargetTemplate::LocalPodman { container }
2188            | TargetTemplate::LocalDocker { container }
2189            | TargetTemplate::AppleContainer { container }
2190            | TargetTemplate::SshPodman { container, .. }
2191            | TargetTemplate::SshDocker { container, .. } => container,
2192            _ => continue,
2193        };
2194        let arch = container
2195            .platform
2196            .as_deref()
2197            .and_then(|platform| platform.rsplit('/').next())
2198            .map_or_else(
2199                || std::env::consts::ARCH.to_owned(),
2200                normalized_worker_architecture,
2201            );
2202        if !architectures.contains(&arch) {
2203            architectures.push(arch);
2204        }
2205    }
2206    architectures
2207}
2208
2209/// Container platforms name architectures the way Docker does; worker files
2210/// are named the way Rust target triples do.
2211fn normalized_worker_architecture(platform_arch: &str) -> String {
2212    match platform_arch {
2213        "amd64" => "x86_64".to_owned(),
2214        "arm64" => "aarch64".to_owned(),
2215        other => other.to_owned(),
2216    }
2217}
2218
2219/// The digests the daemon's immutable worker cache holds.
2220///
2221/// The cache is never pruned, so this is what any daemon on this machine has
2222/// pinned at some point, which is why it is reported rather than judged.
2223fn pinned_worker_digests() -> Vec<String> {
2224    let root = mj_core::config::data_dir().join("workers").join("pinned");
2225    let Ok(entries) = std::fs::read_dir(root) else {
2226        return Vec::new();
2227    };
2228    let mut digests: Vec<String> = entries
2229        .flatten()
2230        .filter(|entry| entry.path().is_dir())
2231        .filter_map(|entry| entry.file_name().into_string().ok())
2232        .collect();
2233    digests.sort();
2234    digests
2235}
2236
2237/// Whether a worker file was written after the daemon started.
2238///
2239/// The daemon copies the file it finds at startup, so a later modification is
2240/// exactly the case where the running daemon serves older content.
2241fn worker_changed_since_daemon_start(path: &Path, started_at: &str) -> Result<bool> {
2242    let started: SystemTime = chrono::DateTime::parse_from_rfc3339(started_at)
2243        .map_err(|error| anyhow::anyhow!("parse daemon start time {started_at:?}: {error}"))?
2244        .into();
2245    let modified = std::fs::metadata(path)?.modified()?;
2246    Ok(modified > started)
2247}
2248
2249/// The targets the build-cache and worker-binary checks cover: every target
2250/// the user configured, plus each standard local target
2251/// ([`Config::with_local_targets`]) whose engine check in `engine_checks`
2252/// passed. That is the set a new session can use. With no target blocks in
2253/// config.toml, the built-in `podman` target on a host with working Podman
2254/// still needs a worker (launch finding R5-2). A standard target whose
2255/// engine is unavailable is shown as unavailable and needs none.
2256fn offered_targets<'a>(
2257    config: &Config,
2258    engine_checks: impl IntoIterator<Item = &'a DoctorCheck>,
2259) -> Config {
2260    let ready = engine_checks
2261        .into_iter()
2262        .filter(|check| check.status == CheckStatus::Ready)
2263        .map(|check| check.id.as_str())
2264        .collect::<std::collections::BTreeSet<_>>();
2265    let engine_ready = |target_id: &str| match target_id {
2266        "podman" => ready.contains("runtime.podman"),
2267        "docker" => ready.contains("runtime.docker"),
2268        "apple-container" => ready.contains("runtime.apple-container"),
2269        _ => true,
2270    };
2271    let mut offered = config.clone().with_local_targets();
2272    offered
2273        .targets
2274        .retain(|id, _| config.configures_target(id) || engine_ready(id));
2275    offered
2276}
2277
2278fn worker_binary_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
2279    let config = match config {
2280        Ok(config) => config,
2281        Err(ConfigGap::NewerVersion(version)) => {
2282            return vec![newer_config_skip(
2283                "worker.containers",
2284                "Container worker binary",
2285                version,
2286            )];
2287        }
2288        Err(gap @ (ConfigGap::Missing | ConfigGap::Unreadable)) => {
2289            return vec![DoctorCheck::fixable(
2290                "worker.containers",
2291                "Container worker binary",
2292                format!(
2293                    "Worker availability cannot be checked until {}.",
2294                    gap.awaited()
2295                ),
2296                gap.remediation(),
2297            )];
2298        }
2299    };
2300    let containers = config
2301        .targets
2302        .iter()
2303        .filter_map(|(id, target)| match target {
2304            TargetTemplate::LocalPodman { container }
2305            | TargetTemplate::LocalDocker { container }
2306            | TargetTemplate::AppleContainer { container } => Some((id, container, None)),
2307            TargetTemplate::SshPodman { container, .. } => {
2308                Some((id, container, Some("ssh-podman")))
2309            }
2310            TargetTemplate::SshDocker { container, .. } => {
2311                Some((id, container, Some("ssh-docker")))
2312            }
2313            _ => None,
2314        })
2315        .collect::<Vec<_>>();
2316    if containers.is_empty() {
2317        return vec![DoctorCheck::unsupported(
2318            "worker.containers",
2319            "Container worker binary",
2320            "No container target is configured.",
2321        )];
2322    }
2323    containers
2324        .into_iter()
2325        .map(|(id, container, remote_kind)| {
2326            if let Some(remote_kind) = remote_kind
2327                && container.platform.is_none()
2328            {
2329                // The remote CPU architecture is only observable once the host
2330                // is reachable, so an explicit `platform` is required here.
2331                return DoctorCheck::unsupported(
2332                    format!("worker.{id}"),
2333                    format!("Container worker binary for target {id}"),
2334                    format!(
2335                        "Set `platform` on this {remote_kind} target to check its worker binary; the remote architecture is unknown until provisioning."
2336                    ),
2337                );
2338            }
2339            worker_binary_check(id, container)
2340        })
2341        .collect()
2342}
2343
2344fn worker_binary_check(id: &str, container: &ContainerTemplate) -> DoctorCheck {
2345    let title = format!("Container worker binary for target {id}");
2346    let arch = match container_architecture(container.platform.as_deref()) {
2347        Ok(arch) => arch,
2348        Err(reason) => {
2349            return DoctorCheck::unsupported(format!("worker.{id}"), title, reason);
2350        }
2351    };
2352    let triple = format!("{arch}-unknown-linux-musl");
2353    match worker_binary_prerequisite_for_arch(arch) {
2354        Ok(WorkerBinaryAvailability::Local { path, source }) => DoctorCheck::ready(
2355            format!("worker.{id}"),
2356            title,
2357            format!(
2358                "{triple} worker is available from {source}: {}",
2359                path.display()
2360            ),
2361        ),
2362        Ok(WorkerBinaryAvailability::Remote { url, .. }) => DoctorCheck::ready(
2363            format!("worker.{id}"),
2364            title,
2365            format!("{triple} worker will be verified and downloaded from {url} when needed."),
2366        ),
2367        Err(error) => DoctorCheck::fixable(
2368            format!("worker.{id}"),
2369            title,
2370            format!("No usable {triple} worker source: {error:#}"),
2371            format!(
2372                "Build it with `cargo build --release --target {triple} -p brokk-mj-worker --bin mj-worker`, install `mj-worker-{triple}` beside `mj`, or set MJ_WORKER_BINARY, MJ_WORKER_DIR, or MJ_WORKER_URL with MJ_WORKER_SHA256."
2373            ),
2374        ),
2375    }
2376}
2377
2378fn container_architecture(platform: Option<&str>) -> std::result::Result<&'static str, String> {
2379    let candidate = platform.unwrap_or(std::env::consts::ARCH);
2380    let candidate = candidate
2381        .split('/')
2382        .rev()
2383        .find(|part| matches!(*part, "x86_64" | "amd64" | "aarch64" | "arm64"))
2384        .unwrap_or(candidate);
2385    match candidate {
2386        "x86_64" | "amd64" => Ok("x86_64"),
2387        "aarch64" | "arm64" => Ok("aarch64"),
2388        other => Err(format!(
2389            "Container architecture {other:?} is unsupported; Mjolnir supports x86_64 and aarch64 Linux workers."
2390        )),
2391    }
2392}
2393
2394fn apple_container_image(config: ConfigStatus<'_>) -> String {
2395    config
2396        .ok()
2397        .and_then(|config| {
2398            config.targets.values().find_map(|target| match target {
2399                TargetTemplate::AppleContainer { container } => Some(container.image.clone()),
2400                _ => None,
2401            })
2402        })
2403        .unwrap_or_else(|| DEFAULT_CONTAINER_IMAGE.into())
2404}
2405
2406pub fn apple_container_check(
2407    platform: &ApplePlatform,
2408    executor: &impl CommandExecutor,
2409    smoke: bool,
2410    image: String,
2411) -> DoctorCheck {
2412    match platform {
2413        ApplePlatform::Linux => {
2414            return DoctorCheck::unsupported(
2415                "runtime.apple-container",
2416                "Apple container runtime",
2417                "macOS only",
2418            );
2419        }
2420        ApplePlatform::Other(current) => {
2421            return DoctorCheck::unsupported(
2422                "runtime.apple-container",
2423                "Apple container runtime",
2424                format!("macOS only (current platform: {current})"),
2425            );
2426        }
2427        ApplePlatform::Macos {
2428            architecture,
2429            major_version,
2430        } if architecture != "aarch64" && architecture != "arm64" => {
2431            return DoctorCheck::unsupported(
2432                "runtime.apple-container",
2433                "Apple container runtime",
2434                "Apple container requires Apple silicon; Intel Macs are unsupported.",
2435            );
2436        }
2437        ApplePlatform::Macos { major_version, .. } if *major_version < 26 => {
2438            return DoctorCheck::unsupported(
2439                "runtime.apple-container",
2440                "Apple container runtime",
2441                format!("Apple container requires macOS 26 or newer (found {major_version})."),
2442            );
2443        }
2444        ApplePlatform::Macos { .. } => {}
2445    }
2446
2447    let daemon = apple_container_daemon_check(executor);
2448    if daemon.status != CheckStatus::Ready {
2449        return daemon;
2450    }
2451
2452    if !smoke {
2453        return DoctorCheck::fixable(
2454            "runtime.apple-container",
2455            "Apple container runtime",
2456            "The daemon is running, but the required disposable smoke test was not requested.",
2457            "Run `mj doctor --json --smoke`.",
2458        );
2459    }
2460
2461    let target = RuntimeTargetTemplate::AppleContainer(RuntimeContainerTemplate {
2462        build_cache: None,
2463        image,
2464        pull_policy: Default::default(),
2465        extra_run_args: vec![],
2466        workspace_storage: Default::default(),
2467    });
2468    match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
2469        Ok(()) => DoctorCheck::ready(
2470            "runtime.apple-container",
2471            "Apple container runtime",
2472            "Installed, daemon running, and disposable run/exec/remove smoke test passed.",
2473        ),
2474        Err(error) => DoctorCheck::fixable(
2475            "runtime.apple-container",
2476            "Apple container runtime",
2477            format!("Disposable run/exec/remove smoke test failed: {error:#}"),
2478            "Fix the configured image or container runtime, then run `mj doctor --json --smoke` again.",
2479        ),
2480    }
2481}
2482
2483/// Probe that the Apple `container` command is installed and its daemon is
2484/// running, phrased as a doctor check.
2485///
2486/// Split out of [`apple_container_check`] so `mj setup` can reuse the same
2487/// probes and remediation text without also demanding the opt-in smoke test.
2488/// The caller is responsible for platform gating.
2489pub fn apple_container_daemon_check(executor: &impl CommandExecutor) -> DoctorCheck {
2490    let installed =
2491        CommandSpec::new("container", ["--version"]).purpose("check Apple container installation");
2492    match executor.execute(&installed) {
2493        Err(error) => {
2494            return DoctorCheck::fixable(
2495                "runtime.apple-container",
2496                "Apple container runtime",
2497                format!("The `container` command is not available: {error}"),
2498                format!("Install the official signed package: {APPLE_CONTAINER_INSTALL_URL}"),
2499            );
2500        }
2501        Ok(output) if output.status != 0 => {
2502            return DoctorCheck::fixable(
2503                "runtime.apple-container",
2504                "Apple container runtime",
2505                format!(
2506                    "The installed `container --version` command failed: {}",
2507                    String::from_utf8_lossy(&output.stderr).trim()
2508                ),
2509                format!("Reinstall the official signed package: {APPLE_CONTAINER_INSTALL_URL}"),
2510            );
2511        }
2512        Ok(_) => {}
2513    }
2514
2515    let status =
2516        CommandSpec::new("container", ["system", "status"]).purpose("check Apple container daemon");
2517    match executor.execute(&status) {
2518        Ok(output) if output.status == 0 => DoctorCheck::ready(
2519            "runtime.apple-container",
2520            "Apple container runtime",
2521            "Installed, and the Apple container daemon is running.",
2522        ),
2523        Ok(output) => DoctorCheck::fixable(
2524            "runtime.apple-container",
2525            "Apple container runtime",
2526            format!(
2527                "The Apple container daemon is stopped: {}",
2528                String::from_utf8_lossy(&output.stderr).trim()
2529            ),
2530            "Run `container system start`.",
2531        ),
2532        Err(error) => DoctorCheck::fixable(
2533            "runtime.apple-container",
2534            "Apple container runtime",
2535            format!("Could not query the Apple container daemon: {error}"),
2536            "Run `container system start`.",
2537        ),
2538    }
2539}
2540
2541pub fn current_apple_platform(executor: &impl CommandExecutor) -> ApplePlatform {
2542    if cfg!(target_os = "linux") {
2543        return ApplePlatform::Linux;
2544    }
2545    if !cfg!(target_os = "macos") {
2546        return ApplePlatform::Other(std::env::consts::OS.into());
2547    }
2548    let major_version = executor
2549        .execute(&CommandSpec::new("sw_vers", ["-productVersion"]).purpose("detect macOS version"))
2550        .ok()
2551        .filter(|output| output.status == 0)
2552        .and_then(|output| {
2553            String::from_utf8(output.stdout)
2554                .ok()
2555                .and_then(|value| value.trim().split('.').next()?.parse().ok())
2556        })
2557        .unwrap_or(0);
2558    ApplePlatform::Macos {
2559        architecture: std::env::consts::ARCH.into(),
2560        major_version,
2561    }
2562}
2563
2564#[cfg(test)]
2565mod tests;
2566
2567/// What one repository still holds from a Mjolnir review capture.
2568#[derive(Debug, Default, PartialEq, Eq)]
2569pub(crate) struct ReviewResidue {
2570    /// `refs/hel/*` refs in the repository.
2571    pub refs: Vec<String>,
2572    /// Scratch index files left in the Git directory by an interrupted capture.
2573    pub scratch_indexes: Vec<PathBuf>,
2574}
2575
2576impl ReviewResidue {
2577    fn is_empty(&self) -> bool {
2578        self.refs.is_empty() && self.scratch_indexes.is_empty()
2579    }
2580}
2581
2582/// Read what a repository still holds from Mjolnir's review captures.
2583///
2584/// Releases before this one staged the whole working tree into the user's own
2585/// object store and pinned it with two refs, and a capture that was killed
2586/// partway left its scratch index behind. Both are the user's to remove, so
2587/// this only reads.
2588pub(crate) fn review_residue(repository: &Path) -> ReviewResidue {
2589    let mut residue = ReviewResidue::default();
2590    let git_dir = repository.join(".git");
2591    if !git_dir.exists() {
2592        return residue;
2593    }
2594    for reference in ["review-baseline", "review-capture"] {
2595        if git_dir.join("refs/hel").join(reference).is_file() {
2596            residue.refs.push(format!("refs/hel/{reference}"));
2597        }
2598    }
2599    // A packed ref survives `git pack-refs`, which a `git gc` runs.
2600    if let Ok(packed) = std::fs::read_to_string(git_dir.join("packed-refs")) {
2601        for line in packed.lines() {
2602            if let Some((_, reference)) = line.split_once(' ')
2603                && reference.starts_with("refs/hel/")
2604                && !residue.refs.iter().any(|known| known == reference)
2605            {
2606                residue.refs.push(reference.to_owned());
2607            }
2608        }
2609    }
2610    if let Ok(entries) = std::fs::read_dir(&git_dir) {
2611        for entry in entries.filter_map(Result::ok) {
2612            if entry
2613                .file_name()
2614                .to_str()
2615                .is_some_and(|name| name.starts_with("hel-review-index-"))
2616            {
2617                residue.scratch_indexes.push(entry.path());
2618            }
2619        }
2620    }
2621    residue.refs.sort();
2622    residue.scratch_indexes.sort();
2623    residue
2624}
2625
2626/// The repositories a person works in by hand, where review leftovers are
2627/// worth reporting.
2628///
2629/// These are the configured bundle repositories and the directories sessions
2630/// started with `--project-directory` use. A session's own managed checkout
2631/// (`<repository>/.mj/clones/<id>` or `<repository>/.mj/worktrees/<id>`) is
2632/// Mjolnir's working state, not a leftover, so it is never reported. Neither is
2633/// a repository a live session is working in, because its refs are in use; a
2634/// linked worktree shares its refs with the repository it came from, so a live
2635/// worktree session keeps that repository out too.
2636pub(crate) fn review_residue_repositories(
2637    configured: impl IntoIterator<Item = PathBuf>,
2638    sessions: &[&mj_core::state::SessionRecord],
2639) -> Vec<PathBuf> {
2640    use mj_core::state::ManagedCheckoutKind;
2641
2642    let managed_roots = sessions
2643        .iter()
2644        .filter_map(|session| session.managed_worktree.as_ref())
2645        .map(|worktree| worktree.worktree_root.as_path())
2646        .collect::<Vec<_>>();
2647    let mut in_use = Vec::new();
2648    for session in sessions.iter().filter(|session| session.state.is_active()) {
2649        if let Some(directory) = &session.project_directory {
2650            in_use.push(directory.as_path());
2651        }
2652        if let Some(worktree) = &session.managed_worktree
2653            && worktree.kind == ManagedCheckoutKind::Worktree
2654        {
2655            in_use.push(worktree.source_repository.as_path());
2656        }
2657    }
2658    let mut repositories = configured
2659        .into_iter()
2660        .chain(
2661            sessions
2662                .iter()
2663                .filter_map(|session| session.project_directory.clone()),
2664        )
2665        .filter(|repository| {
2666            !is_inside_managed_checkout(repository)
2667                && !managed_roots
2668                    .iter()
2669                    .any(|root| repository.starts_with(root))
2670                && !in_use.iter().any(|directory| repository == directory)
2671        })
2672        .collect::<Vec<_>>();
2673    repositories.sort();
2674    repositories.dedup();
2675    repositories
2676}
2677
2678/// Whether `path` is at or under `<repository>/.mj/clones/<id>` or
2679/// `<repository>/.mj/worktrees/<id>`.
2680fn is_inside_managed_checkout(path: &Path) -> bool {
2681    let components = path
2682        .components()
2683        .map(|component| component.as_os_str())
2684        .collect::<Vec<_>>();
2685    components
2686        .windows(3)
2687        .any(|window| window[0] == ".mj" && (window[1] == "clones" || window[1] == "worktrees"))
2688}
2689
2690/// Report Mjolnir's own leftovers in the repositories the configuration names.
2691///
2692/// This deletes nothing. Removing refs and running `git gc` in someone else's
2693/// repository without asking is the same mistake as writing to it without
2694/// asking, which is what left this residue in the first place.
2695fn review_residue_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
2696    let Ok(config) = config else {
2697        return Vec::new();
2698    };
2699    let configured = config
2700        .bundles
2701        .values()
2702        .flat_map(|bundle| bundle.repositories.iter())
2703        .filter_map(|repository| repository.local.clone());
2704    // A daemon-less machine has no session database, which is not a reason to
2705    // skip the configured repositories.
2706    let state = crate::database::load_state().ok();
2707    let sessions = state
2708        .as_ref()
2709        .map(|state| state.sessions.values().collect::<Vec<_>>())
2710        .unwrap_or_default();
2711    let repositories = review_residue_repositories(configured, &sessions);
2712    if repositories.is_empty() {
2713        return Vec::new();
2714    }
2715    let found = repositories
2716        .into_iter()
2717        .map(|repository| {
2718            let residue = review_residue(&repository);
2719            (repository, residue)
2720        })
2721        .filter(|(_, residue)| !residue.is_empty())
2722        .collect::<Vec<_>>();
2723    if found.is_empty() {
2724        return vec![DoctorCheck::ready(
2725            "review.residue",
2726            "Review leftovers in your repositories",
2727            "No Mjolnir refs or scratch index files were found in the configured repositories.",
2728        )];
2729    }
2730    let detail = found
2731        .iter()
2732        .map(|(repository, residue)| {
2733            let mut parts = Vec::new();
2734            if !residue.refs.is_empty() {
2735                parts.push(residue.refs.join(", "));
2736            }
2737            if !residue.scratch_indexes.is_empty() {
2738                parts.push(mj_core::text::counted(
2739                    residue.scratch_indexes.len(),
2740                    "leftover scratch index file",
2741                    "leftover scratch index files",
2742                ));
2743            }
2744            format!("{}: {}", repository.display(), parts.join("; "))
2745        })
2746        .collect::<Vec<_>>()
2747        .join(". ");
2748    let commands = found
2749        .iter()
2750        .flat_map(|(repository, residue)| {
2751            let repository = repository.display().to_string();
2752            let mut commands = residue
2753                .refs
2754                .iter()
2755                .map(|reference| format!("git -C {repository} update-ref -d {reference}"))
2756                .collect::<Vec<_>>();
2757            commands.extend(
2758                residue
2759                    .scratch_indexes
2760                    .iter()
2761                    .map(|index| format!("rm -f {}", index.display())),
2762            );
2763            commands.push(format!("git -C {repository} gc --prune=now"));
2764            commands
2765        })
2766        .collect::<Vec<_>>()
2767        .join("\n");
2768    vec![DoctorCheck::fixable(
2769        "review.residue",
2770        "Review leftovers in your repositories",
2771        format!("Mjolnir left these in repositories it does not own: {detail}."),
2772        format!("Remove them yourself when you are ready:\n{commands}"),
2773    )]
2774}