Skip to main content

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