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