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