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 let Err(error) = profile.ensure_ready(id) {
680 return DoctorCheck::fixable(
681 format!("harness.{id}"),
682 title,
683 format!("{error:#}"),
684 "Make the change named above. Sessions on this profile are refused until then; the rest of Mjolnir keeps working.",
685 );
686 }
687 if !harness_is_authenticated_with_executor(profile, executor) {
688 return DoctorCheck::fixable(
689 format!("harness.{id}"),
690 title,
691 format!(
692 "No usable authentication was detected for {}",
693 profile.home.display()
694 ),
695 harness_login_remediation(id, profile),
696 );
697 }
698 DoctorCheck::ready(
699 format!("harness.{id}"),
700 title,
701 format!(
702 "{} is present and authentication is available",
703 profile.home.display()
704 ),
705 )
706}
707
708fn profile_summary(config: &Config, id: &str, profile: &HarnessProfile) -> String {
713 let delegation = if config
714 .subagents
715 .eligible_profiles
716 .get(id)
717 .copied()
718 .unwrap_or(false)
719 {
720 "any session's sub-agents may use it"
721 } else {
722 "only its own sessions' sub-agents may use it"
723 };
724 format!(
725 "{}; {}; {delegation}.",
726 profile.kind.display_name(),
727 profile_quota_source(profile)
728 )
729}
730
731fn profile_quota_source(profile: &HarnessProfile) -> String {
735 match profile.kind {
736 HarnessKind::Claude => "Claude subscription quota".to_owned(),
737 HarnessKind::Codex => match crate::quota::provider_credential(profile) {
738 Some(provider) if crate::zai_usage::serves_quota(&provider.host) => {
739 format!("quota from {}", provider.host)
740 }
741 Some(provider) => format!(
742 "pay-per-use through {}, counted as 100% left when choosing a sub-agent's profile",
743 provider.host
744 ),
745 None => match profile.codex_provider() {
746 Ok(None) => "ChatGPT subscription quota".to_owned(),
747 Ok(Some(provider)) => format!(
748 "no quota report, because custom provider {:?} has no API key in this profile's environment",
749 provider.id
750 ),
751 Err(error) => format!("its Codex config.toml could not be read ({error:#})"),
752 },
753 },
754 kind => format!("quota as {} reports it", kind.display_name()),
755 }
756}
757
758fn secret_checks(config: ConfigStatus<'_>, config_path: &Path) -> Vec<DoctorCheck> {
766 use mj_core::config::{secrets_path_beside, secrets_permission_problem};
767
768 let mut checks = Vec::new();
769 let secrets = secrets_path_beside(config_path);
770 if secrets.exists() {
771 checks.push(match secrets_permission_problem(&secrets) {
772 Ok(None) => DoctorCheck::ready(
773 "secrets.file",
774 "Secrets file",
775 format!("{} is readable only by its owner", secrets.display()),
776 ),
777 Ok(Some(problem)) => DoctorCheck::warning(
778 "secrets.file",
779 "Secrets file",
780 problem,
781 format!("Run `chmod 600 {}`.", secrets.display()),
782 ),
783 Err(error) => DoctorCheck::warning(
784 "secrets.file",
785 "Secrets file",
786 format!("{error:#}"),
787 "Make the file readable by the user running Mjolnir.",
788 ),
789 });
790 }
791 let Ok(config) = config else {
792 return checks;
793 };
794 for (id, target) in &config.targets {
797 if let Err(error) = target.ensure_ready(id) {
798 checks.push(DoctorCheck::fixable(
799 format!("targets.{id}.environment"),
800 format!("Target {id} environment"),
801 format!("{error:#}"),
802 "Make the change named above. Sessions on this target are refused until then.",
803 ));
804 }
805 }
806 let profiles = config
807 .profiles
808 .iter()
809 .map(|(id, profile)| (format!("profiles.{id}"), &profile.environment));
810 let targets = config.targets.iter().filter_map(|(id, target)| {
811 target
812 .container()
813 .map(|container| (format!("targets.{id}"), &container.environment))
814 });
815 for (owner, environment) in profiles.chain(targets) {
816 if let Some(check) = plain_text_credential_check(&owner, environment, &secrets) {
817 checks.push(check);
818 }
819 }
820 checks
821}
822
823fn plain_text_credential_check(
826 owner: &str,
827 environment: &mj_core::config::Environment,
828 secrets: &Path,
829) -> Option<DoctorCheck> {
830 let names = environment
831 .sources()
832 .iter()
833 .filter(|(name, value)| {
834 !value.is_reference() && mj_core::config::looks_like_credential(name)
835 })
836 .map(|(name, _)| name.as_str())
837 .collect::<Vec<_>>();
838 if names.is_empty() {
839 return None;
840 }
841 let example = names[0];
842 Some(DoctorCheck::warning(
843 format!("{owner}.secrets"),
844 "Credentials in config.toml",
845 format!(
846 "[{owner}.environment] holds {} as plain text; config.toml is copied into isolated instances and read as ordinary configuration",
847 names.join(", ")
848 ),
849 format!(
850 "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.",
851 secrets.display()
852 ),
853 ))
854}
855
856fn subagent_eligibility_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
866 let Ok(config) = config else {
867 return Vec::new();
868 };
869 let mut checks = vec![subagent_policy_check(config)];
870 checks.extend(
871 config
872 .subagents
873 .eligible_profiles
874 .iter()
875 .filter(|(_, eligible)| **eligible)
876 .filter_map(|(id, _)| match config.profiles.get(id) {
877 Some(profile) if !profile.enabled => Some(DoctorCheck::warning(
878 format!("subagents.{id}"),
879 format!("Sub-agent profile {id}"),
880 format!(
881 "Profile {id:?} is listed in [subagents.eligible_profiles] but is disabled, so it is not offered for sub-agent use."
882 ),
883 format!(
884 "Re-enable profile {id:?}, or remove it from [subagents.eligible_profiles]."
885 ),
886 )),
887 _ => None,
888 }),
889 );
890 checks
891}
892
893fn subagent_policy_check(config: &Config) -> DoctorCheck {
897 let subagents = &config.subagents;
898 let eligible = subagents
899 .eligible_profiles
900 .iter()
901 .filter(|(_, eligible)| **eligible)
902 .map(|(id, _)| id.as_str())
903 .collect::<Vec<_>>();
904 let others = if eligible.is_empty() {
905 "no other profile".to_owned()
906 } else {
907 eligible.join(", ")
908 };
909 let detail = format!(
910 "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}.",
911 subagents.max_concurrent
912 );
913 DoctorCheck::ready("subagents.policy", "Sub-agent policy", detail)
914}
915
916fn harness_login_remediation(id: &str, profile: &HarnessProfile) -> String {
923 let (program, arguments) = match login_command(profile) {
924 Ok(command) => command,
925 Err(error) => return format!("{error} Check {}.", profile.home.display()),
929 };
930 format!(
931 "Run `mj login --profile {id}`; it runs `{program} {}` against {}.",
932 arguments.join(" "),
933 profile.home.display()
934 )
935}
936
937fn podman_checks(
942 config: ConfigStatus<'_>,
943 executor: &impl CommandExecutor,
944 smoke: bool,
945 platform: &ApplePlatform,
946) -> Vec<DoctorCheck> {
947 let effective = config.map(|config| config.clone().with_local_targets());
948 let effective = effective.as_ref().map_err(|gap| *gap);
949 let explicit = config.is_ok_and(|config| {
950 local_podman_targets(config)
951 .iter()
952 .any(|(id, _)| config.configures_target(id))
953 });
954 let preflight = builtin_target_availability(
955 podman_check(effective, executor, platform),
956 explicit,
957 "Podman",
958 "podman",
959 );
960 let preflight_passed = preflight.status == CheckStatus::Ready;
961 let mut checks = vec![preflight];
962 if preflight_passed {
963 checks.extend(
964 podman_image_checks(effective, executor, smoke)
965 .into_iter()
966 .map(|(id, check)| builtin_image_check(config, &id, check, smoke)),
967 );
968 }
969 checks
970}
971
972fn builtin_image_check(
976 config: ConfigStatus<'_>,
977 target_id: &str,
978 check: DoctorCheck,
979 smoke: bool,
980) -> DoctorCheck {
981 let explicit = config.is_ok_and(|config| config.configures_target(target_id));
982 if smoke || explicit || check.status != CheckStatus::Fixable {
985 return check;
986 }
987 DoctorCheck::warning(
988 check.id,
989 check.title,
990 format!(
991 "{} (built-in `{target_id}` target; the dashboard downloads its image when it starts)",
992 check.detail
993 ),
994 check.remediation.unwrap_or_default(),
995 )
996}
997
998fn builtin_target_availability(
1007 check: DoctorCheck,
1008 explicit: bool,
1009 engine: &str,
1010 target_id: &str,
1011) -> DoctorCheck {
1012 if explicit || check.status != CheckStatus::Fixable {
1013 return check;
1014 }
1015 DoctorCheck::unsupported(
1016 check.id,
1017 check.title,
1018 format!(
1019 "{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: {}",
1020 check.detail
1021 ),
1022 )
1023}
1024
1025fn podman_check(
1026 config: ConfigStatus<'_>,
1027 executor: &impl CommandExecutor,
1028 platform: &ApplePlatform,
1029) -> DoctorCheck {
1030 let config = match config {
1031 Ok(config) => config,
1032 Err(ConfigGap::NewerVersion(version)) => {
1033 return newer_config_skip("runtime.podman", "Rootless Podman", version);
1034 }
1035 Err(gap @ (ConfigGap::Missing | ConfigGap::Unreadable)) => {
1036 return DoctorCheck::unsupported(
1037 "runtime.podman",
1038 "Rootless Podman",
1039 format!(
1040 "Podman prerequisites cannot be evaluated until {}.",
1041 gap.awaited()
1042 ),
1043 );
1044 }
1045 };
1046 if local_podman_targets(config).is_empty() {
1047 return DoctorCheck::unsupported(
1048 "runtime.podman",
1049 "Rootless Podman",
1050 "No local-podman target is configured.",
1051 );
1052 }
1053 local_podman_runtime_check(executor, platform)
1054}
1055
1056pub fn local_podman_runtime_check(
1063 executor: &impl CommandExecutor,
1064 platform: &ApplePlatform,
1065) -> DoctorCheck {
1066 if !matches!(platform, ApplePlatform::Linux) {
1067 return DoctorCheck::unsupported(
1068 "runtime.podman",
1069 "Rootless Podman",
1070 "Mjolnir's local Podman target requires a Linux host; Podman machine is not supported. Use localhost or an SSH target on a Linux host.",
1071 );
1072 }
1073 match verify_local_podman(executor) {
1074 Ok(preflight) => DoctorCheck::ready(
1075 "runtime.podman",
1076 "Rootless Podman",
1077 format!("Podman {} has a valid rootless UID map.", preflight.version),
1078 ),
1079 Err(error) => DoctorCheck::fixable(
1080 "runtime.podman",
1081 "Rootless Podman",
1082 podman_failure_detail(&error),
1083 podman_remediation(&error),
1084 ),
1085 }
1086}
1087
1088fn local_podman_targets(config: &Config) -> Vec<(&String, &ContainerTemplate)> {
1089 config
1090 .targets
1091 .iter()
1092 .filter_map(|(id, target)| match target {
1093 TargetTemplate::LocalPodman { container } => Some((id, container)),
1094 _ => None,
1095 })
1096 .collect()
1097}
1098
1099fn podman_image_checks(
1100 config: ConfigStatus<'_>,
1101 executor: &impl CommandExecutor,
1102 smoke: bool,
1103) -> Vec<(String, DoctorCheck)> {
1104 let Ok(config) = config else {
1105 return Vec::new();
1106 };
1107 local_podman_targets(config)
1108 .into_iter()
1109 .map(|(id, container)| {
1110 (
1111 id.clone(),
1112 podman_image_check(id, &container.image, executor, smoke),
1113 )
1114 })
1115 .collect()
1116}
1117
1118fn podman_image_check(
1119 id: &str,
1120 image: &str,
1121 executor: &impl CommandExecutor,
1122 smoke: bool,
1123) -> DoctorCheck {
1124 let check_id = format!("runtime.podman.image.{id}");
1125 let title = format!("Podman image for target {id}");
1126 if smoke {
1127 let target = RuntimeTargetTemplate::LocalPodman(RuntimeContainerTemplate {
1128 build_cache: None,
1129 image: image.to_owned(),
1130 pull_policy: Default::default(),
1131 extra_run_args: vec![],
1132 workspace_storage: Default::default(),
1133 });
1134 return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1135 Ok(()) => DoctorCheck::ready(
1136 check_id,
1137 title,
1138 format!("Disposable run/exec/remove smoke test passed for image {image}."),
1139 ),
1140 Err(error) => DoctorCheck::fixable(
1141 check_id,
1142 title,
1143 format!(
1144 "Disposable run/exec/remove smoke test failed for image {image}: {error:#}"
1145 ),
1146 "Fix the configured image or Podman runtime, then run `mj doctor --json --smoke` again.",
1147 ),
1148 };
1149 }
1150
1151 let command = CommandSpec::new("podman", ["image", "exists", image])
1152 .purpose("check Podman image presence");
1153 match executor.execute(&command) {
1154 Ok(output) if output.status == 0 => DoctorCheck::ready(
1155 check_id,
1156 title,
1157 format!("Image {image} is present in local Podman storage."),
1158 ),
1159 Ok(_) => DoctorCheck::fixable(
1160 check_id,
1161 title,
1162 format!("Image {image} is not present in local Podman storage."),
1163 missing_image_remediation(image),
1164 ),
1165 Err(error) => DoctorCheck::fixable(
1166 check_id,
1167 title,
1168 format!(
1169 "Could not check whether image {image} is present in local Podman storage: {error}"
1170 ),
1171 missing_image_remediation(image),
1172 ),
1173 }
1174}
1175
1176fn missing_image_remediation(image: &str) -> String {
1177 format!(
1178 "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."
1179 )
1180}
1181
1182fn docker_checks(
1184 config: ConfigStatus<'_>,
1185 executor: &impl CommandExecutor,
1186 smoke: bool,
1187) -> Vec<DoctorCheck> {
1188 let config = match config {
1189 Ok(config) => config,
1190 Err(ConfigGap::NewerVersion(version)) => {
1191 return vec![newer_config_skip("runtime.docker", "Docker", version)];
1192 }
1193 Err(gap @ (ConfigGap::Missing | ConfigGap::Unreadable)) => {
1194 return vec![DoctorCheck::unsupported(
1195 "runtime.docker",
1196 "Docker",
1197 format!(
1198 "Docker prerequisites cannot be evaluated until {}.",
1199 gap.awaited()
1200 ),
1201 )];
1202 }
1203 };
1204 let explicit = local_docker_targets(config)
1205 .iter()
1206 .any(|(id, _)| config.configures_target(id));
1207 let effective = config.clone().with_local_targets();
1208 let targets = local_docker_targets(&effective);
1209 if targets.is_empty() {
1210 return vec![DoctorCheck::unsupported(
1211 "runtime.docker",
1212 "Docker",
1213 "No local-docker target is configured.",
1214 )];
1215 }
1216 let preflight = builtin_target_availability(
1217 local_docker_runtime_check(executor),
1218 explicit,
1219 "Docker",
1220 "docker",
1221 );
1222 if preflight.status != CheckStatus::Ready {
1223 return vec![preflight];
1224 }
1225 let mut checks = vec![preflight];
1226 checks.extend(targets.into_iter().map(|(id, container)| {
1227 builtin_image_check(
1228 Ok(config),
1229 id,
1230 docker_image_check(id, &container.image, executor, smoke),
1231 smoke,
1232 )
1233 }));
1234 checks
1235}
1236
1237pub fn local_docker_runtime_check(executor: &impl CommandExecutor) -> DoctorCheck {
1238 match verify_local_docker(executor) {
1239 Ok(preflight) => DoctorCheck::ready(
1240 "runtime.docker",
1241 "Docker",
1242 format!(
1243 "Docker {} is connected to a Linux daemon.",
1244 preflight.version
1245 ),
1246 ),
1247 Err(error) => match error.downcast_ref::<DockerUnavailable>() {
1248 Some(problem) => DoctorCheck::fixable(
1249 "runtime.docker",
1250 "Docker",
1251 problem.to_string(),
1252 problem.remediation(),
1253 ),
1254 None => DoctorCheck::fixable(
1255 "runtime.docker",
1256 "Docker",
1257 format!("{error:#}"),
1258 "Install and start Docker, then make sure `docker info` succeeds as the user running mj.",
1259 ),
1260 },
1261 }
1262}
1263
1264fn local_docker_targets(config: &Config) -> Vec<(&String, &ContainerTemplate)> {
1265 config
1266 .targets
1267 .iter()
1268 .filter_map(|(id, target)| match target {
1269 TargetTemplate::LocalDocker { container } => Some((id, container)),
1270 _ => None,
1271 })
1272 .collect()
1273}
1274
1275fn docker_image_check(
1276 id: &str,
1277 image: &str,
1278 executor: &impl CommandExecutor,
1279 smoke: bool,
1280) -> DoctorCheck {
1281 let check_id = format!("runtime.docker.image.{id}");
1282 let title = format!("Docker image for target {id}");
1283 if smoke {
1284 let target = RuntimeTargetTemplate::LocalDocker(RuntimeContainerTemplate {
1285 build_cache: None,
1286 image: image.to_owned(),
1287 pull_policy: Default::default(),
1288 extra_run_args: vec![],
1289 workspace_storage: Default::default(),
1290 });
1291 return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1292 Ok(()) => DoctorCheck::ready(
1293 check_id,
1294 title,
1295 match local_docker_vm_share(executor) {
1296 Ok(Some(reason)) => format!(
1297 "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."
1298 ),
1299 _ => format!(
1300 "Disposable run/exec/remove and OverlayFS attachment smoke test passed for image {image}."
1301 ),
1302 },
1303 ),
1304 Err(error) => DoctorCheck::fixable(
1305 check_id,
1306 title,
1307 format!(
1308 "Disposable run/exec/remove smoke test failed for image {image}: {error:#}"
1309 ),
1310 "Fix the configured image or Docker runtime, then run `mj doctor --json --smoke` again.",
1311 ),
1312 };
1313 }
1314 let command = CommandSpec::new("docker", ["image", "inspect", image])
1315 .purpose("check Docker image presence");
1316 match executor.execute(&command) {
1317 Ok(output) if output.status == 0 => DoctorCheck::ready(
1318 check_id,
1319 title,
1320 format!("Image {image} is present in Docker storage."),
1321 ),
1322 Ok(_) => DoctorCheck::fixable(
1323 check_id,
1324 title,
1325 format!("Image {image} is not present in Docker storage."),
1326 format!("Pull it with `docker pull {image}`, or run `mj doctor --json --smoke`."),
1327 ),
1328 Err(error) => DoctorCheck::fixable(
1329 check_id,
1330 title,
1331 format!("Could not inspect Docker image {image}: {error}"),
1332 format!("Make sure `docker info` succeeds, then run `docker pull {image}`."),
1333 ),
1334 }
1335}
1336
1337enum SshConnectivity {
1342 Reachable,
1343 Failed { detail: String, remediation: String },
1344}
1345
1346fn ssh_connectivity(ssh: &RuntimeSshTarget, executor: &impl CommandExecutor) -> SshConnectivity {
1351 let destination = &ssh.destination;
1352 let command = ssh_connectivity_probe(ssh);
1353 match executor.execute(&command) {
1354 Err(error) => SshConnectivity::Failed {
1355 detail: format!("Could not run `ssh {destination} true`: {error:#}"),
1356 remediation: ssh_launch_failure_remediation(&error, ssh),
1357 },
1358 Ok(output) if output.status != 0 => {
1359 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
1360 SshConnectivity::Failed {
1361 detail: format!("`ssh {destination} true` failed: {stderr}"),
1362 remediation: ssh_failure_remediation(&stderr, ssh),
1363 }
1364 }
1365 Ok(_) => SshConnectivity::Reachable,
1366 }
1367}
1368
1369const 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).";
1370
1371#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1377enum SshFailure {
1378 UntrustedHostKey,
1379 Unauthenticated,
1380 ClientMissing,
1381 Unreachable,
1383 Unrecognized,
1384}
1385
1386fn classify_ssh_stderr(stderr: &str) -> SshFailure {
1387 const UNTRUSTED_HOST_KEY: [&str; 3] = [
1388 "Host key verification failed",
1389 "No ECDSA host key is known",
1390 "REMOTE HOST IDENTIFICATION HAS CHANGED",
1391 ];
1392 const UNAUTHENTICATED: [&str; 4] = [
1393 "Permission denied",
1394 "Too many authentication failures",
1395 "no matching host key",
1396 "Authentication failed",
1397 ];
1398 const CLIENT_MISSING: [&str; 2] = ["ssh: command not found", "No such file or directory"];
1399 const UNREACHABLE: [&str; 3] = [
1400 "Connection timed out",
1401 "No route to host",
1402 "Network is unreachable",
1403 ];
1404
1405 let reported = |signatures: &[&str]| signatures.iter().any(|text| stderr.contains(text));
1406 if reported(&UNTRUSTED_HOST_KEY) {
1407 SshFailure::UntrustedHostKey
1408 } else if reported(&UNAUTHENTICATED) {
1409 SshFailure::Unauthenticated
1410 } else if reported(&CLIENT_MISSING) {
1411 SshFailure::ClientMissing
1412 } else if reported(&UNREACHABLE) {
1413 SshFailure::Unreachable
1414 } else {
1415 SshFailure::Unrecognized
1416 }
1417}
1418
1419fn ssh_launch_failure_remediation(error: &anyhow::Error, ssh: &RuntimeSshTarget) -> String {
1422 if error.downcast_ref::<CommandTimedOut>().is_some() {
1423 return ssh_unreachable_remediation(ssh);
1424 }
1425 let missing_binary = error.chain().any(|cause| {
1426 cause
1427 .downcast_ref::<std::io::Error>()
1428 .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
1429 });
1430 if missing_binary {
1431 return SSH_MISSING_REMEDIATION.to_owned();
1432 }
1433 format!(
1434 "Run `ssh {} true` by hand and resolve the error it reports: {error:#}",
1435 ssh.destination
1436 )
1437}
1438
1439fn ssh_unreachable_remediation(ssh: &RuntimeSshTarget) -> String {
1442 let host = ssh_host_only(&ssh.destination);
1443 format!(
1444 "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.",
1445 ssh.destination
1446 )
1447}
1448
1449fn ssh_failure_remediation(stderr: &str, ssh: &RuntimeSshTarget) -> String {
1451 let destination = &ssh.destination;
1452 match classify_ssh_stderr(stderr) {
1453 SshFailure::UntrustedHostKey => {
1454 let host = ssh_host_only(destination);
1455 format!(
1456 "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."
1457 )
1458 }
1459 SshFailure::Unauthenticated => match ssh_identity_file(ssh) {
1460 Some(identity) => format!(
1461 "Install your public key on the host with `ssh-copy-id -i {identity}.pub {destination}`."
1462 ),
1463 None => {
1464 format!("Install your public key on the host with `ssh-copy-id {destination}`.")
1465 }
1466 },
1467 SshFailure::ClientMissing => SSH_MISSING_REMEDIATION.to_owned(),
1468 SshFailure::Unreachable => ssh_unreachable_remediation(ssh),
1469 SshFailure::Unrecognized => {
1470 format!(
1471 "Run `ssh {destination} true` by hand and resolve the error it reports: {stderr}"
1472 )
1473 }
1474 }
1475}
1476
1477fn ssh_host_only(destination: &str) -> &str {
1479 destination
1480 .rsplit_once('@')
1481 .map_or(destination, |(_, host)| host)
1482}
1483
1484fn ssh_identity_file(ssh: &RuntimeSshTarget) -> Option<&str> {
1486 let position = ssh.ssh_args.iter().position(|arg| arg == "-i")?;
1487 ssh.ssh_args.get(position + 1).map(String::as_str)
1488}
1489
1490fn ssh_bare_checks(config: ConfigStatus<'_>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
1492 let Ok(config) = config else {
1493 return Vec::new();
1494 };
1495 config
1496 .targets
1497 .iter()
1498 .filter_map(|(id, target)| match target {
1499 TargetTemplate::SshBare { ssh, .. } => {
1500 Some(ssh_bare_check(id, &RuntimeSshTarget::from(ssh), executor))
1501 }
1502 _ => None,
1503 })
1504 .collect()
1505}
1506
1507fn ssh_bare_check(
1508 id: &str,
1509 ssh: &RuntimeSshTarget,
1510 executor: &impl CommandExecutor,
1511) -> DoctorCheck {
1512 let check_id = format!("runtime.ssh-bare.{id}");
1513 let title = format!("SSH access for target {id}");
1514 match ssh_connectivity(ssh, executor) {
1515 SshConnectivity::Reachable => DoctorCheck::ready(
1516 check_id,
1517 title,
1518 format!(
1519 "`ssh {} true` succeeds noninteractively from this host.",
1520 ssh.destination
1521 ),
1522 ),
1523 SshConnectivity::Failed {
1524 detail,
1525 remediation,
1526 } => DoctorCheck::fixable(check_id, title, detail, remediation),
1527 }
1528}
1529
1530fn ssh_podman_checks(
1533 config: ConfigStatus<'_>,
1534 executor: &impl CommandExecutor,
1535 smoke: bool,
1536) -> Vec<DoctorCheck> {
1537 let Ok(config) = config else {
1538 return Vec::new();
1539 };
1540 config
1541 .targets
1542 .iter()
1543 .flat_map(|(id, target)| match target {
1544 TargetTemplate::SshPodman { ssh, container, .. } => {
1545 let ssh = RuntimeSshTarget::from(ssh);
1546 let (check, reachable) =
1547 ssh_podman_check(id, &ssh, &container.image, executor, smoke);
1548 let mut checks = vec![check];
1549 if reachable {
1551 checks.push(ssh_podman_limits_check(id, &ssh, executor));
1552 }
1553 checks
1554 }
1555 _ => Vec::new(),
1556 })
1557 .collect()
1558}
1559
1560fn ssh_podman_check(
1563 id: &str,
1564 ssh: &RuntimeSshTarget,
1565 image: &str,
1566 executor: &impl CommandExecutor,
1567 smoke: bool,
1568) -> (DoctorCheck, bool) {
1569 let check_id = format!("runtime.ssh-podman.{id}");
1570 let title = format!("Remote Podman for target {id}");
1571 if let SshConnectivity::Failed {
1574 detail,
1575 remediation,
1576 } = ssh_connectivity(ssh, executor)
1577 {
1578 return (
1579 DoctorCheck::fixable(check_id, title, detail, remediation),
1580 false,
1581 );
1582 }
1583 (
1584 ssh_podman_runtime_check(check_id, title, ssh, image, executor, smoke),
1585 true,
1586 )
1587}
1588
1589fn ssh_podman_runtime_check(
1591 check_id: String,
1592 title: String,
1593 ssh: &RuntimeSshTarget,
1594 image: &str,
1595 executor: &impl CommandExecutor,
1596 smoke: bool,
1597) -> DoctorCheck {
1598 let destination = &ssh.destination;
1599 let preflight = match verify_ssh_podman(ssh, executor) {
1600 Ok(preflight) => preflight,
1601 Err(error) => {
1602 let detail = podman_failure_detail(&error);
1603 let remediation = match podman_remediation_match(&error) {
1604 Some(remediation) => {
1605 format!("On {destination}: {remediation} See {PODMAN_DOCUMENTATION_URL}.")
1606 }
1607 None => format!(
1608 "Verify `ssh {destination}` succeeds noninteractively from this host, then install rootless Podman 4.3 or newer there. See {PODMAN_DOCUMENTATION_URL}."
1609 ),
1610 };
1611 return DoctorCheck::fixable(check_id, title, detail, remediation);
1612 }
1613 };
1614 let linger_warning = preflight.warnings.first();
1615 if !smoke && let Some(warning) = linger_warning {
1616 return DoctorCheck::warning(
1617 check_id,
1618 title,
1619 format!(
1620 "Remote rootless Podman {} is available via {destination}, but {}",
1621 preflight.version, warning.detail
1622 ),
1623 &warning.remediation,
1624 );
1625 }
1626 if !smoke {
1627 return DoctorCheck::ready(
1628 check_id,
1629 title,
1630 format!(
1631 "Remote rootless Podman {} is available via {destination}. Run `mj doctor --json --smoke` to verify the image end to end.",
1632 preflight.version
1633 ),
1634 );
1635 }
1636
1637 let target = RuntimeTargetTemplate::SshPodman {
1638 ssh: ssh.clone(),
1639 container: RuntimeContainerTemplate {
1640 build_cache: None,
1641 image: image.to_owned(),
1642 pull_policy: Default::default(),
1643 extra_run_args: vec![],
1644 workspace_storage: Default::default(),
1645 },
1646 };
1647 match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1648 Ok(()) => match linger_warning {
1649 Some(warning) => DoctorCheck::warning(
1650 check_id,
1651 title,
1652 format!(
1653 "Disposable run/exec/remove smoke test passed for image {image} on {destination}, but {}",
1654 warning.detail
1655 ),
1656 &warning.remediation,
1657 ),
1658 None => DoctorCheck::ready(
1659 check_id,
1660 title,
1661 format!(
1662 "Disposable run/exec/remove smoke test passed for image {image} on {destination}."
1663 ),
1664 ),
1665 },
1666 Err(error) => DoctorCheck::fixable(
1667 check_id,
1668 title,
1669 format!(
1670 "Disposable run/exec/remove smoke test failed for image {image} on {destination}: {error:#}"
1671 ),
1672 format!(
1673 "Fix the configured image or Podman runtime on {destination}, then run `mj doctor --json --smoke` again."
1674 ),
1675 ),
1676 }
1677}
1678
1679const SSH_PODMAN_HOST_LIMITS_SCRIPT: &str = r#"
1688if [ -r /proc/sys/kernel/keys/maxkeys ]; then
1689 printf 'keys.max=%s\n' "$(cat /proc/sys/kernel/keys/maxkeys)"
1690fi
1691if [ -r /proc/key-users ]; then
1692 awk -v uid="$(id -u)" '
1693 { user = $1; sub(/:$/, "", user) }
1694 user == uid {
1695 split($4, quota, "/")
1696 printf "keys.used=%s\nkeys.quota=%s\n", quota[1], quota[2]
1697 }
1698 ' /proc/key-users
1699fi
1700unreadable=0
1701maxstartups=
1702# A drop-in directory that cannot be listed hides any override it holds.
1703if [ -d /etc/ssh/sshd_config.d ] && ! [ -r /etc/ssh/sshd_config.d ]; then
1704 unreadable=1
1705fi
1706for file in /etc/ssh/sshd_config /etc/ssh/sshd_config.d/*.conf; do
1707 [ -e "$file" ] || continue
1708 if [ -r "$file" ]; then
1709 match=$(grep -i '^[[:space:]]*maxstartups[[:space:]]' "$file" 2>/dev/null | tail -n 1)
1710 [ -n "$match" ] && maxstartups=$(printf '%s\n' "$match" | awk '{ print $2 }')
1711 else
1712 unreadable=1
1713 fi
1714done
1715[ -n "$maxstartups" ] && printf 'maxstartups=%s\n' "$maxstartups"
1716[ "$unreadable" = 1 ] && printf 'maxstartups.unreadable=1\n'
1717exit 0
1718"#;
1719
1720const KEYRING_PRESSURE_PERCENT: u64 = 80;
1724
1725#[derive(Debug, Default, PartialEq, Eq)]
1728struct HostLimits {
1729 keys_used: Option<u64>,
1730 keys_quota: Option<u64>,
1731 keys_max: Option<u64>,
1732 max_startups: Option<String>,
1733 max_startups_unreadable: bool,
1734}
1735
1736fn parse_host_limits(stdout: &[u8]) -> HostLimits {
1737 let text = String::from_utf8_lossy(stdout);
1738 let mut limits = HostLimits::default();
1739 for line in text.lines() {
1740 let Some((name, value)) = line.split_once('=') else {
1741 continue;
1742 };
1743 let value = value.trim();
1744 match name.trim() {
1745 "keys.used" => limits.keys_used = value.parse().ok(),
1746 "keys.quota" => limits.keys_quota = value.parse().ok(),
1747 "keys.max" => limits.keys_max = value.parse().ok(),
1748 "maxstartups" if !value.is_empty() => limits.max_startups = Some(value.to_owned()),
1749 "maxstartups.unreadable" => limits.max_startups_unreadable = value == "1",
1750 _ => {}
1751 }
1752 }
1753 limits
1754}
1755
1756impl HostLimits {
1757 fn is_empty(&self) -> bool {
1759 self.keys_used.is_none()
1760 && self.keys_quota.is_none()
1761 && self.keys_max.is_none()
1762 && self.max_startups.is_none()
1763 && !self.max_startups_unreadable
1764 }
1765
1766 fn keyring_is_under_pressure(&self) -> bool {
1767 match (self.keys_used, self.keys_quota) {
1768 (Some(used), Some(quota)) if quota > 0 => {
1769 used.saturating_mul(100) >= quota.saturating_mul(KEYRING_PRESSURE_PERCENT)
1770 }
1771 _ => false,
1772 }
1773 }
1774
1775 fn keyring_sentence(&self, destination: &str) -> String {
1776 match (self.keys_used, self.keys_quota) {
1777 (Some(used), Some(quota)) => {
1778 let system = match self.keys_max {
1779 Some(max) => format!(", and `kernel.keys.maxkeys` is {max}"),
1780 None => String::new(),
1781 };
1782 format!(
1783 "The login user on {destination} holds {used} of its {quota} kernel keyring quota{system}."
1784 )
1785 }
1786 _ => format!(
1787 "The kernel keyring quota for the login user on {destination} could not be read."
1788 ),
1789 }
1790 }
1791
1792 fn max_startups_sentence(&self) -> String {
1793 match (&self.max_startups, self.max_startups_unreadable) {
1794 (Some(value), _) => format!("sshd MaxStartups is {value}."),
1795 (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(),
1796 (None, false) => {
1797 "sshd MaxStartups is not set in sshd_config, so sshd's default applies.".to_owned()
1798 }
1799 }
1800 }
1801}
1802
1803fn ssh_podman_limits_check(
1807 id: &str,
1808 ssh: &RuntimeSshTarget,
1809 executor: &impl CommandExecutor,
1810) -> DoctorCheck {
1811 let check_id = format!("runtime.ssh-podman.{id}.limits");
1812 let title = format!("Host limits for target {id}");
1813 let destination = &ssh.destination;
1814 let manual = || {
1815 format!(
1816 "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`."
1817 )
1818 };
1819 let command = ssh_validation_command(
1820 ssh,
1821 vec![
1822 "sh".to_owned(),
1823 "-c".to_owned(),
1824 SSH_PODMAN_HOST_LIMITS_SCRIPT.to_owned(),
1825 ],
1826 "read ssh-podman host limits",
1827 );
1828 let limits = match executor.execute(&command) {
1829 Ok(output) if output.status == 0 => parse_host_limits(&output.stdout),
1830 Ok(output) => {
1831 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
1832 return DoctorCheck::warning(
1833 check_id,
1834 title,
1835 format!(
1836 "Could not read the kernel keyring quota or sshd MaxStartups from {destination}: {stderr}"
1837 ),
1838 manual(),
1839 );
1840 }
1841 Err(error) => {
1842 return DoctorCheck::warning(
1843 check_id,
1844 title,
1845 format!(
1846 "Could not read the kernel keyring quota or sshd MaxStartups from {destination}: {error}"
1847 ),
1848 manual(),
1849 );
1850 }
1851 };
1852 if limits.is_empty() {
1853 return DoctorCheck::warning(
1854 check_id,
1855 title,
1856 format!("{destination} reported no readable kernel keyring or sshd limits."),
1857 manual(),
1858 );
1859 }
1860 let detail = format!(
1861 "{} {}",
1862 limits.keyring_sentence(destination),
1863 limits.max_startups_sentence()
1864 );
1865 if limits.keyring_is_under_pressure() {
1866 return DoctorCheck::warning(
1867 check_id,
1868 title,
1869 format!(
1870 "{detail} Every container takes a session keyring, so `podman run` fails with `crun: create keyring` once the quota is gone."
1871 ),
1872 format!(
1873 "Raise `kernel.keys.maxkeys` and `kernel.keys.maxbytes` with sysctl on {destination}, and close finished sessions promptly."
1874 ),
1875 );
1876 }
1877 DoctorCheck::ready(check_id, title, detail)
1878}
1879
1880fn ssh_docker_checks(
1883 config: ConfigStatus<'_>,
1884 executor: &impl CommandExecutor,
1885 smoke: bool,
1886) -> Vec<DoctorCheck> {
1887 let Ok(config) = config else {
1888 return Vec::new();
1889 };
1890 config
1891 .targets
1892 .iter()
1893 .filter_map(|(id, target)| match target {
1894 TargetTemplate::SshDocker { ssh, container } => Some(ssh_docker_check(
1895 id,
1896 &RuntimeSshTarget::from(ssh),
1897 &container.image,
1898 executor,
1899 smoke,
1900 )),
1901 _ => None,
1902 })
1903 .collect()
1904}
1905
1906fn ssh_docker_check(
1907 id: &str,
1908 ssh: &RuntimeSshTarget,
1909 image: &str,
1910 executor: &impl CommandExecutor,
1911 smoke: bool,
1912) -> DoctorCheck {
1913 let check_id = format!("runtime.ssh-docker.{id}");
1914 let title = format!("Remote Docker for target {id}");
1915 let destination = &ssh.destination;
1916 if let SshConnectivity::Failed {
1917 detail,
1918 remediation,
1919 } = ssh_connectivity(ssh, executor)
1920 {
1921 return DoctorCheck::fixable(check_id, title, detail, remediation);
1922 }
1923
1924 let preflight = match verify_ssh_docker(ssh, executor) {
1925 Ok(preflight) => preflight,
1926 Err(error) => {
1927 let detail = format!("{error:#}");
1928 return DoctorCheck::fixable(
1929 check_id,
1930 title,
1931 detail,
1932 format!(
1933 "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."
1934 ),
1935 );
1936 }
1937 };
1938
1939 if smoke {
1940 let target = RuntimeTargetTemplate::SshDocker {
1941 ssh: ssh.clone(),
1942 container: RuntimeContainerTemplate {
1943 build_cache: None,
1944 image: image.to_owned(),
1945 pull_policy: Default::default(),
1946 extra_run_args: vec![],
1947 workspace_storage: Default::default(),
1948 },
1949 };
1950 return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1951 Ok(()) => DoctorCheck::ready(
1952 check_id,
1953 title,
1954 format!(
1955 "Remote Docker {} is available via {destination}; disposable run/exec/remove and remote OverlayFS attachment smoke test passed for image {image}.",
1956 preflight.version
1957 ),
1958 ),
1959 Err(error) => DoctorCheck::fixable(
1960 check_id,
1961 title,
1962 format!(
1963 "Disposable run/exec/remove smoke test failed for image {image} on {destination}: {error:#}"
1964 ),
1965 format!(
1966 "Fix the configured image or Docker runtime on {destination}, then run `mj doctor --json --smoke` again."
1967 ),
1968 ),
1969 };
1970 }
1971
1972 let image_command = ssh_command(
1973 ssh,
1974 [
1975 "docker".to_owned(),
1976 "image".to_owned(),
1977 "inspect".to_owned(),
1978 image.to_owned(),
1979 ]
1980 .to_vec(),
1981 )
1982 .purpose("check remote Docker image presence");
1983 match executor.execute(&image_command) {
1984 Ok(output) if output.status == 0 => DoctorCheck::ready(
1985 check_id,
1986 title,
1987 format!(
1988 "Remote Docker {} is available via {destination}; image {image} is present. Run `mj doctor --json --smoke` to verify remote OverlayFS attachments.",
1989 preflight.version
1990 ),
1991 ),
1992 Ok(output) => DoctorCheck::fixable(
1993 check_id,
1994 title,
1995 format!(
1996 "Image {image} is not present in remote Docker storage on {destination}: {}",
1997 String::from_utf8_lossy(&output.stderr).trim()
1998 ),
1999 format!(
2000 "Pull it on {destination} with `ssh {destination} docker pull {image}`, or run `mj doctor --json --smoke`."
2001 ),
2002 ),
2003 Err(error) => DoctorCheck::fixable(
2004 check_id,
2005 title,
2006 format!("Could not inspect remote Docker image {image} on {destination}: {error}"),
2007 format!(
2008 "Verify `ssh {destination} docker info` succeeds, then pull {image} on that host."
2009 ),
2010 ),
2011 }
2012}
2013
2014fn doctor_smoke_id() -> String {
2016 format!(
2017 "doctor-{}-{:x}",
2018 std::process::id(),
2019 SystemTime::now()
2020 .duration_since(UNIX_EPOCH)
2021 .unwrap_or_default()
2022 .as_nanos()
2023 )
2024}
2025
2026fn podman_remediation(error: &anyhow::Error) -> String {
2027 let fix = podman_remediation_match(error).unwrap_or(
2028 "Install Podman with `sudo apt update && sudo apt install -y podman uidmap` (Debian/Ubuntu) or `sudo dnf install -y podman shadow-utils` (Fedora).",
2029 );
2030 format!("{fix} See {PODMAN_DOCUMENTATION_URL}.")
2031}
2032
2033fn podman_failure_detail(error: &anyhow::Error) -> String {
2035 podman_probe_observation(error).map_or_else(|| format!("{error:#}"), str::to_owned)
2036}
2037
2038fn podman_remediation_match(error: &anyhow::Error) -> Option<&'static str> {
2045 failed_podman_postcondition(error).map(PodmanPostcondition::remediation)
2046}
2047
2048const AWS_CLI_INSTALL_URL: &str =
2049 "https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html";
2050
2051fn aws_checks(config: ConfigStatus<'_>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
2054 let Ok(config) = config else {
2055 return Vec::new();
2056 };
2057 config
2058 .targets
2059 .iter()
2060 .filter_map(|(id, target)| match target {
2061 TargetTemplate::AwsEc2 {
2062 aws_profile,
2063 region,
2064 launch_template,
2065 ..
2066 } => Some(aws_target_check(
2067 id,
2068 aws_profile.as_deref(),
2069 region,
2070 launch_template,
2071 executor,
2072 )),
2073 _ => None,
2074 })
2075 .collect()
2076}
2077
2078fn aws_global_args<'a>(profile: Option<&'a str>, region: &'a str) -> Vec<String> {
2081 vec![
2082 "--profile".to_owned(),
2083 profile.unwrap_or("default").to_owned(),
2084 "--region".to_owned(),
2085 region.to_owned(),
2086 ]
2087}
2088
2089fn aws_target_check(
2090 id: &str,
2091 profile: Option<&str>,
2092 region: &str,
2093 launch_template: &str,
2094 executor: &impl CommandExecutor,
2095) -> DoctorCheck {
2096 let check_id = format!("runtime.aws-ec2.{id}");
2097 let title = format!("AWS EC2 target {id}");
2098 let profile_label = profile.unwrap_or("default");
2099
2100 let version = CommandSpec::new("aws", ["--version"]).purpose("check AWS CLI installation");
2101 match executor.execute(&version) {
2102 Err(error) => {
2103 return DoctorCheck::fixable(
2104 check_id,
2105 title,
2106 format!("The `aws` command is not available: {error}"),
2107 format!("Install the AWS CLI and put `aws` on PATH: {AWS_CLI_INSTALL_URL}"),
2108 );
2109 }
2110 Ok(output) if output.status != 0 => {
2111 return DoctorCheck::fixable(
2112 check_id,
2113 title,
2114 format!(
2115 "`aws --version` failed: {}",
2116 String::from_utf8_lossy(&output.stderr).trim()
2117 ),
2118 format!("Reinstall the AWS CLI: {AWS_CLI_INSTALL_URL}"),
2119 );
2120 }
2121 Ok(_) => {}
2122 }
2123
2124 let mut identity_args = aws_global_args(profile, region);
2125 identity_args.extend(["sts".to_owned(), "get-caller-identity".to_owned()]);
2126 identity_args.extend(["--output".to_owned(), "json".to_owned()]);
2127 let identity =
2128 CommandSpec::new("aws", identity_args).purpose("check AWS credentials for a doctor target");
2129 match executor.execute(&identity) {
2130 Err(error) => {
2131 return DoctorCheck::fixable(
2132 check_id,
2133 title,
2134 format!("Could not run `aws sts get-caller-identity`: {error}"),
2135 format!(
2136 "Configure credentials with `aws configure --profile {profile_label}`, or sign in with `aws sso login --profile {profile_label}`."
2137 ),
2138 );
2139 }
2140 Ok(output) if output.status != 0 => {
2141 return DoctorCheck::fixable(
2142 check_id,
2143 title,
2144 format!(
2145 "AWS credentials for profile {profile_label} are not usable: {}",
2146 String::from_utf8_lossy(&output.stderr).trim()
2147 ),
2148 format!(
2149 "Configure credentials with `aws configure --profile {profile_label}`, or sign in with `aws sso login --profile {profile_label}`."
2150 ),
2151 );
2152 }
2153 Ok(_) => {}
2154 }
2155
2156 let by_id = launch_template.starts_with("lt-");
2159 let mut template_args = aws_global_args(profile, region);
2160 template_args.extend(["ec2".to_owned(), "describe-launch-templates".to_owned()]);
2161 template_args.extend([
2162 if by_id {
2163 "--launch-template-ids".to_owned()
2164 } else {
2165 "--launch-template-names".to_owned()
2166 },
2167 launch_template.to_owned(),
2168 ]);
2169 template_args.extend(["--output".to_owned(), "json".to_owned()]);
2170 let template =
2171 CommandSpec::new("aws", template_args).purpose("check the configured AWS launch template");
2172 let template_remediation = format!(
2173 "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."
2174 );
2175 match executor.execute(&template) {
2176 Err(error) => DoctorCheck::fixable(
2177 check_id,
2178 title,
2179 format!("Could not query launch template {launch_template}: {error}"),
2180 template_remediation,
2181 ),
2182 Ok(output) if output.status != 0 => DoctorCheck::fixable(
2183 check_id,
2184 title,
2185 format!(
2186 "Launch template {launch_template} was not found in {region}: {}",
2187 String::from_utf8_lossy(&output.stderr).trim()
2188 ),
2189 template_remediation,
2190 ),
2191 Ok(_) => DoctorCheck::ready(
2192 check_id,
2193 title,
2194 format!(
2195 "The AWS CLI is installed, profile {profile_label} has valid credentials, and launch template {launch_template} exists in {region}."
2196 ),
2197 ),
2198 }
2199}
2200
2201fn daemon_build_check() -> DoctorCheck {
2209 const ID: &str = "daemon.build";
2210 const TITLE: &str = "Daemon build";
2211 let Ok(metadata) = mj_client::daemon::read_metadata_any() else {
2212 return DoctorCheck::ready(
2213 ID,
2214 TITLE,
2215 "No Mjolnir daemon is running; the next command starts one from this build.",
2216 );
2217 };
2218 let pid = metadata.pid;
2219 match mj_client::executable::process_runs_this_executable(pid) {
2220 Ok(Some(true)) => DoctorCheck::ready(
2221 ID,
2222 TITLE,
2223 format!(
2224 "Daemon {pid} runs this build (version {}).",
2225 mj_client::build_identity::this_build().describe()
2226 ),
2227 ),
2228 Ok(Some(false)) => DoctorCheck::warning(
2229 ID,
2230 TITLE,
2231 format!(
2232 "{}. Two builds can report the same version, so the files are what tell them apart. Code rebuilt since that daemon started is not running.",
2233 mj_client::executable::describe_running_daemon_and_client_builds(
2234 pid,
2235 &metadata.build_version,
2236 ),
2237 ),
2238 "Run `mj daemon restart` from this build. It now fails rather than reporting success if another client's build wins.",
2239 ),
2240 Ok(None) => DoctorCheck::ready(
2241 ID,
2242 TITLE,
2243 format!(
2244 "Daemon {pid} is recorded but not running; the next command starts one from this build."
2245 ),
2246 ),
2247 Err(error) => DoctorCheck::warning(
2248 ID,
2249 TITLE,
2250 format!("Could not tell which build daemon {pid} runs: {error:#}"),
2251 "Run `mj daemon restart` from this build if rebuilt code is not taking effect.",
2252 ),
2253 }
2254}
2255
2256fn worker_freshness_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
2264 let mut checks = Vec::new();
2265 let daemon = mj_client::daemon::read_metadata_any()
2266 .ok()
2267 .filter(|metadata| mj_client::daemon::process_is_alive(metadata.pid));
2268 let pinned = pinned_worker_digests();
2269 let mut sources: Vec<(String, Result<WorkerBinaryAvailability>)> = vec![(
2270 "this host".to_owned(),
2271 crate::controller::native_worker_binary_prerequisite(),
2272 )];
2273 for arch in container_worker_architectures(config) {
2274 sources.push((
2275 format!("{arch} Linux targets"),
2276 worker_binary_prerequisite_for_arch(&arch),
2277 ));
2278 }
2279 for (label, availability) in sources {
2280 let id = format!("worker.freshness.{}", label.replace(' ', "-"));
2281 let title = format!("Worker binary for {label}");
2282 let path = match availability {
2283 Ok(WorkerBinaryAvailability::Local { path, .. }) => path,
2284 Ok(WorkerBinaryAvailability::Remote { .. }) => continue,
2287 Err(error) => {
2288 checks.push(DoctorCheck::unsupported(
2289 id,
2290 title,
2291 format!("No worker binary resolves for {label}: {error:#}"),
2292 ));
2293 continue;
2294 }
2295 };
2296 let digest = mj_core::worker_launch::worker_executable_digest(&path)
2297 .unwrap_or_else(|error| format!("unreadable ({error:#})"));
2298 let pinned_note = if pinned.is_empty() {
2299 "the daemon has pinned no worker".to_owned()
2300 } else if pinned.contains(&digest) {
2301 "this content is in the daemon's pinned worker cache".to_owned()
2302 } else {
2303 format!(
2304 "the pinned worker cache holds {} instead",
2305 pinned.join(", ")
2306 )
2307 };
2308 let detail = format!("{} has digest {digest}; {pinned_note}.", path.display());
2309 let Some(metadata) = daemon.as_ref() else {
2310 checks.push(DoctorCheck::ready(
2311 id,
2312 title,
2313 format!("{detail} No daemon is running, so the next session uses this file."),
2314 ));
2315 continue;
2316 };
2317 match worker_changed_since_daemon_start(&path, &metadata.started_at) {
2318 Ok(true) => checks.push(DoctorCheck::warning(
2319 id,
2320 title,
2321 format!("{detail} It was rebuilt after daemon {} started, which froze the copy it serves.", metadata.pid),
2322 "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.",
2323 )),
2324 Ok(false) => checks.push(DoctorCheck::ready(id, title, detail)),
2325 Err(error) => checks.push(DoctorCheck::warning(
2326 id,
2327 title,
2328 format!("{detail} Could not compare it with the daemon's start time: {error:#}"),
2329 "Run `mj daemon restart` if rebuilt worker code is not taking effect.",
2330 )),
2331 }
2332 }
2333 checks
2334}
2335
2336fn container_worker_architectures(config: ConfigStatus<'_>) -> Vec<String> {
2338 let Ok(config) = config else {
2339 return Vec::new();
2340 };
2341 let mut architectures = Vec::new();
2342 for target in config.targets.values() {
2343 let container = match target {
2344 TargetTemplate::LocalPodman { container }
2345 | TargetTemplate::LocalDocker { container }
2346 | TargetTemplate::AppleContainer { container }
2347 | TargetTemplate::SshPodman { container, .. }
2348 | TargetTemplate::SshDocker { container, .. } => container,
2349 _ => continue,
2350 };
2351 let arch = container
2352 .platform
2353 .as_deref()
2354 .and_then(|platform| platform.rsplit('/').next())
2355 .map_or_else(
2356 || std::env::consts::ARCH.to_owned(),
2357 normalized_worker_architecture,
2358 );
2359 if !architectures.contains(&arch) {
2360 architectures.push(arch);
2361 }
2362 }
2363 architectures
2364}
2365
2366fn normalized_worker_architecture(platform_arch: &str) -> String {
2369 crate::targets::normalize_architecture(platform_arch)
2370 .unwrap_or(platform_arch)
2371 .to_owned()
2372}
2373
2374fn pinned_worker_digests() -> Vec<String> {
2379 let root = mj_core::config::data_dir().join("workers").join("pinned");
2380 let Ok(entries) = std::fs::read_dir(root) else {
2381 return Vec::new();
2382 };
2383 let mut digests: Vec<String> = entries
2384 .flatten()
2385 .filter(|entry| entry.path().is_dir())
2386 .filter_map(|entry| entry.file_name().into_string().ok())
2387 .collect();
2388 digests.sort();
2389 digests
2390}
2391
2392fn worker_changed_since_daemon_start(path: &Path, started_at: &str) -> Result<bool> {
2397 let started: SystemTime = chrono::DateTime::parse_from_rfc3339(started_at)
2398 .map_err(|error| anyhow::anyhow!("parse daemon start time {started_at:?}: {error}"))?
2399 .into();
2400 let modified = std::fs::metadata(path)?.modified()?;
2401 Ok(modified > started)
2402}
2403
2404fn offered_targets<'a>(
2412 config: &Config,
2413 engine_checks: impl IntoIterator<Item = &'a DoctorCheck>,
2414) -> Config {
2415 let ready = engine_checks
2416 .into_iter()
2417 .filter(|check| check.status == CheckStatus::Ready)
2418 .map(|check| check.id.as_str())
2419 .collect::<std::collections::BTreeSet<_>>();
2420 let engine_ready = |target_id: &str| match target_id {
2421 "podman" => ready.contains("runtime.podman"),
2422 "docker" => ready.contains("runtime.docker"),
2423 "apple-container" => ready.contains("runtime.apple-container"),
2424 _ => true,
2425 };
2426 let mut offered = config.clone().with_local_targets();
2427 offered
2428 .targets
2429 .retain(|id, _| config.configures_target(id) || engine_ready(id));
2430 offered
2431}
2432
2433fn worker_binary_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
2434 let config = match config {
2435 Ok(config) => config,
2436 Err(ConfigGap::NewerVersion(version)) => {
2437 return vec![newer_config_skip(
2438 "worker.containers",
2439 "Container worker binary",
2440 version,
2441 )];
2442 }
2443 Err(gap @ (ConfigGap::Missing | ConfigGap::Unreadable)) => {
2444 return vec![DoctorCheck::fixable(
2445 "worker.containers",
2446 "Container worker binary",
2447 format!(
2448 "Worker availability cannot be checked until {}.",
2449 gap.awaited()
2450 ),
2451 gap.remediation(),
2452 )];
2453 }
2454 };
2455 let containers = config
2456 .targets
2457 .iter()
2458 .filter_map(|(id, target)| match target {
2459 TargetTemplate::LocalPodman { container }
2460 | TargetTemplate::LocalDocker { container }
2461 | TargetTemplate::AppleContainer { container } => Some((id, container, None)),
2462 TargetTemplate::SshPodman { container, .. } => {
2463 Some((id, container, Some("ssh-podman")))
2464 }
2465 TargetTemplate::SshDocker { container, .. } => {
2466 Some((id, container, Some("ssh-docker")))
2467 }
2468 _ => None,
2469 })
2470 .collect::<Vec<_>>();
2471 if containers.is_empty() {
2472 return vec![DoctorCheck::unsupported(
2473 "worker.containers",
2474 "Container worker binary",
2475 "No container target is configured.",
2476 )];
2477 }
2478 containers
2479 .into_iter()
2480 .map(|(id, container, remote_kind)| {
2481 if let Some(remote_kind) = remote_kind
2482 && container.platform.is_none()
2483 {
2484 return DoctorCheck::unsupported(
2487 format!("worker.{id}"),
2488 format!("Container worker binary for target {id}"),
2489 format!(
2490 "Set `platform` on this {remote_kind} target to check its worker binary; the remote architecture is unknown until provisioning."
2491 ),
2492 );
2493 }
2494 worker_binary_check(id, container)
2495 })
2496 .collect()
2497}
2498
2499fn ssh_bare_worker_checks(
2503 config: ConfigStatus<'_>,
2504 executor: &impl CommandExecutor,
2505) -> Vec<DoctorCheck> {
2506 let Ok(config) = config else {
2507 return Vec::new();
2508 };
2509 config
2510 .targets
2511 .iter()
2512 .filter(|(_, target)| matches!(target, TargetTemplate::SshBare { .. }))
2513 .map(|(id, target)| {
2514 let check_id = format!("worker.{id}");
2515 let title = format!("Worker binary for SSH target {id}");
2516 match ssh_worker_binary_prerequisite(target, executor) {
2517 None => DoctorCheck::unsupported(
2518 check_id,
2519 title,
2520 format!(
2521 "The platform of this host could not be read over SSH, so its worker binary is not checked; see `runtime.ssh-bare.{id}`."
2522 ),
2523 ),
2524 Some((triple, result)) => worker_source_check(check_id, title, &triple, result),
2525 }
2526 })
2527 .collect()
2528}
2529
2530fn worker_binary_check(id: &str, container: &ContainerTemplate) -> DoctorCheck {
2531 let title = format!("Container worker binary for target {id}");
2532 let arch = match container_architecture(container.platform.as_deref()) {
2533 Ok(arch) => arch,
2534 Err(reason) => {
2535 return DoctorCheck::unsupported(format!("worker.{id}"), title, reason);
2536 }
2537 };
2538 let triple = format!("{arch}-unknown-linux-musl");
2539 worker_source_check(
2540 format!("worker.{id}"),
2541 title,
2542 &triple,
2543 worker_binary_prerequisite_for_arch(arch),
2544 )
2545}
2546
2547fn worker_source_check(
2548 check_id: String,
2549 title: String,
2550 triple: &str,
2551 source: anyhow::Result<WorkerBinaryAvailability>,
2552) -> DoctorCheck {
2553 match source {
2554 Ok(WorkerBinaryAvailability::Local { path, source }) => DoctorCheck::ready(
2555 check_id,
2556 title,
2557 format!(
2558 "{triple} worker is available from {source}: {}",
2559 path.display()
2560 ),
2561 ),
2562 Ok(WorkerBinaryAvailability::Remote { url, .. }) => DoctorCheck::ready(
2563 check_id,
2564 title,
2565 format!("{triple} worker will be verified and downloaded from {url} when needed."),
2566 ),
2567 Err(error) => DoctorCheck::fixable(
2568 check_id,
2569 title,
2570 format!("No usable {triple} worker source: {error:#}"),
2571 format!(
2572 "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."
2573 ),
2574 ),
2575 }
2576}
2577
2578fn container_architecture(platform: Option<&str>) -> std::result::Result<&'static str, String> {
2579 let candidate = platform.unwrap_or(std::env::consts::ARCH);
2580 let candidate = candidate
2581 .split('/')
2582 .rev()
2583 .find(|part| matches!(*part, "x86_64" | "amd64" | "aarch64" | "arm64"))
2584 .unwrap_or(candidate);
2585 crate::targets::normalize_architecture(candidate).map_err(|_| format!(
2586 "Container architecture {candidate:?} is unsupported; Mjolnir supports x86_64 and aarch64 Linux workers."
2587 ))
2588}
2589
2590fn apple_container_image(config: ConfigStatus<'_>) -> String {
2591 config
2592 .ok()
2593 .and_then(|config| {
2594 config.targets.values().find_map(|target| match target {
2595 TargetTemplate::AppleContainer { container } => Some(container.image.clone()),
2596 _ => None,
2597 })
2598 })
2599 .unwrap_or_else(|| DEFAULT_CONTAINER_IMAGE.into())
2600}
2601
2602pub fn apple_container_runtime_check(
2604 platform: &ApplePlatform,
2605 executor: &impl CommandExecutor,
2606) -> DoctorCheck {
2607 match platform {
2608 ApplePlatform::Linux => {
2609 return DoctorCheck::unsupported(
2610 "runtime.apple-container",
2611 "Apple container runtime",
2612 "macOS only",
2613 );
2614 }
2615 ApplePlatform::Other(current) => {
2616 return DoctorCheck::unsupported(
2617 "runtime.apple-container",
2618 "Apple container runtime",
2619 format!("macOS only (current platform: {current})"),
2620 );
2621 }
2622 ApplePlatform::Macos {
2623 architecture,
2624 major_version,
2625 } if architecture != "aarch64" && architecture != "arm64" => {
2626 return DoctorCheck::unsupported(
2627 "runtime.apple-container",
2628 "Apple container runtime",
2629 "Apple container requires Apple silicon; Intel Macs are unsupported.",
2630 );
2631 }
2632 ApplePlatform::Macos { major_version, .. } if *major_version < 26 => {
2633 return DoctorCheck::unsupported(
2634 "runtime.apple-container",
2635 "Apple container runtime",
2636 format!("Apple container requires macOS 26 or newer (found {major_version})."),
2637 );
2638 }
2639 ApplePlatform::Macos { .. } => {}
2640 }
2641
2642 apple_container_daemon_check(executor)
2643}
2644
2645pub fn apple_container_check(
2646 platform: &ApplePlatform,
2647 executor: &impl CommandExecutor,
2648 smoke: bool,
2649 image: String,
2650) -> DoctorCheck {
2651 let daemon = apple_container_runtime_check(platform, executor);
2652 if daemon.status != CheckStatus::Ready {
2653 return daemon;
2654 }
2655
2656 if !smoke {
2657 return DoctorCheck::fixable(
2658 "runtime.apple-container",
2659 "Apple container runtime",
2660 "The daemon is running, but the required disposable smoke test was not requested.",
2661 "Run `mj doctor --json --smoke`.",
2662 );
2663 }
2664
2665 let target = RuntimeTargetTemplate::AppleContainer(RuntimeContainerTemplate {
2666 build_cache: None,
2667 image,
2668 pull_policy: Default::default(),
2669 extra_run_args: vec![],
2670 workspace_storage: Default::default(),
2671 });
2672 match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
2673 Ok(()) => DoctorCheck::ready(
2674 "runtime.apple-container",
2675 "Apple container runtime",
2676 "Installed, daemon running, and disposable run/exec/remove smoke test passed.",
2677 ),
2678 Err(error) => DoctorCheck::fixable(
2679 "runtime.apple-container",
2680 "Apple container runtime",
2681 format!("Disposable run/exec/remove smoke test failed: {error:#}"),
2682 "Fix the configured image or container runtime, then run `mj doctor --json --smoke` again.",
2683 ),
2684 }
2685}
2686
2687fn apple_container_daemon_check(executor: &impl CommandExecutor) -> DoctorCheck {
2692 let installed =
2693 CommandSpec::new("container", ["--version"]).purpose("check Apple container installation");
2694 match executor.execute(&installed) {
2695 Err(error) => {
2696 return DoctorCheck::fixable(
2697 "runtime.apple-container",
2698 "Apple container runtime",
2699 format!(
2700 "The `container` command is not available: {}",
2701 error.root_cause()
2702 ),
2703 format!("Install the official signed package: {APPLE_CONTAINER_INSTALL_URL}"),
2704 );
2705 }
2706 Ok(output) if output.status != 0 => {
2707 return DoctorCheck::fixable(
2708 "runtime.apple-container",
2709 "Apple container runtime",
2710 format!(
2711 "The installed `container --version` command failed: {}",
2712 String::from_utf8_lossy(&output.stderr).trim()
2713 ),
2714 format!("Reinstall the official signed package: {APPLE_CONTAINER_INSTALL_URL}"),
2715 );
2716 }
2717 Ok(_) => {}
2718 }
2719
2720 let status =
2721 CommandSpec::new("container", ["system", "status"]).purpose("check Apple container daemon");
2722 match executor.execute(&status) {
2723 Ok(output) if output.status == 0 => DoctorCheck::ready(
2724 "runtime.apple-container",
2725 "Apple container runtime",
2726 "Installed, and the Apple container daemon is running.",
2727 ),
2728 Ok(output) => DoctorCheck::fixable(
2729 "runtime.apple-container",
2730 "Apple container runtime",
2731 format!(
2732 "The Apple container daemon is stopped: {}",
2733 String::from_utf8_lossy(&output.stderr).trim()
2734 ),
2735 "Run `container system start`.",
2736 ),
2737 Err(error) => DoctorCheck::fixable(
2738 "runtime.apple-container",
2739 "Apple container runtime",
2740 format!("Could not query the Apple container daemon: {error}"),
2741 "Run `container system start`.",
2742 ),
2743 }
2744}
2745
2746pub fn current_apple_platform(executor: &impl CommandExecutor) -> ApplePlatform {
2747 if cfg!(target_os = "linux") {
2748 return ApplePlatform::Linux;
2749 }
2750 if !cfg!(target_os = "macos") {
2751 return ApplePlatform::Other(std::env::consts::OS.into());
2752 }
2753 let major_version = executor
2754 .execute(&CommandSpec::new("sw_vers", ["-productVersion"]).purpose("detect macOS version"))
2755 .ok()
2756 .filter(|output| output.status == 0)
2757 .and_then(|output| {
2758 String::from_utf8(output.stdout)
2759 .ok()
2760 .and_then(|value| value.trim().split('.').next()?.parse().ok())
2761 })
2762 .unwrap_or(0);
2763 ApplePlatform::Macos {
2764 architecture: std::env::consts::ARCH.into(),
2765 major_version,
2766 }
2767}
2768
2769#[cfg(test)]
2770mod tests;
2771
2772#[derive(Debug, Default, PartialEq, Eq)]
2774pub(crate) struct ReviewResidue {
2775 pub refs: Vec<String>,
2777 pub scratch_indexes: Vec<PathBuf>,
2779}
2780
2781impl ReviewResidue {
2782 fn is_empty(&self) -> bool {
2783 self.refs.is_empty() && self.scratch_indexes.is_empty()
2784 }
2785}
2786
2787pub(crate) fn review_residue(repository: &Path) -> ReviewResidue {
2794 let mut residue = ReviewResidue::default();
2795 let git_dir = repository.join(".git");
2796 if !git_dir.exists() {
2797 return residue;
2798 }
2799 for reference in ["review-baseline", "review-capture"] {
2800 if git_dir.join("refs/hel").join(reference).is_file() {
2801 residue.refs.push(format!("refs/hel/{reference}"));
2802 }
2803 }
2804 if let Ok(packed) = std::fs::read_to_string(git_dir.join("packed-refs")) {
2806 for line in packed.lines() {
2807 if let Some((_, reference)) = line.split_once(' ')
2808 && reference.starts_with("refs/hel/")
2809 && !residue.refs.iter().any(|known| known == reference)
2810 {
2811 residue.refs.push(reference.to_owned());
2812 }
2813 }
2814 }
2815 if let Ok(entries) = std::fs::read_dir(&git_dir) {
2816 for entry in entries.filter_map(Result::ok) {
2817 if entry
2818 .file_name()
2819 .to_str()
2820 .is_some_and(|name| name.starts_with("hel-review-index-"))
2821 {
2822 residue.scratch_indexes.push(entry.path());
2823 }
2824 }
2825 }
2826 residue.refs.sort();
2827 residue.scratch_indexes.sort();
2828 residue
2829}
2830
2831pub(crate) fn review_residue_repositories(
2842 configured: impl IntoIterator<Item = PathBuf>,
2843 sessions: &[&mj_core::state::SessionRecord],
2844) -> Vec<PathBuf> {
2845 use mj_core::state::ManagedCheckoutKind;
2846
2847 let managed_roots = sessions
2848 .iter()
2849 .filter_map(|session| session.managed_worktree.as_ref())
2850 .map(|worktree| worktree.worktree_root.as_path())
2851 .collect::<Vec<_>>();
2852 let mut in_use = Vec::new();
2853 for session in sessions.iter().filter(|session| session.state.is_active()) {
2854 if let Some(directory) = &session.project_directory {
2855 in_use.push(directory.as_path());
2856 }
2857 if let Some(worktree) = &session.managed_worktree
2858 && worktree.kind == ManagedCheckoutKind::Worktree
2859 {
2860 in_use.push(worktree.source_repository.as_path());
2861 }
2862 }
2863 let mut repositories = configured
2864 .into_iter()
2865 .chain(
2866 sessions
2867 .iter()
2868 .filter_map(|session| session.project_directory.clone()),
2869 )
2870 .filter(|repository| {
2871 !is_inside_managed_checkout(repository)
2872 && !managed_roots
2873 .iter()
2874 .any(|root| repository.starts_with(root))
2875 && !in_use.iter().any(|directory| repository == directory)
2876 })
2877 .collect::<Vec<_>>();
2878 repositories.sort();
2879 repositories.dedup();
2880 repositories
2881}
2882
2883fn is_inside_managed_checkout(path: &Path) -> bool {
2886 let components = path
2887 .components()
2888 .map(|component| component.as_os_str())
2889 .collect::<Vec<_>>();
2890 components
2891 .windows(3)
2892 .any(|window| window[0] == ".mj" && (window[1] == "clones" || window[1] == "worktrees"))
2893}
2894
2895fn review_residue_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
2901 let Ok(config) = config else {
2902 return Vec::new();
2903 };
2904 let configured = config
2905 .bundles
2906 .values()
2907 .flat_map(|bundle| bundle.repositories.iter())
2908 .filter_map(|repository| repository.local.clone());
2909 let state = crate::database::load_state().ok();
2912 let sessions = state
2913 .as_ref()
2914 .map(|state| state.sessions.values().collect::<Vec<_>>())
2915 .unwrap_or_default();
2916 let repositories = review_residue_repositories(configured, &sessions);
2917 if repositories.is_empty() {
2918 return Vec::new();
2919 }
2920 let found = repositories
2921 .into_iter()
2922 .map(|repository| {
2923 let residue = review_residue(&repository);
2924 (repository, residue)
2925 })
2926 .filter(|(_, residue)| !residue.is_empty())
2927 .collect::<Vec<_>>();
2928 if found.is_empty() {
2929 return vec![DoctorCheck::ready(
2930 "review.residue",
2931 "Review leftovers in your repositories",
2932 "No Mjolnir refs or scratch index files were found in the configured repositories.",
2933 )];
2934 }
2935 let detail = found
2936 .iter()
2937 .map(|(repository, residue)| {
2938 let mut parts = Vec::new();
2939 if !residue.refs.is_empty() {
2940 parts.push(residue.refs.join(", "));
2941 }
2942 if !residue.scratch_indexes.is_empty() {
2943 parts.push(mj_core::text::counted(
2944 residue.scratch_indexes.len(),
2945 "leftover scratch index file",
2946 "leftover scratch index files",
2947 ));
2948 }
2949 format!("{}: {}", repository.display(), parts.join("; "))
2950 })
2951 .collect::<Vec<_>>()
2952 .join(". ");
2953 let commands = found
2954 .iter()
2955 .flat_map(|(repository, residue)| {
2956 let repository = repository.display().to_string();
2957 let mut commands = residue
2958 .refs
2959 .iter()
2960 .map(|reference| format!("git -C {repository} update-ref -d {reference}"))
2961 .collect::<Vec<_>>();
2962 commands.extend(
2963 residue
2964 .scratch_indexes
2965 .iter()
2966 .map(|index| format!("rm -f {}", index.display())),
2967 );
2968 commands.push(format!("git -C {repository} gc --prune=now"));
2969 commands
2970 })
2971 .collect::<Vec<_>>()
2972 .join("\n");
2973 vec![DoctorCheck::fixable(
2974 "review.residue",
2975 "Review leftovers in your repositories",
2976 format!("Mjolnir left these in repositories it does not own: {detail}."),
2977 format!("Remove them yourself when you are ready:\n{commands}"),
2978 )]
2979}
2980
2981fn bifrost_check(config: ConfigStatus<'_>, executor: &impl CommandExecutor) -> Option<DoctorCheck> {
2989 let config = config.ok()?;
2990 if !mj_core::review::settings::can_review(config) {
2991 return None;
2992 }
2993 Some(bifrost_check_for(
2994 &mj_review::bifrost::bifrost_binary(),
2995 executor,
2996 ))
2997}
2998
2999fn bifrost_check_for(binary: &Path, executor: &impl CommandExecutor) -> DoctorCheck {
3000 const ID: &str = "review.bifrost";
3001 const TITLE: &str = "Bifrost for turn review";
3002 let required = mj_review::bifrost::REQUIRED_BIFROST_VERSION;
3003 let shown = binary.display();
3004 let remediation = format!(
3005 "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`.",
3006 env = mj_review::bifrost::BIFROST_BIN_ENV,
3007 );
3008 let command = CommandSpec::new(binary.display().to_string(), ["--version"])
3009 .purpose("read the Bifrost version used by turn review");
3010 let output = match executor.execute(&command) {
3011 Ok(output) if output.status == 0 => output,
3012 Ok(output) => {
3013 return DoctorCheck::warning(
3014 ID,
3015 TITLE,
3016 format!("`{shown} --version` exited with status {}.", output.status),
3017 remediation,
3018 );
3019 }
3020 Err(error) => {
3021 return DoctorCheck::warning(
3022 ID,
3023 TITLE,
3024 format!("Could not run `{shown}` for the turn review: {error}"),
3025 remediation,
3026 );
3027 }
3028 };
3029 let text = String::from_utf8_lossy(&output.stdout);
3030 let first_line = text.lines().next().unwrap_or_default().trim();
3031 let version = first_line
3032 .split_whitespace()
3033 .next_back()
3034 .and_then(|token| semver::Version::parse(token).ok());
3035 let minimum = semver::Version::parse(required).expect("the required Bifrost version is semver");
3036 match version {
3037 Some(version) if version >= minimum => DoctorCheck::ready(
3038 ID,
3039 TITLE,
3040 format!("`{shown}` is Bifrost {version}; turn review needs {required} or later."),
3041 ),
3042 Some(version) => DoctorCheck::warning(
3043 ID,
3044 TITLE,
3045 format!(
3046 "`{shown}` is Bifrost {version}, older than the {required} turn review needs, so every review would fail."
3047 ),
3048 remediation,
3049 ),
3050 None => DoctorCheck::warning(
3051 ID,
3052 TITLE,
3053 format!("`{shown} --version` printed {first_line:?}, which does not name a version."),
3054 remediation,
3055 ),
3056 }
3057}