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