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