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