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::{WorkerBinaryAvailability, worker_binary_prerequisite_for_arch};
11use crate::setup::{
12    DiscoveredHome, discover_harness_homes_with_executor, harness_is_authenticated_with_executor,
13};
14use crate::targets::{
15    BoundedProcessExecutor, CommandExecutor, CommandSpec, CommandTimedOut,
16    ContainerTemplate as RuntimeContainerTemplate, PodmanProbe, ProcessExecutor,
17    SshTarget as RuntimeSshTarget, TargetTemplate as RuntimeTargetTemplate, failed_podman_probe,
18    run_setup_smoke_test, ssh_command, ssh_connectivity_probe, ssh_validation_command,
19    verify_local_docker, verify_local_podman, verify_ssh_docker, verify_ssh_podman,
20};
21use mj_core::config::{
22    Config, ContainerTemplate, HarnessHost, HarnessKind, HarnessProfile, TargetTemplate,
23    config_path,
24};
25use mj_core::credentials::login_command;
26
27// Only the image for the Apple container smoke test when the config has no
28// apple-container target. This intentionally stays a small stock image rather
29// than setup::DEFAULT_IMAGE: the check just proves the runtime can start a
30// container, and pulling the multi-gigabyte agent-dev image to do that would be
31// a poor trade.
32const DEFAULT_CONTAINER_IMAGE: &str = "ubuntu:24.04";
33const APPLE_CONTAINER_INSTALL_URL: &str = "https://github.com/apple/container#initial-install";
34
35/// How long a single prerequisite probe may take before doctor reports it as a
36/// fixable check instead of waiting for it.
37///
38/// Every probe outside the opt-in smoke tests is a local or short network call,
39/// so this only ever fires for a wedged runtime socket, a blackholed network,
40/// or a credential helper waiting on something that will never arrive.
41pub const PROBE_TIMEOUT: Duration = Duration::from_secs(15);
42
43/// The executor `mj doctor` and `mj setup` run their prerequisite probes
44/// through: one deadline per probe, so a wedged runtime cannot hang the run.
45pub const fn probe_executor() -> BoundedProcessExecutor {
46    BoundedProcessExecutor::new(PROBE_TIMEOUT)
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
50#[serde(rename_all = "lowercase")]
51pub enum CheckStatus {
52    Ready,
53    Warning,
54    Fixable,
55    Unsupported,
56}
57
58impl CheckStatus {
59    pub const fn label(self) -> &'static str {
60        match self {
61            Self::Ready => "ready",
62            Self::Warning => "warning",
63            Self::Fixable => "fixable",
64            Self::Unsupported => "unsupported",
65        }
66    }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
70pub struct DoctorCheck {
71    pub id: String,
72    pub title: String,
73    pub status: CheckStatus,
74    pub detail: String,
75    pub remediation: Option<String>,
76}
77
78impl DoctorCheck {
79    fn ready(id: impl Into<String>, title: impl Into<String>, detail: impl Into<String>) -> Self {
80        Self {
81            id: id.into(),
82            title: title.into(),
83            status: CheckStatus::Ready,
84            detail: detail.into(),
85            remediation: None,
86        }
87    }
88
89    fn warning(
90        id: impl Into<String>,
91        title: impl Into<String>,
92        detail: impl Into<String>,
93        remediation: impl Into<String>,
94    ) -> Self {
95        Self {
96            id: id.into(),
97            title: title.into(),
98            status: CheckStatus::Warning,
99            detail: detail.into(),
100            remediation: Some(remediation.into()),
101        }
102    }
103
104    pub(crate) fn fixable(
105        id: impl Into<String>,
106        title: impl Into<String>,
107        detail: impl Into<String>,
108        remediation: impl Into<String>,
109    ) -> Self {
110        Self {
111            id: id.into(),
112            title: title.into(),
113            status: CheckStatus::Fixable,
114            detail: detail.into(),
115            remediation: Some(remediation.into()),
116        }
117    }
118
119    fn unsupported(
120        id: impl Into<String>,
121        title: impl Into<String>,
122        detail: impl Into<String>,
123    ) -> Self {
124        Self {
125            id: id.into(),
126            title: title.into(),
127            status: CheckStatus::Unsupported,
128            detail: detail.into(),
129            remediation: None,
130        }
131    }
132}
133
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub struct DoctorOptions {
136    pub smoke: bool,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub enum ApplePlatform {
141    Linux,
142    Macos {
143        architecture: String,
144        major_version: u32,
145    },
146    Other(String),
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub enum InstructionsPlatform {
151    Linux,
152    Macos,
153}
154
155pub fn run_current(options: DoctorOptions) -> Vec<DoctorCheck> {
156    if options.smoke {
157        // A smoke test may legitimately pull a multi-gigabyte image, which no
158        // probe deadline could tell apart from a hung runtime, so an opt-in
159        // `--smoke` run keeps waiting for its commands.
160        return run_with(
161            &ProcessExecutor,
162            current_apple_platform(&ProcessExecutor),
163            options,
164        );
165    }
166    let executor = probe_executor();
167    run_with(&executor, current_apple_platform(&executor), options)
168}
169
170pub fn run_with(
171    executor: &impl CommandExecutor,
172    apple_platform: ApplePlatform,
173    options: DoctorOptions,
174) -> Vec<DoctorCheck> {
175    run_with_config_path(&config_path(), executor, apple_platform, options)
176}
177
178/// The same checks as [`run_with`], against an explicit configuration file.
179///
180/// `mj setup` uses this to report on the configuration it just wrote, so a
181/// first run ends with exactly the summary and remediations `mj doctor`
182/// would print.
183pub fn run_with_config_path(
184    config_path: &Path,
185    executor: &impl CommandExecutor,
186    apple_platform: ApplePlatform,
187    options: DoctorOptions,
188) -> Vec<DoctorCheck> {
189    let (loaded, mut checks) = configuration_checks(config_path);
190    let config: ConfigStatus<'_> = loaded.as_ref().map_err(|gap| *gap);
191    checks.push(harness_discovery_check(config, executor));
192    checks.extend(harness_checks(config, executor));
193    checks.extend(subagent_eligibility_checks(config));
194    checks.extend(podman_checks(config, executor, options.smoke));
195    checks.extend(docker_checks(config, executor, options.smoke));
196    checks.extend(ssh_bare_checks(config, executor));
197    checks.extend(ssh_podman_checks(config, executor, options.smoke));
198    checks.extend(ssh_docker_checks(config, executor, options.smoke));
199    checks.extend(aws_checks(config, executor));
200    checks.extend(worker_binary_checks(config));
201    checks.push(daemon_build_check());
202    checks.extend(worker_freshness_checks(config));
203    checks.extend(review_residue_checks(config));
204    checks.push(apple_container_check(
205        &apple_platform,
206        executor,
207        options.smoke,
208        apple_container_image(config),
209    ));
210    checks
211}
212
213fn harness_discovery_check(
214    config: ConfigStatus<'_>,
215    executor: &impl CommandExecutor,
216) -> DoctorCheck {
217    let home = dirs::home_dir();
218    let overrides = HarnessKind::ALL.into_iter().filter_map(|kind| {
219        std::env::var_os(kind.home_env()).map(|path| (kind, kind.home_from_environment(path)))
220    });
221    let discovered = discover_harness_homes_with_executor(home.as_deref(), overrides, executor);
222    harness_discovery_check_from(
223        &discovered,
224        config.is_ok_and(|config| !config.profiles.is_empty()),
225    )
226}
227
228fn harness_discovery_check_from(
229    discovered: &[DiscoveredHome],
230    has_configured_profiles: bool,
231) -> DoctorCheck {
232    if discovered.is_empty() {
233        return if has_configured_profiles {
234            DoctorCheck::ready(
235                "harness.discovery",
236                "Harness home discovery",
237                "No default or environment-overridden harness homes were found; configured profile homes are checked below.",
238            )
239        } else {
240            DoctorCheck::fixable(
241                "harness.discovery",
242                "Harness home discovery",
243                "No Codex, Claude Code, Kimi Code, or Grok Build home was found in the default or environment-overridden locations.",
244                "Install and sign in to a supported harness, then open F7 Settings → Agent Profiles.",
245            )
246        };
247    }
248
249    let homes = discovered
250        .iter()
251        .map(|home| {
252            let authentication = if home.authenticated {
253                "authenticated"
254            } else {
255                "not authenticated"
256            };
257            format!(
258                "{} at {} ({authentication})",
259                home.kind.display_name(),
260                home.path.display()
261            )
262        })
263        .collect::<Vec<_>>()
264        .join("; ");
265    DoctorCheck::ready(
266        "harness.discovery",
267        "Harness home discovery",
268        format!("Discovered {homes}. Configured profile authentication is checked below."),
269    )
270}
271
272pub fn all_ready(checks: &[DoctorCheck]) -> bool {
273    checks
274        .iter()
275        .all(|check| check.status != CheckStatus::Fixable)
276}
277
278pub fn render_human(checks: &[DoctorCheck], output: &mut impl Write) -> Result<()> {
279    for check in checks {
280        writeln!(
281            output,
282            "{} {}: {}",
283            check.status.label(),
284            check.title,
285            check.detail
286        )?;
287        if let Some(remediation) = &check.remediation {
288            writeln!(output, "  remediation: {remediation}")?;
289        }
290    }
291    Ok(())
292}
293
294pub fn setup_instructions(platform: InstructionsPlatform) -> String {
295    match platform {
296        InstructionsPlatform::Linux => format!(
297            "# Hel setup instructions for Linux\n\n\
298This page is self-contained. Follow this exact loop as the user who will run Hel:\n\n\
2991. Run `mj doctor --json`.\n\
3002. Follow every `fixable` remediation from its JSON output.\n\
3013. Run `mj doctor --json` again. Repeat until no check is `fixable`.\n\
3024. Finish with `mj doctor --json --smoke` to verify every configured container\n\
303   image end to end, and resolve anything it reports as `fixable`.\n\n\
304For a coding-agent handoff, provide this entire instructions page together with\n\
305the latest `mj doctor --json` output.\n\n\
306## Linux container-runtime postconditions\n\n{}\n\n{}",
307            crate::targets::PODMAN_DOCUMENTATION,
308            crate::targets::DOCKER_DOCUMENTATION
309        ),
310        InstructionsPlatform::Macos => format!(
311            "# Hel setup instructions for macOS\n\n\
312This page is self-contained. Follow this exact loop as the user who will run Hel:\n\n\
3131. Run `mj doctor --json`.\n\
3142. Follow every `fixable` remediation from its JSON output.\n\
3153. Run `mj doctor --json` again. Repeat until no check is `fixable`.\n\n\
316For a coding-agent handoff, provide this entire instructions page together with\n\
317the latest `mj doctor --json` output.\n\n\
318## Apple container runtime\n\n\
319Hel's Apple container target requires Apple silicon and macOS 26 or newer.\n\
320On an Intel Mac or an older macOS release, the target is unsupported; use a\n\
321local Podman, SSH, or AWS target instead.\n\n\
322If the `container` command is absent, install only the official signed package:\n\n\
323<https://github.com/apple/container#initial-install>\n\n\
324Hel never downloads or installs that package. If doctor reports a stopped\n\
325daemon, run exactly:\n\n```console\ncontainer system start\n```\n\n\
326Finish with the opt-in disposable runtime test in JSON mode:\n\n```console\nmj doctor --json --smoke\n```\n\n\
327Apple container is ready only when that smoke test creates a disposable\n\
328container, executes `true` in it, and removes it successfully. Use the image\n\
329configured by an `apple-container` target; without one, doctor uses\n\
330`{DEFAULT_CONTAINER_IMAGE}` for the smoke test.\n\n\
331## Shared Hel prerequisites\n\n\
332`mj doctor --json` also checks the configuration, each configured harness home\n\
333and authentication marker, selected container worker binaries, and any relevant\n\
334Podman prerequisites. Resolve every `fixable` status before starting a session."
335        ),
336    }
337}
338
339/// Why `mj doctor` has no configuration for the checks that need one.
340///
341/// The two cases call for opposite advice, so every dependent check is told
342/// which one it is: a file this build cannot read because a newer Mjolnir
343/// wrote it is not broken, and telling the user to fix or replace it would
344/// destroy that build's settings.
345#[derive(Debug, Clone, Copy, PartialEq, Eq)]
346enum ConfigGap {
347    /// A newer Mjolnir wrote the file; the value is the version it carries.
348    NewerVersion(u32),
349    /// The file is missing, or is not valid Mjolnir TOML.
350    Unreadable,
351}
352
353/// What a dependent check works from: the loaded configuration, or why there
354/// is none.
355type ConfigStatus<'a> = std::result::Result<&'a Config, ConfigGap>;
356
357/// What a check reports when the configuration came from a newer Mjolnir.
358///
359/// Nothing about the check can be evaluated, and nothing the user does to
360/// `config.toml` would help, so the check skips and names the one real fix.
361fn newer_config_skip(id: &str, title: &str, version: u32) -> DoctorCheck {
362    DoctorCheck::unsupported(
363        id,
364        title,
365        format!(
366            "Skipped: config.toml was written by a newer Mjolnir (config version {version}; this build supports {}). Update Mjolnir to that build or newer.",
367            mj_core::config::CONFIG_VERSION
368        ),
369    )
370}
371
372fn configuration_checks(path: &Path) -> (std::result::Result<Config, ConfigGap>, Vec<DoctorCheck>) {
373    if !path.exists() {
374        return (
375            Err(ConfigGap::Unreadable),
376            vec![DoctorCheck::fixable(
377                "config",
378                "Mjolnir configuration",
379                format!("{} does not exist", path.display()),
380                "Open Mjolnir and press F7 for Settings to add an agent profile.",
381            )],
382        );
383    }
384    // A config a newer build wrote is not broken TOML: replacing it with
385    // `mj setup` would discard that build's settings. Say what is actually
386    // wrong before the load below reports it as invalid.
387    if let Some(found) = mj_core::config::newer_version_on_disk(path) {
388        return (
389            Err(ConfigGap::NewerVersion(found)),
390            vec![DoctorCheck::fixable(
391                "config",
392                "Mjolnir configuration",
393                format!(
394                    "{} was written by a newer Mjolnir (config version {found}; this build supports {})",
395                    path.display(),
396                    mj_core::config::CONFIG_VERSION
397                ),
398                "Update Mjolnir to that build or newer. Do not lower the version value by hand or replace the file.",
399            )],
400        );
401    }
402    match Config::load_from(path) {
403        Ok(config) => {
404            let mut checks = vec![DoctorCheck::ready(
405                "config",
406                "Mjolnir configuration",
407                format!("{} is valid", path.display()),
408            )];
409            if config.enabled_profiles().next().is_none() || config.bundles.is_empty() {
410                checks.push(DoctorCheck::fixable(
411                    "config.session-prerequisites",
412                    "Session configuration",
413                    "An enabled profile and project bundle are required for configured bundle sessions. Local targets are supplied automatically.",
414                    "Open F7 Settings to add or enable agent profiles and projects.",
415                ));
416            } else {
417                checks.push(DoctorCheck::ready(
418                    "config.session-prerequisites",
419                    "Session configuration",
420                    "At least one profile, bundle, and target are configured.",
421                ));
422            }
423            (Ok(config), checks)
424        }
425        Err(error) => (
426            Err(ConfigGap::Unreadable),
427            vec![DoctorCheck::fixable(
428                "config",
429                "Mjolnir configuration",
430                format!("{} is invalid: {error:#}", path.display()),
431                "Fix the reported TOML error in config.toml, or run `mj setup` to replace it.",
432            )],
433        ),
434    }
435}
436
437fn harness_checks(config: ConfigStatus<'_>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
438    let config = match config {
439        Ok(config) => config,
440        Err(ConfigGap::NewerVersion(version)) => {
441            return vec![newer_config_skip(
442                "harness.profiles",
443                "Harness profiles",
444                version,
445            )];
446        }
447        Err(ConfigGap::Unreadable) => {
448            return vec![DoctorCheck::fixable(
449                "harness.profiles",
450                "Harness profiles",
451                "Harness homes cannot be checked until config.toml is valid.",
452                "Fix config.toml, then rerun `mj doctor --json`.",
453            )];
454        }
455    };
456    if config.profiles.is_empty() {
457        return vec![DoctorCheck::fixable(
458            "harness.profiles",
459            "Harness profiles",
460            "No harness profiles are configured.",
461            "Open F7 Settings → Agent Profiles to detect accounts or add a profile.",
462        )];
463    }
464    config
465        .profiles
466        .iter()
467        .map(|(id, profile)| {
468            let title = format!("Harness profile {id}");
469            if !profile.enabled {
470                return DoctorCheck::ready(
471                    format!("harness.{id}"),
472                    title,
473                    "Profile is disabled; home and authentication checks were skipped.",
474                );
475            }
476            if let Some(default_home) = unscopable_home_is_ignored(config, profile) {
477                return DoctorCheck::fixable(
478                    format!("harness.{id}"),
479                    title,
480                    format!(
481                        "{} is ignored by a session on this machine: {} on macOS reads {} \
482                         whatever {} says",
483                        profile.home.display(),
484                        profile.kind.display_name(),
485                        default_home.display(),
486                        profile.kind.home_env(),
487                    ),
488                    format!(
489                        "Set this profile's home to {}, or use it only on container and SSH \
490                         targets, where the home is still scoped.",
491                        default_home.display()
492                    ),
493                );
494            }
495            if !profile.home.is_dir() {
496                return DoctorCheck::fixable(
497                    format!("harness.{id}"),
498                    title,
499                    format!("{} does not exist", profile.home.display()),
500                    format!(
501                        "{} If this profile should use an existing installation, select its home in Setup.",
502                        harness_login_remediation(id, profile)
503                    ),
504                );
505            }
506            if !harness_is_authenticated_with_executor(profile, executor) {
507                return DoctorCheck::fixable(
508                    format!("harness.{id}"),
509                    title,
510                    format!(
511                        "No usable authentication was detected for {}",
512                        profile.home.display()
513                    ),
514                    harness_login_remediation(id, profile),
515                );
516            }
517            DoctorCheck::ready(
518                format!("harness.{id}"),
519                title,
520                format!(
521                    "{} is present and authentication is available",
522                    profile.home.display()
523                ),
524            )
525        })
526        .collect()
527}
528
529/// The harness's own default home, for a profile whose configured home this
530/// machine cannot scope, and `None` when the home is honored as configured.
531///
532/// Claude Code on macOS is the only such case today: Mjolnir sets no
533/// `CLAUDE_CONFIG_DIR` there, so a profile pointing anywhere but Claude's own
534/// home would be silently unused. Saying so is better than letting the session
535/// run against a home nobody configured.
536fn unscopable_home_is_ignored(config: &Config, profile: &HarnessProfile) -> Option<PathBuf> {
537    if profile
538        .kind
539        .scopes_home_with_environment(HarnessHost::current())
540    {
541        return None;
542    }
543    // The variable still scopes a home on a container or SSH target, so a
544    // profile that can only run there is configured correctly and must not be
545    // told to collapse the separation its sessions rely on.
546    if !config
547        .targets
548        .values()
549        .any(|target| matches!(target, TargetTemplate::LocalBare))
550    {
551        return None;
552    }
553    let default_home = dirs::home_dir()?.join(profile.kind.default_home_leaf());
554    (profile.home != default_home).then_some(default_home)
555}
556
557/// Warn about a profile that is both listed for sub-agent use and disabled.
558///
559/// The daemon keeps running and simply does not offer such a profile to a
560/// parent, because the delegation candidates and the spawn gate both require an
561/// enabled profile. This surfaces the contradiction so the eligible list and
562/// the profile's `enabled` flag can be reconciled, rather than leaving a profile
563/// the user meant to use silently unavailable.
564fn subagent_eligibility_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
565    let Ok(config) = config else {
566        return Vec::new();
567    };
568    config
569        .subagents
570        .eligible_profiles
571        .iter()
572        .filter(|(_, eligible)| **eligible)
573        .filter_map(|(id, _)| {
574            let profile = config.profiles.get(id)?;
575            (!profile.enabled).then(|| {
576                DoctorCheck::warning(
577                    format!("subagents.{id}"),
578                    format!("Sub-agent profile {id}"),
579                    format!(
580                        "Profile {id:?} is listed in [subagents.eligible_profiles] but is disabled, so it is not offered for sub-agent use."
581                    ),
582                    format!(
583                        "Re-enable profile {id:?}, or remove it from [subagents.eligible_profiles]."
584                    ),
585                )
586            })
587        })
588        .collect()
589}
590
591/// Point an unauthenticated profile at `mj login`, which already knows how to
592/// sign each harness in.
593///
594/// The underlying command is named only for the reader's benefit; it comes from
595/// [`login_command`], the one place that tracks what each harness CLI actually
596/// accepts, so this text cannot drift away from what `mj login` runs.
597fn harness_login_remediation(id: &str, profile: &HarnessProfile) -> String {
598    let (program, arguments) = match login_command(profile) {
599        Ok(command) => command,
600        // An API-key profile has no login to recommend; say what is missing
601        // instead. The authentication gate normally passes such a profile, so
602        // this text appears only when its configuration file is absent.
603        Err(error) => return format!("{error} Check {}.", profile.home.display()),
604    };
605    format!(
606        "Run `mj login --profile {id}`; it runs `{program} {}` against {}.",
607        arguments.join(" "),
608        profile.home.display()
609    )
610}
611
612/// Host Podman prerequisites, then one image check per `local-podman` target.
613///
614/// The image checks run only after the host preflight passes, because a broken
615/// Podman installation already reports its own actionable check.
616fn podman_checks(
617    config: ConfigStatus<'_>,
618    executor: &impl CommandExecutor,
619    smoke: bool,
620) -> Vec<DoctorCheck> {
621    let preflight = podman_check(config, executor);
622    let preflight_passed = preflight.status == CheckStatus::Ready;
623    let mut checks = vec![preflight];
624    if preflight_passed {
625        checks.extend(podman_image_checks(config, executor, smoke));
626    }
627    checks
628}
629
630fn podman_check(config: ConfigStatus<'_>, executor: &impl CommandExecutor) -> DoctorCheck {
631    let config = match config {
632        Ok(config) => config,
633        Err(ConfigGap::NewerVersion(version)) => {
634            return newer_config_skip("runtime.podman", "Rootless Podman", version);
635        }
636        Err(ConfigGap::Unreadable) => {
637            return DoctorCheck::unsupported(
638                "runtime.podman",
639                "Rootless Podman",
640                "Podman prerequisites cannot be evaluated until config.toml is valid.",
641            );
642        }
643    };
644    if local_podman_targets(config).is_empty() {
645        return DoctorCheck::unsupported(
646            "runtime.podman",
647            "Rootless Podman",
648            "No local-podman target is configured.",
649        );
650    }
651    local_podman_runtime_check(executor)
652}
653
654/// Probe the local rootless Podman prerequisites and phrase the result as a
655/// doctor check.
656///
657/// This is the single source of truth for Podman availability wording and
658/// remediation. `mj setup` calls it directly so its runtime list reports the
659/// same detail and fix that `mj doctor` would.
660pub fn local_podman_runtime_check(executor: &impl CommandExecutor) -> DoctorCheck {
661    match verify_local_podman(executor) {
662        Ok(preflight) => DoctorCheck::ready(
663            "runtime.podman",
664            "Rootless Podman",
665            format!("Podman {} has a valid rootless UID map.", preflight.version),
666        ),
667        Err(error) => {
668            let detail = format!("{error:#}");
669            DoctorCheck::fixable(
670                "runtime.podman",
671                "Rootless Podman",
672                detail,
673                podman_remediation(&error),
674            )
675        }
676    }
677}
678
679fn local_podman_targets(config: &Config) -> Vec<(&String, &ContainerTemplate)> {
680    config
681        .targets
682        .iter()
683        .filter_map(|(id, target)| match target {
684            TargetTemplate::LocalPodman { container } => Some((id, container)),
685            _ => None,
686        })
687        .collect()
688}
689
690fn podman_image_checks(
691    config: ConfigStatus<'_>,
692    executor: &impl CommandExecutor,
693    smoke: bool,
694) -> Vec<DoctorCheck> {
695    let Ok(config) = config else {
696        return Vec::new();
697    };
698    local_podman_targets(config)
699        .into_iter()
700        .map(|(id, container)| podman_image_check(id, &container.image, executor, smoke))
701        .collect()
702}
703
704fn podman_image_check(
705    id: &str,
706    image: &str,
707    executor: &impl CommandExecutor,
708    smoke: bool,
709) -> DoctorCheck {
710    let check_id = format!("runtime.podman.image.{id}");
711    let title = format!("Podman image for target {id}");
712    if smoke {
713        let target = RuntimeTargetTemplate::LocalPodman(RuntimeContainerTemplate {
714            build_cache: None,
715            image: image.to_owned(),
716            pull_policy: Default::default(),
717            extra_run_args: vec![],
718            workspace_storage: Default::default(),
719        });
720        return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
721            Ok(()) => DoctorCheck::ready(
722                check_id,
723                title,
724                format!("Disposable run/exec/remove smoke test passed for image {image}."),
725            ),
726            Err(error) => DoctorCheck::fixable(
727                check_id,
728                title,
729                format!(
730                    "Disposable run/exec/remove smoke test failed for image {image}: {error:#}"
731                ),
732                "Fix the configured image or Podman runtime, then run `mj doctor --json --smoke` again.",
733            ),
734        };
735    }
736
737    let command = CommandSpec::new("podman", ["image", "exists", image])
738        .purpose("check Podman image presence");
739    match executor.execute(&command) {
740        Ok(output) if output.status == 0 => DoctorCheck::ready(
741            check_id,
742            title,
743            format!("Image {image} is present in local Podman storage."),
744        ),
745        Ok(_) => DoctorCheck::fixable(
746            check_id,
747            title,
748            format!("Image {image} is not present in local Podman storage."),
749            missing_image_remediation(image),
750        ),
751        Err(error) => DoctorCheck::fixable(
752            check_id,
753            title,
754            format!(
755                "Could not check whether image {image} is present in local Podman storage: {error}"
756            ),
757            missing_image_remediation(image),
758        ),
759    }
760}
761
762fn missing_image_remediation(image: &str) -> String {
763    format!(
764        "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."
765    )
766}
767
768/// Host Docker prerequisites, then one image check per `local-docker` target.
769fn docker_checks(
770    config: ConfigStatus<'_>,
771    executor: &impl CommandExecutor,
772    smoke: bool,
773) -> Vec<DoctorCheck> {
774    let config = match config {
775        Ok(config) => config,
776        Err(ConfigGap::NewerVersion(version)) => {
777            return vec![newer_config_skip("runtime.docker", "Docker", version)];
778        }
779        Err(ConfigGap::Unreadable) => {
780            return vec![DoctorCheck::unsupported(
781                "runtime.docker",
782                "Docker",
783                "Docker prerequisites cannot be evaluated until config.toml is valid.",
784            )];
785        }
786    };
787    let targets = local_docker_targets(config);
788    if targets.is_empty() {
789        return vec![DoctorCheck::unsupported(
790            "runtime.docker",
791            "Docker",
792            "No local-docker target is configured.",
793        )];
794    }
795    let preflight = local_docker_runtime_check(executor);
796    if preflight.status != CheckStatus::Ready {
797        return vec![preflight];
798    }
799    let mut checks = vec![preflight];
800    checks.extend(
801        targets
802            .into_iter()
803            .map(|(id, container)| docker_image_check(id, &container.image, executor, smoke)),
804    );
805    checks
806}
807
808pub fn local_docker_runtime_check(executor: &impl CommandExecutor) -> DoctorCheck {
809    match verify_local_docker(executor) {
810        Ok(preflight) => DoctorCheck::ready(
811            "runtime.docker",
812            "Docker",
813            format!(
814                "Docker {} is connected to a Linux daemon.",
815                preflight.version
816            ),
817        ),
818        Err(error) => DoctorCheck::fixable(
819            "runtime.docker",
820            "Docker",
821            format!("{error:#}"),
822            "Install and start Docker, then make sure `docker info` succeeds as the user running mj.",
823        ),
824    }
825}
826
827fn local_docker_targets(config: &Config) -> Vec<(&String, &ContainerTemplate)> {
828    config
829        .targets
830        .iter()
831        .filter_map(|(id, target)| match target {
832            TargetTemplate::LocalDocker { container } => Some((id, container)),
833            _ => None,
834        })
835        .collect()
836}
837
838fn docker_image_check(
839    id: &str,
840    image: &str,
841    executor: &impl CommandExecutor,
842    smoke: bool,
843) -> DoctorCheck {
844    let check_id = format!("runtime.docker.image.{id}");
845    let title = format!("Docker image for target {id}");
846    if smoke {
847        let target = RuntimeTargetTemplate::LocalDocker(RuntimeContainerTemplate {
848            build_cache: None,
849            image: image.to_owned(),
850            pull_policy: Default::default(),
851            extra_run_args: vec![],
852            workspace_storage: Default::default(),
853        });
854        return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
855            Ok(()) => DoctorCheck::ready(
856                check_id,
857                title,
858                format!(
859                    "Disposable run/exec/remove and OverlayFS attachment smoke test passed for image {image}."
860                ),
861            ),
862            Err(error) => DoctorCheck::fixable(
863                check_id,
864                title,
865                format!(
866                    "Disposable run/exec/remove smoke test failed for image {image}: {error:#}"
867                ),
868                "Fix the configured image or Docker runtime, then run `mj doctor --json --smoke` again.",
869            ),
870        };
871    }
872    let command = CommandSpec::new("docker", ["image", "inspect", image])
873        .purpose("check Docker image presence");
874    match executor.execute(&command) {
875        Ok(output) if output.status == 0 => DoctorCheck::ready(
876            check_id,
877            title,
878            format!("Image {image} is present in Docker storage."),
879        ),
880        Ok(_) => DoctorCheck::fixable(
881            check_id,
882            title,
883            format!("Image {image} is not present in Docker storage."),
884            format!("Pull it with `docker pull {image}`, or run `mj doctor --json --smoke`."),
885        ),
886        Err(error) => DoctorCheck::fixable(
887            check_id,
888            title,
889            format!("Could not inspect Docker image {image}: {error}"),
890            format!("Make sure `docker info` succeeds, then run `docker pull {image}`."),
891        ),
892    }
893}
894
895/// The outcome of the shared SSH connectivity probe.
896///
897/// Both SSH-backed checks run this first: an unreachable host makes every
898/// later probe fail with a misleading message.
899enum SshConnectivity {
900    Reachable,
901    Failed { detail: String, remediation: String },
902}
903
904/// Probe `ssh <destination> true` and map any failure to a copy-paste fix.
905///
906/// Hel never generates keys, runs `ssh-copy-id`, or accepts a host key on the
907/// user's behalf; it only says exactly which command would fix the failure.
908fn ssh_connectivity(ssh: &RuntimeSshTarget, executor: &impl CommandExecutor) -> SshConnectivity {
909    let destination = &ssh.destination;
910    let command = ssh_connectivity_probe(ssh);
911    match executor.execute(&command) {
912        Err(error) => SshConnectivity::Failed {
913            detail: format!("Could not run `ssh {destination} true`: {error:#}"),
914            remediation: ssh_launch_failure_remediation(&error, ssh),
915        },
916        Ok(output) if output.status != 0 => {
917            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
918            SshConnectivity::Failed {
919                detail: format!("`ssh {destination} true` failed: {stderr}"),
920                remediation: ssh_failure_remediation(&stderr, ssh),
921            }
922        }
923        Ok(_) => SshConnectivity::Reachable,
924    }
925}
926
927const 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).";
928
929/// What OpenSSH reported, as far as doctor needs to tell the cases apart.
930///
931/// OpenSSH is an external tool, so its wording is the only signal available.
932/// This is the one place in doctor that reads it; everything downstream works
933/// from the classification rather than the text.
934#[derive(Debug, Clone, Copy, PartialEq, Eq)]
935enum SshFailure {
936    UntrustedHostKey,
937    Unauthenticated,
938    ClientMissing,
939    /// The host answered nothing at all.
940    Unreachable,
941    Unrecognized,
942}
943
944fn classify_ssh_stderr(stderr: &str) -> SshFailure {
945    const UNTRUSTED_HOST_KEY: [&str; 3] = [
946        "Host key verification failed",
947        "No ECDSA host key is known",
948        "REMOTE HOST IDENTIFICATION HAS CHANGED",
949    ];
950    const UNAUTHENTICATED: [&str; 4] = [
951        "Permission denied",
952        "Too many authentication failures",
953        "no matching host key",
954        "Authentication failed",
955    ];
956    const CLIENT_MISSING: [&str; 2] = ["ssh: command not found", "No such file or directory"];
957    const UNREACHABLE: [&str; 3] = [
958        "Connection timed out",
959        "No route to host",
960        "Network is unreachable",
961    ];
962
963    let reported = |signatures: &[&str]| signatures.iter().any(|text| stderr.contains(text));
964    if reported(&UNTRUSTED_HOST_KEY) {
965        SshFailure::UntrustedHostKey
966    } else if reported(&UNAUTHENTICATED) {
967        SshFailure::Unauthenticated
968    } else if reported(&CLIENT_MISSING) {
969        SshFailure::ClientMissing
970    } else if reported(&UNREACHABLE) {
971        SshFailure::Unreachable
972    } else {
973        SshFailure::Unrecognized
974    }
975}
976
977/// Map a failure to run `ssh` at all (as opposed to `ssh` exiting nonzero)
978/// to the command that fixes it.
979fn ssh_launch_failure_remediation(error: &anyhow::Error, ssh: &RuntimeSshTarget) -> String {
980    if error.downcast_ref::<CommandTimedOut>().is_some() {
981        return ssh_unreachable_remediation(ssh);
982    }
983    let missing_binary = error.chain().any(|cause| {
984        cause
985            .downcast_ref::<std::io::Error>()
986            .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
987    });
988    if missing_binary {
989        return SSH_MISSING_REMEDIATION.to_owned();
990    }
991    format!(
992        "Run `ssh {} true` by hand and resolve the error it reports: {error:#}",
993        ssh.destination
994    )
995}
996
997/// The host answered nothing: it is asleep, behind a down VPN, or the cloud
998/// session that exposes it has expired.
999fn ssh_unreachable_remediation(ssh: &RuntimeSshTarget) -> String {
1000    let host = ssh_host_only(&ssh.destination);
1001    format!(
1002        "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.",
1003        ssh.destination
1004    )
1005}
1006
1007/// Map `ssh -o BatchMode=yes` stderr to the command that fixes it.
1008fn ssh_failure_remediation(stderr: &str, ssh: &RuntimeSshTarget) -> String {
1009    let destination = &ssh.destination;
1010    match classify_ssh_stderr(stderr) {
1011        SshFailure::UntrustedHostKey => {
1012            let host = ssh_host_only(destination);
1013            format!(
1014                "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."
1015            )
1016        }
1017        SshFailure::Unauthenticated => match ssh_identity_file(ssh) {
1018            Some(identity) => format!(
1019                "Install your public key on the host with `ssh-copy-id -i {identity}.pub {destination}`."
1020            ),
1021            None => {
1022                format!("Install your public key on the host with `ssh-copy-id {destination}`.")
1023            }
1024        },
1025        SshFailure::ClientMissing => SSH_MISSING_REMEDIATION.to_owned(),
1026        SshFailure::Unreachable => ssh_unreachable_remediation(ssh),
1027        SshFailure::Unrecognized => {
1028            format!(
1029                "Run `ssh {destination} true` by hand and resolve the error it reports: {stderr}"
1030            )
1031        }
1032    }
1033}
1034
1035/// The host part of an OpenSSH destination, without any `user@` prefix.
1036fn ssh_host_only(destination: &str) -> &str {
1037    destination
1038        .rsplit_once('@')
1039        .map_or(destination, |(_, host)| host)
1040}
1041
1042/// The identity file provisioning passes, recovered from the built ssh args.
1043fn ssh_identity_file(ssh: &RuntimeSshTarget) -> Option<&str> {
1044    let position = ssh.ssh_args.iter().position(|arg| arg == "-i")?;
1045    ssh.ssh_args.get(position + 1).map(String::as_str)
1046}
1047
1048/// One check per `ssh-bare` target: can Hel reach the host noninteractively?
1049fn ssh_bare_checks(config: ConfigStatus<'_>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
1050    let Ok(config) = config else {
1051        return Vec::new();
1052    };
1053    config
1054        .targets
1055        .iter()
1056        .filter_map(|(id, target)| match target {
1057            TargetTemplate::SshBare { ssh, .. } => {
1058                Some(ssh_bare_check(id, &RuntimeSshTarget::from(ssh), executor))
1059            }
1060            _ => None,
1061        })
1062        .collect()
1063}
1064
1065fn ssh_bare_check(
1066    id: &str,
1067    ssh: &RuntimeSshTarget,
1068    executor: &impl CommandExecutor,
1069) -> DoctorCheck {
1070    let check_id = format!("runtime.ssh-bare.{id}");
1071    let title = format!("SSH access for target {id}");
1072    match ssh_connectivity(ssh, executor) {
1073        SshConnectivity::Reachable => DoctorCheck::ready(
1074            check_id,
1075            title,
1076            format!(
1077                "`ssh {} true` succeeds noninteractively from this host.",
1078                ssh.destination
1079            ),
1080        ),
1081        SshConnectivity::Failed {
1082            detail,
1083            remediation,
1084        } => DoctorCheck::fixable(check_id, title, detail, remediation),
1085    }
1086}
1087
1088/// Two checks per `ssh-podman` target: the same Podman probes run over SSH,
1089/// then the host limits that only bite under provisioning load.
1090fn ssh_podman_checks(
1091    config: ConfigStatus<'_>,
1092    executor: &impl CommandExecutor,
1093    smoke: bool,
1094) -> Vec<DoctorCheck> {
1095    let Ok(config) = config else {
1096        return Vec::new();
1097    };
1098    config
1099        .targets
1100        .iter()
1101        .flat_map(|(id, target)| match target {
1102            TargetTemplate::SshPodman { ssh, container, .. } => {
1103                let ssh = RuntimeSshTarget::from(ssh);
1104                let (check, reachable) =
1105                    ssh_podman_check(id, &ssh, &container.image, executor, smoke);
1106                let mut checks = vec![check];
1107                // An unreachable host has one problem, not two.
1108                if reachable {
1109                    checks.push(ssh_podman_limits_check(id, &ssh, executor));
1110                }
1111                checks
1112            }
1113            _ => Vec::new(),
1114        })
1115        .collect()
1116}
1117
1118/// The Podman check for one target, paired with whether the host answered SSH
1119/// at all: the caller skips its follow-up probes when it did not.
1120fn ssh_podman_check(
1121    id: &str,
1122    ssh: &RuntimeSshTarget,
1123    image: &str,
1124    executor: &impl CommandExecutor,
1125    smoke: bool,
1126) -> (DoctorCheck, bool) {
1127    let check_id = format!("runtime.ssh-podman.{id}");
1128    let title = format!("Remote Podman for target {id}");
1129    // Connectivity first: a remote Podman probe on an unreachable host reports
1130    // a Podman problem the user does not have.
1131    if let SshConnectivity::Failed {
1132        detail,
1133        remediation,
1134    } = ssh_connectivity(ssh, executor)
1135    {
1136        return (
1137            DoctorCheck::fixable(check_id, title, detail, remediation),
1138            false,
1139        );
1140    }
1141    (
1142        ssh_podman_runtime_check(check_id, title, ssh, image, executor, smoke),
1143        true,
1144    )
1145}
1146
1147/// The Podman half of the target's checks, on a host already known reachable.
1148fn ssh_podman_runtime_check(
1149    check_id: String,
1150    title: String,
1151    ssh: &RuntimeSshTarget,
1152    image: &str,
1153    executor: &impl CommandExecutor,
1154    smoke: bool,
1155) -> DoctorCheck {
1156    let destination = &ssh.destination;
1157    let preflight = match verify_ssh_podman(ssh, executor) {
1158        Ok(preflight) => preflight,
1159        Err(error) => {
1160            let detail = format!("{error:#}");
1161            let remediation = match podman_remediation_match(&error) {
1162                Some(remediation) => format!("On {destination}: {remediation}"),
1163                None => format!(
1164                    "Verify `ssh {destination}` succeeds noninteractively from this host, then install rootless Podman 4 or newer there (see docs/PODMAN.md)."
1165                ),
1166            };
1167            return DoctorCheck::fixable(check_id, title, detail, remediation);
1168        }
1169    };
1170    let linger_warning = preflight.warnings.first();
1171    if !smoke && let Some(warning) = linger_warning {
1172        return DoctorCheck::warning(
1173            check_id,
1174            title,
1175            format!(
1176                "Remote rootless Podman {} is available via {destination}, but {}",
1177                preflight.version, warning.detail
1178            ),
1179            &warning.remediation,
1180        );
1181    }
1182    if !smoke {
1183        return DoctorCheck::ready(
1184            check_id,
1185            title,
1186            format!(
1187                "Remote rootless Podman {} is available via {destination}. Run `mj doctor --json --smoke` to verify the image end to end.",
1188                preflight.version
1189            ),
1190        );
1191    }
1192
1193    let target = RuntimeTargetTemplate::SshPodman {
1194        ssh: ssh.clone(),
1195        container: RuntimeContainerTemplate {
1196            build_cache: None,
1197            image: image.to_owned(),
1198            pull_policy: Default::default(),
1199            extra_run_args: vec![],
1200            workspace_storage: Default::default(),
1201        },
1202    };
1203    match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1204        Ok(()) => match linger_warning {
1205            Some(warning) => DoctorCheck::warning(
1206                check_id,
1207                title,
1208                format!(
1209                    "Disposable run/exec/remove smoke test passed for image {image} on {destination}, but {}",
1210                    warning.detail
1211                ),
1212                &warning.remediation,
1213            ),
1214            None => DoctorCheck::ready(
1215                check_id,
1216                title,
1217                format!(
1218                    "Disposable run/exec/remove smoke test passed for image {image} on {destination}."
1219                ),
1220            ),
1221        },
1222        Err(error) => DoctorCheck::fixable(
1223            check_id,
1224            title,
1225            format!(
1226                "Disposable run/exec/remove smoke test failed for image {image} on {destination}: {error:#}"
1227            ),
1228            format!(
1229                "Fix the configured image or Podman runtime on {destination}, then run `mj doctor --json --smoke` again."
1230            ),
1231        ),
1232    }
1233}
1234
1235/// Host limits that cause provisioning failures under load, read on their own SSH
1236/// round trip so the provisioning preflight never pays for them.
1237///
1238/// Every crun container takes a session keyring, so `podman run` fails with
1239/// `crun: create keyring` once the login user's keyring quota is exhausted, and
1240/// sshd refuses new connections past `MaxStartups`. `sshd -T` needs root, so the
1241/// directive is read from the config files instead; drop-ins may be unreadable,
1242/// which the script reports rather than guessing.
1243const SSH_PODMAN_HOST_LIMITS_SCRIPT: &str = r#"
1244if [ -r /proc/sys/kernel/keys/maxkeys ]; then
1245    printf 'keys.max=%s\n' "$(cat /proc/sys/kernel/keys/maxkeys)"
1246fi
1247if [ -r /proc/key-users ]; then
1248    awk -v uid="$(id -u)" '
1249        { user = $1; sub(/:$/, "", user) }
1250        user == uid {
1251            split($4, quota, "/")
1252            printf "keys.used=%s\nkeys.quota=%s\n", quota[1], quota[2]
1253        }
1254    ' /proc/key-users
1255fi
1256unreadable=0
1257maxstartups=
1258# A drop-in directory that cannot be listed hides any override it holds.
1259if [ -d /etc/ssh/sshd_config.d ] && ! [ -r /etc/ssh/sshd_config.d ]; then
1260    unreadable=1
1261fi
1262for file in /etc/ssh/sshd_config /etc/ssh/sshd_config.d/*.conf; do
1263    [ -e "$file" ] || continue
1264    if [ -r "$file" ]; then
1265        match=$(grep -i '^[[:space:]]*maxstartups[[:space:]]' "$file" 2>/dev/null | tail -n 1)
1266        [ -n "$match" ] && maxstartups=$(printf '%s\n' "$match" | awk '{ print $2 }')
1267    else
1268        unreadable=1
1269    fi
1270done
1271[ -n "$maxstartups" ] && printf 'maxstartups=%s\n' "$maxstartups"
1272[ "$unreadable" = 1 ] && printf 'maxstartups.unreadable=1\n'
1273exit 0
1274"#;
1275
1276/// Keyring use at or above this share of the quota is reported as a warning:
1277/// the remaining headroom is a few concurrent containers, not a comfortable
1278/// margin.
1279const KEYRING_PRESSURE_PERCENT: u64 = 80;
1280
1281/// What `SSH_PODMAN_HOST_LIMITS_SCRIPT` managed to read. Every field is
1282/// optional: an unreadable file is reported, never guessed at.
1283#[derive(Debug, Default, PartialEq, Eq)]
1284struct HostLimits {
1285    keys_used: Option<u64>,
1286    keys_quota: Option<u64>,
1287    keys_max: Option<u64>,
1288    max_startups: Option<String>,
1289    max_startups_unreadable: bool,
1290}
1291
1292fn parse_host_limits(stdout: &[u8]) -> HostLimits {
1293    let text = String::from_utf8_lossy(stdout);
1294    let mut limits = HostLimits::default();
1295    for line in text.lines() {
1296        let Some((name, value)) = line.split_once('=') else {
1297            continue;
1298        };
1299        let value = value.trim();
1300        match name.trim() {
1301            "keys.used" => limits.keys_used = value.parse().ok(),
1302            "keys.quota" => limits.keys_quota = value.parse().ok(),
1303            "keys.max" => limits.keys_max = value.parse().ok(),
1304            "maxstartups" if !value.is_empty() => limits.max_startups = Some(value.to_owned()),
1305            "maxstartups.unreadable" => limits.max_startups_unreadable = value == "1",
1306            _ => {}
1307        }
1308    }
1309    limits
1310}
1311
1312impl HostLimits {
1313    /// True when the script produced nothing a reader could act on.
1314    fn is_empty(&self) -> bool {
1315        self.keys_used.is_none()
1316            && self.keys_quota.is_none()
1317            && self.keys_max.is_none()
1318            && self.max_startups.is_none()
1319            && !self.max_startups_unreadable
1320    }
1321
1322    fn keyring_is_under_pressure(&self) -> bool {
1323        match (self.keys_used, self.keys_quota) {
1324            (Some(used), Some(quota)) if quota > 0 => {
1325                used.saturating_mul(100) >= quota.saturating_mul(KEYRING_PRESSURE_PERCENT)
1326            }
1327            _ => false,
1328        }
1329    }
1330
1331    fn keyring_sentence(&self, destination: &str) -> String {
1332        match (self.keys_used, self.keys_quota) {
1333            (Some(used), Some(quota)) => {
1334                let system = match self.keys_max {
1335                    Some(max) => format!(", and `kernel.keys.maxkeys` is {max}"),
1336                    None => String::new(),
1337                };
1338                format!(
1339                    "The login user on {destination} holds {used} of its {quota} kernel keyring quota{system}."
1340                )
1341            }
1342            _ => format!(
1343                "The kernel keyring quota for the login user on {destination} could not be read."
1344            ),
1345        }
1346    }
1347
1348    fn max_startups_sentence(&self) -> String {
1349        match (&self.max_startups, self.max_startups_unreadable) {
1350            (Some(value), _) => format!("sshd MaxStartups is {value}."),
1351            (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(),
1352            (None, false) => {
1353                "sshd MaxStartups is not set in sshd_config, so sshd's default applies.".to_owned()
1354            }
1355        }
1356    }
1357}
1358
1359/// Report the two host limits that made provisioning fail under load. The
1360/// target still works when they cannot be read, so an unreadable host is a
1361/// warning with a manual command, never a `fixable` runtime failure.
1362fn ssh_podman_limits_check(
1363    id: &str,
1364    ssh: &RuntimeSshTarget,
1365    executor: &impl CommandExecutor,
1366) -> DoctorCheck {
1367    let check_id = format!("runtime.ssh-podman.{id}.limits");
1368    let title = format!("Host limits for target {id}");
1369    let destination = &ssh.destination;
1370    let manual = || {
1371        format!(
1372            "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`."
1373        )
1374    };
1375    let command = ssh_validation_command(
1376        ssh,
1377        vec![
1378            "sh".to_owned(),
1379            "-c".to_owned(),
1380            SSH_PODMAN_HOST_LIMITS_SCRIPT.to_owned(),
1381        ],
1382        "read ssh-podman host limits",
1383    );
1384    let limits = match executor.execute(&command) {
1385        Ok(output) if output.status == 0 => parse_host_limits(&output.stdout),
1386        Ok(output) => {
1387            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
1388            return DoctorCheck::warning(
1389                check_id,
1390                title,
1391                format!(
1392                    "Could not read the kernel keyring quota or sshd MaxStartups from {destination}: {stderr}"
1393                ),
1394                manual(),
1395            );
1396        }
1397        Err(error) => {
1398            return DoctorCheck::warning(
1399                check_id,
1400                title,
1401                format!(
1402                    "Could not read the kernel keyring quota or sshd MaxStartups from {destination}: {error}"
1403                ),
1404                manual(),
1405            );
1406        }
1407    };
1408    if limits.is_empty() {
1409        return DoctorCheck::warning(
1410            check_id,
1411            title,
1412            format!("{destination} reported no readable kernel keyring or sshd limits."),
1413            manual(),
1414        );
1415    }
1416    let detail = format!(
1417        "{} {}",
1418        limits.keyring_sentence(destination),
1419        limits.max_startups_sentence()
1420    );
1421    if limits.keyring_is_under_pressure() {
1422        return DoctorCheck::warning(
1423            check_id,
1424            title,
1425            format!(
1426                "{detail} Every container takes a session keyring, so `podman run` fails with `crun: create keyring` once the quota is gone."
1427            ),
1428            format!(
1429                "Raise `kernel.keys.maxkeys` and `kernel.keys.maxbytes` with sysctl on {destination}, and close finished sessions promptly."
1430            ),
1431        );
1432    }
1433    DoctorCheck::ready(check_id, title, detail)
1434}
1435
1436/// One check per `ssh-docker` target: Docker daemon, image, and optional
1437/// remote OverlayFS smoke test, all executed on the SSH host.
1438fn ssh_docker_checks(
1439    config: ConfigStatus<'_>,
1440    executor: &impl CommandExecutor,
1441    smoke: bool,
1442) -> Vec<DoctorCheck> {
1443    let Ok(config) = config else {
1444        return Vec::new();
1445    };
1446    config
1447        .targets
1448        .iter()
1449        .filter_map(|(id, target)| match target {
1450            TargetTemplate::SshDocker { ssh, container } => Some(ssh_docker_check(
1451                id,
1452                &RuntimeSshTarget::from(ssh),
1453                &container.image,
1454                executor,
1455                smoke,
1456            )),
1457            _ => None,
1458        })
1459        .collect()
1460}
1461
1462fn ssh_docker_check(
1463    id: &str,
1464    ssh: &RuntimeSshTarget,
1465    image: &str,
1466    executor: &impl CommandExecutor,
1467    smoke: bool,
1468) -> DoctorCheck {
1469    let check_id = format!("runtime.ssh-docker.{id}");
1470    let title = format!("Remote Docker for target {id}");
1471    let destination = &ssh.destination;
1472    if let SshConnectivity::Failed {
1473        detail,
1474        remediation,
1475    } = ssh_connectivity(ssh, executor)
1476    {
1477        return DoctorCheck::fixable(check_id, title, detail, remediation);
1478    }
1479
1480    let preflight = match verify_ssh_docker(ssh, executor) {
1481        Ok(preflight) => preflight,
1482        Err(error) => {
1483            let detail = format!("{error:#}");
1484            return DoctorCheck::fixable(
1485                check_id,
1486                title,
1487                detail,
1488                format!(
1489                    "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."
1490                ),
1491            );
1492        }
1493    };
1494
1495    if smoke {
1496        let target = RuntimeTargetTemplate::SshDocker {
1497            ssh: ssh.clone(),
1498            container: RuntimeContainerTemplate {
1499                build_cache: None,
1500                image: image.to_owned(),
1501                pull_policy: Default::default(),
1502                extra_run_args: vec![],
1503                workspace_storage: Default::default(),
1504            },
1505        };
1506        return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1507            Ok(()) => DoctorCheck::ready(
1508                check_id,
1509                title,
1510                format!(
1511                    "Remote Docker {} is available via {destination}; disposable run/exec/remove and remote OverlayFS attachment smoke test passed for image {image}.",
1512                    preflight.version
1513                ),
1514            ),
1515            Err(error) => DoctorCheck::fixable(
1516                check_id,
1517                title,
1518                format!(
1519                    "Disposable run/exec/remove smoke test failed for image {image} on {destination}: {error:#}"
1520                ),
1521                format!(
1522                    "Fix the configured image or Docker runtime on {destination}, then run `mj doctor --json --smoke` again."
1523                ),
1524            ),
1525        };
1526    }
1527
1528    let image_command = ssh_command(
1529        ssh,
1530        [
1531            "docker".to_owned(),
1532            "image".to_owned(),
1533            "inspect".to_owned(),
1534            image.to_owned(),
1535        ]
1536        .to_vec(),
1537    )
1538    .purpose("check remote Docker image presence");
1539    match executor.execute(&image_command) {
1540        Ok(output) if output.status == 0 => DoctorCheck::ready(
1541            check_id,
1542            title,
1543            format!(
1544                "Remote Docker {} is available via {destination}; image {image} is present. Run `mj doctor --json --smoke` to verify remote OverlayFS attachments.",
1545                preflight.version
1546            ),
1547        ),
1548        Ok(output) => DoctorCheck::fixable(
1549            check_id,
1550            title,
1551            format!(
1552                "Image {image} is not present in remote Docker storage on {destination}: {}",
1553                String::from_utf8_lossy(&output.stderr).trim()
1554            ),
1555            format!(
1556                "Pull it on {destination} with `ssh {destination} docker pull {image}`, or run `mj doctor --json --smoke`."
1557            ),
1558        ),
1559        Err(error) => DoctorCheck::fixable(
1560            check_id,
1561            title,
1562            format!("Could not inspect remote Docker image {image} on {destination}: {error}"),
1563            format!(
1564                "Verify `ssh {destination} docker info` succeeds, then pull {image} on that host."
1565            ),
1566        ),
1567    }
1568}
1569
1570/// Shared disposable-container identity for every doctor smoke test.
1571fn doctor_smoke_id() -> String {
1572    format!(
1573        "doctor-{}-{:x}",
1574        std::process::id(),
1575        SystemTime::now()
1576            .duration_since(UNIX_EPOCH)
1577            .unwrap_or_default()
1578            .as_nanos()
1579    )
1580}
1581
1582fn podman_remediation(error: &anyhow::Error) -> &'static str {
1583    podman_remediation_match(error).unwrap_or(
1584        "Install Podman with `sudo apt update && sudo apt install -y podman uidmap` (Debian/Ubuntu) or `sudo dnf install -y podman shadow-utils` (Fedora).",
1585    )
1586}
1587
1588/// Map a Podman preflight failure to its specific remediation, if one applies.
1589///
1590/// The preflight reports which postcondition failed on the error itself, so
1591/// the fix is chosen from that probe rather than by matching the message text
1592/// this repository just produced. A failure that is not a probe result, such
1593/// as an unreachable SSH host, has no specific fix here.
1594fn podman_remediation_match(error: &anyhow::Error) -> Option<&'static str> {
1595    failed_podman_probe(error).map(PodmanProbe::remediation)
1596}
1597
1598const AWS_CLI_INSTALL_URL: &str =
1599    "https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html";
1600
1601/// One check per `aws-ec2` target: the AWS CLI, its credentials, and the
1602/// configured launch template.
1603fn aws_checks(config: ConfigStatus<'_>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
1604    let Ok(config) = config else {
1605        return Vec::new();
1606    };
1607    config
1608        .targets
1609        .iter()
1610        .filter_map(|(id, target)| match target {
1611            TargetTemplate::AwsEc2 {
1612                aws_profile,
1613                region,
1614                launch_template,
1615                ..
1616            } => Some(aws_target_check(
1617                id,
1618                aws_profile.as_deref(),
1619                region,
1620                launch_template,
1621                executor,
1622            )),
1623            _ => None,
1624        })
1625        .collect()
1626}
1627
1628/// The profile and region every AWS probe carries, applied exactly the way
1629/// provisioning applies them in `targets`.
1630fn aws_global_args<'a>(profile: Option<&'a str>, region: &'a str) -> Vec<String> {
1631    vec![
1632        "--profile".to_owned(),
1633        profile.unwrap_or("default").to_owned(),
1634        "--region".to_owned(),
1635        region.to_owned(),
1636    ]
1637}
1638
1639fn aws_target_check(
1640    id: &str,
1641    profile: Option<&str>,
1642    region: &str,
1643    launch_template: &str,
1644    executor: &impl CommandExecutor,
1645) -> DoctorCheck {
1646    let check_id = format!("runtime.aws-ec2.{id}");
1647    let title = format!("AWS EC2 target {id}");
1648    let profile_label = profile.unwrap_or("default");
1649
1650    let version = CommandSpec::new("aws", ["--version"]).purpose("check AWS CLI installation");
1651    match executor.execute(&version) {
1652        Err(error) => {
1653            return DoctorCheck::fixable(
1654                check_id,
1655                title,
1656                format!("The `aws` command is not available: {error}"),
1657                format!("Install the AWS CLI and put `aws` on PATH: {AWS_CLI_INSTALL_URL}"),
1658            );
1659        }
1660        Ok(output) if output.status != 0 => {
1661            return DoctorCheck::fixable(
1662                check_id,
1663                title,
1664                format!(
1665                    "`aws --version` failed: {}",
1666                    String::from_utf8_lossy(&output.stderr).trim()
1667                ),
1668                format!("Reinstall the AWS CLI: {AWS_CLI_INSTALL_URL}"),
1669            );
1670        }
1671        Ok(_) => {}
1672    }
1673
1674    let mut identity_args = aws_global_args(profile, region);
1675    identity_args.extend(["sts".to_owned(), "get-caller-identity".to_owned()]);
1676    identity_args.extend(["--output".to_owned(), "json".to_owned()]);
1677    let identity =
1678        CommandSpec::new("aws", identity_args).purpose("check AWS credentials for a doctor target");
1679    match executor.execute(&identity) {
1680        Err(error) => {
1681            return DoctorCheck::fixable(
1682                check_id,
1683                title,
1684                format!("Could not run `aws sts get-caller-identity`: {error}"),
1685                format!(
1686                    "Configure credentials with `aws configure --profile {profile_label}`, or sign in with `aws sso login --profile {profile_label}`."
1687                ),
1688            );
1689        }
1690        Ok(output) if output.status != 0 => {
1691            return DoctorCheck::fixable(
1692                check_id,
1693                title,
1694                format!(
1695                    "AWS credentials for profile {profile_label} are not usable: {}",
1696                    String::from_utf8_lossy(&output.stderr).trim()
1697                ),
1698                format!(
1699                    "Configure credentials with `aws configure --profile {profile_label}`, or sign in with `aws sso login --profile {profile_label}`."
1700                ),
1701            );
1702        }
1703        Ok(_) => {}
1704    }
1705
1706    // Launch templates are addressed by id when they carry the `lt-` prefix
1707    // and by name otherwise, the same split provisioning uses.
1708    let by_id = launch_template.starts_with("lt-");
1709    let mut template_args = aws_global_args(profile, region);
1710    template_args.extend(["ec2".to_owned(), "describe-launch-templates".to_owned()]);
1711    template_args.extend([
1712        if by_id {
1713            "--launch-template-ids".to_owned()
1714        } else {
1715            "--launch-template-names".to_owned()
1716        },
1717        launch_template.to_owned(),
1718    ]);
1719    template_args.extend(["--output".to_owned(), "json".to_owned()]);
1720    let template =
1721        CommandSpec::new("aws", template_args).purpose("check the configured AWS launch template");
1722    let template_remediation = format!(
1723        "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."
1724    );
1725    match executor.execute(&template) {
1726        Err(error) => DoctorCheck::fixable(
1727            check_id,
1728            title,
1729            format!("Could not query launch template {launch_template}: {error}"),
1730            template_remediation,
1731        ),
1732        Ok(output) if output.status != 0 => DoctorCheck::fixable(
1733            check_id,
1734            title,
1735            format!(
1736                "Launch template {launch_template} was not found in {region}: {}",
1737                String::from_utf8_lossy(&output.stderr).trim()
1738            ),
1739            template_remediation,
1740        ),
1741        Ok(_) => DoctorCheck::ready(
1742            check_id,
1743            title,
1744            format!(
1745                "The AWS CLI is installed, profile {profile_label} has valid credentials, and launch template {launch_template} exists in {region}."
1746            ),
1747        ),
1748    }
1749}
1750
1751/// Whether the running daemon is this build.
1752///
1753/// Two Mjolnir builds carry the same version string, so the version line in
1754/// `mj daemon status` cannot answer it. A daemon left over from before a
1755/// rebuild keeps serving the old code, and, when its executable was unlinked
1756/// by the rebuild, also loses every portable worker source it would have
1757/// pinned. Both are invisible without this check.
1758fn daemon_build_check() -> DoctorCheck {
1759    const ID: &str = "daemon.build";
1760    const TITLE: &str = "Daemon build";
1761    let Ok(metadata) = mj_client::daemon::read_metadata_any() else {
1762        return DoctorCheck::ready(
1763            ID,
1764            TITLE,
1765            "No Mjolnir daemon is running; the next command starts one from this build.",
1766        );
1767    };
1768    let pid = metadata.pid;
1769    match mj_client::executable::process_runs_this_executable(pid) {
1770        Ok(Some(true)) => DoctorCheck::ready(
1771            ID,
1772            TITLE,
1773            format!(
1774                "Daemon {pid} runs this build (version {}).",
1775                metadata.build_version
1776            ),
1777        ),
1778        Ok(Some(false)) => DoctorCheck::warning(
1779            ID,
1780            TITLE,
1781            format!(
1782                "{}. Two builds can report the same version, so the files are what tell them apart. Code rebuilt since that daemon started is not running.",
1783                mj_client::executable::describe_running_daemon_and_client_builds(
1784                    pid,
1785                    &metadata.build_version,
1786                ),
1787            ),
1788            "Run `mj daemon restart` from this build. It now fails rather than reporting success if another client's build wins.",
1789        ),
1790        Ok(None) => DoctorCheck::ready(
1791            ID,
1792            TITLE,
1793            format!(
1794                "Daemon {pid} is recorded but not running; the next command starts one from this build."
1795            ),
1796        ),
1797        Err(error) => DoctorCheck::warning(
1798            ID,
1799            TITLE,
1800            format!("Could not tell which build daemon {pid} runs: {error:#}"),
1801            "Run `mj daemon restart` from this build if rebuilt code is not taking effect.",
1802        ),
1803    }
1804}
1805
1806/// Whether a new session would run the worker binary as it is on disk now.
1807///
1808/// The daemon copies each worker it can find into a content-addressed cache
1809/// when it starts and serves that copy for the rest of its life, so rebuilding
1810/// `mj-worker` does not reach a running daemon. Nothing else reports this, and
1811/// the digests are what make it checkable at all: two worker builds differ by
1812/// content, not by name or version.
1813fn worker_freshness_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
1814    let mut checks = Vec::new();
1815    let daemon = mj_client::daemon::read_metadata_any()
1816        .ok()
1817        .filter(|metadata| mj_client::daemon::process_is_alive(metadata.pid));
1818    let pinned = pinned_worker_digests();
1819    let mut sources: Vec<(String, Result<WorkerBinaryAvailability>)> = vec![(
1820        "this host".to_owned(),
1821        crate::controller::native_worker_binary_prerequisite(),
1822    )];
1823    for arch in container_worker_architectures(config) {
1824        sources.push((
1825            format!("{arch} Linux targets"),
1826            worker_binary_prerequisite_for_arch(&arch),
1827        ));
1828    }
1829    for (label, availability) in sources {
1830        let id = format!("worker.freshness.{}", label.replace(' ', "-"));
1831        let title = format!("Worker binary for {label}");
1832        let path = match availability {
1833            Ok(WorkerBinaryAvailability::Local { path, .. }) => path,
1834            // A remote worker is fetched by digest when a target is
1835            // provisioned, so it cannot go stale behind a running daemon.
1836            Ok(WorkerBinaryAvailability::Remote { .. }) => continue,
1837            Err(error) => {
1838                checks.push(DoctorCheck::unsupported(
1839                    id,
1840                    title,
1841                    format!("No worker binary resolves for {label}: {error:#}"),
1842                ));
1843                continue;
1844            }
1845        };
1846        let digest = mj_core::worker_launch::worker_executable_digest(&path)
1847            .unwrap_or_else(|error| format!("unreadable ({error:#})"));
1848        let pinned_note = if pinned.is_empty() {
1849            "the daemon has pinned no worker".to_owned()
1850        } else if pinned.contains(&digest) {
1851            "this content is in the daemon's pinned worker cache".to_owned()
1852        } else {
1853            format!(
1854                "the pinned worker cache holds {} instead",
1855                pinned.join(", ")
1856            )
1857        };
1858        let detail = format!("{} has digest {digest}; {pinned_note}.", path.display());
1859        let Some(metadata) = daemon.as_ref() else {
1860            checks.push(DoctorCheck::ready(
1861                id,
1862                title,
1863                format!("{detail} No daemon is running, so the next session uses this file."),
1864            ));
1865            continue;
1866        };
1867        match worker_changed_since_daemon_start(&path, &metadata.started_at) {
1868            Ok(true) => checks.push(DoctorCheck::warning(
1869                id,
1870                title,
1871                format!("{detail} It was rebuilt after daemon {} started, which froze the copy it serves.", metadata.pid),
1872                "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.",
1873            )),
1874            Ok(false) => checks.push(DoctorCheck::ready(id, title, detail)),
1875            Err(error) => checks.push(DoctorCheck::warning(
1876                id,
1877                title,
1878                format!("{detail} Could not compare it with the daemon's start time: {error:#}"),
1879                "Run `mj daemon restart` if rebuilt worker code is not taking effect.",
1880            )),
1881        }
1882    }
1883    checks
1884}
1885
1886/// The architectures this configuration needs a portable Linux worker for.
1887fn container_worker_architectures(config: ConfigStatus<'_>) -> Vec<String> {
1888    let Ok(config) = config else {
1889        return Vec::new();
1890    };
1891    let mut architectures = Vec::new();
1892    for target in config.targets.values() {
1893        let container = match target {
1894            TargetTemplate::LocalPodman { container }
1895            | TargetTemplate::LocalDocker { container }
1896            | TargetTemplate::AppleContainer { container }
1897            | TargetTemplate::SshPodman { container, .. }
1898            | TargetTemplate::SshDocker { container, .. } => container,
1899            _ => continue,
1900        };
1901        let arch = container
1902            .platform
1903            .as_deref()
1904            .and_then(|platform| platform.rsplit('/').next())
1905            .map_or_else(
1906                || std::env::consts::ARCH.to_owned(),
1907                normalized_worker_architecture,
1908            );
1909        if !architectures.contains(&arch) {
1910            architectures.push(arch);
1911        }
1912    }
1913    architectures
1914}
1915
1916/// Container platforms name architectures the way Docker does; worker files
1917/// are named the way Rust target triples do.
1918fn normalized_worker_architecture(platform_arch: &str) -> String {
1919    match platform_arch {
1920        "amd64" => "x86_64".to_owned(),
1921        "arm64" => "aarch64".to_owned(),
1922        other => other.to_owned(),
1923    }
1924}
1925
1926/// The digests the daemon's immutable worker cache holds.
1927///
1928/// The cache is never pruned, so this is what any daemon on this machine has
1929/// pinned at some point, which is why it is reported rather than judged.
1930fn pinned_worker_digests() -> Vec<String> {
1931    let root = mj_core::config::data_dir().join("workers").join("pinned");
1932    let Ok(entries) = std::fs::read_dir(root) else {
1933        return Vec::new();
1934    };
1935    let mut digests: Vec<String> = entries
1936        .flatten()
1937        .filter(|entry| entry.path().is_dir())
1938        .filter_map(|entry| entry.file_name().into_string().ok())
1939        .collect();
1940    digests.sort();
1941    digests
1942}
1943
1944/// Whether a worker file was written after the daemon started.
1945///
1946/// The daemon copies the file it finds at startup, so a later modification is
1947/// exactly the case where the running daemon serves older content.
1948fn worker_changed_since_daemon_start(path: &Path, started_at: &str) -> Result<bool> {
1949    let started: SystemTime = chrono::DateTime::parse_from_rfc3339(started_at)
1950        .map_err(|error| anyhow::anyhow!("parse daemon start time {started_at:?}: {error}"))?
1951        .into();
1952    let modified = std::fs::metadata(path)?.modified()?;
1953    Ok(modified > started)
1954}
1955
1956fn worker_binary_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
1957    let config = match config {
1958        Ok(config) => config,
1959        Err(ConfigGap::NewerVersion(version)) => {
1960            return vec![newer_config_skip(
1961                "worker.containers",
1962                "Container worker binary",
1963                version,
1964            )];
1965        }
1966        Err(ConfigGap::Unreadable) => {
1967            return vec![DoctorCheck::fixable(
1968                "worker.containers",
1969                "Container worker binary",
1970                "Worker availability cannot be checked until config.toml is valid.",
1971                "Fix config.toml, then rerun `mj doctor --json`.",
1972            )];
1973        }
1974    };
1975    let containers = config
1976        .targets
1977        .iter()
1978        .filter_map(|(id, target)| match target {
1979            TargetTemplate::LocalPodman { container }
1980            | TargetTemplate::LocalDocker { container }
1981            | TargetTemplate::AppleContainer { container } => Some((id, container, None)),
1982            TargetTemplate::SshPodman { container, .. } => {
1983                Some((id, container, Some("ssh-podman")))
1984            }
1985            TargetTemplate::SshDocker { container, .. } => {
1986                Some((id, container, Some("ssh-docker")))
1987            }
1988            _ => None,
1989        })
1990        .collect::<Vec<_>>();
1991    if containers.is_empty() {
1992        return vec![DoctorCheck::unsupported(
1993            "worker.containers",
1994            "Container worker binary",
1995            "No container target is configured.",
1996        )];
1997    }
1998    containers
1999        .into_iter()
2000        .map(|(id, container, remote_kind)| {
2001            if let Some(remote_kind) = remote_kind
2002                && container.platform.is_none()
2003            {
2004                // The remote CPU architecture is only observable once the host
2005                // is reachable, so an explicit `platform` is required here.
2006                return DoctorCheck::unsupported(
2007                    format!("worker.{id}"),
2008                    format!("Container worker binary for target {id}"),
2009                    format!(
2010                        "Set `platform` on this {remote_kind} target to check its worker binary; the remote architecture is unknown until provisioning."
2011                    ),
2012                );
2013            }
2014            worker_binary_check(id, container)
2015        })
2016        .collect()
2017}
2018
2019fn worker_binary_check(id: &str, container: &ContainerTemplate) -> DoctorCheck {
2020    let title = format!("Container worker binary for target {id}");
2021    let arch = match container_architecture(container.platform.as_deref()) {
2022        Ok(arch) => arch,
2023        Err(reason) => {
2024            return DoctorCheck::unsupported(format!("worker.{id}"), title, reason);
2025        }
2026    };
2027    let triple = format!("{arch}-unknown-linux-musl");
2028    match worker_binary_prerequisite_for_arch(arch) {
2029        Ok(WorkerBinaryAvailability::Local { path, source }) => DoctorCheck::ready(
2030            format!("worker.{id}"),
2031            title,
2032            format!(
2033                "{triple} worker is available from {source}: {}",
2034                path.display()
2035            ),
2036        ),
2037        Ok(WorkerBinaryAvailability::Remote { url, .. }) => DoctorCheck::ready(
2038            format!("worker.{id}"),
2039            title,
2040            format!("{triple} worker will be verified and downloaded from {url} when needed."),
2041        ),
2042        Err(error) => DoctorCheck::fixable(
2043            format!("worker.{id}"),
2044            title,
2045            format!("No usable {triple} worker source: {error:#}"),
2046            format!(
2047                "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."
2048            ),
2049        ),
2050    }
2051}
2052
2053fn container_architecture(platform: Option<&str>) -> std::result::Result<&'static str, String> {
2054    let candidate = platform.unwrap_or(std::env::consts::ARCH);
2055    let candidate = candidate
2056        .split('/')
2057        .rev()
2058        .find(|part| matches!(*part, "x86_64" | "amd64" | "aarch64" | "arm64"))
2059        .unwrap_or(candidate);
2060    match candidate {
2061        "x86_64" | "amd64" => Ok("x86_64"),
2062        "aarch64" | "arm64" => Ok("aarch64"),
2063        other => Err(format!(
2064            "Container architecture {other:?} is unsupported; Mjolnir supports x86_64 and aarch64 Linux workers."
2065        )),
2066    }
2067}
2068
2069fn apple_container_image(config: ConfigStatus<'_>) -> String {
2070    config
2071        .ok()
2072        .and_then(|config| {
2073            config.targets.values().find_map(|target| match target {
2074                TargetTemplate::AppleContainer { container } => Some(container.image.clone()),
2075                _ => None,
2076            })
2077        })
2078        .unwrap_or_else(|| DEFAULT_CONTAINER_IMAGE.into())
2079}
2080
2081pub fn apple_container_check(
2082    platform: &ApplePlatform,
2083    executor: &impl CommandExecutor,
2084    smoke: bool,
2085    image: String,
2086) -> DoctorCheck {
2087    match platform {
2088        ApplePlatform::Linux => {
2089            return DoctorCheck::unsupported(
2090                "runtime.apple-container",
2091                "Apple container runtime",
2092                "macOS only",
2093            );
2094        }
2095        ApplePlatform::Other(current) => {
2096            return DoctorCheck::unsupported(
2097                "runtime.apple-container",
2098                "Apple container runtime",
2099                format!("macOS only (current platform: {current})"),
2100            );
2101        }
2102        ApplePlatform::Macos {
2103            architecture,
2104            major_version,
2105        } if architecture != "aarch64" && architecture != "arm64" => {
2106            return DoctorCheck::unsupported(
2107                "runtime.apple-container",
2108                "Apple container runtime",
2109                "Apple container requires Apple silicon; Intel Macs are unsupported.",
2110            );
2111        }
2112        ApplePlatform::Macos { major_version, .. } if *major_version < 26 => {
2113            return DoctorCheck::unsupported(
2114                "runtime.apple-container",
2115                "Apple container runtime",
2116                format!("Apple container requires macOS 26 or newer (found {major_version})."),
2117            );
2118        }
2119        ApplePlatform::Macos { .. } => {}
2120    }
2121
2122    let daemon = apple_container_daemon_check(executor);
2123    if daemon.status != CheckStatus::Ready {
2124        return daemon;
2125    }
2126
2127    if !smoke {
2128        return DoctorCheck::fixable(
2129            "runtime.apple-container",
2130            "Apple container runtime",
2131            "The daemon is running, but the required disposable smoke test was not requested.",
2132            "Run `mj doctor --json --smoke`.",
2133        );
2134    }
2135
2136    let target = RuntimeTargetTemplate::AppleContainer(RuntimeContainerTemplate {
2137        build_cache: None,
2138        image,
2139        pull_policy: Default::default(),
2140        extra_run_args: vec![],
2141        workspace_storage: Default::default(),
2142    });
2143    match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
2144        Ok(()) => DoctorCheck::ready(
2145            "runtime.apple-container",
2146            "Apple container runtime",
2147            "Installed, daemon running, and disposable run/exec/remove smoke test passed.",
2148        ),
2149        Err(error) => DoctorCheck::fixable(
2150            "runtime.apple-container",
2151            "Apple container runtime",
2152            format!("Disposable run/exec/remove smoke test failed: {error:#}"),
2153            "Fix the configured image or container runtime, then run `mj doctor --json --smoke` again.",
2154        ),
2155    }
2156}
2157
2158/// Probe that the Apple `container` command is installed and its daemon is
2159/// running, phrased as a doctor check.
2160///
2161/// Split out of [`apple_container_check`] so `mj setup` can reuse the same
2162/// probes and remediation text without also demanding the opt-in smoke test.
2163/// The caller is responsible for platform gating.
2164pub fn apple_container_daemon_check(executor: &impl CommandExecutor) -> DoctorCheck {
2165    let installed =
2166        CommandSpec::new("container", ["--version"]).purpose("check Apple container installation");
2167    match executor.execute(&installed) {
2168        Err(error) => {
2169            return DoctorCheck::fixable(
2170                "runtime.apple-container",
2171                "Apple container runtime",
2172                format!("The `container` command is not available: {error}"),
2173                format!("Install the official signed package: {APPLE_CONTAINER_INSTALL_URL}"),
2174            );
2175        }
2176        Ok(output) if output.status != 0 => {
2177            return DoctorCheck::fixable(
2178                "runtime.apple-container",
2179                "Apple container runtime",
2180                format!(
2181                    "The installed `container --version` command failed: {}",
2182                    String::from_utf8_lossy(&output.stderr).trim()
2183                ),
2184                format!("Reinstall the official signed package: {APPLE_CONTAINER_INSTALL_URL}"),
2185            );
2186        }
2187        Ok(_) => {}
2188    }
2189
2190    let status =
2191        CommandSpec::new("container", ["system", "status"]).purpose("check Apple container daemon");
2192    match executor.execute(&status) {
2193        Ok(output) if output.status == 0 => DoctorCheck::ready(
2194            "runtime.apple-container",
2195            "Apple container runtime",
2196            "Installed, and the Apple container daemon is running.",
2197        ),
2198        Ok(output) => DoctorCheck::fixable(
2199            "runtime.apple-container",
2200            "Apple container runtime",
2201            format!(
2202                "The Apple container daemon is stopped: {}",
2203                String::from_utf8_lossy(&output.stderr).trim()
2204            ),
2205            "Run `container system start`.",
2206        ),
2207        Err(error) => DoctorCheck::fixable(
2208            "runtime.apple-container",
2209            "Apple container runtime",
2210            format!("Could not query the Apple container daemon: {error}"),
2211            "Run `container system start`.",
2212        ),
2213    }
2214}
2215
2216pub fn current_apple_platform(executor: &impl CommandExecutor) -> ApplePlatform {
2217    if cfg!(target_os = "linux") {
2218        return ApplePlatform::Linux;
2219    }
2220    if !cfg!(target_os = "macos") {
2221        return ApplePlatform::Other(std::env::consts::OS.into());
2222    }
2223    let major_version = executor
2224        .execute(&CommandSpec::new("sw_vers", ["-productVersion"]).purpose("detect macOS version"))
2225        .ok()
2226        .filter(|output| output.status == 0)
2227        .and_then(|output| {
2228            String::from_utf8(output.stdout)
2229                .ok()
2230                .and_then(|value| value.trim().split('.').next()?.parse().ok())
2231        })
2232        .unwrap_or(0);
2233    ApplePlatform::Macos {
2234        architecture: std::env::consts::ARCH.into(),
2235        major_version,
2236    }
2237}
2238
2239#[cfg(test)]
2240mod tests;
2241
2242/// What one repository still holds from a Mjolnir review capture.
2243#[derive(Debug, Default, PartialEq, Eq)]
2244pub(crate) struct ReviewResidue {
2245    /// `refs/hel/*` refs in the repository.
2246    pub refs: Vec<String>,
2247    /// Scratch index files left in the Git directory by an interrupted capture.
2248    pub scratch_indexes: Vec<PathBuf>,
2249}
2250
2251impl ReviewResidue {
2252    fn is_empty(&self) -> bool {
2253        self.refs.is_empty() && self.scratch_indexes.is_empty()
2254    }
2255}
2256
2257/// Read what a repository still holds from Mjolnir's review captures.
2258///
2259/// Releases before this one staged the whole working tree into the user's own
2260/// object store and pinned it with two refs, and a capture that was killed
2261/// partway left its scratch index behind. Both are the user's to remove, so
2262/// this only reads.
2263pub(crate) fn review_residue(repository: &Path) -> ReviewResidue {
2264    let mut residue = ReviewResidue::default();
2265    let git_dir = repository.join(".git");
2266    if !git_dir.exists() {
2267        return residue;
2268    }
2269    for reference in ["review-baseline", "review-capture"] {
2270        if git_dir.join("refs/hel").join(reference).is_file() {
2271            residue.refs.push(format!("refs/hel/{reference}"));
2272        }
2273    }
2274    // A packed ref survives `git pack-refs`, which a `git gc` runs.
2275    if let Ok(packed) = std::fs::read_to_string(git_dir.join("packed-refs")) {
2276        for line in packed.lines() {
2277            if let Some((_, reference)) = line.split_once(' ')
2278                && reference.starts_with("refs/hel/")
2279                && !residue.refs.iter().any(|known| known == reference)
2280            {
2281                residue.refs.push(reference.to_owned());
2282            }
2283        }
2284    }
2285    if let Ok(entries) = std::fs::read_dir(&git_dir) {
2286        for entry in entries.filter_map(Result::ok) {
2287            if entry
2288                .file_name()
2289                .to_str()
2290                .is_some_and(|name| name.starts_with("hel-review-index-"))
2291            {
2292                residue.scratch_indexes.push(entry.path());
2293            }
2294        }
2295    }
2296    residue.refs.sort();
2297    residue.scratch_indexes.sort();
2298    residue
2299}
2300
2301/// Report Mjolnir's own leftovers in the repositories the configuration names.
2302///
2303/// This deletes nothing. Removing refs and running `git gc` in someone else's
2304/// repository without asking is the same mistake as writing to it without
2305/// asking, which is what left this residue in the first place.
2306fn review_residue_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
2307    let Ok(config) = config else {
2308        return Vec::new();
2309    };
2310    let mut repositories: Vec<PathBuf> = config
2311        .bundles
2312        .values()
2313        .flat_map(|bundle| bundle.repositories.iter())
2314        .filter_map(|repository| repository.local.clone())
2315        .collect();
2316    // A session started with `--project-directory` has no bundle, and those
2317    // are exactly the repositories a person works in by hand, so they are the
2318    // ones where leftovers matter most. A daemon-less machine has no session
2319    // database, which is not a reason to skip the configured repositories.
2320    if let Ok(state) = crate::database::load_state() {
2321        repositories.extend(
2322            state
2323                .sessions
2324                .values()
2325                .filter_map(|session| session.project_directory.clone()),
2326        );
2327    }
2328    repositories.sort();
2329    repositories.dedup();
2330    if repositories.is_empty() {
2331        return Vec::new();
2332    }
2333    let found = repositories
2334        .into_iter()
2335        .map(|repository| {
2336            let residue = review_residue(&repository);
2337            (repository, residue)
2338        })
2339        .filter(|(_, residue)| !residue.is_empty())
2340        .collect::<Vec<_>>();
2341    if found.is_empty() {
2342        return vec![DoctorCheck::ready(
2343            "review.residue",
2344            "Review leftovers in your repositories",
2345            "No Mjolnir refs or scratch index files were found in the configured repositories.",
2346        )];
2347    }
2348    let detail = found
2349        .iter()
2350        .map(|(repository, residue)| {
2351            let mut parts = Vec::new();
2352            if !residue.refs.is_empty() {
2353                parts.push(residue.refs.join(", "));
2354            }
2355            if !residue.scratch_indexes.is_empty() {
2356                parts.push(format!(
2357                    "{} leftover scratch index file(s)",
2358                    residue.scratch_indexes.len()
2359                ));
2360            }
2361            format!("{}: {}", repository.display(), parts.join("; "))
2362        })
2363        .collect::<Vec<_>>()
2364        .join(". ");
2365    let commands = found
2366        .iter()
2367        .flat_map(|(repository, residue)| {
2368            let repository = repository.display().to_string();
2369            let mut commands = residue
2370                .refs
2371                .iter()
2372                .map(|reference| format!("git -C {repository} update-ref -d {reference}"))
2373                .collect::<Vec<_>>();
2374            commands.extend(
2375                residue
2376                    .scratch_indexes
2377                    .iter()
2378                    .map(|index| format!("rm -f {}", index.display())),
2379            );
2380            commands.push(format!("git -C {repository} gc --prune=now"));
2381            commands
2382        })
2383        .collect::<Vec<_>>()
2384        .join("\n");
2385    vec![DoctorCheck::fixable(
2386        "review.residue",
2387        "Review leftovers in your repositories",
2388        format!(
2389            "Mjolnir left these in repositories it does not own: {detail}. \
2390             A running session's own `refs/hel/review-baseline` is in use; \
2391             remove that one only when no session is working in that repository."
2392        ),
2393        format!("Remove them yourself when you are ready:\n{commands}"),
2394    )]
2395}