1use std::io::Write;
4use std::path::{Path, PathBuf};
5use std::time::{Duration, SystemTime, UNIX_EPOCH};
6
7use anyhow::Result;
8use serde::Serialize;
9
10use crate::controller::{
11 WorkerBinaryAvailability, ssh_worker_binary_prerequisite, 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, CommandTimedOut,
18 ContainerTemplate as RuntimeContainerTemplate, DockerUnavailable, PODMAN_DOCUMENTATION_URL,
19 PodmanPostcondition, ProcessExecutor, SshTarget as RuntimeSshTarget,
20 TargetTemplate as RuntimeTargetTemplate, failed_podman_postcondition, local_docker_vm_share,
21 podman_probe_observation, run_setup_smoke_test, ssh_command, ssh_connectivity_probe,
22 ssh_validation_command, verify_local_docker, verify_local_podman, verify_ssh_docker,
23 verify_ssh_podman,
24};
25use mj_core::config::{
26 Config, ContainerTemplate, HarnessKind, HarnessProfile, TargetTemplate, config_path,
27};
28use mj_core::credentials::login_command;
29
30const DEFAULT_CONTAINER_IMAGE: &str = "ubuntu:24.04";
36const APPLE_CONTAINER_INSTALL_URL: &str = "https://github.com/apple/container#initial-install";
37
38pub const PROBE_TIMEOUT: Duration = Duration::from_secs(15);
45
46pub const fn probe_executor() -> BoundedProcessExecutor {
49 BoundedProcessExecutor::new(PROBE_TIMEOUT)
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
53#[serde(rename_all = "lowercase")]
54pub enum CheckStatus {
55 Ready,
56 Warning,
57 Fixable,
58 Unsupported,
59}
60
61impl CheckStatus {
62 pub const fn label(self) -> &'static str {
63 match self {
64 Self::Ready => "ready",
65 Self::Warning => "warning",
66 Self::Fixable => "fixable",
67 Self::Unsupported => "unsupported",
68 }
69 }
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
73pub struct DoctorCheck {
74 pub id: String,
75 pub title: String,
76 pub status: CheckStatus,
77 pub detail: String,
78 pub remediation: Option<String>,
79}
80
81impl DoctorCheck {
82 fn ready(id: impl Into<String>, title: impl Into<String>, detail: impl Into<String>) -> Self {
83 Self {
84 id: id.into(),
85 title: title.into(),
86 status: CheckStatus::Ready,
87 detail: detail.into(),
88 remediation: None,
89 }
90 }
91
92 fn warning(
93 id: impl Into<String>,
94 title: impl Into<String>,
95 detail: impl Into<String>,
96 remediation: impl Into<String>,
97 ) -> Self {
98 Self {
99 id: id.into(),
100 title: title.into(),
101 status: CheckStatus::Warning,
102 detail: detail.into(),
103 remediation: Some(remediation.into()),
104 }
105 }
106
107 pub(crate) fn fixable(
108 id: impl Into<String>,
109 title: impl Into<String>,
110 detail: impl Into<String>,
111 remediation: impl Into<String>,
112 ) -> Self {
113 Self {
114 id: id.into(),
115 title: title.into(),
116 status: CheckStatus::Fixable,
117 detail: detail.into(),
118 remediation: Some(remediation.into()),
119 }
120 }
121
122 fn unsupported(
123 id: impl Into<String>,
124 title: impl Into<String>,
125 detail: impl Into<String>,
126 ) -> Self {
127 Self {
128 id: id.into(),
129 title: title.into(),
130 status: CheckStatus::Unsupported,
131 detail: detail.into(),
132 remediation: None,
133 }
134 }
135}
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub struct DoctorOptions {
139 pub smoke: bool,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub enum ApplePlatform {
144 Linux,
145 Macos {
146 architecture: String,
147 major_version: u32,
148 },
149 Other(String),
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum InstructionsPlatform {
154 Linux,
155 Macos,
156}
157
158pub fn run_current(options: DoctorOptions) -> Vec<DoctorCheck> {
159 if options.smoke {
160 return run_with(
164 &ProcessExecutor,
165 current_apple_platform(&ProcessExecutor),
166 options,
167 );
168 }
169 let executor = probe_executor();
170 run_with(&executor, current_apple_platform(&executor), options)
171}
172
173pub fn run_with(
174 executor: &impl CommandExecutor,
175 apple_platform: ApplePlatform,
176 options: DoctorOptions,
177) -> Vec<DoctorCheck> {
178 run_with_config_path(&config_path(), executor, apple_platform, options)
179}
180
181pub fn run_with_config_path(
187 config_path: &Path,
188 executor: &impl CommandExecutor,
189 apple_platform: ApplePlatform,
190 options: DoctorOptions,
191) -> Vec<DoctorCheck> {
192 let (loaded, mut checks) = configuration_checks(config_path);
193 let config: ConfigStatus<'_> = loaded.as_ref().map_err(|gap| *gap);
194 checks.push(harness_discovery_check(config, executor));
195 checks.extend(harness_checks(config, executor));
196 checks.extend(subagent_eligibility_checks(config));
197 checks.extend(secret_checks(config, config_path));
198 let podman = podman_checks(config, executor, options.smoke, &apple_platform);
199 let docker = docker_checks(config, executor, options.smoke);
200 let apple_container = apple_container_check(
201 &apple_platform,
202 executor,
203 options.smoke,
204 apple_container_image(config),
205 );
206 let offered = config.map(|config| {
207 offered_targets(
208 config,
209 podman.iter().chain(&docker).chain([&apple_container]),
210 )
211 });
212 let offered = offered.as_ref().map_err(|gap| *gap);
213 checks.extend(podman);
214 checks.extend(docker);
215 checks.extend(ssh_bare_checks(config, executor));
216 checks.extend(ssh_podman_checks(config, executor, options.smoke));
217 checks.extend(ssh_docker_checks(config, executor, options.smoke));
218 checks.extend(build_cache_checks(offered, executor));
219 checks.extend(aws_checks(config, executor));
220 checks.extend(worker_binary_checks(offered));
221 checks.extend(ssh_bare_worker_checks(config, executor));
222 checks.push(daemon_build_check());
223 checks.extend(worker_freshness_checks(offered));
224 checks.extend(review_residue_checks(config));
225 checks.extend(bifrost_check(config, executor));
226 checks.push(apple_container);
228 checks
229}
230
231fn build_cache_checks(
232 config: ConfigStatus<'_>,
233 executor: &impl CommandExecutor,
234) -> Vec<DoctorCheck> {
235 let Ok(config) = config else {
236 return Vec::new();
237 };
238 crate::controller::doctor_host_mbx(config, executor)
239 .into_iter()
240 .map(|host| {
241 let id = format!("build-cache.{}", host.host);
242 let title = format!("Build cache on {}", host.host);
243 let targets = host.targets.join(", ");
244 match host.status {
245 crate::controller::DoctorHostMbxStatus::Unsupported(reason) =>
246 DoctorCheck::unsupported(id, title, reason),
247 crate::controller::DoctorHostMbxStatus::Absent => DoctorCheck::ready(
248 id,
249 title,
250 format!(
251 "No native mbx is installed; targets {targets} can use Mjolnir's mbx {}.",
252 crate::controller::MBX_VERSION
253 ),
254 ),
255 crate::controller::DoctorHostMbxStatus::Compatible(version) => DoctorCheck::ready(
256 id,
257 title,
258 format!(
259 "Host mbx {version} is compatible with Mjolnir's mbx {} for targets {targets}.",
260 crate::controller::MBX_VERSION
261 ),
262 ),
263 crate::controller::DoctorHostMbxStatus::TooOld(version) => DoctorCheck::warning(
264 id,
265 title,
266 format!(
267 "Host mbx {version} is older than Mjolnir's mbx {}; sessions on targets {targets} run without the shared build cache.",
268 crate::controller::MBX_VERSION
269 ),
270 format!(
271 "Upgrade mbx on {} to {} or newer, then rerun `mj doctor`.",
272 host.host,
273 crate::controller::MBX_VERSION
274 ),
275 ),
276 crate::controller::DoctorHostMbxStatus::Unknown(error) => DoctorCheck::warning(
277 id,
278 title,
279 format!("Could not check host mbx for targets {targets}: {error}"),
280 format!(
281 "Check access to {} and run `mbx --version` there, then rerun `mj doctor`.",
282 host.host
283 ),
284 ),
285 }
286 })
287 .collect()
288}
289
290fn harness_discovery_check(
291 config: ConfigStatus<'_>,
292 executor: &impl CommandExecutor,
293) -> DoctorCheck {
294 let home = dirs::home_dir();
295 let overrides = HarnessKind::ALL.into_iter().filter_map(|kind| {
296 std::env::var_os(kind.home_env()).map(|path| (kind, kind.home_from_environment(path)))
297 });
298 let discovered = discover_harness_homes_with_executor(home.as_deref(), overrides, executor);
299 harness_discovery_check_from(
300 &discovered,
301 config.is_ok_and(|config| !config.profiles.is_empty()),
302 &settings_key(config.ok()),
303 )
304}
305
306fn harness_discovery_check_from(
307 discovered: &[DiscoveredHome],
308 has_configured_profiles: bool,
309 settings_key: &str,
310) -> DoctorCheck {
311 if discovered.is_empty() {
312 return if has_configured_profiles {
313 DoctorCheck::ready(
314 "harness.discovery",
315 "Harness home discovery",
316 "No default or environment-overridden harness homes were found; configured profile homes are checked below.",
317 )
318 } else {
319 DoctorCheck::fixable(
320 "harness.discovery",
321 "Harness home discovery",
322 format!(
323 "No {} home was found in the default or environment-overridden locations.",
324 HarnessKind::every_display_name_or()
325 ),
326 format!(
327 "Install and sign in to a supported harness, then open Mjolnir, press {settings_key} for Settings, and choose Agent Profiles."
328 ),
329 )
330 };
331 }
332
333 let homes = discovered
334 .iter()
335 .map(|home| {
336 let authentication = if home.authenticated {
337 "authenticated"
338 } else {
339 "not authenticated"
340 };
341 format!(
342 "{} at {} ({authentication})",
343 home.kind.display_name(),
344 home.path.display()
345 )
346 })
347 .collect::<Vec<_>>()
348 .join("; ");
349 DoctorCheck::ready(
350 "harness.discovery",
351 "Harness home discovery",
352 format!("Discovered {homes}. Configured profile authentication is checked below."),
353 )
354}
355
356fn settings_key(config: Option<&Config>) -> String {
359 let keybinds = config.map_or_else(mj_core::config::Keybinds::default, Config::keybinds);
360 keybinds
361 .labels(mj_core::config::KeyAction::OpenSettings)
362 .into_iter()
363 .next()
364 .unwrap_or_else(|| "the Settings command in the command palette".to_owned())
365}
366
367pub fn all_ready(checks: &[DoctorCheck]) -> bool {
368 checks
369 .iter()
370 .all(|check| check.status != CheckStatus::Fixable)
371}
372
373pub fn render_human(checks: &[DoctorCheck], output: &mut impl Write) -> Result<()> {
374 for check in checks {
375 writeln!(
376 output,
377 "{} {}: {}",
378 check.status.label(),
379 check.title,
380 check.detail
381 )?;
382 if let Some(remediation) = &check.remediation {
383 writeln!(output, " remediation: {remediation}")?;
384 }
385 }
386 Ok(())
387}
388
389pub fn setup_instructions(platform: InstructionsPlatform) -> String {
390 match platform {
391 InstructionsPlatform::Linux => format!(
392 "# Mjolnir setup instructions for Linux\n\n\
393This page is self-contained. Follow this exact loop as the user who will run `mj`:\n\n\
3941. Run `mj doctor --json`.\n\
3952. Follow every `fixable` remediation from its JSON output.\n\
3963. Run `mj doctor --json` again. Repeat until no check is `fixable`.\n\
3974. Finish with `mj doctor --json --smoke` to verify every configured container\n\
398 image end to end, and resolve anything it reports as `fixable`.\n\n\
399For a coding-agent handoff, provide this entire instructions page together with\n\
400the latest `mj doctor --json` output.\n\n\
401## Local bare runtime\n\n\
402A local bare runtime runs the agent directly on this machine. It needs the\n\
403native `mj-worker` installed beside `mj`; the release installer and the npm\n\
404package include it. The worker installs the pinned harness version itself.\n\
405Codex and Claude need Node.js 22 or newer and npm on `PATH`. Kimi and Grok\n\
406need curl and Bash. Muse needs curl and tar. Mjolnir does not install these\n\
407prerequisites.\n\n\
408## Linux container-runtime postconditions\n\n{}\n\n{}",
409 crate::targets::PODMAN_DOCUMENTATION,
410 crate::targets::DOCKER_DOCUMENTATION
411 ),
412 InstructionsPlatform::Macos => format!(
413 "# Mjolnir setup instructions for macOS\n\n\
414Run these commands as the user who will run `mj`:\n\n\
4151. Install and sign in to a supported coding agent, then run `mj setup`.\n\
416 Setup discovers agent homes and local runtimes, offers AWS when the AWS CLI\n\
417 has credentials, and offers SSH hosts from `~/.ssh/config`. Review its\n\
418 proposed configuration and confirm writing `config.toml`. It runs smoke\n\
419 tests for newly added container targets and ends with a doctor report.\n\
4202. Run `mj doctor --json` and follow every `fixable` remediation. Repeat until\n\
421 no check is `fixable`. Review warnings for any target you intend to use;\n\
422 `unsupported` runtimes are unavailable, not ready.\n\
4233. If you intend to use containers, start the runtime and run\n\
424 `mj doctor --json --smoke`. Do this for Docker Desktop as well as Apple\n\
425 container. Resolve failures for the target you intend to use.\n\
4264. Run `mj`, then press n in the Sessions pane to start your first session.\n\
427 If no profile was discovered, open Settings (Ctrl-B then s by default)\n\
428 to add one first.\n\n\
429You can also configure profiles and targets in Settings instead of `mj setup`.\n\
430For a named instance, use the same `--instance <name>` on every command above.\n\n\
431For a coding-agent handoff, provide this entire instructions page together with\n\
432the latest `mj doctor --json` output.\n\n\
433## Local bare runtime\n\n\
434A local bare runtime runs the agent directly on this machine. It needs the\n\
435native `mj-worker` installed beside `mj`; the release installer, npm package,\n\
436and Homebrew formula include it (Homebrew keeps both binaries in libexec).\n\
437The worker installs the pinned harness version itself. Codex and Claude need\n\
438Node.js 22 or newer and npm on `PATH`. Kimi and Grok need curl and Bash.\n\
439Muse needs curl and tar. Mjolnir does not install these prerequisites.\n\n\
440## Docker Desktop\n\n\
441Start Docker Desktop and wait for its Linux daemon to be ready before setup\n\
442or the smoke test. `mj doctor --json --smoke` checks the container image and\n\
443a host directory attachment, including disposable container cleanup. Docker\n\
444Desktop's VM cannot overlay host directories, so attachments mount read-only\n\
445there. A connected daemon alone does not establish that these operations work.\n\n\
446## Apple container runtime\n\n\
447Mjolnir's Apple container target requires Apple silicon and macOS 26 or newer.\n\
448On an Intel Mac or an older macOS release, the target is unsupported; use the\n\
449local bare runtime, an SSH target, or an AWS target instead.\n\n\
450On a supported Mac, if `container` is absent, install only the official signed package:\n\n\
451<https://github.com/apple/container#initial-install>\n\n\
452Mjolnir never downloads or installs that package. If doctor reports a stopped\n\
453daemon, run exactly:\n\n```console\ncontainer system start\n```\n\n\
454Finish with the opt-in disposable runtime test in JSON mode:\n\n```console\nmj doctor --json --smoke\n```\n\n\
455Apple container is ready only when that smoke test creates a disposable\n\
456container, executes `true` in it, and removes it successfully. Use the image\n\
457configured by an `apple-container` target; without one, doctor uses\n\
458`{DEFAULT_CONTAINER_IMAGE}` for the smoke test.\n\n\
459## Shared Mjolnir prerequisites\n\n\
460`mj doctor --json` also checks the configuration, each configured harness home\n\
461and authentication marker, selected container worker binaries, and any relevant\n\
462Podman prerequisites. Resolve every `fixable` status before starting a session.\n"
463 ),
464 }
465}
466
467#[derive(Debug, Clone, Copy, PartialEq, Eq)]
475enum ConfigGap {
476 NewerVersion(u32),
478 Missing,
481 Unreadable,
483}
484
485impl ConfigGap {
486 fn awaited(self) -> &'static str {
489 match self {
490 ConfigGap::NewerVersion(_) => "this Mjolnir can read config.toml",
491 ConfigGap::Missing => "config.toml exists",
492 ConfigGap::Unreadable => "config.toml is valid",
493 }
494 }
495
496 fn remediation(self) -> &'static str {
502 match self {
503 ConfigGap::NewerVersion(_) => {
504 "Update Mjolnir to the build that wrote config.toml or newer, then rerun `mj doctor`."
505 }
506 ConfigGap::Missing => {
507 "Run `mj` and add an agent profile, or run `mj setup`; either one writes config.toml. Then rerun `mj doctor`."
508 }
509 ConfigGap::Unreadable => "Fix config.toml, then rerun `mj doctor`.",
510 }
511 }
512}
513
514type ConfigStatus<'a> = std::result::Result<&'a Config, ConfigGap>;
517
518fn newer_config_skip(id: &str, title: &str, version: u32) -> DoctorCheck {
523 DoctorCheck::unsupported(
524 id,
525 title,
526 format!(
527 "Skipped: config.toml was written by a newer Mjolnir (config version {version}; this build supports {}). Update Mjolnir to that build or newer.",
528 mj_core::config::CONFIG_VERSION
529 ),
530 )
531}
532
533fn configuration_checks(path: &Path) -> (std::result::Result<Config, ConfigGap>, Vec<DoctorCheck>) {
534 if !path.exists() {
535 return (
536 Err(ConfigGap::Missing),
537 vec![DoctorCheck::fixable(
538 "config",
539 "Mjolnir configuration",
540 format!("{} does not exist", path.display()),
541 format!(
542 "Open Mjolnir and press {} for Settings to add an agent profile.",
543 settings_key(None)
544 ),
545 )],
546 );
547 }
548 if let Some(found) = mj_core::config::newer_version_on_disk(path) {
552 return (
553 Err(ConfigGap::NewerVersion(found)),
554 vec![DoctorCheck::fixable(
555 "config",
556 "Mjolnir configuration",
557 format!(
558 "{} was written by a newer Mjolnir (config version {found}; this build supports {})",
559 path.display(),
560 mj_core::config::CONFIG_VERSION
561 ),
562 "Update Mjolnir to that build or newer. Do not lower the version value by hand or replace the file.",
563 )],
564 );
565 }
566 match Config::load_from(path) {
567 Ok(config) => {
568 let mut checks = vec![DoctorCheck::ready(
569 "config",
570 "Mjolnir configuration",
571 format!("{} is valid", path.display()),
572 )];
573 if config.enabled_profiles().next().is_none() {
577 checks.push(DoctorCheck::fixable(
578 "config.session-prerequisites",
579 "Session configuration",
580 "No agent profile is enabled, so no session can start. Local targets are supplied automatically.",
581 format!(
582 "Open Mjolnir and press {} for Settings to add or enable an agent profile.",
583 settings_key(Some(&config))
584 ),
585 ));
586 } else if config.bundles.is_empty() {
587 checks.push(DoctorCheck::ready(
588 "config.session-prerequisites",
589 "Session configuration",
590 "An agent profile is enabled. No project bundle is configured; sessions start from a project directory, and a bundle is only needed to start from a saved set of repositories.",
591 ));
592 } else {
593 checks.push(DoctorCheck::ready(
594 "config.session-prerequisites",
595 "Session configuration",
596 "At least one profile, bundle, and target are configured.",
597 ));
598 }
599 (Ok(config), checks)
600 }
601 Err(error) => (
602 Err(ConfigGap::Unreadable),
603 vec![DoctorCheck::fixable(
604 "config",
605 "Mjolnir configuration",
606 format!("{} is invalid: {error:#}", path.display()),
607 "Fix the reported TOML error in config.toml, or run `mj setup` to replace it.",
608 )],
609 ),
610 }
611}
612
613fn harness_checks(config: ConfigStatus<'_>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
614 let config = match config {
615 Ok(config) => config,
616 Err(ConfigGap::NewerVersion(version)) => {
617 return vec![newer_config_skip(
618 "harness.profiles",
619 "Harness profiles",
620 version,
621 )];
622 }
623 Err(gap @ (ConfigGap::Missing | ConfigGap::Unreadable)) => {
624 return vec![DoctorCheck::fixable(
625 "harness.profiles",
626 "Harness profiles",
627 format!("Harness homes cannot be checked until {}.", gap.awaited()),
628 gap.remediation(),
629 )];
630 }
631 };
632 if config.profiles.is_empty() {
633 return vec![DoctorCheck::fixable(
634 "harness.profiles",
635 "Harness profiles",
636 "No harness profiles are configured.",
637 format!(
638 "Open Mjolnir, press {} for Settings, and choose Agent Profiles to detect accounts or add a profile.",
639 settings_key(Some(config))
640 ),
641 )];
642 }
643 config
644 .profiles
645 .iter()
646 .map(|(id, profile)| {
647 let mut check = harness_profile_check(id, profile, executor);
648 check.detail = format!("{} {}", profile_summary(config, id, profile), check.detail);
649 check
650 })
651 .collect()
652}
653
654fn harness_profile_check(
656 id: &str,
657 profile: &HarnessProfile,
658 executor: &impl CommandExecutor,
659) -> DoctorCheck {
660 let title = format!("Harness profile {id}");
661 if !profile.enabled {
662 return DoctorCheck::ready(
663 format!("harness.{id}"),
664 title,
665 "Profile is disabled; home and authentication checks were skipped.",
666 );
667 }
668 if !profile.home.is_dir() {
669 return DoctorCheck::fixable(
670 format!("harness.{id}"),
671 title,
672 format!("{} does not exist", profile.home.display()),
673 format!(
674 "{} If this profile should use an existing installation, select its home in Setup.",
675 harness_login_remediation(id, profile)
676 ),
677 );
678 }
679 if !harness_is_authenticated_with_executor(profile, executor) {
680 return DoctorCheck::fixable(
681 format!("harness.{id}"),
682 title,
683 format!(
684 "No usable authentication was detected for {}",
685 profile.home.display()
686 ),
687 harness_login_remediation(id, profile),
688 );
689 }
690 DoctorCheck::ready(
691 format!("harness.{id}"),
692 title,
693 format!(
694 "{} is present and authentication is available",
695 profile.home.display()
696 ),
697 )
698}
699
700fn profile_summary(config: &Config, id: &str, profile: &HarnessProfile) -> String {
705 let delegation = if config
706 .subagents
707 .eligible_profiles
708 .get(id)
709 .copied()
710 .unwrap_or(false)
711 {
712 "any session's sub-agents may use it"
713 } else {
714 "only its own sessions' sub-agents may use it"
715 };
716 format!(
717 "{}; {}; {delegation}.",
718 profile.kind.display_name(),
719 profile_quota_source(profile)
720 )
721}
722
723fn profile_quota_source(profile: &HarnessProfile) -> String {
727 match profile.kind {
728 HarnessKind::Claude => "Claude subscription quota".to_owned(),
729 HarnessKind::Codex => match crate::quota::provider_credential(profile) {
730 Some(provider) if crate::zai_usage::serves_quota(&provider.host) => {
731 format!("quota from {}", provider.host)
732 }
733 Some(provider) => format!(
734 "pay-per-use through {}, counted as 100% left when choosing a sub-agent's profile",
735 provider.host
736 ),
737 None => match profile.codex_provider() {
738 Ok(None) => "ChatGPT subscription quota".to_owned(),
739 Ok(Some(provider)) => format!(
740 "no quota report, because custom provider {:?} has no API key in this profile's environment",
741 provider.id
742 ),
743 Err(error) => format!("its Codex config.toml could not be read ({error:#})"),
744 },
745 },
746 kind => format!("quota as {} reports it", kind.display_name()),
747 }
748}
749
750fn secret_checks(config: ConfigStatus<'_>, config_path: &Path) -> Vec<DoctorCheck> {
758 use mj_core::config::{secrets_path_beside, secrets_permission_problem};
759
760 let mut checks = Vec::new();
761 let secrets = secrets_path_beside(config_path);
762 if secrets.exists() {
763 checks.push(match secrets_permission_problem(&secrets) {
764 Ok(None) => DoctorCheck::ready(
765 "secrets.file",
766 "Secrets file",
767 format!("{} is readable only by its owner", secrets.display()),
768 ),
769 Ok(Some(problem)) => DoctorCheck::warning(
770 "secrets.file",
771 "Secrets file",
772 problem,
773 format!("Run `chmod 600 {}`.", secrets.display()),
774 ),
775 Err(error) => DoctorCheck::warning(
776 "secrets.file",
777 "Secrets file",
778 format!("{error:#}"),
779 "Make the file readable by the user running Mjolnir.",
780 ),
781 });
782 }
783 let Ok(config) = config else {
784 return checks;
785 };
786 let profiles = config
787 .profiles
788 .iter()
789 .map(|(id, profile)| (format!("profiles.{id}"), &profile.environment));
790 let targets = config.targets.iter().filter_map(|(id, target)| {
791 target
792 .container()
793 .map(|container| (format!("targets.{id}"), &container.environment))
794 });
795 for (owner, environment) in profiles.chain(targets) {
796 if let Some(check) = plain_text_credential_check(&owner, environment, &secrets) {
797 checks.push(check);
798 }
799 }
800 checks
801}
802
803fn plain_text_credential_check(
806 owner: &str,
807 environment: &mj_core::config::Environment,
808 secrets: &Path,
809) -> Option<DoctorCheck> {
810 let names = environment
811 .sources()
812 .iter()
813 .filter(|(name, value)| {
814 !value.is_reference() && mj_core::config::looks_like_credential(name)
815 })
816 .map(|(name, _)| name.as_str())
817 .collect::<Vec<_>>();
818 if names.is_empty() {
819 return None;
820 }
821 let example = names[0];
822 Some(DoctorCheck::warning(
823 format!("{owner}.secrets"),
824 "Credentials in config.toml",
825 format!(
826 "[{owner}.environment] holds {} as plain text; config.toml is copied into isolated instances and read as ordinary configuration",
827 names.join(", ")
828 ),
829 format!(
830 "Move each value into {} as `{example} = \"...\"` (mode 600) and refer to it as `{example} = {{ from_secret = \"{example}\" }}`, or use `{{ from_env = \"{example}\" }}` to read the daemon's environment.",
831 secrets.display()
832 ),
833 ))
834}
835
836fn subagent_eligibility_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
846 let Ok(config) = config else {
847 return Vec::new();
848 };
849 let mut checks = vec![subagent_policy_check(config)];
850 checks.extend(
851 config
852 .subagents
853 .eligible_profiles
854 .iter()
855 .filter(|(_, eligible)| **eligible)
856 .filter_map(|(id, _)| match config.profiles.get(id) {
857 Some(profile) if !profile.enabled => Some(DoctorCheck::warning(
858 format!("subagents.{id}"),
859 format!("Sub-agent profile {id}"),
860 format!(
861 "Profile {id:?} is listed in [subagents.eligible_profiles] but is disabled, so it is not offered for sub-agent use."
862 ),
863 format!(
864 "Re-enable profile {id:?}, or remove it from [subagents.eligible_profiles]."
865 ),
866 )),
867 _ => None,
868 }),
869 );
870 checks
871}
872
873fn subagent_policy_check(config: &Config) -> DoctorCheck {
877 let subagents = &config.subagents;
878 let eligible = subagents
879 .eligible_profiles
880 .iter()
881 .filter(|(_, eligible)| **eligible)
882 .map(|(id, _)| id.as_str())
883 .collect::<Vec<_>>();
884 let others = if eligible.is_empty() {
885 "no other profile".to_owned()
886 } else {
887 eligible.join(", ")
888 };
889 let detail = format!(
890 "Claude and Codex sessions may opt in, up to {} sub-agents at once per session. A session's sub-agents may use its own profile and: {others}.",
891 subagents.max_concurrent
892 );
893 DoctorCheck::ready("subagents.policy", "Sub-agent policy", detail)
894}
895
896fn harness_login_remediation(id: &str, profile: &HarnessProfile) -> String {
903 let (program, arguments) = match login_command(profile) {
904 Ok(command) => command,
905 Err(error) => return format!("{error} Check {}.", profile.home.display()),
909 };
910 format!(
911 "Run `mj login --profile {id}`; it runs `{program} {}` against {}.",
912 arguments.join(" "),
913 profile.home.display()
914 )
915}
916
917fn podman_checks(
922 config: ConfigStatus<'_>,
923 executor: &impl CommandExecutor,
924 smoke: bool,
925 platform: &ApplePlatform,
926) -> Vec<DoctorCheck> {
927 let effective = config.map(|config| config.clone().with_local_targets());
928 let effective = effective.as_ref().map_err(|gap| *gap);
929 let explicit = config.is_ok_and(|config| {
930 local_podman_targets(config)
931 .iter()
932 .any(|(id, _)| config.configures_target(id))
933 });
934 let preflight = builtin_target_availability(
935 podman_check(effective, executor, platform),
936 explicit,
937 "Podman",
938 "podman",
939 );
940 let preflight_passed = preflight.status == CheckStatus::Ready;
941 let mut checks = vec![preflight];
942 if preflight_passed {
943 checks.extend(
944 podman_image_checks(effective, executor, smoke)
945 .into_iter()
946 .map(|(id, check)| builtin_image_check(config, &id, check, smoke)),
947 );
948 }
949 checks
950}
951
952fn builtin_image_check(
956 config: ConfigStatus<'_>,
957 target_id: &str,
958 check: DoctorCheck,
959 smoke: bool,
960) -> DoctorCheck {
961 let explicit = config.is_ok_and(|config| config.configures_target(target_id));
962 if smoke || explicit || check.status != CheckStatus::Fixable {
965 return check;
966 }
967 DoctorCheck::warning(
968 check.id,
969 check.title,
970 format!(
971 "{} (built-in `{target_id}` target; the dashboard downloads its image when it starts)",
972 check.detail
973 ),
974 check.remediation.unwrap_or_default(),
975 )
976}
977
978fn builtin_target_availability(
987 check: DoctorCheck,
988 explicit: bool,
989 engine: &str,
990 target_id: &str,
991) -> DoctorCheck {
992 if explicit || check.status != CheckStatus::Fixable {
993 return check;
994 }
995 DoctorCheck::unsupported(
996 check.id,
997 check.title,
998 format!(
999 "{engine} is not available and no configured target uses it, so the built-in `{target_id}` target is marked unavailable, which does not fail the check: {}",
1000 check.detail
1001 ),
1002 )
1003}
1004
1005fn podman_check(
1006 config: ConfigStatus<'_>,
1007 executor: &impl CommandExecutor,
1008 platform: &ApplePlatform,
1009) -> DoctorCheck {
1010 let config = match config {
1011 Ok(config) => config,
1012 Err(ConfigGap::NewerVersion(version)) => {
1013 return newer_config_skip("runtime.podman", "Rootless Podman", version);
1014 }
1015 Err(gap @ (ConfigGap::Missing | ConfigGap::Unreadable)) => {
1016 return DoctorCheck::unsupported(
1017 "runtime.podman",
1018 "Rootless Podman",
1019 format!(
1020 "Podman prerequisites cannot be evaluated until {}.",
1021 gap.awaited()
1022 ),
1023 );
1024 }
1025 };
1026 if local_podman_targets(config).is_empty() {
1027 return DoctorCheck::unsupported(
1028 "runtime.podman",
1029 "Rootless Podman",
1030 "No local-podman target is configured.",
1031 );
1032 }
1033 local_podman_runtime_check(executor, platform)
1034}
1035
1036pub fn local_podman_runtime_check(
1043 executor: &impl CommandExecutor,
1044 platform: &ApplePlatform,
1045) -> DoctorCheck {
1046 if !matches!(platform, ApplePlatform::Linux) {
1047 return DoctorCheck::unsupported(
1048 "runtime.podman",
1049 "Rootless Podman",
1050 "Mjolnir's local Podman target requires a Linux host; Podman machine is not supported. Use localhost or an SSH target on a Linux host.",
1051 );
1052 }
1053 match verify_local_podman(executor) {
1054 Ok(preflight) => DoctorCheck::ready(
1055 "runtime.podman",
1056 "Rootless Podman",
1057 format!("Podman {} has a valid rootless UID map.", preflight.version),
1058 ),
1059 Err(error) => DoctorCheck::fixable(
1060 "runtime.podman",
1061 "Rootless Podman",
1062 podman_failure_detail(&error),
1063 podman_remediation(&error),
1064 ),
1065 }
1066}
1067
1068fn local_podman_targets(config: &Config) -> Vec<(&String, &ContainerTemplate)> {
1069 config
1070 .targets
1071 .iter()
1072 .filter_map(|(id, target)| match target {
1073 TargetTemplate::LocalPodman { container } => Some((id, container)),
1074 _ => None,
1075 })
1076 .collect()
1077}
1078
1079fn podman_image_checks(
1080 config: ConfigStatus<'_>,
1081 executor: &impl CommandExecutor,
1082 smoke: bool,
1083) -> Vec<(String, DoctorCheck)> {
1084 let Ok(config) = config else {
1085 return Vec::new();
1086 };
1087 local_podman_targets(config)
1088 .into_iter()
1089 .map(|(id, container)| {
1090 (
1091 id.clone(),
1092 podman_image_check(id, &container.image, executor, smoke),
1093 )
1094 })
1095 .collect()
1096}
1097
1098fn podman_image_check(
1099 id: &str,
1100 image: &str,
1101 executor: &impl CommandExecutor,
1102 smoke: bool,
1103) -> DoctorCheck {
1104 let check_id = format!("runtime.podman.image.{id}");
1105 let title = format!("Podman image for target {id}");
1106 if smoke {
1107 let target = RuntimeTargetTemplate::LocalPodman(RuntimeContainerTemplate {
1108 build_cache: None,
1109 image: image.to_owned(),
1110 pull_policy: Default::default(),
1111 extra_run_args: vec![],
1112 workspace_storage: Default::default(),
1113 });
1114 return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1115 Ok(()) => DoctorCheck::ready(
1116 check_id,
1117 title,
1118 format!("Disposable run/exec/remove smoke test passed for image {image}."),
1119 ),
1120 Err(error) => DoctorCheck::fixable(
1121 check_id,
1122 title,
1123 format!(
1124 "Disposable run/exec/remove smoke test failed for image {image}: {error:#}"
1125 ),
1126 "Fix the configured image or Podman runtime, then run `mj doctor --json --smoke` again.",
1127 ),
1128 };
1129 }
1130
1131 let command = CommandSpec::new("podman", ["image", "exists", image])
1132 .purpose("check Podman image presence");
1133 match executor.execute(&command) {
1134 Ok(output) if output.status == 0 => DoctorCheck::ready(
1135 check_id,
1136 title,
1137 format!("Image {image} is present in local Podman storage."),
1138 ),
1139 Ok(_) => DoctorCheck::fixable(
1140 check_id,
1141 title,
1142 format!("Image {image} is not present in local Podman storage."),
1143 missing_image_remediation(image),
1144 ),
1145 Err(error) => DoctorCheck::fixable(
1146 check_id,
1147 title,
1148 format!(
1149 "Could not check whether image {image} is present in local Podman storage: {error}"
1150 ),
1151 missing_image_remediation(image),
1152 ),
1153 }
1154}
1155
1156fn missing_image_remediation(image: &str) -> String {
1157 format!(
1158 "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."
1159 )
1160}
1161
1162fn docker_checks(
1164 config: ConfigStatus<'_>,
1165 executor: &impl CommandExecutor,
1166 smoke: bool,
1167) -> Vec<DoctorCheck> {
1168 let config = match config {
1169 Ok(config) => config,
1170 Err(ConfigGap::NewerVersion(version)) => {
1171 return vec![newer_config_skip("runtime.docker", "Docker", version)];
1172 }
1173 Err(gap @ (ConfigGap::Missing | ConfigGap::Unreadable)) => {
1174 return vec![DoctorCheck::unsupported(
1175 "runtime.docker",
1176 "Docker",
1177 format!(
1178 "Docker prerequisites cannot be evaluated until {}.",
1179 gap.awaited()
1180 ),
1181 )];
1182 }
1183 };
1184 let explicit = local_docker_targets(config)
1185 .iter()
1186 .any(|(id, _)| config.configures_target(id));
1187 let effective = config.clone().with_local_targets();
1188 let targets = local_docker_targets(&effective);
1189 if targets.is_empty() {
1190 return vec![DoctorCheck::unsupported(
1191 "runtime.docker",
1192 "Docker",
1193 "No local-docker target is configured.",
1194 )];
1195 }
1196 let preflight = builtin_target_availability(
1197 local_docker_runtime_check(executor),
1198 explicit,
1199 "Docker",
1200 "docker",
1201 );
1202 if preflight.status != CheckStatus::Ready {
1203 return vec![preflight];
1204 }
1205 let mut checks = vec![preflight];
1206 checks.extend(targets.into_iter().map(|(id, container)| {
1207 builtin_image_check(
1208 Ok(config),
1209 id,
1210 docker_image_check(id, &container.image, executor, smoke),
1211 smoke,
1212 )
1213 }));
1214 checks
1215}
1216
1217pub fn local_docker_runtime_check(executor: &impl CommandExecutor) -> DoctorCheck {
1218 match verify_local_docker(executor) {
1219 Ok(preflight) => DoctorCheck::ready(
1220 "runtime.docker",
1221 "Docker",
1222 format!(
1223 "Docker {} is connected to a Linux daemon.",
1224 preflight.version
1225 ),
1226 ),
1227 Err(error) => match error.downcast_ref::<DockerUnavailable>() {
1228 Some(problem) => DoctorCheck::fixable(
1229 "runtime.docker",
1230 "Docker",
1231 problem.to_string(),
1232 problem.remediation(),
1233 ),
1234 None => DoctorCheck::fixable(
1235 "runtime.docker",
1236 "Docker",
1237 format!("{error:#}"),
1238 "Install and start Docker, then make sure `docker info` succeeds as the user running mj.",
1239 ),
1240 },
1241 }
1242}
1243
1244fn local_docker_targets(config: &Config) -> Vec<(&String, &ContainerTemplate)> {
1245 config
1246 .targets
1247 .iter()
1248 .filter_map(|(id, target)| match target {
1249 TargetTemplate::LocalDocker { container } => Some((id, container)),
1250 _ => None,
1251 })
1252 .collect()
1253}
1254
1255fn docker_image_check(
1256 id: &str,
1257 image: &str,
1258 executor: &impl CommandExecutor,
1259 smoke: bool,
1260) -> DoctorCheck {
1261 let check_id = format!("runtime.docker.image.{id}");
1262 let title = format!("Docker image for target {id}");
1263 if smoke {
1264 let target = RuntimeTargetTemplate::LocalDocker(RuntimeContainerTemplate {
1265 build_cache: None,
1266 image: image.to_owned(),
1267 pull_policy: Default::default(),
1268 extra_run_args: vec![],
1269 workspace_storage: Default::default(),
1270 });
1271 return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1272 Ok(()) => DoctorCheck::ready(
1273 check_id,
1274 title,
1275 match local_docker_vm_share(executor) {
1276 Ok(Some(reason)) => format!(
1277 "Disposable run/exec/remove and read-only attachment smoke test passed for image {image}. {reason}, so attached directories mount read-only instead of copy-on-write."
1278 ),
1279 _ => format!(
1280 "Disposable run/exec/remove and OverlayFS attachment smoke test passed for image {image}."
1281 ),
1282 },
1283 ),
1284 Err(error) => DoctorCheck::fixable(
1285 check_id,
1286 title,
1287 format!(
1288 "Disposable run/exec/remove smoke test failed for image {image}: {error:#}"
1289 ),
1290 "Fix the configured image or Docker runtime, then run `mj doctor --json --smoke` again.",
1291 ),
1292 };
1293 }
1294 let command = CommandSpec::new("docker", ["image", "inspect", image])
1295 .purpose("check Docker image presence");
1296 match executor.execute(&command) {
1297 Ok(output) if output.status == 0 => DoctorCheck::ready(
1298 check_id,
1299 title,
1300 format!("Image {image} is present in Docker storage."),
1301 ),
1302 Ok(_) => DoctorCheck::fixable(
1303 check_id,
1304 title,
1305 format!("Image {image} is not present in Docker storage."),
1306 format!("Pull it with `docker pull {image}`, or run `mj doctor --json --smoke`."),
1307 ),
1308 Err(error) => DoctorCheck::fixable(
1309 check_id,
1310 title,
1311 format!("Could not inspect Docker image {image}: {error}"),
1312 format!("Make sure `docker info` succeeds, then run `docker pull {image}`."),
1313 ),
1314 }
1315}
1316
1317enum SshConnectivity {
1322 Reachable,
1323 Failed { detail: String, remediation: String },
1324}
1325
1326fn ssh_connectivity(ssh: &RuntimeSshTarget, executor: &impl CommandExecutor) -> SshConnectivity {
1331 let destination = &ssh.destination;
1332 let command = ssh_connectivity_probe(ssh);
1333 match executor.execute(&command) {
1334 Err(error) => SshConnectivity::Failed {
1335 detail: format!("Could not run `ssh {destination} true`: {error:#}"),
1336 remediation: ssh_launch_failure_remediation(&error, ssh),
1337 },
1338 Ok(output) if output.status != 0 => {
1339 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
1340 SshConnectivity::Failed {
1341 detail: format!("`ssh {destination} true` failed: {stderr}"),
1342 remediation: ssh_failure_remediation(&stderr, ssh),
1343 }
1344 }
1345 Ok(_) => SshConnectivity::Reachable,
1346 }
1347}
1348
1349const 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).";
1350
1351#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1357enum SshFailure {
1358 UntrustedHostKey,
1359 Unauthenticated,
1360 ClientMissing,
1361 Unreachable,
1363 Unrecognized,
1364}
1365
1366fn classify_ssh_stderr(stderr: &str) -> SshFailure {
1367 const UNTRUSTED_HOST_KEY: [&str; 3] = [
1368 "Host key verification failed",
1369 "No ECDSA host key is known",
1370 "REMOTE HOST IDENTIFICATION HAS CHANGED",
1371 ];
1372 const UNAUTHENTICATED: [&str; 4] = [
1373 "Permission denied",
1374 "Too many authentication failures",
1375 "no matching host key",
1376 "Authentication failed",
1377 ];
1378 const CLIENT_MISSING: [&str; 2] = ["ssh: command not found", "No such file or directory"];
1379 const UNREACHABLE: [&str; 3] = [
1380 "Connection timed out",
1381 "No route to host",
1382 "Network is unreachable",
1383 ];
1384
1385 let reported = |signatures: &[&str]| signatures.iter().any(|text| stderr.contains(text));
1386 if reported(&UNTRUSTED_HOST_KEY) {
1387 SshFailure::UntrustedHostKey
1388 } else if reported(&UNAUTHENTICATED) {
1389 SshFailure::Unauthenticated
1390 } else if reported(&CLIENT_MISSING) {
1391 SshFailure::ClientMissing
1392 } else if reported(&UNREACHABLE) {
1393 SshFailure::Unreachable
1394 } else {
1395 SshFailure::Unrecognized
1396 }
1397}
1398
1399fn ssh_launch_failure_remediation(error: &anyhow::Error, ssh: &RuntimeSshTarget) -> String {
1402 if error.downcast_ref::<CommandTimedOut>().is_some() {
1403 return ssh_unreachable_remediation(ssh);
1404 }
1405 let missing_binary = error.chain().any(|cause| {
1406 cause
1407 .downcast_ref::<std::io::Error>()
1408 .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
1409 });
1410 if missing_binary {
1411 return SSH_MISSING_REMEDIATION.to_owned();
1412 }
1413 format!(
1414 "Run `ssh {} true` by hand and resolve the error it reports: {error:#}",
1415 ssh.destination
1416 )
1417}
1418
1419fn ssh_unreachable_remediation(ssh: &RuntimeSshTarget) -> String {
1422 let host = ssh_host_only(&ssh.destination);
1423 format!(
1424 "Check that {host} is up and reachable from this machine: wake it, bring up the VPN, or refresh the cloud session that exposes it, then run `ssh {} true` by hand.",
1425 ssh.destination
1426 )
1427}
1428
1429fn ssh_failure_remediation(stderr: &str, ssh: &RuntimeSshTarget) -> String {
1431 let destination = &ssh.destination;
1432 match classify_ssh_stderr(stderr) {
1433 SshFailure::UntrustedHostKey => {
1434 let host = ssh_host_only(destination);
1435 format!(
1436 "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."
1437 )
1438 }
1439 SshFailure::Unauthenticated => match ssh_identity_file(ssh) {
1440 Some(identity) => format!(
1441 "Install your public key on the host with `ssh-copy-id -i {identity}.pub {destination}`."
1442 ),
1443 None => {
1444 format!("Install your public key on the host with `ssh-copy-id {destination}`.")
1445 }
1446 },
1447 SshFailure::ClientMissing => SSH_MISSING_REMEDIATION.to_owned(),
1448 SshFailure::Unreachable => ssh_unreachable_remediation(ssh),
1449 SshFailure::Unrecognized => {
1450 format!(
1451 "Run `ssh {destination} true` by hand and resolve the error it reports: {stderr}"
1452 )
1453 }
1454 }
1455}
1456
1457fn ssh_host_only(destination: &str) -> &str {
1459 destination
1460 .rsplit_once('@')
1461 .map_or(destination, |(_, host)| host)
1462}
1463
1464fn ssh_identity_file(ssh: &RuntimeSshTarget) -> Option<&str> {
1466 let position = ssh.ssh_args.iter().position(|arg| arg == "-i")?;
1467 ssh.ssh_args.get(position + 1).map(String::as_str)
1468}
1469
1470fn ssh_bare_checks(config: ConfigStatus<'_>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
1472 let Ok(config) = config else {
1473 return Vec::new();
1474 };
1475 config
1476 .targets
1477 .iter()
1478 .filter_map(|(id, target)| match target {
1479 TargetTemplate::SshBare { ssh, .. } => {
1480 Some(ssh_bare_check(id, &RuntimeSshTarget::from(ssh), executor))
1481 }
1482 _ => None,
1483 })
1484 .collect()
1485}
1486
1487fn ssh_bare_check(
1488 id: &str,
1489 ssh: &RuntimeSshTarget,
1490 executor: &impl CommandExecutor,
1491) -> DoctorCheck {
1492 let check_id = format!("runtime.ssh-bare.{id}");
1493 let title = format!("SSH access for target {id}");
1494 match ssh_connectivity(ssh, executor) {
1495 SshConnectivity::Reachable => DoctorCheck::ready(
1496 check_id,
1497 title,
1498 format!(
1499 "`ssh {} true` succeeds noninteractively from this host.",
1500 ssh.destination
1501 ),
1502 ),
1503 SshConnectivity::Failed {
1504 detail,
1505 remediation,
1506 } => DoctorCheck::fixable(check_id, title, detail, remediation),
1507 }
1508}
1509
1510fn ssh_podman_checks(
1513 config: ConfigStatus<'_>,
1514 executor: &impl CommandExecutor,
1515 smoke: bool,
1516) -> Vec<DoctorCheck> {
1517 let Ok(config) = config else {
1518 return Vec::new();
1519 };
1520 config
1521 .targets
1522 .iter()
1523 .flat_map(|(id, target)| match target {
1524 TargetTemplate::SshPodman { ssh, container, .. } => {
1525 let ssh = RuntimeSshTarget::from(ssh);
1526 let (check, reachable) =
1527 ssh_podman_check(id, &ssh, &container.image, executor, smoke);
1528 let mut checks = vec![check];
1529 if reachable {
1531 checks.push(ssh_podman_limits_check(id, &ssh, executor));
1532 }
1533 checks
1534 }
1535 _ => Vec::new(),
1536 })
1537 .collect()
1538}
1539
1540fn ssh_podman_check(
1543 id: &str,
1544 ssh: &RuntimeSshTarget,
1545 image: &str,
1546 executor: &impl CommandExecutor,
1547 smoke: bool,
1548) -> (DoctorCheck, bool) {
1549 let check_id = format!("runtime.ssh-podman.{id}");
1550 let title = format!("Remote Podman for target {id}");
1551 if let SshConnectivity::Failed {
1554 detail,
1555 remediation,
1556 } = ssh_connectivity(ssh, executor)
1557 {
1558 return (
1559 DoctorCheck::fixable(check_id, title, detail, remediation),
1560 false,
1561 );
1562 }
1563 (
1564 ssh_podman_runtime_check(check_id, title, ssh, image, executor, smoke),
1565 true,
1566 )
1567}
1568
1569fn ssh_podman_runtime_check(
1571 check_id: String,
1572 title: String,
1573 ssh: &RuntimeSshTarget,
1574 image: &str,
1575 executor: &impl CommandExecutor,
1576 smoke: bool,
1577) -> DoctorCheck {
1578 let destination = &ssh.destination;
1579 let preflight = match verify_ssh_podman(ssh, executor) {
1580 Ok(preflight) => preflight,
1581 Err(error) => {
1582 let detail = podman_failure_detail(&error);
1583 let remediation = match podman_remediation_match(&error) {
1584 Some(remediation) => {
1585 format!("On {destination}: {remediation} See {PODMAN_DOCUMENTATION_URL}.")
1586 }
1587 None => format!(
1588 "Verify `ssh {destination}` succeeds noninteractively from this host, then install rootless Podman 4.3 or newer there. See {PODMAN_DOCUMENTATION_URL}."
1589 ),
1590 };
1591 return DoctorCheck::fixable(check_id, title, detail, remediation);
1592 }
1593 };
1594 let linger_warning = preflight.warnings.first();
1595 if !smoke && let Some(warning) = linger_warning {
1596 return DoctorCheck::warning(
1597 check_id,
1598 title,
1599 format!(
1600 "Remote rootless Podman {} is available via {destination}, but {}",
1601 preflight.version, warning.detail
1602 ),
1603 &warning.remediation,
1604 );
1605 }
1606 if !smoke {
1607 return DoctorCheck::ready(
1608 check_id,
1609 title,
1610 format!(
1611 "Remote rootless Podman {} is available via {destination}. Run `mj doctor --json --smoke` to verify the image end to end.",
1612 preflight.version
1613 ),
1614 );
1615 }
1616
1617 let target = RuntimeTargetTemplate::SshPodman {
1618 ssh: ssh.clone(),
1619 container: RuntimeContainerTemplate {
1620 build_cache: None,
1621 image: image.to_owned(),
1622 pull_policy: Default::default(),
1623 extra_run_args: vec![],
1624 workspace_storage: Default::default(),
1625 },
1626 };
1627 match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1628 Ok(()) => match linger_warning {
1629 Some(warning) => DoctorCheck::warning(
1630 check_id,
1631 title,
1632 format!(
1633 "Disposable run/exec/remove smoke test passed for image {image} on {destination}, but {}",
1634 warning.detail
1635 ),
1636 &warning.remediation,
1637 ),
1638 None => DoctorCheck::ready(
1639 check_id,
1640 title,
1641 format!(
1642 "Disposable run/exec/remove smoke test passed for image {image} on {destination}."
1643 ),
1644 ),
1645 },
1646 Err(error) => DoctorCheck::fixable(
1647 check_id,
1648 title,
1649 format!(
1650 "Disposable run/exec/remove smoke test failed for image {image} on {destination}: {error:#}"
1651 ),
1652 format!(
1653 "Fix the configured image or Podman runtime on {destination}, then run `mj doctor --json --smoke` again."
1654 ),
1655 ),
1656 }
1657}
1658
1659const SSH_PODMAN_HOST_LIMITS_SCRIPT: &str = r#"
1668if [ -r /proc/sys/kernel/keys/maxkeys ]; then
1669 printf 'keys.max=%s\n' "$(cat /proc/sys/kernel/keys/maxkeys)"
1670fi
1671if [ -r /proc/key-users ]; then
1672 awk -v uid="$(id -u)" '
1673 { user = $1; sub(/:$/, "", user) }
1674 user == uid {
1675 split($4, quota, "/")
1676 printf "keys.used=%s\nkeys.quota=%s\n", quota[1], quota[2]
1677 }
1678 ' /proc/key-users
1679fi
1680unreadable=0
1681maxstartups=
1682# A drop-in directory that cannot be listed hides any override it holds.
1683if [ -d /etc/ssh/sshd_config.d ] && ! [ -r /etc/ssh/sshd_config.d ]; then
1684 unreadable=1
1685fi
1686for file in /etc/ssh/sshd_config /etc/ssh/sshd_config.d/*.conf; do
1687 [ -e "$file" ] || continue
1688 if [ -r "$file" ]; then
1689 match=$(grep -i '^[[:space:]]*maxstartups[[:space:]]' "$file" 2>/dev/null | tail -n 1)
1690 [ -n "$match" ] && maxstartups=$(printf '%s\n' "$match" | awk '{ print $2 }')
1691 else
1692 unreadable=1
1693 fi
1694done
1695[ -n "$maxstartups" ] && printf 'maxstartups=%s\n' "$maxstartups"
1696[ "$unreadable" = 1 ] && printf 'maxstartups.unreadable=1\n'
1697exit 0
1698"#;
1699
1700const KEYRING_PRESSURE_PERCENT: u64 = 80;
1704
1705#[derive(Debug, Default, PartialEq, Eq)]
1708struct HostLimits {
1709 keys_used: Option<u64>,
1710 keys_quota: Option<u64>,
1711 keys_max: Option<u64>,
1712 max_startups: Option<String>,
1713 max_startups_unreadable: bool,
1714}
1715
1716fn parse_host_limits(stdout: &[u8]) -> HostLimits {
1717 let text = String::from_utf8_lossy(stdout);
1718 let mut limits = HostLimits::default();
1719 for line in text.lines() {
1720 let Some((name, value)) = line.split_once('=') else {
1721 continue;
1722 };
1723 let value = value.trim();
1724 match name.trim() {
1725 "keys.used" => limits.keys_used = value.parse().ok(),
1726 "keys.quota" => limits.keys_quota = value.parse().ok(),
1727 "keys.max" => limits.keys_max = value.parse().ok(),
1728 "maxstartups" if !value.is_empty() => limits.max_startups = Some(value.to_owned()),
1729 "maxstartups.unreadable" => limits.max_startups_unreadable = value == "1",
1730 _ => {}
1731 }
1732 }
1733 limits
1734}
1735
1736impl HostLimits {
1737 fn is_empty(&self) -> bool {
1739 self.keys_used.is_none()
1740 && self.keys_quota.is_none()
1741 && self.keys_max.is_none()
1742 && self.max_startups.is_none()
1743 && !self.max_startups_unreadable
1744 }
1745
1746 fn keyring_is_under_pressure(&self) -> bool {
1747 match (self.keys_used, self.keys_quota) {
1748 (Some(used), Some(quota)) if quota > 0 => {
1749 used.saturating_mul(100) >= quota.saturating_mul(KEYRING_PRESSURE_PERCENT)
1750 }
1751 _ => false,
1752 }
1753 }
1754
1755 fn keyring_sentence(&self, destination: &str) -> String {
1756 match (self.keys_used, self.keys_quota) {
1757 (Some(used), Some(quota)) => {
1758 let system = match self.keys_max {
1759 Some(max) => format!(", and `kernel.keys.maxkeys` is {max}"),
1760 None => String::new(),
1761 };
1762 format!(
1763 "The login user on {destination} holds {used} of its {quota} kernel keyring quota{system}."
1764 )
1765 }
1766 _ => format!(
1767 "The kernel keyring quota for the login user on {destination} could not be read."
1768 ),
1769 }
1770 }
1771
1772 fn max_startups_sentence(&self) -> String {
1773 match (&self.max_startups, self.max_startups_unreadable) {
1774 (Some(value), _) => format!("sshd MaxStartups is {value}."),
1775 (None, true) => "sshd MaxStartups is not set in a readable sshd_config file, so sshd's default applies unless an unreadable drop-in overrides it.".to_owned(),
1776 (None, false) => {
1777 "sshd MaxStartups is not set in sshd_config, so sshd's default applies.".to_owned()
1778 }
1779 }
1780 }
1781}
1782
1783fn ssh_podman_limits_check(
1787 id: &str,
1788 ssh: &RuntimeSshTarget,
1789 executor: &impl CommandExecutor,
1790) -> DoctorCheck {
1791 let check_id = format!("runtime.ssh-podman.{id}.limits");
1792 let title = format!("Host limits for target {id}");
1793 let destination = &ssh.destination;
1794 let manual = || {
1795 format!(
1796 "Read them by hand on {destination}: `cat /proc/key-users /proc/sys/kernel/keys/maxkeys` and `grep -ri maxstartups /etc/ssh/sshd_config /etc/ssh/sshd_config.d`."
1797 )
1798 };
1799 let command = ssh_validation_command(
1800 ssh,
1801 vec![
1802 "sh".to_owned(),
1803 "-c".to_owned(),
1804 SSH_PODMAN_HOST_LIMITS_SCRIPT.to_owned(),
1805 ],
1806 "read ssh-podman host limits",
1807 );
1808 let limits = match executor.execute(&command) {
1809 Ok(output) if output.status == 0 => parse_host_limits(&output.stdout),
1810 Ok(output) => {
1811 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
1812 return DoctorCheck::warning(
1813 check_id,
1814 title,
1815 format!(
1816 "Could not read the kernel keyring quota or sshd MaxStartups from {destination}: {stderr}"
1817 ),
1818 manual(),
1819 );
1820 }
1821 Err(error) => {
1822 return DoctorCheck::warning(
1823 check_id,
1824 title,
1825 format!(
1826 "Could not read the kernel keyring quota or sshd MaxStartups from {destination}: {error}"
1827 ),
1828 manual(),
1829 );
1830 }
1831 };
1832 if limits.is_empty() {
1833 return DoctorCheck::warning(
1834 check_id,
1835 title,
1836 format!("{destination} reported no readable kernel keyring or sshd limits."),
1837 manual(),
1838 );
1839 }
1840 let detail = format!(
1841 "{} {}",
1842 limits.keyring_sentence(destination),
1843 limits.max_startups_sentence()
1844 );
1845 if limits.keyring_is_under_pressure() {
1846 return DoctorCheck::warning(
1847 check_id,
1848 title,
1849 format!(
1850 "{detail} Every container takes a session keyring, so `podman run` fails with `crun: create keyring` once the quota is gone."
1851 ),
1852 format!(
1853 "Raise `kernel.keys.maxkeys` and `kernel.keys.maxbytes` with sysctl on {destination}, and close finished sessions promptly."
1854 ),
1855 );
1856 }
1857 DoctorCheck::ready(check_id, title, detail)
1858}
1859
1860fn ssh_docker_checks(
1863 config: ConfigStatus<'_>,
1864 executor: &impl CommandExecutor,
1865 smoke: bool,
1866) -> Vec<DoctorCheck> {
1867 let Ok(config) = config else {
1868 return Vec::new();
1869 };
1870 config
1871 .targets
1872 .iter()
1873 .filter_map(|(id, target)| match target {
1874 TargetTemplate::SshDocker { ssh, container } => Some(ssh_docker_check(
1875 id,
1876 &RuntimeSshTarget::from(ssh),
1877 &container.image,
1878 executor,
1879 smoke,
1880 )),
1881 _ => None,
1882 })
1883 .collect()
1884}
1885
1886fn ssh_docker_check(
1887 id: &str,
1888 ssh: &RuntimeSshTarget,
1889 image: &str,
1890 executor: &impl CommandExecutor,
1891 smoke: bool,
1892) -> DoctorCheck {
1893 let check_id = format!("runtime.ssh-docker.{id}");
1894 let title = format!("Remote Docker for target {id}");
1895 let destination = &ssh.destination;
1896 if let SshConnectivity::Failed {
1897 detail,
1898 remediation,
1899 } = ssh_connectivity(ssh, executor)
1900 {
1901 return DoctorCheck::fixable(check_id, title, detail, remediation);
1902 }
1903
1904 let preflight = match verify_ssh_docker(ssh, executor) {
1905 Ok(preflight) => preflight,
1906 Err(error) => {
1907 let detail = format!("{error:#}");
1908 return DoctorCheck::fixable(
1909 check_id,
1910 title,
1911 detail,
1912 format!(
1913 "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."
1914 ),
1915 );
1916 }
1917 };
1918
1919 if smoke {
1920 let target = RuntimeTargetTemplate::SshDocker {
1921 ssh: ssh.clone(),
1922 container: RuntimeContainerTemplate {
1923 build_cache: None,
1924 image: image.to_owned(),
1925 pull_policy: Default::default(),
1926 extra_run_args: vec![],
1927 workspace_storage: Default::default(),
1928 },
1929 };
1930 return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1931 Ok(()) => DoctorCheck::ready(
1932 check_id,
1933 title,
1934 format!(
1935 "Remote Docker {} is available via {destination}; disposable run/exec/remove and remote OverlayFS attachment smoke test passed for image {image}.",
1936 preflight.version
1937 ),
1938 ),
1939 Err(error) => DoctorCheck::fixable(
1940 check_id,
1941 title,
1942 format!(
1943 "Disposable run/exec/remove smoke test failed for image {image} on {destination}: {error:#}"
1944 ),
1945 format!(
1946 "Fix the configured image or Docker runtime on {destination}, then run `mj doctor --json --smoke` again."
1947 ),
1948 ),
1949 };
1950 }
1951
1952 let image_command = ssh_command(
1953 ssh,
1954 [
1955 "docker".to_owned(),
1956 "image".to_owned(),
1957 "inspect".to_owned(),
1958 image.to_owned(),
1959 ]
1960 .to_vec(),
1961 )
1962 .purpose("check remote Docker image presence");
1963 match executor.execute(&image_command) {
1964 Ok(output) if output.status == 0 => DoctorCheck::ready(
1965 check_id,
1966 title,
1967 format!(
1968 "Remote Docker {} is available via {destination}; image {image} is present. Run `mj doctor --json --smoke` to verify remote OverlayFS attachments.",
1969 preflight.version
1970 ),
1971 ),
1972 Ok(output) => DoctorCheck::fixable(
1973 check_id,
1974 title,
1975 format!(
1976 "Image {image} is not present in remote Docker storage on {destination}: {}",
1977 String::from_utf8_lossy(&output.stderr).trim()
1978 ),
1979 format!(
1980 "Pull it on {destination} with `ssh {destination} docker pull {image}`, or run `mj doctor --json --smoke`."
1981 ),
1982 ),
1983 Err(error) => DoctorCheck::fixable(
1984 check_id,
1985 title,
1986 format!("Could not inspect remote Docker image {image} on {destination}: {error}"),
1987 format!(
1988 "Verify `ssh {destination} docker info` succeeds, then pull {image} on that host."
1989 ),
1990 ),
1991 }
1992}
1993
1994fn doctor_smoke_id() -> String {
1996 format!(
1997 "doctor-{}-{:x}",
1998 std::process::id(),
1999 SystemTime::now()
2000 .duration_since(UNIX_EPOCH)
2001 .unwrap_or_default()
2002 .as_nanos()
2003 )
2004}
2005
2006fn podman_remediation(error: &anyhow::Error) -> String {
2007 let fix = podman_remediation_match(error).unwrap_or(
2008 "Install Podman with `sudo apt update && sudo apt install -y podman uidmap` (Debian/Ubuntu) or `sudo dnf install -y podman shadow-utils` (Fedora).",
2009 );
2010 format!("{fix} See {PODMAN_DOCUMENTATION_URL}.")
2011}
2012
2013fn podman_failure_detail(error: &anyhow::Error) -> String {
2015 podman_probe_observation(error).map_or_else(|| format!("{error:#}"), str::to_owned)
2016}
2017
2018fn podman_remediation_match(error: &anyhow::Error) -> Option<&'static str> {
2025 failed_podman_postcondition(error).map(PodmanPostcondition::remediation)
2026}
2027
2028const AWS_CLI_INSTALL_URL: &str =
2029 "https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html";
2030
2031fn aws_checks(config: ConfigStatus<'_>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
2034 let Ok(config) = config else {
2035 return Vec::new();
2036 };
2037 config
2038 .targets
2039 .iter()
2040 .filter_map(|(id, target)| match target {
2041 TargetTemplate::AwsEc2 {
2042 aws_profile,
2043 region,
2044 launch_template,
2045 ..
2046 } => Some(aws_target_check(
2047 id,
2048 aws_profile.as_deref(),
2049 region,
2050 launch_template,
2051 executor,
2052 )),
2053 _ => None,
2054 })
2055 .collect()
2056}
2057
2058fn aws_global_args<'a>(profile: Option<&'a str>, region: &'a str) -> Vec<String> {
2061 vec![
2062 "--profile".to_owned(),
2063 profile.unwrap_or("default").to_owned(),
2064 "--region".to_owned(),
2065 region.to_owned(),
2066 ]
2067}
2068
2069fn aws_target_check(
2070 id: &str,
2071 profile: Option<&str>,
2072 region: &str,
2073 launch_template: &str,
2074 executor: &impl CommandExecutor,
2075) -> DoctorCheck {
2076 let check_id = format!("runtime.aws-ec2.{id}");
2077 let title = format!("AWS EC2 target {id}");
2078 let profile_label = profile.unwrap_or("default");
2079
2080 let version = CommandSpec::new("aws", ["--version"]).purpose("check AWS CLI installation");
2081 match executor.execute(&version) {
2082 Err(error) => {
2083 return DoctorCheck::fixable(
2084 check_id,
2085 title,
2086 format!("The `aws` command is not available: {error}"),
2087 format!("Install the AWS CLI and put `aws` on PATH: {AWS_CLI_INSTALL_URL}"),
2088 );
2089 }
2090 Ok(output) if output.status != 0 => {
2091 return DoctorCheck::fixable(
2092 check_id,
2093 title,
2094 format!(
2095 "`aws --version` failed: {}",
2096 String::from_utf8_lossy(&output.stderr).trim()
2097 ),
2098 format!("Reinstall the AWS CLI: {AWS_CLI_INSTALL_URL}"),
2099 );
2100 }
2101 Ok(_) => {}
2102 }
2103
2104 let mut identity_args = aws_global_args(profile, region);
2105 identity_args.extend(["sts".to_owned(), "get-caller-identity".to_owned()]);
2106 identity_args.extend(["--output".to_owned(), "json".to_owned()]);
2107 let identity =
2108 CommandSpec::new("aws", identity_args).purpose("check AWS credentials for a doctor target");
2109 match executor.execute(&identity) {
2110 Err(error) => {
2111 return DoctorCheck::fixable(
2112 check_id,
2113 title,
2114 format!("Could not run `aws sts get-caller-identity`: {error}"),
2115 format!(
2116 "Configure credentials with `aws configure --profile {profile_label}`, or sign in with `aws sso login --profile {profile_label}`."
2117 ),
2118 );
2119 }
2120 Ok(output) if output.status != 0 => {
2121 return DoctorCheck::fixable(
2122 check_id,
2123 title,
2124 format!(
2125 "AWS credentials for profile {profile_label} are not usable: {}",
2126 String::from_utf8_lossy(&output.stderr).trim()
2127 ),
2128 format!(
2129 "Configure credentials with `aws configure --profile {profile_label}`, or sign in with `aws sso login --profile {profile_label}`."
2130 ),
2131 );
2132 }
2133 Ok(_) => {}
2134 }
2135
2136 let by_id = launch_template.starts_with("lt-");
2139 let mut template_args = aws_global_args(profile, region);
2140 template_args.extend(["ec2".to_owned(), "describe-launch-templates".to_owned()]);
2141 template_args.extend([
2142 if by_id {
2143 "--launch-template-ids".to_owned()
2144 } else {
2145 "--launch-template-names".to_owned()
2146 },
2147 launch_template.to_owned(),
2148 ]);
2149 template_args.extend(["--output".to_owned(), "json".to_owned()]);
2150 let template =
2151 CommandSpec::new("aws", template_args).purpose("check the configured AWS launch template");
2152 let template_remediation = format!(
2153 "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."
2154 );
2155 match executor.execute(&template) {
2156 Err(error) => DoctorCheck::fixable(
2157 check_id,
2158 title,
2159 format!("Could not query launch template {launch_template}: {error}"),
2160 template_remediation,
2161 ),
2162 Ok(output) if output.status != 0 => DoctorCheck::fixable(
2163 check_id,
2164 title,
2165 format!(
2166 "Launch template {launch_template} was not found in {region}: {}",
2167 String::from_utf8_lossy(&output.stderr).trim()
2168 ),
2169 template_remediation,
2170 ),
2171 Ok(_) => DoctorCheck::ready(
2172 check_id,
2173 title,
2174 format!(
2175 "The AWS CLI is installed, profile {profile_label} has valid credentials, and launch template {launch_template} exists in {region}."
2176 ),
2177 ),
2178 }
2179}
2180
2181fn daemon_build_check() -> DoctorCheck {
2189 const ID: &str = "daemon.build";
2190 const TITLE: &str = "Daemon build";
2191 let Ok(metadata) = mj_client::daemon::read_metadata_any() else {
2192 return DoctorCheck::ready(
2193 ID,
2194 TITLE,
2195 "No Mjolnir daemon is running; the next command starts one from this build.",
2196 );
2197 };
2198 let pid = metadata.pid;
2199 match mj_client::executable::process_runs_this_executable(pid) {
2200 Ok(Some(true)) => DoctorCheck::ready(
2201 ID,
2202 TITLE,
2203 format!(
2204 "Daemon {pid} runs this build (version {}).",
2205 mj_client::build_identity::this_build().describe()
2206 ),
2207 ),
2208 Ok(Some(false)) => DoctorCheck::warning(
2209 ID,
2210 TITLE,
2211 format!(
2212 "{}. Two builds can report the same version, so the files are what tell them apart. Code rebuilt since that daemon started is not running.",
2213 mj_client::executable::describe_running_daemon_and_client_builds(
2214 pid,
2215 &metadata.build_version,
2216 ),
2217 ),
2218 "Run `mj daemon restart` from this build. It now fails rather than reporting success if another client's build wins.",
2219 ),
2220 Ok(None) => DoctorCheck::ready(
2221 ID,
2222 TITLE,
2223 format!(
2224 "Daemon {pid} is recorded but not running; the next command starts one from this build."
2225 ),
2226 ),
2227 Err(error) => DoctorCheck::warning(
2228 ID,
2229 TITLE,
2230 format!("Could not tell which build daemon {pid} runs: {error:#}"),
2231 "Run `mj daemon restart` from this build if rebuilt code is not taking effect.",
2232 ),
2233 }
2234}
2235
2236fn worker_freshness_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
2244 let mut checks = Vec::new();
2245 let daemon = mj_client::daemon::read_metadata_any()
2246 .ok()
2247 .filter(|metadata| mj_client::daemon::process_is_alive(metadata.pid));
2248 let pinned = pinned_worker_digests();
2249 let mut sources: Vec<(String, Result<WorkerBinaryAvailability>)> = vec![(
2250 "this host".to_owned(),
2251 crate::controller::native_worker_binary_prerequisite(),
2252 )];
2253 for arch in container_worker_architectures(config) {
2254 sources.push((
2255 format!("{arch} Linux targets"),
2256 worker_binary_prerequisite_for_arch(&arch),
2257 ));
2258 }
2259 for (label, availability) in sources {
2260 let id = format!("worker.freshness.{}", label.replace(' ', "-"));
2261 let title = format!("Worker binary for {label}");
2262 let path = match availability {
2263 Ok(WorkerBinaryAvailability::Local { path, .. }) => path,
2264 Ok(WorkerBinaryAvailability::Remote { .. }) => continue,
2267 Err(error) => {
2268 checks.push(DoctorCheck::unsupported(
2269 id,
2270 title,
2271 format!("No worker binary resolves for {label}: {error:#}"),
2272 ));
2273 continue;
2274 }
2275 };
2276 let digest = mj_core::worker_launch::worker_executable_digest(&path)
2277 .unwrap_or_else(|error| format!("unreadable ({error:#})"));
2278 let pinned_note = if pinned.is_empty() {
2279 "the daemon has pinned no worker".to_owned()
2280 } else if pinned.contains(&digest) {
2281 "this content is in the daemon's pinned worker cache".to_owned()
2282 } else {
2283 format!(
2284 "the pinned worker cache holds {} instead",
2285 pinned.join(", ")
2286 )
2287 };
2288 let detail = format!("{} has digest {digest}; {pinned_note}.", path.display());
2289 let Some(metadata) = daemon.as_ref() else {
2290 checks.push(DoctorCheck::ready(
2291 id,
2292 title,
2293 format!("{detail} No daemon is running, so the next session uses this file."),
2294 ));
2295 continue;
2296 };
2297 match worker_changed_since_daemon_start(&path, &metadata.started_at) {
2298 Ok(true) => checks.push(DoctorCheck::warning(
2299 id,
2300 title,
2301 format!("{detail} It was rebuilt after daemon {} started, which froze the copy it serves.", metadata.pid),
2302 "Run `mj daemon restart` so new sessions use the rebuilt worker. Sessions already running keep their worker until they are quiet enough to be upgraded.",
2303 )),
2304 Ok(false) => checks.push(DoctorCheck::ready(id, title, detail)),
2305 Err(error) => checks.push(DoctorCheck::warning(
2306 id,
2307 title,
2308 format!("{detail} Could not compare it with the daemon's start time: {error:#}"),
2309 "Run `mj daemon restart` if rebuilt worker code is not taking effect.",
2310 )),
2311 }
2312 }
2313 checks
2314}
2315
2316fn container_worker_architectures(config: ConfigStatus<'_>) -> Vec<String> {
2318 let Ok(config) = config else {
2319 return Vec::new();
2320 };
2321 let mut architectures = Vec::new();
2322 for target in config.targets.values() {
2323 let container = match target {
2324 TargetTemplate::LocalPodman { container }
2325 | TargetTemplate::LocalDocker { container }
2326 | TargetTemplate::AppleContainer { container }
2327 | TargetTemplate::SshPodman { container, .. }
2328 | TargetTemplate::SshDocker { container, .. } => container,
2329 _ => continue,
2330 };
2331 let arch = container
2332 .platform
2333 .as_deref()
2334 .and_then(|platform| platform.rsplit('/').next())
2335 .map_or_else(
2336 || std::env::consts::ARCH.to_owned(),
2337 normalized_worker_architecture,
2338 );
2339 if !architectures.contains(&arch) {
2340 architectures.push(arch);
2341 }
2342 }
2343 architectures
2344}
2345
2346fn normalized_worker_architecture(platform_arch: &str) -> String {
2349 crate::targets::normalize_architecture(platform_arch)
2350 .unwrap_or(platform_arch)
2351 .to_owned()
2352}
2353
2354fn pinned_worker_digests() -> Vec<String> {
2359 let root = mj_core::config::data_dir().join("workers").join("pinned");
2360 let Ok(entries) = std::fs::read_dir(root) else {
2361 return Vec::new();
2362 };
2363 let mut digests: Vec<String> = entries
2364 .flatten()
2365 .filter(|entry| entry.path().is_dir())
2366 .filter_map(|entry| entry.file_name().into_string().ok())
2367 .collect();
2368 digests.sort();
2369 digests
2370}
2371
2372fn worker_changed_since_daemon_start(path: &Path, started_at: &str) -> Result<bool> {
2377 let started: SystemTime = chrono::DateTime::parse_from_rfc3339(started_at)
2378 .map_err(|error| anyhow::anyhow!("parse daemon start time {started_at:?}: {error}"))?
2379 .into();
2380 let modified = std::fs::metadata(path)?.modified()?;
2381 Ok(modified > started)
2382}
2383
2384fn offered_targets<'a>(
2392 config: &Config,
2393 engine_checks: impl IntoIterator<Item = &'a DoctorCheck>,
2394) -> Config {
2395 let ready = engine_checks
2396 .into_iter()
2397 .filter(|check| check.status == CheckStatus::Ready)
2398 .map(|check| check.id.as_str())
2399 .collect::<std::collections::BTreeSet<_>>();
2400 let engine_ready = |target_id: &str| match target_id {
2401 "podman" => ready.contains("runtime.podman"),
2402 "docker" => ready.contains("runtime.docker"),
2403 "apple-container" => ready.contains("runtime.apple-container"),
2404 _ => true,
2405 };
2406 let mut offered = config.clone().with_local_targets();
2407 offered
2408 .targets
2409 .retain(|id, _| config.configures_target(id) || engine_ready(id));
2410 offered
2411}
2412
2413fn worker_binary_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
2414 let config = match config {
2415 Ok(config) => config,
2416 Err(ConfigGap::NewerVersion(version)) => {
2417 return vec![newer_config_skip(
2418 "worker.containers",
2419 "Container worker binary",
2420 version,
2421 )];
2422 }
2423 Err(gap @ (ConfigGap::Missing | ConfigGap::Unreadable)) => {
2424 return vec![DoctorCheck::fixable(
2425 "worker.containers",
2426 "Container worker binary",
2427 format!(
2428 "Worker availability cannot be checked until {}.",
2429 gap.awaited()
2430 ),
2431 gap.remediation(),
2432 )];
2433 }
2434 };
2435 let containers = config
2436 .targets
2437 .iter()
2438 .filter_map(|(id, target)| match target {
2439 TargetTemplate::LocalPodman { container }
2440 | TargetTemplate::LocalDocker { container }
2441 | TargetTemplate::AppleContainer { container } => Some((id, container, None)),
2442 TargetTemplate::SshPodman { container, .. } => {
2443 Some((id, container, Some("ssh-podman")))
2444 }
2445 TargetTemplate::SshDocker { container, .. } => {
2446 Some((id, container, Some("ssh-docker")))
2447 }
2448 _ => None,
2449 })
2450 .collect::<Vec<_>>();
2451 if containers.is_empty() {
2452 return vec![DoctorCheck::unsupported(
2453 "worker.containers",
2454 "Container worker binary",
2455 "No container target is configured.",
2456 )];
2457 }
2458 containers
2459 .into_iter()
2460 .map(|(id, container, remote_kind)| {
2461 if let Some(remote_kind) = remote_kind
2462 && container.platform.is_none()
2463 {
2464 return DoctorCheck::unsupported(
2467 format!("worker.{id}"),
2468 format!("Container worker binary for target {id}"),
2469 format!(
2470 "Set `platform` on this {remote_kind} target to check its worker binary; the remote architecture is unknown until provisioning."
2471 ),
2472 );
2473 }
2474 worker_binary_check(id, container)
2475 })
2476 .collect()
2477}
2478
2479fn ssh_bare_worker_checks(
2483 config: ConfigStatus<'_>,
2484 executor: &impl CommandExecutor,
2485) -> Vec<DoctorCheck> {
2486 let Ok(config) = config else {
2487 return Vec::new();
2488 };
2489 config
2490 .targets
2491 .iter()
2492 .filter(|(_, target)| matches!(target, TargetTemplate::SshBare { .. }))
2493 .map(|(id, target)| {
2494 let check_id = format!("worker.{id}");
2495 let title = format!("Worker binary for SSH target {id}");
2496 match ssh_worker_binary_prerequisite(target, executor) {
2497 None => DoctorCheck::unsupported(
2498 check_id,
2499 title,
2500 format!(
2501 "The platform of this host could not be read over SSH, so its worker binary is not checked; see `runtime.ssh-bare.{id}`."
2502 ),
2503 ),
2504 Some((triple, result)) => worker_source_check(check_id, title, &triple, result),
2505 }
2506 })
2507 .collect()
2508}
2509
2510fn worker_binary_check(id: &str, container: &ContainerTemplate) -> DoctorCheck {
2511 let title = format!("Container worker binary for target {id}");
2512 let arch = match container_architecture(container.platform.as_deref()) {
2513 Ok(arch) => arch,
2514 Err(reason) => {
2515 return DoctorCheck::unsupported(format!("worker.{id}"), title, reason);
2516 }
2517 };
2518 let triple = format!("{arch}-unknown-linux-musl");
2519 worker_source_check(
2520 format!("worker.{id}"),
2521 title,
2522 &triple,
2523 worker_binary_prerequisite_for_arch(arch),
2524 )
2525}
2526
2527fn worker_source_check(
2528 check_id: String,
2529 title: String,
2530 triple: &str,
2531 source: anyhow::Result<WorkerBinaryAvailability>,
2532) -> DoctorCheck {
2533 match source {
2534 Ok(WorkerBinaryAvailability::Local { path, source }) => DoctorCheck::ready(
2535 check_id,
2536 title,
2537 format!(
2538 "{triple} worker is available from {source}: {}",
2539 path.display()
2540 ),
2541 ),
2542 Ok(WorkerBinaryAvailability::Remote { url, .. }) => DoctorCheck::ready(
2543 check_id,
2544 title,
2545 format!("{triple} worker will be verified and downloaded from {url} when needed."),
2546 ),
2547 Err(error) => DoctorCheck::fixable(
2548 check_id,
2549 title,
2550 format!("No usable {triple} worker source: {error:#}"),
2551 format!(
2552 "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."
2553 ),
2554 ),
2555 }
2556}
2557
2558fn container_architecture(platform: Option<&str>) -> std::result::Result<&'static str, String> {
2559 let candidate = platform.unwrap_or(std::env::consts::ARCH);
2560 let candidate = candidate
2561 .split('/')
2562 .rev()
2563 .find(|part| matches!(*part, "x86_64" | "amd64" | "aarch64" | "arm64"))
2564 .unwrap_or(candidate);
2565 crate::targets::normalize_architecture(candidate).map_err(|_| format!(
2566 "Container architecture {candidate:?} is unsupported; Mjolnir supports x86_64 and aarch64 Linux workers."
2567 ))
2568}
2569
2570fn apple_container_image(config: ConfigStatus<'_>) -> String {
2571 config
2572 .ok()
2573 .and_then(|config| {
2574 config.targets.values().find_map(|target| match target {
2575 TargetTemplate::AppleContainer { container } => Some(container.image.clone()),
2576 _ => None,
2577 })
2578 })
2579 .unwrap_or_else(|| DEFAULT_CONTAINER_IMAGE.into())
2580}
2581
2582pub fn apple_container_runtime_check(
2584 platform: &ApplePlatform,
2585 executor: &impl CommandExecutor,
2586) -> DoctorCheck {
2587 match platform {
2588 ApplePlatform::Linux => {
2589 return DoctorCheck::unsupported(
2590 "runtime.apple-container",
2591 "Apple container runtime",
2592 "macOS only",
2593 );
2594 }
2595 ApplePlatform::Other(current) => {
2596 return DoctorCheck::unsupported(
2597 "runtime.apple-container",
2598 "Apple container runtime",
2599 format!("macOS only (current platform: {current})"),
2600 );
2601 }
2602 ApplePlatform::Macos {
2603 architecture,
2604 major_version,
2605 } if architecture != "aarch64" && architecture != "arm64" => {
2606 return DoctorCheck::unsupported(
2607 "runtime.apple-container",
2608 "Apple container runtime",
2609 "Apple container requires Apple silicon; Intel Macs are unsupported.",
2610 );
2611 }
2612 ApplePlatform::Macos { major_version, .. } if *major_version < 26 => {
2613 return DoctorCheck::unsupported(
2614 "runtime.apple-container",
2615 "Apple container runtime",
2616 format!("Apple container requires macOS 26 or newer (found {major_version})."),
2617 );
2618 }
2619 ApplePlatform::Macos { .. } => {}
2620 }
2621
2622 apple_container_daemon_check(executor)
2623}
2624
2625pub fn apple_container_check(
2626 platform: &ApplePlatform,
2627 executor: &impl CommandExecutor,
2628 smoke: bool,
2629 image: String,
2630) -> DoctorCheck {
2631 let daemon = apple_container_runtime_check(platform, executor);
2632 if daemon.status != CheckStatus::Ready {
2633 return daemon;
2634 }
2635
2636 if !smoke {
2637 return DoctorCheck::fixable(
2638 "runtime.apple-container",
2639 "Apple container runtime",
2640 "The daemon is running, but the required disposable smoke test was not requested.",
2641 "Run `mj doctor --json --smoke`.",
2642 );
2643 }
2644
2645 let target = RuntimeTargetTemplate::AppleContainer(RuntimeContainerTemplate {
2646 build_cache: None,
2647 image,
2648 pull_policy: Default::default(),
2649 extra_run_args: vec![],
2650 workspace_storage: Default::default(),
2651 });
2652 match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
2653 Ok(()) => DoctorCheck::ready(
2654 "runtime.apple-container",
2655 "Apple container runtime",
2656 "Installed, daemon running, and disposable run/exec/remove smoke test passed.",
2657 ),
2658 Err(error) => DoctorCheck::fixable(
2659 "runtime.apple-container",
2660 "Apple container runtime",
2661 format!("Disposable run/exec/remove smoke test failed: {error:#}"),
2662 "Fix the configured image or container runtime, then run `mj doctor --json --smoke` again.",
2663 ),
2664 }
2665}
2666
2667fn apple_container_daemon_check(executor: &impl CommandExecutor) -> DoctorCheck {
2672 let installed =
2673 CommandSpec::new("container", ["--version"]).purpose("check Apple container installation");
2674 match executor.execute(&installed) {
2675 Err(error) => {
2676 return DoctorCheck::fixable(
2677 "runtime.apple-container",
2678 "Apple container runtime",
2679 format!(
2680 "The `container` command is not available: {}",
2681 error.root_cause()
2682 ),
2683 format!("Install the official signed package: {APPLE_CONTAINER_INSTALL_URL}"),
2684 );
2685 }
2686 Ok(output) if output.status != 0 => {
2687 return DoctorCheck::fixable(
2688 "runtime.apple-container",
2689 "Apple container runtime",
2690 format!(
2691 "The installed `container --version` command failed: {}",
2692 String::from_utf8_lossy(&output.stderr).trim()
2693 ),
2694 format!("Reinstall the official signed package: {APPLE_CONTAINER_INSTALL_URL}"),
2695 );
2696 }
2697 Ok(_) => {}
2698 }
2699
2700 let status =
2701 CommandSpec::new("container", ["system", "status"]).purpose("check Apple container daemon");
2702 match executor.execute(&status) {
2703 Ok(output) if output.status == 0 => DoctorCheck::ready(
2704 "runtime.apple-container",
2705 "Apple container runtime",
2706 "Installed, and the Apple container daemon is running.",
2707 ),
2708 Ok(output) => DoctorCheck::fixable(
2709 "runtime.apple-container",
2710 "Apple container runtime",
2711 format!(
2712 "The Apple container daemon is stopped: {}",
2713 String::from_utf8_lossy(&output.stderr).trim()
2714 ),
2715 "Run `container system start`.",
2716 ),
2717 Err(error) => DoctorCheck::fixable(
2718 "runtime.apple-container",
2719 "Apple container runtime",
2720 format!("Could not query the Apple container daemon: {error}"),
2721 "Run `container system start`.",
2722 ),
2723 }
2724}
2725
2726pub fn current_apple_platform(executor: &impl CommandExecutor) -> ApplePlatform {
2727 if cfg!(target_os = "linux") {
2728 return ApplePlatform::Linux;
2729 }
2730 if !cfg!(target_os = "macos") {
2731 return ApplePlatform::Other(std::env::consts::OS.into());
2732 }
2733 let major_version = executor
2734 .execute(&CommandSpec::new("sw_vers", ["-productVersion"]).purpose("detect macOS version"))
2735 .ok()
2736 .filter(|output| output.status == 0)
2737 .and_then(|output| {
2738 String::from_utf8(output.stdout)
2739 .ok()
2740 .and_then(|value| value.trim().split('.').next()?.parse().ok())
2741 })
2742 .unwrap_or(0);
2743 ApplePlatform::Macos {
2744 architecture: std::env::consts::ARCH.into(),
2745 major_version,
2746 }
2747}
2748
2749#[cfg(test)]
2750mod tests;
2751
2752#[derive(Debug, Default, PartialEq, Eq)]
2754pub(crate) struct ReviewResidue {
2755 pub refs: Vec<String>,
2757 pub scratch_indexes: Vec<PathBuf>,
2759}
2760
2761impl ReviewResidue {
2762 fn is_empty(&self) -> bool {
2763 self.refs.is_empty() && self.scratch_indexes.is_empty()
2764 }
2765}
2766
2767pub(crate) fn review_residue(repository: &Path) -> ReviewResidue {
2774 let mut residue = ReviewResidue::default();
2775 let git_dir = repository.join(".git");
2776 if !git_dir.exists() {
2777 return residue;
2778 }
2779 for reference in ["review-baseline", "review-capture"] {
2780 if git_dir.join("refs/hel").join(reference).is_file() {
2781 residue.refs.push(format!("refs/hel/{reference}"));
2782 }
2783 }
2784 if let Ok(packed) = std::fs::read_to_string(git_dir.join("packed-refs")) {
2786 for line in packed.lines() {
2787 if let Some((_, reference)) = line.split_once(' ')
2788 && reference.starts_with("refs/hel/")
2789 && !residue.refs.iter().any(|known| known == reference)
2790 {
2791 residue.refs.push(reference.to_owned());
2792 }
2793 }
2794 }
2795 if let Ok(entries) = std::fs::read_dir(&git_dir) {
2796 for entry in entries.filter_map(Result::ok) {
2797 if entry
2798 .file_name()
2799 .to_str()
2800 .is_some_and(|name| name.starts_with("hel-review-index-"))
2801 {
2802 residue.scratch_indexes.push(entry.path());
2803 }
2804 }
2805 }
2806 residue.refs.sort();
2807 residue.scratch_indexes.sort();
2808 residue
2809}
2810
2811pub(crate) fn review_residue_repositories(
2822 configured: impl IntoIterator<Item = PathBuf>,
2823 sessions: &[&mj_core::state::SessionRecord],
2824) -> Vec<PathBuf> {
2825 use mj_core::state::ManagedCheckoutKind;
2826
2827 let managed_roots = sessions
2828 .iter()
2829 .filter_map(|session| session.managed_worktree.as_ref())
2830 .map(|worktree| worktree.worktree_root.as_path())
2831 .collect::<Vec<_>>();
2832 let mut in_use = Vec::new();
2833 for session in sessions.iter().filter(|session| session.state.is_active()) {
2834 if let Some(directory) = &session.project_directory {
2835 in_use.push(directory.as_path());
2836 }
2837 if let Some(worktree) = &session.managed_worktree
2838 && worktree.kind == ManagedCheckoutKind::Worktree
2839 {
2840 in_use.push(worktree.source_repository.as_path());
2841 }
2842 }
2843 let mut repositories = configured
2844 .into_iter()
2845 .chain(
2846 sessions
2847 .iter()
2848 .filter_map(|session| session.project_directory.clone()),
2849 )
2850 .filter(|repository| {
2851 !is_inside_managed_checkout(repository)
2852 && !managed_roots
2853 .iter()
2854 .any(|root| repository.starts_with(root))
2855 && !in_use.iter().any(|directory| repository == directory)
2856 })
2857 .collect::<Vec<_>>();
2858 repositories.sort();
2859 repositories.dedup();
2860 repositories
2861}
2862
2863fn is_inside_managed_checkout(path: &Path) -> bool {
2866 let components = path
2867 .components()
2868 .map(|component| component.as_os_str())
2869 .collect::<Vec<_>>();
2870 components
2871 .windows(3)
2872 .any(|window| window[0] == ".mj" && (window[1] == "clones" || window[1] == "worktrees"))
2873}
2874
2875fn review_residue_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
2881 let Ok(config) = config else {
2882 return Vec::new();
2883 };
2884 let configured = config
2885 .bundles
2886 .values()
2887 .flat_map(|bundle| bundle.repositories.iter())
2888 .filter_map(|repository| repository.local.clone());
2889 let state = crate::database::load_state().ok();
2892 let sessions = state
2893 .as_ref()
2894 .map(|state| state.sessions.values().collect::<Vec<_>>())
2895 .unwrap_or_default();
2896 let repositories = review_residue_repositories(configured, &sessions);
2897 if repositories.is_empty() {
2898 return Vec::new();
2899 }
2900 let found = repositories
2901 .into_iter()
2902 .map(|repository| {
2903 let residue = review_residue(&repository);
2904 (repository, residue)
2905 })
2906 .filter(|(_, residue)| !residue.is_empty())
2907 .collect::<Vec<_>>();
2908 if found.is_empty() {
2909 return vec![DoctorCheck::ready(
2910 "review.residue",
2911 "Review leftovers in your repositories",
2912 "No Mjolnir refs or scratch index files were found in the configured repositories.",
2913 )];
2914 }
2915 let detail = found
2916 .iter()
2917 .map(|(repository, residue)| {
2918 let mut parts = Vec::new();
2919 if !residue.refs.is_empty() {
2920 parts.push(residue.refs.join(", "));
2921 }
2922 if !residue.scratch_indexes.is_empty() {
2923 parts.push(mj_core::text::counted(
2924 residue.scratch_indexes.len(),
2925 "leftover scratch index file",
2926 "leftover scratch index files",
2927 ));
2928 }
2929 format!("{}: {}", repository.display(), parts.join("; "))
2930 })
2931 .collect::<Vec<_>>()
2932 .join(". ");
2933 let commands = found
2934 .iter()
2935 .flat_map(|(repository, residue)| {
2936 let repository = repository.display().to_string();
2937 let mut commands = residue
2938 .refs
2939 .iter()
2940 .map(|reference| format!("git -C {repository} update-ref -d {reference}"))
2941 .collect::<Vec<_>>();
2942 commands.extend(
2943 residue
2944 .scratch_indexes
2945 .iter()
2946 .map(|index| format!("rm -f {}", index.display())),
2947 );
2948 commands.push(format!("git -C {repository} gc --prune=now"));
2949 commands
2950 })
2951 .collect::<Vec<_>>()
2952 .join("\n");
2953 vec![DoctorCheck::fixable(
2954 "review.residue",
2955 "Review leftovers in your repositories",
2956 format!("Mjolnir left these in repositories it does not own: {detail}."),
2957 format!("Remove them yourself when you are ready:\n{commands}"),
2958 )]
2959}
2960
2961fn bifrost_check(config: ConfigStatus<'_>, executor: &impl CommandExecutor) -> Option<DoctorCheck> {
2969 let config = config.ok()?;
2970 if !mj_core::review::settings::can_review(config) {
2971 return None;
2972 }
2973 Some(bifrost_check_for(
2974 &mj_review::bifrost::bifrost_binary(),
2975 executor,
2976 ))
2977}
2978
2979fn bifrost_check_for(binary: &Path, executor: &impl CommandExecutor) -> DoctorCheck {
2980 const ID: &str = "review.bifrost";
2981 const TITLE: &str = "Bifrost for turn review";
2982 let required = mj_review::bifrost::REQUIRED_BIFROST_VERSION;
2983 let shown = binary.display();
2984 let remediation = format!(
2985 "Install Bifrost {required} or later (`cargo install brokk-bifrost@{required} --locked --bin bifrost`), or start the daemon with {env} set to a newer binary (`{env}=/path/to/bifrost mj daemon restart`), then rerun `mj doctor`.",
2986 env = mj_review::bifrost::BIFROST_BIN_ENV,
2987 );
2988 let command = CommandSpec::new(binary.display().to_string(), ["--version"])
2989 .purpose("read the Bifrost version used by turn review");
2990 let output = match executor.execute(&command) {
2991 Ok(output) if output.status == 0 => output,
2992 Ok(output) => {
2993 return DoctorCheck::warning(
2994 ID,
2995 TITLE,
2996 format!("`{shown} --version` exited with status {}.", output.status),
2997 remediation,
2998 );
2999 }
3000 Err(error) => {
3001 return DoctorCheck::warning(
3002 ID,
3003 TITLE,
3004 format!("Could not run `{shown}` for the turn review: {error}"),
3005 remediation,
3006 );
3007 }
3008 };
3009 let text = String::from_utf8_lossy(&output.stdout);
3010 let first_line = text.lines().next().unwrap_or_default().trim();
3011 let version = first_line
3012 .split_whitespace()
3013 .next_back()
3014 .and_then(|token| semver::Version::parse(token).ok());
3015 let minimum = semver::Version::parse(required).expect("the required Bifrost version is semver");
3016 match version {
3017 Some(version) if version >= minimum => DoctorCheck::ready(
3018 ID,
3019 TITLE,
3020 format!("`{shown}` is Bifrost {version}; turn review needs {required} or later."),
3021 ),
3022 Some(version) => DoctorCheck::warning(
3023 ID,
3024 TITLE,
3025 format!(
3026 "`{shown}` is Bifrost {version}, older than the {required} turn review needs, so every review would fail."
3027 ),
3028 remediation,
3029 ),
3030 None => DoctorCheck::warning(
3031 ID,
3032 TITLE,
3033 format!("`{shown} --version` printed {first_line:?}, which does not name a version."),
3034 remediation,
3035 ),
3036 }
3037}