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