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