1use 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
28const DEFAULT_CONTAINER_IMAGE: &str = "ubuntu:24.04";
34const APPLE_CONTAINER_INSTALL_URL: &str = "https://github.com/apple/container#initial-install";
35
36pub const PROBE_TIMEOUT: Duration = Duration::from_secs(15);
43
44pub 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 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
179pub 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 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
455fn harness_login_remediation(id: &str, profile: &HarnessProfile) -> String {
462 let (program, arguments) = match login_command(profile) {
463 Ok(command) => command,
464 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
476fn 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
512pub 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
625fn 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
745enum SshConnectivity {
750 Reachable,
751 Failed { detail: String, remediation: String },
752}
753
754fn 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
779fn 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
811fn ssh_host_only(destination: &str) -> &str {
813 destination
814 .rsplit_once('@')
815 .map_or(destination, |(_, host)| host)
816}
817
818fn 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
824fn 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
864fn 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 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
985fn 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
1118fn 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
1136fn 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
1158fn 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
1185fn 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 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 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
1498pub 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 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 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 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 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 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}