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::{WorkerBinaryAvailability, worker_binary_prerequisite_for_arch};
11use crate::setup::{
12 DiscoveredHome, discover_harness_homes_with_executor, harness_is_authenticated_with_executor,
13};
14use crate::targets::{
15 BoundedProcessExecutor, CommandExecutor, CommandSpec, CommandTimedOut,
16 ContainerTemplate as RuntimeContainerTemplate, DockerUnavailable, PODMAN_DOCUMENTATION_URL,
17 PodmanPostcondition, ProcessExecutor, SshTarget as RuntimeSshTarget,
18 TargetTemplate as RuntimeTargetTemplate, failed_podman_postcondition, podman_probe_observation,
19 run_setup_smoke_test, ssh_command, ssh_connectivity_probe, ssh_validation_command,
20 verify_local_docker, verify_local_podman, verify_ssh_docker, verify_ssh_podman,
21};
22use mj_core::config::{
23 Config, ContainerTemplate, HarnessHost, HarnessKind, HarnessProfile, TargetTemplate,
24 config_path,
25};
26use mj_core::credentials::login_command;
27
28const DEFAULT_CONTAINER_IMAGE: &str = "ubuntu:24.04";
34const APPLE_CONTAINER_INSTALL_URL: &str = "https://github.com/apple/container#initial-install";
35
36pub const PROBE_TIMEOUT: Duration = Duration::from_secs(15);
43
44pub const fn probe_executor() -> BoundedProcessExecutor {
47 BoundedProcessExecutor::new(PROBE_TIMEOUT)
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
51#[serde(rename_all = "lowercase")]
52pub enum CheckStatus {
53 Ready,
54 Warning,
55 Fixable,
56 Unsupported,
57}
58
59impl CheckStatus {
60 pub const fn label(self) -> &'static str {
61 match self {
62 Self::Ready => "ready",
63 Self::Warning => "warning",
64 Self::Fixable => "fixable",
65 Self::Unsupported => "unsupported",
66 }
67 }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
71pub struct DoctorCheck {
72 pub id: String,
73 pub title: String,
74 pub status: CheckStatus,
75 pub detail: String,
76 pub remediation: Option<String>,
77}
78
79impl DoctorCheck {
80 fn ready(id: impl Into<String>, title: impl Into<String>, detail: impl Into<String>) -> Self {
81 Self {
82 id: id.into(),
83 title: title.into(),
84 status: CheckStatus::Ready,
85 detail: detail.into(),
86 remediation: None,
87 }
88 }
89
90 fn warning(
91 id: impl Into<String>,
92 title: impl Into<String>,
93 detail: impl Into<String>,
94 remediation: impl Into<String>,
95 ) -> Self {
96 Self {
97 id: id.into(),
98 title: title.into(),
99 status: CheckStatus::Warning,
100 detail: detail.into(),
101 remediation: Some(remediation.into()),
102 }
103 }
104
105 pub(crate) fn fixable(
106 id: impl Into<String>,
107 title: impl Into<String>,
108 detail: impl Into<String>,
109 remediation: impl Into<String>,
110 ) -> Self {
111 Self {
112 id: id.into(),
113 title: title.into(),
114 status: CheckStatus::Fixable,
115 detail: detail.into(),
116 remediation: Some(remediation.into()),
117 }
118 }
119
120 fn unsupported(
121 id: impl Into<String>,
122 title: impl Into<String>,
123 detail: impl Into<String>,
124 ) -> Self {
125 Self {
126 id: id.into(),
127 title: title.into(),
128 status: CheckStatus::Unsupported,
129 detail: detail.into(),
130 remediation: None,
131 }
132 }
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub struct DoctorOptions {
137 pub smoke: bool,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub enum ApplePlatform {
142 Linux,
143 Macos {
144 architecture: String,
145 major_version: u32,
146 },
147 Other(String),
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub enum InstructionsPlatform {
152 Linux,
153 Macos,
154}
155
156pub fn run_current(options: DoctorOptions) -> Vec<DoctorCheck> {
157 if options.smoke {
158 return run_with(
162 &ProcessExecutor,
163 current_apple_platform(&ProcessExecutor),
164 options,
165 );
166 }
167 let executor = probe_executor();
168 run_with(&executor, current_apple_platform(&executor), options)
169}
170
171pub fn run_with(
172 executor: &impl CommandExecutor,
173 apple_platform: ApplePlatform,
174 options: DoctorOptions,
175) -> Vec<DoctorCheck> {
176 run_with_config_path(&config_path(), executor, apple_platform, options)
177}
178
179pub fn run_with_config_path(
185 config_path: &Path,
186 executor: &impl CommandExecutor,
187 apple_platform: ApplePlatform,
188 options: DoctorOptions,
189) -> Vec<DoctorCheck> {
190 let (loaded, mut checks) = configuration_checks(config_path);
191 let config: ConfigStatus<'_> = loaded.as_ref().map_err(|gap| *gap);
192 checks.push(harness_discovery_check(config, executor));
193 checks.extend(harness_checks(config, executor));
194 checks.extend(subagent_eligibility_checks(config));
195 let podman = podman_checks(config, executor, options.smoke);
196 let docker = docker_checks(config, executor, options.smoke);
197 let apple_container = apple_container_check(
198 &apple_platform,
199 executor,
200 options.smoke,
201 apple_container_image(config),
202 );
203 let offered = config.map(|config| {
204 offered_targets(
205 config,
206 podman.iter().chain(&docker).chain([&apple_container]),
207 )
208 });
209 let offered = offered.as_ref().map_err(|gap| *gap);
210 checks.extend(podman);
211 checks.extend(docker);
212 checks.extend(ssh_bare_checks(config, executor));
213 checks.extend(ssh_podman_checks(config, executor, options.smoke));
214 checks.extend(ssh_docker_checks(config, executor, options.smoke));
215 checks.extend(build_cache_checks(offered, executor));
216 checks.extend(aws_checks(config, executor));
217 checks.extend(worker_binary_checks(offered));
218 checks.push(daemon_build_check());
219 checks.extend(worker_freshness_checks(offered));
220 checks.extend(review_residue_checks(config));
221 checks.push(apple_container);
223 checks
224}
225
226fn build_cache_checks(
227 config: ConfigStatus<'_>,
228 executor: &impl CommandExecutor,
229) -> Vec<DoctorCheck> {
230 let Ok(config) = config else {
231 return Vec::new();
232 };
233 crate::controller::doctor_host_mbx(config, executor)
234 .into_iter()
235 .map(|host| {
236 let id = format!("build-cache.{}", host.host);
237 let title = format!("Build cache on {}", host.host);
238 let targets = host.targets.join(", ");
239 match host.status {
240 crate::controller::DoctorHostMbxStatus::Absent => DoctorCheck::ready(
241 id,
242 title,
243 format!(
244 "No native mbx is installed; targets {targets} can use Mjolnir's mbx {}.",
245 crate::controller::MBX_VERSION
246 ),
247 ),
248 crate::controller::DoctorHostMbxStatus::Compatible(version) => DoctorCheck::ready(
249 id,
250 title,
251 format!(
252 "Host mbx {version} is compatible with Mjolnir's mbx {} for targets {targets}.",
253 crate::controller::MBX_VERSION
254 ),
255 ),
256 crate::controller::DoctorHostMbxStatus::TooOld(version) => DoctorCheck::warning(
257 id,
258 title,
259 format!(
260 "Host mbx {version} is older than Mjolnir's mbx {}; sessions on targets {targets} run without the shared build cache.",
261 crate::controller::MBX_VERSION
262 ),
263 format!(
264 "Upgrade mbx on {} to {} or newer, then rerun `mj doctor`.",
265 host.host,
266 crate::controller::MBX_VERSION
267 ),
268 ),
269 crate::controller::DoctorHostMbxStatus::Unknown(error) => DoctorCheck::warning(
270 id,
271 title,
272 format!("Could not check host mbx for targets {targets}: {error}"),
273 format!(
274 "Check access to {} and run `mbx --version` there, then rerun `mj doctor`.",
275 host.host
276 ),
277 ),
278 }
279 })
280 .collect()
281}
282
283fn harness_discovery_check(
284 config: ConfigStatus<'_>,
285 executor: &impl CommandExecutor,
286) -> DoctorCheck {
287 let home = dirs::home_dir();
288 let overrides = HarnessKind::ALL.into_iter().filter_map(|kind| {
289 std::env::var_os(kind.home_env()).map(|path| (kind, kind.home_from_environment(path)))
290 });
291 let discovered = discover_harness_homes_with_executor(home.as_deref(), overrides, executor);
292 harness_discovery_check_from(
293 &discovered,
294 config.is_ok_and(|config| !config.profiles.is_empty()),
295 &settings_key(config.ok()),
296 )
297}
298
299fn harness_discovery_check_from(
300 discovered: &[DiscoveredHome],
301 has_configured_profiles: bool,
302 settings_key: &str,
303) -> DoctorCheck {
304 if discovered.is_empty() {
305 return if has_configured_profiles {
306 DoctorCheck::ready(
307 "harness.discovery",
308 "Harness home discovery",
309 "No default or environment-overridden harness homes were found; configured profile homes are checked below.",
310 )
311 } else {
312 DoctorCheck::fixable(
313 "harness.discovery",
314 "Harness home discovery",
315 "No Codex, Claude Code, Kimi Code, or Grok Build home was found in the default or environment-overridden locations.",
316 format!(
317 "Install and sign in to a supported harness, then open Mjolnir, press {settings_key} for Settings, and choose Agent Profiles."
318 ),
319 )
320 };
321 }
322
323 let homes = discovered
324 .iter()
325 .map(|home| {
326 let authentication = if home.authenticated {
327 "authenticated"
328 } else {
329 "not authenticated"
330 };
331 format!(
332 "{} at {} ({authentication})",
333 home.kind.display_name(),
334 home.path.display()
335 )
336 })
337 .collect::<Vec<_>>()
338 .join("; ");
339 DoctorCheck::ready(
340 "harness.discovery",
341 "Harness home discovery",
342 format!("Discovered {homes}. Configured profile authentication is checked below."),
343 )
344}
345
346fn settings_key(config: Option<&Config>) -> String {
349 let keybinds = config.map_or_else(mj_core::config::Keybinds::default, Config::keybinds);
350 keybinds
351 .labels(mj_core::config::KeyAction::OpenSettings)
352 .into_iter()
353 .next()
354 .unwrap_or_else(|| "the Settings command in the command palette".to_owned())
355}
356
357pub fn all_ready(checks: &[DoctorCheck]) -> bool {
358 checks
359 .iter()
360 .all(|check| check.status != CheckStatus::Fixable)
361}
362
363pub fn render_human(checks: &[DoctorCheck], output: &mut impl Write) -> Result<()> {
364 for check in checks {
365 writeln!(
366 output,
367 "{} {}: {}",
368 check.status.label(),
369 check.title,
370 check.detail
371 )?;
372 if let Some(remediation) = &check.remediation {
373 writeln!(output, " remediation: {remediation}")?;
374 }
375 }
376 Ok(())
377}
378
379pub fn setup_instructions(platform: InstructionsPlatform) -> String {
380 match platform {
381 InstructionsPlatform::Linux => format!(
382 "# Mjolnir setup instructions for Linux\n\n\
383This page is self-contained. Follow this exact loop as the user who will run `mj`:\n\n\
3841. Run `mj doctor --json`.\n\
3852. Follow every `fixable` remediation from its JSON output.\n\
3863. Run `mj doctor --json` again. Repeat until no check is `fixable`.\n\
3874. Finish with `mj doctor --json --smoke` to verify every configured container\n\
388 image end to end, and resolve anything it reports as `fixable`.\n\n\
389For a coding-agent handoff, provide this entire instructions page together with\n\
390the latest `mj doctor --json` output.\n\n\
391## Local bare runtime\n\n\
392A local bare runtime runs the agent directly on this machine. It needs the\n\
393native `mj-worker` installed beside `mj`; the release installer and the npm\n\
394package include it. The worker installs the pinned harness version itself.\n\
395Codex and Claude need Node.js 22 or newer and npm on `PATH`. Kimi and Grok\n\
396need curl and Bash. Muse needs curl and tar. Mjolnir does not install these\n\
397prerequisites.\n\n\
398## Linux container-runtime postconditions\n\n{}\n\n{}",
399 crate::targets::PODMAN_DOCUMENTATION,
400 crate::targets::DOCKER_DOCUMENTATION
401 ),
402 InstructionsPlatform::Macos => format!(
403 "# Mjolnir setup instructions for macOS\n\n\
404This page is self-contained. Follow this exact loop as the user who will run `mj`:\n\n\
4051. Run `mj doctor --json`.\n\
4062. Follow every `fixable` remediation from its JSON output.\n\
4073. Run `mj doctor --json` again. Repeat until no check is `fixable`.\n\n\
408For a coding-agent handoff, provide this entire instructions page together with\n\
409the latest `mj doctor --json` output.\n\n\
410## Local bare runtime\n\n\
411A local bare runtime runs the agent directly on this machine. It needs the\n\
412native `mj-worker` installed beside `mj`; the release installer and the npm\n\
413package include it. The worker installs the pinned harness version itself.\n\
414Codex and Claude need Node.js 22 or newer and npm on `PATH`. Kimi and Grok\n\
415need curl and Bash. Muse needs curl and tar. Mjolnir does not install these\n\
416prerequisites.\n\n\
417## Apple container runtime\n\n\
418Mjolnir's Apple container target requires Apple silicon and macOS 26 or newer.\n\
419On an Intel Mac or an older macOS release, the target is unsupported; use the\n\
420local bare runtime, an SSH target, or an AWS target instead.\n\n\
421If the `container` command is absent, install only the official signed package:\n\n\
422<https://github.com/apple/container#initial-install>\n\n\
423Mjolnir never downloads or installs that package. If doctor reports a stopped\n\
424daemon, run exactly:\n\n```console\ncontainer system start\n```\n\n\
425Finish with the opt-in disposable runtime test in JSON mode:\n\n```console\nmj doctor --json --smoke\n```\n\n\
426Apple container is ready only when that smoke test creates a disposable\n\
427container, executes `true` in it, and removes it successfully. Use the image\n\
428configured by an `apple-container` target; without one, doctor uses\n\
429`{DEFAULT_CONTAINER_IMAGE}` for the smoke test.\n\n\
430## Shared Mjolnir prerequisites\n\n\
431`mj doctor --json` also checks the configuration, each configured harness home\n\
432and authentication marker, selected container worker binaries, and any relevant\n\
433Podman prerequisites. Resolve every `fixable` status before starting a session.\n"
434 ),
435 }
436}
437
438#[derive(Debug, Clone, Copy, PartialEq, Eq)]
445enum ConfigGap {
446 NewerVersion(u32),
448 Unreadable,
450}
451
452type ConfigStatus<'a> = std::result::Result<&'a Config, ConfigGap>;
455
456fn newer_config_skip(id: &str, title: &str, version: u32) -> DoctorCheck {
461 DoctorCheck::unsupported(
462 id,
463 title,
464 format!(
465 "Skipped: config.toml was written by a newer Mjolnir (config version {version}; this build supports {}). Update Mjolnir to that build or newer.",
466 mj_core::config::CONFIG_VERSION
467 ),
468 )
469}
470
471fn configuration_checks(path: &Path) -> (std::result::Result<Config, ConfigGap>, Vec<DoctorCheck>) {
472 if !path.exists() {
473 return (
474 Err(ConfigGap::Unreadable),
475 vec![DoctorCheck::fixable(
476 "config",
477 "Mjolnir configuration",
478 format!("{} does not exist", path.display()),
479 format!(
480 "Open Mjolnir and press {} for Settings to add an agent profile.",
481 settings_key(None)
482 ),
483 )],
484 );
485 }
486 if let Some(found) = mj_core::config::newer_version_on_disk(path) {
490 return (
491 Err(ConfigGap::NewerVersion(found)),
492 vec![DoctorCheck::fixable(
493 "config",
494 "Mjolnir configuration",
495 format!(
496 "{} was written by a newer Mjolnir (config version {found}; this build supports {})",
497 path.display(),
498 mj_core::config::CONFIG_VERSION
499 ),
500 "Update Mjolnir to that build or newer. Do not lower the version value by hand or replace the file.",
501 )],
502 );
503 }
504 match Config::load_from(path) {
505 Ok(config) => {
506 let mut checks = vec![DoctorCheck::ready(
507 "config",
508 "Mjolnir configuration",
509 format!("{} is valid", path.display()),
510 )];
511 if config.enabled_profiles().next().is_none() {
515 checks.push(DoctorCheck::fixable(
516 "config.session-prerequisites",
517 "Session configuration",
518 "No agent profile is enabled, so no session can start. Local targets are supplied automatically.",
519 format!(
520 "Open Mjolnir and press {} for Settings to add or enable an agent profile.",
521 settings_key(Some(&config))
522 ),
523 ));
524 } else if config.bundles.is_empty() {
525 checks.push(DoctorCheck::ready(
526 "config.session-prerequisites",
527 "Session configuration",
528 "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.",
529 ));
530 } else {
531 checks.push(DoctorCheck::ready(
532 "config.session-prerequisites",
533 "Session configuration",
534 "At least one profile, bundle, and target are configured.",
535 ));
536 }
537 (Ok(config), checks)
538 }
539 Err(error) => (
540 Err(ConfigGap::Unreadable),
541 vec![DoctorCheck::fixable(
542 "config",
543 "Mjolnir configuration",
544 format!("{} is invalid: {error:#}", path.display()),
545 "Fix the reported TOML error in config.toml, or run `mj setup` to replace it.",
546 )],
547 ),
548 }
549}
550
551fn harness_checks(config: ConfigStatus<'_>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
552 let config = match config {
553 Ok(config) => config,
554 Err(ConfigGap::NewerVersion(version)) => {
555 return vec![newer_config_skip(
556 "harness.profiles",
557 "Harness profiles",
558 version,
559 )];
560 }
561 Err(ConfigGap::Unreadable) => {
562 return vec![DoctorCheck::fixable(
563 "harness.profiles",
564 "Harness profiles",
565 "Harness homes cannot be checked until config.toml is valid.",
566 "Fix config.toml, then rerun `mj doctor --json`.",
567 )];
568 }
569 };
570 if config.profiles.is_empty() {
571 return vec![DoctorCheck::fixable(
572 "harness.profiles",
573 "Harness profiles",
574 "No harness profiles are configured.",
575 format!(
576 "Open Mjolnir, press {} for Settings, and choose Agent Profiles to detect accounts or add a profile.",
577 settings_key(Some(config))
578 ),
579 )];
580 }
581 config
582 .profiles
583 .iter()
584 .map(|(id, profile)| {
585 let mut check = harness_profile_check(config, id, profile, executor);
586 check.detail = format!("{} {}", profile_summary(config, id, profile), check.detail);
587 check
588 })
589 .collect()
590}
591
592fn harness_profile_check(
594 config: &Config,
595 id: &str,
596 profile: &HarnessProfile,
597 executor: &impl CommandExecutor,
598) -> DoctorCheck {
599 let title = format!("Harness profile {id}");
600 if !profile.enabled {
601 return DoctorCheck::ready(
602 format!("harness.{id}"),
603 title,
604 "Profile is disabled; home and authentication checks were skipped.",
605 );
606 }
607 if let Some(default_home) = unscopable_home_is_ignored(config, profile) {
608 return DoctorCheck::fixable(
609 format!("harness.{id}"),
610 title,
611 format!(
612 "{} is ignored by a session on this machine: {} on macOS reads {} \
613 whatever {} says",
614 profile.home.display(),
615 profile.kind.display_name(),
616 default_home.display(),
617 profile.kind.home_env(),
618 ),
619 format!(
620 "Set this profile's home to {}, or use it only on container and SSH \
621 targets, where the home is still scoped.",
622 default_home.display()
623 ),
624 );
625 }
626 if !profile.home.is_dir() {
627 return DoctorCheck::fixable(
628 format!("harness.{id}"),
629 title,
630 format!("{} does not exist", profile.home.display()),
631 format!(
632 "{} If this profile should use an existing installation, select its home in Setup.",
633 harness_login_remediation(id, profile)
634 ),
635 );
636 }
637 if !harness_is_authenticated_with_executor(profile, executor) {
638 return DoctorCheck::fixable(
639 format!("harness.{id}"),
640 title,
641 format!(
642 "No usable authentication was detected for {}",
643 profile.home.display()
644 ),
645 harness_login_remediation(id, profile),
646 );
647 }
648 DoctorCheck::ready(
649 format!("harness.{id}"),
650 title,
651 format!(
652 "{} is present and authentication is available",
653 profile.home.display()
654 ),
655 )
656}
657
658fn profile_summary(config: &Config, id: &str, profile: &HarnessProfile) -> String {
663 let delegation = if config
664 .subagents
665 .eligible_profiles
666 .get(id)
667 .copied()
668 .unwrap_or(false)
669 {
670 "any session's sub-agents may use it"
671 } else {
672 "only its own sessions' sub-agents may use it"
673 };
674 format!(
675 "{}; {}; {delegation}.",
676 profile.kind.display_name(),
677 profile_quota_source(profile)
678 )
679}
680
681fn profile_quota_source(profile: &HarnessProfile) -> String {
685 match profile.kind {
686 HarnessKind::Claude => "Claude subscription quota".to_owned(),
687 HarnessKind::Codex => match crate::quota::provider_credential(profile) {
688 Some(provider) if crate::zai_usage::serves_quota(&provider.host) => {
689 format!("quota from {}", provider.host)
690 }
691 Some(provider) => format!(
692 "pay-per-use through {}, counted as 100% left when choosing a sub-agent's profile",
693 provider.host
694 ),
695 None => match profile.codex_provider() {
696 Ok(None) => "ChatGPT subscription quota".to_owned(),
697 Ok(Some(provider)) => format!(
698 "no quota report, because custom provider {:?} has no API key in this profile's environment",
699 provider.id
700 ),
701 Err(error) => format!("its Codex config.toml could not be read ({error:#})"),
702 },
703 },
704 kind => format!("quota as {} reports it", kind.display_name()),
705 }
706}
707
708fn unscopable_home_is_ignored(config: &Config, profile: &HarnessProfile) -> Option<PathBuf> {
716 if profile
717 .kind
718 .scopes_home_with_environment(HarnessHost::current())
719 {
720 return None;
721 }
722 if !config
726 .targets
727 .values()
728 .any(|target| matches!(target, TargetTemplate::LocalBare))
729 {
730 return None;
731 }
732 let default_home = dirs::home_dir()?.join(profile.kind.default_home_leaf());
733 (profile.home != default_home).then_some(default_home)
734}
735
736fn subagent_eligibility_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
746 let Ok(config) = config else {
747 return Vec::new();
748 };
749 let mut checks = vec![subagent_policy_check(config)];
750 checks.extend(
751 config
752 .subagents
753 .eligible_profiles
754 .iter()
755 .filter(|(_, eligible)| **eligible)
756 .filter_map(|(id, _)| match config.profiles.get(id) {
757 Some(profile) if !profile.enabled => Some(DoctorCheck::warning(
758 format!("subagents.{id}"),
759 format!("Sub-agent profile {id}"),
760 format!(
761 "Profile {id:?} is listed in [subagents.eligible_profiles] but is disabled, so it is not offered for sub-agent use."
762 ),
763 format!(
764 "Re-enable profile {id:?}, or remove it from [subagents.eligible_profiles]."
765 ),
766 )),
767 _ => None,
768 }),
769 );
770 checks
771}
772
773fn subagent_policy_check(config: &Config) -> DoctorCheck {
777 let subagents = &config.subagents;
778 let eligible = subagents
779 .eligible_profiles
780 .iter()
781 .filter(|(_, eligible)| **eligible)
782 .map(|(id, _)| id.as_str())
783 .collect::<Vec<_>>();
784 let others = if eligible.is_empty() {
785 "no other profile".to_owned()
786 } else {
787 eligible.join(", ")
788 };
789 let detail = format!(
790 "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}.",
791 subagents.max_concurrent
792 );
793 DoctorCheck::ready("subagents.policy", "Sub-agent policy", detail)
794}
795
796fn harness_login_remediation(id: &str, profile: &HarnessProfile) -> String {
803 let (program, arguments) = match login_command(profile) {
804 Ok(command) => command,
805 Err(error) => return format!("{error} Check {}.", profile.home.display()),
809 };
810 format!(
811 "Run `mj login --profile {id}`; it runs `{program} {}` against {}.",
812 arguments.join(" "),
813 profile.home.display()
814 )
815}
816
817fn podman_checks(
822 config: ConfigStatus<'_>,
823 executor: &impl CommandExecutor,
824 smoke: bool,
825) -> Vec<DoctorCheck> {
826 let effective = config.map(|config| config.clone().with_local_targets());
827 let effective = effective.as_ref().map_err(|gap| *gap);
828 let explicit = config.is_ok_and(|config| {
829 local_podman_targets(config)
830 .iter()
831 .any(|(id, _)| config.configures_target(id))
832 });
833 let preflight = builtin_target_availability(
834 podman_check(effective, executor),
835 explicit,
836 "Podman",
837 "podman",
838 );
839 let preflight_passed = preflight.status == CheckStatus::Ready;
840 let mut checks = vec![preflight];
841 if preflight_passed {
842 checks.extend(
843 podman_image_checks(effective, executor, smoke)
844 .into_iter()
845 .map(|(id, check)| builtin_image_check(config, &id, check)),
846 );
847 }
848 checks
849}
850
851fn builtin_image_check(
855 config: ConfigStatus<'_>,
856 target_id: &str,
857 check: DoctorCheck,
858) -> DoctorCheck {
859 let explicit = config.is_ok_and(|config| config.configures_target(target_id));
860 if explicit || check.status != CheckStatus::Fixable {
861 return check;
862 }
863 DoctorCheck::warning(
864 check.id,
865 check.title,
866 format!(
867 "{} (built-in `{target_id}` target; the dashboard downloads its image when it starts)",
868 check.detail
869 ),
870 check.remediation.unwrap_or_default(),
871 )
872}
873
874fn builtin_target_availability(
883 check: DoctorCheck,
884 explicit: bool,
885 engine: &str,
886 target_id: &str,
887) -> DoctorCheck {
888 if explicit || check.status != CheckStatus::Fixable {
889 return check;
890 }
891 DoctorCheck::unsupported(
892 check.id,
893 check.title,
894 format!(
895 "{engine} is not available, so the built-in `{target_id}` target is marked unavailable: {}",
896 check.detail
897 ),
898 )
899}
900
901fn podman_check(config: ConfigStatus<'_>, executor: &impl CommandExecutor) -> DoctorCheck {
902 let config = match config {
903 Ok(config) => config,
904 Err(ConfigGap::NewerVersion(version)) => {
905 return newer_config_skip("runtime.podman", "Rootless Podman", version);
906 }
907 Err(ConfigGap::Unreadable) => {
908 return DoctorCheck::unsupported(
909 "runtime.podman",
910 "Rootless Podman",
911 "Podman prerequisites cannot be evaluated until config.toml is valid.",
912 );
913 }
914 };
915 if local_podman_targets(config).is_empty() {
916 return DoctorCheck::unsupported(
917 "runtime.podman",
918 "Rootless Podman",
919 "No local-podman target is configured.",
920 );
921 }
922 local_podman_runtime_check(executor)
923}
924
925pub fn local_podman_runtime_check(executor: &impl CommandExecutor) -> DoctorCheck {
932 match verify_local_podman(executor) {
933 Ok(preflight) => DoctorCheck::ready(
934 "runtime.podman",
935 "Rootless Podman",
936 format!("Podman {} has a valid rootless UID map.", preflight.version),
937 ),
938 Err(error) => DoctorCheck::fixable(
939 "runtime.podman",
940 "Rootless Podman",
941 podman_failure_detail(&error),
942 podman_remediation(&error),
943 ),
944 }
945}
946
947fn local_podman_targets(config: &Config) -> Vec<(&String, &ContainerTemplate)> {
948 config
949 .targets
950 .iter()
951 .filter_map(|(id, target)| match target {
952 TargetTemplate::LocalPodman { container } => Some((id, container)),
953 _ => None,
954 })
955 .collect()
956}
957
958fn podman_image_checks(
959 config: ConfigStatus<'_>,
960 executor: &impl CommandExecutor,
961 smoke: bool,
962) -> Vec<(String, DoctorCheck)> {
963 let Ok(config) = config else {
964 return Vec::new();
965 };
966 local_podman_targets(config)
967 .into_iter()
968 .map(|(id, container)| {
969 (
970 id.clone(),
971 podman_image_check(id, &container.image, executor, smoke),
972 )
973 })
974 .collect()
975}
976
977fn podman_image_check(
978 id: &str,
979 image: &str,
980 executor: &impl CommandExecutor,
981 smoke: bool,
982) -> DoctorCheck {
983 let check_id = format!("runtime.podman.image.{id}");
984 let title = format!("Podman image for target {id}");
985 if smoke {
986 let target = RuntimeTargetTemplate::LocalPodman(RuntimeContainerTemplate {
987 build_cache: None,
988 image: image.to_owned(),
989 pull_policy: Default::default(),
990 extra_run_args: vec![],
991 workspace_storage: Default::default(),
992 });
993 return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
994 Ok(()) => DoctorCheck::ready(
995 check_id,
996 title,
997 format!("Disposable run/exec/remove smoke test passed for image {image}."),
998 ),
999 Err(error) => DoctorCheck::fixable(
1000 check_id,
1001 title,
1002 format!(
1003 "Disposable run/exec/remove smoke test failed for image {image}: {error:#}"
1004 ),
1005 "Fix the configured image or Podman runtime, then run `mj doctor --json --smoke` again.",
1006 ),
1007 };
1008 }
1009
1010 let command = CommandSpec::new("podman", ["image", "exists", image])
1011 .purpose("check Podman image presence");
1012 match executor.execute(&command) {
1013 Ok(output) if output.status == 0 => DoctorCheck::ready(
1014 check_id,
1015 title,
1016 format!("Image {image} is present in local Podman storage."),
1017 ),
1018 Ok(_) => DoctorCheck::fixable(
1019 check_id,
1020 title,
1021 format!("Image {image} is not present in local Podman storage."),
1022 missing_image_remediation(image),
1023 ),
1024 Err(error) => DoctorCheck::fixable(
1025 check_id,
1026 title,
1027 format!(
1028 "Could not check whether image {image} is present in local Podman storage: {error}"
1029 ),
1030 missing_image_remediation(image),
1031 ),
1032 }
1033}
1034
1035fn missing_image_remediation(image: &str) -> String {
1036 format!(
1037 "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."
1038 )
1039}
1040
1041fn docker_checks(
1043 config: ConfigStatus<'_>,
1044 executor: &impl CommandExecutor,
1045 smoke: bool,
1046) -> Vec<DoctorCheck> {
1047 let config = match config {
1048 Ok(config) => config,
1049 Err(ConfigGap::NewerVersion(version)) => {
1050 return vec![newer_config_skip("runtime.docker", "Docker", version)];
1051 }
1052 Err(ConfigGap::Unreadable) => {
1053 return vec![DoctorCheck::unsupported(
1054 "runtime.docker",
1055 "Docker",
1056 "Docker prerequisites cannot be evaluated until config.toml is valid.",
1057 )];
1058 }
1059 };
1060 let explicit = local_docker_targets(config)
1061 .iter()
1062 .any(|(id, _)| config.configures_target(id));
1063 let effective = config.clone().with_local_targets();
1064 let targets = local_docker_targets(&effective);
1065 if targets.is_empty() {
1066 return vec![DoctorCheck::unsupported(
1067 "runtime.docker",
1068 "Docker",
1069 "No local-docker target is configured.",
1070 )];
1071 }
1072 let preflight = builtin_target_availability(
1073 local_docker_runtime_check(executor),
1074 explicit,
1075 "Docker",
1076 "docker",
1077 );
1078 if preflight.status != CheckStatus::Ready {
1079 return vec![preflight];
1080 }
1081 let mut checks = vec![preflight];
1082 checks.extend(targets.into_iter().map(|(id, container)| {
1083 builtin_image_check(
1084 Ok(config),
1085 id,
1086 docker_image_check(id, &container.image, executor, smoke),
1087 )
1088 }));
1089 checks
1090}
1091
1092pub fn local_docker_runtime_check(executor: &impl CommandExecutor) -> DoctorCheck {
1093 match verify_local_docker(executor) {
1094 Ok(preflight) => DoctorCheck::ready(
1095 "runtime.docker",
1096 "Docker",
1097 format!(
1098 "Docker {} is connected to a Linux daemon.",
1099 preflight.version
1100 ),
1101 ),
1102 Err(error) => match error.downcast_ref::<DockerUnavailable>() {
1103 Some(problem) => DoctorCheck::fixable(
1104 "runtime.docker",
1105 "Docker",
1106 problem.to_string(),
1107 problem.remediation(),
1108 ),
1109 None => DoctorCheck::fixable(
1110 "runtime.docker",
1111 "Docker",
1112 format!("{error:#}"),
1113 "Install and start Docker, then make sure `docker info` succeeds as the user running mj.",
1114 ),
1115 },
1116 }
1117}
1118
1119fn local_docker_targets(config: &Config) -> Vec<(&String, &ContainerTemplate)> {
1120 config
1121 .targets
1122 .iter()
1123 .filter_map(|(id, target)| match target {
1124 TargetTemplate::LocalDocker { container } => Some((id, container)),
1125 _ => None,
1126 })
1127 .collect()
1128}
1129
1130fn docker_image_check(
1131 id: &str,
1132 image: &str,
1133 executor: &impl CommandExecutor,
1134 smoke: bool,
1135) -> DoctorCheck {
1136 let check_id = format!("runtime.docker.image.{id}");
1137 let title = format!("Docker image for target {id}");
1138 if smoke {
1139 let target = RuntimeTargetTemplate::LocalDocker(RuntimeContainerTemplate {
1140 build_cache: None,
1141 image: image.to_owned(),
1142 pull_policy: Default::default(),
1143 extra_run_args: vec![],
1144 workspace_storage: Default::default(),
1145 });
1146 return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1147 Ok(()) => DoctorCheck::ready(
1148 check_id,
1149 title,
1150 format!(
1151 "Disposable run/exec/remove and OverlayFS attachment smoke test passed for image {image}."
1152 ),
1153 ),
1154 Err(error) => DoctorCheck::fixable(
1155 check_id,
1156 title,
1157 format!(
1158 "Disposable run/exec/remove smoke test failed for image {image}: {error:#}"
1159 ),
1160 "Fix the configured image or Docker runtime, then run `mj doctor --json --smoke` again.",
1161 ),
1162 };
1163 }
1164 let command = CommandSpec::new("docker", ["image", "inspect", image])
1165 .purpose("check Docker image presence");
1166 match executor.execute(&command) {
1167 Ok(output) if output.status == 0 => DoctorCheck::ready(
1168 check_id,
1169 title,
1170 format!("Image {image} is present in Docker storage."),
1171 ),
1172 Ok(_) => DoctorCheck::fixable(
1173 check_id,
1174 title,
1175 format!("Image {image} is not present in Docker storage."),
1176 format!("Pull it with `docker pull {image}`, or run `mj doctor --json --smoke`."),
1177 ),
1178 Err(error) => DoctorCheck::fixable(
1179 check_id,
1180 title,
1181 format!("Could not inspect Docker image {image}: {error}"),
1182 format!("Make sure `docker info` succeeds, then run `docker pull {image}`."),
1183 ),
1184 }
1185}
1186
1187enum SshConnectivity {
1192 Reachable,
1193 Failed { detail: String, remediation: String },
1194}
1195
1196fn ssh_connectivity(ssh: &RuntimeSshTarget, executor: &impl CommandExecutor) -> SshConnectivity {
1201 let destination = &ssh.destination;
1202 let command = ssh_connectivity_probe(ssh);
1203 match executor.execute(&command) {
1204 Err(error) => SshConnectivity::Failed {
1205 detail: format!("Could not run `ssh {destination} true`: {error:#}"),
1206 remediation: ssh_launch_failure_remediation(&error, ssh),
1207 },
1208 Ok(output) if output.status != 0 => {
1209 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
1210 SshConnectivity::Failed {
1211 detail: format!("`ssh {destination} true` failed: {stderr}"),
1212 remediation: ssh_failure_remediation(&stderr, ssh),
1213 }
1214 }
1215 Ok(_) => SshConnectivity::Reachable,
1216 }
1217}
1218
1219const 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).";
1220
1221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1227enum SshFailure {
1228 UntrustedHostKey,
1229 Unauthenticated,
1230 ClientMissing,
1231 Unreachable,
1233 Unrecognized,
1234}
1235
1236fn classify_ssh_stderr(stderr: &str) -> SshFailure {
1237 const UNTRUSTED_HOST_KEY: [&str; 3] = [
1238 "Host key verification failed",
1239 "No ECDSA host key is known",
1240 "REMOTE HOST IDENTIFICATION HAS CHANGED",
1241 ];
1242 const UNAUTHENTICATED: [&str; 4] = [
1243 "Permission denied",
1244 "Too many authentication failures",
1245 "no matching host key",
1246 "Authentication failed",
1247 ];
1248 const CLIENT_MISSING: [&str; 2] = ["ssh: command not found", "No such file or directory"];
1249 const UNREACHABLE: [&str; 3] = [
1250 "Connection timed out",
1251 "No route to host",
1252 "Network is unreachable",
1253 ];
1254
1255 let reported = |signatures: &[&str]| signatures.iter().any(|text| stderr.contains(text));
1256 if reported(&UNTRUSTED_HOST_KEY) {
1257 SshFailure::UntrustedHostKey
1258 } else if reported(&UNAUTHENTICATED) {
1259 SshFailure::Unauthenticated
1260 } else if reported(&CLIENT_MISSING) {
1261 SshFailure::ClientMissing
1262 } else if reported(&UNREACHABLE) {
1263 SshFailure::Unreachable
1264 } else {
1265 SshFailure::Unrecognized
1266 }
1267}
1268
1269fn ssh_launch_failure_remediation(error: &anyhow::Error, ssh: &RuntimeSshTarget) -> String {
1272 if error.downcast_ref::<CommandTimedOut>().is_some() {
1273 return ssh_unreachable_remediation(ssh);
1274 }
1275 let missing_binary = error.chain().any(|cause| {
1276 cause
1277 .downcast_ref::<std::io::Error>()
1278 .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
1279 });
1280 if missing_binary {
1281 return SSH_MISSING_REMEDIATION.to_owned();
1282 }
1283 format!(
1284 "Run `ssh {} true` by hand and resolve the error it reports: {error:#}",
1285 ssh.destination
1286 )
1287}
1288
1289fn ssh_unreachable_remediation(ssh: &RuntimeSshTarget) -> String {
1292 let host = ssh_host_only(&ssh.destination);
1293 format!(
1294 "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.",
1295 ssh.destination
1296 )
1297}
1298
1299fn ssh_failure_remediation(stderr: &str, ssh: &RuntimeSshTarget) -> String {
1301 let destination = &ssh.destination;
1302 match classify_ssh_stderr(stderr) {
1303 SshFailure::UntrustedHostKey => {
1304 let host = ssh_host_only(destination);
1305 format!(
1306 "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."
1307 )
1308 }
1309 SshFailure::Unauthenticated => match ssh_identity_file(ssh) {
1310 Some(identity) => format!(
1311 "Install your public key on the host with `ssh-copy-id -i {identity}.pub {destination}`."
1312 ),
1313 None => {
1314 format!("Install your public key on the host with `ssh-copy-id {destination}`.")
1315 }
1316 },
1317 SshFailure::ClientMissing => SSH_MISSING_REMEDIATION.to_owned(),
1318 SshFailure::Unreachable => ssh_unreachable_remediation(ssh),
1319 SshFailure::Unrecognized => {
1320 format!(
1321 "Run `ssh {destination} true` by hand and resolve the error it reports: {stderr}"
1322 )
1323 }
1324 }
1325}
1326
1327fn ssh_host_only(destination: &str) -> &str {
1329 destination
1330 .rsplit_once('@')
1331 .map_or(destination, |(_, host)| host)
1332}
1333
1334fn ssh_identity_file(ssh: &RuntimeSshTarget) -> Option<&str> {
1336 let position = ssh.ssh_args.iter().position(|arg| arg == "-i")?;
1337 ssh.ssh_args.get(position + 1).map(String::as_str)
1338}
1339
1340fn ssh_bare_checks(config: ConfigStatus<'_>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
1342 let Ok(config) = config else {
1343 return Vec::new();
1344 };
1345 config
1346 .targets
1347 .iter()
1348 .filter_map(|(id, target)| match target {
1349 TargetTemplate::SshBare { ssh, .. } => {
1350 Some(ssh_bare_check(id, &RuntimeSshTarget::from(ssh), executor))
1351 }
1352 _ => None,
1353 })
1354 .collect()
1355}
1356
1357fn ssh_bare_check(
1358 id: &str,
1359 ssh: &RuntimeSshTarget,
1360 executor: &impl CommandExecutor,
1361) -> DoctorCheck {
1362 let check_id = format!("runtime.ssh-bare.{id}");
1363 let title = format!("SSH access for target {id}");
1364 match ssh_connectivity(ssh, executor) {
1365 SshConnectivity::Reachable => DoctorCheck::ready(
1366 check_id,
1367 title,
1368 format!(
1369 "`ssh {} true` succeeds noninteractively from this host.",
1370 ssh.destination
1371 ),
1372 ),
1373 SshConnectivity::Failed {
1374 detail,
1375 remediation,
1376 } => DoctorCheck::fixable(check_id, title, detail, remediation),
1377 }
1378}
1379
1380fn ssh_podman_checks(
1383 config: ConfigStatus<'_>,
1384 executor: &impl CommandExecutor,
1385 smoke: bool,
1386) -> Vec<DoctorCheck> {
1387 let Ok(config) = config else {
1388 return Vec::new();
1389 };
1390 config
1391 .targets
1392 .iter()
1393 .flat_map(|(id, target)| match target {
1394 TargetTemplate::SshPodman { ssh, container, .. } => {
1395 let ssh = RuntimeSshTarget::from(ssh);
1396 let (check, reachable) =
1397 ssh_podman_check(id, &ssh, &container.image, executor, smoke);
1398 let mut checks = vec![check];
1399 if reachable {
1401 checks.push(ssh_podman_limits_check(id, &ssh, executor));
1402 }
1403 checks
1404 }
1405 _ => Vec::new(),
1406 })
1407 .collect()
1408}
1409
1410fn ssh_podman_check(
1413 id: &str,
1414 ssh: &RuntimeSshTarget,
1415 image: &str,
1416 executor: &impl CommandExecutor,
1417 smoke: bool,
1418) -> (DoctorCheck, bool) {
1419 let check_id = format!("runtime.ssh-podman.{id}");
1420 let title = format!("Remote Podman for target {id}");
1421 if let SshConnectivity::Failed {
1424 detail,
1425 remediation,
1426 } = ssh_connectivity(ssh, executor)
1427 {
1428 return (
1429 DoctorCheck::fixable(check_id, title, detail, remediation),
1430 false,
1431 );
1432 }
1433 (
1434 ssh_podman_runtime_check(check_id, title, ssh, image, executor, smoke),
1435 true,
1436 )
1437}
1438
1439fn ssh_podman_runtime_check(
1441 check_id: String,
1442 title: String,
1443 ssh: &RuntimeSshTarget,
1444 image: &str,
1445 executor: &impl CommandExecutor,
1446 smoke: bool,
1447) -> DoctorCheck {
1448 let destination = &ssh.destination;
1449 let preflight = match verify_ssh_podman(ssh, executor) {
1450 Ok(preflight) => preflight,
1451 Err(error) => {
1452 let detail = podman_failure_detail(&error);
1453 let remediation = match podman_remediation_match(&error) {
1454 Some(remediation) => {
1455 format!("On {destination}: {remediation} See {PODMAN_DOCUMENTATION_URL}.")
1456 }
1457 None => format!(
1458 "Verify `ssh {destination}` succeeds noninteractively from this host, then install rootless Podman 4.3 or newer there. See {PODMAN_DOCUMENTATION_URL}."
1459 ),
1460 };
1461 return DoctorCheck::fixable(check_id, title, detail, remediation);
1462 }
1463 };
1464 let linger_warning = preflight.warnings.first();
1465 if !smoke && let Some(warning) = linger_warning {
1466 return DoctorCheck::warning(
1467 check_id,
1468 title,
1469 format!(
1470 "Remote rootless Podman {} is available via {destination}, but {}",
1471 preflight.version, warning.detail
1472 ),
1473 &warning.remediation,
1474 );
1475 }
1476 if !smoke {
1477 return DoctorCheck::ready(
1478 check_id,
1479 title,
1480 format!(
1481 "Remote rootless Podman {} is available via {destination}. Run `mj doctor --json --smoke` to verify the image end to end.",
1482 preflight.version
1483 ),
1484 );
1485 }
1486
1487 let target = RuntimeTargetTemplate::SshPodman {
1488 ssh: ssh.clone(),
1489 container: RuntimeContainerTemplate {
1490 build_cache: None,
1491 image: image.to_owned(),
1492 pull_policy: Default::default(),
1493 extra_run_args: vec![],
1494 workspace_storage: Default::default(),
1495 },
1496 };
1497 match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1498 Ok(()) => match linger_warning {
1499 Some(warning) => DoctorCheck::warning(
1500 check_id,
1501 title,
1502 format!(
1503 "Disposable run/exec/remove smoke test passed for image {image} on {destination}, but {}",
1504 warning.detail
1505 ),
1506 &warning.remediation,
1507 ),
1508 None => DoctorCheck::ready(
1509 check_id,
1510 title,
1511 format!(
1512 "Disposable run/exec/remove smoke test passed for image {image} on {destination}."
1513 ),
1514 ),
1515 },
1516 Err(error) => DoctorCheck::fixable(
1517 check_id,
1518 title,
1519 format!(
1520 "Disposable run/exec/remove smoke test failed for image {image} on {destination}: {error:#}"
1521 ),
1522 format!(
1523 "Fix the configured image or Podman runtime on {destination}, then run `mj doctor --json --smoke` again."
1524 ),
1525 ),
1526 }
1527}
1528
1529const SSH_PODMAN_HOST_LIMITS_SCRIPT: &str = r#"
1538if [ -r /proc/sys/kernel/keys/maxkeys ]; then
1539 printf 'keys.max=%s\n' "$(cat /proc/sys/kernel/keys/maxkeys)"
1540fi
1541if [ -r /proc/key-users ]; then
1542 awk -v uid="$(id -u)" '
1543 { user = $1; sub(/:$/, "", user) }
1544 user == uid {
1545 split($4, quota, "/")
1546 printf "keys.used=%s\nkeys.quota=%s\n", quota[1], quota[2]
1547 }
1548 ' /proc/key-users
1549fi
1550unreadable=0
1551maxstartups=
1552# A drop-in directory that cannot be listed hides any override it holds.
1553if [ -d /etc/ssh/sshd_config.d ] && ! [ -r /etc/ssh/sshd_config.d ]; then
1554 unreadable=1
1555fi
1556for file in /etc/ssh/sshd_config /etc/ssh/sshd_config.d/*.conf; do
1557 [ -e "$file" ] || continue
1558 if [ -r "$file" ]; then
1559 match=$(grep -i '^[[:space:]]*maxstartups[[:space:]]' "$file" 2>/dev/null | tail -n 1)
1560 [ -n "$match" ] && maxstartups=$(printf '%s\n' "$match" | awk '{ print $2 }')
1561 else
1562 unreadable=1
1563 fi
1564done
1565[ -n "$maxstartups" ] && printf 'maxstartups=%s\n' "$maxstartups"
1566[ "$unreadable" = 1 ] && printf 'maxstartups.unreadable=1\n'
1567exit 0
1568"#;
1569
1570const KEYRING_PRESSURE_PERCENT: u64 = 80;
1574
1575#[derive(Debug, Default, PartialEq, Eq)]
1578struct HostLimits {
1579 keys_used: Option<u64>,
1580 keys_quota: Option<u64>,
1581 keys_max: Option<u64>,
1582 max_startups: Option<String>,
1583 max_startups_unreadable: bool,
1584}
1585
1586fn parse_host_limits(stdout: &[u8]) -> HostLimits {
1587 let text = String::from_utf8_lossy(stdout);
1588 let mut limits = HostLimits::default();
1589 for line in text.lines() {
1590 let Some((name, value)) = line.split_once('=') else {
1591 continue;
1592 };
1593 let value = value.trim();
1594 match name.trim() {
1595 "keys.used" => limits.keys_used = value.parse().ok(),
1596 "keys.quota" => limits.keys_quota = value.parse().ok(),
1597 "keys.max" => limits.keys_max = value.parse().ok(),
1598 "maxstartups" if !value.is_empty() => limits.max_startups = Some(value.to_owned()),
1599 "maxstartups.unreadable" => limits.max_startups_unreadable = value == "1",
1600 _ => {}
1601 }
1602 }
1603 limits
1604}
1605
1606impl HostLimits {
1607 fn is_empty(&self) -> bool {
1609 self.keys_used.is_none()
1610 && self.keys_quota.is_none()
1611 && self.keys_max.is_none()
1612 && self.max_startups.is_none()
1613 && !self.max_startups_unreadable
1614 }
1615
1616 fn keyring_is_under_pressure(&self) -> bool {
1617 match (self.keys_used, self.keys_quota) {
1618 (Some(used), Some(quota)) if quota > 0 => {
1619 used.saturating_mul(100) >= quota.saturating_mul(KEYRING_PRESSURE_PERCENT)
1620 }
1621 _ => false,
1622 }
1623 }
1624
1625 fn keyring_sentence(&self, destination: &str) -> String {
1626 match (self.keys_used, self.keys_quota) {
1627 (Some(used), Some(quota)) => {
1628 let system = match self.keys_max {
1629 Some(max) => format!(", and `kernel.keys.maxkeys` is {max}"),
1630 None => String::new(),
1631 };
1632 format!(
1633 "The login user on {destination} holds {used} of its {quota} kernel keyring quota{system}."
1634 )
1635 }
1636 _ => format!(
1637 "The kernel keyring quota for the login user on {destination} could not be read."
1638 ),
1639 }
1640 }
1641
1642 fn max_startups_sentence(&self) -> String {
1643 match (&self.max_startups, self.max_startups_unreadable) {
1644 (Some(value), _) => format!("sshd MaxStartups is {value}."),
1645 (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(),
1646 (None, false) => {
1647 "sshd MaxStartups is not set in sshd_config, so sshd's default applies.".to_owned()
1648 }
1649 }
1650 }
1651}
1652
1653fn ssh_podman_limits_check(
1657 id: &str,
1658 ssh: &RuntimeSshTarget,
1659 executor: &impl CommandExecutor,
1660) -> DoctorCheck {
1661 let check_id = format!("runtime.ssh-podman.{id}.limits");
1662 let title = format!("Host limits for target {id}");
1663 let destination = &ssh.destination;
1664 let manual = || {
1665 format!(
1666 "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`."
1667 )
1668 };
1669 let command = ssh_validation_command(
1670 ssh,
1671 vec![
1672 "sh".to_owned(),
1673 "-c".to_owned(),
1674 SSH_PODMAN_HOST_LIMITS_SCRIPT.to_owned(),
1675 ],
1676 "read ssh-podman host limits",
1677 );
1678 let limits = match executor.execute(&command) {
1679 Ok(output) if output.status == 0 => parse_host_limits(&output.stdout),
1680 Ok(output) => {
1681 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
1682 return DoctorCheck::warning(
1683 check_id,
1684 title,
1685 format!(
1686 "Could not read the kernel keyring quota or sshd MaxStartups from {destination}: {stderr}"
1687 ),
1688 manual(),
1689 );
1690 }
1691 Err(error) => {
1692 return DoctorCheck::warning(
1693 check_id,
1694 title,
1695 format!(
1696 "Could not read the kernel keyring quota or sshd MaxStartups from {destination}: {error}"
1697 ),
1698 manual(),
1699 );
1700 }
1701 };
1702 if limits.is_empty() {
1703 return DoctorCheck::warning(
1704 check_id,
1705 title,
1706 format!("{destination} reported no readable kernel keyring or sshd limits."),
1707 manual(),
1708 );
1709 }
1710 let detail = format!(
1711 "{} {}",
1712 limits.keyring_sentence(destination),
1713 limits.max_startups_sentence()
1714 );
1715 if limits.keyring_is_under_pressure() {
1716 return DoctorCheck::warning(
1717 check_id,
1718 title,
1719 format!(
1720 "{detail} Every container takes a session keyring, so `podman run` fails with `crun: create keyring` once the quota is gone."
1721 ),
1722 format!(
1723 "Raise `kernel.keys.maxkeys` and `kernel.keys.maxbytes` with sysctl on {destination}, and close finished sessions promptly."
1724 ),
1725 );
1726 }
1727 DoctorCheck::ready(check_id, title, detail)
1728}
1729
1730fn ssh_docker_checks(
1733 config: ConfigStatus<'_>,
1734 executor: &impl CommandExecutor,
1735 smoke: bool,
1736) -> Vec<DoctorCheck> {
1737 let Ok(config) = config else {
1738 return Vec::new();
1739 };
1740 config
1741 .targets
1742 .iter()
1743 .filter_map(|(id, target)| match target {
1744 TargetTemplate::SshDocker { ssh, container } => Some(ssh_docker_check(
1745 id,
1746 &RuntimeSshTarget::from(ssh),
1747 &container.image,
1748 executor,
1749 smoke,
1750 )),
1751 _ => None,
1752 })
1753 .collect()
1754}
1755
1756fn ssh_docker_check(
1757 id: &str,
1758 ssh: &RuntimeSshTarget,
1759 image: &str,
1760 executor: &impl CommandExecutor,
1761 smoke: bool,
1762) -> DoctorCheck {
1763 let check_id = format!("runtime.ssh-docker.{id}");
1764 let title = format!("Remote Docker for target {id}");
1765 let destination = &ssh.destination;
1766 if let SshConnectivity::Failed {
1767 detail,
1768 remediation,
1769 } = ssh_connectivity(ssh, executor)
1770 {
1771 return DoctorCheck::fixable(check_id, title, detail, remediation);
1772 }
1773
1774 let preflight = match verify_ssh_docker(ssh, executor) {
1775 Ok(preflight) => preflight,
1776 Err(error) => {
1777 let detail = format!("{error:#}");
1778 return DoctorCheck::fixable(
1779 check_id,
1780 title,
1781 detail,
1782 format!(
1783 "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."
1784 ),
1785 );
1786 }
1787 };
1788
1789 if smoke {
1790 let target = RuntimeTargetTemplate::SshDocker {
1791 ssh: ssh.clone(),
1792 container: RuntimeContainerTemplate {
1793 build_cache: None,
1794 image: image.to_owned(),
1795 pull_policy: Default::default(),
1796 extra_run_args: vec![],
1797 workspace_storage: Default::default(),
1798 },
1799 };
1800 return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1801 Ok(()) => DoctorCheck::ready(
1802 check_id,
1803 title,
1804 format!(
1805 "Remote Docker {} is available via {destination}; disposable run/exec/remove and remote OverlayFS attachment smoke test passed for image {image}.",
1806 preflight.version
1807 ),
1808 ),
1809 Err(error) => DoctorCheck::fixable(
1810 check_id,
1811 title,
1812 format!(
1813 "Disposable run/exec/remove smoke test failed for image {image} on {destination}: {error:#}"
1814 ),
1815 format!(
1816 "Fix the configured image or Docker runtime on {destination}, then run `mj doctor --json --smoke` again."
1817 ),
1818 ),
1819 };
1820 }
1821
1822 let image_command = ssh_command(
1823 ssh,
1824 [
1825 "docker".to_owned(),
1826 "image".to_owned(),
1827 "inspect".to_owned(),
1828 image.to_owned(),
1829 ]
1830 .to_vec(),
1831 )
1832 .purpose("check remote Docker image presence");
1833 match executor.execute(&image_command) {
1834 Ok(output) if output.status == 0 => DoctorCheck::ready(
1835 check_id,
1836 title,
1837 format!(
1838 "Remote Docker {} is available via {destination}; image {image} is present. Run `mj doctor --json --smoke` to verify remote OverlayFS attachments.",
1839 preflight.version
1840 ),
1841 ),
1842 Ok(output) => DoctorCheck::fixable(
1843 check_id,
1844 title,
1845 format!(
1846 "Image {image} is not present in remote Docker storage on {destination}: {}",
1847 String::from_utf8_lossy(&output.stderr).trim()
1848 ),
1849 format!(
1850 "Pull it on {destination} with `ssh {destination} docker pull {image}`, or run `mj doctor --json --smoke`."
1851 ),
1852 ),
1853 Err(error) => DoctorCheck::fixable(
1854 check_id,
1855 title,
1856 format!("Could not inspect remote Docker image {image} on {destination}: {error}"),
1857 format!(
1858 "Verify `ssh {destination} docker info` succeeds, then pull {image} on that host."
1859 ),
1860 ),
1861 }
1862}
1863
1864fn doctor_smoke_id() -> String {
1866 format!(
1867 "doctor-{}-{:x}",
1868 std::process::id(),
1869 SystemTime::now()
1870 .duration_since(UNIX_EPOCH)
1871 .unwrap_or_default()
1872 .as_nanos()
1873 )
1874}
1875
1876fn podman_remediation(error: &anyhow::Error) -> String {
1877 let fix = podman_remediation_match(error).unwrap_or(
1878 "Install Podman with `sudo apt update && sudo apt install -y podman uidmap` (Debian/Ubuntu) or `sudo dnf install -y podman shadow-utils` (Fedora).",
1879 );
1880 format!("{fix} See {PODMAN_DOCUMENTATION_URL}.")
1881}
1882
1883fn podman_failure_detail(error: &anyhow::Error) -> String {
1885 podman_probe_observation(error).map_or_else(|| format!("{error:#}"), str::to_owned)
1886}
1887
1888fn podman_remediation_match(error: &anyhow::Error) -> Option<&'static str> {
1895 failed_podman_postcondition(error).map(PodmanPostcondition::remediation)
1896}
1897
1898const AWS_CLI_INSTALL_URL: &str =
1899 "https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html";
1900
1901fn aws_checks(config: ConfigStatus<'_>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
1904 let Ok(config) = config else {
1905 return Vec::new();
1906 };
1907 config
1908 .targets
1909 .iter()
1910 .filter_map(|(id, target)| match target {
1911 TargetTemplate::AwsEc2 {
1912 aws_profile,
1913 region,
1914 launch_template,
1915 ..
1916 } => Some(aws_target_check(
1917 id,
1918 aws_profile.as_deref(),
1919 region,
1920 launch_template,
1921 executor,
1922 )),
1923 _ => None,
1924 })
1925 .collect()
1926}
1927
1928fn aws_global_args<'a>(profile: Option<&'a str>, region: &'a str) -> Vec<String> {
1931 vec![
1932 "--profile".to_owned(),
1933 profile.unwrap_or("default").to_owned(),
1934 "--region".to_owned(),
1935 region.to_owned(),
1936 ]
1937}
1938
1939fn aws_target_check(
1940 id: &str,
1941 profile: Option<&str>,
1942 region: &str,
1943 launch_template: &str,
1944 executor: &impl CommandExecutor,
1945) -> DoctorCheck {
1946 let check_id = format!("runtime.aws-ec2.{id}");
1947 let title = format!("AWS EC2 target {id}");
1948 let profile_label = profile.unwrap_or("default");
1949
1950 let version = CommandSpec::new("aws", ["--version"]).purpose("check AWS CLI installation");
1951 match executor.execute(&version) {
1952 Err(error) => {
1953 return DoctorCheck::fixable(
1954 check_id,
1955 title,
1956 format!("The `aws` command is not available: {error}"),
1957 format!("Install the AWS CLI and put `aws` on PATH: {AWS_CLI_INSTALL_URL}"),
1958 );
1959 }
1960 Ok(output) if output.status != 0 => {
1961 return DoctorCheck::fixable(
1962 check_id,
1963 title,
1964 format!(
1965 "`aws --version` failed: {}",
1966 String::from_utf8_lossy(&output.stderr).trim()
1967 ),
1968 format!("Reinstall the AWS CLI: {AWS_CLI_INSTALL_URL}"),
1969 );
1970 }
1971 Ok(_) => {}
1972 }
1973
1974 let mut identity_args = aws_global_args(profile, region);
1975 identity_args.extend(["sts".to_owned(), "get-caller-identity".to_owned()]);
1976 identity_args.extend(["--output".to_owned(), "json".to_owned()]);
1977 let identity =
1978 CommandSpec::new("aws", identity_args).purpose("check AWS credentials for a doctor target");
1979 match executor.execute(&identity) {
1980 Err(error) => {
1981 return DoctorCheck::fixable(
1982 check_id,
1983 title,
1984 format!("Could not run `aws sts get-caller-identity`: {error}"),
1985 format!(
1986 "Configure credentials with `aws configure --profile {profile_label}`, or sign in with `aws sso login --profile {profile_label}`."
1987 ),
1988 );
1989 }
1990 Ok(output) if output.status != 0 => {
1991 return DoctorCheck::fixable(
1992 check_id,
1993 title,
1994 format!(
1995 "AWS credentials for profile {profile_label} are not usable: {}",
1996 String::from_utf8_lossy(&output.stderr).trim()
1997 ),
1998 format!(
1999 "Configure credentials with `aws configure --profile {profile_label}`, or sign in with `aws sso login --profile {profile_label}`."
2000 ),
2001 );
2002 }
2003 Ok(_) => {}
2004 }
2005
2006 let by_id = launch_template.starts_with("lt-");
2009 let mut template_args = aws_global_args(profile, region);
2010 template_args.extend(["ec2".to_owned(), "describe-launch-templates".to_owned()]);
2011 template_args.extend([
2012 if by_id {
2013 "--launch-template-ids".to_owned()
2014 } else {
2015 "--launch-template-names".to_owned()
2016 },
2017 launch_template.to_owned(),
2018 ]);
2019 template_args.extend(["--output".to_owned(), "json".to_owned()]);
2020 let template =
2021 CommandSpec::new("aws", template_args).purpose("check the configured AWS launch template");
2022 let template_remediation = format!(
2023 "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."
2024 );
2025 match executor.execute(&template) {
2026 Err(error) => DoctorCheck::fixable(
2027 check_id,
2028 title,
2029 format!("Could not query launch template {launch_template}: {error}"),
2030 template_remediation,
2031 ),
2032 Ok(output) if output.status != 0 => DoctorCheck::fixable(
2033 check_id,
2034 title,
2035 format!(
2036 "Launch template {launch_template} was not found in {region}: {}",
2037 String::from_utf8_lossy(&output.stderr).trim()
2038 ),
2039 template_remediation,
2040 ),
2041 Ok(_) => DoctorCheck::ready(
2042 check_id,
2043 title,
2044 format!(
2045 "The AWS CLI is installed, profile {profile_label} has valid credentials, and launch template {launch_template} exists in {region}."
2046 ),
2047 ),
2048 }
2049}
2050
2051fn daemon_build_check() -> DoctorCheck {
2059 const ID: &str = "daemon.build";
2060 const TITLE: &str = "Daemon build";
2061 let Ok(metadata) = mj_client::daemon::read_metadata_any() else {
2062 return DoctorCheck::ready(
2063 ID,
2064 TITLE,
2065 "No Mjolnir daemon is running; the next command starts one from this build.",
2066 );
2067 };
2068 let pid = metadata.pid;
2069 match mj_client::executable::process_runs_this_executable(pid) {
2070 Ok(Some(true)) => DoctorCheck::ready(
2071 ID,
2072 TITLE,
2073 format!(
2074 "Daemon {pid} runs this build (version {}).",
2075 metadata.build_version
2076 ),
2077 ),
2078 Ok(Some(false)) => DoctorCheck::warning(
2079 ID,
2080 TITLE,
2081 format!(
2082 "{}. Two builds can report the same version, so the files are what tell them apart. Code rebuilt since that daemon started is not running.",
2083 mj_client::executable::describe_running_daemon_and_client_builds(
2084 pid,
2085 &metadata.build_version,
2086 ),
2087 ),
2088 "Run `mj daemon restart` from this build. It now fails rather than reporting success if another client's build wins.",
2089 ),
2090 Ok(None) => DoctorCheck::ready(
2091 ID,
2092 TITLE,
2093 format!(
2094 "Daemon {pid} is recorded but not running; the next command starts one from this build."
2095 ),
2096 ),
2097 Err(error) => DoctorCheck::warning(
2098 ID,
2099 TITLE,
2100 format!("Could not tell which build daemon {pid} runs: {error:#}"),
2101 "Run `mj daemon restart` from this build if rebuilt code is not taking effect.",
2102 ),
2103 }
2104}
2105
2106fn worker_freshness_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
2114 let mut checks = Vec::new();
2115 let daemon = mj_client::daemon::read_metadata_any()
2116 .ok()
2117 .filter(|metadata| mj_client::daemon::process_is_alive(metadata.pid));
2118 let pinned = pinned_worker_digests();
2119 let mut sources: Vec<(String, Result<WorkerBinaryAvailability>)> = vec![(
2120 "this host".to_owned(),
2121 crate::controller::native_worker_binary_prerequisite(),
2122 )];
2123 for arch in container_worker_architectures(config) {
2124 sources.push((
2125 format!("{arch} Linux targets"),
2126 worker_binary_prerequisite_for_arch(&arch),
2127 ));
2128 }
2129 for (label, availability) in sources {
2130 let id = format!("worker.freshness.{}", label.replace(' ', "-"));
2131 let title = format!("Worker binary for {label}");
2132 let path = match availability {
2133 Ok(WorkerBinaryAvailability::Local { path, .. }) => path,
2134 Ok(WorkerBinaryAvailability::Remote { .. }) => continue,
2137 Err(error) => {
2138 checks.push(DoctorCheck::unsupported(
2139 id,
2140 title,
2141 format!("No worker binary resolves for {label}: {error:#}"),
2142 ));
2143 continue;
2144 }
2145 };
2146 let digest = mj_core::worker_launch::worker_executable_digest(&path)
2147 .unwrap_or_else(|error| format!("unreadable ({error:#})"));
2148 let pinned_note = if pinned.is_empty() {
2149 "the daemon has pinned no worker".to_owned()
2150 } else if pinned.contains(&digest) {
2151 "this content is in the daemon's pinned worker cache".to_owned()
2152 } else {
2153 format!(
2154 "the pinned worker cache holds {} instead",
2155 pinned.join(", ")
2156 )
2157 };
2158 let detail = format!("{} has digest {digest}; {pinned_note}.", path.display());
2159 let Some(metadata) = daemon.as_ref() else {
2160 checks.push(DoctorCheck::ready(
2161 id,
2162 title,
2163 format!("{detail} No daemon is running, so the next session uses this file."),
2164 ));
2165 continue;
2166 };
2167 match worker_changed_since_daemon_start(&path, &metadata.started_at) {
2168 Ok(true) => checks.push(DoctorCheck::warning(
2169 id,
2170 title,
2171 format!("{detail} It was rebuilt after daemon {} started, which froze the copy it serves.", metadata.pid),
2172 "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.",
2173 )),
2174 Ok(false) => checks.push(DoctorCheck::ready(id, title, detail)),
2175 Err(error) => checks.push(DoctorCheck::warning(
2176 id,
2177 title,
2178 format!("{detail} Could not compare it with the daemon's start time: {error:#}"),
2179 "Run `mj daemon restart` if rebuilt worker code is not taking effect.",
2180 )),
2181 }
2182 }
2183 checks
2184}
2185
2186fn container_worker_architectures(config: ConfigStatus<'_>) -> Vec<String> {
2188 let Ok(config) = config else {
2189 return Vec::new();
2190 };
2191 let mut architectures = Vec::new();
2192 for target in config.targets.values() {
2193 let container = match target {
2194 TargetTemplate::LocalPodman { container }
2195 | TargetTemplate::LocalDocker { container }
2196 | TargetTemplate::AppleContainer { container }
2197 | TargetTemplate::SshPodman { container, .. }
2198 | TargetTemplate::SshDocker { container, .. } => container,
2199 _ => continue,
2200 };
2201 let arch = container
2202 .platform
2203 .as_deref()
2204 .and_then(|platform| platform.rsplit('/').next())
2205 .map_or_else(
2206 || std::env::consts::ARCH.to_owned(),
2207 normalized_worker_architecture,
2208 );
2209 if !architectures.contains(&arch) {
2210 architectures.push(arch);
2211 }
2212 }
2213 architectures
2214}
2215
2216fn normalized_worker_architecture(platform_arch: &str) -> String {
2219 match platform_arch {
2220 "amd64" => "x86_64".to_owned(),
2221 "arm64" => "aarch64".to_owned(),
2222 other => other.to_owned(),
2223 }
2224}
2225
2226fn pinned_worker_digests() -> Vec<String> {
2231 let root = mj_core::config::data_dir().join("workers").join("pinned");
2232 let Ok(entries) = std::fs::read_dir(root) else {
2233 return Vec::new();
2234 };
2235 let mut digests: Vec<String> = entries
2236 .flatten()
2237 .filter(|entry| entry.path().is_dir())
2238 .filter_map(|entry| entry.file_name().into_string().ok())
2239 .collect();
2240 digests.sort();
2241 digests
2242}
2243
2244fn worker_changed_since_daemon_start(path: &Path, started_at: &str) -> Result<bool> {
2249 let started: SystemTime = chrono::DateTime::parse_from_rfc3339(started_at)
2250 .map_err(|error| anyhow::anyhow!("parse daemon start time {started_at:?}: {error}"))?
2251 .into();
2252 let modified = std::fs::metadata(path)?.modified()?;
2253 Ok(modified > started)
2254}
2255
2256fn offered_targets<'a>(
2264 config: &Config,
2265 engine_checks: impl IntoIterator<Item = &'a DoctorCheck>,
2266) -> Config {
2267 let ready = engine_checks
2268 .into_iter()
2269 .filter(|check| check.status == CheckStatus::Ready)
2270 .map(|check| check.id.as_str())
2271 .collect::<std::collections::BTreeSet<_>>();
2272 let engine_ready = |target_id: &str| match target_id {
2273 "podman" => ready.contains("runtime.podman"),
2274 "docker" => ready.contains("runtime.docker"),
2275 "apple-container" => ready.contains("runtime.apple-container"),
2276 _ => true,
2277 };
2278 let mut offered = config.clone().with_local_targets();
2279 offered
2280 .targets
2281 .retain(|id, _| config.configures_target(id) || engine_ready(id));
2282 offered
2283}
2284
2285fn worker_binary_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
2286 let config = match config {
2287 Ok(config) => config,
2288 Err(ConfigGap::NewerVersion(version)) => {
2289 return vec![newer_config_skip(
2290 "worker.containers",
2291 "Container worker binary",
2292 version,
2293 )];
2294 }
2295 Err(ConfigGap::Unreadable) => {
2296 return vec![DoctorCheck::fixable(
2297 "worker.containers",
2298 "Container worker binary",
2299 "Worker availability cannot be checked until config.toml is valid.",
2300 "Fix config.toml, then rerun `mj doctor --json`.",
2301 )];
2302 }
2303 };
2304 let containers = config
2305 .targets
2306 .iter()
2307 .filter_map(|(id, target)| match target {
2308 TargetTemplate::LocalPodman { container }
2309 | TargetTemplate::LocalDocker { container }
2310 | TargetTemplate::AppleContainer { container } => Some((id, container, None)),
2311 TargetTemplate::SshPodman { container, .. } => {
2312 Some((id, container, Some("ssh-podman")))
2313 }
2314 TargetTemplate::SshDocker { container, .. } => {
2315 Some((id, container, Some("ssh-docker")))
2316 }
2317 _ => None,
2318 })
2319 .collect::<Vec<_>>();
2320 if containers.is_empty() {
2321 return vec![DoctorCheck::unsupported(
2322 "worker.containers",
2323 "Container worker binary",
2324 "No container target is configured.",
2325 )];
2326 }
2327 containers
2328 .into_iter()
2329 .map(|(id, container, remote_kind)| {
2330 if let Some(remote_kind) = remote_kind
2331 && container.platform.is_none()
2332 {
2333 return DoctorCheck::unsupported(
2336 format!("worker.{id}"),
2337 format!("Container worker binary for target {id}"),
2338 format!(
2339 "Set `platform` on this {remote_kind} target to check its worker binary; the remote architecture is unknown until provisioning."
2340 ),
2341 );
2342 }
2343 worker_binary_check(id, container)
2344 })
2345 .collect()
2346}
2347
2348fn worker_binary_check(id: &str, container: &ContainerTemplate) -> DoctorCheck {
2349 let title = format!("Container worker binary for target {id}");
2350 let arch = match container_architecture(container.platform.as_deref()) {
2351 Ok(arch) => arch,
2352 Err(reason) => {
2353 return DoctorCheck::unsupported(format!("worker.{id}"), title, reason);
2354 }
2355 };
2356 let triple = format!("{arch}-unknown-linux-musl");
2357 match worker_binary_prerequisite_for_arch(arch) {
2358 Ok(WorkerBinaryAvailability::Local { path, source }) => DoctorCheck::ready(
2359 format!("worker.{id}"),
2360 title,
2361 format!(
2362 "{triple} worker is available from {source}: {}",
2363 path.display()
2364 ),
2365 ),
2366 Ok(WorkerBinaryAvailability::Remote { url, .. }) => DoctorCheck::ready(
2367 format!("worker.{id}"),
2368 title,
2369 format!("{triple} worker will be verified and downloaded from {url} when needed."),
2370 ),
2371 Err(error) => DoctorCheck::fixable(
2372 format!("worker.{id}"),
2373 title,
2374 format!("No usable {triple} worker source: {error:#}"),
2375 format!(
2376 "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."
2377 ),
2378 ),
2379 }
2380}
2381
2382fn container_architecture(platform: Option<&str>) -> std::result::Result<&'static str, String> {
2383 let candidate = platform.unwrap_or(std::env::consts::ARCH);
2384 let candidate = candidate
2385 .split('/')
2386 .rev()
2387 .find(|part| matches!(*part, "x86_64" | "amd64" | "aarch64" | "arm64"))
2388 .unwrap_or(candidate);
2389 match candidate {
2390 "x86_64" | "amd64" => Ok("x86_64"),
2391 "aarch64" | "arm64" => Ok("aarch64"),
2392 other => Err(format!(
2393 "Container architecture {other:?} is unsupported; Mjolnir supports x86_64 and aarch64 Linux workers."
2394 )),
2395 }
2396}
2397
2398fn apple_container_image(config: ConfigStatus<'_>) -> String {
2399 config
2400 .ok()
2401 .and_then(|config| {
2402 config.targets.values().find_map(|target| match target {
2403 TargetTemplate::AppleContainer { container } => Some(container.image.clone()),
2404 _ => None,
2405 })
2406 })
2407 .unwrap_or_else(|| DEFAULT_CONTAINER_IMAGE.into())
2408}
2409
2410pub fn apple_container_check(
2411 platform: &ApplePlatform,
2412 executor: &impl CommandExecutor,
2413 smoke: bool,
2414 image: String,
2415) -> DoctorCheck {
2416 match platform {
2417 ApplePlatform::Linux => {
2418 return DoctorCheck::unsupported(
2419 "runtime.apple-container",
2420 "Apple container runtime",
2421 "macOS only",
2422 );
2423 }
2424 ApplePlatform::Other(current) => {
2425 return DoctorCheck::unsupported(
2426 "runtime.apple-container",
2427 "Apple container runtime",
2428 format!("macOS only (current platform: {current})"),
2429 );
2430 }
2431 ApplePlatform::Macos {
2432 architecture,
2433 major_version,
2434 } if architecture != "aarch64" && architecture != "arm64" => {
2435 return DoctorCheck::unsupported(
2436 "runtime.apple-container",
2437 "Apple container runtime",
2438 "Apple container requires Apple silicon; Intel Macs are unsupported.",
2439 );
2440 }
2441 ApplePlatform::Macos { major_version, .. } if *major_version < 26 => {
2442 return DoctorCheck::unsupported(
2443 "runtime.apple-container",
2444 "Apple container runtime",
2445 format!("Apple container requires macOS 26 or newer (found {major_version})."),
2446 );
2447 }
2448 ApplePlatform::Macos { .. } => {}
2449 }
2450
2451 let daemon = apple_container_daemon_check(executor);
2452 if daemon.status != CheckStatus::Ready {
2453 return daemon;
2454 }
2455
2456 if !smoke {
2457 return DoctorCheck::fixable(
2458 "runtime.apple-container",
2459 "Apple container runtime",
2460 "The daemon is running, but the required disposable smoke test was not requested.",
2461 "Run `mj doctor --json --smoke`.",
2462 );
2463 }
2464
2465 let target = RuntimeTargetTemplate::AppleContainer(RuntimeContainerTemplate {
2466 build_cache: None,
2467 image,
2468 pull_policy: Default::default(),
2469 extra_run_args: vec![],
2470 workspace_storage: Default::default(),
2471 });
2472 match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
2473 Ok(()) => DoctorCheck::ready(
2474 "runtime.apple-container",
2475 "Apple container runtime",
2476 "Installed, daemon running, and disposable run/exec/remove smoke test passed.",
2477 ),
2478 Err(error) => DoctorCheck::fixable(
2479 "runtime.apple-container",
2480 "Apple container runtime",
2481 format!("Disposable run/exec/remove smoke test failed: {error:#}"),
2482 "Fix the configured image or container runtime, then run `mj doctor --json --smoke` again.",
2483 ),
2484 }
2485}
2486
2487pub fn apple_container_daemon_check(executor: &impl CommandExecutor) -> DoctorCheck {
2494 let installed =
2495 CommandSpec::new("container", ["--version"]).purpose("check Apple container installation");
2496 match executor.execute(&installed) {
2497 Err(error) => {
2498 return DoctorCheck::fixable(
2499 "runtime.apple-container",
2500 "Apple container runtime",
2501 format!("The `container` command is not available: {error}"),
2502 format!("Install the official signed package: {APPLE_CONTAINER_INSTALL_URL}"),
2503 );
2504 }
2505 Ok(output) if output.status != 0 => {
2506 return DoctorCheck::fixable(
2507 "runtime.apple-container",
2508 "Apple container runtime",
2509 format!(
2510 "The installed `container --version` command failed: {}",
2511 String::from_utf8_lossy(&output.stderr).trim()
2512 ),
2513 format!("Reinstall the official signed package: {APPLE_CONTAINER_INSTALL_URL}"),
2514 );
2515 }
2516 Ok(_) => {}
2517 }
2518
2519 let status =
2520 CommandSpec::new("container", ["system", "status"]).purpose("check Apple container daemon");
2521 match executor.execute(&status) {
2522 Ok(output) if output.status == 0 => DoctorCheck::ready(
2523 "runtime.apple-container",
2524 "Apple container runtime",
2525 "Installed, and the Apple container daemon is running.",
2526 ),
2527 Ok(output) => DoctorCheck::fixable(
2528 "runtime.apple-container",
2529 "Apple container runtime",
2530 format!(
2531 "The Apple container daemon is stopped: {}",
2532 String::from_utf8_lossy(&output.stderr).trim()
2533 ),
2534 "Run `container system start`.",
2535 ),
2536 Err(error) => DoctorCheck::fixable(
2537 "runtime.apple-container",
2538 "Apple container runtime",
2539 format!("Could not query the Apple container daemon: {error}"),
2540 "Run `container system start`.",
2541 ),
2542 }
2543}
2544
2545pub fn current_apple_platform(executor: &impl CommandExecutor) -> ApplePlatform {
2546 if cfg!(target_os = "linux") {
2547 return ApplePlatform::Linux;
2548 }
2549 if !cfg!(target_os = "macos") {
2550 return ApplePlatform::Other(std::env::consts::OS.into());
2551 }
2552 let major_version = executor
2553 .execute(&CommandSpec::new("sw_vers", ["-productVersion"]).purpose("detect macOS version"))
2554 .ok()
2555 .filter(|output| output.status == 0)
2556 .and_then(|output| {
2557 String::from_utf8(output.stdout)
2558 .ok()
2559 .and_then(|value| value.trim().split('.').next()?.parse().ok())
2560 })
2561 .unwrap_or(0);
2562 ApplePlatform::Macos {
2563 architecture: std::env::consts::ARCH.into(),
2564 major_version,
2565 }
2566}
2567
2568#[cfg(test)]
2569mod tests;
2570
2571#[derive(Debug, Default, PartialEq, Eq)]
2573pub(crate) struct ReviewResidue {
2574 pub refs: Vec<String>,
2576 pub scratch_indexes: Vec<PathBuf>,
2578}
2579
2580impl ReviewResidue {
2581 fn is_empty(&self) -> bool {
2582 self.refs.is_empty() && self.scratch_indexes.is_empty()
2583 }
2584}
2585
2586pub(crate) fn review_residue(repository: &Path) -> ReviewResidue {
2593 let mut residue = ReviewResidue::default();
2594 let git_dir = repository.join(".git");
2595 if !git_dir.exists() {
2596 return residue;
2597 }
2598 for reference in ["review-baseline", "review-capture"] {
2599 if git_dir.join("refs/hel").join(reference).is_file() {
2600 residue.refs.push(format!("refs/hel/{reference}"));
2601 }
2602 }
2603 if let Ok(packed) = std::fs::read_to_string(git_dir.join("packed-refs")) {
2605 for line in packed.lines() {
2606 if let Some((_, reference)) = line.split_once(' ')
2607 && reference.starts_with("refs/hel/")
2608 && !residue.refs.iter().any(|known| known == reference)
2609 {
2610 residue.refs.push(reference.to_owned());
2611 }
2612 }
2613 }
2614 if let Ok(entries) = std::fs::read_dir(&git_dir) {
2615 for entry in entries.filter_map(Result::ok) {
2616 if entry
2617 .file_name()
2618 .to_str()
2619 .is_some_and(|name| name.starts_with("hel-review-index-"))
2620 {
2621 residue.scratch_indexes.push(entry.path());
2622 }
2623 }
2624 }
2625 residue.refs.sort();
2626 residue.scratch_indexes.sort();
2627 residue
2628}
2629
2630pub(crate) fn review_residue_repositories(
2641 configured: impl IntoIterator<Item = PathBuf>,
2642 sessions: &[&mj_core::state::SessionRecord],
2643) -> Vec<PathBuf> {
2644 use mj_core::state::ManagedCheckoutKind;
2645
2646 let managed_roots = sessions
2647 .iter()
2648 .filter_map(|session| session.managed_worktree.as_ref())
2649 .map(|worktree| worktree.worktree_root.as_path())
2650 .collect::<Vec<_>>();
2651 let mut in_use = Vec::new();
2652 for session in sessions.iter().filter(|session| session.state.is_active()) {
2653 if let Some(directory) = &session.project_directory {
2654 in_use.push(directory.as_path());
2655 }
2656 if let Some(worktree) = &session.managed_worktree
2657 && worktree.kind == ManagedCheckoutKind::Worktree
2658 {
2659 in_use.push(worktree.source_repository.as_path());
2660 }
2661 }
2662 let mut repositories = configured
2663 .into_iter()
2664 .chain(
2665 sessions
2666 .iter()
2667 .filter_map(|session| session.project_directory.clone()),
2668 )
2669 .filter(|repository| {
2670 !is_inside_managed_checkout(repository)
2671 && !managed_roots
2672 .iter()
2673 .any(|root| repository.starts_with(root))
2674 && !in_use.iter().any(|directory| repository == directory)
2675 })
2676 .collect::<Vec<_>>();
2677 repositories.sort();
2678 repositories.dedup();
2679 repositories
2680}
2681
2682fn is_inside_managed_checkout(path: &Path) -> bool {
2685 let components = path
2686 .components()
2687 .map(|component| component.as_os_str())
2688 .collect::<Vec<_>>();
2689 components
2690 .windows(3)
2691 .any(|window| window[0] == ".mj" && (window[1] == "clones" || window[1] == "worktrees"))
2692}
2693
2694fn review_residue_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
2700 let Ok(config) = config else {
2701 return Vec::new();
2702 };
2703 let configured = config
2704 .bundles
2705 .values()
2706 .flat_map(|bundle| bundle.repositories.iter())
2707 .filter_map(|repository| repository.local.clone());
2708 let state = crate::database::load_state().ok();
2711 let sessions = state
2712 .as_ref()
2713 .map(|state| state.sessions.values().collect::<Vec<_>>())
2714 .unwrap_or_default();
2715 let repositories = review_residue_repositories(configured, &sessions);
2716 if repositories.is_empty() {
2717 return Vec::new();
2718 }
2719 let found = repositories
2720 .into_iter()
2721 .map(|repository| {
2722 let residue = review_residue(&repository);
2723 (repository, residue)
2724 })
2725 .filter(|(_, residue)| !residue.is_empty())
2726 .collect::<Vec<_>>();
2727 if found.is_empty() {
2728 return vec![DoctorCheck::ready(
2729 "review.residue",
2730 "Review leftovers in your repositories",
2731 "No Mjolnir refs or scratch index files were found in the configured repositories.",
2732 )];
2733 }
2734 let detail = found
2735 .iter()
2736 .map(|(repository, residue)| {
2737 let mut parts = Vec::new();
2738 if !residue.refs.is_empty() {
2739 parts.push(residue.refs.join(", "));
2740 }
2741 if !residue.scratch_indexes.is_empty() {
2742 parts.push(mj_core::text::counted(
2743 residue.scratch_indexes.len(),
2744 "leftover scratch index file",
2745 "leftover scratch index files",
2746 ));
2747 }
2748 format!("{}: {}", repository.display(), parts.join("; "))
2749 })
2750 .collect::<Vec<_>>()
2751 .join(". ");
2752 let commands = found
2753 .iter()
2754 .flat_map(|(repository, residue)| {
2755 let repository = repository.display().to_string();
2756 let mut commands = residue
2757 .refs
2758 .iter()
2759 .map(|reference| format!("git -C {repository} update-ref -d {reference}"))
2760 .collect::<Vec<_>>();
2761 commands.extend(
2762 residue
2763 .scratch_indexes
2764 .iter()
2765 .map(|index| format!("rm -f {}", index.display())),
2766 );
2767 commands.push(format!("git -C {repository} gc --prune=now"));
2768 commands
2769 })
2770 .collect::<Vec<_>>()
2771 .join("\n");
2772 vec![DoctorCheck::fixable(
2773 "review.residue",
2774 "Review leftovers in your repositories",
2775 format!("Mjolnir left these in repositories it does not own: {detail}."),
2776 format!("Remove them yourself when you are ready:\n{commands}"),
2777 )]
2778}