Skip to main content

mj_controller/
doctor.rs

1//! Actionable host and configuration prerequisite checks.
2
3use std::io::Write;
4use std::path::Path;
5use std::time::{Duration, SystemTime, UNIX_EPOCH};
6
7use anyhow::Result;
8use serde::Serialize;
9
10use crate::controller::{
11    WorkerBinaryAvailability, backend_ssh, worker_binary_prerequisite_for_arch,
12};
13use crate::setup::{
14    DiscoveredHome, discover_harness_homes_with_executor, harness_is_authenticated_with_executor,
15};
16use crate::targets::{
17    BoundedProcessExecutor, CommandExecutor, CommandSpec,
18    ContainerTemplate as RuntimeContainerTemplate, ProcessExecutor, SshTarget as RuntimeSshTarget,
19    TargetTemplate as RuntimeTargetTemplate, run_setup_smoke_test, ssh_command,
20    ssh_connectivity_probe, verify_local_docker, verify_local_podman, verify_ssh_docker,
21    verify_ssh_podman,
22};
23use mj_core::config::{
24    Config, ContainerTemplate, HarnessKind, HarnessProfile, TargetTemplate, config_path,
25};
26use mj_core::credentials::login_command;
27
28// Only the image for the Apple container smoke test when the config has no
29// apple-container target. This intentionally stays a small stock image rather
30// than setup::DEFAULT_IMAGE: the check just proves the runtime can start a
31// container, and pulling the multi-gigabyte agent-dev image to do that would be
32// a poor trade.
33const DEFAULT_CONTAINER_IMAGE: &str = "ubuntu:24.04";
34const APPLE_CONTAINER_INSTALL_URL: &str = "https://github.com/apple/container#initial-install";
35
36/// How long a single prerequisite probe may take before doctor reports it as a
37/// fixable check instead of waiting for it.
38///
39/// Every probe outside the opt-in smoke tests is a local or short network call,
40/// so this only ever fires for a wedged runtime socket, a blackholed network,
41/// or a credential helper waiting on something that will never arrive.
42pub const PROBE_TIMEOUT: Duration = Duration::from_secs(15);
43
44/// The executor `mj doctor` and `mj setup` run their prerequisite probes
45/// through: one deadline per probe, so a wedged runtime cannot hang the run.
46pub const fn probe_executor() -> BoundedProcessExecutor {
47    BoundedProcessExecutor::new(PROBE_TIMEOUT)
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
51#[serde(rename_all = "lowercase")]
52pub enum CheckStatus {
53    Ready,
54    Warning,
55    Fixable,
56    Unsupported,
57}
58
59impl CheckStatus {
60    pub const fn label(self) -> &'static str {
61        match self {
62            Self::Ready => "ready",
63            Self::Warning => "warning",
64            Self::Fixable => "fixable",
65            Self::Unsupported => "unsupported",
66        }
67    }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
71pub struct DoctorCheck {
72    pub id: String,
73    pub title: String,
74    pub status: CheckStatus,
75    pub detail: String,
76    pub remediation: Option<String>,
77}
78
79impl DoctorCheck {
80    fn ready(id: impl Into<String>, title: impl Into<String>, detail: impl Into<String>) -> Self {
81        Self {
82            id: id.into(),
83            title: title.into(),
84            status: CheckStatus::Ready,
85            detail: detail.into(),
86            remediation: None,
87        }
88    }
89
90    fn warning(
91        id: impl Into<String>,
92        title: impl Into<String>,
93        detail: impl Into<String>,
94        remediation: impl Into<String>,
95    ) -> Self {
96        Self {
97            id: id.into(),
98            title: title.into(),
99            status: CheckStatus::Warning,
100            detail: detail.into(),
101            remediation: Some(remediation.into()),
102        }
103    }
104
105    pub(crate) fn fixable(
106        id: impl Into<String>,
107        title: impl Into<String>,
108        detail: impl Into<String>,
109        remediation: impl Into<String>,
110    ) -> Self {
111        Self {
112            id: id.into(),
113            title: title.into(),
114            status: CheckStatus::Fixable,
115            detail: detail.into(),
116            remediation: Some(remediation.into()),
117        }
118    }
119
120    fn unsupported(
121        id: impl Into<String>,
122        title: impl Into<String>,
123        detail: impl Into<String>,
124    ) -> Self {
125        Self {
126            id: id.into(),
127            title: title.into(),
128            status: CheckStatus::Unsupported,
129            detail: detail.into(),
130            remediation: None,
131        }
132    }
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub struct DoctorOptions {
137    pub smoke: bool,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub enum ApplePlatform {
142    Linux,
143    Macos {
144        architecture: String,
145        major_version: u32,
146    },
147    Other(String),
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub enum InstructionsPlatform {
152    Linux,
153    Macos,
154}
155
156pub fn run_current(options: DoctorOptions) -> Vec<DoctorCheck> {
157    if options.smoke {
158        // A smoke test may legitimately pull a multi-gigabyte image, which no
159        // probe deadline could tell apart from a hung runtime, so an opt-in
160        // `--smoke` run keeps waiting for its commands.
161        return run_with(
162            &ProcessExecutor,
163            current_apple_platform(&ProcessExecutor),
164            options,
165        );
166    }
167    let executor = probe_executor();
168    run_with(&executor, current_apple_platform(&executor), options)
169}
170
171pub fn run_with(
172    executor: &impl CommandExecutor,
173    apple_platform: ApplePlatform,
174    options: DoctorOptions,
175) -> Vec<DoctorCheck> {
176    run_with_config_path(&config_path(), executor, apple_platform, options)
177}
178
179/// The same checks as [`run_with`], against an explicit configuration file.
180///
181/// `mj setup` uses this to report on the configuration it just wrote, so a
182/// first run ends with exactly the summary and remediations `mj doctor`
183/// would print.
184pub fn run_with_config_path(
185    config_path: &Path,
186    executor: &impl CommandExecutor,
187    apple_platform: ApplePlatform,
188    options: DoctorOptions,
189) -> Vec<DoctorCheck> {
190    let (config, mut checks) = configuration_checks(config_path);
191    checks.push(harness_discovery_check(config.as_ref(), executor));
192    checks.extend(harness_checks(config.as_ref(), executor));
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(apple_container_check(
201        &apple_platform,
202        executor,
203        options.smoke,
204        apple_container_image(config.as_ref()),
205    ));
206    checks
207}
208
209fn harness_discovery_check(
210    config: Option<&Config>,
211    executor: &impl CommandExecutor,
212) -> DoctorCheck {
213    let home = dirs::home_dir();
214    let overrides = HarnessKind::ALL.into_iter().filter_map(|kind| {
215        std::env::var_os(kind.home_env()).map(|path| (kind, kind.home_from_environment(path)))
216    });
217    let discovered = discover_harness_homes_with_executor(home.as_deref(), overrides, executor);
218    harness_discovery_check_from(
219        &discovered,
220        config.is_some_and(|config| !config.profiles.is_empty()),
221    )
222}
223
224fn harness_discovery_check_from(
225    discovered: &[DiscoveredHome],
226    has_configured_profiles: bool,
227) -> DoctorCheck {
228    if discovered.is_empty() {
229        return if has_configured_profiles {
230            DoctorCheck::ready(
231                "harness.discovery",
232                "Harness home discovery",
233                "No default or environment-overridden harness homes were found; configured profile homes are checked below.",
234            )
235        } else {
236            DoctorCheck::fixable(
237                "harness.discovery",
238                "Harness home discovery",
239                "No Codex, Claude Code, Kimi Code, or Grok Build home was found in the default or environment-overridden locations.",
240                "Install and sign in to a supported harness, then run `mj setup`.",
241            )
242        };
243    }
244
245    let homes = discovered
246        .iter()
247        .map(|home| {
248            let authentication = if home.authenticated {
249                "authenticated"
250            } else {
251                "not authenticated"
252            };
253            format!(
254                "{} at {} ({authentication})",
255                home.kind.display_name(),
256                home.path.display()
257            )
258        })
259        .collect::<Vec<_>>()
260        .join("; ");
261    DoctorCheck::ready(
262        "harness.discovery",
263        "Harness home discovery",
264        format!("Discovered {homes}. Configured profile authentication is checked below."),
265    )
266}
267
268pub fn all_ready(checks: &[DoctorCheck]) -> bool {
269    checks
270        .iter()
271        .all(|check| check.status != CheckStatus::Fixable)
272}
273
274pub fn render_human(checks: &[DoctorCheck], output: &mut impl Write) -> Result<()> {
275    for check in checks {
276        writeln!(
277            output,
278            "{} {}: {}",
279            check.status.label(),
280            check.title,
281            check.detail
282        )?;
283        if let Some(remediation) = &check.remediation {
284            writeln!(output, "  remediation: {remediation}")?;
285        }
286    }
287    Ok(())
288}
289
290pub fn setup_instructions(platform: InstructionsPlatform) -> String {
291    match platform {
292        InstructionsPlatform::Linux => format!(
293            "# Hel setup instructions for Linux\n\n\
294This page is self-contained. Follow this exact loop as the user who will run Hel:\n\n\
2951. Run `mj doctor --json`.\n\
2962. Follow every `fixable` remediation from its JSON output.\n\
2973. Run `mj doctor --json` again. Repeat until no check is `fixable`.\n\
2984. Finish with `mj doctor --json --smoke` to verify every configured container\n\
299   image end to end, and resolve anything it reports as `fixable`.\n\n\
300For a coding-agent handoff, provide this entire instructions page together with\n\
301the latest `mj doctor --json` output.\n\n\
302## Linux container-runtime postconditions\n\n{}\n\n{}",
303            crate::targets::PODMAN_DOCUMENTATION,
304            crate::targets::DOCKER_DOCUMENTATION
305        ),
306        InstructionsPlatform::Macos => format!(
307            "# Hel setup instructions for macOS\n\n\
308This page is self-contained. Follow this exact loop as the user who will run Hel:\n\n\
3091. Run `mj doctor --json`.\n\
3102. Follow every `fixable` remediation from its JSON output.\n\
3113. Run `mj doctor --json` again. Repeat until no check is `fixable`.\n\n\
312For a coding-agent handoff, provide this entire instructions page together with\n\
313the latest `mj doctor --json` output.\n\n\
314## Apple container runtime\n\n\
315Hel's Apple container target requires Apple silicon and macOS 26 or newer.\n\
316On an Intel Mac or an older macOS release, the target is unsupported; use a\n\
317local Podman, SSH, or AWS target instead.\n\n\
318If the `container` command is absent, install only the official signed package:\n\n\
319<https://github.com/apple/container#initial-install>\n\n\
320Hel never downloads or installs that package. If doctor reports a stopped\n\
321daemon, run exactly:\n\n```console\ncontainer system start\n```\n\n\
322Finish with the opt-in disposable runtime test in JSON mode:\n\n```console\nmj doctor --json --smoke\n```\n\n\
323Apple container is ready only when that smoke test creates a disposable\n\
324container, executes `true` in it, and removes it successfully. Use the image\n\
325configured by an `apple-container` target; without one, doctor uses\n\
326`{DEFAULT_CONTAINER_IMAGE}` for the smoke test.\n\n\
327## Shared Hel prerequisites\n\n\
328`mj doctor --json` also checks the configuration, each configured harness home\n\
329and authentication marker, selected container worker binaries, and any relevant\n\
330Podman prerequisites. Resolve every `fixable` status before starting a session."
331        ),
332    }
333}
334
335fn configuration_checks(path: &Path) -> (Option<Config>, Vec<DoctorCheck>) {
336    if !path.exists() {
337        return (
338            None,
339            vec![DoctorCheck::fixable(
340                "config",
341                "Mjolnir configuration",
342                format!("{} does not exist", path.display()),
343                "Run `mj setup` to create config.toml.",
344            )],
345        );
346    }
347    match Config::load_from(path) {
348        Ok(config) => {
349            let mut checks = vec![match config.newer_build_notice() {
350                // Hel still runs on a config a newer build owns, but every
351                // save refuses, so say so rather than reporting it as valid.
352                Some(notice) => DoctorCheck::warning(
353                    "config",
354                    "Mjolnir configuration",
355                    format!("{}: {notice}", path.display()),
356                    "Update Mjolnir, or change settings with the newer build.",
357                ),
358                None => DoctorCheck::ready(
359                    "config",
360                    "Mjolnir configuration",
361                    format!("{} is valid", path.display()),
362                ),
363            }];
364            if config.enabled_profiles().next().is_none()
365                || config.bundles.is_empty()
366                || config.targets.is_empty()
367            {
368                checks.push(DoctorCheck::fixable(
369                    "config.session-prerequisites",
370                    "Session configuration",
371                    "At least one enabled profile, bundle, and target are required.",
372                    "Run `mj setup`, or enable or add profiles, bundles, and targets in config.toml.",
373                ));
374            } else {
375                checks.push(DoctorCheck::ready(
376                    "config.session-prerequisites",
377                    "Session configuration",
378                    "At least one profile, bundle, and target are configured.",
379                ));
380            }
381            (Some(config), checks)
382        }
383        Err(error) => (
384            None,
385            vec![DoctorCheck::fixable(
386                "config",
387                "Mjolnir configuration",
388                format!("{} is invalid: {error:#}", path.display()),
389                "Fix the reported TOML error in config.toml, or run `mj setup` to replace it.",
390            )],
391        ),
392    }
393}
394
395fn harness_checks(config: Option<&Config>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
396    let Some(config) = config else {
397        return vec![DoctorCheck::fixable(
398            "harness.profiles",
399            "Harness profiles",
400            "Harness homes cannot be checked until config.toml is valid.",
401            "Fix config.toml, then rerun `mj doctor --json`.",
402        )];
403    };
404    if config.profiles.is_empty() {
405        return vec![DoctorCheck::fixable(
406            "harness.profiles",
407            "Harness profiles",
408            "No harness profiles are configured.",
409            "Run `mj setup` to discover homes, or add a profile to config.toml.",
410        )];
411    }
412    config
413        .profiles
414        .iter()
415        .map(|(id, profile)| {
416            let title = format!("Harness profile {id}");
417            if !profile.enabled {
418                return DoctorCheck::ready(
419                    format!("harness.{id}"),
420                    title,
421                    "Profile is disabled; home and authentication checks were skipped.",
422                );
423            }
424            if !profile.home.is_dir() {
425                return DoctorCheck::fixable(
426                    format!("harness.{id}"),
427                    title,
428                    format!("{} does not exist", profile.home.display()),
429                    format!(
430                        "{} If this profile should use an existing installation, select its home in Setup.",
431                        harness_login_remediation(id, profile)
432                    ),
433                );
434            }
435            if !harness_is_authenticated_with_executor(profile.kind, &profile.home, executor) {
436                return DoctorCheck::fixable(
437                    format!("harness.{id}"),
438                    title,
439                    format!(
440                        "No usable authentication was detected for {}",
441                        profile.home.display()
442                    ),
443                    harness_login_remediation(id, profile),
444                );
445            }
446            DoctorCheck::ready(
447                format!("harness.{id}"),
448                title,
449                format!(
450                    "{} is present and authentication is available",
451                    profile.home.display()
452                ),
453            )
454        })
455        .collect()
456}
457
458/// Point an unauthenticated profile at `mj login`, which already knows how to
459/// sign each harness in.
460///
461/// The underlying command is named only for the reader's benefit; it comes from
462/// [`login_command`], the one place that tracks what each harness CLI actually
463/// accepts, so this text cannot drift away from what `mj login` runs.
464fn harness_login_remediation(id: &str, profile: &HarnessProfile) -> String {
465    let (program, arguments) = login_command(profile);
466    format!(
467        "Run `mj login --profile {id}`; it runs `{program} {}` against {}.",
468        arguments.join(" "),
469        profile.home.display()
470    )
471}
472
473/// Host Podman prerequisites, then one image check per `local-podman` target.
474///
475/// The image checks run only after the host preflight passes, because a broken
476/// Podman installation already reports its own actionable check.
477fn podman_checks(
478    config: Option<&Config>,
479    executor: &impl CommandExecutor,
480    smoke: bool,
481) -> Vec<DoctorCheck> {
482    let preflight = podman_check(config, executor);
483    let preflight_passed = preflight.status == CheckStatus::Ready;
484    let mut checks = vec![preflight];
485    if preflight_passed {
486        checks.extend(podman_image_checks(config, executor, smoke));
487    }
488    checks
489}
490
491fn podman_check(config: Option<&Config>, executor: &impl CommandExecutor) -> DoctorCheck {
492    let Some(config) = config else {
493        return DoctorCheck::unsupported(
494            "runtime.podman",
495            "Rootless Podman",
496            "Podman prerequisites cannot be evaluated until config.toml is valid.",
497        );
498    };
499    if local_podman_targets(config).is_empty() {
500        return DoctorCheck::unsupported(
501            "runtime.podman",
502            "Rootless Podman",
503            "No local-podman target is configured.",
504        );
505    }
506    local_podman_runtime_check(executor)
507}
508
509/// Probe the local rootless Podman prerequisites and phrase the result as a
510/// doctor check.
511///
512/// This is the single source of truth for Podman availability wording and
513/// remediation. `mj setup` calls it directly so its runtime list reports the
514/// same detail and fix that `mj doctor` would.
515pub fn local_podman_runtime_check(executor: &impl CommandExecutor) -> DoctorCheck {
516    match verify_local_podman(executor) {
517        Ok(preflight) => DoctorCheck::ready(
518            "runtime.podman",
519            "Rootless Podman",
520            format!("Podman {} has a valid rootless UID map.", preflight.version),
521        ),
522        Err(error) => {
523            let detail = format!("{error:#}");
524            DoctorCheck::fixable(
525                "runtime.podman",
526                "Rootless Podman",
527                detail.clone(),
528                podman_remediation(&detail),
529            )
530        }
531    }
532}
533
534fn local_podman_targets(config: &Config) -> Vec<(&String, &ContainerTemplate)> {
535    config
536        .targets
537        .iter()
538        .filter_map(|(id, target)| match target {
539            TargetTemplate::LocalPodman { container } => Some((id, container)),
540            _ => None,
541        })
542        .collect()
543}
544
545fn podman_image_checks(
546    config: Option<&Config>,
547    executor: &impl CommandExecutor,
548    smoke: bool,
549) -> Vec<DoctorCheck> {
550    let Some(config) = config else {
551        return Vec::new();
552    };
553    local_podman_targets(config)
554        .into_iter()
555        .map(|(id, container)| podman_image_check(id, &container.image, executor, smoke))
556        .collect()
557}
558
559fn podman_image_check(
560    id: &str,
561    image: &str,
562    executor: &impl CommandExecutor,
563    smoke: bool,
564) -> DoctorCheck {
565    let check_id = format!("runtime.podman.image.{id}");
566    let title = format!("Podman image for target {id}");
567    if smoke {
568        let target = RuntimeTargetTemplate::LocalPodman(RuntimeContainerTemplate {
569            image: image.to_owned(),
570            pull_policy: Default::default(),
571            extra_run_args: vec![],
572            workspace_storage: Default::default(),
573        });
574        return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
575            Ok(()) => DoctorCheck::ready(
576                check_id,
577                title,
578                format!("Disposable run/exec/remove smoke test passed for image {image}."),
579            ),
580            Err(error) => DoctorCheck::fixable(
581                check_id,
582                title,
583                format!(
584                    "Disposable run/exec/remove smoke test failed for image {image}: {error:#}"
585                ),
586                "Fix the configured image or Podman runtime, then run `mj doctor --json --smoke` again.",
587            ),
588        };
589    }
590
591    let command = CommandSpec::new("podman", ["image", "exists", image])
592        .purpose("check Podman image presence");
593    match executor.execute(&command) {
594        Ok(output) if output.status == 0 => DoctorCheck::ready(
595            check_id,
596            title,
597            format!("Image {image} is present in local Podman storage."),
598        ),
599        Ok(_) => DoctorCheck::fixable(
600            check_id,
601            title,
602            format!("Image {image} is not present in local Podman storage."),
603            missing_image_remediation(image),
604        ),
605        Err(error) => DoctorCheck::fixable(
606            check_id,
607            title,
608            format!(
609                "Could not check whether image {image} is present in local Podman storage: {error}"
610            ),
611            missing_image_remediation(image),
612        ),
613    }
614}
615
616fn missing_image_remediation(image: &str) -> String {
617    format!(
618        "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."
619    )
620}
621
622/// Host Docker prerequisites, then one image check per `local-docker` target.
623fn docker_checks(
624    config: Option<&Config>,
625    executor: &impl CommandExecutor,
626    smoke: bool,
627) -> Vec<DoctorCheck> {
628    let Some(config) = config else {
629        return vec![DoctorCheck::unsupported(
630            "runtime.docker",
631            "Docker",
632            "Docker prerequisites cannot be evaluated until config.toml is valid.",
633        )];
634    };
635    let targets = local_docker_targets(config);
636    if targets.is_empty() {
637        return vec![DoctorCheck::unsupported(
638            "runtime.docker",
639            "Docker",
640            "No local-docker target is configured.",
641        )];
642    }
643    let preflight = local_docker_runtime_check(executor);
644    if preflight.status != CheckStatus::Ready {
645        return vec![preflight];
646    }
647    let mut checks = vec![preflight];
648    checks.extend(
649        targets
650            .into_iter()
651            .map(|(id, container)| docker_image_check(id, &container.image, executor, smoke)),
652    );
653    checks
654}
655
656pub fn local_docker_runtime_check(executor: &impl CommandExecutor) -> DoctorCheck {
657    match verify_local_docker(executor) {
658        Ok(preflight) => DoctorCheck::ready(
659            "runtime.docker",
660            "Docker",
661            format!(
662                "Docker {} is connected to a Linux daemon.",
663                preflight.version
664            ),
665        ),
666        Err(error) => DoctorCheck::fixable(
667            "runtime.docker",
668            "Docker",
669            format!("{error:#}"),
670            "Install and start Docker, then make sure `docker info` succeeds as the user running mj.",
671        ),
672    }
673}
674
675fn local_docker_targets(config: &Config) -> Vec<(&String, &ContainerTemplate)> {
676    config
677        .targets
678        .iter()
679        .filter_map(|(id, target)| match target {
680            TargetTemplate::LocalDocker { container } => Some((id, container)),
681            _ => None,
682        })
683        .collect()
684}
685
686fn docker_image_check(
687    id: &str,
688    image: &str,
689    executor: &impl CommandExecutor,
690    smoke: bool,
691) -> DoctorCheck {
692    let check_id = format!("runtime.docker.image.{id}");
693    let title = format!("Docker image for target {id}");
694    if smoke {
695        let target = RuntimeTargetTemplate::LocalDocker(RuntimeContainerTemplate {
696            image: image.to_owned(),
697            pull_policy: Default::default(),
698            extra_run_args: vec![],
699            workspace_storage: Default::default(),
700        });
701        return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
702            Ok(()) => DoctorCheck::ready(
703                check_id,
704                title,
705                format!(
706                    "Disposable run/exec/remove and OverlayFS attachment smoke test passed for image {image}."
707                ),
708            ),
709            Err(error) => DoctorCheck::fixable(
710                check_id,
711                title,
712                format!(
713                    "Disposable run/exec/remove smoke test failed for image {image}: {error:#}"
714                ),
715                "Fix the configured image or Docker runtime, then run `mj doctor --json --smoke` again.",
716            ),
717        };
718    }
719    let command = CommandSpec::new("docker", ["image", "inspect", image])
720        .purpose("check Docker image presence");
721    match executor.execute(&command) {
722        Ok(output) if output.status == 0 => DoctorCheck::ready(
723            check_id,
724            title,
725            format!("Image {image} is present in Docker storage."),
726        ),
727        Ok(_) => DoctorCheck::fixable(
728            check_id,
729            title,
730            format!("Image {image} is not present in Docker storage."),
731            format!("Pull it with `docker pull {image}`, or run `mj doctor --json --smoke`."),
732        ),
733        Err(error) => DoctorCheck::fixable(
734            check_id,
735            title,
736            format!("Could not inspect Docker image {image}: {error}"),
737            format!("Make sure `docker info` succeeds, then run `docker pull {image}`."),
738        ),
739    }
740}
741
742/// The outcome of the shared SSH connectivity probe.
743///
744/// Both SSH-backed checks run this first: an unreachable host makes every
745/// later probe fail with a misleading message.
746enum SshConnectivity {
747    Reachable,
748    Failed { detail: String, remediation: String },
749}
750
751/// Probe `ssh <destination> true` and map any failure to a copy-paste fix.
752///
753/// Hel never generates keys, runs `ssh-copy-id`, or accepts a host key on the
754/// user's behalf; it only says exactly which command would fix the failure.
755fn ssh_connectivity(ssh: &RuntimeSshTarget, executor: &impl CommandExecutor) -> SshConnectivity {
756    let destination = &ssh.destination;
757    let command = ssh_connectivity_probe(ssh);
758    match executor.execute(&command) {
759        Err(error) => SshConnectivity::Failed {
760            detail: format!("Could not run `ssh {destination} true`: {error}"),
761            remediation: SSH_MISSING_REMEDIATION.to_owned(),
762        },
763        Ok(output) if output.status != 0 => {
764            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
765            SshConnectivity::Failed {
766                detail: format!("`ssh {destination} true` failed: {stderr}"),
767                remediation: ssh_failure_remediation(&stderr, ssh),
768            }
769        }
770        Ok(_) => SshConnectivity::Reachable,
771    }
772}
773
774const 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).";
775
776/// Map `ssh -o BatchMode=yes` stderr to the command that fixes it.
777fn ssh_failure_remediation(stderr: &str, ssh: &RuntimeSshTarget) -> String {
778    let destination = &ssh.destination;
779    if stderr.contains("Host key verification failed")
780        || stderr.contains("No ECDSA host key is known")
781        || stderr.contains("REMOTE HOST IDENTIFICATION HAS CHANGED")
782    {
783        let host = ssh_host_only(destination);
784        return format!(
785            "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."
786        );
787    }
788    if stderr.contains("Permission denied")
789        || stderr.contains("Too many authentication failures")
790        || stderr.contains("no matching host key")
791        || stderr.contains("Authentication failed")
792    {
793        return match ssh_identity_file(ssh) {
794            Some(identity) => format!(
795                "Install your public key on the host with `ssh-copy-id -i {identity}.pub {destination}`."
796            ),
797            None => {
798                format!("Install your public key on the host with `ssh-copy-id {destination}`.")
799            }
800        };
801    }
802    if stderr.contains("ssh: command not found") || stderr.contains("No such file or directory") {
803        return SSH_MISSING_REMEDIATION.to_owned();
804    }
805    format!("Run `ssh {destination} true` by hand and resolve the error it reports: {stderr}")
806}
807
808/// The host part of an OpenSSH destination, without any `user@` prefix.
809fn ssh_host_only(destination: &str) -> &str {
810    destination
811        .rsplit_once('@')
812        .map_or(destination, |(_, host)| host)
813}
814
815/// The identity file provisioning passes, recovered from the built ssh args.
816fn ssh_identity_file(ssh: &RuntimeSshTarget) -> Option<&str> {
817    let position = ssh.ssh_args.iter().position(|arg| arg == "-i")?;
818    ssh.ssh_args.get(position + 1).map(String::as_str)
819}
820
821/// One check per `ssh-bare` target: can Hel reach the host noninteractively?
822fn ssh_bare_checks(config: Option<&Config>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
823    let Some(config) = config else {
824        return Vec::new();
825    };
826    config
827        .targets
828        .iter()
829        .filter_map(|(id, target)| match target {
830            TargetTemplate::SshBare { ssh, .. } => {
831                Some(ssh_bare_check(id, &backend_ssh(ssh), executor))
832            }
833            _ => None,
834        })
835        .collect()
836}
837
838fn ssh_bare_check(
839    id: &str,
840    ssh: &RuntimeSshTarget,
841    executor: &impl CommandExecutor,
842) -> DoctorCheck {
843    let check_id = format!("runtime.ssh-bare.{id}");
844    let title = format!("SSH access for target {id}");
845    match ssh_connectivity(ssh, executor) {
846        SshConnectivity::Reachable => DoctorCheck::ready(
847            check_id,
848            title,
849            format!(
850                "`ssh {} true` succeeds noninteractively from this host.",
851                ssh.destination
852            ),
853        ),
854        SshConnectivity::Failed {
855            detail,
856            remediation,
857        } => DoctorCheck::fixable(check_id, title, detail, remediation),
858    }
859}
860
861/// One check per `ssh-podman` target: the same Podman probes, run over SSH.
862fn ssh_podman_checks(
863    config: Option<&Config>,
864    executor: &impl CommandExecutor,
865    smoke: bool,
866) -> Vec<DoctorCheck> {
867    let Some(config) = config else {
868        return Vec::new();
869    };
870    config
871        .targets
872        .iter()
873        .filter_map(|(id, target)| match target {
874            TargetTemplate::SshPodman { ssh, container, .. } => Some(ssh_podman_check(
875                id,
876                &backend_ssh(ssh),
877                &container.image,
878                executor,
879                smoke,
880            )),
881            _ => None,
882        })
883        .collect()
884}
885
886fn ssh_podman_check(
887    id: &str,
888    ssh: &RuntimeSshTarget,
889    image: &str,
890    executor: &impl CommandExecutor,
891    smoke: bool,
892) -> DoctorCheck {
893    let check_id = format!("runtime.ssh-podman.{id}");
894    let title = format!("Remote Podman for target {id}");
895    let destination = &ssh.destination;
896    // Connectivity first: a remote Podman probe on an unreachable host reports
897    // a Podman problem the user does not have.
898    if let SshConnectivity::Failed {
899        detail,
900        remediation,
901    } = ssh_connectivity(ssh, executor)
902    {
903        return DoctorCheck::fixable(check_id, title, detail, remediation);
904    }
905    let preflight = match verify_ssh_podman(ssh, executor) {
906        Ok(preflight) => preflight,
907        Err(error) => {
908            let detail = format!("{error:#}");
909            let remediation = match podman_remediation_match(&detail) {
910                Some(remediation) => format!("On {destination}: {remediation}"),
911                None => format!(
912                    "Verify `ssh {destination}` succeeds noninteractively from this host, then install rootless Podman 4 or newer there (see docs/PODMAN.md)."
913                ),
914            };
915            return DoctorCheck::fixable(check_id, title, detail, remediation);
916        }
917    };
918    let linger_warning = preflight.warnings.first();
919    if !smoke && let Some(warning) = linger_warning {
920        return DoctorCheck::warning(
921            check_id,
922            title,
923            format!(
924                "Remote rootless Podman {} is available via {destination}, but {}",
925                preflight.version, warning.detail
926            ),
927            &warning.remediation,
928        );
929    }
930    if !smoke {
931        return DoctorCheck::ready(
932            check_id,
933            title,
934            format!(
935                "Remote rootless Podman {} is available via {destination}. Run `mj doctor --json --smoke` to verify the image end to end.",
936                preflight.version
937            ),
938        );
939    }
940
941    let target = RuntimeTargetTemplate::SshPodman {
942        ssh: ssh.clone(),
943        container: RuntimeContainerTemplate {
944            image: image.to_owned(),
945            pull_policy: Default::default(),
946            extra_run_args: vec![],
947            workspace_storage: Default::default(),
948        },
949    };
950    match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
951        Ok(()) => match linger_warning {
952            Some(warning) => DoctorCheck::warning(
953                check_id,
954                title,
955                format!(
956                    "Disposable run/exec/remove smoke test passed for image {image} on {destination}, but {}",
957                    warning.detail
958                ),
959                &warning.remediation,
960            ),
961            None => DoctorCheck::ready(
962                check_id,
963                title,
964                format!(
965                    "Disposable run/exec/remove smoke test passed for image {image} on {destination}."
966                ),
967            ),
968        },
969        Err(error) => DoctorCheck::fixable(
970            check_id,
971            title,
972            format!(
973                "Disposable run/exec/remove smoke test failed for image {image} on {destination}: {error:#}"
974            ),
975            format!(
976                "Fix the configured image or Podman runtime on {destination}, then run `mj doctor --json --smoke` again."
977            ),
978        ),
979    }
980}
981
982/// One check per `ssh-docker` target: Docker daemon, image, and optional
983/// remote OverlayFS smoke test, all executed on the SSH host.
984fn ssh_docker_checks(
985    config: Option<&Config>,
986    executor: &impl CommandExecutor,
987    smoke: bool,
988) -> Vec<DoctorCheck> {
989    let Some(config) = config else {
990        return Vec::new();
991    };
992    config
993        .targets
994        .iter()
995        .filter_map(|(id, target)| match target {
996            TargetTemplate::SshDocker { ssh, container } => Some(ssh_docker_check(
997                id,
998                &backend_ssh(ssh),
999                &container.image,
1000                executor,
1001                smoke,
1002            )),
1003            _ => None,
1004        })
1005        .collect()
1006}
1007
1008fn ssh_docker_check(
1009    id: &str,
1010    ssh: &RuntimeSshTarget,
1011    image: &str,
1012    executor: &impl CommandExecutor,
1013    smoke: bool,
1014) -> DoctorCheck {
1015    let check_id = format!("runtime.ssh-docker.{id}");
1016    let title = format!("Remote Docker for target {id}");
1017    let destination = &ssh.destination;
1018    if let SshConnectivity::Failed {
1019        detail,
1020        remediation,
1021    } = ssh_connectivity(ssh, executor)
1022    {
1023        return DoctorCheck::fixable(check_id, title, detail, remediation);
1024    }
1025
1026    let preflight = match verify_ssh_docker(ssh, executor) {
1027        Ok(preflight) => preflight,
1028        Err(error) => {
1029            let detail = format!("{error:#}");
1030            return DoctorCheck::fixable(
1031                check_id,
1032                title,
1033                detail,
1034                format!(
1035                    "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."
1036                ),
1037            );
1038        }
1039    };
1040
1041    if smoke {
1042        let target = RuntimeTargetTemplate::SshDocker {
1043            ssh: ssh.clone(),
1044            container: RuntimeContainerTemplate {
1045                image: image.to_owned(),
1046                pull_policy: Default::default(),
1047                extra_run_args: vec![],
1048                workspace_storage: Default::default(),
1049            },
1050        };
1051        return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1052            Ok(()) => DoctorCheck::ready(
1053                check_id,
1054                title,
1055                format!(
1056                    "Remote Docker {} is available via {destination}; disposable run/exec/remove and remote OverlayFS attachment smoke test passed for image {image}.",
1057                    preflight.version
1058                ),
1059            ),
1060            Err(error) => DoctorCheck::fixable(
1061                check_id,
1062                title,
1063                format!(
1064                    "Disposable run/exec/remove smoke test failed for image {image} on {destination}: {error:#}"
1065                ),
1066                format!(
1067                    "Fix the configured image or Docker runtime on {destination}, then run `mj doctor --json --smoke` again."
1068                ),
1069            ),
1070        };
1071    }
1072
1073    let image_command = ssh_command(
1074        ssh,
1075        [
1076            "docker".to_owned(),
1077            "image".to_owned(),
1078            "inspect".to_owned(),
1079            image.to_owned(),
1080        ]
1081        .to_vec(),
1082    )
1083    .purpose("check remote Docker image presence");
1084    match executor.execute(&image_command) {
1085        Ok(output) if output.status == 0 => DoctorCheck::ready(
1086            check_id,
1087            title,
1088            format!(
1089                "Remote Docker {} is available via {destination}; image {image} is present. Run `mj doctor --json --smoke` to verify remote OverlayFS attachments.",
1090                preflight.version
1091            ),
1092        ),
1093        Ok(output) => DoctorCheck::fixable(
1094            check_id,
1095            title,
1096            format!(
1097                "Image {image} is not present in remote Docker storage on {destination}: {}",
1098                String::from_utf8_lossy(&output.stderr).trim()
1099            ),
1100            format!(
1101                "Pull it on {destination} with `ssh {destination} docker pull {image}`, or run `mj doctor --json --smoke`."
1102            ),
1103        ),
1104        Err(error) => DoctorCheck::fixable(
1105            check_id,
1106            title,
1107            format!("Could not inspect remote Docker image {image} on {destination}: {error}"),
1108            format!(
1109                "Verify `ssh {destination} docker info` succeeds, then pull {image} on that host."
1110            ),
1111        ),
1112    }
1113}
1114
1115/// Shared disposable-container identity for every doctor smoke test.
1116fn doctor_smoke_id() -> String {
1117    format!(
1118        "doctor-{}-{:x}",
1119        std::process::id(),
1120        SystemTime::now()
1121            .duration_since(UNIX_EPOCH)
1122            .unwrap_or_default()
1123            .as_nanos()
1124    )
1125}
1126
1127fn podman_remediation(detail: &str) -> &'static str {
1128    podman_remediation_match(detail).unwrap_or(
1129        "Install Podman with `sudo apt update && sudo apt install -y podman uidmap` (Debian/Ubuntu) or `sudo dnf install -y podman shadow-utils` (Fedora).",
1130    )
1131}
1132
1133/// Map a Podman preflight failure to its specific remediation, if one applies.
1134fn podman_remediation_match(detail: &str) -> Option<&'static str> {
1135    if detail.contains("Podman 4.0.0") {
1136        Some(
1137            "Upgrade Podman: Debian/Ubuntu `sudo apt update && sudo apt install -y podman uidmap`; Fedora `sudo dnf install -y podman shadow-utils`.",
1138        )
1139    } else if detail.contains("podman unshare") {
1140        Some(
1141            "Install UID mapping support with `sudo apt install -y uidmap` (Debian/Ubuntu) or `sudo dnf install -y shadow-utils` (Fedora), add `/etc/subuid` and `/etc/subgid` entries, then log out and back in.",
1142        )
1143    } else if detail.contains("Rootless") {
1144        Some(
1145            "Run mj without `sudo`; unset `CONTAINER_HOST` and select the rootless local Podman connection.",
1146        )
1147    } else {
1148        None
1149    }
1150}
1151
1152const AWS_CLI_INSTALL_URL: &str =
1153    "https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html";
1154
1155/// One check per `aws-ec2` target: the AWS CLI, its credentials, and the
1156/// configured launch template.
1157fn aws_checks(config: Option<&Config>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
1158    let Some(config) = config else {
1159        return Vec::new();
1160    };
1161    config
1162        .targets
1163        .iter()
1164        .filter_map(|(id, target)| match target {
1165            TargetTemplate::AwsEc2 {
1166                aws_profile,
1167                region,
1168                launch_template,
1169                ..
1170            } => Some(aws_target_check(
1171                id,
1172                aws_profile.as_deref(),
1173                region,
1174                launch_template,
1175                executor,
1176            )),
1177            _ => None,
1178        })
1179        .collect()
1180}
1181
1182/// The profile and region every AWS probe carries, applied exactly the way
1183/// provisioning applies them in `targets`.
1184fn aws_global_args<'a>(profile: Option<&'a str>, region: &'a str) -> Vec<String> {
1185    vec![
1186        "--profile".to_owned(),
1187        profile.unwrap_or("default").to_owned(),
1188        "--region".to_owned(),
1189        region.to_owned(),
1190    ]
1191}
1192
1193fn aws_target_check(
1194    id: &str,
1195    profile: Option<&str>,
1196    region: &str,
1197    launch_template: &str,
1198    executor: &impl CommandExecutor,
1199) -> DoctorCheck {
1200    let check_id = format!("runtime.aws-ec2.{id}");
1201    let title = format!("AWS EC2 target {id}");
1202    let profile_label = profile.unwrap_or("default");
1203
1204    let version = CommandSpec::new("aws", ["--version"]).purpose("check AWS CLI installation");
1205    match executor.execute(&version) {
1206        Err(error) => {
1207            return DoctorCheck::fixable(
1208                check_id,
1209                title,
1210                format!("The `aws` command is not available: {error}"),
1211                format!("Install the AWS CLI and put `aws` on PATH: {AWS_CLI_INSTALL_URL}"),
1212            );
1213        }
1214        Ok(output) if output.status != 0 => {
1215            return DoctorCheck::fixable(
1216                check_id,
1217                title,
1218                format!(
1219                    "`aws --version` failed: {}",
1220                    String::from_utf8_lossy(&output.stderr).trim()
1221                ),
1222                format!("Reinstall the AWS CLI: {AWS_CLI_INSTALL_URL}"),
1223            );
1224        }
1225        Ok(_) => {}
1226    }
1227
1228    let mut identity_args = aws_global_args(profile, region);
1229    identity_args.extend(["sts".to_owned(), "get-caller-identity".to_owned()]);
1230    identity_args.extend(["--output".to_owned(), "json".to_owned()]);
1231    let identity =
1232        CommandSpec::new("aws", identity_args).purpose("check AWS credentials for a doctor target");
1233    match executor.execute(&identity) {
1234        Err(error) => {
1235            return DoctorCheck::fixable(
1236                check_id,
1237                title,
1238                format!("Could not run `aws sts get-caller-identity`: {error}"),
1239                format!(
1240                    "Configure credentials with `aws configure --profile {profile_label}`, or sign in with `aws sso login --profile {profile_label}`."
1241                ),
1242            );
1243        }
1244        Ok(output) if output.status != 0 => {
1245            return DoctorCheck::fixable(
1246                check_id,
1247                title,
1248                format!(
1249                    "AWS credentials for profile {profile_label} are not usable: {}",
1250                    String::from_utf8_lossy(&output.stderr).trim()
1251                ),
1252                format!(
1253                    "Configure credentials with `aws configure --profile {profile_label}`, or sign in with `aws sso login --profile {profile_label}`."
1254                ),
1255            );
1256        }
1257        Ok(_) => {}
1258    }
1259
1260    // Launch templates are addressed by id when they carry the `lt-` prefix
1261    // and by name otherwise, the same split provisioning uses.
1262    let by_id = launch_template.starts_with("lt-");
1263    let mut template_args = aws_global_args(profile, region);
1264    template_args.extend(["ec2".to_owned(), "describe-launch-templates".to_owned()]);
1265    template_args.extend([
1266        if by_id {
1267            "--launch-template-ids".to_owned()
1268        } else {
1269            "--launch-template-names".to_owned()
1270        },
1271        launch_template.to_owned(),
1272    ]);
1273    template_args.extend(["--output".to_owned(), "json".to_owned()]);
1274    let template =
1275        CommandSpec::new("aws", template_args).purpose("check the configured AWS launch template");
1276    let template_remediation = format!(
1277        "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."
1278    );
1279    match executor.execute(&template) {
1280        Err(error) => DoctorCheck::fixable(
1281            check_id,
1282            title,
1283            format!("Could not query launch template {launch_template}: {error}"),
1284            template_remediation,
1285        ),
1286        Ok(output) if output.status != 0 => DoctorCheck::fixable(
1287            check_id,
1288            title,
1289            format!(
1290                "Launch template {launch_template} was not found in {region}: {}",
1291                String::from_utf8_lossy(&output.stderr).trim()
1292            ),
1293            template_remediation,
1294        ),
1295        Ok(_) => DoctorCheck::ready(
1296            check_id,
1297            title,
1298            format!(
1299                "The AWS CLI is installed, profile {profile_label} has valid credentials, and launch template {launch_template} exists in {region}."
1300            ),
1301        ),
1302    }
1303}
1304
1305fn worker_binary_checks(config: Option<&Config>) -> Vec<DoctorCheck> {
1306    let Some(config) = config else {
1307        return vec![DoctorCheck::fixable(
1308            "worker.containers",
1309            "Container worker binary",
1310            "Worker availability cannot be checked until config.toml is valid.",
1311            "Fix config.toml, then rerun `mj doctor --json`.",
1312        )];
1313    };
1314    let containers = config
1315        .targets
1316        .iter()
1317        .filter_map(|(id, target)| match target {
1318            TargetTemplate::LocalPodman { container }
1319            | TargetTemplate::LocalDocker { container }
1320            | TargetTemplate::AppleContainer { container } => Some((id, container, None)),
1321            TargetTemplate::SshPodman { container, .. } => {
1322                Some((id, container, Some("ssh-podman")))
1323            }
1324            TargetTemplate::SshDocker { container, .. } => {
1325                Some((id, container, Some("ssh-docker")))
1326            }
1327            _ => None,
1328        })
1329        .collect::<Vec<_>>();
1330    if containers.is_empty() {
1331        return vec![DoctorCheck::unsupported(
1332            "worker.containers",
1333            "Container worker binary",
1334            "No container target is configured.",
1335        )];
1336    }
1337    containers
1338        .into_iter()
1339        .map(|(id, container, remote_kind)| {
1340            if let Some(remote_kind) = remote_kind
1341                && container.platform.is_none()
1342            {
1343                // The remote CPU architecture is only observable once the host
1344                // is reachable, so an explicit `platform` is required here.
1345                return DoctorCheck::unsupported(
1346                    format!("worker.{id}"),
1347                    format!("Container worker binary for target {id}"),
1348                    format!(
1349                        "Set `platform` on this {remote_kind} target to check its worker binary; the remote architecture is unknown until provisioning."
1350                    ),
1351                );
1352            }
1353            worker_binary_check(id, container)
1354        })
1355        .collect()
1356}
1357
1358fn worker_binary_check(id: &str, container: &ContainerTemplate) -> DoctorCheck {
1359    let title = format!("Container worker binary for target {id}");
1360    let arch = match container_architecture(container.platform.as_deref()) {
1361        Ok(arch) => arch,
1362        Err(reason) => {
1363            return DoctorCheck::unsupported(format!("worker.{id}"), title, reason);
1364        }
1365    };
1366    let triple = format!("{arch}-unknown-linux-musl");
1367    match worker_binary_prerequisite_for_arch(arch) {
1368        Ok(WorkerBinaryAvailability::Local { path, source }) => DoctorCheck::ready(
1369            format!("worker.{id}"),
1370            title,
1371            format!(
1372                "{triple} worker is available from {source}: {}",
1373                path.display()
1374            ),
1375        ),
1376        Ok(WorkerBinaryAvailability::Remote { url, .. }) => DoctorCheck::ready(
1377            format!("worker.{id}"),
1378            title,
1379            format!("{triple} worker will be verified and downloaded from {url} when needed."),
1380        ),
1381        Err(error) => DoctorCheck::fixable(
1382            format!("worker.{id}"),
1383            title,
1384            format!("No usable {triple} worker source: {error:#}"),
1385            format!(
1386                "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."
1387            ),
1388        ),
1389    }
1390}
1391
1392fn container_architecture(platform: Option<&str>) -> std::result::Result<&'static str, String> {
1393    let candidate = platform.unwrap_or(std::env::consts::ARCH);
1394    let candidate = candidate
1395        .split('/')
1396        .rev()
1397        .find(|part| matches!(*part, "x86_64" | "amd64" | "aarch64" | "arm64"))
1398        .unwrap_or(candidate);
1399    match candidate {
1400        "x86_64" | "amd64" => Ok("x86_64"),
1401        "aarch64" | "arm64" => Ok("aarch64"),
1402        other => Err(format!(
1403            "Container architecture {other:?} is unsupported; Mjolnir supports x86_64 and aarch64 Linux workers."
1404        )),
1405    }
1406}
1407
1408fn apple_container_image(config: Option<&Config>) -> String {
1409    config
1410        .and_then(|config| {
1411            config.targets.values().find_map(|target| match target {
1412                TargetTemplate::AppleContainer { container } => Some(container.image.clone()),
1413                _ => None,
1414            })
1415        })
1416        .unwrap_or_else(|| DEFAULT_CONTAINER_IMAGE.into())
1417}
1418
1419pub fn apple_container_check(
1420    platform: &ApplePlatform,
1421    executor: &impl CommandExecutor,
1422    smoke: bool,
1423    image: String,
1424) -> DoctorCheck {
1425    match platform {
1426        ApplePlatform::Linux => {
1427            return DoctorCheck::unsupported(
1428                "runtime.apple-container",
1429                "Apple container runtime",
1430                "macOS only",
1431            );
1432        }
1433        ApplePlatform::Other(current) => {
1434            return DoctorCheck::unsupported(
1435                "runtime.apple-container",
1436                "Apple container runtime",
1437                format!("macOS only (current platform: {current})"),
1438            );
1439        }
1440        ApplePlatform::Macos {
1441            architecture,
1442            major_version,
1443        } if architecture != "aarch64" && architecture != "arm64" => {
1444            return DoctorCheck::unsupported(
1445                "runtime.apple-container",
1446                "Apple container runtime",
1447                "Apple container requires Apple silicon; Intel Macs are unsupported.",
1448            );
1449        }
1450        ApplePlatform::Macos { major_version, .. } if *major_version < 26 => {
1451            return DoctorCheck::unsupported(
1452                "runtime.apple-container",
1453                "Apple container runtime",
1454                format!("Apple container requires macOS 26 or newer (found {major_version})."),
1455            );
1456        }
1457        ApplePlatform::Macos { .. } => {}
1458    }
1459
1460    let daemon = apple_container_daemon_check(executor);
1461    if daemon.status != CheckStatus::Ready {
1462        return daemon;
1463    }
1464
1465    if !smoke {
1466        return DoctorCheck::fixable(
1467            "runtime.apple-container",
1468            "Apple container runtime",
1469            "The daemon is running, but the required disposable smoke test was not requested.",
1470            "Run `mj doctor --json --smoke`.",
1471        );
1472    }
1473
1474    let target = RuntimeTargetTemplate::AppleContainer(RuntimeContainerTemplate {
1475        image,
1476        pull_policy: Default::default(),
1477        extra_run_args: vec![],
1478        workspace_storage: Default::default(),
1479    });
1480    match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1481        Ok(()) => DoctorCheck::ready(
1482            "runtime.apple-container",
1483            "Apple container runtime",
1484            "Installed, daemon running, and disposable run/exec/remove smoke test passed.",
1485        ),
1486        Err(error) => DoctorCheck::fixable(
1487            "runtime.apple-container",
1488            "Apple container runtime",
1489            format!("Disposable run/exec/remove smoke test failed: {error:#}"),
1490            "Fix the configured image or container runtime, then run `mj doctor --json --smoke` again.",
1491        ),
1492    }
1493}
1494
1495/// Probe that the Apple `container` command is installed and its daemon is
1496/// running, phrased as a doctor check.
1497///
1498/// Split out of [`apple_container_check`] so `mj setup` can reuse the same
1499/// probes and remediation text without also demanding the opt-in smoke test.
1500/// The caller is responsible for platform gating.
1501pub fn apple_container_daemon_check(executor: &impl CommandExecutor) -> DoctorCheck {
1502    let installed =
1503        CommandSpec::new("container", ["--version"]).purpose("check Apple container installation");
1504    match executor.execute(&installed) {
1505        Err(error) => {
1506            return DoctorCheck::fixable(
1507                "runtime.apple-container",
1508                "Apple container runtime",
1509                format!("The `container` command is not available: {error}"),
1510                format!("Install the official signed package: {APPLE_CONTAINER_INSTALL_URL}"),
1511            );
1512        }
1513        Ok(output) if output.status != 0 => {
1514            return DoctorCheck::fixable(
1515                "runtime.apple-container",
1516                "Apple container runtime",
1517                format!(
1518                    "The installed `container --version` command failed: {}",
1519                    String::from_utf8_lossy(&output.stderr).trim()
1520                ),
1521                format!("Reinstall the official signed package: {APPLE_CONTAINER_INSTALL_URL}"),
1522            );
1523        }
1524        Ok(_) => {}
1525    }
1526
1527    let status =
1528        CommandSpec::new("container", ["system", "status"]).purpose("check Apple container daemon");
1529    match executor.execute(&status) {
1530        Ok(output) if output.status == 0 => DoctorCheck::ready(
1531            "runtime.apple-container",
1532            "Apple container runtime",
1533            "Installed, and the Apple container daemon is running.",
1534        ),
1535        Ok(output) => DoctorCheck::fixable(
1536            "runtime.apple-container",
1537            "Apple container runtime",
1538            format!(
1539                "The Apple container daemon is stopped: {}",
1540                String::from_utf8_lossy(&output.stderr).trim()
1541            ),
1542            "Run `container system start`.",
1543        ),
1544        Err(error) => DoctorCheck::fixable(
1545            "runtime.apple-container",
1546            "Apple container runtime",
1547            format!("Could not query the Apple container daemon: {error}"),
1548            "Run `container system start`.",
1549        ),
1550    }
1551}
1552
1553pub fn current_apple_platform(executor: &impl CommandExecutor) -> ApplePlatform {
1554    if cfg!(target_os = "linux") {
1555        return ApplePlatform::Linux;
1556    }
1557    if !cfg!(target_os = "macos") {
1558        return ApplePlatform::Other(std::env::consts::OS.into());
1559    }
1560    let major_version = executor
1561        .execute(&CommandSpec::new("sw_vers", ["-productVersion"]).purpose("detect macOS version"))
1562        .ok()
1563        .filter(|output| output.status == 0)
1564        .and_then(|output| {
1565            String::from_utf8(output.stdout)
1566                .ok()
1567                .and_then(|value| value.trim().split('.').next()?.parse().ok())
1568        })
1569        .unwrap_or(0);
1570    ApplePlatform::Macos {
1571        architecture: std::env::consts::ARCH.into(),
1572        major_version,
1573    }
1574}
1575
1576#[cfg(test)]
1577mod tests {
1578    use std::cell::RefCell;
1579    use std::path::PathBuf;
1580
1581    use anyhow::anyhow;
1582
1583    use super::*;
1584    use crate::targets::CommandOutput;
1585
1586    struct FakeExecutor {
1587        commands: RefCell<Vec<CommandSpec>>,
1588        responses: RefCell<Vec<Result<CommandOutput>>>,
1589    }
1590
1591    impl FakeExecutor {
1592        fn new(responses: impl IntoIterator<Item = Result<CommandOutput>>) -> Self {
1593            Self {
1594                commands: RefCell::new(vec![]),
1595                responses: RefCell::new(responses.into_iter().collect()),
1596            }
1597        }
1598    }
1599
1600    impl CommandExecutor for FakeExecutor {
1601        fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1602            self.commands.borrow_mut().push(command.clone());
1603            self.responses.borrow_mut().remove(0)
1604        }
1605    }
1606
1607    fn output(stdout: impl AsRef<[u8]>) -> CommandOutput {
1608        CommandOutput {
1609            status: 0,
1610            stdout: stdout.as_ref().to_vec(),
1611            stderr: vec![],
1612        }
1613    }
1614
1615    fn failed(stderr: impl AsRef<[u8]>) -> CommandOutput {
1616        CommandOutput {
1617            status: 1,
1618            stdout: vec![],
1619            stderr: stderr.as_ref().to_vec(),
1620        }
1621    }
1622
1623    /// Prefix canned responses with a successful SSH connectivity probe, which
1624    /// every SSH-backed check runs first.
1625    fn reachable_then(
1626        responses: impl IntoIterator<Item = Result<CommandOutput>>,
1627    ) -> Vec<Result<CommandOutput>> {
1628        let mut all = vec![Ok(output(b""))];
1629        all.extend(responses);
1630        all
1631    }
1632
1633    fn passing_ssh_podman_probes() -> Vec<Result<CommandOutput>> {
1634        let mut responses = passing_podman_probes();
1635        responses.push(Ok(output(b"yes\n")));
1636        responses
1637    }
1638
1639    fn passing_podman_probes() -> Vec<Result<CommandOutput>> {
1640        vec![
1641            Ok(output(b"podman version 5.4.2\n")),
1642            Ok(output(b"true\n")),
1643            Ok(output(
1644                b"         0       1000          1\n         1     100000      65536\n",
1645            )),
1646        ]
1647    }
1648
1649    fn container(image: &str) -> ContainerTemplate {
1650        ContainerTemplate {
1651            image: image.to_owned(),
1652            pull_policy: Default::default(),
1653            platform: None,
1654            cpus: None,
1655            memory: None,
1656            environment: std::collections::BTreeMap::new(),
1657            workspace_storage: Default::default(),
1658        }
1659    }
1660
1661    fn ssh_connection() -> mj_core::config::SshConnection {
1662        mj_core::config::SshConnection {
1663            host: "example.test".into(),
1664            user: Some("dev".into()),
1665            identity_file: None,
1666            extra_args: vec![],
1667        }
1668    }
1669
1670    #[test]
1671    fn doctor_reports_a_config_owned_by_a_newer_hel_as_read_only() {
1672        let directory = tempfile::tempdir().unwrap();
1673        let path = directory.path().join("config.toml");
1674        std::fs::write(
1675            &path,
1676            format!(
1677                "version = {}\n\n[targets.localhost]\nkind = \"local-bare\"\n",
1678                mj_core::config::CONFIG_VERSION + 1
1679            ),
1680        )
1681        .unwrap();
1682
1683        let (config, checks) = configuration_checks(&path);
1684
1685        assert!(config.is_some());
1686        let check = checks.iter().find(|check| check.id == "config").unwrap();
1687        assert_eq!(check.status, CheckStatus::Warning);
1688        assert!(check.detail.contains("read-only"), "{}", check.detail);
1689    }
1690
1691    fn config_with(targets: impl IntoIterator<Item = (&'static str, TargetTemplate)>) -> Config {
1692        Config {
1693            targets: targets
1694                .into_iter()
1695                .map(|(id, target)| (id.to_owned(), target))
1696                .collect(),
1697            ..Config::default()
1698        }
1699    }
1700
1701    fn runtime_ssh() -> RuntimeSshTarget {
1702        backend_ssh(&ssh_connection())
1703    }
1704
1705    #[test]
1706    fn podman_check_is_unsupported_without_a_valid_config() {
1707        let executor = FakeExecutor::new([]);
1708
1709        let check = podman_check(None, &executor);
1710
1711        assert_eq!(check.status, CheckStatus::Unsupported);
1712        assert_eq!(
1713            check.detail,
1714            "Podman prerequisites cannot be evaluated until config.toml is valid."
1715        );
1716        assert!(executor.commands.borrow().is_empty());
1717    }
1718
1719    #[test]
1720    fn podman_check_is_unsupported_without_a_local_podman_target() {
1721        let executor = FakeExecutor::new([]);
1722        let config = config_with([(
1723            "apple",
1724            TargetTemplate::AppleContainer {
1725                container: container("ubuntu:24.04"),
1726            },
1727        )]);
1728
1729        let check = podman_check(Some(&config), &executor);
1730
1731        assert_eq!(check.status, CheckStatus::Unsupported);
1732        assert_eq!(check.detail, "No local-podman target is configured.");
1733        assert!(executor.commands.borrow().is_empty());
1734    }
1735
1736    #[test]
1737    fn podman_check_probes_the_host_when_a_local_podman_target_exists() {
1738        let executor = FakeExecutor::new(passing_podman_probes());
1739        let config = config_with([(
1740            "podman",
1741            TargetTemplate::LocalPodman {
1742                container: container("ubuntu:24.04"),
1743            },
1744        )]);
1745
1746        let check = podman_check(Some(&config), &executor);
1747
1748        assert_eq!(check.status, CheckStatus::Ready);
1749        assert!(check.detail.contains("Podman 5.4.2"));
1750        assert_eq!(executor.commands.borrow().len(), 3);
1751    }
1752
1753    #[test]
1754    fn podman_check_is_fixable_with_an_upgrade_remediation_for_an_old_runtime() {
1755        let executor = FakeExecutor::new(reachable_then([Ok(output(b"podman version 3.4.7\n"))]));
1756        let config = config_with([(
1757            "podman",
1758            TargetTemplate::LocalPodman {
1759                container: container("ubuntu:24.04"),
1760            },
1761        )]);
1762
1763        let check = podman_check(Some(&config), &executor);
1764
1765        assert_eq!(check.status, CheckStatus::Fixable);
1766        assert!(
1767            check
1768                .remediation
1769                .as_deref()
1770                .unwrap()
1771                .contains("Upgrade Podman")
1772        );
1773    }
1774
1775    #[test]
1776    fn podman_image_check_is_ready_when_the_image_is_present() {
1777        let executor = FakeExecutor::new([Ok(output(b""))]);
1778
1779        let check =
1780            podman_image_check("podman", "localhost/hel/agent-dev:latest", &executor, false);
1781
1782        assert_eq!(check.id, "runtime.podman.image.podman");
1783        assert_eq!(check.title, "Podman image for target podman");
1784        assert_eq!(check.status, CheckStatus::Ready);
1785        assert_eq!(
1786            executor.commands.borrow()[0].args,
1787            vec!["image", "exists", "localhost/hel/agent-dev:latest"]
1788        );
1789    }
1790
1791    #[test]
1792    fn podman_image_check_is_fixable_with_a_pull_remediation_when_the_image_is_missing() {
1793        let executor = FakeExecutor::new([Ok(failed(b""))]);
1794
1795        let check = podman_image_check("podman", "ghcr.io/example/dev:1", &executor, false);
1796
1797        assert_eq!(check.status, CheckStatus::Fixable);
1798        assert!(
1799            check
1800                .detail
1801                .contains("is not present in local Podman storage")
1802        );
1803        assert_eq!(
1804            check.remediation.as_deref(),
1805            Some(
1806                "Pull it with `podman pull ghcr.io/example/dev:1`, build it from containers/Containerfile.agent-dev, or run `mj doctor --json --smoke` to verify the full pull-and-run path."
1807            )
1808        );
1809    }
1810
1811    #[test]
1812    fn podman_image_check_smoke_runs_a_disposable_container() {
1813        let executor = FakeExecutor::new([
1814            Ok(output(b"created\n")),
1815            Ok(output(b"ok\n")),
1816            Ok(output(b"removed\n")),
1817        ]);
1818
1819        let check = podman_image_check("podman", "ubuntu:24.04", &executor, true);
1820
1821        assert_eq!(check.status, CheckStatus::Ready);
1822        let commands = executor.commands.borrow();
1823        assert_eq!(commands.len(), 3);
1824        assert!(commands.iter().all(|command| command.program == "podman"));
1825        assert_eq!(commands[0].args[0], "run");
1826        assert_eq!(commands[1].args[0], "exec");
1827        assert_eq!(commands[2].args[0], "rm");
1828    }
1829
1830    #[test]
1831    fn image_checks_are_skipped_when_the_host_podman_preflight_fails() {
1832        let executor = FakeExecutor::new(reachable_then([Ok(output(b"podman version 3.4.7\n"))]));
1833        let config = config_with([(
1834            "podman",
1835            TargetTemplate::LocalPodman {
1836                container: container("ubuntu:24.04"),
1837            },
1838        )]);
1839
1840        let checks = podman_checks(Some(&config), &executor, false);
1841
1842        assert_eq!(checks.len(), 1);
1843        assert_eq!(checks[0].id, "runtime.podman");
1844    }
1845
1846    #[test]
1847    fn image_checks_follow_a_passing_preflight_for_each_local_podman_target() {
1848        let mut responses = passing_podman_probes();
1849        responses.push(Ok(output(b"")));
1850        responses.push(Ok(failed(b"")));
1851        let executor = FakeExecutor::new(responses);
1852        let config = config_with([
1853            (
1854                "alpha",
1855                TargetTemplate::LocalPodman {
1856                    container: container("ubuntu:24.04"),
1857                },
1858            ),
1859            (
1860                "beta",
1861                TargetTemplate::LocalPodman {
1862                    container: container("ghcr.io/example/dev:1"),
1863                },
1864            ),
1865        ]);
1866
1867        let checks = podman_checks(Some(&config), &executor, false);
1868
1869        assert_eq!(
1870            checks
1871                .iter()
1872                .map(|check| check.id.as_str())
1873                .collect::<Vec<_>>(),
1874            vec![
1875                "runtime.podman",
1876                "runtime.podman.image.alpha",
1877                "runtime.podman.image.beta"
1878            ]
1879        );
1880        assert_eq!(checks[1].status, CheckStatus::Ready);
1881        assert_eq!(checks[2].status, CheckStatus::Fixable);
1882    }
1883
1884    #[test]
1885    fn docker_checks_probe_the_daemon_then_the_configured_image() {
1886        let executor = FakeExecutor::new([
1887            Ok(output(b"29.0.1 linux\n")),
1888            Ok(output(b"image metadata\n")),
1889        ]);
1890        let config = config_with([(
1891            "docker",
1892            TargetTemplate::LocalDocker {
1893                container: container("ghcr.io/example/dev:1"),
1894            },
1895        )]);
1896
1897        let checks = docker_checks(Some(&config), &executor, false);
1898
1899        assert_eq!(
1900            checks
1901                .iter()
1902                .map(|check| check.id.as_str())
1903                .collect::<Vec<_>>(),
1904            vec!["runtime.docker", "runtime.docker.image.docker"]
1905        );
1906        assert!(
1907            checks
1908                .iter()
1909                .all(|check| check.status == CheckStatus::Ready)
1910        );
1911        let commands = executor.commands.borrow();
1912        assert_eq!(commands[0].program, "docker");
1913        assert_eq!(
1914            commands[0].args,
1915            ["version", "--format", "{{.Server.Version}} {{.Server.Os}}"]
1916        );
1917        assert_eq!(
1918            commands[1].args,
1919            ["image", "inspect", "ghcr.io/example/dev:1"]
1920        );
1921    }
1922
1923    #[test]
1924    fn docker_image_smoke_uses_managed_overlay_run_exec_and_cleanup() {
1925        let executor = FakeExecutor::new([
1926            Ok(output(b"created\n")),
1927            Ok(output(b"ok\n")),
1928            Ok(output(b"removed\n")),
1929        ]);
1930
1931        let check = docker_image_check("docker", "ubuntu:24.04", &executor, true);
1932
1933        assert_eq!(check.status, CheckStatus::Ready);
1934        let commands = executor.commands.borrow();
1935        assert_eq!(commands.len(), 3);
1936        assert_eq!(commands[0].program, "sh");
1937        assert!(commands[0].args[1].contains("docker volume create"));
1938        assert!(commands[0].args.contains(&"--pull=missing".to_owned()));
1939        assert_eq!(commands[1].program, "docker");
1940        assert_eq!(commands[1].args[0], "exec");
1941        assert_eq!(commands[2].program, "sh");
1942        assert!(commands[2].args[1].contains("docker rm --force"));
1943        assert!(commands[2].args[1].contains("docker volume rm --force"));
1944    }
1945
1946    #[test]
1947    fn ssh_podman_check_is_ready_after_ssh_wrapped_probes_without_smoke() {
1948        let executor = FakeExecutor::new(reachable_then(passing_ssh_podman_probes()));
1949
1950        let check = ssh_podman_check("remote", &runtime_ssh(), "ubuntu:24.04", &executor, false);
1951
1952        assert_eq!(check.id, "runtime.ssh-podman.remote");
1953        assert_eq!(check.title, "Remote Podman for target remote");
1954        assert_eq!(check.status, CheckStatus::Ready);
1955        assert!(check.detail.contains("Remote rootless Podman 5.4.2"));
1956        assert!(check.detail.contains("dev@example.test"));
1957        let commands = executor.commands.borrow();
1958        assert_eq!(commands.len(), 5);
1959        assert_eq!(commands[0].args.last().unwrap(), "'true'");
1960        for command in commands.iter().skip(1) {
1961            assert_eq!(command.program, "ssh");
1962            assert!(command.args.contains(&"dev@example.test".to_owned()));
1963        }
1964        assert!(
1965            commands[4]
1966                .args
1967                .last()
1968                .unwrap()
1969                .contains("'loginctl show-user")
1970        );
1971    }
1972
1973    #[test]
1974    fn ssh_podman_check_warns_when_remote_user_lingering_is_disabled() {
1975        let mut responses = passing_podman_probes();
1976        responses.push(Ok(output(b"no\n")));
1977        let executor = FakeExecutor::new(reachable_then(responses));
1978
1979        let check = ssh_podman_check("remote", &runtime_ssh(), "ubuntu:24.04", &executor, false);
1980
1981        assert_eq!(check.status, CheckStatus::Warning);
1982        assert!(all_ready(std::slice::from_ref(&check)));
1983        assert!(check.detail.contains("Podman 5.4.2 is available"));
1984        assert!(check.detail.contains("last SSH connection closes"));
1985        assert!(
1986            check
1987                .remediation
1988                .as_deref()
1989                .unwrap()
1990                .contains("sudo loginctl enable-linger")
1991        );
1992    }
1993
1994    #[test]
1995    fn ssh_podman_check_explains_when_durability_cannot_be_verified() {
1996        let mut responses = passing_podman_probes();
1997        responses.push(Ok(CommandOutput {
1998            status: 127,
1999            stdout: vec![],
2000            stderr: b"sh: loginctl: not found\n".to_vec(),
2001        }));
2002        let executor = FakeExecutor::new(reachable_then(responses));
2003
2004        let check = ssh_podman_check("remote", &runtime_ssh(), "ubuntu:24.04", &executor, false);
2005
2006        assert_eq!(check.status, CheckStatus::Warning);
2007        assert!(all_ready(std::slice::from_ref(&check)));
2008        assert!(check.detail.contains("durability check is unavailable"));
2009        assert!(check.detail.contains("may not use systemd"));
2010        assert!(check.detail.contains("cannot verify"));
2011        let remediation = check.remediation.as_deref().unwrap();
2012        assert!(remediation.contains("service manager"));
2013        assert!(!remediation.contains("sudo loginctl enable-linger"));
2014    }
2015
2016    #[test]
2017    fn ssh_podman_check_failure_scopes_the_remediation_to_the_remote_host() {
2018        let executor = FakeExecutor::new(reachable_then([Ok(output(b"podman version 3.4.7\n"))]));
2019
2020        let check = ssh_podman_check("remote", &runtime_ssh(), "ubuntu:24.04", &executor, false);
2021
2022        assert_eq!(check.status, CheckStatus::Fixable);
2023        assert!(check.detail.contains("dev@example.test"));
2024        assert!(
2025            check
2026                .remediation
2027                .as_deref()
2028                .unwrap()
2029                .starts_with("On dev@example.test: Upgrade Podman")
2030        );
2031    }
2032
2033    #[test]
2034    fn ssh_podman_check_reports_the_shared_ssh_remediation_before_probing_podman() {
2035        let executor = FakeExecutor::new([Ok(failed(
2036            b"dev@example.test: Permission denied (publickey).",
2037        ))]);
2038
2039        let check = ssh_podman_check("remote", &runtime_ssh(), "ubuntu:24.04", &executor, false);
2040
2041        assert_eq!(check.status, CheckStatus::Fixable);
2042        assert_eq!(
2043            check.remediation.as_deref(),
2044            Some("Install your public key on the host with `ssh-copy-id dev@example.test`.")
2045        );
2046        // The remote Podman probes never ran: the host is not reachable.
2047        assert_eq!(executor.commands.borrow().len(), 1);
2048    }
2049
2050    #[test]
2051    fn ssh_podman_check_smoke_runs_an_ssh_wrapped_disposable_container() {
2052        let mut responses = passing_ssh_podman_probes();
2053        responses.extend([
2054            Ok(output(b"created\n")),
2055            Ok(output(b"ok\n")),
2056            Ok(output(b"removed\n")),
2057        ]);
2058        let executor = FakeExecutor::new(reachable_then(responses));
2059
2060        let check = ssh_podman_check("remote", &runtime_ssh(), "ubuntu:24.04", &executor, true);
2061
2062        assert_eq!(check.status, CheckStatus::Ready);
2063        let commands = executor.commands.borrow();
2064        assert_eq!(commands.len(), 8);
2065        for command in commands.iter().skip(5) {
2066            assert_eq!(command.program, "ssh");
2067            assert!(command.args.contains(&"dev@example.test".to_owned()));
2068        }
2069        assert!(commands[5].args.last().unwrap().contains("'run' '--init'"));
2070        assert!(commands[6].args.last().unwrap().ends_with("'true'"));
2071        assert!(commands[7].args.last().unwrap().contains("'rm' '--force'"));
2072    }
2073
2074    #[test]
2075    fn ssh_docker_check_probes_connectivity_daemon_and_remote_image() {
2076        let executor = FakeExecutor::new([
2077            Ok(output(b"")),
2078            Ok(output(b"29.0.1 linux\n")),
2079            Ok(output(b"image metadata\n")),
2080        ]);
2081        let check = ssh_docker_check(
2082            "remote",
2083            &runtime_ssh(),
2084            "ghcr.io/example/dev:1",
2085            &executor,
2086            false,
2087        );
2088
2089        assert_eq!(check.id, "runtime.ssh-docker.remote");
2090        assert_eq!(check.title, "Remote Docker for target remote");
2091        assert_eq!(check.status, CheckStatus::Ready);
2092        assert!(check.detail.contains("Remote Docker 29.0.1"));
2093        assert!(
2094            check
2095                .detail
2096                .contains("image ghcr.io/example/dev:1 is present")
2097        );
2098        let commands = executor.commands.borrow();
2099        assert_eq!(commands.len(), 3);
2100        assert!(commands.iter().all(|command| command.program == "ssh"));
2101        assert!(commands[0].args.last().unwrap().contains("'true'"));
2102        assert!(
2103            commands[1]
2104                .args
2105                .last()
2106                .unwrap()
2107                .contains("'docker' 'version'")
2108        );
2109        assert!(
2110            commands[2]
2111                .args
2112                .last()
2113                .unwrap()
2114                .contains("'docker' 'image' 'inspect'")
2115        );
2116    }
2117
2118    #[test]
2119    fn ssh_docker_check_smoke_runs_overlay_on_the_remote_host() {
2120        let executor = FakeExecutor::new([
2121            Ok(output(b"")),
2122            Ok(output(b"29.0.1 linux\n")),
2123            Ok(output(b"/tmp/mj-docker-overlay-smoke.fixture\n")),
2124            Ok(output(b"created\n")),
2125            Ok(output(b"ok\n")),
2126            Ok(output(b"verified\n")),
2127            Ok(output(b"removed\n")),
2128            Ok(output(b"removed\n")),
2129        ]);
2130        let check = ssh_docker_check("remote", &runtime_ssh(), "ubuntu:24.04", &executor, true);
2131
2132        assert_eq!(check.status, CheckStatus::Ready);
2133        assert!(check.detail.contains("remote OverlayFS attachment"));
2134        let commands = executor.commands.borrow();
2135        assert_eq!(commands.len(), 8);
2136        assert!(commands.iter().all(|command| command.program == "ssh"));
2137        assert!(commands[2].args.last().unwrap().contains("mktemp"));
2138        assert!(commands[3].args.last().unwrap().contains("'docker' 'run'"));
2139        assert!(commands[4].args.last().unwrap().contains("'docker' 'exec'"));
2140        assert!(commands[5].args.last().unwrap().contains("original.txt"));
2141        assert!(commands[6].args.last().unwrap().contains("docker rm"));
2142        assert!(commands[7].args.last().unwrap().contains("'rm' '-rf'"));
2143    }
2144
2145    #[test]
2146    fn ssh_podman_checks_are_skipped_without_a_valid_config() {
2147        let executor = FakeExecutor::new([]);
2148
2149        assert!(ssh_podman_checks(None, &executor, false).is_empty());
2150        assert!(executor.commands.borrow().is_empty());
2151    }
2152
2153    fn ssh_bare_config() -> Config {
2154        config_with([(
2155            "builder",
2156            TargetTemplate::SshBare {
2157                ssh: mj_core::config::SshConnection {
2158                    host: "example.test".into(),
2159                    user: Some("dev".into()),
2160                    identity_file: Some(PathBuf::from("/home/dev/.ssh/id_ed25519")),
2161                    extra_args: vec![],
2162                },
2163                permissions: mj_core::config::PermissionMode::Yolo,
2164                workspace_prefix: PathBuf::from(".local/share/hel/workspaces"),
2165            },
2166        )])
2167    }
2168
2169    #[test]
2170    fn ssh_bare_check_is_ready_when_the_batch_mode_probe_succeeds() {
2171        let executor = FakeExecutor::new([Ok(output(b""))]);
2172
2173        let checks = ssh_bare_checks(Some(&ssh_bare_config()), &executor);
2174
2175        assert_eq!(checks.len(), 1);
2176        assert_eq!(checks[0].id, "runtime.ssh-bare.builder");
2177        assert_eq!(checks[0].status, CheckStatus::Ready);
2178        let command = &executor.commands.borrow()[0];
2179        assert_eq!(command.program, "ssh");
2180        assert_eq!(
2181            command.args[..4],
2182            ["-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes"]
2183        );
2184        assert!(command.args.contains(&"dev@example.test".to_owned()));
2185        assert_eq!(command.args.last().unwrap(), "'true'");
2186    }
2187
2188    #[test]
2189    fn ssh_bare_check_permission_denied_recommends_ssh_copy_id_with_the_identity() {
2190        let executor = FakeExecutor::new([Ok(failed(
2191            b"dev@example.test: Permission denied (publickey).",
2192        ))]);
2193
2194        let checks = ssh_bare_checks(Some(&ssh_bare_config()), &executor);
2195
2196        assert_eq!(checks[0].status, CheckStatus::Fixable);
2197        assert_eq!(
2198            checks[0].remediation.as_deref(),
2199            Some(
2200                "Install your public key on the host with `ssh-copy-id -i /home/dev/.ssh/id_ed25519.pub dev@example.test`."
2201            )
2202        );
2203    }
2204
2205    #[test]
2206    fn ssh_bare_check_host_key_failure_recommends_keyscan_with_a_fingerprint_caution() {
2207        let executor = FakeExecutor::new([Ok(failed(
2208            b"Host key verification failed.\nNo ECDSA host key is known for example.test",
2209        ))]);
2210
2211        let checks = ssh_bare_checks(Some(&ssh_bare_config()), &executor);
2212
2213        assert_eq!(checks[0].status, CheckStatus::Fixable);
2214        let remediation = checks[0].remediation.as_deref().unwrap();
2215        assert!(
2216            remediation.contains("ssh-keyscan -H example.test >> ~/.ssh/known_hosts"),
2217            "{remediation}"
2218        );
2219        assert!(
2220            remediation.contains("Verify the fingerprint"),
2221            "{remediation}"
2222        );
2223    }
2224
2225    #[test]
2226    fn ssh_bare_check_without_an_ssh_client_recommends_installing_openssh() {
2227        let executor = FakeExecutor::new([Err(anyhow!("No such file or directory (os error 2)"))]);
2228
2229        let checks = ssh_bare_checks(Some(&ssh_bare_config()), &executor);
2230
2231        assert_eq!(checks[0].status, CheckStatus::Fixable);
2232        assert_eq!(
2233            checks[0].remediation.as_deref(),
2234            Some(SSH_MISSING_REMEDIATION)
2235        );
2236    }
2237
2238    #[test]
2239    fn ssh_bare_check_falls_back_to_quoting_an_unrecognized_ssh_failure() {
2240        let executor = FakeExecutor::new([Ok(failed(
2241            b"ssh: connect to host example.test port 22: Connection timed out",
2242        ))]);
2243
2244        let checks = ssh_bare_checks(Some(&ssh_bare_config()), &executor);
2245
2246        let remediation = checks[0].remediation.as_deref().unwrap();
2247        assert!(
2248            remediation.contains("Connection timed out"),
2249            "{remediation}"
2250        );
2251        assert!(
2252            remediation.contains("Run `ssh dev@example.test true` by hand"),
2253            "{remediation}"
2254        );
2255    }
2256
2257    #[test]
2258    fn ssh_bare_checks_are_skipped_without_a_valid_config() {
2259        let executor = FakeExecutor::new([]);
2260
2261        assert!(ssh_bare_checks(None, &executor).is_empty());
2262        assert!(executor.commands.borrow().is_empty());
2263    }
2264
2265    #[test]
2266    fn worker_check_for_an_ssh_podman_target_without_platform_is_unsupported() {
2267        let config = config_with([(
2268            "remote",
2269            TargetTemplate::SshPodman {
2270                ssh: ssh_connection(),
2271                container: container("ubuntu:24.04"),
2272            },
2273        )]);
2274
2275        let checks = worker_binary_checks(Some(&config));
2276
2277        assert_eq!(checks.len(), 1);
2278        assert_eq!(checks[0].id, "worker.remote");
2279        assert_eq!(checks[0].status, CheckStatus::Unsupported);
2280        assert_eq!(
2281            checks[0].detail,
2282            "Set `platform` on this ssh-podman target to check its worker binary; the remote architecture is unknown until provisioning."
2283        );
2284    }
2285
2286    #[test]
2287    fn worker_check_for_an_ssh_podman_target_with_platform_uses_the_normal_check() {
2288        let mut remote = container("ubuntu:24.04");
2289        remote.platform = Some("linux/amd64".into());
2290        let config = config_with([(
2291            "remote",
2292            TargetTemplate::SshPodman {
2293                ssh: ssh_connection(),
2294                container: remote,
2295            },
2296        )]);
2297
2298        let checks = worker_binary_checks(Some(&config));
2299
2300        assert_eq!(checks.len(), 1);
2301        assert_eq!(checks[0].id, "worker.remote");
2302        assert_ne!(checks[0].status, CheckStatus::Unsupported);
2303        assert!(checks[0].detail.contains("x86_64-unknown-linux-musl"));
2304    }
2305
2306    #[test]
2307    fn worker_check_for_an_ssh_docker_target_hints_at_remote_architecture() {
2308        let config = config_with([(
2309            "remote",
2310            TargetTemplate::SshDocker {
2311                ssh: ssh_connection(),
2312                container: container("ubuntu:24.04"),
2313            },
2314        )]);
2315
2316        let checks = worker_binary_checks(Some(&config));
2317
2318        assert_eq!(checks.len(), 1);
2319        assert_eq!(checks[0].status, CheckStatus::Unsupported);
2320        assert_eq!(
2321            checks[0].detail,
2322            "Set `platform` on this ssh-docker target to check its worker binary; the remote architecture is unknown until provisioning."
2323        );
2324    }
2325
2326    #[test]
2327    fn an_unauthenticated_profile_is_fixed_by_hel_login_for_that_profile() {
2328        let directory = tempfile::tempdir().unwrap();
2329        let home = directory.path().join("claude-home");
2330        std::fs::create_dir_all(&home).unwrap();
2331        let profile = HarnessProfile {
2332            enabled: true,
2333            kind: HarnessKind::Claude,
2334            home,
2335            environment: std::collections::BTreeMap::new(),
2336            context_window_bytes: None,
2337        };
2338        let config = Config {
2339            profiles: [("work".to_owned(), profile.clone())].into_iter().collect(),
2340            ..Config::default()
2341        };
2342
2343        let executor = FakeExecutor::new([Ok(output(br#"{"loggedIn":false}"#))]);
2344        let checks = harness_checks(Some(&config), &executor);
2345
2346        assert_eq!(checks.len(), 1);
2347        assert_eq!(checks[0].status, CheckStatus::Fixable);
2348        let remediation = checks[0].remediation.as_deref().unwrap();
2349        assert!(
2350            remediation.contains("mj login --profile work"),
2351            "{remediation}"
2352        );
2353        // The underlying command is quoted from the one place that verified it,
2354        // so doctor cannot recommend something `mj login` does not run.
2355        let (program, arguments) = login_command(&profile);
2356        assert!(
2357            remediation.contains(&format!("`{program} {}`", arguments.join(" "))),
2358            "{remediation}"
2359        );
2360    }
2361
2362    #[test]
2363    fn doctor_reports_disabled_profiles_without_probing_them() {
2364        let profile = HarnessProfile {
2365            enabled: false,
2366            kind: HarnessKind::Claude,
2367            home: PathBuf::from("/missing/disabled-profile"),
2368            environment: std::collections::BTreeMap::new(),
2369            context_window_bytes: None,
2370        };
2371        let config = Config {
2372            profiles: [("retired".to_owned(), profile)].into_iter().collect(),
2373            ..Config::default()
2374        };
2375        let executor = FakeExecutor::new([]);
2376
2377        let checks = harness_checks(Some(&config), &executor);
2378
2379        assert_eq!(checks.len(), 1);
2380        assert_eq!(checks[0].status, CheckStatus::Ready);
2381        assert!(checks[0].detail.contains("disabled"));
2382        assert!(executor.commands.borrow().is_empty());
2383    }
2384
2385    #[test]
2386    fn harness_discovery_reports_each_authentication_state() {
2387        let check = harness_discovery_check_from(
2388            &[
2389                DiscoveredHome {
2390                    kind: HarnessKind::Codex,
2391                    path: "/agents/codex".into(),
2392                    authenticated: true,
2393                },
2394                DiscoveredHome {
2395                    kind: HarnessKind::Kimi,
2396                    path: "/agents/kimi".into(),
2397                    authenticated: false,
2398                },
2399            ],
2400            true,
2401        );
2402
2403        assert_eq!(check.status, CheckStatus::Ready);
2404        assert!(
2405            check
2406                .detail
2407                .contains("Codex at /agents/codex (authenticated)")
2408        );
2409        assert!(
2410            check
2411                .detail
2412                .contains("Kimi Code at /agents/kimi (not authenticated)")
2413        );
2414    }
2415
2416    #[test]
2417    fn missing_harness_homes_are_fixable_without_a_configured_profile() {
2418        let check = harness_discovery_check_from(&[], false);
2419
2420        assert_eq!(check.status, CheckStatus::Fixable);
2421        assert_eq!(
2422            check.remediation.as_deref(),
2423            Some("Install and sign in to a supported harness, then run `mj setup`.")
2424        );
2425    }
2426
2427    #[test]
2428    fn apple_container_is_unsupported_on_intel_macs() {
2429        let executor = FakeExecutor::new([]);
2430
2431        let check = apple_container_check(
2432            &ApplePlatform::Macos {
2433                architecture: "x86_64".into(),
2434                major_version: 26,
2435            },
2436            &executor,
2437            false,
2438            DEFAULT_CONTAINER_IMAGE.into(),
2439        );
2440
2441        assert_eq!(check.status, CheckStatus::Unsupported);
2442        assert!(check.detail.contains("Intel Macs"));
2443        assert!(executor.commands.borrow().is_empty());
2444    }
2445
2446    #[test]
2447    fn apple_container_is_unsupported_before_macos_26() {
2448        let executor = FakeExecutor::new([]);
2449
2450        let check = apple_container_check(
2451            &ApplePlatform::Macos {
2452                architecture: "aarch64".into(),
2453                major_version: 25,
2454            },
2455            &executor,
2456            false,
2457            DEFAULT_CONTAINER_IMAGE.into(),
2458        );
2459
2460        assert_eq!(check.status, CheckStatus::Unsupported);
2461        assert!(check.detail.contains("macOS 26"));
2462    }
2463
2464    #[test]
2465    fn apple_container_not_installed_has_official_package_remediation() {
2466        let executor = FakeExecutor::new([Err(anyhow!("No such file or directory"))]);
2467
2468        let check = apple_container_check(
2469            &ApplePlatform::Macos {
2470                architecture: "aarch64".into(),
2471                major_version: 26,
2472            },
2473            &executor,
2474            false,
2475            DEFAULT_CONTAINER_IMAGE.into(),
2476        );
2477
2478        assert_eq!(check.status, CheckStatus::Fixable);
2479        assert_eq!(
2480            check.remediation.as_deref(),
2481            Some(
2482                format!("Install the official signed package: {APPLE_CONTAINER_INSTALL_URL}")
2483                    .as_str()
2484            )
2485        );
2486    }
2487
2488    #[test]
2489    fn apple_container_stopped_daemon_has_start_remediation() {
2490        let executor = FakeExecutor::new([
2491            Ok(output(b"container version 1\n")),
2492            Ok(CommandOutput {
2493                status: 1,
2494                stdout: vec![],
2495                stderr: b"daemon is not running".to_vec(),
2496            }),
2497        ]);
2498
2499        let check = apple_container_check(
2500            &ApplePlatform::Macos {
2501                architecture: "aarch64".into(),
2502                major_version: 26,
2503            },
2504            &executor,
2505            false,
2506            DEFAULT_CONTAINER_IMAGE.into(),
2507        );
2508
2509        assert_eq!(check.status, CheckStatus::Fixable);
2510        assert_eq!(
2511            check.remediation.as_deref(),
2512            Some("Run `container system start`.")
2513        );
2514    }
2515
2516    #[test]
2517    fn apple_container_is_ready_only_after_the_opt_in_smoke_test() {
2518        let executor = FakeExecutor::new([
2519            Ok(output(b"container version 1\n")),
2520            Ok(output(b"running\n")),
2521            Ok(output(b"created\n")),
2522            Ok(output(b"ok\n")),
2523            Ok(output(b"removed\n")),
2524        ]);
2525
2526        let check = apple_container_check(
2527            &ApplePlatform::Macos {
2528                architecture: "aarch64".into(),
2529                major_version: 26,
2530            },
2531            &executor,
2532            true,
2533            DEFAULT_CONTAINER_IMAGE.into(),
2534        );
2535
2536        assert_eq!(check.status, CheckStatus::Ready);
2537        assert_eq!(executor.commands.borrow().len(), 5);
2538        assert_eq!(executor.commands.borrow()[2].args[0], "run");
2539        assert_eq!(executor.commands.borrow()[3].args[0], "exec");
2540        assert_eq!(executor.commands.borrow()[4].args[0], "rm");
2541    }
2542
2543    #[test]
2544    fn linux_reports_apple_container_as_macos_only() {
2545        let executor = FakeExecutor::new([]);
2546        let check = apple_container_check(
2547            &ApplePlatform::Linux,
2548            &executor,
2549            false,
2550            DEFAULT_CONTAINER_IMAGE.into(),
2551        );
2552        assert_eq!(check.status, CheckStatus::Unsupported);
2553        assert_eq!(check.detail, "macOS only");
2554    }
2555
2556    fn aws_target(launch_template: &str) -> TargetTemplate {
2557        TargetTemplate::AwsEc2 {
2558            aws_profile: Some("hel".into()),
2559            region: "us-east-1".into(),
2560            launch_template: launch_template.to_owned(),
2561            launch_template_version: None,
2562            ssh_user: "ubuntu".into(),
2563            address_source: mj_core::config::AwsAddressSource::default(),
2564            identity_file: None,
2565            ssh_args: vec![],
2566        }
2567    }
2568
2569    #[test]
2570    fn aws_check_is_ready_after_the_cli_credential_and_launch_template_probes() {
2571        let executor = FakeExecutor::new([
2572            Ok(output(b"aws-cli/2.17.0\n")),
2573            Ok(output(b"{\"Account\":\"123456789012\"}\n")),
2574            Ok(output(b"{\"LaunchTemplates\":[{}]}\n")),
2575        ]);
2576        let config = config_with([("aws", aws_target("hel-runson"))]);
2577
2578        let checks = aws_checks(Some(&config), &executor);
2579
2580        assert_eq!(checks.len(), 1);
2581        assert_eq!(checks[0].id, "runtime.aws-ec2.aws");
2582        assert_eq!(checks[0].status, CheckStatus::Ready);
2583        let commands = executor.commands.borrow();
2584        assert!(commands.iter().all(|command| command.program == "aws"));
2585        // Profile and region are applied exactly as provisioning applies them.
2586        assert_eq!(
2587            commands[2].args,
2588            vec![
2589                "--profile",
2590                "hel",
2591                "--region",
2592                "us-east-1",
2593                "ec2",
2594                "describe-launch-templates",
2595                "--launch-template-names",
2596                "hel-runson",
2597                "--output",
2598                "json"
2599            ]
2600        );
2601    }
2602
2603    #[test]
2604    fn aws_check_is_fixable_with_an_install_remediation_without_the_cli() {
2605        let executor = FakeExecutor::new([Err(anyhow!("No such file or directory"))]);
2606
2607        let check = aws_target_check("aws", None, "us-east-1", "hel-runson", &executor);
2608
2609        assert_eq!(check.status, CheckStatus::Fixable);
2610        assert!(
2611            check
2612                .remediation
2613                .as_deref()
2614                .unwrap()
2615                .contains(AWS_CLI_INSTALL_URL)
2616        );
2617        assert_eq!(executor.commands.borrow().len(), 1);
2618    }
2619
2620    #[test]
2621    fn aws_check_is_fixable_with_a_sign_in_remediation_for_expired_credentials() {
2622        let executor = FakeExecutor::new([
2623            Ok(output(b"aws-cli/2.17.0\n")),
2624            Ok(failed(b"ExpiredToken: the security token has expired")),
2625        ]);
2626
2627        let check = aws_target_check("aws", Some("hel"), "us-east-1", "hel-runson", &executor);
2628
2629        assert_eq!(check.status, CheckStatus::Fixable);
2630        assert!(check.detail.contains("ExpiredToken"));
2631        assert_eq!(
2632            check.remediation.as_deref(),
2633            Some(
2634                "Configure credentials with `aws configure --profile hel`, or sign in with `aws sso login --profile hel`."
2635            )
2636        );
2637    }
2638
2639    #[test]
2640    fn aws_check_is_fixable_when_the_launch_template_is_missing() {
2641        let executor = FakeExecutor::new([
2642            Ok(output(b"aws-cli/2.17.0\n")),
2643            Ok(output(b"{\"Account\":\"123456789012\"}\n")),
2644            Ok(failed(b"InvalidLaunchTemplateName.NotFoundException")),
2645        ]);
2646
2647        let check = aws_target_check("aws", Some("hel"), "us-east-1", "lt-0123456789", &executor);
2648
2649        assert_eq!(check.status, CheckStatus::Fixable);
2650        assert!(check.detail.contains("was not found in us-east-1"));
2651        // An `lt-` value is a template id, not a name.
2652        assert_eq!(
2653            executor.commands.borrow()[2].args[6],
2654            "--launch-template-ids"
2655        );
2656    }
2657
2658    #[test]
2659    fn aws_checks_are_skipped_for_configs_without_an_aws_target() {
2660        let executor = FakeExecutor::new([]);
2661        let config = config_with([(
2662            "podman",
2663            TargetTemplate::LocalPodman {
2664                container: container("ubuntu:24.04"),
2665            },
2666        )]);
2667
2668        assert!(aws_checks(Some(&config), &executor).is_empty());
2669        assert!(aws_checks(None, &executor).is_empty());
2670        assert!(executor.commands.borrow().is_empty());
2671    }
2672
2673    #[test]
2674    fn apple_container_daemon_check_is_ready_once_the_daemon_answers() {
2675        let executor = FakeExecutor::new([
2676            Ok(output(b"container version 1\n")),
2677            Ok(output(b"running\n")),
2678        ]);
2679
2680        let check = apple_container_daemon_check(&executor);
2681
2682        assert_eq!(check.status, CheckStatus::Ready);
2683        assert_eq!(executor.commands.borrow().len(), 2);
2684    }
2685
2686    #[test]
2687    fn linux_instructions_embed_podman_postconditions_and_doctor_loop() {
2688        let instructions = setup_instructions(InstructionsPlatform::Linux);
2689        assert!(instructions.contains("mj doctor --json"));
2690        assert!(instructions.contains("mj doctor --json --smoke"));
2691        assert!(instructions.contains("podman unshare cat /proc/self/uid_map"));
2692        assert!(instructions.contains("Podman **4.0.0 or newer**"));
2693        assert!(instructions.contains("kind = \"local-docker\""));
2694        assert!(instructions.contains("--opt type=overlay"));
2695    }
2696}