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(storage_checks(config, executor));
219 checks.extend(build_cache_checks(offered, executor));
220 checks.extend(build_cache_release_check(
221 &crate::controller::recent_release_failures(),
222 ));
223 checks.extend(aws_checks(config, executor));
224 checks.extend(worker_binary_checks(offered));
225 checks.extend(ssh_bare_worker_checks(config, executor));
226 checks.push(daemon_build_check());
227 checks.extend(worker_freshness_checks(offered));
228 checks.extend(review_residue_checks(config));
229 checks.extend(bifrost_check(config, executor));
230 checks.push(apple_container);
232 checks
233}
234
235fn build_cache_checks(
236 config: ConfigStatus<'_>,
237 executor: &impl CommandExecutor,
238) -> Vec<DoctorCheck> {
239 let Ok(config) = config else {
240 return Vec::new();
241 };
242 crate::controller::doctor_host_mbx(config, executor)
243 .into_iter()
244 .map(|host| {
245 let id = format!("build-cache.{}", host.host);
246 let title = format!("Build cache on {}", host.host);
247 let targets = host.targets.join(", ");
248 match host.status {
249 crate::controller::DoctorHostMbxStatus::Unsupported(reason) =>
250 DoctorCheck::unsupported(id, title, reason),
251 crate::controller::DoctorHostMbxStatus::Absent => DoctorCheck::ready(
252 id,
253 title,
254 format!(
255 "No native mbx is installed; targets {targets} can use Mjolnir's mbx {}.",
256 crate::controller::MBX_VERSION
257 ),
258 ),
259 crate::controller::DoctorHostMbxStatus::Compatible(version) => DoctorCheck::ready(
260 id,
261 title,
262 format!(
263 "Host mbx {version} is compatible with Mjolnir's mbx {} for targets {targets}.",
264 crate::controller::MBX_VERSION
265 ),
266 ),
267 crate::controller::DoctorHostMbxStatus::TooOld(version) => DoctorCheck::warning(
268 id,
269 title,
270 format!(
271 "Host mbx {version} is older than Mjolnir's mbx {}; sessions on targets {targets} run without the shared build cache.",
272 crate::controller::MBX_VERSION
273 ),
274 format!(
275 "Upgrade mbx on {} to {} or newer, then rerun `mj doctor`.",
276 host.host,
277 crate::controller::MBX_VERSION
278 ),
279 ),
280 crate::controller::DoctorHostMbxStatus::Unknown(error) => DoctorCheck::warning(
281 id,
282 title,
283 format!("Could not check host mbx for targets {targets}: {error}"),
284 format!(
285 "Check access to {} and run `mbx --version` there, then rerun `mj doctor`.",
286 host.host
287 ),
288 ),
289 }
290 })
291 .collect()
292}
293
294fn build_cache_release_check(
297 failures: &[crate::controller::ReleaseFailure],
298) -> Option<DoctorCheck> {
299 let latest = failures.last()?;
300 let days = crate::controller::FAILURE_REPORT_SECS / (24 * 60 * 60);
301 let count = failures.len();
302 let releases = if count == 1 { "release" } else { "releases" };
303 Some(DoctorCheck::warning(
304 "build-cache.release",
305 "Build output of removed sessions",
306 format!(
307 "{count} build cache {releases} failed in the last {days} days, so mbx still holds \
308 build output for workspaces Mjolnir removed. Latest, on {}: {}",
309 latest.host, latest.error
310 ),
311 format!(
312 "On {}, run: {}. mbx also removes this output once it reaches its target.max_age.",
313 latest.host, latest.remediation
314 ),
315 ))
316}
317
318fn harness_discovery_check(
319 config: ConfigStatus<'_>,
320 executor: &impl CommandExecutor,
321) -> DoctorCheck {
322 let home = dirs::home_dir();
323 let overrides = HarnessKind::ALL.into_iter().filter_map(|kind| {
324 std::env::var_os(kind.home_env()).map(|path| (kind, kind.home_from_environment(path)))
325 });
326 let discovered = discover_harness_homes_with_executor(home.as_deref(), overrides, executor);
327 harness_discovery_check_from(
328 &discovered,
329 config.is_ok_and(|config| !config.profiles.is_empty()),
330 &settings_key(config.ok()),
331 )
332}
333
334fn harness_discovery_check_from(
335 discovered: &[DiscoveredHome],
336 has_configured_profiles: bool,
337 settings_key: &str,
338) -> DoctorCheck {
339 if discovered.is_empty() {
340 return if has_configured_profiles {
341 DoctorCheck::ready(
342 "harness.discovery",
343 "Harness home discovery",
344 "No default or environment-overridden harness homes were found; configured profile homes are checked below.",
345 )
346 } else {
347 DoctorCheck::fixable(
348 "harness.discovery",
349 "Harness home discovery",
350 format!(
351 "No {} home was found in the default or environment-overridden locations.",
352 HarnessKind::every_display_name_or()
353 ),
354 format!(
355 "Install and sign in to a supported harness, then open Mjolnir, press {settings_key} for Settings, and choose Agent Profiles."
356 ),
357 )
358 };
359 }
360
361 let homes = discovered
362 .iter()
363 .map(|home| {
364 let authentication = if home.authenticated {
365 "authenticated"
366 } else {
367 "not authenticated"
368 };
369 format!(
370 "{} at {} ({authentication})",
371 home.kind.display_name(),
372 home.path.display()
373 )
374 })
375 .collect::<Vec<_>>()
376 .join("; ");
377 DoctorCheck::ready(
378 "harness.discovery",
379 "Harness home discovery",
380 format!("Discovered {homes}. Configured profile authentication is checked below."),
381 )
382}
383
384fn settings_key(config: Option<&Config>) -> String {
387 let keybinds = config.map_or_else(mj_core::config::Keybinds::default, Config::keybinds);
388 keybinds
389 .labels(mj_core::config::KeyAction::OpenSettings)
390 .into_iter()
391 .next()
392 .unwrap_or_else(|| "the Settings command in the command palette".to_owned())
393}
394
395pub fn all_ready(checks: &[DoctorCheck]) -> bool {
396 checks
397 .iter()
398 .all(|check| check.status != CheckStatus::Fixable)
399}
400
401pub fn render_human(checks: &[DoctorCheck], output: &mut impl Write) -> Result<()> {
402 for check in checks {
403 writeln!(
404 output,
405 "{} {}: {}",
406 check.status.label(),
407 check.title,
408 check.detail
409 )?;
410 if let Some(remediation) = &check.remediation {
411 writeln!(output, " remediation: {remediation}")?;
412 }
413 }
414 Ok(())
415}
416
417pub fn setup_instructions(platform: InstructionsPlatform) -> String {
418 match platform {
419 InstructionsPlatform::Linux => format!(
420 "# Mjolnir setup instructions for Linux\n\n\
421This page is self-contained. Follow this exact loop as the user who will run `mj`:\n\n\
4221. Run `mj doctor --json`.\n\
4232. Follow every `fixable` remediation from its JSON output.\n\
4243. Run `mj doctor --json` again. Repeat until no check is `fixable`.\n\
4254. Finish with `mj doctor --json --smoke` to verify every configured container\n\
426 image end to end, and resolve anything it reports as `fixable`.\n\n\
427For a coding-agent handoff, provide this entire instructions page together with\n\
428the latest `mj doctor --json` output.\n\n\
429## Local bare runtime\n\n\
430A local bare runtime runs the agent directly on this machine. It needs the\n\
431native `mj-worker` installed beside `mj`; the release installer and the npm\n\
432package include it. The worker installs the pinned harness version itself.\n\
433Codex and Claude need Node.js 22 or newer and npm on `PATH`. Kimi and Grok\n\
434need curl and Bash. Muse needs curl and tar. Mjolnir does not install these\n\
435prerequisites.\n\n\
436## Linux container-runtime postconditions\n\n{}\n\n{}",
437 crate::targets::PODMAN_DOCUMENTATION,
438 crate::targets::DOCKER_DOCUMENTATION
439 ),
440 InstructionsPlatform::Macos => format!(
441 "# Mjolnir setup instructions for macOS\n\n\
442Run these commands as the user who will run `mj`:\n\n\
4431. Install and sign in to a supported coding agent, then run `mj setup`.\n\
444 Setup discovers agent homes and local runtimes, offers AWS when the AWS CLI\n\
445 has credentials, and offers SSH hosts from `~/.ssh/config`. Review its\n\
446 proposed configuration and confirm writing `config.toml`. It runs smoke\n\
447 tests for newly added container targets and ends with a doctor report.\n\
4482. Run `mj doctor --json` and follow every `fixable` remediation. Repeat until\n\
449 no check is `fixable`. Review warnings for any target you intend to use;\n\
450 `unsupported` runtimes are unavailable, not ready.\n\
4513. If you intend to use containers, start the runtime and run\n\
452 `mj doctor --json --smoke`. Do this for Docker Desktop as well as Apple\n\
453 container. Resolve failures for the target you intend to use.\n\
4544. Run `mj`, then press n in the Sessions pane to start your first session.\n\
455 If no profile was discovered, open Settings (Ctrl-B then s by default)\n\
456 to add one first.\n\n\
457You can also configure profiles and targets in Settings instead of `mj setup`.\n\
458For a named instance, use the same `--instance <name>` on every command above.\n\n\
459For a coding-agent handoff, provide this entire instructions page together with\n\
460the latest `mj doctor --json` output.\n\n\
461## Local bare runtime\n\n\
462A local bare runtime runs the agent directly on this machine. It needs the\n\
463native `mj-worker` installed beside `mj`; the release installer, npm package,\n\
464and Homebrew formula include it (Homebrew keeps both binaries in libexec).\n\
465The worker installs the pinned harness version itself. Codex and Claude need\n\
466Node.js 22 or newer and npm on `PATH`. Kimi and Grok need curl and Bash.\n\
467Muse needs curl and tar. Mjolnir does not install these prerequisites.\n\n\
468## Docker Desktop\n\n\
469Start Docker Desktop and wait for its Linux daemon to be ready before setup\n\
470or the smoke test. `mj doctor --json --smoke` checks the container image and\n\
471a host directory attachment, including disposable container cleanup. Docker\n\
472Desktop's VM cannot overlay host directories, so attachments mount read-only\n\
473there. A connected daemon alone does not establish that these operations work.\n\n\
474## Apple container runtime\n\n\
475Mjolnir's Apple container target requires Apple silicon and macOS 26 or newer.\n\
476On an Intel Mac or an older macOS release, the target is unsupported; use the\n\
477local bare runtime, an SSH target, or an AWS target instead.\n\n\
478On a supported Mac, if `container` is absent, install only the official signed package:\n\n\
479<https://github.com/apple/container#initial-install>\n\n\
480Mjolnir never downloads or installs that package. If doctor reports a stopped\n\
481daemon, run exactly:\n\n```console\ncontainer system start\n```\n\n\
482Finish with the opt-in disposable runtime test in JSON mode:\n\n```console\nmj doctor --json --smoke\n```\n\n\
483Apple container is ready only when that smoke test creates a disposable\n\
484container, executes `true` in it, and removes it successfully. Use the image\n\
485configured by an `apple-container` target; without one, doctor uses\n\
486`{DEFAULT_CONTAINER_IMAGE}` for the smoke test.\n\n\
487## Shared Mjolnir prerequisites\n\n\
488`mj doctor --json` also checks the configuration, each configured harness home\n\
489and authentication marker, selected container worker binaries, and any relevant\n\
490Podman prerequisites. Resolve every `fixable` status before starting a session.\n"
491 ),
492 }
493}
494
495#[derive(Debug, Clone, Copy, PartialEq, Eq)]
503enum ConfigGap {
504 NewerVersion(u32),
506 Missing,
509 Unreadable,
511}
512
513impl ConfigGap {
514 fn awaited(self) -> &'static str {
517 match self {
518 ConfigGap::NewerVersion(_) => "this Mjolnir can read config.toml",
519 ConfigGap::Missing => "config.toml exists",
520 ConfigGap::Unreadable => "config.toml is valid",
521 }
522 }
523
524 fn remediation(self) -> &'static str {
530 match self {
531 ConfigGap::NewerVersion(_) => {
532 "Update Mjolnir to the build that wrote config.toml or newer, then rerun `mj doctor`."
533 }
534 ConfigGap::Missing => {
535 "Run `mj` and add an agent profile, or run `mj setup`; either one writes config.toml. Then rerun `mj doctor`."
536 }
537 ConfigGap::Unreadable => "Fix config.toml, then rerun `mj doctor`.",
538 }
539 }
540}
541
542type ConfigStatus<'a> = std::result::Result<&'a Config, ConfigGap>;
545
546fn newer_config_skip(id: &str, title: &str, version: u32) -> DoctorCheck {
551 DoctorCheck::unsupported(
552 id,
553 title,
554 format!(
555 "Skipped: config.toml was written by a newer Mjolnir (config version {version}; this build supports {}). Update Mjolnir to that build or newer.",
556 mj_core::config::CONFIG_VERSION
557 ),
558 )
559}
560
561fn configuration_checks(path: &Path) -> (std::result::Result<Config, ConfigGap>, Vec<DoctorCheck>) {
562 if !path.exists() {
563 return (
564 Err(ConfigGap::Missing),
565 vec![DoctorCheck::fixable(
566 "config",
567 "Mjolnir configuration",
568 format!("{} does not exist", path.display()),
569 format!(
570 "Open Mjolnir and press {} for Settings to add an agent profile.",
571 settings_key(None)
572 ),
573 )],
574 );
575 }
576 if let Some(found) = mj_core::config::newer_version_on_disk(path) {
580 return (
581 Err(ConfigGap::NewerVersion(found)),
582 vec![DoctorCheck::fixable(
583 "config",
584 "Mjolnir configuration",
585 format!(
586 "{} was written by a newer Mjolnir (config version {found}; this build supports {})",
587 path.display(),
588 mj_core::config::CONFIG_VERSION
589 ),
590 "Update Mjolnir to that build or newer. Do not lower the version value by hand or replace the file.",
591 )],
592 );
593 }
594 match Config::load_from(path) {
595 Ok(config) => {
596 let mut checks = vec![DoctorCheck::ready(
597 "config",
598 "Mjolnir configuration",
599 format!("{} is valid", path.display()),
600 )];
601 if config.enabled_profiles().next().is_none() {
605 checks.push(DoctorCheck::fixable(
606 "config.session-prerequisites",
607 "Session configuration",
608 "No agent profile is enabled, so no session can start. Local targets are supplied automatically.",
609 format!(
610 "Open Mjolnir and press {} for Settings to add or enable an agent profile.",
611 settings_key(Some(&config))
612 ),
613 ));
614 } else if config.bundles.is_empty() {
615 checks.push(DoctorCheck::ready(
616 "config.session-prerequisites",
617 "Session configuration",
618 "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.",
619 ));
620 } else {
621 checks.push(DoctorCheck::ready(
622 "config.session-prerequisites",
623 "Session configuration",
624 "At least one profile, bundle, and target are configured.",
625 ));
626 }
627 (Ok(config), checks)
628 }
629 Err(error) => (
630 Err(ConfigGap::Unreadable),
631 vec![DoctorCheck::fixable(
632 "config",
633 "Mjolnir configuration",
634 format!("{} is invalid: {error:#}", path.display()),
635 "Fix the reported TOML error in config.toml, or run `mj setup` to replace it.",
636 )],
637 ),
638 }
639}
640
641fn harness_checks(config: ConfigStatus<'_>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
642 let config = match config {
643 Ok(config) => config,
644 Err(ConfigGap::NewerVersion(version)) => {
645 return vec![newer_config_skip(
646 "harness.profiles",
647 "Harness profiles",
648 version,
649 )];
650 }
651 Err(gap @ (ConfigGap::Missing | ConfigGap::Unreadable)) => {
652 return vec![DoctorCheck::fixable(
653 "harness.profiles",
654 "Harness profiles",
655 format!("Harness homes cannot be checked until {}.", gap.awaited()),
656 gap.remediation(),
657 )];
658 }
659 };
660 if config.profiles.is_empty() {
661 return vec![DoctorCheck::fixable(
662 "harness.profiles",
663 "Harness profiles",
664 "No harness profiles are configured.",
665 format!(
666 "Open Mjolnir, press {} for Settings, and choose Agent Profiles to detect accounts or add a profile.",
667 settings_key(Some(config))
668 ),
669 )];
670 }
671 config
672 .profiles
673 .iter()
674 .map(|(id, profile)| {
675 let mut check = harness_profile_check(id, profile, executor);
676 check.detail = format!("{} {}", profile_summary(config, id, profile), check.detail);
677 check
678 })
679 .collect()
680}
681
682fn harness_profile_check(
684 id: &str,
685 profile: &HarnessProfile,
686 executor: &impl CommandExecutor,
687) -> DoctorCheck {
688 let title = format!("Harness profile {id}");
689 if !profile.enabled {
690 return DoctorCheck::ready(
691 format!("harness.{id}"),
692 title,
693 "Profile is disabled; home and authentication checks were skipped.",
694 );
695 }
696 if !profile.home.is_dir() {
697 return DoctorCheck::fixable(
698 format!("harness.{id}"),
699 title,
700 format!("{} does not exist", profile.home.display()),
701 format!(
702 "{} If this profile should use an existing installation, select its home in Setup.",
703 harness_login_remediation(id, profile)
704 ),
705 );
706 }
707 if let Err(error) = profile.ensure_ready(id) {
708 return DoctorCheck::fixable(
709 format!("harness.{id}"),
710 title,
711 format!("{error:#}"),
712 "Make the change named above. Sessions on this profile are refused until then; the rest of Mjolnir keeps working.",
713 );
714 }
715 if !harness_is_authenticated_with_executor(profile, executor) {
716 return DoctorCheck::fixable(
717 format!("harness.{id}"),
718 title,
719 format!(
720 "No usable authentication was detected for {}",
721 profile.home.display()
722 ),
723 harness_login_remediation(id, profile),
724 );
725 }
726 DoctorCheck::ready(
727 format!("harness.{id}"),
728 title,
729 format!(
730 "{} is present and authentication is available",
731 profile.home.display()
732 ),
733 )
734}
735
736fn profile_summary(config: &Config, id: &str, profile: &HarnessProfile) -> String {
741 let delegation = if config
742 .subagents
743 .eligible_profiles
744 .get(id)
745 .copied()
746 .unwrap_or(false)
747 {
748 "any session's sub-agents may use it"
749 } else {
750 "only its own sessions' sub-agents may use it"
751 };
752 format!(
753 "{}; {}; {delegation}.",
754 profile.kind.display_name(),
755 profile_quota_source(profile)
756 )
757}
758
759fn profile_quota_source(profile: &HarnessProfile) -> String {
763 match profile.kind {
764 HarnessKind::Claude => "Claude subscription quota".to_owned(),
765 HarnessKind::Codex => match crate::quota::provider_credential(profile) {
766 Some(provider) if crate::zai_usage::serves_quota(&provider.host) => {
767 format!("quota from {}", provider.host)
768 }
769 Some(provider) => format!(
770 "pay-per-use through {}, counted as 100% left when choosing a sub-agent's profile",
771 provider.host
772 ),
773 None => match profile.codex_provider() {
774 Ok(None) => "ChatGPT subscription quota".to_owned(),
775 Ok(Some(provider)) => format!(
776 "no quota report, because custom provider {:?} has no API key in this profile's environment",
777 provider.id
778 ),
779 Err(error) => format!("its Codex config.toml could not be read ({error:#})"),
780 },
781 },
782 kind => format!("quota as {} reports it", kind.display_name()),
783 }
784}
785
786fn secret_checks(config: ConfigStatus<'_>, config_path: &Path) -> Vec<DoctorCheck> {
794 use mj_core::config::{secrets_path_beside, secrets_permission_problem};
795
796 let mut checks = Vec::new();
797 let secrets = secrets_path_beside(config_path);
798 if secrets.exists() {
799 checks.push(match secrets_permission_problem(&secrets) {
800 Ok(None) => DoctorCheck::ready(
801 "secrets.file",
802 "Secrets file",
803 format!("{} is readable only by its owner", secrets.display()),
804 ),
805 Ok(Some(problem)) => DoctorCheck::warning(
806 "secrets.file",
807 "Secrets file",
808 problem,
809 format!("Run `chmod 600 {}`.", secrets.display()),
810 ),
811 Err(error) => DoctorCheck::warning(
812 "secrets.file",
813 "Secrets file",
814 format!("{error:#}"),
815 "Make the file readable by the user running Mjolnir.",
816 ),
817 });
818 }
819 let Ok(config) = config else {
820 return checks;
821 };
822 for (id, target) in &config.targets {
825 if let Err(error) = target.ensure_ready(id) {
826 checks.push(DoctorCheck::fixable(
827 format!("targets.{id}.environment"),
828 format!("Target {id} environment"),
829 format!("{error:#}"),
830 "Make the change named above. Sessions on this target are refused until then.",
831 ));
832 }
833 }
834 let profiles = config
835 .profiles
836 .iter()
837 .map(|(id, profile)| (format!("profiles.{id}"), &profile.environment));
838 let targets = config.targets.iter().filter_map(|(id, target)| {
839 target
840 .container()
841 .map(|container| (format!("targets.{id}"), &container.environment))
842 });
843 for (owner, environment) in profiles.chain(targets) {
844 if let Some(check) = plain_text_credential_check(&owner, environment, &secrets) {
845 checks.push(check);
846 }
847 }
848 checks
849}
850
851fn plain_text_credential_check(
854 owner: &str,
855 environment: &mj_core::config::Environment,
856 secrets: &Path,
857) -> Option<DoctorCheck> {
858 let names = environment
859 .sources()
860 .iter()
861 .filter(|(name, value)| {
862 !value.is_reference() && mj_core::config::looks_like_credential(name)
863 })
864 .map(|(name, _)| name.as_str())
865 .collect::<Vec<_>>();
866 if names.is_empty() {
867 return None;
868 }
869 let example = names[0];
870 Some(DoctorCheck::warning(
871 format!("{owner}.secrets"),
872 "Credentials in config.toml",
873 format!(
874 "[{owner}.environment] holds {} as plain text; config.toml is copied into isolated instances and read as ordinary configuration",
875 names.join(", ")
876 ),
877 format!(
878 "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.",
879 secrets.display()
880 ),
881 ))
882}
883
884fn subagent_eligibility_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
894 let Ok(config) = config else {
895 return Vec::new();
896 };
897 let mut checks = vec![subagent_policy_check(config)];
898 checks.extend(
899 config
900 .subagents
901 .eligible_profiles
902 .iter()
903 .filter(|(_, eligible)| **eligible)
904 .filter_map(|(id, _)| match config.profiles.get(id) {
905 Some(profile) if !profile.enabled => Some(DoctorCheck::warning(
906 format!("subagents.{id}"),
907 format!("Sub-agent profile {id}"),
908 format!(
909 "Profile {id:?} is listed in [subagents.eligible_profiles] but is disabled, so it is not offered for sub-agent use."
910 ),
911 format!(
912 "Re-enable profile {id:?}, or remove it from [subagents.eligible_profiles]."
913 ),
914 )),
915 _ => None,
916 }),
917 );
918 checks
919}
920
921fn subagent_policy_check(config: &Config) -> DoctorCheck {
925 let subagents = &config.subagents;
926 let eligible = subagents
927 .eligible_profiles
928 .iter()
929 .filter(|(_, eligible)| **eligible)
930 .map(|(id, _)| id.as_str())
931 .collect::<Vec<_>>();
932 let others = if eligible.is_empty() {
933 "no other profile".to_owned()
934 } else {
935 eligible.join(", ")
936 };
937 let detail = format!(
938 "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}.",
939 subagents.max_concurrent
940 );
941 DoctorCheck::ready("subagents.policy", "Sub-agent policy", detail)
942}
943
944fn harness_login_remediation(id: &str, profile: &HarnessProfile) -> String {
951 let (program, arguments) = match login_command(profile) {
952 Ok(command) => command,
953 Err(error) => return format!("{error} Check {}.", profile.home.display()),
957 };
958 format!(
959 "Run `mj login --profile {id}`; it runs `{program} {}` against {}.",
960 arguments.join(" "),
961 profile.home.display()
962 )
963}
964
965fn podman_checks(
970 config: ConfigStatus<'_>,
971 executor: &impl CommandExecutor,
972 smoke: bool,
973 platform: &ApplePlatform,
974) -> Vec<DoctorCheck> {
975 let effective = config.map(|config| config.clone().with_local_targets());
976 let effective = effective.as_ref().map_err(|gap| *gap);
977 let explicit = config.is_ok_and(|config| {
978 local_podman_targets(config)
979 .iter()
980 .any(|(id, _)| config.configures_target(id))
981 });
982 let preflight = builtin_target_availability(
983 podman_check(effective, executor, platform),
984 explicit,
985 "Podman",
986 "podman",
987 );
988 let preflight_passed = preflight.status == CheckStatus::Ready;
989 let mut checks = vec![preflight];
990 if preflight_passed {
991 checks.extend(
992 podman_image_checks(effective, executor, smoke)
993 .into_iter()
994 .map(|(id, check)| builtin_image_check(config, &id, check, smoke)),
995 );
996 }
997 checks
998}
999
1000fn builtin_image_check(
1004 config: ConfigStatus<'_>,
1005 target_id: &str,
1006 check: DoctorCheck,
1007 smoke: bool,
1008) -> DoctorCheck {
1009 let explicit = config.is_ok_and(|config| config.configures_target(target_id));
1010 if smoke || explicit || check.status != CheckStatus::Fixable {
1013 return check;
1014 }
1015 DoctorCheck::warning(
1016 check.id,
1017 check.title,
1018 format!(
1019 "{} (built-in `{target_id}` target; the dashboard downloads its image when it starts)",
1020 check.detail
1021 ),
1022 check.remediation.unwrap_or_default(),
1023 )
1024}
1025
1026fn builtin_target_availability(
1035 check: DoctorCheck,
1036 explicit: bool,
1037 engine: &str,
1038 target_id: &str,
1039) -> DoctorCheck {
1040 if explicit || check.status != CheckStatus::Fixable {
1041 return check;
1042 }
1043 DoctorCheck::unsupported(
1044 check.id,
1045 check.title,
1046 format!(
1047 "{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: {}",
1048 check.detail
1049 ),
1050 )
1051}
1052
1053fn podman_check(
1054 config: ConfigStatus<'_>,
1055 executor: &impl CommandExecutor,
1056 platform: &ApplePlatform,
1057) -> DoctorCheck {
1058 let config = match config {
1059 Ok(config) => config,
1060 Err(ConfigGap::NewerVersion(version)) => {
1061 return newer_config_skip("runtime.podman", "Rootless Podman", version);
1062 }
1063 Err(gap @ (ConfigGap::Missing | ConfigGap::Unreadable)) => {
1064 return DoctorCheck::unsupported(
1065 "runtime.podman",
1066 "Rootless Podman",
1067 format!(
1068 "Podman prerequisites cannot be evaluated until {}.",
1069 gap.awaited()
1070 ),
1071 );
1072 }
1073 };
1074 if local_podman_targets(config).is_empty() {
1075 return DoctorCheck::unsupported(
1076 "runtime.podman",
1077 "Rootless Podman",
1078 "No local-podman target is configured.",
1079 );
1080 }
1081 local_podman_runtime_check(executor, platform)
1082}
1083
1084pub fn local_podman_runtime_check(
1091 executor: &impl CommandExecutor,
1092 platform: &ApplePlatform,
1093) -> DoctorCheck {
1094 if !matches!(platform, ApplePlatform::Linux) {
1095 return DoctorCheck::unsupported(
1096 "runtime.podman",
1097 "Rootless Podman",
1098 "Mjolnir's local Podman target requires a Linux host; Podman machine is not supported. Use localhost or an SSH target on a Linux host.",
1099 );
1100 }
1101 match verify_local_podman(executor) {
1102 Ok(preflight) => DoctorCheck::ready(
1103 "runtime.podman",
1104 "Rootless Podman",
1105 format!("Podman {} has a valid rootless UID map.", preflight.version),
1106 ),
1107 Err(error) => DoctorCheck::fixable(
1108 "runtime.podman",
1109 "Rootless Podman",
1110 podman_failure_detail(&error),
1111 podman_remediation(&error),
1112 ),
1113 }
1114}
1115
1116fn local_podman_targets(config: &Config) -> Vec<(&String, &ContainerTemplate)> {
1117 config
1118 .targets
1119 .iter()
1120 .filter_map(|(id, target)| match target {
1121 TargetTemplate::LocalPodman { container } => Some((id, container)),
1122 _ => None,
1123 })
1124 .collect()
1125}
1126
1127fn podman_image_checks(
1128 config: ConfigStatus<'_>,
1129 executor: &impl CommandExecutor,
1130 smoke: bool,
1131) -> Vec<(String, DoctorCheck)> {
1132 let Ok(config) = config else {
1133 return Vec::new();
1134 };
1135 local_podman_targets(config)
1136 .into_iter()
1137 .map(|(id, container)| {
1138 (
1139 id.clone(),
1140 podman_image_check(id, &container.image, executor, smoke),
1141 )
1142 })
1143 .collect()
1144}
1145
1146fn podman_image_check(
1147 id: &str,
1148 image: &str,
1149 executor: &impl CommandExecutor,
1150 smoke: bool,
1151) -> DoctorCheck {
1152 let check_id = format!("runtime.podman.image.{id}");
1153 let title = format!("Podman image for target {id}");
1154 if smoke {
1155 let target = RuntimeTargetTemplate::LocalPodman(RuntimeContainerTemplate {
1156 build_cache: None,
1157 image: image.to_owned(),
1158 pull_policy: Default::default(),
1159 extra_run_args: vec![],
1160 workspace_storage: Default::default(),
1161 });
1162 return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1163 Ok(()) => DoctorCheck::ready(
1164 check_id,
1165 title,
1166 format!("Disposable run/exec/remove smoke test passed for image {image}."),
1167 ),
1168 Err(error) => DoctorCheck::fixable(
1169 check_id,
1170 title,
1171 format!(
1172 "Disposable run/exec/remove smoke test failed for image {image}: {error:#}"
1173 ),
1174 "Fix the configured image or Podman runtime, then run `mj doctor --json --smoke` again.",
1175 ),
1176 };
1177 }
1178
1179 let command = CommandSpec::new("podman", ["image", "exists", image])
1180 .purpose("check Podman image presence");
1181 match executor.execute(&command) {
1182 Ok(output) if output.status == 0 => DoctorCheck::ready(
1183 check_id,
1184 title,
1185 format!("Image {image} is present in local Podman storage."),
1186 ),
1187 Ok(_) => DoctorCheck::fixable(
1188 check_id,
1189 title,
1190 format!("Image {image} is not present in local Podman storage."),
1191 missing_image_remediation(image),
1192 ),
1193 Err(error) => DoctorCheck::fixable(
1194 check_id,
1195 title,
1196 format!(
1197 "Could not check whether image {image} is present in local Podman storage: {error}"
1198 ),
1199 missing_image_remediation(image),
1200 ),
1201 }
1202}
1203
1204fn missing_image_remediation(image: &str) -> String {
1205 format!(
1206 "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."
1207 )
1208}
1209
1210fn docker_checks(
1212 config: ConfigStatus<'_>,
1213 executor: &impl CommandExecutor,
1214 smoke: bool,
1215) -> Vec<DoctorCheck> {
1216 let config = match config {
1217 Ok(config) => config,
1218 Err(ConfigGap::NewerVersion(version)) => {
1219 return vec![newer_config_skip("runtime.docker", "Docker", version)];
1220 }
1221 Err(gap @ (ConfigGap::Missing | ConfigGap::Unreadable)) => {
1222 return vec![DoctorCheck::unsupported(
1223 "runtime.docker",
1224 "Docker",
1225 format!(
1226 "Docker prerequisites cannot be evaluated until {}.",
1227 gap.awaited()
1228 ),
1229 )];
1230 }
1231 };
1232 let explicit = local_docker_targets(config)
1233 .iter()
1234 .any(|(id, _)| config.configures_target(id));
1235 let effective = config.clone().with_local_targets();
1236 let targets = local_docker_targets(&effective);
1237 if targets.is_empty() {
1238 return vec![DoctorCheck::unsupported(
1239 "runtime.docker",
1240 "Docker",
1241 "No local-docker target is configured.",
1242 )];
1243 }
1244 let preflight = builtin_target_availability(
1245 local_docker_runtime_check(executor),
1246 explicit,
1247 "Docker",
1248 "docker",
1249 );
1250 if preflight.status != CheckStatus::Ready {
1251 return vec![preflight];
1252 }
1253 let mut checks = vec![preflight];
1254 checks.extend(targets.into_iter().map(|(id, container)| {
1255 builtin_image_check(
1256 Ok(config),
1257 id,
1258 docker_image_check(id, &container.image, executor, smoke),
1259 smoke,
1260 )
1261 }));
1262 checks
1263}
1264
1265pub fn local_docker_runtime_check(executor: &impl CommandExecutor) -> DoctorCheck {
1266 match verify_local_docker(executor) {
1267 Ok(preflight) => DoctorCheck::ready(
1268 "runtime.docker",
1269 "Docker",
1270 format!(
1271 "Docker {} is connected to a Linux daemon.",
1272 preflight.version
1273 ),
1274 ),
1275 Err(error) => match error.downcast_ref::<DockerUnavailable>() {
1276 Some(problem) => DoctorCheck::fixable(
1277 "runtime.docker",
1278 "Docker",
1279 problem.to_string(),
1280 problem.remediation(),
1281 ),
1282 None => DoctorCheck::fixable(
1283 "runtime.docker",
1284 "Docker",
1285 format!("{error:#}"),
1286 "Install and start Docker, then make sure `docker info` succeeds as the user running mj.",
1287 ),
1288 },
1289 }
1290}
1291
1292fn local_docker_targets(config: &Config) -> Vec<(&String, &ContainerTemplate)> {
1293 config
1294 .targets
1295 .iter()
1296 .filter_map(|(id, target)| match target {
1297 TargetTemplate::LocalDocker { container } => Some((id, container)),
1298 _ => None,
1299 })
1300 .collect()
1301}
1302
1303fn docker_image_check(
1304 id: &str,
1305 image: &str,
1306 executor: &impl CommandExecutor,
1307 smoke: bool,
1308) -> DoctorCheck {
1309 let check_id = format!("runtime.docker.image.{id}");
1310 let title = format!("Docker image for target {id}");
1311 if smoke {
1312 let target = RuntimeTargetTemplate::LocalDocker(RuntimeContainerTemplate {
1313 build_cache: None,
1314 image: image.to_owned(),
1315 pull_policy: Default::default(),
1316 extra_run_args: vec![],
1317 workspace_storage: Default::default(),
1318 });
1319 return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1320 Ok(()) => DoctorCheck::ready(
1321 check_id,
1322 title,
1323 match local_docker_vm_share(executor) {
1324 Ok(Some(reason)) => format!(
1325 "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."
1326 ),
1327 _ => format!(
1328 "Disposable run/exec/remove and OverlayFS attachment smoke test passed for image {image}."
1329 ),
1330 },
1331 ),
1332 Err(error) => DoctorCheck::fixable(
1333 check_id,
1334 title,
1335 format!(
1336 "Disposable run/exec/remove smoke test failed for image {image}: {error:#}"
1337 ),
1338 "Fix the configured image or Docker runtime, then run `mj doctor --json --smoke` again.",
1339 ),
1340 };
1341 }
1342 let command = CommandSpec::new("docker", ["image", "inspect", image])
1343 .purpose("check Docker image presence");
1344 match executor.execute(&command) {
1345 Ok(output) if output.status == 0 => DoctorCheck::ready(
1346 check_id,
1347 title,
1348 format!("Image {image} is present in Docker storage."),
1349 ),
1350 Ok(_) => DoctorCheck::fixable(
1351 check_id,
1352 title,
1353 format!("Image {image} is not present in Docker storage."),
1354 format!("Pull it with `docker pull {image}`, or run `mj doctor --json --smoke`."),
1355 ),
1356 Err(error) => DoctorCheck::fixable(
1357 check_id,
1358 title,
1359 format!("Could not inspect Docker image {image}: {error}"),
1360 format!("Make sure `docker info` succeeds, then run `docker pull {image}`."),
1361 ),
1362 }
1363}
1364
1365enum SshConnectivity {
1370 Reachable,
1371 Failed { detail: String, remediation: String },
1372}
1373
1374fn ssh_connectivity(ssh: &RuntimeSshTarget, executor: &impl CommandExecutor) -> SshConnectivity {
1379 let destination = &ssh.destination;
1380 let command = ssh_connectivity_probe(ssh);
1381 match executor.execute(&command) {
1382 Err(error) => SshConnectivity::Failed {
1383 detail: format!("Could not run `ssh {destination} true`: {error:#}"),
1384 remediation: ssh_launch_failure_remediation(&error, ssh),
1385 },
1386 Ok(output) if output.status != 0 => {
1387 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
1388 SshConnectivity::Failed {
1389 detail: format!("`ssh {destination} true` failed: {stderr}"),
1390 remediation: ssh_failure_remediation(&stderr, ssh),
1391 }
1392 }
1393 Ok(_) => SshConnectivity::Reachable,
1394 }
1395}
1396
1397const 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).";
1398
1399#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1405enum SshFailure {
1406 UntrustedHostKey,
1407 Unauthenticated,
1408 ClientMissing,
1409 Unreachable,
1411 Unrecognized,
1412}
1413
1414fn classify_ssh_stderr(stderr: &str) -> SshFailure {
1415 const UNTRUSTED_HOST_KEY: [&str; 3] = [
1416 "Host key verification failed",
1417 "No ECDSA host key is known",
1418 "REMOTE HOST IDENTIFICATION HAS CHANGED",
1419 ];
1420 const UNAUTHENTICATED: [&str; 4] = [
1421 "Permission denied",
1422 "Too many authentication failures",
1423 "no matching host key",
1424 "Authentication failed",
1425 ];
1426 const CLIENT_MISSING: [&str; 2] = ["ssh: command not found", "No such file or directory"];
1427 const UNREACHABLE: [&str; 3] = [
1428 "Connection timed out",
1429 "No route to host",
1430 "Network is unreachable",
1431 ];
1432
1433 let reported = |signatures: &[&str]| signatures.iter().any(|text| stderr.contains(text));
1434 if reported(&UNTRUSTED_HOST_KEY) {
1435 SshFailure::UntrustedHostKey
1436 } else if reported(&UNAUTHENTICATED) {
1437 SshFailure::Unauthenticated
1438 } else if reported(&CLIENT_MISSING) {
1439 SshFailure::ClientMissing
1440 } else if reported(&UNREACHABLE) {
1441 SshFailure::Unreachable
1442 } else {
1443 SshFailure::Unrecognized
1444 }
1445}
1446
1447fn ssh_launch_failure_remediation(error: &anyhow::Error, ssh: &RuntimeSshTarget) -> String {
1450 if error.downcast_ref::<CommandTimedOut>().is_some() {
1451 return ssh_unreachable_remediation(ssh);
1452 }
1453 let missing_binary = error.chain().any(|cause| {
1454 cause
1455 .downcast_ref::<std::io::Error>()
1456 .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
1457 });
1458 if missing_binary {
1459 return SSH_MISSING_REMEDIATION.to_owned();
1460 }
1461 format!(
1462 "Run `ssh {} true` by hand and resolve the error it reports: {error:#}",
1463 ssh.destination
1464 )
1465}
1466
1467fn ssh_unreachable_remediation(ssh: &RuntimeSshTarget) -> String {
1470 let host = ssh_host_only(&ssh.destination);
1471 format!(
1472 "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.",
1473 ssh.destination
1474 )
1475}
1476
1477fn ssh_failure_remediation(stderr: &str, ssh: &RuntimeSshTarget) -> String {
1479 let destination = &ssh.destination;
1480 match classify_ssh_stderr(stderr) {
1481 SshFailure::UntrustedHostKey => {
1482 let host = ssh_host_only(destination);
1483 format!(
1484 "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."
1485 )
1486 }
1487 SshFailure::Unauthenticated => match ssh_identity_file(ssh) {
1488 Some(identity) => format!(
1489 "Install your public key on the host with `ssh-copy-id -i {identity}.pub {destination}`."
1490 ),
1491 None => {
1492 format!("Install your public key on the host with `ssh-copy-id {destination}`.")
1493 }
1494 },
1495 SshFailure::ClientMissing => SSH_MISSING_REMEDIATION.to_owned(),
1496 SshFailure::Unreachable => ssh_unreachable_remediation(ssh),
1497 SshFailure::Unrecognized => {
1498 format!(
1499 "Run `ssh {destination} true` by hand and resolve the error it reports: {stderr}"
1500 )
1501 }
1502 }
1503}
1504
1505fn ssh_host_only(destination: &str) -> &str {
1507 destination
1508 .rsplit_once('@')
1509 .map_or(destination, |(_, host)| host)
1510}
1511
1512fn ssh_identity_file(ssh: &RuntimeSshTarget) -> Option<&str> {
1514 let position = ssh.ssh_args.iter().position(|arg| arg == "-i")?;
1515 ssh.ssh_args.get(position + 1).map(String::as_str)
1516}
1517
1518fn ssh_bare_checks(config: ConfigStatus<'_>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
1520 let Ok(config) = config else {
1521 return Vec::new();
1522 };
1523 config
1524 .targets
1525 .iter()
1526 .filter_map(|(id, target)| match target {
1527 TargetTemplate::SshBare { ssh, .. } => {
1528 Some(ssh_bare_check(id, &RuntimeSshTarget::from(ssh), executor))
1529 }
1530 _ => None,
1531 })
1532 .collect()
1533}
1534
1535fn ssh_bare_check(
1536 id: &str,
1537 ssh: &RuntimeSshTarget,
1538 executor: &impl CommandExecutor,
1539) -> DoctorCheck {
1540 let check_id = format!("runtime.ssh-bare.{id}");
1541 let title = format!("SSH access for target {id}");
1542 match ssh_connectivity(ssh, executor) {
1543 SshConnectivity::Reachable => DoctorCheck::ready(
1544 check_id,
1545 title,
1546 format!(
1547 "`ssh {} true` succeeds noninteractively from this host.",
1548 ssh.destination
1549 ),
1550 ),
1551 SshConnectivity::Failed {
1552 detail,
1553 remediation,
1554 } => DoctorCheck::fixable(check_id, title, detail, remediation),
1555 }
1556}
1557
1558fn ssh_podman_checks(
1561 config: ConfigStatus<'_>,
1562 executor: &impl CommandExecutor,
1563 smoke: bool,
1564) -> Vec<DoctorCheck> {
1565 let Ok(config) = config else {
1566 return Vec::new();
1567 };
1568 config
1569 .targets
1570 .iter()
1571 .flat_map(|(id, target)| match target {
1572 TargetTemplate::SshPodman { ssh, container, .. } => {
1573 let ssh = RuntimeSshTarget::from(ssh);
1574 let (check, reachable) =
1575 ssh_podman_check(id, &ssh, &container.image, executor, smoke);
1576 let mut checks = vec![check];
1577 if reachable {
1579 checks.push(ssh_podman_limits_check(id, &ssh, executor));
1580 }
1581 checks
1582 }
1583 _ => Vec::new(),
1584 })
1585 .collect()
1586}
1587
1588fn ssh_podman_check(
1591 id: &str,
1592 ssh: &RuntimeSshTarget,
1593 image: &str,
1594 executor: &impl CommandExecutor,
1595 smoke: bool,
1596) -> (DoctorCheck, bool) {
1597 let check_id = format!("runtime.ssh-podman.{id}");
1598 let title = format!("Remote Podman for target {id}");
1599 if let SshConnectivity::Failed {
1602 detail,
1603 remediation,
1604 } = ssh_connectivity(ssh, executor)
1605 {
1606 return (
1607 DoctorCheck::fixable(check_id, title, detail, remediation),
1608 false,
1609 );
1610 }
1611 (
1612 ssh_podman_runtime_check(check_id, title, ssh, image, executor, smoke),
1613 true,
1614 )
1615}
1616
1617fn ssh_podman_runtime_check(
1619 check_id: String,
1620 title: String,
1621 ssh: &RuntimeSshTarget,
1622 image: &str,
1623 executor: &impl CommandExecutor,
1624 smoke: bool,
1625) -> DoctorCheck {
1626 let destination = &ssh.destination;
1627 let preflight = match verify_ssh_podman(ssh, executor) {
1628 Ok(preflight) => preflight,
1629 Err(error) => {
1630 let detail = podman_failure_detail(&error);
1631 let remediation = match podman_remediation_match(&error) {
1632 Some(remediation) => {
1633 format!("On {destination}: {remediation} See {PODMAN_DOCUMENTATION_URL}.")
1634 }
1635 None => format!(
1636 "Verify `ssh {destination}` succeeds noninteractively from this host, then install rootless Podman 4.3 or newer there. See {PODMAN_DOCUMENTATION_URL}."
1637 ),
1638 };
1639 return DoctorCheck::fixable(check_id, title, detail, remediation);
1640 }
1641 };
1642 let linger_warning = preflight.warnings.first();
1643 if !smoke && let Some(warning) = linger_warning {
1644 return DoctorCheck::warning(
1645 check_id,
1646 title,
1647 format!(
1648 "Remote rootless Podman {} is available via {destination}, but {}",
1649 preflight.version, warning.detail
1650 ),
1651 &warning.remediation,
1652 );
1653 }
1654 if !smoke {
1655 return DoctorCheck::ready(
1656 check_id,
1657 title,
1658 format!(
1659 "Remote rootless Podman {} is available via {destination}. Run `mj doctor --json --smoke` to verify the image end to end.",
1660 preflight.version
1661 ),
1662 );
1663 }
1664
1665 let target = RuntimeTargetTemplate::SshPodman {
1666 ssh: ssh.clone(),
1667 container: RuntimeContainerTemplate {
1668 build_cache: None,
1669 image: image.to_owned(),
1670 pull_policy: Default::default(),
1671 extra_run_args: vec![],
1672 workspace_storage: Default::default(),
1673 },
1674 };
1675 match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1676 Ok(()) => match linger_warning {
1677 Some(warning) => DoctorCheck::warning(
1678 check_id,
1679 title,
1680 format!(
1681 "Disposable run/exec/remove smoke test passed for image {image} on {destination}, but {}",
1682 warning.detail
1683 ),
1684 &warning.remediation,
1685 ),
1686 None => DoctorCheck::ready(
1687 check_id,
1688 title,
1689 format!(
1690 "Disposable run/exec/remove smoke test passed for image {image} on {destination}."
1691 ),
1692 ),
1693 },
1694 Err(error) => DoctorCheck::fixable(
1695 check_id,
1696 title,
1697 format!(
1698 "Disposable run/exec/remove smoke test failed for image {image} on {destination}: {error:#}"
1699 ),
1700 format!(
1701 "Fix the configured image or Podman runtime on {destination}, then run `mj doctor --json --smoke` again."
1702 ),
1703 ),
1704 }
1705}
1706
1707const SSH_PODMAN_HOST_LIMITS_SCRIPT: &str = r#"
1716if [ -r /proc/sys/kernel/keys/maxkeys ]; then
1717 printf 'keys.max=%s\n' "$(cat /proc/sys/kernel/keys/maxkeys)"
1718fi
1719if [ -r /proc/key-users ]; then
1720 awk -v uid="$(id -u)" '
1721 { user = $1; sub(/:$/, "", user) }
1722 user == uid {
1723 split($4, quota, "/")
1724 printf "keys.used=%s\nkeys.quota=%s\n", quota[1], quota[2]
1725 }
1726 ' /proc/key-users
1727fi
1728unreadable=0
1729maxstartups=
1730# A drop-in directory that cannot be listed hides any override it holds.
1731if [ -d /etc/ssh/sshd_config.d ] && ! [ -r /etc/ssh/sshd_config.d ]; then
1732 unreadable=1
1733fi
1734for file in /etc/ssh/sshd_config /etc/ssh/sshd_config.d/*.conf; do
1735 [ -e "$file" ] || continue
1736 if [ -r "$file" ]; then
1737 match=$(grep -i '^[[:space:]]*maxstartups[[:space:]]' "$file" 2>/dev/null | tail -n 1)
1738 [ -n "$match" ] && maxstartups=$(printf '%s\n' "$match" | awk '{ print $2 }')
1739 else
1740 unreadable=1
1741 fi
1742done
1743[ -n "$maxstartups" ] && printf 'maxstartups=%s\n' "$maxstartups"
1744[ "$unreadable" = 1 ] && printf 'maxstartups.unreadable=1\n'
1745exit 0
1746"#;
1747
1748const KEYRING_PRESSURE_PERCENT: u64 = 80;
1752
1753#[derive(Debug, Default, PartialEq, Eq)]
1756struct HostLimits {
1757 keys_used: Option<u64>,
1758 keys_quota: Option<u64>,
1759 keys_max: Option<u64>,
1760 max_startups: Option<String>,
1761 max_startups_unreadable: bool,
1762}
1763
1764fn parse_host_limits(stdout: &[u8]) -> HostLimits {
1765 let text = String::from_utf8_lossy(stdout);
1766 let mut limits = HostLimits::default();
1767 for line in text.lines() {
1768 let Some((name, value)) = line.split_once('=') else {
1769 continue;
1770 };
1771 let value = value.trim();
1772 match name.trim() {
1773 "keys.used" => limits.keys_used = value.parse().ok(),
1774 "keys.quota" => limits.keys_quota = value.parse().ok(),
1775 "keys.max" => limits.keys_max = value.parse().ok(),
1776 "maxstartups" if !value.is_empty() => limits.max_startups = Some(value.to_owned()),
1777 "maxstartups.unreadable" => limits.max_startups_unreadable = value == "1",
1778 _ => {}
1779 }
1780 }
1781 limits
1782}
1783
1784impl HostLimits {
1785 fn is_empty(&self) -> bool {
1787 self.keys_used.is_none()
1788 && self.keys_quota.is_none()
1789 && self.keys_max.is_none()
1790 && self.max_startups.is_none()
1791 && !self.max_startups_unreadable
1792 }
1793
1794 fn keyring_is_under_pressure(&self) -> bool {
1795 match (self.keys_used, self.keys_quota) {
1796 (Some(used), Some(quota)) if quota > 0 => {
1797 used.saturating_mul(100) >= quota.saturating_mul(KEYRING_PRESSURE_PERCENT)
1798 }
1799 _ => false,
1800 }
1801 }
1802
1803 fn keyring_sentence(&self, destination: &str) -> String {
1804 match (self.keys_used, self.keys_quota) {
1805 (Some(used), Some(quota)) => {
1806 let system = match self.keys_max {
1807 Some(max) => format!(", and `kernel.keys.maxkeys` is {max}"),
1808 None => String::new(),
1809 };
1810 format!(
1811 "The login user on {destination} holds {used} of its {quota} kernel keyring quota{system}."
1812 )
1813 }
1814 _ => format!(
1815 "The kernel keyring quota for the login user on {destination} could not be read."
1816 ),
1817 }
1818 }
1819
1820 fn max_startups_sentence(&self) -> String {
1821 match (&self.max_startups, self.max_startups_unreadable) {
1822 (Some(value), _) => format!("sshd MaxStartups is {value}."),
1823 (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(),
1824 (None, false) => {
1825 "sshd MaxStartups is not set in sshd_config, so sshd's default applies.".to_owned()
1826 }
1827 }
1828 }
1829}
1830
1831fn ssh_podman_limits_check(
1835 id: &str,
1836 ssh: &RuntimeSshTarget,
1837 executor: &impl CommandExecutor,
1838) -> DoctorCheck {
1839 let check_id = format!("runtime.ssh-podman.{id}.limits");
1840 let title = format!("Host limits for target {id}");
1841 let destination = &ssh.destination;
1842 let manual = || {
1843 format!(
1844 "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`."
1845 )
1846 };
1847 let command = ssh_validation_command(
1848 ssh,
1849 vec![
1850 "sh".to_owned(),
1851 "-c".to_owned(),
1852 SSH_PODMAN_HOST_LIMITS_SCRIPT.to_owned(),
1853 ],
1854 "read ssh-podman host limits",
1855 );
1856 let limits = match executor.execute(&command) {
1857 Ok(output) if output.status == 0 => parse_host_limits(&output.stdout),
1858 Ok(output) => {
1859 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
1860 return DoctorCheck::warning(
1861 check_id,
1862 title,
1863 format!(
1864 "Could not read the kernel keyring quota or sshd MaxStartups from {destination}: {stderr}"
1865 ),
1866 manual(),
1867 );
1868 }
1869 Err(error) => {
1870 return DoctorCheck::warning(
1871 check_id,
1872 title,
1873 format!(
1874 "Could not read the kernel keyring quota or sshd MaxStartups from {destination}: {error}"
1875 ),
1876 manual(),
1877 );
1878 }
1879 };
1880 if limits.is_empty() {
1881 return DoctorCheck::warning(
1882 check_id,
1883 title,
1884 format!("{destination} reported no readable kernel keyring or sshd limits."),
1885 manual(),
1886 );
1887 }
1888 let detail = format!(
1889 "{} {}",
1890 limits.keyring_sentence(destination),
1891 limits.max_startups_sentence()
1892 );
1893 if limits.keyring_is_under_pressure() {
1894 return DoctorCheck::warning(
1895 check_id,
1896 title,
1897 format!(
1898 "{detail} Every container takes a session keyring, so `podman run` fails with `crun: create keyring` once the quota is gone."
1899 ),
1900 format!(
1901 "Raise `kernel.keys.maxkeys` and `kernel.keys.maxbytes` with sysctl on {destination}, and close finished sessions promptly."
1902 ),
1903 );
1904 }
1905 DoctorCheck::ready(check_id, title, detail)
1906}
1907
1908fn storage_checks(config: ConfigStatus<'_>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
1913 use mj_core::targets::storage::{StorageCondition, TargetStorageView};
1914 let Ok(config) = config else {
1915 return Vec::new();
1916 };
1917 let controller = crate::controller::Controller {
1918 config: config.clone(),
1919 state: mj_core::state::State::default(),
1920 };
1921 controller
1922 .deployment_capacity_targets()
1923 .into_iter()
1924 .filter(|target| target.kind == crate::targets::DeploymentCapacityKind::Host)
1925 .filter_map(|target| {
1926 let probe = target.probes.first()?;
1927 let check_id = format!("storage.{}", target.id);
1928 let title = format!("Free space on {}", target.host);
1929 let output = match executor.execute(probe) {
1930 Ok(output) if output.status == 0 => output,
1931 Ok(output) if probe.ssh_destination.is_some() && output.status == 255 => {
1933 return None;
1934 }
1935 Ok(output) => {
1936 return Some(DoctorCheck::warning(
1937 check_id,
1938 title,
1939 format!(
1940 "Could not measure free space: {}",
1941 String::from_utf8_lossy(&output.stderr).trim()
1942 ),
1943 "Check that `df` runs on the host.",
1944 ));
1945 }
1946 Err(error) => {
1947 return Some(DoctorCheck::warning(
1948 check_id,
1949 title,
1950 format!("Could not measure free space: {error:#}"),
1951 "Check that the host is reachable and `df` runs on it.",
1952 ));
1953 }
1954 };
1955 let (home, filesystems) =
1956 mj_core::targets::storage::parse_storage_lines(&output.stdout);
1957 let view =
1958 TargetStorageView::evaluate(&target.host, home, &filesystems, None, |_| None, None);
1959 let lines = view.filesystem_lines().join("; ");
1962 let flagged = |condition| {
1963 view.filesystems
1964 .iter()
1965 .filter(|filesystem| filesystem.condition == condition)
1966 .map(|filesystem| filesystem.space.mount.clone())
1967 .collect::<Vec<_>>()
1968 .join(", ")
1969 };
1970 Some(match view.worst().map(|filesystem| filesystem.condition) {
1971 None => DoctorCheck::warning(
1972 check_id,
1973 title,
1974 "The probe reported no filesystem.",
1975 "Check that `df -Pk` runs on the host.",
1976 ),
1977 Some(StorageCondition::Ok) => DoctorCheck::ready(check_id, title, format!("{lines}.")),
1978 Some(StorageCondition::Low) => DoctorCheck::warning(
1979 check_id,
1980 title,
1981 format!("Disk low on {}: {lines}.", flagged(StorageCondition::Low)),
1982 format!("Free space on {} before it fills up.", target.host),
1983 ),
1984 Some(StorageCondition::Full) => DoctorCheck::fixable(
1985 check_id,
1986 title,
1987 format!(
1988 "Disk full on {}: {lines}. Mjolnir refuses writes there, and sessions that write there wait instead of restarting.",
1989 flagged(StorageCondition::Full)
1990 ),
1991 format!(
1992 "Free at least {} on {} of {}.",
1993 mj_core::move_workspace::format_bytes(
1994 mj_core::targets::storage::WRITE_RESERVE_BYTES
1995 ),
1996 flagged(StorageCondition::Full),
1997 target.host
1998 ),
1999 ),
2000 })
2001 })
2002 .collect()
2003}
2004
2005fn ssh_docker_checks(
2008 config: ConfigStatus<'_>,
2009 executor: &impl CommandExecutor,
2010 smoke: bool,
2011) -> Vec<DoctorCheck> {
2012 let Ok(config) = config else {
2013 return Vec::new();
2014 };
2015 config
2016 .targets
2017 .iter()
2018 .filter_map(|(id, target)| match target {
2019 TargetTemplate::SshDocker { ssh, container } => Some(ssh_docker_check(
2020 id,
2021 &RuntimeSshTarget::from(ssh),
2022 &container.image,
2023 executor,
2024 smoke,
2025 )),
2026 _ => None,
2027 })
2028 .collect()
2029}
2030
2031fn ssh_docker_check(
2032 id: &str,
2033 ssh: &RuntimeSshTarget,
2034 image: &str,
2035 executor: &impl CommandExecutor,
2036 smoke: bool,
2037) -> DoctorCheck {
2038 let check_id = format!("runtime.ssh-docker.{id}");
2039 let title = format!("Remote Docker for target {id}");
2040 let destination = &ssh.destination;
2041 if let SshConnectivity::Failed {
2042 detail,
2043 remediation,
2044 } = ssh_connectivity(ssh, executor)
2045 {
2046 return DoctorCheck::fixable(check_id, title, detail, remediation);
2047 }
2048
2049 let preflight = match verify_ssh_docker(ssh, executor) {
2050 Ok(preflight) => preflight,
2051 Err(error) => {
2052 let detail = format!("{error:#}");
2053 return DoctorCheck::fixable(
2054 check_id,
2055 title,
2056 detail,
2057 format!(
2058 "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."
2059 ),
2060 );
2061 }
2062 };
2063
2064 if smoke {
2065 let target = RuntimeTargetTemplate::SshDocker {
2066 ssh: ssh.clone(),
2067 container: RuntimeContainerTemplate {
2068 build_cache: None,
2069 image: image.to_owned(),
2070 pull_policy: Default::default(),
2071 extra_run_args: vec![],
2072 workspace_storage: Default::default(),
2073 },
2074 };
2075 return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
2076 Ok(()) => DoctorCheck::ready(
2077 check_id,
2078 title,
2079 format!(
2080 "Remote Docker {} is available via {destination}; disposable run/exec/remove and remote OverlayFS attachment smoke test passed for image {image}.",
2081 preflight.version
2082 ),
2083 ),
2084 Err(error) => DoctorCheck::fixable(
2085 check_id,
2086 title,
2087 format!(
2088 "Disposable run/exec/remove smoke test failed for image {image} on {destination}: {error:#}"
2089 ),
2090 format!(
2091 "Fix the configured image or Docker runtime on {destination}, then run `mj doctor --json --smoke` again."
2092 ),
2093 ),
2094 };
2095 }
2096
2097 let image_command = ssh_command(
2098 ssh,
2099 [
2100 "docker".to_owned(),
2101 "image".to_owned(),
2102 "inspect".to_owned(),
2103 image.to_owned(),
2104 ]
2105 .to_vec(),
2106 )
2107 .purpose("check remote Docker image presence");
2108 match executor.execute(&image_command) {
2109 Ok(output) if output.status == 0 => DoctorCheck::ready(
2110 check_id,
2111 title,
2112 format!(
2113 "Remote Docker {} is available via {destination}; image {image} is present. Run `mj doctor --json --smoke` to verify remote OverlayFS attachments.",
2114 preflight.version
2115 ),
2116 ),
2117 Ok(output) => DoctorCheck::fixable(
2118 check_id,
2119 title,
2120 format!(
2121 "Image {image} is not present in remote Docker storage on {destination}: {}",
2122 String::from_utf8_lossy(&output.stderr).trim()
2123 ),
2124 format!(
2125 "Pull it on {destination} with `ssh {destination} docker pull {image}`, or run `mj doctor --json --smoke`."
2126 ),
2127 ),
2128 Err(error) => DoctorCheck::fixable(
2129 check_id,
2130 title,
2131 format!("Could not inspect remote Docker image {image} on {destination}: {error}"),
2132 format!(
2133 "Verify `ssh {destination} docker info` succeeds, then pull {image} on that host."
2134 ),
2135 ),
2136 }
2137}
2138
2139fn doctor_smoke_id() -> String {
2141 format!(
2142 "doctor-{}-{:x}",
2143 std::process::id(),
2144 SystemTime::now()
2145 .duration_since(UNIX_EPOCH)
2146 .unwrap_or_default()
2147 .as_nanos()
2148 )
2149}
2150
2151fn podman_remediation(error: &anyhow::Error) -> String {
2152 let fix = podman_remediation_match(error).unwrap_or(
2153 "Install Podman with `sudo apt update && sudo apt install -y podman uidmap` (Debian/Ubuntu) or `sudo dnf install -y podman shadow-utils` (Fedora).",
2154 );
2155 format!("{fix} See {PODMAN_DOCUMENTATION_URL}.")
2156}
2157
2158fn podman_failure_detail(error: &anyhow::Error) -> String {
2160 podman_probe_observation(error).map_or_else(|| format!("{error:#}"), str::to_owned)
2161}
2162
2163fn podman_remediation_match(error: &anyhow::Error) -> Option<&'static str> {
2170 failed_podman_postcondition(error).map(PodmanPostcondition::remediation)
2171}
2172
2173const AWS_CLI_INSTALL_URL: &str =
2174 "https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html";
2175
2176fn aws_checks(config: ConfigStatus<'_>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
2179 let Ok(config) = config else {
2180 return Vec::new();
2181 };
2182 config
2183 .targets
2184 .iter()
2185 .filter_map(|(id, target)| match target {
2186 TargetTemplate::AwsEc2 {
2187 aws_profile,
2188 region,
2189 launch_template,
2190 ..
2191 } => Some(aws_target_check(
2192 id,
2193 aws_profile.as_deref(),
2194 region,
2195 launch_template,
2196 executor,
2197 )),
2198 _ => None,
2199 })
2200 .collect()
2201}
2202
2203fn aws_global_args<'a>(profile: Option<&'a str>, region: &'a str) -> Vec<String> {
2206 vec![
2207 "--profile".to_owned(),
2208 profile.unwrap_or("default").to_owned(),
2209 "--region".to_owned(),
2210 region.to_owned(),
2211 ]
2212}
2213
2214fn aws_target_check(
2215 id: &str,
2216 profile: Option<&str>,
2217 region: &str,
2218 launch_template: &str,
2219 executor: &impl CommandExecutor,
2220) -> DoctorCheck {
2221 let check_id = format!("runtime.aws-ec2.{id}");
2222 let title = format!("AWS EC2 target {id}");
2223 let profile_label = profile.unwrap_or("default");
2224
2225 let version = CommandSpec::new("aws", ["--version"]).purpose("check AWS CLI installation");
2226 match executor.execute(&version) {
2227 Err(error) => {
2228 return DoctorCheck::fixable(
2229 check_id,
2230 title,
2231 format!("The `aws` command is not available: {error}"),
2232 format!("Install the AWS CLI and put `aws` on PATH: {AWS_CLI_INSTALL_URL}"),
2233 );
2234 }
2235 Ok(output) if output.status != 0 => {
2236 return DoctorCheck::fixable(
2237 check_id,
2238 title,
2239 format!(
2240 "`aws --version` failed: {}",
2241 String::from_utf8_lossy(&output.stderr).trim()
2242 ),
2243 format!("Reinstall the AWS CLI: {AWS_CLI_INSTALL_URL}"),
2244 );
2245 }
2246 Ok(_) => {}
2247 }
2248
2249 let mut identity_args = aws_global_args(profile, region);
2250 identity_args.extend(["sts".to_owned(), "get-caller-identity".to_owned()]);
2251 identity_args.extend(["--output".to_owned(), "json".to_owned()]);
2252 let identity =
2253 CommandSpec::new("aws", identity_args).purpose("check AWS credentials for a doctor target");
2254 match executor.execute(&identity) {
2255 Err(error) => {
2256 return DoctorCheck::fixable(
2257 check_id,
2258 title,
2259 format!("Could not run `aws sts get-caller-identity`: {error}"),
2260 format!(
2261 "Configure credentials with `aws configure --profile {profile_label}`, or sign in with `aws sso login --profile {profile_label}`."
2262 ),
2263 );
2264 }
2265 Ok(output) if output.status != 0 => {
2266 return DoctorCheck::fixable(
2267 check_id,
2268 title,
2269 format!(
2270 "AWS credentials for profile {profile_label} are not usable: {}",
2271 String::from_utf8_lossy(&output.stderr).trim()
2272 ),
2273 format!(
2274 "Configure credentials with `aws configure --profile {profile_label}`, or sign in with `aws sso login --profile {profile_label}`."
2275 ),
2276 );
2277 }
2278 Ok(_) => {}
2279 }
2280
2281 let by_id = launch_template.starts_with("lt-");
2284 let mut template_args = aws_global_args(profile, region);
2285 template_args.extend(["ec2".to_owned(), "describe-launch-templates".to_owned()]);
2286 template_args.extend([
2287 if by_id {
2288 "--launch-template-ids".to_owned()
2289 } else {
2290 "--launch-template-names".to_owned()
2291 },
2292 launch_template.to_owned(),
2293 ]);
2294 template_args.extend(["--output".to_owned(), "json".to_owned()]);
2295 let template =
2296 CommandSpec::new("aws", template_args).purpose("check the configured AWS launch template");
2297 let template_remediation = format!(
2298 "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."
2299 );
2300 match executor.execute(&template) {
2301 Err(error) => DoctorCheck::fixable(
2302 check_id,
2303 title,
2304 format!("Could not query launch template {launch_template}: {error}"),
2305 template_remediation,
2306 ),
2307 Ok(output) if output.status != 0 => DoctorCheck::fixable(
2308 check_id,
2309 title,
2310 format!(
2311 "Launch template {launch_template} was not found in {region}: {}",
2312 String::from_utf8_lossy(&output.stderr).trim()
2313 ),
2314 template_remediation,
2315 ),
2316 Ok(_) => DoctorCheck::ready(
2317 check_id,
2318 title,
2319 format!(
2320 "The AWS CLI is installed, profile {profile_label} has valid credentials, and launch template {launch_template} exists in {region}."
2321 ),
2322 ),
2323 }
2324}
2325
2326fn daemon_build_check() -> DoctorCheck {
2334 const ID: &str = "daemon.build";
2335 const TITLE: &str = "Daemon build";
2336 let Ok(metadata) = mj_client::daemon::read_metadata_any() else {
2337 return DoctorCheck::ready(
2338 ID,
2339 TITLE,
2340 "No Mjolnir daemon is running; the next command starts one from this build.",
2341 );
2342 };
2343 let pid = metadata.pid;
2344 match mj_client::executable::process_runs_this_executable(pid) {
2345 Ok(Some(true)) => DoctorCheck::ready(
2346 ID,
2347 TITLE,
2348 format!(
2349 "Daemon {pid} runs this build (version {}).",
2350 mj_client::build_identity::this_build().describe()
2351 ),
2352 ),
2353 Ok(Some(false)) => DoctorCheck::warning(
2354 ID,
2355 TITLE,
2356 format!(
2357 "{}. Two builds can report the same version, so the files are what tell them apart. Code rebuilt since that daemon started is not running.",
2358 mj_client::executable::describe_running_daemon_and_client_builds(
2359 pid,
2360 &metadata.build_version,
2361 ),
2362 ),
2363 "Run `mj daemon restart` from this build. It now fails rather than reporting success if another client's build wins.",
2364 ),
2365 Ok(None) => DoctorCheck::ready(
2366 ID,
2367 TITLE,
2368 format!(
2369 "Daemon {pid} is recorded but not running; the next command starts one from this build."
2370 ),
2371 ),
2372 Err(error) => DoctorCheck::warning(
2373 ID,
2374 TITLE,
2375 format!("Could not tell which build daemon {pid} runs: {error:#}"),
2376 "Run `mj daemon restart` from this build if rebuilt code is not taking effect.",
2377 ),
2378 }
2379}
2380
2381fn worker_freshness_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
2389 let mut checks = Vec::new();
2390 let daemon = mj_client::daemon::read_metadata_any()
2391 .ok()
2392 .filter(|metadata| mj_client::daemon::process_is_alive(metadata.pid));
2393 let pinned = pinned_worker_digests();
2394 let mut sources: Vec<(String, Result<WorkerBinaryAvailability>)> = vec![(
2395 "this host".to_owned(),
2396 crate::controller::native_worker_binary_prerequisite(),
2397 )];
2398 for arch in container_worker_architectures(config) {
2399 sources.push((
2400 format!("{arch} Linux targets"),
2401 worker_binary_prerequisite_for_arch(&arch),
2402 ));
2403 }
2404 for (label, availability) in sources {
2405 let id = format!("worker.freshness.{}", label.replace(' ', "-"));
2406 let title = format!("Worker binary for {label}");
2407 let path = match availability {
2408 Ok(WorkerBinaryAvailability::Local { path, .. }) => path,
2409 Ok(WorkerBinaryAvailability::Remote { .. }) => continue,
2412 Err(error) => {
2413 checks.push(DoctorCheck::unsupported(
2414 id,
2415 title,
2416 format!("No worker binary resolves for {label}: {error:#}"),
2417 ));
2418 continue;
2419 }
2420 };
2421 let digest = mj_core::worker_launch::worker_executable_digest(&path)
2422 .unwrap_or_else(|error| format!("unreadable ({error:#})"));
2423 let pinned_note = if pinned.is_empty() {
2424 "the daemon has pinned no worker".to_owned()
2425 } else if pinned.contains(&digest) {
2426 "this content is in the daemon's pinned worker cache".to_owned()
2427 } else {
2428 format!(
2429 "the pinned worker cache holds {} instead",
2430 pinned.join(", ")
2431 )
2432 };
2433 let detail = format!("{} has digest {digest}; {pinned_note}.", path.display());
2434 let Some(metadata) = daemon.as_ref() else {
2435 checks.push(DoctorCheck::ready(
2436 id,
2437 title,
2438 format!("{detail} No daemon is running, so the next session uses this file."),
2439 ));
2440 continue;
2441 };
2442 match worker_changed_since_daemon_start(&path, &metadata.started_at) {
2443 Ok(true) => checks.push(DoctorCheck::warning(
2444 id,
2445 title,
2446 format!("{detail} It was rebuilt after daemon {} started, which froze the copy it serves.", metadata.pid),
2447 "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.",
2448 )),
2449 Ok(false) => checks.push(DoctorCheck::ready(id, title, detail)),
2450 Err(error) => checks.push(DoctorCheck::warning(
2451 id,
2452 title,
2453 format!("{detail} Could not compare it with the daemon's start time: {error:#}"),
2454 "Run `mj daemon restart` if rebuilt worker code is not taking effect.",
2455 )),
2456 }
2457 }
2458 checks
2459}
2460
2461fn container_worker_architectures(config: ConfigStatus<'_>) -> Vec<String> {
2463 let Ok(config) = config else {
2464 return Vec::new();
2465 };
2466 let mut architectures = Vec::new();
2467 for target in config.targets.values() {
2468 let container = match target {
2469 TargetTemplate::LocalPodman { container }
2470 | TargetTemplate::LocalDocker { container }
2471 | TargetTemplate::AppleContainer { container }
2472 | TargetTemplate::SshPodman { container, .. }
2473 | TargetTemplate::SshDocker { container, .. } => container,
2474 _ => continue,
2475 };
2476 let arch = container
2477 .platform
2478 .as_deref()
2479 .and_then(|platform| platform.rsplit('/').next())
2480 .map_or_else(
2481 || std::env::consts::ARCH.to_owned(),
2482 normalized_worker_architecture,
2483 );
2484 if !architectures.contains(&arch) {
2485 architectures.push(arch);
2486 }
2487 }
2488 architectures
2489}
2490
2491fn normalized_worker_architecture(platform_arch: &str) -> String {
2494 crate::targets::normalize_architecture(platform_arch)
2495 .unwrap_or(platform_arch)
2496 .to_owned()
2497}
2498
2499fn pinned_worker_digests() -> Vec<String> {
2504 let root = mj_core::config::data_dir().join("workers").join("pinned");
2505 let Ok(entries) = std::fs::read_dir(root) else {
2506 return Vec::new();
2507 };
2508 let mut digests: Vec<String> = entries
2509 .flatten()
2510 .filter(|entry| entry.path().is_dir())
2511 .filter_map(|entry| entry.file_name().into_string().ok())
2512 .collect();
2513 digests.sort();
2514 digests
2515}
2516
2517fn worker_changed_since_daemon_start(path: &Path, started_at: &str) -> Result<bool> {
2522 let started: SystemTime = chrono::DateTime::parse_from_rfc3339(started_at)
2523 .map_err(|error| anyhow::anyhow!("parse daemon start time {started_at:?}: {error}"))?
2524 .into();
2525 let modified = std::fs::metadata(path)?.modified()?;
2526 Ok(modified > started)
2527}
2528
2529fn offered_targets<'a>(
2537 config: &Config,
2538 engine_checks: impl IntoIterator<Item = &'a DoctorCheck>,
2539) -> Config {
2540 let ready = engine_checks
2541 .into_iter()
2542 .filter(|check| check.status == CheckStatus::Ready)
2543 .map(|check| check.id.as_str())
2544 .collect::<std::collections::BTreeSet<_>>();
2545 let engine_ready = |target_id: &str| match target_id {
2546 "podman" => ready.contains("runtime.podman"),
2547 "docker" => ready.contains("runtime.docker"),
2548 "apple-container" => ready.contains("runtime.apple-container"),
2549 _ => true,
2550 };
2551 let mut offered = config.clone().with_local_targets();
2552 offered
2553 .targets
2554 .retain(|id, _| config.configures_target(id) || engine_ready(id));
2555 offered
2556}
2557
2558fn worker_binary_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
2559 let config = match config {
2560 Ok(config) => config,
2561 Err(ConfigGap::NewerVersion(version)) => {
2562 return vec![newer_config_skip(
2563 "worker.containers",
2564 "Container worker binary",
2565 version,
2566 )];
2567 }
2568 Err(gap @ (ConfigGap::Missing | ConfigGap::Unreadable)) => {
2569 return vec![DoctorCheck::fixable(
2570 "worker.containers",
2571 "Container worker binary",
2572 format!(
2573 "Worker availability cannot be checked until {}.",
2574 gap.awaited()
2575 ),
2576 gap.remediation(),
2577 )];
2578 }
2579 };
2580 let containers = config
2581 .targets
2582 .iter()
2583 .filter_map(|(id, target)| match target {
2584 TargetTemplate::LocalPodman { container }
2585 | TargetTemplate::LocalDocker { container }
2586 | TargetTemplate::AppleContainer { container } => Some((id, container, None)),
2587 TargetTemplate::SshPodman { container, .. } => {
2588 Some((id, container, Some("ssh-podman")))
2589 }
2590 TargetTemplate::SshDocker { container, .. } => {
2591 Some((id, container, Some("ssh-docker")))
2592 }
2593 _ => None,
2594 })
2595 .collect::<Vec<_>>();
2596 if containers.is_empty() {
2597 return vec![DoctorCheck::unsupported(
2598 "worker.containers",
2599 "Container worker binary",
2600 "No container target is configured.",
2601 )];
2602 }
2603 containers
2604 .into_iter()
2605 .map(|(id, container, remote_kind)| {
2606 if let Some(remote_kind) = remote_kind
2607 && container.platform.is_none()
2608 {
2609 return DoctorCheck::unsupported(
2612 format!("worker.{id}"),
2613 format!("Container worker binary for target {id}"),
2614 format!(
2615 "Set `platform` on this {remote_kind} target to check its worker binary; the remote architecture is unknown until provisioning."
2616 ),
2617 );
2618 }
2619 worker_binary_check(id, container)
2620 })
2621 .collect()
2622}
2623
2624fn ssh_bare_worker_checks(
2628 config: ConfigStatus<'_>,
2629 executor: &impl CommandExecutor,
2630) -> Vec<DoctorCheck> {
2631 let Ok(config) = config else {
2632 return Vec::new();
2633 };
2634 config
2635 .targets
2636 .iter()
2637 .filter(|(_, target)| matches!(target, TargetTemplate::SshBare { .. }))
2638 .map(|(id, target)| {
2639 let check_id = format!("worker.{id}");
2640 let title = format!("Worker binary for SSH target {id}");
2641 match ssh_worker_binary_prerequisite(target, executor) {
2642 None => DoctorCheck::unsupported(
2643 check_id,
2644 title,
2645 format!(
2646 "The platform of this host could not be read over SSH, so its worker binary is not checked; see `runtime.ssh-bare.{id}`."
2647 ),
2648 ),
2649 Some((triple, result)) => worker_source_check(check_id, title, &triple, result),
2650 }
2651 })
2652 .collect()
2653}
2654
2655fn worker_binary_check(id: &str, container: &ContainerTemplate) -> DoctorCheck {
2656 let title = format!("Container worker binary for target {id}");
2657 let arch = match container_architecture(container.platform.as_deref()) {
2658 Ok(arch) => arch,
2659 Err(reason) => {
2660 return DoctorCheck::unsupported(format!("worker.{id}"), title, reason);
2661 }
2662 };
2663 let triple = format!("{arch}-unknown-linux-musl");
2664 worker_source_check(
2665 format!("worker.{id}"),
2666 title,
2667 &triple,
2668 worker_binary_prerequisite_for_arch(arch),
2669 )
2670}
2671
2672fn worker_source_check(
2673 check_id: String,
2674 title: String,
2675 triple: &str,
2676 source: anyhow::Result<WorkerBinaryAvailability>,
2677) -> DoctorCheck {
2678 match source {
2679 Ok(WorkerBinaryAvailability::Local { path, source }) => DoctorCheck::ready(
2680 check_id,
2681 title,
2682 format!(
2683 "{triple} worker is available from {source}: {}",
2684 path.display()
2685 ),
2686 ),
2687 Ok(WorkerBinaryAvailability::Remote { url, .. }) => DoctorCheck::ready(
2688 check_id,
2689 title,
2690 format!("{triple} worker will be verified and downloaded from {url} when needed."),
2691 ),
2692 Err(error) => DoctorCheck::fixable(
2693 check_id,
2694 title,
2695 format!("No usable {triple} worker source: {error:#}"),
2696 format!(
2697 "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."
2698 ),
2699 ),
2700 }
2701}
2702
2703fn container_architecture(platform: Option<&str>) -> std::result::Result<&'static str, String> {
2704 let candidate = platform.unwrap_or(std::env::consts::ARCH);
2705 let candidate = candidate
2706 .split('/')
2707 .rev()
2708 .find(|part| matches!(*part, "x86_64" | "amd64" | "aarch64" | "arm64"))
2709 .unwrap_or(candidate);
2710 crate::targets::normalize_architecture(candidate).map_err(|_| format!(
2711 "Container architecture {candidate:?} is unsupported; Mjolnir supports x86_64 and aarch64 Linux workers."
2712 ))
2713}
2714
2715fn apple_container_image(config: ConfigStatus<'_>) -> String {
2716 config
2717 .ok()
2718 .and_then(|config| {
2719 config.targets.values().find_map(|target| match target {
2720 TargetTemplate::AppleContainer { container } => Some(container.image.clone()),
2721 _ => None,
2722 })
2723 })
2724 .unwrap_or_else(|| DEFAULT_CONTAINER_IMAGE.into())
2725}
2726
2727pub fn apple_container_runtime_check(
2729 platform: &ApplePlatform,
2730 executor: &impl CommandExecutor,
2731) -> DoctorCheck {
2732 match platform {
2733 ApplePlatform::Linux => {
2734 return DoctorCheck::unsupported(
2735 "runtime.apple-container",
2736 "Apple container runtime",
2737 "macOS only",
2738 );
2739 }
2740 ApplePlatform::Other(current) => {
2741 return DoctorCheck::unsupported(
2742 "runtime.apple-container",
2743 "Apple container runtime",
2744 format!("macOS only (current platform: {current})"),
2745 );
2746 }
2747 ApplePlatform::Macos {
2748 architecture,
2749 major_version,
2750 } if architecture != "aarch64" && architecture != "arm64" => {
2751 return DoctorCheck::unsupported(
2752 "runtime.apple-container",
2753 "Apple container runtime",
2754 "Apple container requires Apple silicon; Intel Macs are unsupported.",
2755 );
2756 }
2757 ApplePlatform::Macos { major_version, .. } if *major_version < 26 => {
2758 return DoctorCheck::unsupported(
2759 "runtime.apple-container",
2760 "Apple container runtime",
2761 format!("Apple container requires macOS 26 or newer (found {major_version})."),
2762 );
2763 }
2764 ApplePlatform::Macos { .. } => {}
2765 }
2766
2767 apple_container_daemon_check(executor)
2768}
2769
2770pub fn apple_container_check(
2771 platform: &ApplePlatform,
2772 executor: &impl CommandExecutor,
2773 smoke: bool,
2774 image: String,
2775) -> DoctorCheck {
2776 let daemon = apple_container_runtime_check(platform, executor);
2777 if daemon.status != CheckStatus::Ready {
2778 return daemon;
2779 }
2780
2781 if !smoke {
2782 return DoctorCheck::fixable(
2783 "runtime.apple-container",
2784 "Apple container runtime",
2785 "The daemon is running, but the required disposable smoke test was not requested.",
2786 "Run `mj doctor --json --smoke`.",
2787 );
2788 }
2789
2790 let target = RuntimeTargetTemplate::AppleContainer(RuntimeContainerTemplate {
2791 build_cache: None,
2792 image,
2793 pull_policy: Default::default(),
2794 extra_run_args: vec![],
2795 workspace_storage: Default::default(),
2796 });
2797 match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
2798 Ok(()) => DoctorCheck::ready(
2799 "runtime.apple-container",
2800 "Apple container runtime",
2801 "Installed, daemon running, and disposable run/exec/remove smoke test passed.",
2802 ),
2803 Err(error) => DoctorCheck::fixable(
2804 "runtime.apple-container",
2805 "Apple container runtime",
2806 format!("Disposable run/exec/remove smoke test failed: {error:#}"),
2807 "Fix the configured image or container runtime, then run `mj doctor --json --smoke` again.",
2808 ),
2809 }
2810}
2811
2812fn apple_container_daemon_check(executor: &impl CommandExecutor) -> DoctorCheck {
2817 let installed =
2818 CommandSpec::new("container", ["--version"]).purpose("check Apple container installation");
2819 match executor.execute(&installed) {
2820 Err(error) => {
2821 return DoctorCheck::fixable(
2822 "runtime.apple-container",
2823 "Apple container runtime",
2824 format!(
2825 "The `container` command is not available: {}",
2826 error.root_cause()
2827 ),
2828 format!("Install the official signed package: {APPLE_CONTAINER_INSTALL_URL}"),
2829 );
2830 }
2831 Ok(output) if output.status != 0 => {
2832 return DoctorCheck::fixable(
2833 "runtime.apple-container",
2834 "Apple container runtime",
2835 format!(
2836 "The installed `container --version` command failed: {}",
2837 String::from_utf8_lossy(&output.stderr).trim()
2838 ),
2839 format!("Reinstall the official signed package: {APPLE_CONTAINER_INSTALL_URL}"),
2840 );
2841 }
2842 Ok(_) => {}
2843 }
2844
2845 let status =
2846 CommandSpec::new("container", ["system", "status"]).purpose("check Apple container daemon");
2847 match executor.execute(&status) {
2848 Ok(output) if output.status == 0 => DoctorCheck::ready(
2849 "runtime.apple-container",
2850 "Apple container runtime",
2851 "Installed, and the Apple container daemon is running.",
2852 ),
2853 Ok(output) => DoctorCheck::fixable(
2854 "runtime.apple-container",
2855 "Apple container runtime",
2856 format!(
2857 "The Apple container daemon is stopped: {}",
2858 String::from_utf8_lossy(&output.stderr).trim()
2859 ),
2860 "Run `container system start`.",
2861 ),
2862 Err(error) => DoctorCheck::fixable(
2863 "runtime.apple-container",
2864 "Apple container runtime",
2865 format!("Could not query the Apple container daemon: {error}"),
2866 "Run `container system start`.",
2867 ),
2868 }
2869}
2870
2871pub fn current_apple_platform(executor: &impl CommandExecutor) -> ApplePlatform {
2872 if cfg!(target_os = "linux") {
2873 return ApplePlatform::Linux;
2874 }
2875 if !cfg!(target_os = "macos") {
2876 return ApplePlatform::Other(std::env::consts::OS.into());
2877 }
2878 let major_version = executor
2879 .execute(&CommandSpec::new("sw_vers", ["-productVersion"]).purpose("detect macOS version"))
2880 .ok()
2881 .filter(|output| output.status == 0)
2882 .and_then(|output| {
2883 String::from_utf8(output.stdout)
2884 .ok()
2885 .and_then(|value| value.trim().split('.').next()?.parse().ok())
2886 })
2887 .unwrap_or(0);
2888 ApplePlatform::Macos {
2889 architecture: std::env::consts::ARCH.into(),
2890 major_version,
2891 }
2892}
2893
2894#[cfg(test)]
2895mod tests;
2896
2897#[derive(Debug, Default, PartialEq, Eq)]
2899pub(crate) struct ReviewResidue {
2900 pub refs: Vec<String>,
2902 pub scratch_indexes: Vec<PathBuf>,
2904}
2905
2906impl ReviewResidue {
2907 fn is_empty(&self) -> bool {
2908 self.refs.is_empty() && self.scratch_indexes.is_empty()
2909 }
2910}
2911
2912pub(crate) fn review_residue(repository: &Path) -> ReviewResidue {
2919 let mut residue = ReviewResidue::default();
2920 let git_dir = repository.join(".git");
2921 if !git_dir.exists() {
2922 return residue;
2923 }
2924 for reference in ["review-baseline", "review-capture"] {
2925 if git_dir.join("refs/hel").join(reference).is_file() {
2926 residue.refs.push(format!("refs/hel/{reference}"));
2927 }
2928 }
2929 if let Ok(packed) = std::fs::read_to_string(git_dir.join("packed-refs")) {
2931 for line in packed.lines() {
2932 if let Some((_, reference)) = line.split_once(' ')
2933 && reference.starts_with("refs/hel/")
2934 && !residue.refs.iter().any(|known| known == reference)
2935 {
2936 residue.refs.push(reference.to_owned());
2937 }
2938 }
2939 }
2940 if let Ok(entries) = std::fs::read_dir(&git_dir) {
2941 for entry in entries.filter_map(Result::ok) {
2942 if entry
2943 .file_name()
2944 .to_str()
2945 .is_some_and(|name| name.starts_with("hel-review-index-"))
2946 {
2947 residue.scratch_indexes.push(entry.path());
2948 }
2949 }
2950 }
2951 residue.refs.sort();
2952 residue.scratch_indexes.sort();
2953 residue
2954}
2955
2956pub(crate) fn review_residue_repositories(
2967 configured: impl IntoIterator<Item = PathBuf>,
2968 sessions: &[&mj_core::state::SessionRecord],
2969) -> Vec<PathBuf> {
2970 use mj_core::state::ManagedCheckoutKind;
2971
2972 let managed_roots = sessions
2973 .iter()
2974 .filter_map(|session| session.managed_worktree.as_ref())
2975 .map(|worktree| worktree.worktree_root.as_path())
2976 .collect::<Vec<_>>();
2977 let mut in_use = Vec::new();
2978 for session in sessions.iter().filter(|session| session.state.is_active()) {
2979 if let Some(directory) = &session.project_directory {
2980 in_use.push(directory.as_path());
2981 }
2982 if let Some(worktree) = &session.managed_worktree
2983 && worktree.kind == ManagedCheckoutKind::Worktree
2984 {
2985 in_use.push(worktree.source_repository.as_path());
2986 }
2987 }
2988 let mut repositories = configured
2989 .into_iter()
2990 .chain(
2991 sessions
2992 .iter()
2993 .filter_map(|session| session.project_directory.clone()),
2994 )
2995 .filter(|repository| {
2996 !is_inside_managed_checkout(repository)
2997 && !managed_roots
2998 .iter()
2999 .any(|root| repository.starts_with(root))
3000 && !in_use.iter().any(|directory| repository == directory)
3001 })
3002 .collect::<Vec<_>>();
3003 repositories.sort();
3004 repositories.dedup();
3005 repositories
3006}
3007
3008fn is_inside_managed_checkout(path: &Path) -> bool {
3011 let components = path
3012 .components()
3013 .map(|component| component.as_os_str())
3014 .collect::<Vec<_>>();
3015 components
3016 .windows(3)
3017 .any(|window| window[0] == ".mj" && (window[1] == "clones" || window[1] == "worktrees"))
3018}
3019
3020fn review_residue_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
3026 let Ok(config) = config else {
3027 return Vec::new();
3028 };
3029 let configured = config
3030 .bundles
3031 .values()
3032 .flat_map(|bundle| bundle.repositories.iter())
3033 .filter_map(|repository| repository.local.clone());
3034 let state = crate::database::load_state().ok();
3037 let sessions = state
3038 .as_ref()
3039 .map(|state| state.sessions.values().collect::<Vec<_>>())
3040 .unwrap_or_default();
3041 let repositories = review_residue_repositories(configured, &sessions);
3042 if repositories.is_empty() {
3043 return Vec::new();
3044 }
3045 let found = repositories
3046 .into_iter()
3047 .map(|repository| {
3048 let residue = review_residue(&repository);
3049 (repository, residue)
3050 })
3051 .filter(|(_, residue)| !residue.is_empty())
3052 .collect::<Vec<_>>();
3053 if found.is_empty() {
3054 return vec![DoctorCheck::ready(
3055 "review.residue",
3056 "Review leftovers in your repositories",
3057 "No Mjolnir refs or scratch index files were found in the configured repositories.",
3058 )];
3059 }
3060 let detail = found
3061 .iter()
3062 .map(|(repository, residue)| {
3063 let mut parts = Vec::new();
3064 if !residue.refs.is_empty() {
3065 parts.push(residue.refs.join(", "));
3066 }
3067 if !residue.scratch_indexes.is_empty() {
3068 parts.push(mj_core::text::counted(
3069 residue.scratch_indexes.len(),
3070 "leftover scratch index file",
3071 "leftover scratch index files",
3072 ));
3073 }
3074 format!("{}: {}", repository.display(), parts.join("; "))
3075 })
3076 .collect::<Vec<_>>()
3077 .join(". ");
3078 let commands = found
3079 .iter()
3080 .flat_map(|(repository, residue)| {
3081 let repository = repository.display().to_string();
3082 let mut commands = residue
3083 .refs
3084 .iter()
3085 .map(|reference| format!("git -C {repository} update-ref -d {reference}"))
3086 .collect::<Vec<_>>();
3087 commands.extend(
3088 residue
3089 .scratch_indexes
3090 .iter()
3091 .map(|index| format!("rm -f {}", index.display())),
3092 );
3093 commands.push(format!("git -C {repository} gc --prune=now"));
3094 commands
3095 })
3096 .collect::<Vec<_>>()
3097 .join("\n");
3098 vec![DoctorCheck::fixable(
3099 "review.residue",
3100 "Review leftovers in your repositories",
3101 format!("Mjolnir left these in repositories it does not own: {detail}."),
3102 format!("Remove them yourself when you are ready:\n{commands}"),
3103 )]
3104}
3105
3106fn bifrost_check(config: ConfigStatus<'_>, executor: &impl CommandExecutor) -> Option<DoctorCheck> {
3114 let config = config.ok()?;
3115 if !mj_core::review::settings::can_review(config) {
3116 return None;
3117 }
3118 Some(bifrost_check_for(
3119 &mj_review::bifrost::bifrost_binary(),
3120 executor,
3121 ))
3122}
3123
3124fn bifrost_check_for(binary: &Path, executor: &impl CommandExecutor) -> DoctorCheck {
3125 const ID: &str = "review.bifrost";
3126 const TITLE: &str = "Bifrost for turn review";
3127 let required = mj_review::bifrost::REQUIRED_BIFROST_VERSION;
3128 let shown = binary.display();
3129 let remediation = format!(
3130 "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`.",
3131 env = mj_review::bifrost::BIFROST_BIN_ENV,
3132 );
3133 let command = CommandSpec::new(binary.display().to_string(), ["--version"])
3134 .purpose("read the Bifrost version used by turn review");
3135 let output = match executor.execute(&command) {
3136 Ok(output) if output.status == 0 => output,
3137 Ok(output) => {
3138 return DoctorCheck::warning(
3139 ID,
3140 TITLE,
3141 format!("`{shown} --version` exited with status {}.", output.status),
3142 remediation,
3143 );
3144 }
3145 Err(error) => {
3146 return DoctorCheck::warning(
3147 ID,
3148 TITLE,
3149 format!("Could not run `{shown}` for the turn review: {error}"),
3150 remediation,
3151 );
3152 }
3153 };
3154 let text = String::from_utf8_lossy(&output.stdout);
3155 let first_line = text.lines().next().unwrap_or_default().trim();
3156 let version = first_line
3157 .split_whitespace()
3158 .next_back()
3159 .and_then(|token| semver::Version::parse(token).ok());
3160 let minimum = semver::Version::parse(required).expect("the required Bifrost version is semver");
3161 match version {
3162 Some(version) if version >= minimum => DoctorCheck::ready(
3163 ID,
3164 TITLE,
3165 format!("`{shown}` is Bifrost {version}; turn review needs {required} or later."),
3166 ),
3167 Some(version) => DoctorCheck::warning(
3168 ID,
3169 TITLE,
3170 format!(
3171 "`{shown}` is Bifrost {version}, older than the {required} turn review needs, so every review would fail."
3172 ),
3173 remediation,
3174 ),
3175 None => DoctorCheck::warning(
3176 ID,
3177 TITLE,
3178 format!("`{shown} --version` printed {first_line:?}, which does not name a version."),
3179 remediation,
3180 ),
3181 }
3182}