Skip to main content

mj_controller/
doctor.rs

1//! Actionable host and configuration prerequisite checks.
2
3use std::io::Write;
4use std::path::Path;
5use std::time::{Duration, SystemTime, UNIX_EPOCH};
6
7use anyhow::Result;
8use serde::Serialize;
9
10use crate::controller::{
11    WorkerBinaryAvailability, backend_ssh, worker_binary_prerequisite_for_arch,
12};
13use crate::setup::{
14    DiscoveredHome, discover_harness_homes_with_executor, harness_is_authenticated_with_executor,
15};
16use crate::targets::{
17    BoundedProcessExecutor, CommandExecutor, CommandSpec,
18    ContainerTemplate as RuntimeContainerTemplate, ProcessExecutor, SshTarget as RuntimeSshTarget,
19    TargetTemplate as RuntimeTargetTemplate, run_setup_smoke_test, ssh_command,
20    ssh_connectivity_probe, verify_local_docker, verify_local_podman, verify_ssh_docker,
21    verify_ssh_podman,
22};
23use mj_core::config::{
24    Config, ContainerTemplate, HarnessKind, HarnessProfile, TargetTemplate, config_path,
25};
26use mj_core::credentials::login_command;
27
28// Only the image for the Apple container smoke test when the config has no
29// apple-container target. This intentionally stays a small stock image rather
30// than setup::DEFAULT_IMAGE: the check just proves the runtime can start a
31// container, and pulling the multi-gigabyte agent-dev image to do that would be
32// a poor trade.
33const DEFAULT_CONTAINER_IMAGE: &str = "ubuntu:24.04";
34const APPLE_CONTAINER_INSTALL_URL: &str = "https://github.com/apple/container#initial-install";
35
36/// How long a single prerequisite probe may take before doctor reports it as a
37/// fixable check instead of waiting for it.
38///
39/// Every probe outside the opt-in smoke tests is a local or short network call,
40/// so this only ever fires for a wedged runtime socket, a blackholed network,
41/// or a credential helper waiting on something that will never arrive.
42pub const PROBE_TIMEOUT: Duration = Duration::from_secs(15);
43
44/// The executor `mj doctor` and `mj setup` run their prerequisite probes
45/// through: one deadline per probe, so a wedged runtime cannot hang the run.
46pub const fn probe_executor() -> BoundedProcessExecutor {
47    BoundedProcessExecutor::new(PROBE_TIMEOUT)
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
51#[serde(rename_all = "lowercase")]
52pub enum CheckStatus {
53    Ready,
54    Warning,
55    Fixable,
56    Unsupported,
57}
58
59impl CheckStatus {
60    pub const fn label(self) -> &'static str {
61        match self {
62            Self::Ready => "ready",
63            Self::Warning => "warning",
64            Self::Fixable => "fixable",
65            Self::Unsupported => "unsupported",
66        }
67    }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
71pub struct DoctorCheck {
72    pub id: String,
73    pub title: String,
74    pub status: CheckStatus,
75    pub detail: String,
76    pub remediation: Option<String>,
77}
78
79impl DoctorCheck {
80    fn ready(id: impl Into<String>, title: impl Into<String>, detail: impl Into<String>) -> Self {
81        Self {
82            id: id.into(),
83            title: title.into(),
84            status: CheckStatus::Ready,
85            detail: detail.into(),
86            remediation: None,
87        }
88    }
89
90    fn warning(
91        id: impl Into<String>,
92        title: impl Into<String>,
93        detail: impl Into<String>,
94        remediation: impl Into<String>,
95    ) -> Self {
96        Self {
97            id: id.into(),
98            title: title.into(),
99            status: CheckStatus::Warning,
100            detail: detail.into(),
101            remediation: Some(remediation.into()),
102        }
103    }
104
105    pub(crate) fn fixable(
106        id: impl Into<String>,
107        title: impl Into<String>,
108        detail: impl Into<String>,
109        remediation: impl Into<String>,
110    ) -> Self {
111        Self {
112            id: id.into(),
113            title: title.into(),
114            status: CheckStatus::Fixable,
115            detail: detail.into(),
116            remediation: Some(remediation.into()),
117        }
118    }
119
120    fn unsupported(
121        id: impl Into<String>,
122        title: impl Into<String>,
123        detail: impl Into<String>,
124    ) -> Self {
125        Self {
126            id: id.into(),
127            title: title.into(),
128            status: CheckStatus::Unsupported,
129            detail: detail.into(),
130            remediation: None,
131        }
132    }
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub struct DoctorOptions {
137    pub smoke: bool,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub enum ApplePlatform {
142    Linux,
143    Macos {
144        architecture: String,
145        major_version: u32,
146    },
147    Other(String),
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub enum InstructionsPlatform {
152    Linux,
153    Macos,
154}
155
156pub fn run_current(options: DoctorOptions) -> Vec<DoctorCheck> {
157    if options.smoke {
158        // A smoke test may legitimately pull a multi-gigabyte image, which no
159        // probe deadline could tell apart from a hung runtime, so an opt-in
160        // `--smoke` run keeps waiting for its commands.
161        return run_with(
162            &ProcessExecutor,
163            current_apple_platform(&ProcessExecutor),
164            options,
165        );
166    }
167    let executor = probe_executor();
168    run_with(&executor, current_apple_platform(&executor), options)
169}
170
171pub fn run_with(
172    executor: &impl CommandExecutor,
173    apple_platform: ApplePlatform,
174    options: DoctorOptions,
175) -> Vec<DoctorCheck> {
176    run_with_config_path(&config_path(), executor, apple_platform, options)
177}
178
179/// The same checks as [`run_with`], against an explicit configuration file.
180///
181/// `mj setup` uses this to report on the configuration it just wrote, so a
182/// first run ends with exactly the summary and remediations `mj doctor`
183/// would print.
184pub fn run_with_config_path(
185    config_path: &Path,
186    executor: &impl CommandExecutor,
187    apple_platform: ApplePlatform,
188    options: DoctorOptions,
189) -> Vec<DoctorCheck> {
190    let (config, mut checks) = configuration_checks(config_path);
191    checks.push(harness_discovery_check(config.as_ref(), executor));
192    checks.extend(harness_checks(config.as_ref(), executor));
193    checks.extend(podman_checks(config.as_ref(), executor, options.smoke));
194    checks.extend(docker_checks(config.as_ref(), executor, options.smoke));
195    checks.extend(ssh_bare_checks(config.as_ref(), executor));
196    checks.extend(ssh_podman_checks(config.as_ref(), executor, options.smoke));
197    checks.extend(ssh_docker_checks(config.as_ref(), executor, options.smoke));
198    checks.extend(aws_checks(config.as_ref(), executor));
199    checks.extend(worker_binary_checks(config.as_ref()));
200    checks.push(apple_container_check(
201        &apple_platform,
202        executor,
203        options.smoke,
204        apple_container_image(config.as_ref()),
205    ));
206    checks
207}
208
209fn harness_discovery_check(
210    config: Option<&Config>,
211    executor: &impl CommandExecutor,
212) -> DoctorCheck {
213    let home = dirs::home_dir();
214    let overrides = HarnessKind::ALL.into_iter().filter_map(|kind| {
215        std::env::var_os(kind.home_env()).map(|path| (kind, kind.home_from_environment(path)))
216    });
217    let discovered = discover_harness_homes_with_executor(home.as_deref(), overrides, executor);
218    harness_discovery_check_from(
219        &discovered,
220        config.is_some_and(|config| !config.profiles.is_empty()),
221    )
222}
223
224fn harness_discovery_check_from(
225    discovered: &[DiscoveredHome],
226    has_configured_profiles: bool,
227) -> DoctorCheck {
228    if discovered.is_empty() {
229        return if has_configured_profiles {
230            DoctorCheck::ready(
231                "harness.discovery",
232                "Harness home discovery",
233                "No default or environment-overridden harness homes were found; configured profile homes are checked below.",
234            )
235        } else {
236            DoctorCheck::fixable(
237                "harness.discovery",
238                "Harness home discovery",
239                "No Codex, Claude Code, Kimi Code, or Grok Build home was found in the default or environment-overridden locations.",
240                "Install and sign in to a supported harness, then open F7 Settings → Agent Profiles.",
241            )
242        };
243    }
244
245    let homes = discovered
246        .iter()
247        .map(|home| {
248            let authentication = if home.authenticated {
249                "authenticated"
250            } else {
251                "not authenticated"
252            };
253            format!(
254                "{} at {} ({authentication})",
255                home.kind.display_name(),
256                home.path.display()
257            )
258        })
259        .collect::<Vec<_>>()
260        .join("; ");
261    DoctorCheck::ready(
262        "harness.discovery",
263        "Harness home discovery",
264        format!("Discovered {homes}. Configured profile authentication is checked below."),
265    )
266}
267
268pub fn all_ready(checks: &[DoctorCheck]) -> bool {
269    checks
270        .iter()
271        .all(|check| check.status != CheckStatus::Fixable)
272}
273
274pub fn render_human(checks: &[DoctorCheck], output: &mut impl Write) -> Result<()> {
275    for check in checks {
276        writeln!(
277            output,
278            "{} {}: {}",
279            check.status.label(),
280            check.title,
281            check.detail
282        )?;
283        if let Some(remediation) = &check.remediation {
284            writeln!(output, "  remediation: {remediation}")?;
285        }
286    }
287    Ok(())
288}
289
290pub fn setup_instructions(platform: InstructionsPlatform) -> String {
291    match platform {
292        InstructionsPlatform::Linux => format!(
293            "# Hel setup instructions for Linux\n\n\
294This page is self-contained. Follow this exact loop as the user who will run Hel:\n\n\
2951. Run `mj doctor --json`.\n\
2962. Follow every `fixable` remediation from its JSON output.\n\
2973. Run `mj doctor --json` again. Repeat until no check is `fixable`.\n\
2984. Finish with `mj doctor --json --smoke` to verify every configured container\n\
299   image end to end, and resolve anything it reports as `fixable`.\n\n\
300For a coding-agent handoff, provide this entire instructions page together with\n\
301the latest `mj doctor --json` output.\n\n\
302## Linux container-runtime postconditions\n\n{}\n\n{}",
303            crate::targets::PODMAN_DOCUMENTATION,
304            crate::targets::DOCKER_DOCUMENTATION
305        ),
306        InstructionsPlatform::Macos => format!(
307            "# Hel setup instructions for macOS\n\n\
308This page is self-contained. Follow this exact loop as the user who will run Hel:\n\n\
3091. Run `mj doctor --json`.\n\
3102. Follow every `fixable` remediation from its JSON output.\n\
3113. Run `mj doctor --json` again. Repeat until no check is `fixable`.\n\n\
312For a coding-agent handoff, provide this entire instructions page together with\n\
313the latest `mj doctor --json` output.\n\n\
314## Apple container runtime\n\n\
315Hel's Apple container target requires Apple silicon and macOS 26 or newer.\n\
316On an Intel Mac or an older macOS release, the target is unsupported; use a\n\
317local Podman, SSH, or AWS target instead.\n\n\
318If the `container` command is absent, install only the official signed package:\n\n\
319<https://github.com/apple/container#initial-install>\n\n\
320Hel never downloads or installs that package. If doctor reports a stopped\n\
321daemon, run exactly:\n\n```console\ncontainer system start\n```\n\n\
322Finish with the opt-in disposable runtime test in JSON mode:\n\n```console\nmj doctor --json --smoke\n```\n\n\
323Apple container is ready only when that smoke test creates a disposable\n\
324container, executes `true` in it, and removes it successfully. Use the image\n\
325configured by an `apple-container` target; without one, doctor uses\n\
326`{DEFAULT_CONTAINER_IMAGE}` for the smoke test.\n\n\
327## Shared Hel prerequisites\n\n\
328`mj doctor --json` also checks the configuration, each configured harness home\n\
329and authentication marker, selected container worker binaries, and any relevant\n\
330Podman prerequisites. Resolve every `fixable` status before starting a session."
331        ),
332    }
333}
334
335fn configuration_checks(path: &Path) -> (Option<Config>, Vec<DoctorCheck>) {
336    if !path.exists() {
337        return (
338            None,
339            vec![DoctorCheck::fixable(
340                "config",
341                "Mjolnir configuration",
342                format!("{} does not exist", path.display()),
343                "Open Mjolnir and press F7 for Settings to add an agent profile.",
344            )],
345        );
346    }
347    match Config::load_from(path) {
348        Ok(config) => {
349            let mut checks = vec![match config.newer_build_notice() {
350                // Hel still runs on a config a newer build owns, but every
351                // save refuses, so say so rather than reporting it as valid.
352                Some(notice) => DoctorCheck::warning(
353                    "config",
354                    "Mjolnir configuration",
355                    format!("{}: {notice}", path.display()),
356                    "Update Mjolnir, or change settings with the newer build.",
357                ),
358                None => DoctorCheck::ready(
359                    "config",
360                    "Mjolnir configuration",
361                    format!("{} is valid", path.display()),
362                ),
363            }];
364            if config.enabled_profiles().next().is_none() || config.bundles.is_empty() {
365                checks.push(DoctorCheck::fixable(
366                    "config.session-prerequisites",
367                    "Session configuration",
368                    "An enabled profile and project bundle are required for configured bundle sessions. Local targets are supplied automatically.",
369                    "Open F7 Settings to add or enable agent profiles and projects.",
370                ));
371            } else {
372                checks.push(DoctorCheck::ready(
373                    "config.session-prerequisites",
374                    "Session configuration",
375                    "At least one profile, bundle, and target are configured.",
376                ));
377            }
378            (Some(config), checks)
379        }
380        Err(error) => (
381            None,
382            vec![DoctorCheck::fixable(
383                "config",
384                "Mjolnir configuration",
385                format!("{} is invalid: {error:#}", path.display()),
386                "Fix the reported TOML error in config.toml, or run `mj setup` to replace it.",
387            )],
388        ),
389    }
390}
391
392fn harness_checks(config: Option<&Config>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
393    let Some(config) = config else {
394        return vec![DoctorCheck::fixable(
395            "harness.profiles",
396            "Harness profiles",
397            "Harness homes cannot be checked until config.toml is valid.",
398            "Fix config.toml, then rerun `mj doctor --json`.",
399        )];
400    };
401    if config.profiles.is_empty() {
402        return vec![DoctorCheck::fixable(
403            "harness.profiles",
404            "Harness profiles",
405            "No harness profiles are configured.",
406            "Open F7 Settings → Agent Profiles to detect accounts or add a profile.",
407        )];
408    }
409    config
410        .profiles
411        .iter()
412        .map(|(id, profile)| {
413            let title = format!("Harness profile {id}");
414            if !profile.enabled {
415                return DoctorCheck::ready(
416                    format!("harness.{id}"),
417                    title,
418                    "Profile is disabled; home and authentication checks were skipped.",
419                );
420            }
421            if !profile.home.is_dir() {
422                return DoctorCheck::fixable(
423                    format!("harness.{id}"),
424                    title,
425                    format!("{} does not exist", profile.home.display()),
426                    format!(
427                        "{} If this profile should use an existing installation, select its home in Setup.",
428                        harness_login_remediation(id, profile)
429                    ),
430                );
431            }
432            if !harness_is_authenticated_with_executor(profile, executor) {
433                return DoctorCheck::fixable(
434                    format!("harness.{id}"),
435                    title,
436                    format!(
437                        "No usable authentication was detected for {}",
438                        profile.home.display()
439                    ),
440                    harness_login_remediation(id, profile),
441                );
442            }
443            DoctorCheck::ready(
444                format!("harness.{id}"),
445                title,
446                format!(
447                    "{} is present and authentication is available",
448                    profile.home.display()
449                ),
450            )
451        })
452        .collect()
453}
454
455/// Point an unauthenticated profile at `mj login`, which already knows how to
456/// sign each harness in.
457///
458/// The underlying command is named only for the reader's benefit; it comes from
459/// [`login_command`], the one place that tracks what each harness CLI actually
460/// accepts, so this text cannot drift away from what `mj login` runs.
461fn harness_login_remediation(id: &str, profile: &HarnessProfile) -> String {
462    let (program, arguments) = match login_command(profile) {
463        Ok(command) => command,
464        // An API-key profile has no login to recommend; say what is missing
465        // instead. The authentication gate normally passes such a profile, so
466        // this text appears only when its configuration file is absent.
467        Err(error) => return format!("{error} Check {}.", profile.home.display()),
468    };
469    format!(
470        "Run `mj login --profile {id}`; it runs `{program} {}` against {}.",
471        arguments.join(" "),
472        profile.home.display()
473    )
474}
475
476/// Host Podman prerequisites, then one image check per `local-podman` target.
477///
478/// The image checks run only after the host preflight passes, because a broken
479/// Podman installation already reports its own actionable check.
480fn podman_checks(
481    config: Option<&Config>,
482    executor: &impl CommandExecutor,
483    smoke: bool,
484) -> Vec<DoctorCheck> {
485    let preflight = podman_check(config, executor);
486    let preflight_passed = preflight.status == CheckStatus::Ready;
487    let mut checks = vec![preflight];
488    if preflight_passed {
489        checks.extend(podman_image_checks(config, executor, smoke));
490    }
491    checks
492}
493
494fn podman_check(config: Option<&Config>, executor: &impl CommandExecutor) -> DoctorCheck {
495    let Some(config) = config else {
496        return DoctorCheck::unsupported(
497            "runtime.podman",
498            "Rootless Podman",
499            "Podman prerequisites cannot be evaluated until config.toml is valid.",
500        );
501    };
502    if local_podman_targets(config).is_empty() {
503        return DoctorCheck::unsupported(
504            "runtime.podman",
505            "Rootless Podman",
506            "No local-podman target is configured.",
507        );
508    }
509    local_podman_runtime_check(executor)
510}
511
512/// Probe the local rootless Podman prerequisites and phrase the result as a
513/// doctor check.
514///
515/// This is the single source of truth for Podman availability wording and
516/// remediation. `mj setup` calls it directly so its runtime list reports the
517/// same detail and fix that `mj doctor` would.
518pub fn local_podman_runtime_check(executor: &impl CommandExecutor) -> DoctorCheck {
519    match verify_local_podman(executor) {
520        Ok(preflight) => DoctorCheck::ready(
521            "runtime.podman",
522            "Rootless Podman",
523            format!("Podman {} has a valid rootless UID map.", preflight.version),
524        ),
525        Err(error) => {
526            let detail = format!("{error:#}");
527            DoctorCheck::fixable(
528                "runtime.podman",
529                "Rootless Podman",
530                detail.clone(),
531                podman_remediation(&detail),
532            )
533        }
534    }
535}
536
537fn local_podman_targets(config: &Config) -> Vec<(&String, &ContainerTemplate)> {
538    config
539        .targets
540        .iter()
541        .filter_map(|(id, target)| match target {
542            TargetTemplate::LocalPodman { container } => Some((id, container)),
543            _ => None,
544        })
545        .collect()
546}
547
548fn podman_image_checks(
549    config: Option<&Config>,
550    executor: &impl CommandExecutor,
551    smoke: bool,
552) -> Vec<DoctorCheck> {
553    let Some(config) = config else {
554        return Vec::new();
555    };
556    local_podman_targets(config)
557        .into_iter()
558        .map(|(id, container)| podman_image_check(id, &container.image, executor, smoke))
559        .collect()
560}
561
562fn podman_image_check(
563    id: &str,
564    image: &str,
565    executor: &impl CommandExecutor,
566    smoke: bool,
567) -> DoctorCheck {
568    let check_id = format!("runtime.podman.image.{id}");
569    let title = format!("Podman image for target {id}");
570    if smoke {
571        let target = RuntimeTargetTemplate::LocalPodman(RuntimeContainerTemplate {
572            image: image.to_owned(),
573            pull_policy: Default::default(),
574            extra_run_args: vec![],
575            workspace_storage: Default::default(),
576        });
577        return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
578            Ok(()) => DoctorCheck::ready(
579                check_id,
580                title,
581                format!("Disposable run/exec/remove smoke test passed for image {image}."),
582            ),
583            Err(error) => DoctorCheck::fixable(
584                check_id,
585                title,
586                format!(
587                    "Disposable run/exec/remove smoke test failed for image {image}: {error:#}"
588                ),
589                "Fix the configured image or Podman runtime, then run `mj doctor --json --smoke` again.",
590            ),
591        };
592    }
593
594    let command = CommandSpec::new("podman", ["image", "exists", image])
595        .purpose("check Podman image presence");
596    match executor.execute(&command) {
597        Ok(output) if output.status == 0 => DoctorCheck::ready(
598            check_id,
599            title,
600            format!("Image {image} is present in local Podman storage."),
601        ),
602        Ok(_) => DoctorCheck::fixable(
603            check_id,
604            title,
605            format!("Image {image} is not present in local Podman storage."),
606            missing_image_remediation(image),
607        ),
608        Err(error) => DoctorCheck::fixable(
609            check_id,
610            title,
611            format!(
612                "Could not check whether image {image} is present in local Podman storage: {error}"
613            ),
614            missing_image_remediation(image),
615        ),
616    }
617}
618
619fn missing_image_remediation(image: &str) -> String {
620    format!(
621        "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."
622    )
623}
624
625/// Host Docker prerequisites, then one image check per `local-docker` target.
626fn docker_checks(
627    config: Option<&Config>,
628    executor: &impl CommandExecutor,
629    smoke: bool,
630) -> Vec<DoctorCheck> {
631    let Some(config) = config else {
632        return vec![DoctorCheck::unsupported(
633            "runtime.docker",
634            "Docker",
635            "Docker prerequisites cannot be evaluated until config.toml is valid.",
636        )];
637    };
638    let targets = local_docker_targets(config);
639    if targets.is_empty() {
640        return vec![DoctorCheck::unsupported(
641            "runtime.docker",
642            "Docker",
643            "No local-docker target is configured.",
644        )];
645    }
646    let preflight = local_docker_runtime_check(executor);
647    if preflight.status != CheckStatus::Ready {
648        return vec![preflight];
649    }
650    let mut checks = vec![preflight];
651    checks.extend(
652        targets
653            .into_iter()
654            .map(|(id, container)| docker_image_check(id, &container.image, executor, smoke)),
655    );
656    checks
657}
658
659pub fn local_docker_runtime_check(executor: &impl CommandExecutor) -> DoctorCheck {
660    match verify_local_docker(executor) {
661        Ok(preflight) => DoctorCheck::ready(
662            "runtime.docker",
663            "Docker",
664            format!(
665                "Docker {} is connected to a Linux daemon.",
666                preflight.version
667            ),
668        ),
669        Err(error) => DoctorCheck::fixable(
670            "runtime.docker",
671            "Docker",
672            format!("{error:#}"),
673            "Install and start Docker, then make sure `docker info` succeeds as the user running mj.",
674        ),
675    }
676}
677
678fn local_docker_targets(config: &Config) -> Vec<(&String, &ContainerTemplate)> {
679    config
680        .targets
681        .iter()
682        .filter_map(|(id, target)| match target {
683            TargetTemplate::LocalDocker { container } => Some((id, container)),
684            _ => None,
685        })
686        .collect()
687}
688
689fn docker_image_check(
690    id: &str,
691    image: &str,
692    executor: &impl CommandExecutor,
693    smoke: bool,
694) -> DoctorCheck {
695    let check_id = format!("runtime.docker.image.{id}");
696    let title = format!("Docker image for target {id}");
697    if smoke {
698        let target = RuntimeTargetTemplate::LocalDocker(RuntimeContainerTemplate {
699            image: image.to_owned(),
700            pull_policy: Default::default(),
701            extra_run_args: vec![],
702            workspace_storage: Default::default(),
703        });
704        return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
705            Ok(()) => DoctorCheck::ready(
706                check_id,
707                title,
708                format!(
709                    "Disposable run/exec/remove and OverlayFS attachment smoke test passed for image {image}."
710                ),
711            ),
712            Err(error) => DoctorCheck::fixable(
713                check_id,
714                title,
715                format!(
716                    "Disposable run/exec/remove smoke test failed for image {image}: {error:#}"
717                ),
718                "Fix the configured image or Docker runtime, then run `mj doctor --json --smoke` again.",
719            ),
720        };
721    }
722    let command = CommandSpec::new("docker", ["image", "inspect", image])
723        .purpose("check Docker image presence");
724    match executor.execute(&command) {
725        Ok(output) if output.status == 0 => DoctorCheck::ready(
726            check_id,
727            title,
728            format!("Image {image} is present in Docker storage."),
729        ),
730        Ok(_) => DoctorCheck::fixable(
731            check_id,
732            title,
733            format!("Image {image} is not present in Docker storage."),
734            format!("Pull it with `docker pull {image}`, or run `mj doctor --json --smoke`."),
735        ),
736        Err(error) => DoctorCheck::fixable(
737            check_id,
738            title,
739            format!("Could not inspect Docker image {image}: {error}"),
740            format!("Make sure `docker info` succeeds, then run `docker pull {image}`."),
741        ),
742    }
743}
744
745/// The outcome of the shared SSH connectivity probe.
746///
747/// Both SSH-backed checks run this first: an unreachable host makes every
748/// later probe fail with a misleading message.
749enum SshConnectivity {
750    Reachable,
751    Failed { detail: String, remediation: String },
752}
753
754/// Probe `ssh <destination> true` and map any failure to a copy-paste fix.
755///
756/// Hel never generates keys, runs `ssh-copy-id`, or accepts a host key on the
757/// user's behalf; it only says exactly which command would fix the failure.
758fn ssh_connectivity(ssh: &RuntimeSshTarget, executor: &impl CommandExecutor) -> SshConnectivity {
759    let destination = &ssh.destination;
760    let command = ssh_connectivity_probe(ssh);
761    match executor.execute(&command) {
762        Err(error) => SshConnectivity::Failed {
763            detail: format!("Could not run `ssh {destination} true`: {error}"),
764            remediation: SSH_MISSING_REMEDIATION.to_owned(),
765        },
766        Ok(output) if output.status != 0 => {
767            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
768            SshConnectivity::Failed {
769                detail: format!("`ssh {destination} true` failed: {stderr}"),
770                remediation: ssh_failure_remediation(&stderr, ssh),
771            }
772        }
773        Ok(_) => SshConnectivity::Reachable,
774    }
775}
776
777const 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).";
778
779/// Map `ssh -o BatchMode=yes` stderr to the command that fixes it.
780fn ssh_failure_remediation(stderr: &str, ssh: &RuntimeSshTarget) -> String {
781    let destination = &ssh.destination;
782    if stderr.contains("Host key verification failed")
783        || stderr.contains("No ECDSA host key is known")
784        || stderr.contains("REMOTE HOST IDENTIFICATION HAS CHANGED")
785    {
786        let host = ssh_host_only(destination);
787        return format!(
788            "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."
789        );
790    }
791    if stderr.contains("Permission denied")
792        || stderr.contains("Too many authentication failures")
793        || stderr.contains("no matching host key")
794        || stderr.contains("Authentication failed")
795    {
796        return match ssh_identity_file(ssh) {
797            Some(identity) => format!(
798                "Install your public key on the host with `ssh-copy-id -i {identity}.pub {destination}`."
799            ),
800            None => {
801                format!("Install your public key on the host with `ssh-copy-id {destination}`.")
802            }
803        };
804    }
805    if stderr.contains("ssh: command not found") || stderr.contains("No such file or directory") {
806        return SSH_MISSING_REMEDIATION.to_owned();
807    }
808    format!("Run `ssh {destination} true` by hand and resolve the error it reports: {stderr}")
809}
810
811/// The host part of an OpenSSH destination, without any `user@` prefix.
812fn ssh_host_only(destination: &str) -> &str {
813    destination
814        .rsplit_once('@')
815        .map_or(destination, |(_, host)| host)
816}
817
818/// The identity file provisioning passes, recovered from the built ssh args.
819fn ssh_identity_file(ssh: &RuntimeSshTarget) -> Option<&str> {
820    let position = ssh.ssh_args.iter().position(|arg| arg == "-i")?;
821    ssh.ssh_args.get(position + 1).map(String::as_str)
822}
823
824/// One check per `ssh-bare` target: can Hel reach the host noninteractively?
825fn ssh_bare_checks(config: Option<&Config>, executor: &impl CommandExecutor) -> 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<&Config>,
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<&Config>,
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<&Config>, 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 `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<&Config>) -> 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<&Config>) -> 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 crate::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() -> mj_core::config::SshConnection {
1665        mj_core::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                mj_core::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)>) -> Config {
1695        Config {
1696            targets: targets
1697                .into_iter()
1698                .map(|(id, target)| (id.to_owned(), target))
1699                .collect(),
1700            ..Config::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() -> Config {
2157        config_with([(
2158            "builder",
2159            TargetTemplate::SshBare {
2160                ssh: mj_core::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: mj_core::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            guardian_review_model: None,
2341        };
2342        let config = Config {
2343            profiles: [("work".to_owned(), profile.clone())].into_iter().collect(),
2344            ..Config::default()
2345        };
2346
2347        let executor = FakeExecutor::new([Ok(output(br#"{"loggedIn":false}"#))]);
2348        let checks = harness_checks(Some(&config), &executor);
2349
2350        assert_eq!(checks.len(), 1);
2351        assert_eq!(checks[0].status, CheckStatus::Fixable);
2352        let remediation = checks[0].remediation.as_deref().unwrap();
2353        assert!(
2354            remediation.contains("mj login --profile work"),
2355            "{remediation}"
2356        );
2357        // The underlying command is quoted from the one place that verified it,
2358        // so doctor cannot recommend something `mj login` does not run.
2359        let (program, arguments) =
2360            login_command(&profile).expect("OAuth profile has a login command");
2361        assert!(
2362            remediation.contains(&format!("`{program} {}`", arguments.join(" "))),
2363            "{remediation}"
2364        );
2365    }
2366
2367    #[test]
2368    fn doctor_reports_disabled_profiles_without_probing_them() {
2369        let profile = HarnessProfile {
2370            enabled: false,
2371            kind: HarnessKind::Claude,
2372            home: PathBuf::from("/missing/disabled-profile"),
2373            environment: std::collections::BTreeMap::new(),
2374            context_window_bytes: None,
2375            guardian_review_model: None,
2376        };
2377        let config = Config {
2378            profiles: [("retired".to_owned(), profile)].into_iter().collect(),
2379            ..Config::default()
2380        };
2381        let executor = FakeExecutor::new([]);
2382
2383        let checks = harness_checks(Some(&config), &executor);
2384
2385        assert_eq!(checks.len(), 1);
2386        assert_eq!(checks[0].status, CheckStatus::Ready);
2387        assert!(checks[0].detail.contains("disabled"));
2388        assert!(executor.commands.borrow().is_empty());
2389    }
2390
2391    #[test]
2392    fn harness_discovery_reports_each_authentication_state() {
2393        let check = harness_discovery_check_from(
2394            &[
2395                DiscoveredHome {
2396                    kind: HarnessKind::Codex,
2397                    path: "/agents/codex".into(),
2398                    authenticated: true,
2399                },
2400                DiscoveredHome {
2401                    kind: HarnessKind::Kimi,
2402                    path: "/agents/kimi".into(),
2403                    authenticated: false,
2404                },
2405            ],
2406            true,
2407        );
2408
2409        assert_eq!(check.status, CheckStatus::Ready);
2410        assert!(
2411            check
2412                .detail
2413                .contains("Codex at /agents/codex (authenticated)")
2414        );
2415        assert!(
2416            check
2417                .detail
2418                .contains("Kimi Code at /agents/kimi (not authenticated)")
2419        );
2420    }
2421
2422    #[test]
2423    fn missing_harness_homes_are_fixable_without_a_configured_profile() {
2424        let check = harness_discovery_check_from(&[], false);
2425
2426        assert_eq!(check.status, CheckStatus::Fixable);
2427        assert_eq!(
2428            check.remediation.as_deref(),
2429            Some(
2430                "Install and sign in to a supported harness, then open F7 Settings → Agent Profiles."
2431            )
2432        );
2433    }
2434
2435    #[test]
2436    fn apple_container_is_unsupported_on_intel_macs() {
2437        let executor = FakeExecutor::new([]);
2438
2439        let check = apple_container_check(
2440            &ApplePlatform::Macos {
2441                architecture: "x86_64".into(),
2442                major_version: 26,
2443            },
2444            &executor,
2445            false,
2446            DEFAULT_CONTAINER_IMAGE.into(),
2447        );
2448
2449        assert_eq!(check.status, CheckStatus::Unsupported);
2450        assert!(check.detail.contains("Intel Macs"));
2451        assert!(executor.commands.borrow().is_empty());
2452    }
2453
2454    #[test]
2455    fn apple_container_is_unsupported_before_macos_26() {
2456        let executor = FakeExecutor::new([]);
2457
2458        let check = apple_container_check(
2459            &ApplePlatform::Macos {
2460                architecture: "aarch64".into(),
2461                major_version: 25,
2462            },
2463            &executor,
2464            false,
2465            DEFAULT_CONTAINER_IMAGE.into(),
2466        );
2467
2468        assert_eq!(check.status, CheckStatus::Unsupported);
2469        assert!(check.detail.contains("macOS 26"));
2470    }
2471
2472    #[test]
2473    fn apple_container_not_installed_has_official_package_remediation() {
2474        let executor = FakeExecutor::new([Err(anyhow!("No such file or directory"))]);
2475
2476        let check = apple_container_check(
2477            &ApplePlatform::Macos {
2478                architecture: "aarch64".into(),
2479                major_version: 26,
2480            },
2481            &executor,
2482            false,
2483            DEFAULT_CONTAINER_IMAGE.into(),
2484        );
2485
2486        assert_eq!(check.status, CheckStatus::Fixable);
2487        assert_eq!(
2488            check.remediation.as_deref(),
2489            Some(
2490                format!("Install the official signed package: {APPLE_CONTAINER_INSTALL_URL}")
2491                    .as_str()
2492            )
2493        );
2494    }
2495
2496    #[test]
2497    fn apple_container_stopped_daemon_has_start_remediation() {
2498        let executor = FakeExecutor::new([
2499            Ok(output(b"container version 1\n")),
2500            Ok(CommandOutput {
2501                status: 1,
2502                stdout: vec![],
2503                stderr: b"daemon is not running".to_vec(),
2504            }),
2505        ]);
2506
2507        let check = apple_container_check(
2508            &ApplePlatform::Macos {
2509                architecture: "aarch64".into(),
2510                major_version: 26,
2511            },
2512            &executor,
2513            false,
2514            DEFAULT_CONTAINER_IMAGE.into(),
2515        );
2516
2517        assert_eq!(check.status, CheckStatus::Fixable);
2518        assert_eq!(
2519            check.remediation.as_deref(),
2520            Some("Run `container system start`.")
2521        );
2522    }
2523
2524    #[test]
2525    fn apple_container_is_ready_only_after_the_opt_in_smoke_test() {
2526        let executor = FakeExecutor::new([
2527            Ok(output(b"container version 1\n")),
2528            Ok(output(b"running\n")),
2529            Ok(output(b"created\n")),
2530            Ok(output(b"ok\n")),
2531            Ok(output(b"removed\n")),
2532        ]);
2533
2534        let check = apple_container_check(
2535            &ApplePlatform::Macos {
2536                architecture: "aarch64".into(),
2537                major_version: 26,
2538            },
2539            &executor,
2540            true,
2541            DEFAULT_CONTAINER_IMAGE.into(),
2542        );
2543
2544        assert_eq!(check.status, CheckStatus::Ready);
2545        assert_eq!(executor.commands.borrow().len(), 5);
2546        assert_eq!(executor.commands.borrow()[2].args[0], "run");
2547        assert_eq!(executor.commands.borrow()[3].args[0], "exec");
2548        assert_eq!(executor.commands.borrow()[4].args[0], "rm");
2549    }
2550
2551    #[test]
2552    fn linux_reports_apple_container_as_macos_only() {
2553        let executor = FakeExecutor::new([]);
2554        let check = apple_container_check(
2555            &ApplePlatform::Linux,
2556            &executor,
2557            false,
2558            DEFAULT_CONTAINER_IMAGE.into(),
2559        );
2560        assert_eq!(check.status, CheckStatus::Unsupported);
2561        assert_eq!(check.detail, "macOS only");
2562    }
2563
2564    fn aws_target(launch_template: &str) -> TargetTemplate {
2565        TargetTemplate::AwsEc2 {
2566            aws_profile: Some("hel".into()),
2567            region: "us-east-1".into(),
2568            launch_template: launch_template.to_owned(),
2569            launch_template_version: None,
2570            ssh_user: "ubuntu".into(),
2571            address_source: mj_core::config::AwsAddressSource::default(),
2572            identity_file: None,
2573            ssh_args: vec![],
2574        }
2575    }
2576
2577    #[test]
2578    fn aws_check_is_ready_after_the_cli_credential_and_launch_template_probes() {
2579        let executor = FakeExecutor::new([
2580            Ok(output(b"aws-cli/2.17.0\n")),
2581            Ok(output(b"{\"Account\":\"123456789012\"}\n")),
2582            Ok(output(b"{\"LaunchTemplates\":[{}]}\n")),
2583        ]);
2584        let config = config_with([("aws", aws_target("hel-runson"))]);
2585
2586        let checks = aws_checks(Some(&config), &executor);
2587
2588        assert_eq!(checks.len(), 1);
2589        assert_eq!(checks[0].id, "runtime.aws-ec2.aws");
2590        assert_eq!(checks[0].status, CheckStatus::Ready);
2591        let commands = executor.commands.borrow();
2592        assert!(commands.iter().all(|command| command.program == "aws"));
2593        // Profile and region are applied exactly as provisioning applies them.
2594        assert_eq!(
2595            commands[2].args,
2596            vec![
2597                "--profile",
2598                "hel",
2599                "--region",
2600                "us-east-1",
2601                "ec2",
2602                "describe-launch-templates",
2603                "--launch-template-names",
2604                "hel-runson",
2605                "--output",
2606                "json"
2607            ]
2608        );
2609    }
2610
2611    #[test]
2612    fn aws_check_is_fixable_with_an_install_remediation_without_the_cli() {
2613        let executor = FakeExecutor::new([Err(anyhow!("No such file or directory"))]);
2614
2615        let check = aws_target_check("aws", None, "us-east-1", "hel-runson", &executor);
2616
2617        assert_eq!(check.status, CheckStatus::Fixable);
2618        assert!(
2619            check
2620                .remediation
2621                .as_deref()
2622                .unwrap()
2623                .contains(AWS_CLI_INSTALL_URL)
2624        );
2625        assert_eq!(executor.commands.borrow().len(), 1);
2626    }
2627
2628    #[test]
2629    fn aws_check_is_fixable_with_a_sign_in_remediation_for_expired_credentials() {
2630        let executor = FakeExecutor::new([
2631            Ok(output(b"aws-cli/2.17.0\n")),
2632            Ok(failed(b"ExpiredToken: the security token has expired")),
2633        ]);
2634
2635        let check = aws_target_check("aws", Some("hel"), "us-east-1", "hel-runson", &executor);
2636
2637        assert_eq!(check.status, CheckStatus::Fixable);
2638        assert!(check.detail.contains("ExpiredToken"));
2639        assert_eq!(
2640            check.remediation.as_deref(),
2641            Some(
2642                "Configure credentials with `aws configure --profile hel`, or sign in with `aws sso login --profile hel`."
2643            )
2644        );
2645    }
2646
2647    #[test]
2648    fn aws_check_is_fixable_when_the_launch_template_is_missing() {
2649        let executor = FakeExecutor::new([
2650            Ok(output(b"aws-cli/2.17.0\n")),
2651            Ok(output(b"{\"Account\":\"123456789012\"}\n")),
2652            Ok(failed(b"InvalidLaunchTemplateName.NotFoundException")),
2653        ]);
2654
2655        let check = aws_target_check("aws", Some("hel"), "us-east-1", "lt-0123456789", &executor);
2656
2657        assert_eq!(check.status, CheckStatus::Fixable);
2658        assert!(check.detail.contains("was not found in us-east-1"));
2659        // An `lt-` value is a template id, not a name.
2660        assert_eq!(
2661            executor.commands.borrow()[2].args[6],
2662            "--launch-template-ids"
2663        );
2664    }
2665
2666    #[test]
2667    fn aws_checks_are_skipped_for_configs_without_an_aws_target() {
2668        let executor = FakeExecutor::new([]);
2669        let config = config_with([(
2670            "podman",
2671            TargetTemplate::LocalPodman {
2672                container: container("ubuntu:24.04"),
2673            },
2674        )]);
2675
2676        assert!(aws_checks(Some(&config), &executor).is_empty());
2677        assert!(aws_checks(None, &executor).is_empty());
2678        assert!(executor.commands.borrow().is_empty());
2679    }
2680
2681    #[test]
2682    fn apple_container_daemon_check_is_ready_once_the_daemon_answers() {
2683        let executor = FakeExecutor::new([
2684            Ok(output(b"container version 1\n")),
2685            Ok(output(b"running\n")),
2686        ]);
2687
2688        let check = apple_container_daemon_check(&executor);
2689
2690        assert_eq!(check.status, CheckStatus::Ready);
2691        assert_eq!(executor.commands.borrow().len(), 2);
2692    }
2693
2694    #[test]
2695    fn linux_instructions_embed_podman_postconditions_and_doctor_loop() {
2696        let instructions = setup_instructions(InstructionsPlatform::Linux);
2697        assert!(instructions.contains("mj doctor --json"));
2698        assert!(instructions.contains("mj doctor --json --smoke"));
2699        assert!(instructions.contains("podman unshare cat /proc/self/uid_map"));
2700        assert!(instructions.contains("Podman **4.0.0 or newer**"));
2701        assert!(instructions.contains("kind = \"local-docker\""));
2702        assert!(instructions.contains("--opt type=overlay"));
2703    }
2704}