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