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