Skip to main content

mj_controller/
doctor.rs

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