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