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