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