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 !harness_is_authenticated_with_executor(profile, executor) {
680        return DoctorCheck::fixable(
681            format!("harness.{id}"),
682            title,
683            format!(
684                "No usable authentication was detected for {}",
685                profile.home.display()
686            ),
687            harness_login_remediation(id, profile),
688        );
689    }
690    DoctorCheck::ready(
691        format!("harness.{id}"),
692        title,
693        format!(
694            "{} is present and authentication is available",
695            profile.home.display()
696        ),
697    )
698}
699
700/// One sentence saying what a profile is: its harness, where its quota comes
701/// from, and whether other sessions' sub-agents may use it. Quota ranking and
702/// delegation both depend on these, and none of them shows in the profile's
703/// own table in config.toml.
704fn profile_summary(config: &Config, id: &str, profile: &HarnessProfile) -> String {
705    let delegation = if config
706        .subagents
707        .eligible_profiles
708        .get(id)
709        .copied()
710        .unwrap_or(false)
711    {
712        "any session's sub-agents may use it"
713    } else {
714        "only its own sessions' sub-agents may use it"
715    };
716    format!(
717        "{}; {}; {delegation}.",
718        profile.kind.display_name(),
719        profile_quota_source(profile)
720    )
721}
722
723/// Where a profile's quota report comes from, in the terms the quota refresh
724/// uses: a Codex profile is read through its custom provider only when that
725/// provider's API key is in the profile's environment.
726fn profile_quota_source(profile: &HarnessProfile) -> String {
727    match profile.kind {
728        HarnessKind::Claude => "Claude subscription quota".to_owned(),
729        HarnessKind::Codex => match crate::quota::provider_credential(profile) {
730            Some(provider) if crate::zai_usage::serves_quota(&provider.host) => {
731                format!("quota from {}", provider.host)
732            }
733            Some(provider) => format!(
734                "pay-per-use through {}, counted as 100% left when choosing a sub-agent's profile",
735                provider.host
736            ),
737            None => match profile.codex_provider() {
738                Ok(None) => "ChatGPT subscription quota".to_owned(),
739                Ok(Some(provider)) => format!(
740                    "no quota report, because custom provider {:?} has no API key in this profile's environment",
741                    provider.id
742                ),
743                Err(error) => format!("its Codex config.toml could not be read ({error:#})"),
744            },
745        },
746        kind => format!("quota as {} reports it", kind.display_name()),
747    }
748}
749
750/// The secrets file's permissions, and credentials written into `config.toml`
751/// as plain text that belong in it instead.
752///
753/// `config.toml` is copied into isolated instances, pasted into bug reports,
754/// and read by agents diagnosing a setup, so a credential in it travels with
755/// it. `secrets.toml` beside it is read only when a reference names one of
756/// its entries and is never copied with the configuration.
757fn secret_checks(config: ConfigStatus<'_>, config_path: &Path) -> Vec<DoctorCheck> {
758    use mj_core::config::{secrets_path_beside, secrets_permission_problem};
759
760    let mut checks = Vec::new();
761    let secrets = secrets_path_beside(config_path);
762    if secrets.exists() {
763        checks.push(match secrets_permission_problem(&secrets) {
764            Ok(None) => DoctorCheck::ready(
765                "secrets.file",
766                "Secrets file",
767                format!("{} is readable only by its owner", secrets.display()),
768            ),
769            Ok(Some(problem)) => DoctorCheck::warning(
770                "secrets.file",
771                "Secrets file",
772                problem,
773                format!("Run `chmod 600 {}`.", secrets.display()),
774            ),
775            Err(error) => DoctorCheck::warning(
776                "secrets.file",
777                "Secrets file",
778                format!("{error:#}"),
779                "Make the file readable by the user running Mjolnir.",
780            ),
781        });
782    }
783    let Ok(config) = config else {
784        return checks;
785    };
786    let profiles = config
787        .profiles
788        .iter()
789        .map(|(id, profile)| (format!("profiles.{id}"), &profile.environment));
790    let targets = config.targets.iter().filter_map(|(id, target)| {
791        target
792            .container()
793            .map(|container| (format!("targets.{id}"), &container.environment))
794    });
795    for (owner, environment) in profiles.chain(targets) {
796        if let Some(check) = plain_text_credential_check(&owner, environment, &secrets) {
797            checks.push(check);
798        }
799    }
800    checks
801}
802
803/// A warning naming each literal environment value under `owner` whose
804/// variable name suggests a credential.
805fn plain_text_credential_check(
806    owner: &str,
807    environment: &mj_core::config::Environment,
808    secrets: &Path,
809) -> Option<DoctorCheck> {
810    let names = environment
811        .sources()
812        .iter()
813        .filter(|(name, value)| {
814            !value.is_reference() && mj_core::config::looks_like_credential(name)
815        })
816        .map(|(name, _)| name.as_str())
817        .collect::<Vec<_>>();
818    if names.is_empty() {
819        return None;
820    }
821    let example = names[0];
822    Some(DoctorCheck::warning(
823        format!("{owner}.secrets"),
824        "Credentials in config.toml",
825        format!(
826            "[{owner}.environment] holds {} as plain text; config.toml is copied into isolated instances and read as ordinary configuration",
827            names.join(", ")
828        ),
829        format!(
830            "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.",
831            secrets.display()
832        ),
833    ))
834}
835
836/// The sub-agent policy in one line, then a warning for each profile that is
837/// both listed for sub-agent use and disabled.
838///
839/// The daemon keeps running with such a profile and simply does not offer it
840/// to a parent, because the delegation candidates and the spawn gate both
841/// require an enabled profile. This surfaces the contradiction so the eligible
842/// list and the profile's `enabled` flag can be reconciled. An eligible id
843/// that names no profile never reaches here: the configuration fails to load,
844/// and the configuration check reports it.
845fn subagent_eligibility_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
846    let Ok(config) = config else {
847        return Vec::new();
848    };
849    let mut checks = vec![subagent_policy_check(config)];
850    checks.extend(
851        config
852            .subagents
853            .eligible_profiles
854            .iter()
855            .filter(|(_, eligible)| **eligible)
856            .filter_map(|(id, _)| match config.profiles.get(id) {
857                Some(profile) if !profile.enabled => Some(DoctorCheck::warning(
858                    format!("subagents.{id}"),
859                    format!("Sub-agent profile {id}"),
860                    format!(
861                        "Profile {id:?} is listed in [subagents.eligible_profiles] but is disabled, so it is not offered for sub-agent use."
862                    ),
863                    format!(
864                        "Re-enable profile {id:?}, or remove it from [subagents.eligible_profiles]."
865                    ),
866                )),
867                _ => None,
868            }),
869    );
870    checks
871}
872
873/// How many sub-agents a session may start, and on which profiles. Whether a
874/// given session uses Mjolnir sub-agents at all is a per-session choice, not
875/// a global policy, so this check only describes the shared limits.
876fn subagent_policy_check(config: &Config) -> DoctorCheck {
877    let subagents = &config.subagents;
878    let eligible = subagents
879        .eligible_profiles
880        .iter()
881        .filter(|(_, eligible)| **eligible)
882        .map(|(id, _)| id.as_str())
883        .collect::<Vec<_>>();
884    let others = if eligible.is_empty() {
885        "no other profile".to_owned()
886    } else {
887        eligible.join(", ")
888    };
889    let detail = format!(
890        "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}.",
891        subagents.max_concurrent
892    );
893    DoctorCheck::ready("subagents.policy", "Sub-agent policy", detail)
894}
895
896/// Point an unauthenticated profile at `mj login`, which already knows how to
897/// sign each harness in.
898///
899/// The underlying command is named only for the reader's benefit; it comes from
900/// [`login_command`], the one place that tracks what each harness CLI actually
901/// accepts, so this text cannot drift away from what `mj login` runs.
902fn harness_login_remediation(id: &str, profile: &HarnessProfile) -> String {
903    let (program, arguments) = match login_command(profile) {
904        Ok(command) => command,
905        // An API-key profile has no login to recommend; say what is missing
906        // instead. The authentication gate normally passes such a profile, so
907        // this text appears only when its configuration file is absent.
908        Err(error) => return format!("{error} Check {}.", profile.home.display()),
909    };
910    format!(
911        "Run `mj login --profile {id}`; it runs `{program} {}` against {}.",
912        arguments.join(" "),
913        profile.home.display()
914    )
915}
916
917/// Host Podman prerequisites, then one image check per `local-podman` target.
918///
919/// The image checks run only after the host preflight passes, because a broken
920/// Podman installation already reports its own actionable check.
921fn podman_checks(
922    config: ConfigStatus<'_>,
923    executor: &impl CommandExecutor,
924    smoke: bool,
925    platform: &ApplePlatform,
926) -> Vec<DoctorCheck> {
927    let effective = config.map(|config| config.clone().with_local_targets());
928    let effective = effective.as_ref().map_err(|gap| *gap);
929    let explicit = config.is_ok_and(|config| {
930        local_podman_targets(config)
931            .iter()
932            .any(|(id, _)| config.configures_target(id))
933    });
934    let preflight = builtin_target_availability(
935        podman_check(effective, executor, platform),
936        explicit,
937        "Podman",
938        "podman",
939    );
940    let preflight_passed = preflight.status == CheckStatus::Ready;
941    let mut checks = vec![preflight];
942    if preflight_passed {
943        checks.extend(
944            podman_image_checks(effective, executor, smoke)
945                .into_iter()
946                .map(|(id, check)| builtin_image_check(config, &id, check, smoke)),
947        );
948    }
949    checks
950}
951
952/// An image check for a standard local target the user never configured.
953/// The dashboard downloads that image itself when it starts, so a missing
954/// image is a warning rather than a fault.
955fn builtin_image_check(
956    config: ConfigStatus<'_>,
957    target_id: &str,
958    check: DoctorCheck,
959    smoke: bool,
960) -> DoctorCheck {
961    let explicit = config.is_ok_and(|config| config.configures_target(target_id));
962    // A requested smoke test that fails is a runtime fault, not a missing
963    // image the dashboard would download (#1152).
964    if smoke || explicit || check.status != CheckStatus::Fixable {
965        return check;
966    }
967    DoctorCheck::warning(
968        check.id,
969        check.title,
970        format!(
971            "{} (built-in `{target_id}` target; the dashboard downloads its image when it starts)",
972            check.detail
973        ),
974        check.remediation.unwrap_or_default(),
975    )
976}
977
978/// Doctor checks the same target set the dashboard lists: the configured
979/// targets plus the standard local ones [`Config::with_local_targets`]
980/// supplies whether or not their engine is installed. A standard target whose
981/// engine is missing or not running is reported as unavailable, as the
982/// dashboard's Targets pane marks it, rather than as a fault to fix: nobody
983/// asked for it. A target the user configured keeps the fixable result; a
984/// block that only repeats a standard target does not count as configured
985/// ([`Config::configures_target`]).
986fn builtin_target_availability(
987    check: DoctorCheck,
988    explicit: bool,
989    engine: &str,
990    target_id: &str,
991) -> DoctorCheck {
992    if explicit || check.status != CheckStatus::Fixable {
993        return check;
994    }
995    DoctorCheck::unsupported(
996        check.id,
997        check.title,
998        format!(
999            "{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: {}",
1000            check.detail
1001        ),
1002    )
1003}
1004
1005fn podman_check(
1006    config: ConfigStatus<'_>,
1007    executor: &impl CommandExecutor,
1008    platform: &ApplePlatform,
1009) -> DoctorCheck {
1010    let config = match config {
1011        Ok(config) => config,
1012        Err(ConfigGap::NewerVersion(version)) => {
1013            return newer_config_skip("runtime.podman", "Rootless Podman", version);
1014        }
1015        Err(gap @ (ConfigGap::Missing | ConfigGap::Unreadable)) => {
1016            return DoctorCheck::unsupported(
1017                "runtime.podman",
1018                "Rootless Podman",
1019                format!(
1020                    "Podman prerequisites cannot be evaluated until {}.",
1021                    gap.awaited()
1022                ),
1023            );
1024        }
1025    };
1026    if local_podman_targets(config).is_empty() {
1027        return DoctorCheck::unsupported(
1028            "runtime.podman",
1029            "Rootless Podman",
1030            "No local-podman target is configured.",
1031        );
1032    }
1033    local_podman_runtime_check(executor, platform)
1034}
1035
1036/// Probe the local rootless Podman prerequisites and phrase the result as a
1037/// doctor check.
1038///
1039/// This is the single source of truth for Podman availability wording and
1040/// remediation. Settings runtime discovery calls it directly so its runtime
1041/// list reports the same detail and fix that `mj doctor` would.
1042pub fn local_podman_runtime_check(
1043    executor: &impl CommandExecutor,
1044    platform: &ApplePlatform,
1045) -> DoctorCheck {
1046    if !matches!(platform, ApplePlatform::Linux) {
1047        return DoctorCheck::unsupported(
1048            "runtime.podman",
1049            "Rootless Podman",
1050            "Mjolnir's local Podman target requires a Linux host; Podman machine is not supported. Use localhost or an SSH target on a Linux host.",
1051        );
1052    }
1053    match verify_local_podman(executor) {
1054        Ok(preflight) => DoctorCheck::ready(
1055            "runtime.podman",
1056            "Rootless Podman",
1057            format!("Podman {} has a valid rootless UID map.", preflight.version),
1058        ),
1059        Err(error) => DoctorCheck::fixable(
1060            "runtime.podman",
1061            "Rootless Podman",
1062            podman_failure_detail(&error),
1063            podman_remediation(&error),
1064        ),
1065    }
1066}
1067
1068fn local_podman_targets(config: &Config) -> Vec<(&String, &ContainerTemplate)> {
1069    config
1070        .targets
1071        .iter()
1072        .filter_map(|(id, target)| match target {
1073            TargetTemplate::LocalPodman { container } => Some((id, container)),
1074            _ => None,
1075        })
1076        .collect()
1077}
1078
1079fn podman_image_checks(
1080    config: ConfigStatus<'_>,
1081    executor: &impl CommandExecutor,
1082    smoke: bool,
1083) -> Vec<(String, DoctorCheck)> {
1084    let Ok(config) = config else {
1085        return Vec::new();
1086    };
1087    local_podman_targets(config)
1088        .into_iter()
1089        .map(|(id, container)| {
1090            (
1091                id.clone(),
1092                podman_image_check(id, &container.image, executor, smoke),
1093            )
1094        })
1095        .collect()
1096}
1097
1098fn podman_image_check(
1099    id: &str,
1100    image: &str,
1101    executor: &impl CommandExecutor,
1102    smoke: bool,
1103) -> DoctorCheck {
1104    let check_id = format!("runtime.podman.image.{id}");
1105    let title = format!("Podman image for target {id}");
1106    if smoke {
1107        let target = RuntimeTargetTemplate::LocalPodman(RuntimeContainerTemplate {
1108            build_cache: None,
1109            image: image.to_owned(),
1110            pull_policy: Default::default(),
1111            extra_run_args: vec![],
1112            workspace_storage: Default::default(),
1113        });
1114        return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1115            Ok(()) => DoctorCheck::ready(
1116                check_id,
1117                title,
1118                format!("Disposable run/exec/remove smoke test passed for image {image}."),
1119            ),
1120            Err(error) => DoctorCheck::fixable(
1121                check_id,
1122                title,
1123                format!(
1124                    "Disposable run/exec/remove smoke test failed for image {image}: {error:#}"
1125                ),
1126                "Fix the configured image or Podman runtime, then run `mj doctor --json --smoke` again.",
1127            ),
1128        };
1129    }
1130
1131    let command = CommandSpec::new("podman", ["image", "exists", image])
1132        .purpose("check Podman image presence");
1133    match executor.execute(&command) {
1134        Ok(output) if output.status == 0 => DoctorCheck::ready(
1135            check_id,
1136            title,
1137            format!("Image {image} is present in local Podman storage."),
1138        ),
1139        Ok(_) => DoctorCheck::fixable(
1140            check_id,
1141            title,
1142            format!("Image {image} is not present in local Podman storage."),
1143            missing_image_remediation(image),
1144        ),
1145        Err(error) => DoctorCheck::fixable(
1146            check_id,
1147            title,
1148            format!(
1149                "Could not check whether image {image} is present in local Podman storage: {error}"
1150            ),
1151            missing_image_remediation(image),
1152        ),
1153    }
1154}
1155
1156fn missing_image_remediation(image: &str) -> String {
1157    format!(
1158        "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."
1159    )
1160}
1161
1162/// Host Docker prerequisites, then one image check per `local-docker` target.
1163fn docker_checks(
1164    config: ConfigStatus<'_>,
1165    executor: &impl CommandExecutor,
1166    smoke: bool,
1167) -> Vec<DoctorCheck> {
1168    let config = match config {
1169        Ok(config) => config,
1170        Err(ConfigGap::NewerVersion(version)) => {
1171            return vec![newer_config_skip("runtime.docker", "Docker", version)];
1172        }
1173        Err(gap @ (ConfigGap::Missing | ConfigGap::Unreadable)) => {
1174            return vec![DoctorCheck::unsupported(
1175                "runtime.docker",
1176                "Docker",
1177                format!(
1178                    "Docker prerequisites cannot be evaluated until {}.",
1179                    gap.awaited()
1180                ),
1181            )];
1182        }
1183    };
1184    let explicit = local_docker_targets(config)
1185        .iter()
1186        .any(|(id, _)| config.configures_target(id));
1187    let effective = config.clone().with_local_targets();
1188    let targets = local_docker_targets(&effective);
1189    if targets.is_empty() {
1190        return vec![DoctorCheck::unsupported(
1191            "runtime.docker",
1192            "Docker",
1193            "No local-docker target is configured.",
1194        )];
1195    }
1196    let preflight = builtin_target_availability(
1197        local_docker_runtime_check(executor),
1198        explicit,
1199        "Docker",
1200        "docker",
1201    );
1202    if preflight.status != CheckStatus::Ready {
1203        return vec![preflight];
1204    }
1205    let mut checks = vec![preflight];
1206    checks.extend(targets.into_iter().map(|(id, container)| {
1207        builtin_image_check(
1208            Ok(config),
1209            id,
1210            docker_image_check(id, &container.image, executor, smoke),
1211            smoke,
1212        )
1213    }));
1214    checks
1215}
1216
1217pub fn local_docker_runtime_check(executor: &impl CommandExecutor) -> DoctorCheck {
1218    match verify_local_docker(executor) {
1219        Ok(preflight) => DoctorCheck::ready(
1220            "runtime.docker",
1221            "Docker",
1222            format!(
1223                "Docker {} is connected to a Linux daemon.",
1224                preflight.version
1225            ),
1226        ),
1227        Err(error) => match error.downcast_ref::<DockerUnavailable>() {
1228            Some(problem) => DoctorCheck::fixable(
1229                "runtime.docker",
1230                "Docker",
1231                problem.to_string(),
1232                problem.remediation(),
1233            ),
1234            None => DoctorCheck::fixable(
1235                "runtime.docker",
1236                "Docker",
1237                format!("{error:#}"),
1238                "Install and start Docker, then make sure `docker info` succeeds as the user running mj.",
1239            ),
1240        },
1241    }
1242}
1243
1244fn local_docker_targets(config: &Config) -> Vec<(&String, &ContainerTemplate)> {
1245    config
1246        .targets
1247        .iter()
1248        .filter_map(|(id, target)| match target {
1249            TargetTemplate::LocalDocker { container } => Some((id, container)),
1250            _ => None,
1251        })
1252        .collect()
1253}
1254
1255fn docker_image_check(
1256    id: &str,
1257    image: &str,
1258    executor: &impl CommandExecutor,
1259    smoke: bool,
1260) -> DoctorCheck {
1261    let check_id = format!("runtime.docker.image.{id}");
1262    let title = format!("Docker image for target {id}");
1263    if smoke {
1264        let target = RuntimeTargetTemplate::LocalDocker(RuntimeContainerTemplate {
1265            build_cache: None,
1266            image: image.to_owned(),
1267            pull_policy: Default::default(),
1268            extra_run_args: vec![],
1269            workspace_storage: Default::default(),
1270        });
1271        return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1272            Ok(()) => DoctorCheck::ready(
1273                check_id,
1274                title,
1275                match local_docker_vm_share(executor) {
1276                    Ok(Some(reason)) => format!(
1277                        "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."
1278                    ),
1279                    _ => format!(
1280                        "Disposable run/exec/remove and OverlayFS attachment smoke test passed for image {image}."
1281                    ),
1282                },
1283            ),
1284            Err(error) => DoctorCheck::fixable(
1285                check_id,
1286                title,
1287                format!(
1288                    "Disposable run/exec/remove smoke test failed for image {image}: {error:#}"
1289                ),
1290                "Fix the configured image or Docker runtime, then run `mj doctor --json --smoke` again.",
1291            ),
1292        };
1293    }
1294    let command = CommandSpec::new("docker", ["image", "inspect", image])
1295        .purpose("check Docker image presence");
1296    match executor.execute(&command) {
1297        Ok(output) if output.status == 0 => DoctorCheck::ready(
1298            check_id,
1299            title,
1300            format!("Image {image} is present in Docker storage."),
1301        ),
1302        Ok(_) => DoctorCheck::fixable(
1303            check_id,
1304            title,
1305            format!("Image {image} is not present in Docker storage."),
1306            format!("Pull it with `docker pull {image}`, or run `mj doctor --json --smoke`."),
1307        ),
1308        Err(error) => DoctorCheck::fixable(
1309            check_id,
1310            title,
1311            format!("Could not inspect Docker image {image}: {error}"),
1312            format!("Make sure `docker info` succeeds, then run `docker pull {image}`."),
1313        ),
1314    }
1315}
1316
1317/// The outcome of the shared SSH connectivity probe.
1318///
1319/// Both SSH-backed checks run this first: an unreachable host makes every
1320/// later probe fail with a misleading message.
1321enum SshConnectivity {
1322    Reachable,
1323    Failed { detail: String, remediation: String },
1324}
1325
1326/// Probe `ssh <destination> true` and map any failure to a copy-paste fix.
1327///
1328/// Hel never generates keys, runs `ssh-copy-id`, or accepts a host key on the
1329/// user's behalf; it only says exactly which command would fix the failure.
1330fn ssh_connectivity(ssh: &RuntimeSshTarget, executor: &impl CommandExecutor) -> SshConnectivity {
1331    let destination = &ssh.destination;
1332    let command = ssh_connectivity_probe(ssh);
1333    match executor.execute(&command) {
1334        Err(error) => SshConnectivity::Failed {
1335            detail: format!("Could not run `ssh {destination} true`: {error:#}"),
1336            remediation: ssh_launch_failure_remediation(&error, ssh),
1337        },
1338        Ok(output) if output.status != 0 => {
1339            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
1340            SshConnectivity::Failed {
1341                detail: format!("`ssh {destination} true` failed: {stderr}"),
1342                remediation: ssh_failure_remediation(&stderr, ssh),
1343            }
1344        }
1345        Ok(_) => SshConnectivity::Reachable,
1346    }
1347}
1348
1349const 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).";
1350
1351/// What OpenSSH reported, as far as doctor needs to tell the cases apart.
1352///
1353/// OpenSSH is an external tool, so its wording is the only signal available.
1354/// This is the one place in doctor that reads it; everything downstream works
1355/// from the classification rather than the text.
1356#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1357enum SshFailure {
1358    UntrustedHostKey,
1359    Unauthenticated,
1360    ClientMissing,
1361    /// The host answered nothing at all.
1362    Unreachable,
1363    Unrecognized,
1364}
1365
1366fn classify_ssh_stderr(stderr: &str) -> SshFailure {
1367    const UNTRUSTED_HOST_KEY: [&str; 3] = [
1368        "Host key verification failed",
1369        "No ECDSA host key is known",
1370        "REMOTE HOST IDENTIFICATION HAS CHANGED",
1371    ];
1372    const UNAUTHENTICATED: [&str; 4] = [
1373        "Permission denied",
1374        "Too many authentication failures",
1375        "no matching host key",
1376        "Authentication failed",
1377    ];
1378    const CLIENT_MISSING: [&str; 2] = ["ssh: command not found", "No such file or directory"];
1379    const UNREACHABLE: [&str; 3] = [
1380        "Connection timed out",
1381        "No route to host",
1382        "Network is unreachable",
1383    ];
1384
1385    let reported = |signatures: &[&str]| signatures.iter().any(|text| stderr.contains(text));
1386    if reported(&UNTRUSTED_HOST_KEY) {
1387        SshFailure::UntrustedHostKey
1388    } else if reported(&UNAUTHENTICATED) {
1389        SshFailure::Unauthenticated
1390    } else if reported(&CLIENT_MISSING) {
1391        SshFailure::ClientMissing
1392    } else if reported(&UNREACHABLE) {
1393        SshFailure::Unreachable
1394    } else {
1395        SshFailure::Unrecognized
1396    }
1397}
1398
1399/// Map a failure to run `ssh` at all (as opposed to `ssh` exiting nonzero)
1400/// to the command that fixes it.
1401fn ssh_launch_failure_remediation(error: &anyhow::Error, ssh: &RuntimeSshTarget) -> String {
1402    if error.downcast_ref::<CommandTimedOut>().is_some() {
1403        return ssh_unreachable_remediation(ssh);
1404    }
1405    let missing_binary = error.chain().any(|cause| {
1406        cause
1407            .downcast_ref::<std::io::Error>()
1408            .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
1409    });
1410    if missing_binary {
1411        return SSH_MISSING_REMEDIATION.to_owned();
1412    }
1413    format!(
1414        "Run `ssh {} true` by hand and resolve the error it reports: {error:#}",
1415        ssh.destination
1416    )
1417}
1418
1419/// The host answered nothing: it is asleep, behind a down VPN, or the cloud
1420/// session that exposes it has expired.
1421fn ssh_unreachable_remediation(ssh: &RuntimeSshTarget) -> String {
1422    let host = ssh_host_only(&ssh.destination);
1423    format!(
1424        "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.",
1425        ssh.destination
1426    )
1427}
1428
1429/// Map `ssh -o BatchMode=yes` stderr to the command that fixes it.
1430fn ssh_failure_remediation(stderr: &str, ssh: &RuntimeSshTarget) -> String {
1431    let destination = &ssh.destination;
1432    match classify_ssh_stderr(stderr) {
1433        SshFailure::UntrustedHostKey => {
1434            let host = ssh_host_only(destination);
1435            format!(
1436                "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."
1437            )
1438        }
1439        SshFailure::Unauthenticated => match ssh_identity_file(ssh) {
1440            Some(identity) => format!(
1441                "Install your public key on the host with `ssh-copy-id -i {identity}.pub {destination}`."
1442            ),
1443            None => {
1444                format!("Install your public key on the host with `ssh-copy-id {destination}`.")
1445            }
1446        },
1447        SshFailure::ClientMissing => SSH_MISSING_REMEDIATION.to_owned(),
1448        SshFailure::Unreachable => ssh_unreachable_remediation(ssh),
1449        SshFailure::Unrecognized => {
1450            format!(
1451                "Run `ssh {destination} true` by hand and resolve the error it reports: {stderr}"
1452            )
1453        }
1454    }
1455}
1456
1457/// The host part of an OpenSSH destination, without any `user@` prefix.
1458fn ssh_host_only(destination: &str) -> &str {
1459    destination
1460        .rsplit_once('@')
1461        .map_or(destination, |(_, host)| host)
1462}
1463
1464/// The identity file provisioning passes, recovered from the built ssh args.
1465fn ssh_identity_file(ssh: &RuntimeSshTarget) -> Option<&str> {
1466    let position = ssh.ssh_args.iter().position(|arg| arg == "-i")?;
1467    ssh.ssh_args.get(position + 1).map(String::as_str)
1468}
1469
1470/// One check per `ssh-bare` target: can Hel reach the host noninteractively?
1471fn ssh_bare_checks(config: ConfigStatus<'_>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
1472    let Ok(config) = config else {
1473        return Vec::new();
1474    };
1475    config
1476        .targets
1477        .iter()
1478        .filter_map(|(id, target)| match target {
1479            TargetTemplate::SshBare { ssh, .. } => {
1480                Some(ssh_bare_check(id, &RuntimeSshTarget::from(ssh), executor))
1481            }
1482            _ => None,
1483        })
1484        .collect()
1485}
1486
1487fn ssh_bare_check(
1488    id: &str,
1489    ssh: &RuntimeSshTarget,
1490    executor: &impl CommandExecutor,
1491) -> DoctorCheck {
1492    let check_id = format!("runtime.ssh-bare.{id}");
1493    let title = format!("SSH access for target {id}");
1494    match ssh_connectivity(ssh, executor) {
1495        SshConnectivity::Reachable => DoctorCheck::ready(
1496            check_id,
1497            title,
1498            format!(
1499                "`ssh {} true` succeeds noninteractively from this host.",
1500                ssh.destination
1501            ),
1502        ),
1503        SshConnectivity::Failed {
1504            detail,
1505            remediation,
1506        } => DoctorCheck::fixable(check_id, title, detail, remediation),
1507    }
1508}
1509
1510/// Two checks per `ssh-podman` target: the same Podman probes run over SSH,
1511/// then the host limits that only bite under provisioning load.
1512fn ssh_podman_checks(
1513    config: ConfigStatus<'_>,
1514    executor: &impl CommandExecutor,
1515    smoke: bool,
1516) -> Vec<DoctorCheck> {
1517    let Ok(config) = config else {
1518        return Vec::new();
1519    };
1520    config
1521        .targets
1522        .iter()
1523        .flat_map(|(id, target)| match target {
1524            TargetTemplate::SshPodman { ssh, container, .. } => {
1525                let ssh = RuntimeSshTarget::from(ssh);
1526                let (check, reachable) =
1527                    ssh_podman_check(id, &ssh, &container.image, executor, smoke);
1528                let mut checks = vec![check];
1529                // An unreachable host has one problem, not two.
1530                if reachable {
1531                    checks.push(ssh_podman_limits_check(id, &ssh, executor));
1532                }
1533                checks
1534            }
1535            _ => Vec::new(),
1536        })
1537        .collect()
1538}
1539
1540/// The Podman check for one target, paired with whether the host answered SSH
1541/// at all: the caller skips its follow-up probes when it did not.
1542fn ssh_podman_check(
1543    id: &str,
1544    ssh: &RuntimeSshTarget,
1545    image: &str,
1546    executor: &impl CommandExecutor,
1547    smoke: bool,
1548) -> (DoctorCheck, bool) {
1549    let check_id = format!("runtime.ssh-podman.{id}");
1550    let title = format!("Remote Podman for target {id}");
1551    // Connectivity first: a remote Podman probe on an unreachable host reports
1552    // a Podman problem the user does not have.
1553    if let SshConnectivity::Failed {
1554        detail,
1555        remediation,
1556    } = ssh_connectivity(ssh, executor)
1557    {
1558        return (
1559            DoctorCheck::fixable(check_id, title, detail, remediation),
1560            false,
1561        );
1562    }
1563    (
1564        ssh_podman_runtime_check(check_id, title, ssh, image, executor, smoke),
1565        true,
1566    )
1567}
1568
1569/// The Podman half of the target's checks, on a host already known reachable.
1570fn ssh_podman_runtime_check(
1571    check_id: String,
1572    title: String,
1573    ssh: &RuntimeSshTarget,
1574    image: &str,
1575    executor: &impl CommandExecutor,
1576    smoke: bool,
1577) -> DoctorCheck {
1578    let destination = &ssh.destination;
1579    let preflight = match verify_ssh_podman(ssh, executor) {
1580        Ok(preflight) => preflight,
1581        Err(error) => {
1582            let detail = podman_failure_detail(&error);
1583            let remediation = match podman_remediation_match(&error) {
1584                Some(remediation) => {
1585                    format!("On {destination}: {remediation} See {PODMAN_DOCUMENTATION_URL}.")
1586                }
1587                None => format!(
1588                    "Verify `ssh {destination}` succeeds noninteractively from this host, then install rootless Podman 4.3 or newer there. See {PODMAN_DOCUMENTATION_URL}."
1589                ),
1590            };
1591            return DoctorCheck::fixable(check_id, title, detail, remediation);
1592        }
1593    };
1594    let linger_warning = preflight.warnings.first();
1595    if !smoke && let Some(warning) = linger_warning {
1596        return DoctorCheck::warning(
1597            check_id,
1598            title,
1599            format!(
1600                "Remote rootless Podman {} is available via {destination}, but {}",
1601                preflight.version, warning.detail
1602            ),
1603            &warning.remediation,
1604        );
1605    }
1606    if !smoke {
1607        return DoctorCheck::ready(
1608            check_id,
1609            title,
1610            format!(
1611                "Remote rootless Podman {} is available via {destination}. Run `mj doctor --json --smoke` to verify the image end to end.",
1612                preflight.version
1613            ),
1614        );
1615    }
1616
1617    let target = RuntimeTargetTemplate::SshPodman {
1618        ssh: ssh.clone(),
1619        container: RuntimeContainerTemplate {
1620            build_cache: None,
1621            image: image.to_owned(),
1622            pull_policy: Default::default(),
1623            extra_run_args: vec![],
1624            workspace_storage: Default::default(),
1625        },
1626    };
1627    match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1628        Ok(()) => match linger_warning {
1629            Some(warning) => DoctorCheck::warning(
1630                check_id,
1631                title,
1632                format!(
1633                    "Disposable run/exec/remove smoke test passed for image {image} on {destination}, but {}",
1634                    warning.detail
1635                ),
1636                &warning.remediation,
1637            ),
1638            None => DoctorCheck::ready(
1639                check_id,
1640                title,
1641                format!(
1642                    "Disposable run/exec/remove smoke test passed for image {image} on {destination}."
1643                ),
1644            ),
1645        },
1646        Err(error) => DoctorCheck::fixable(
1647            check_id,
1648            title,
1649            format!(
1650                "Disposable run/exec/remove smoke test failed for image {image} on {destination}: {error:#}"
1651            ),
1652            format!(
1653                "Fix the configured image or Podman runtime on {destination}, then run `mj doctor --json --smoke` again."
1654            ),
1655        ),
1656    }
1657}
1658
1659/// Host limits that cause provisioning failures under load, read on their own SSH
1660/// round trip so the provisioning preflight never pays for them.
1661///
1662/// Every crun container takes a session keyring, so `podman run` fails with
1663/// `crun: create keyring` once the login user's keyring quota is exhausted, and
1664/// sshd refuses new connections past `MaxStartups`. `sshd -T` needs root, so the
1665/// directive is read from the config files instead; drop-ins may be unreadable,
1666/// which the script reports rather than guessing.
1667const SSH_PODMAN_HOST_LIMITS_SCRIPT: &str = r#"
1668if [ -r /proc/sys/kernel/keys/maxkeys ]; then
1669    printf 'keys.max=%s\n' "$(cat /proc/sys/kernel/keys/maxkeys)"
1670fi
1671if [ -r /proc/key-users ]; then
1672    awk -v uid="$(id -u)" '
1673        { user = $1; sub(/:$/, "", user) }
1674        user == uid {
1675            split($4, quota, "/")
1676            printf "keys.used=%s\nkeys.quota=%s\n", quota[1], quota[2]
1677        }
1678    ' /proc/key-users
1679fi
1680unreadable=0
1681maxstartups=
1682# A drop-in directory that cannot be listed hides any override it holds.
1683if [ -d /etc/ssh/sshd_config.d ] && ! [ -r /etc/ssh/sshd_config.d ]; then
1684    unreadable=1
1685fi
1686for file in /etc/ssh/sshd_config /etc/ssh/sshd_config.d/*.conf; do
1687    [ -e "$file" ] || continue
1688    if [ -r "$file" ]; then
1689        match=$(grep -i '^[[:space:]]*maxstartups[[:space:]]' "$file" 2>/dev/null | tail -n 1)
1690        [ -n "$match" ] && maxstartups=$(printf '%s\n' "$match" | awk '{ print $2 }')
1691    else
1692        unreadable=1
1693    fi
1694done
1695[ -n "$maxstartups" ] && printf 'maxstartups=%s\n' "$maxstartups"
1696[ "$unreadable" = 1 ] && printf 'maxstartups.unreadable=1\n'
1697exit 0
1698"#;
1699
1700/// Keyring use at or above this share of the quota is reported as a warning:
1701/// the remaining headroom is a few concurrent containers, not a comfortable
1702/// margin.
1703const KEYRING_PRESSURE_PERCENT: u64 = 80;
1704
1705/// What `SSH_PODMAN_HOST_LIMITS_SCRIPT` managed to read. Every field is
1706/// optional: an unreadable file is reported, never guessed at.
1707#[derive(Debug, Default, PartialEq, Eq)]
1708struct HostLimits {
1709    keys_used: Option<u64>,
1710    keys_quota: Option<u64>,
1711    keys_max: Option<u64>,
1712    max_startups: Option<String>,
1713    max_startups_unreadable: bool,
1714}
1715
1716fn parse_host_limits(stdout: &[u8]) -> HostLimits {
1717    let text = String::from_utf8_lossy(stdout);
1718    let mut limits = HostLimits::default();
1719    for line in text.lines() {
1720        let Some((name, value)) = line.split_once('=') else {
1721            continue;
1722        };
1723        let value = value.trim();
1724        match name.trim() {
1725            "keys.used" => limits.keys_used = value.parse().ok(),
1726            "keys.quota" => limits.keys_quota = value.parse().ok(),
1727            "keys.max" => limits.keys_max = value.parse().ok(),
1728            "maxstartups" if !value.is_empty() => limits.max_startups = Some(value.to_owned()),
1729            "maxstartups.unreadable" => limits.max_startups_unreadable = value == "1",
1730            _ => {}
1731        }
1732    }
1733    limits
1734}
1735
1736impl HostLimits {
1737    /// True when the script produced nothing a reader could act on.
1738    fn is_empty(&self) -> bool {
1739        self.keys_used.is_none()
1740            && self.keys_quota.is_none()
1741            && self.keys_max.is_none()
1742            && self.max_startups.is_none()
1743            && !self.max_startups_unreadable
1744    }
1745
1746    fn keyring_is_under_pressure(&self) -> bool {
1747        match (self.keys_used, self.keys_quota) {
1748            (Some(used), Some(quota)) if quota > 0 => {
1749                used.saturating_mul(100) >= quota.saturating_mul(KEYRING_PRESSURE_PERCENT)
1750            }
1751            _ => false,
1752        }
1753    }
1754
1755    fn keyring_sentence(&self, destination: &str) -> String {
1756        match (self.keys_used, self.keys_quota) {
1757            (Some(used), Some(quota)) => {
1758                let system = match self.keys_max {
1759                    Some(max) => format!(", and `kernel.keys.maxkeys` is {max}"),
1760                    None => String::new(),
1761                };
1762                format!(
1763                    "The login user on {destination} holds {used} of its {quota} kernel keyring quota{system}."
1764                )
1765            }
1766            _ => format!(
1767                "The kernel keyring quota for the login user on {destination} could not be read."
1768            ),
1769        }
1770    }
1771
1772    fn max_startups_sentence(&self) -> String {
1773        match (&self.max_startups, self.max_startups_unreadable) {
1774            (Some(value), _) => format!("sshd MaxStartups is {value}."),
1775            (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(),
1776            (None, false) => {
1777                "sshd MaxStartups is not set in sshd_config, so sshd's default applies.".to_owned()
1778            }
1779        }
1780    }
1781}
1782
1783/// Report the two host limits that made provisioning fail under load. The
1784/// target still works when they cannot be read, so an unreadable host is a
1785/// warning with a manual command, never a `fixable` runtime failure.
1786fn ssh_podman_limits_check(
1787    id: &str,
1788    ssh: &RuntimeSshTarget,
1789    executor: &impl CommandExecutor,
1790) -> DoctorCheck {
1791    let check_id = format!("runtime.ssh-podman.{id}.limits");
1792    let title = format!("Host limits for target {id}");
1793    let destination = &ssh.destination;
1794    let manual = || {
1795        format!(
1796            "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`."
1797        )
1798    };
1799    let command = ssh_validation_command(
1800        ssh,
1801        vec![
1802            "sh".to_owned(),
1803            "-c".to_owned(),
1804            SSH_PODMAN_HOST_LIMITS_SCRIPT.to_owned(),
1805        ],
1806        "read ssh-podman host limits",
1807    );
1808    let limits = match executor.execute(&command) {
1809        Ok(output) if output.status == 0 => parse_host_limits(&output.stdout),
1810        Ok(output) => {
1811            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
1812            return DoctorCheck::warning(
1813                check_id,
1814                title,
1815                format!(
1816                    "Could not read the kernel keyring quota or sshd MaxStartups from {destination}: {stderr}"
1817                ),
1818                manual(),
1819            );
1820        }
1821        Err(error) => {
1822            return DoctorCheck::warning(
1823                check_id,
1824                title,
1825                format!(
1826                    "Could not read the kernel keyring quota or sshd MaxStartups from {destination}: {error}"
1827                ),
1828                manual(),
1829            );
1830        }
1831    };
1832    if limits.is_empty() {
1833        return DoctorCheck::warning(
1834            check_id,
1835            title,
1836            format!("{destination} reported no readable kernel keyring or sshd limits."),
1837            manual(),
1838        );
1839    }
1840    let detail = format!(
1841        "{} {}",
1842        limits.keyring_sentence(destination),
1843        limits.max_startups_sentence()
1844    );
1845    if limits.keyring_is_under_pressure() {
1846        return DoctorCheck::warning(
1847            check_id,
1848            title,
1849            format!(
1850                "{detail} Every container takes a session keyring, so `podman run` fails with `crun: create keyring` once the quota is gone."
1851            ),
1852            format!(
1853                "Raise `kernel.keys.maxkeys` and `kernel.keys.maxbytes` with sysctl on {destination}, and close finished sessions promptly."
1854            ),
1855        );
1856    }
1857    DoctorCheck::ready(check_id, title, detail)
1858}
1859
1860/// One check per `ssh-docker` target: Docker daemon, image, and optional
1861/// remote OverlayFS smoke test, all executed on the SSH host.
1862fn ssh_docker_checks(
1863    config: ConfigStatus<'_>,
1864    executor: &impl CommandExecutor,
1865    smoke: bool,
1866) -> Vec<DoctorCheck> {
1867    let Ok(config) = config else {
1868        return Vec::new();
1869    };
1870    config
1871        .targets
1872        .iter()
1873        .filter_map(|(id, target)| match target {
1874            TargetTemplate::SshDocker { ssh, container } => Some(ssh_docker_check(
1875                id,
1876                &RuntimeSshTarget::from(ssh),
1877                &container.image,
1878                executor,
1879                smoke,
1880            )),
1881            _ => None,
1882        })
1883        .collect()
1884}
1885
1886fn ssh_docker_check(
1887    id: &str,
1888    ssh: &RuntimeSshTarget,
1889    image: &str,
1890    executor: &impl CommandExecutor,
1891    smoke: bool,
1892) -> DoctorCheck {
1893    let check_id = format!("runtime.ssh-docker.{id}");
1894    let title = format!("Remote Docker for target {id}");
1895    let destination = &ssh.destination;
1896    if let SshConnectivity::Failed {
1897        detail,
1898        remediation,
1899    } = ssh_connectivity(ssh, executor)
1900    {
1901        return DoctorCheck::fixable(check_id, title, detail, remediation);
1902    }
1903
1904    let preflight = match verify_ssh_docker(ssh, executor) {
1905        Ok(preflight) => preflight,
1906        Err(error) => {
1907            let detail = format!("{error:#}");
1908            return DoctorCheck::fixable(
1909                check_id,
1910                title,
1911                detail,
1912                format!(
1913                    "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."
1914                ),
1915            );
1916        }
1917    };
1918
1919    if smoke {
1920        let target = RuntimeTargetTemplate::SshDocker {
1921            ssh: ssh.clone(),
1922            container: RuntimeContainerTemplate {
1923                build_cache: None,
1924                image: image.to_owned(),
1925                pull_policy: Default::default(),
1926                extra_run_args: vec![],
1927                workspace_storage: Default::default(),
1928            },
1929        };
1930        return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1931            Ok(()) => DoctorCheck::ready(
1932                check_id,
1933                title,
1934                format!(
1935                    "Remote Docker {} is available via {destination}; disposable run/exec/remove and remote OverlayFS attachment smoke test passed for image {image}.",
1936                    preflight.version
1937                ),
1938            ),
1939            Err(error) => DoctorCheck::fixable(
1940                check_id,
1941                title,
1942                format!(
1943                    "Disposable run/exec/remove smoke test failed for image {image} on {destination}: {error:#}"
1944                ),
1945                format!(
1946                    "Fix the configured image or Docker runtime on {destination}, then run `mj doctor --json --smoke` again."
1947                ),
1948            ),
1949        };
1950    }
1951
1952    let image_command = ssh_command(
1953        ssh,
1954        [
1955            "docker".to_owned(),
1956            "image".to_owned(),
1957            "inspect".to_owned(),
1958            image.to_owned(),
1959        ]
1960        .to_vec(),
1961    )
1962    .purpose("check remote Docker image presence");
1963    match executor.execute(&image_command) {
1964        Ok(output) if output.status == 0 => DoctorCheck::ready(
1965            check_id,
1966            title,
1967            format!(
1968                "Remote Docker {} is available via {destination}; image {image} is present. Run `mj doctor --json --smoke` to verify remote OverlayFS attachments.",
1969                preflight.version
1970            ),
1971        ),
1972        Ok(output) => DoctorCheck::fixable(
1973            check_id,
1974            title,
1975            format!(
1976                "Image {image} is not present in remote Docker storage on {destination}: {}",
1977                String::from_utf8_lossy(&output.stderr).trim()
1978            ),
1979            format!(
1980                "Pull it on {destination} with `ssh {destination} docker pull {image}`, or run `mj doctor --json --smoke`."
1981            ),
1982        ),
1983        Err(error) => DoctorCheck::fixable(
1984            check_id,
1985            title,
1986            format!("Could not inspect remote Docker image {image} on {destination}: {error}"),
1987            format!(
1988                "Verify `ssh {destination} docker info` succeeds, then pull {image} on that host."
1989            ),
1990        ),
1991    }
1992}
1993
1994/// Shared disposable-container identity for every doctor smoke test.
1995fn doctor_smoke_id() -> String {
1996    format!(
1997        "doctor-{}-{:x}",
1998        std::process::id(),
1999        SystemTime::now()
2000            .duration_since(UNIX_EPOCH)
2001            .unwrap_or_default()
2002            .as_nanos()
2003    )
2004}
2005
2006fn podman_remediation(error: &anyhow::Error) -> String {
2007    let fix = podman_remediation_match(error).unwrap_or(
2008        "Install Podman with `sudo apt update && sudo apt install -y podman uidmap` (Debian/Ubuntu) or `sudo dnf install -y podman shadow-utils` (Fedora).",
2009    );
2010    format!("{fix} See {PODMAN_DOCUMENTATION_URL}.")
2011}
2012
2013/// A Podman failure without its fix, which the check reports separately.
2014fn podman_failure_detail(error: &anyhow::Error) -> String {
2015    podman_probe_observation(error).map_or_else(|| format!("{error:#}"), str::to_owned)
2016}
2017
2018/// Map a Podman preflight failure to its specific remediation, if one applies.
2019///
2020/// The preflight reports which postcondition failed on the error itself, so
2021/// the fix is chosen from that probe rather than by matching the message text
2022/// this repository just produced. A failure that is not a probe result, such
2023/// as an unreachable SSH host, has no specific fix here.
2024fn podman_remediation_match(error: &anyhow::Error) -> Option<&'static str> {
2025    failed_podman_postcondition(error).map(PodmanPostcondition::remediation)
2026}
2027
2028const AWS_CLI_INSTALL_URL: &str =
2029    "https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html";
2030
2031/// One check per `aws-ec2` target: the AWS CLI, its credentials, and the
2032/// configured launch template.
2033fn aws_checks(config: ConfigStatus<'_>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
2034    let Ok(config) = config else {
2035        return Vec::new();
2036    };
2037    config
2038        .targets
2039        .iter()
2040        .filter_map(|(id, target)| match target {
2041            TargetTemplate::AwsEc2 {
2042                aws_profile,
2043                region,
2044                launch_template,
2045                ..
2046            } => Some(aws_target_check(
2047                id,
2048                aws_profile.as_deref(),
2049                region,
2050                launch_template,
2051                executor,
2052            )),
2053            _ => None,
2054        })
2055        .collect()
2056}
2057
2058/// The profile and region every AWS probe carries, applied exactly the way
2059/// provisioning applies them in `targets`.
2060fn aws_global_args<'a>(profile: Option<&'a str>, region: &'a str) -> Vec<String> {
2061    vec![
2062        "--profile".to_owned(),
2063        profile.unwrap_or("default").to_owned(),
2064        "--region".to_owned(),
2065        region.to_owned(),
2066    ]
2067}
2068
2069fn aws_target_check(
2070    id: &str,
2071    profile: Option<&str>,
2072    region: &str,
2073    launch_template: &str,
2074    executor: &impl CommandExecutor,
2075) -> DoctorCheck {
2076    let check_id = format!("runtime.aws-ec2.{id}");
2077    let title = format!("AWS EC2 target {id}");
2078    let profile_label = profile.unwrap_or("default");
2079
2080    let version = CommandSpec::new("aws", ["--version"]).purpose("check AWS CLI installation");
2081    match executor.execute(&version) {
2082        Err(error) => {
2083            return DoctorCheck::fixable(
2084                check_id,
2085                title,
2086                format!("The `aws` command is not available: {error}"),
2087                format!("Install the AWS CLI and put `aws` on PATH: {AWS_CLI_INSTALL_URL}"),
2088            );
2089        }
2090        Ok(output) if output.status != 0 => {
2091            return DoctorCheck::fixable(
2092                check_id,
2093                title,
2094                format!(
2095                    "`aws --version` failed: {}",
2096                    String::from_utf8_lossy(&output.stderr).trim()
2097                ),
2098                format!("Reinstall the AWS CLI: {AWS_CLI_INSTALL_URL}"),
2099            );
2100        }
2101        Ok(_) => {}
2102    }
2103
2104    let mut identity_args = aws_global_args(profile, region);
2105    identity_args.extend(["sts".to_owned(), "get-caller-identity".to_owned()]);
2106    identity_args.extend(["--output".to_owned(), "json".to_owned()]);
2107    let identity =
2108        CommandSpec::new("aws", identity_args).purpose("check AWS credentials for a doctor target");
2109    match executor.execute(&identity) {
2110        Err(error) => {
2111            return DoctorCheck::fixable(
2112                check_id,
2113                title,
2114                format!("Could not run `aws sts get-caller-identity`: {error}"),
2115                format!(
2116                    "Configure credentials with `aws configure --profile {profile_label}`, or sign in with `aws sso login --profile {profile_label}`."
2117                ),
2118            );
2119        }
2120        Ok(output) if output.status != 0 => {
2121            return DoctorCheck::fixable(
2122                check_id,
2123                title,
2124                format!(
2125                    "AWS credentials for profile {profile_label} are not usable: {}",
2126                    String::from_utf8_lossy(&output.stderr).trim()
2127                ),
2128                format!(
2129                    "Configure credentials with `aws configure --profile {profile_label}`, or sign in with `aws sso login --profile {profile_label}`."
2130                ),
2131            );
2132        }
2133        Ok(_) => {}
2134    }
2135
2136    // Launch templates are addressed by id when they carry the `lt-` prefix
2137    // and by name otherwise, the same split provisioning uses.
2138    let by_id = launch_template.starts_with("lt-");
2139    let mut template_args = aws_global_args(profile, region);
2140    template_args.extend(["ec2".to_owned(), "describe-launch-templates".to_owned()]);
2141    template_args.extend([
2142        if by_id {
2143            "--launch-template-ids".to_owned()
2144        } else {
2145            "--launch-template-names".to_owned()
2146        },
2147        launch_template.to_owned(),
2148    ]);
2149    template_args.extend(["--output".to_owned(), "json".to_owned()]);
2150    let template =
2151        CommandSpec::new("aws", template_args).purpose("check the configured AWS launch template");
2152    let template_remediation = format!(
2153        "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."
2154    );
2155    match executor.execute(&template) {
2156        Err(error) => DoctorCheck::fixable(
2157            check_id,
2158            title,
2159            format!("Could not query launch template {launch_template}: {error}"),
2160            template_remediation,
2161        ),
2162        Ok(output) if output.status != 0 => DoctorCheck::fixable(
2163            check_id,
2164            title,
2165            format!(
2166                "Launch template {launch_template} was not found in {region}: {}",
2167                String::from_utf8_lossy(&output.stderr).trim()
2168            ),
2169            template_remediation,
2170        ),
2171        Ok(_) => DoctorCheck::ready(
2172            check_id,
2173            title,
2174            format!(
2175                "The AWS CLI is installed, profile {profile_label} has valid credentials, and launch template {launch_template} exists in {region}."
2176            ),
2177        ),
2178    }
2179}
2180
2181/// Whether the running daemon is this build.
2182///
2183/// Two Mjolnir builds carry the same version string, so the version line in
2184/// `mj daemon status` cannot answer it. A daemon left over from before a
2185/// rebuild keeps serving the old code, and, when its executable was unlinked
2186/// by the rebuild, also loses every portable worker source it would have
2187/// pinned. Both are invisible without this check.
2188fn daemon_build_check() -> DoctorCheck {
2189    const ID: &str = "daemon.build";
2190    const TITLE: &str = "Daemon build";
2191    let Ok(metadata) = mj_client::daemon::read_metadata_any() else {
2192        return DoctorCheck::ready(
2193            ID,
2194            TITLE,
2195            "No Mjolnir daemon is running; the next command starts one from this build.",
2196        );
2197    };
2198    let pid = metadata.pid;
2199    match mj_client::executable::process_runs_this_executable(pid) {
2200        Ok(Some(true)) => DoctorCheck::ready(
2201            ID,
2202            TITLE,
2203            format!(
2204                "Daemon {pid} runs this build (version {}).",
2205                mj_client::build_identity::this_build().describe()
2206            ),
2207        ),
2208        Ok(Some(false)) => DoctorCheck::warning(
2209            ID,
2210            TITLE,
2211            format!(
2212                "{}. Two builds can report the same version, so the files are what tell them apart. Code rebuilt since that daemon started is not running.",
2213                mj_client::executable::describe_running_daemon_and_client_builds(
2214                    pid,
2215                    &metadata.build_version,
2216                ),
2217            ),
2218            "Run `mj daemon restart` from this build. It now fails rather than reporting success if another client's build wins.",
2219        ),
2220        Ok(None) => DoctorCheck::ready(
2221            ID,
2222            TITLE,
2223            format!(
2224                "Daemon {pid} is recorded but not running; the next command starts one from this build."
2225            ),
2226        ),
2227        Err(error) => DoctorCheck::warning(
2228            ID,
2229            TITLE,
2230            format!("Could not tell which build daemon {pid} runs: {error:#}"),
2231            "Run `mj daemon restart` from this build if rebuilt code is not taking effect.",
2232        ),
2233    }
2234}
2235
2236/// Whether a new session would run the worker binary as it is on disk now.
2237///
2238/// The daemon copies each worker it can find into a content-addressed cache
2239/// when it starts and serves that copy for the rest of its life, so rebuilding
2240/// `mj-worker` does not reach a running daemon. Nothing else reports this, and
2241/// the digests are what make it checkable at all: two worker builds differ by
2242/// content, not by name or version.
2243fn worker_freshness_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
2244    let mut checks = Vec::new();
2245    let daemon = mj_client::daemon::read_metadata_any()
2246        .ok()
2247        .filter(|metadata| mj_client::daemon::process_is_alive(metadata.pid));
2248    let pinned = pinned_worker_digests();
2249    let mut sources: Vec<(String, Result<WorkerBinaryAvailability>)> = vec![(
2250        "this host".to_owned(),
2251        crate::controller::native_worker_binary_prerequisite(),
2252    )];
2253    for arch in container_worker_architectures(config) {
2254        sources.push((
2255            format!("{arch} Linux targets"),
2256            worker_binary_prerequisite_for_arch(&arch),
2257        ));
2258    }
2259    for (label, availability) in sources {
2260        let id = format!("worker.freshness.{}", label.replace(' ', "-"));
2261        let title = format!("Worker binary for {label}");
2262        let path = match availability {
2263            Ok(WorkerBinaryAvailability::Local { path, .. }) => path,
2264            // A remote worker is fetched by digest when a target is
2265            // provisioned, so it cannot go stale behind a running daemon.
2266            Ok(WorkerBinaryAvailability::Remote { .. }) => continue,
2267            Err(error) => {
2268                checks.push(DoctorCheck::unsupported(
2269                    id,
2270                    title,
2271                    format!("No worker binary resolves for {label}: {error:#}"),
2272                ));
2273                continue;
2274            }
2275        };
2276        let digest = mj_core::worker_launch::worker_executable_digest(&path)
2277            .unwrap_or_else(|error| format!("unreadable ({error:#})"));
2278        let pinned_note = if pinned.is_empty() {
2279            "the daemon has pinned no worker".to_owned()
2280        } else if pinned.contains(&digest) {
2281            "this content is in the daemon's pinned worker cache".to_owned()
2282        } else {
2283            format!(
2284                "the pinned worker cache holds {} instead",
2285                pinned.join(", ")
2286            )
2287        };
2288        let detail = format!("{} has digest {digest}; {pinned_note}.", path.display());
2289        let Some(metadata) = daemon.as_ref() else {
2290            checks.push(DoctorCheck::ready(
2291                id,
2292                title,
2293                format!("{detail} No daemon is running, so the next session uses this file."),
2294            ));
2295            continue;
2296        };
2297        match worker_changed_since_daemon_start(&path, &metadata.started_at) {
2298            Ok(true) => checks.push(DoctorCheck::warning(
2299                id,
2300                title,
2301                format!("{detail} It was rebuilt after daemon {} started, which froze the copy it serves.", metadata.pid),
2302                "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.",
2303            )),
2304            Ok(false) => checks.push(DoctorCheck::ready(id, title, detail)),
2305            Err(error) => checks.push(DoctorCheck::warning(
2306                id,
2307                title,
2308                format!("{detail} Could not compare it with the daemon's start time: {error:#}"),
2309                "Run `mj daemon restart` if rebuilt worker code is not taking effect.",
2310            )),
2311        }
2312    }
2313    checks
2314}
2315
2316/// The architectures this configuration needs a portable Linux worker for.
2317fn container_worker_architectures(config: ConfigStatus<'_>) -> Vec<String> {
2318    let Ok(config) = config else {
2319        return Vec::new();
2320    };
2321    let mut architectures = Vec::new();
2322    for target in config.targets.values() {
2323        let container = match target {
2324            TargetTemplate::LocalPodman { container }
2325            | TargetTemplate::LocalDocker { container }
2326            | TargetTemplate::AppleContainer { container }
2327            | TargetTemplate::SshPodman { container, .. }
2328            | TargetTemplate::SshDocker { container, .. } => container,
2329            _ => continue,
2330        };
2331        let arch = container
2332            .platform
2333            .as_deref()
2334            .and_then(|platform| platform.rsplit('/').next())
2335            .map_or_else(
2336                || std::env::consts::ARCH.to_owned(),
2337                normalized_worker_architecture,
2338            );
2339        if !architectures.contains(&arch) {
2340            architectures.push(arch);
2341        }
2342    }
2343    architectures
2344}
2345
2346/// Container platforms name architectures the way Docker does; worker files
2347/// are named the way Rust target triples do.
2348fn normalized_worker_architecture(platform_arch: &str) -> String {
2349    crate::targets::normalize_architecture(platform_arch)
2350        .unwrap_or(platform_arch)
2351        .to_owned()
2352}
2353
2354/// The digests the daemon's immutable worker cache holds.
2355///
2356/// The cache is never pruned, so this is what any daemon on this machine has
2357/// pinned at some point, which is why it is reported rather than judged.
2358fn pinned_worker_digests() -> Vec<String> {
2359    let root = mj_core::config::data_dir().join("workers").join("pinned");
2360    let Ok(entries) = std::fs::read_dir(root) else {
2361        return Vec::new();
2362    };
2363    let mut digests: Vec<String> = entries
2364        .flatten()
2365        .filter(|entry| entry.path().is_dir())
2366        .filter_map(|entry| entry.file_name().into_string().ok())
2367        .collect();
2368    digests.sort();
2369    digests
2370}
2371
2372/// Whether a worker file was written after the daemon started.
2373///
2374/// The daemon copies the file it finds at startup, so a later modification is
2375/// exactly the case where the running daemon serves older content.
2376fn worker_changed_since_daemon_start(path: &Path, started_at: &str) -> Result<bool> {
2377    let started: SystemTime = chrono::DateTime::parse_from_rfc3339(started_at)
2378        .map_err(|error| anyhow::anyhow!("parse daemon start time {started_at:?}: {error}"))?
2379        .into();
2380    let modified = std::fs::metadata(path)?.modified()?;
2381    Ok(modified > started)
2382}
2383
2384/// The targets the build-cache and worker-binary checks cover: every target
2385/// the user configured, plus each standard local target
2386/// ([`Config::with_local_targets`]) whose engine check in `engine_checks`
2387/// passed. That is the set a new session can use. With no target blocks in
2388/// config.toml, the built-in `podman` target on a host with working Podman
2389/// still needs a worker (launch finding R5-2). A standard target whose
2390/// engine is unavailable is shown as unavailable and needs none.
2391fn offered_targets<'a>(
2392    config: &Config,
2393    engine_checks: impl IntoIterator<Item = &'a DoctorCheck>,
2394) -> Config {
2395    let ready = engine_checks
2396        .into_iter()
2397        .filter(|check| check.status == CheckStatus::Ready)
2398        .map(|check| check.id.as_str())
2399        .collect::<std::collections::BTreeSet<_>>();
2400    let engine_ready = |target_id: &str| match target_id {
2401        "podman" => ready.contains("runtime.podman"),
2402        "docker" => ready.contains("runtime.docker"),
2403        "apple-container" => ready.contains("runtime.apple-container"),
2404        _ => true,
2405    };
2406    let mut offered = config.clone().with_local_targets();
2407    offered
2408        .targets
2409        .retain(|id, _| config.configures_target(id) || engine_ready(id));
2410    offered
2411}
2412
2413fn worker_binary_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
2414    let config = match config {
2415        Ok(config) => config,
2416        Err(ConfigGap::NewerVersion(version)) => {
2417            return vec![newer_config_skip(
2418                "worker.containers",
2419                "Container worker binary",
2420                version,
2421            )];
2422        }
2423        Err(gap @ (ConfigGap::Missing | ConfigGap::Unreadable)) => {
2424            return vec![DoctorCheck::fixable(
2425                "worker.containers",
2426                "Container worker binary",
2427                format!(
2428                    "Worker availability cannot be checked until {}.",
2429                    gap.awaited()
2430                ),
2431                gap.remediation(),
2432            )];
2433        }
2434    };
2435    let containers = config
2436        .targets
2437        .iter()
2438        .filter_map(|(id, target)| match target {
2439            TargetTemplate::LocalPodman { container }
2440            | TargetTemplate::LocalDocker { container }
2441            | TargetTemplate::AppleContainer { container } => Some((id, container, None)),
2442            TargetTemplate::SshPodman { container, .. } => {
2443                Some((id, container, Some("ssh-podman")))
2444            }
2445            TargetTemplate::SshDocker { container, .. } => {
2446                Some((id, container, Some("ssh-docker")))
2447            }
2448            _ => None,
2449        })
2450        .collect::<Vec<_>>();
2451    if containers.is_empty() {
2452        return vec![DoctorCheck::unsupported(
2453            "worker.containers",
2454            "Container worker binary",
2455            "No container target is configured.",
2456        )];
2457    }
2458    containers
2459        .into_iter()
2460        .map(|(id, container, remote_kind)| {
2461            if let Some(remote_kind) = remote_kind
2462                && container.platform.is_none()
2463            {
2464                // The remote CPU architecture is only observable once the host
2465                // is reachable, so an explicit `platform` is required here.
2466                return DoctorCheck::unsupported(
2467                    format!("worker.{id}"),
2468                    format!("Container worker binary for target {id}"),
2469                    format!(
2470                        "Set `platform` on this {remote_kind} target to check its worker binary; the remote architecture is unknown until provisioning."
2471                    ),
2472                );
2473            }
2474            worker_binary_check(id, container)
2475        })
2476        .collect()
2477}
2478
2479/// One worker check per bare SSH target: the worker the host's own platform
2480/// needs, which the daemon refuses a launch without (RVE-2). A macOS host
2481/// needs a Darwin worker, not the Linux one a container check looks for.
2482fn ssh_bare_worker_checks(
2483    config: ConfigStatus<'_>,
2484    executor: &impl CommandExecutor,
2485) -> Vec<DoctorCheck> {
2486    let Ok(config) = config else {
2487        return Vec::new();
2488    };
2489    config
2490        .targets
2491        .iter()
2492        .filter(|(_, target)| matches!(target, TargetTemplate::SshBare { .. }))
2493        .map(|(id, target)| {
2494            let check_id = format!("worker.{id}");
2495            let title = format!("Worker binary for SSH target {id}");
2496            match ssh_worker_binary_prerequisite(target, executor) {
2497                None => DoctorCheck::unsupported(
2498                    check_id,
2499                    title,
2500                    format!(
2501                        "The platform of this host could not be read over SSH, so its worker binary is not checked; see `runtime.ssh-bare.{id}`."
2502                    ),
2503                ),
2504                Some((triple, result)) => worker_source_check(check_id, title, &triple, result),
2505            }
2506        })
2507        .collect()
2508}
2509
2510fn worker_binary_check(id: &str, container: &ContainerTemplate) -> DoctorCheck {
2511    let title = format!("Container worker binary for target {id}");
2512    let arch = match container_architecture(container.platform.as_deref()) {
2513        Ok(arch) => arch,
2514        Err(reason) => {
2515            return DoctorCheck::unsupported(format!("worker.{id}"), title, reason);
2516        }
2517    };
2518    let triple = format!("{arch}-unknown-linux-musl");
2519    worker_source_check(
2520        format!("worker.{id}"),
2521        title,
2522        &triple,
2523        worker_binary_prerequisite_for_arch(arch),
2524    )
2525}
2526
2527fn worker_source_check(
2528    check_id: String,
2529    title: String,
2530    triple: &str,
2531    source: anyhow::Result<WorkerBinaryAvailability>,
2532) -> DoctorCheck {
2533    match source {
2534        Ok(WorkerBinaryAvailability::Local { path, source }) => DoctorCheck::ready(
2535            check_id,
2536            title,
2537            format!(
2538                "{triple} worker is available from {source}: {}",
2539                path.display()
2540            ),
2541        ),
2542        Ok(WorkerBinaryAvailability::Remote { url, .. }) => DoctorCheck::ready(
2543            check_id,
2544            title,
2545            format!("{triple} worker will be verified and downloaded from {url} when needed."),
2546        ),
2547        Err(error) => DoctorCheck::fixable(
2548            check_id,
2549            title,
2550            format!("No usable {triple} worker source: {error:#}"),
2551            format!(
2552                "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."
2553            ),
2554        ),
2555    }
2556}
2557
2558fn container_architecture(platform: Option<&str>) -> std::result::Result<&'static str, String> {
2559    let candidate = platform.unwrap_or(std::env::consts::ARCH);
2560    let candidate = candidate
2561        .split('/')
2562        .rev()
2563        .find(|part| matches!(*part, "x86_64" | "amd64" | "aarch64" | "arm64"))
2564        .unwrap_or(candidate);
2565    crate::targets::normalize_architecture(candidate).map_err(|_| format!(
2566        "Container architecture {candidate:?} is unsupported; Mjolnir supports x86_64 and aarch64 Linux workers."
2567    ))
2568}
2569
2570fn apple_container_image(config: ConfigStatus<'_>) -> String {
2571    config
2572        .ok()
2573        .and_then(|config| {
2574            config.targets.values().find_map(|target| match target {
2575                TargetTemplate::AppleContainer { container } => Some(container.image.clone()),
2576                _ => None,
2577            })
2578        })
2579        .unwrap_or_else(|| DEFAULT_CONTAINER_IMAGE.into())
2580}
2581
2582/// Platform support and daemon readiness shared by setup and doctor.
2583pub fn apple_container_runtime_check(
2584    platform: &ApplePlatform,
2585    executor: &impl CommandExecutor,
2586) -> DoctorCheck {
2587    match platform {
2588        ApplePlatform::Linux => {
2589            return DoctorCheck::unsupported(
2590                "runtime.apple-container",
2591                "Apple container runtime",
2592                "macOS only",
2593            );
2594        }
2595        ApplePlatform::Other(current) => {
2596            return DoctorCheck::unsupported(
2597                "runtime.apple-container",
2598                "Apple container runtime",
2599                format!("macOS only (current platform: {current})"),
2600            );
2601        }
2602        ApplePlatform::Macos {
2603            architecture,
2604            major_version,
2605        } if architecture != "aarch64" && architecture != "arm64" => {
2606            return DoctorCheck::unsupported(
2607                "runtime.apple-container",
2608                "Apple container runtime",
2609                "Apple container requires Apple silicon; Intel Macs are unsupported.",
2610            );
2611        }
2612        ApplePlatform::Macos { major_version, .. } if *major_version < 26 => {
2613            return DoctorCheck::unsupported(
2614                "runtime.apple-container",
2615                "Apple container runtime",
2616                format!("Apple container requires macOS 26 or newer (found {major_version})."),
2617            );
2618        }
2619        ApplePlatform::Macos { .. } => {}
2620    }
2621
2622    apple_container_daemon_check(executor)
2623}
2624
2625pub fn apple_container_check(
2626    platform: &ApplePlatform,
2627    executor: &impl CommandExecutor,
2628    smoke: bool,
2629    image: String,
2630) -> DoctorCheck {
2631    let daemon = apple_container_runtime_check(platform, executor);
2632    if daemon.status != CheckStatus::Ready {
2633        return daemon;
2634    }
2635
2636    if !smoke {
2637        return DoctorCheck::fixable(
2638            "runtime.apple-container",
2639            "Apple container runtime",
2640            "The daemon is running, but the required disposable smoke test was not requested.",
2641            "Run `mj doctor --json --smoke`.",
2642        );
2643    }
2644
2645    let target = RuntimeTargetTemplate::AppleContainer(RuntimeContainerTemplate {
2646        build_cache: None,
2647        image,
2648        pull_policy: Default::default(),
2649        extra_run_args: vec![],
2650        workspace_storage: Default::default(),
2651    });
2652    match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
2653        Ok(()) => DoctorCheck::ready(
2654            "runtime.apple-container",
2655            "Apple container runtime",
2656            "Installed, daemon running, and disposable run/exec/remove smoke test passed.",
2657        ),
2658        Err(error) => DoctorCheck::fixable(
2659            "runtime.apple-container",
2660            "Apple container runtime",
2661            format!("Disposable run/exec/remove smoke test failed: {error:#}"),
2662            "Fix the configured image or container runtime, then run `mj doctor --json --smoke` again.",
2663        ),
2664    }
2665}
2666
2667/// Probe that the Apple `container` command is installed and its daemon is
2668/// running, phrased as a doctor check.
2669///
2670/// Called only after [`apple_container_runtime_check`] checks platform support.
2671fn apple_container_daemon_check(executor: &impl CommandExecutor) -> DoctorCheck {
2672    let installed =
2673        CommandSpec::new("container", ["--version"]).purpose("check Apple container installation");
2674    match executor.execute(&installed) {
2675        Err(error) => {
2676            return DoctorCheck::fixable(
2677                "runtime.apple-container",
2678                "Apple container runtime",
2679                format!(
2680                    "The `container` command is not available: {}",
2681                    error.root_cause()
2682                ),
2683                format!("Install the official signed package: {APPLE_CONTAINER_INSTALL_URL}"),
2684            );
2685        }
2686        Ok(output) if output.status != 0 => {
2687            return DoctorCheck::fixable(
2688                "runtime.apple-container",
2689                "Apple container runtime",
2690                format!(
2691                    "The installed `container --version` command failed: {}",
2692                    String::from_utf8_lossy(&output.stderr).trim()
2693                ),
2694                format!("Reinstall the official signed package: {APPLE_CONTAINER_INSTALL_URL}"),
2695            );
2696        }
2697        Ok(_) => {}
2698    }
2699
2700    let status =
2701        CommandSpec::new("container", ["system", "status"]).purpose("check Apple container daemon");
2702    match executor.execute(&status) {
2703        Ok(output) if output.status == 0 => DoctorCheck::ready(
2704            "runtime.apple-container",
2705            "Apple container runtime",
2706            "Installed, and the Apple container daemon is running.",
2707        ),
2708        Ok(output) => DoctorCheck::fixable(
2709            "runtime.apple-container",
2710            "Apple container runtime",
2711            format!(
2712                "The Apple container daemon is stopped: {}",
2713                String::from_utf8_lossy(&output.stderr).trim()
2714            ),
2715            "Run `container system start`.",
2716        ),
2717        Err(error) => DoctorCheck::fixable(
2718            "runtime.apple-container",
2719            "Apple container runtime",
2720            format!("Could not query the Apple container daemon: {error}"),
2721            "Run `container system start`.",
2722        ),
2723    }
2724}
2725
2726pub fn current_apple_platform(executor: &impl CommandExecutor) -> ApplePlatform {
2727    if cfg!(target_os = "linux") {
2728        return ApplePlatform::Linux;
2729    }
2730    if !cfg!(target_os = "macos") {
2731        return ApplePlatform::Other(std::env::consts::OS.into());
2732    }
2733    let major_version = executor
2734        .execute(&CommandSpec::new("sw_vers", ["-productVersion"]).purpose("detect macOS version"))
2735        .ok()
2736        .filter(|output| output.status == 0)
2737        .and_then(|output| {
2738            String::from_utf8(output.stdout)
2739                .ok()
2740                .and_then(|value| value.trim().split('.').next()?.parse().ok())
2741        })
2742        .unwrap_or(0);
2743    ApplePlatform::Macos {
2744        architecture: std::env::consts::ARCH.into(),
2745        major_version,
2746    }
2747}
2748
2749#[cfg(test)]
2750mod tests;
2751
2752/// What one repository still holds from a Mjolnir review capture.
2753#[derive(Debug, Default, PartialEq, Eq)]
2754pub(crate) struct ReviewResidue {
2755    /// `refs/hel/*` refs in the repository.
2756    pub refs: Vec<String>,
2757    /// Scratch index files left in the Git directory by an interrupted capture.
2758    pub scratch_indexes: Vec<PathBuf>,
2759}
2760
2761impl ReviewResidue {
2762    fn is_empty(&self) -> bool {
2763        self.refs.is_empty() && self.scratch_indexes.is_empty()
2764    }
2765}
2766
2767/// Read what a repository still holds from Mjolnir's review captures.
2768///
2769/// Releases before this one staged the whole working tree into the user's own
2770/// object store and pinned it with two refs, and a capture that was killed
2771/// partway left its scratch index behind. Both are the user's to remove, so
2772/// this only reads.
2773pub(crate) fn review_residue(repository: &Path) -> ReviewResidue {
2774    let mut residue = ReviewResidue::default();
2775    let git_dir = repository.join(".git");
2776    if !git_dir.exists() {
2777        return residue;
2778    }
2779    for reference in ["review-baseline", "review-capture"] {
2780        if git_dir.join("refs/hel").join(reference).is_file() {
2781            residue.refs.push(format!("refs/hel/{reference}"));
2782        }
2783    }
2784    // A packed ref survives `git pack-refs`, which a `git gc` runs.
2785    if let Ok(packed) = std::fs::read_to_string(git_dir.join("packed-refs")) {
2786        for line in packed.lines() {
2787            if let Some((_, reference)) = line.split_once(' ')
2788                && reference.starts_with("refs/hel/")
2789                && !residue.refs.iter().any(|known| known == reference)
2790            {
2791                residue.refs.push(reference.to_owned());
2792            }
2793        }
2794    }
2795    if let Ok(entries) = std::fs::read_dir(&git_dir) {
2796        for entry in entries.filter_map(Result::ok) {
2797            if entry
2798                .file_name()
2799                .to_str()
2800                .is_some_and(|name| name.starts_with("hel-review-index-"))
2801            {
2802                residue.scratch_indexes.push(entry.path());
2803            }
2804        }
2805    }
2806    residue.refs.sort();
2807    residue.scratch_indexes.sort();
2808    residue
2809}
2810
2811/// The repositories a person works in by hand, where review leftovers are
2812/// worth reporting.
2813///
2814/// These are the configured bundle repositories and the directories sessions
2815/// started with `--project-directory` use. A session's own managed checkout
2816/// (`<repository>/.mj/clones/<id>` or `<repository>/.mj/worktrees/<id>`) is
2817/// Mjolnir's working state, not a leftover, so it is never reported. Neither is
2818/// a repository a live session is working in, because its refs are in use; a
2819/// linked worktree shares its refs with the repository it came from, so a live
2820/// worktree session keeps that repository out too.
2821pub(crate) fn review_residue_repositories(
2822    configured: impl IntoIterator<Item = PathBuf>,
2823    sessions: &[&mj_core::state::SessionRecord],
2824) -> Vec<PathBuf> {
2825    use mj_core::state::ManagedCheckoutKind;
2826
2827    let managed_roots = sessions
2828        .iter()
2829        .filter_map(|session| session.managed_worktree.as_ref())
2830        .map(|worktree| worktree.worktree_root.as_path())
2831        .collect::<Vec<_>>();
2832    let mut in_use = Vec::new();
2833    for session in sessions.iter().filter(|session| session.state.is_active()) {
2834        if let Some(directory) = &session.project_directory {
2835            in_use.push(directory.as_path());
2836        }
2837        if let Some(worktree) = &session.managed_worktree
2838            && worktree.kind == ManagedCheckoutKind::Worktree
2839        {
2840            in_use.push(worktree.source_repository.as_path());
2841        }
2842    }
2843    let mut repositories = configured
2844        .into_iter()
2845        .chain(
2846            sessions
2847                .iter()
2848                .filter_map(|session| session.project_directory.clone()),
2849        )
2850        .filter(|repository| {
2851            !is_inside_managed_checkout(repository)
2852                && !managed_roots
2853                    .iter()
2854                    .any(|root| repository.starts_with(root))
2855                && !in_use.iter().any(|directory| repository == directory)
2856        })
2857        .collect::<Vec<_>>();
2858    repositories.sort();
2859    repositories.dedup();
2860    repositories
2861}
2862
2863/// Whether `path` is at or under `<repository>/.mj/clones/<id>` or
2864/// `<repository>/.mj/worktrees/<id>`.
2865fn is_inside_managed_checkout(path: &Path) -> bool {
2866    let components = path
2867        .components()
2868        .map(|component| component.as_os_str())
2869        .collect::<Vec<_>>();
2870    components
2871        .windows(3)
2872        .any(|window| window[0] == ".mj" && (window[1] == "clones" || window[1] == "worktrees"))
2873}
2874
2875/// Report Mjolnir's own leftovers in the repositories the configuration names.
2876///
2877/// This deletes nothing. Removing refs and running `git gc` in someone else's
2878/// repository without asking is the same mistake as writing to it without
2879/// asking, which is what left this residue in the first place.
2880fn review_residue_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
2881    let Ok(config) = config else {
2882        return Vec::new();
2883    };
2884    let configured = config
2885        .bundles
2886        .values()
2887        .flat_map(|bundle| bundle.repositories.iter())
2888        .filter_map(|repository| repository.local.clone());
2889    // A daemon-less machine has no session database, which is not a reason to
2890    // skip the configured repositories.
2891    let state = crate::database::load_state().ok();
2892    let sessions = state
2893        .as_ref()
2894        .map(|state| state.sessions.values().collect::<Vec<_>>())
2895        .unwrap_or_default();
2896    let repositories = review_residue_repositories(configured, &sessions);
2897    if repositories.is_empty() {
2898        return Vec::new();
2899    }
2900    let found = repositories
2901        .into_iter()
2902        .map(|repository| {
2903            let residue = review_residue(&repository);
2904            (repository, residue)
2905        })
2906        .filter(|(_, residue)| !residue.is_empty())
2907        .collect::<Vec<_>>();
2908    if found.is_empty() {
2909        return vec![DoctorCheck::ready(
2910            "review.residue",
2911            "Review leftovers in your repositories",
2912            "No Mjolnir refs or scratch index files were found in the configured repositories.",
2913        )];
2914    }
2915    let detail = found
2916        .iter()
2917        .map(|(repository, residue)| {
2918            let mut parts = Vec::new();
2919            if !residue.refs.is_empty() {
2920                parts.push(residue.refs.join(", "));
2921            }
2922            if !residue.scratch_indexes.is_empty() {
2923                parts.push(mj_core::text::counted(
2924                    residue.scratch_indexes.len(),
2925                    "leftover scratch index file",
2926                    "leftover scratch index files",
2927                ));
2928            }
2929            format!("{}: {}", repository.display(), parts.join("; "))
2930        })
2931        .collect::<Vec<_>>()
2932        .join(". ");
2933    let commands = found
2934        .iter()
2935        .flat_map(|(repository, residue)| {
2936            let repository = repository.display().to_string();
2937            let mut commands = residue
2938                .refs
2939                .iter()
2940                .map(|reference| format!("git -C {repository} update-ref -d {reference}"))
2941                .collect::<Vec<_>>();
2942            commands.extend(
2943                residue
2944                    .scratch_indexes
2945                    .iter()
2946                    .map(|index| format!("rm -f {}", index.display())),
2947            );
2948            commands.push(format!("git -C {repository} gc --prune=now"));
2949            commands
2950        })
2951        .collect::<Vec<_>>()
2952        .join("\n");
2953    vec![DoctorCheck::fixable(
2954        "review.residue",
2955        "Review leftovers in your repositories",
2956        format!("Mjolnir left these in repositories it does not own: {detail}."),
2957        format!("Remove them yourself when you are ready:\n{commands}"),
2958    )]
2959}
2960
2961/// The Bifrost a turn review would run on this machine, and whether it is new
2962/// enough. A review needs Bifrost's `analyze_diff`, which older releases lack,
2963/// and an old `bifrost` on the login `PATH` is otherwise found only when the
2964/// first review fails. This is a warning: containers carry their own Bifrost,
2965/// and reviews may be off. The check reads `MJ_BIFROST_BIN` from the
2966/// environment `mj doctor` runs in, which is the daemon's environment when the
2967/// daemon was started from the same shell.
2968fn bifrost_check(config: ConfigStatus<'_>, executor: &impl CommandExecutor) -> Option<DoctorCheck> {
2969    let config = config.ok()?;
2970    if !mj_core::review::settings::can_review(config) {
2971        return None;
2972    }
2973    Some(bifrost_check_for(
2974        &mj_review::bifrost::bifrost_binary(),
2975        executor,
2976    ))
2977}
2978
2979fn bifrost_check_for(binary: &Path, executor: &impl CommandExecutor) -> DoctorCheck {
2980    const ID: &str = "review.bifrost";
2981    const TITLE: &str = "Bifrost for turn review";
2982    let required = mj_review::bifrost::REQUIRED_BIFROST_VERSION;
2983    let shown = binary.display();
2984    let remediation = format!(
2985        "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`.",
2986        env = mj_review::bifrost::BIFROST_BIN_ENV,
2987    );
2988    let command = CommandSpec::new(binary.display().to_string(), ["--version"])
2989        .purpose("read the Bifrost version used by turn review");
2990    let output = match executor.execute(&command) {
2991        Ok(output) if output.status == 0 => output,
2992        Ok(output) => {
2993            return DoctorCheck::warning(
2994                ID,
2995                TITLE,
2996                format!("`{shown} --version` exited with status {}.", output.status),
2997                remediation,
2998            );
2999        }
3000        Err(error) => {
3001            return DoctorCheck::warning(
3002                ID,
3003                TITLE,
3004                format!("Could not run `{shown}` for the turn review: {error}"),
3005                remediation,
3006            );
3007        }
3008    };
3009    let text = String::from_utf8_lossy(&output.stdout);
3010    let first_line = text.lines().next().unwrap_or_default().trim();
3011    let version = first_line
3012        .split_whitespace()
3013        .next_back()
3014        .and_then(|token| semver::Version::parse(token).ok());
3015    let minimum = semver::Version::parse(required).expect("the required Bifrost version is semver");
3016    match version {
3017        Some(version) if version >= minimum => DoctorCheck::ready(
3018            ID,
3019            TITLE,
3020            format!("`{shown}` is Bifrost {version}; turn review needs {required} or later."),
3021        ),
3022        Some(version) => DoctorCheck::warning(
3023            ID,
3024            TITLE,
3025            format!(
3026                "`{shown}` is Bifrost {version}, older than the {required} turn review needs, so every review would fail."
3027            ),
3028            remediation,
3029        ),
3030        None => DoctorCheck::warning(
3031            ID,
3032            TITLE,
3033            format!("`{shown} --version` printed {first_line:?}, which does not name a version."),
3034            remediation,
3035        ),
3036    }
3037}