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