Skip to main content

agent_runtime/
doctor.rs

1//! Read-only `agent-runtime doctor` probes. Plan 04 Sprint 3.
2//!
3//! This sprint covers filesystem posture only: link-map symlinks,
4//! managed-block marker pairing, runtime-roots path readability, and
5//! product version posture.
6
7pub mod coverage;
8pub mod probes;
9pub mod project;
10pub mod skill_surface;
11pub mod upgrade;
12pub mod version;
13pub mod version_alignment;
14
15use crate::install::link_map::{LinkMap, LinkMapError};
16use crate::install::overlay::{self, LinkMapOverlay, OverlaySummary};
17use crate::install::plan::{InstallPlan, PlanError};
18use crate::render::manifest::{ProductRoot, RuntimeRootsManifest, SCHEMA_VERSION};
19use serde::Serialize;
20use std::collections::BTreeMap;
21use std::path::{Path, PathBuf};
22use thiserror::Error;
23
24#[derive(Debug, Clone)]
25pub struct DoctorOptions {
26    pub overlay_enabled: bool,
27    pub overlay_path: Option<PathBuf>,
28    pub cli_tools_profile: String,
29    pub check_project: Option<PathBuf>,
30    pub class_filter: Option<DoctorClass>,
31    pub pin_path: Option<PathBuf>,
32}
33
34impl Default for DoctorOptions {
35    fn default() -> Self {
36        Self {
37            overlay_enabled: true,
38            overlay_path: None,
39            cli_tools_profile: "recommended".to_string(),
40            check_project: None,
41            class_filter: None,
42            pin_path: None,
43        }
44    }
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum DoctorClass {
49    SkillSurface,
50    VersionAlignment,
51}
52
53#[derive(Debug, Error)]
54pub enum DoctorError {
55    #[error("runtime-roots: {0}")]
56    RuntimeRoots(#[from] RuntimeRootsError),
57    #[error("link-map: {0}")]
58    LinkMap(#[from] LinkMapError),
59    #[error("plan: {0}")]
60    Plan(#[from] PlanError),
61    #[error("coverage: {0}")]
62    Coverage(#[from] coverage::CoverageError),
63    #[error("version-alignment: {0}")]
64    VersionAlignment(#[from] version_alignment::VersionAlignmentError),
65    #[error("unknown product `{product}`; expected `codex`, `claude`, or `hermes`")]
66    UnknownProduct { product: String },
67}
68
69#[derive(Debug, Error)]
70pub enum RuntimeRootsError {
71    #[error("missing runtime-roots manifest: {path}")]
72    Missing { path: PathBuf },
73    #[error("schema_version mismatch in {file}: expected {expected}, got {found}")]
74    SchemaVersion {
75        file: PathBuf,
76        expected: u32,
77        found: u32,
78    },
79    #[error("parse error in {file}: {source}")]
80    Parse {
81        file: PathBuf,
82        #[source]
83        source: serde_yaml_ng::Error,
84    },
85    #[error("io error reading {file}: {source}")]
86    Io {
87        file: PathBuf,
88        #[source]
89        source: std::io::Error,
90    },
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
94#[serde(rename_all = "lowercase")]
95pub enum DoctorSeverity {
96    Ok,
97    Warn,
98    Block,
99}
100
101impl DoctorSeverity {
102    pub fn exit_code(self) -> u8 {
103        match self {
104            DoctorSeverity::Ok => 0,
105            DoctorSeverity::Warn => 1,
106            DoctorSeverity::Block => 2,
107        }
108    }
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
112pub struct DoctorFinding {
113    pub product: String,
114    pub check: &'static str,
115    pub severity: DoctorSeverity,
116    pub entry_id: Option<String>,
117    pub path: Option<PathBuf>,
118    pub message: String,
119}
120
121impl DoctorFinding {
122    pub fn warn(
123        product: &str,
124        check: &'static str,
125        entry_id: Option<String>,
126        path: Option<PathBuf>,
127        message: impl Into<String>,
128    ) -> Self {
129        Self {
130            product: product.to_string(),
131            check,
132            severity: DoctorSeverity::Warn,
133            entry_id,
134            path,
135            message: message.into(),
136        }
137    }
138
139    pub fn block(
140        product: &str,
141        check: &'static str,
142        entry_id: Option<String>,
143        path: Option<PathBuf>,
144        message: impl Into<String>,
145    ) -> Self {
146        Self {
147            product: product.to_string(),
148            check,
149            severity: DoctorSeverity::Block,
150            entry_id,
151            path,
152            message: message.into(),
153        }
154    }
155}
156
157#[derive(Debug, Clone)]
158pub struct ResolvedRuntimeRoots {
159    pub product: String,
160    pub live_home: PathBuf,
161    pub docs_home: PathBuf,
162    pub state_home: PathBuf,
163    pub plugin_root: Option<PathBuf>,
164}
165
166#[derive(Debug)]
167pub struct DoctorOutcome {
168    pub product: String,
169    pub findings: Vec<DoctorFinding>,
170    pub version_probes: Vec<version::VersionProbeFinding>,
171    pub coverage_probes: Vec<coverage::CoverageFinding>,
172    pub project_probes: Vec<project::ProjectOverlayFinding>,
173    pub skill_surface: Option<skill_surface::SkillSurfaceReport>,
174    pub version_alignment: Option<version_alignment::VersionAlignmentReport>,
175    pub acceptance_boundary: Option<String>,
176    pub ok: usize,
177    pub warn: usize,
178    pub block: usize,
179    pub overlay: Option<OverlaySummary>,
180}
181
182impl DoctorOutcome {
183    pub fn total_checks(&self) -> usize {
184        self.ok + self.warn + self.block
185    }
186
187    pub fn exit_code(&self) -> u8 {
188        if self.block > 0 {
189            DoctorSeverity::Block.exit_code()
190        } else if self.warn > 0 {
191            DoctorSeverity::Warn.exit_code()
192        } else {
193            DoctorSeverity::Ok.exit_code()
194        }
195    }
196}
197
198pub fn run(
199    product: &str,
200    source_root: &Path,
201    live_home_override: Option<&Path>,
202    state_home_override: Option<&Path>,
203    options: &DoctorOptions,
204) -> Result<DoctorOutcome, DoctorError> {
205    if matches!(options.class_filter, Some(DoctorClass::SkillSurface)) {
206        return run_skill_surface_only(product, source_root, options);
207    }
208    if matches!(options.class_filter, Some(DoctorClass::VersionAlignment)) {
209        return run_version_alignment_only(options);
210    }
211
212    let runtime_roots = load_runtime_roots(source_root)?;
213    let product_root = product_root(&runtime_roots, product)?;
214    let resolved_roots = resolve_runtime_roots(
215        product,
216        product_root,
217        live_home_override,
218        state_home_override,
219    );
220
221    let mut link_map = LinkMap::load(source_root, product)?;
222    let mut overlay_summary = None;
223    if options.overlay_enabled {
224        let overlay_opt = match options.overlay_path.as_deref() {
225            Some(path) => LinkMapOverlay::load_from(path)?,
226            None => LinkMapOverlay::load_optional(source_root)?,
227        };
228        if let Some(overlay) = overlay_opt {
229            let summary = overlay::apply(&mut link_map, &overlay)?;
230            overlay_summary = Some(summary);
231        }
232    }
233
234    let plan = InstallPlan::build(
235        product,
236        source_root,
237        &resolved_roots.live_home,
238        &resolved_roots.state_home,
239        &link_map,
240    )?;
241
242    let mut report = probes::ProbeReport::default();
243    report.extend(probes::runtime_roots(&resolved_roots));
244    report.extend(probes::install_plan(product, &plan));
245    let version_probe = version::probe_product(product, product_root);
246    match version_probe.severity {
247        DoctorSeverity::Ok => report.ok += 1,
248        DoctorSeverity::Warn | DoctorSeverity::Block => {
249            report.findings.push(version_probe.to_doctor_finding());
250        }
251    }
252    let coverage_probes = coverage::probe(source_root, &options.cli_tools_profile)?;
253    for probe in &coverage_probes {
254        match probe.severity {
255            DoctorSeverity::Ok => report.ok += 1,
256            DoctorSeverity::Warn | DoctorSeverity::Block => {
257                report.findings.push(probe.to_doctor_finding(product));
258            }
259        }
260    }
261    let project_probes = options
262        .check_project
263        .as_deref()
264        .map(project::probe_project)
265        .unwrap_or_default();
266    for probe in &project_probes {
267        match probe.severity {
268            DoctorSeverity::Ok => report.ok += 1,
269            DoctorSeverity::Warn | DoctorSeverity::Block => {
270                report.findings.push(probe.to_doctor_finding(product));
271            }
272        }
273    }
274
275    let warn = report
276        .findings
277        .iter()
278        .filter(|f| f.severity == DoctorSeverity::Warn)
279        .count();
280    let block = report
281        .findings
282        .iter()
283        .filter(|f| f.severity == DoctorSeverity::Block)
284        .count();
285
286    Ok(DoctorOutcome {
287        product: product.to_string(),
288        findings: report.findings,
289        version_probes: vec![version_probe],
290        coverage_probes,
291        project_probes,
292        skill_surface: None,
293        version_alignment: None,
294        acceptance_boundary: None,
295        ok: report.ok,
296        warn,
297        block,
298        overlay: overlay_summary,
299    })
300}
301
302pub fn resolve_runtime_roots_for_product(
303    product: &str,
304    source_root: &Path,
305    live_home_override: Option<&Path>,
306    state_home_override: Option<&Path>,
307) -> Result<ResolvedRuntimeRoots, DoctorError> {
308    let runtime_roots = load_runtime_roots(source_root)?;
309    let product_root = product_root(&runtime_roots, product)?;
310    Ok(resolve_runtime_roots(
311        product,
312        product_root,
313        live_home_override,
314        state_home_override,
315    ))
316}
317
318fn run_skill_surface_only(
319    product: &str,
320    source_root: &Path,
321    options: &DoctorOptions,
322) -> Result<DoctorOutcome, DoctorError> {
323    let link_map = match load_effective_link_map(source_root, product, options)? {
324        Some(link_map) => link_map,
325        None => {
326            return Ok(DoctorOutcome {
327                product: product.to_string(),
328                findings: Vec::new(),
329                version_probes: Vec::new(),
330                coverage_probes: Vec::new(),
331                project_probes: Vec::new(),
332                skill_surface: Some(skill_surface::SkillSurfaceReport::empty(product)),
333                version_alignment: None,
334                acceptance_boundary: skill_surface::acceptance_boundary(product)
335                    .map(str::to_string),
336                ok: 0,
337                warn: 0,
338                block: 0,
339                overlay: None,
340            });
341        }
342    };
343    let report = skill_surface::check(product, source_root, &link_map);
344    let warn = report
345        .findings
346        .iter()
347        .filter(|finding| finding.severity == DoctorSeverity::Warn)
348        .count();
349    let block = report
350        .findings
351        .iter()
352        .filter(|finding| finding.severity == DoctorSeverity::Block)
353        .count();
354    let ok = report
355        .items
356        .iter()
357        .filter(|item| item.warnings.is_empty())
358        .count();
359    let acceptance_boundary = report.acceptance_boundary.clone();
360    Ok(DoctorOutcome {
361        product: product.to_string(),
362        findings: report.findings.clone(),
363        version_probes: Vec::new(),
364        coverage_probes: Vec::new(),
365        project_probes: Vec::new(),
366        skill_surface: Some(report),
367        version_alignment: None,
368        acceptance_boundary,
369        ok,
370        warn,
371        block,
372        overlay: None,
373    })
374}
375
376fn run_version_alignment_only(options: &DoctorOptions) -> Result<DoctorOutcome, DoctorError> {
377    let Some(pin_path) = options.pin_path.as_deref() else {
378        return Err(DoctorError::VersionAlignment(
379            version_alignment::VersionAlignmentError::MissingPin,
380        ));
381    };
382    // The host binary IS `agent-runtime`, so its own compile-time version is
383    // the authoritative "what `agent-runtime --version` reports" value.
384    let report = version_alignment::check(pin_path, env!("CARGO_PKG_VERSION"))?;
385    let ok = report
386        .items
387        .iter()
388        .filter(|item| item.severity == DoctorSeverity::Ok)
389        .count();
390    let warn = report
391        .items
392        .iter()
393        .filter(|item| item.severity == DoctorSeverity::Warn)
394        .count();
395    let block = report
396        .items
397        .iter()
398        .filter(|item| item.severity == DoctorSeverity::Block)
399        .count();
400    let acceptance_boundary = report.acceptance_boundary.clone();
401    Ok(DoctorOutcome {
402        product: "host".to_string(),
403        findings: report.findings.clone(),
404        version_probes: Vec::new(),
405        coverage_probes: Vec::new(),
406        project_probes: Vec::new(),
407        skill_surface: None,
408        version_alignment: Some(report),
409        acceptance_boundary,
410        ok,
411        warn,
412        block,
413        overlay: None,
414    })
415}
416
417fn load_effective_link_map(
418    source_root: &Path,
419    product: &str,
420    options: &DoctorOptions,
421) -> Result<Option<LinkMap>, DoctorError> {
422    let mut link_map = match LinkMap::load(source_root, product) {
423        Ok(link_map) => link_map,
424        Err(LinkMapError::Missing { .. }) => return Ok(None),
425        Err(err) => return Err(err.into()),
426    };
427    if options.overlay_enabled {
428        let overlay_opt = match options.overlay_path.as_deref() {
429            Some(path) => LinkMapOverlay::load_from(path)?,
430            None => LinkMapOverlay::load_optional(source_root)?,
431        };
432        if let Some(overlay) = overlay_opt {
433            overlay::apply(&mut link_map, &overlay)?;
434        }
435    }
436    Ok(Some(link_map))
437}
438
439fn load_runtime_roots(source_root: &Path) -> Result<RuntimeRootsManifest, RuntimeRootsError> {
440    let file = source_root.join("manifests").join("runtime-roots.yaml");
441    if !file.exists() {
442        return Err(RuntimeRootsError::Missing { path: file });
443    }
444    let raw = std::fs::read_to_string(&file).map_err(|source| RuntimeRootsError::Io {
445        file: file.clone(),
446        source,
447    })?;
448    let parsed: RuntimeRootsManifest =
449        serde_yaml_ng::from_str(&raw).map_err(|source| RuntimeRootsError::Parse {
450            file: file.clone(),
451            source,
452        })?;
453    if parsed.schema_version != SCHEMA_VERSION {
454        return Err(RuntimeRootsError::SchemaVersion {
455            file,
456            expected: SCHEMA_VERSION,
457            found: parsed.schema_version,
458        });
459    }
460    Ok(parsed)
461}
462
463fn product_root<'a>(
464    runtime_roots: &'a RuntimeRootsManifest,
465    product: &str,
466) -> Result<&'a ProductRoot, DoctorError> {
467    match product {
468        "codex" => Ok(&runtime_roots.products.codex),
469        "claude" => Ok(&runtime_roots.products.claude),
470        "hermes" => Ok(&runtime_roots.products.hermes),
471        other => Err(DoctorError::UnknownProduct {
472            product: other.to_string(),
473        }),
474    }
475}
476
477fn resolve_runtime_roots(
478    product: &str,
479    root: &ProductRoot,
480    live_home_override: Option<&Path>,
481    state_home_override: Option<&Path>,
482) -> ResolvedRuntimeRoots {
483    let env: BTreeMap<String, String> = std::env::vars().collect();
484    resolve_runtime_roots_with_env(product, root, live_home_override, state_home_override, env)
485}
486
487fn resolve_runtime_roots_with_env(
488    product: &str,
489    root: &ProductRoot,
490    live_home_override: Option<&Path>,
491    state_home_override: Option<&Path>,
492    mut env: BTreeMap<String, String>,
493) -> ResolvedRuntimeRoots {
494    if let Some(live_home) = live_home_override
495        && product == "codex"
496    {
497        env.insert(
498            "CODEX_HOME".to_string(),
499            live_home.to_string_lossy().into_owned(),
500        );
501    }
502    if let Some(live_home) = live_home_override
503        && product == "hermes"
504    {
505        env.insert(
506            "HERMES_HOME".to_string(),
507            live_home.to_string_lossy().into_owned(),
508        );
509    }
510
511    let live_home = live_home_override
512        .map(Path::to_path_buf)
513        .unwrap_or_else(|| PathBuf::from(expand_env_vars(&root.live_home, &env)));
514    let docs_home = resolve_product_path(product, &root.docs_home, live_home_override, &env);
515    let state_home = state_home_override
516        .map(Path::to_path_buf)
517        .unwrap_or_else(|| PathBuf::from(expand_env_vars(&root.state_home, &env)));
518    let plugin_root = resolve_plugin_root(product, root, live_home_override, &env);
519
520    ResolvedRuntimeRoots {
521        product: product.to_string(),
522        live_home,
523        docs_home,
524        state_home,
525        plugin_root,
526    }
527}
528
529fn resolve_plugin_root(
530    product: &str,
531    root: &ProductRoot,
532    live_home_override: Option<&Path>,
533    env: &BTreeMap<String, String>,
534) -> Option<PathBuf> {
535    if let Some(raw) = root.plugin_root.as_deref() {
536        return Some(resolve_product_path(product, raw, live_home_override, env));
537    }
538    let name = root.plugin_root_env.as_deref()?;
539    let value = env.get(name)?;
540    if value.is_empty() {
541        None
542    } else {
543        Some(PathBuf::from(value))
544    }
545}
546
547fn resolve_product_path(
548    product: &str,
549    raw: &str,
550    live_home_override: Option<&Path>,
551    env: &BTreeMap<String, String>,
552) -> PathBuf {
553    if let Some(live_home) = live_home_override {
554        if product == "claude" {
555            if raw == "$HOME/.claude" {
556                return live_home.to_path_buf();
557            }
558            if let Some(rest) = raw.strip_prefix("$HOME/.claude/") {
559                return live_home.join(rest);
560            }
561        }
562        if product == "codex" {
563            if raw == "$CODEX_HOME" {
564                return live_home.to_path_buf();
565            }
566            if let Some(rest) = raw.strip_prefix("$CODEX_HOME/") {
567                return live_home.join(rest);
568            }
569        }
570        if product == "hermes" {
571            if raw == "$HOME/.hermes" {
572                return live_home.to_path_buf();
573            }
574            if let Some(rest) = raw.strip_prefix("$HOME/.hermes/") {
575                return live_home.join(rest);
576            }
577        }
578    }
579    PathBuf::from(expand_env_vars(raw, env))
580}
581
582fn expand_env_vars(raw: &str, env: &BTreeMap<String, String>) -> String {
583    let chars: Vec<char> = raw.chars().collect();
584    let mut out = String::new();
585    let mut i = 0;
586    while i < chars.len() {
587        if chars[i] != '$' {
588            out.push(chars[i]);
589            i += 1;
590            continue;
591        }
592        if chars.get(i + 1) == Some(&'{')
593            && let Some(end) = find_matching_brace(&chars, i + 1)
594        {
595            let expr: String = chars[i + 2..end].iter().collect();
596            out.push_str(&expand_braced_expr(&expr, env));
597            i = end + 1;
598            continue;
599        }
600        let mut end = i + 1;
601        while end < chars.len() && (chars[end].is_ascii_alphanumeric() || chars[end] == '_') {
602            end += 1;
603        }
604        if end == i + 1 {
605            out.push('$');
606            i += 1;
607            continue;
608        }
609        let name: String = chars[i + 1..end].iter().collect();
610        out.push_str(env.get(&name).map(String::as_str).unwrap_or(""));
611        i = end;
612    }
613    out
614}
615
616fn find_matching_brace(chars: &[char], open_brace: usize) -> Option<usize> {
617    let mut depth = 0usize;
618    let mut i = open_brace + 1;
619    while i < chars.len() {
620        if chars[i] == '$' && chars.get(i + 1) == Some(&'{') {
621            depth += 1;
622            i += 2;
623            continue;
624        }
625        if chars[i] == '}' {
626            if depth == 0 {
627                return Some(i);
628            }
629            depth -= 1;
630        }
631        i += 1;
632    }
633    None
634}
635
636fn expand_braced_expr(expr: &str, env: &BTreeMap<String, String>) -> String {
637    if let Some((name, fallback)) = expr.split_once(":-") {
638        if let Some(value) = env.get(name)
639            && !value.is_empty()
640        {
641            return value.clone();
642        }
643        expand_env_vars(fallback, env)
644    } else {
645        env.get(expr).cloned().unwrap_or_default()
646    }
647}
648
649#[cfg(test)]
650mod tests {
651    use super::*;
652
653    #[test]
654    fn env_expansion_supports_nested_default_values() {
655        let mut env = BTreeMap::new();
656        env.insert("HOME".to_string(), "/tmp/home".to_string());
657        assert_eq!(
658            expand_env_vars("${CODEX_AGENT_STATE_HOME:-$HOME/.local/state}", &env),
659            "/tmp/home/.local/state"
660        );
661    }
662
663    #[test]
664    fn env_expansion_supports_nested_braced_default_values() {
665        let mut env = BTreeMap::new();
666        env.insert("HOME".to_string(), "/tmp/home".to_string());
667        assert_eq!(
668            expand_env_vars(
669                "${CODEX_AGENT_STATE_HOME:-${XDG_STATE_HOME:-$HOME/.local/state}/agent-runtime-kit/codex}",
670                &env
671            ),
672            "/tmp/home/.local/state/agent-runtime-kit/codex"
673        );
674
675        env.insert("XDG_STATE_HOME".to_string(), "/tmp/state".to_string());
676        assert_eq!(
677            expand_env_vars(
678                "${CODEX_AGENT_STATE_HOME:-${XDG_STATE_HOME:-$HOME/.local/state}/agent-runtime-kit/codex}",
679                &env
680            ),
681            "/tmp/state/agent-runtime-kit/codex"
682        );
683
684        env.insert(
685            "CODEX_AGENT_STATE_HOME".to_string(),
686            "/tmp/codex-state".to_string(),
687        );
688        assert_eq!(
689            expand_env_vars(
690                "${CODEX_AGENT_STATE_HOME:-${XDG_STATE_HOME:-$HOME/.local/state}/agent-runtime-kit/codex}",
691                &env
692            ),
693            "/tmp/codex-state"
694        );
695    }
696
697    #[test]
698    fn runtime_root_resolution_uses_plugin_root_env_when_set() {
699        let root = ProductRoot {
700            live_home: "$HOME/.claude".to_string(),
701            docs_home: "$HOME/.claude".to_string(),
702            state_home: "$HOME/.local/state/agent-runtime-kit/claude".to_string(),
703            plugin_root: None,
704            plugin_root_env: Some("CLAUDE_PLUGIN_ROOT".to_string()),
705            hook_config_strategy: None,
706            min_version: "0.0.0".to_string(),
707            recommended_version: "0.0.0".to_string(),
708            min_version_effective_from: "2099-01-01".to_string(),
709            version_probe: "claude --version".to_string(),
710        };
711        let mut env = BTreeMap::new();
712        env.insert("HOME".to_string(), "/tmp/home".to_string());
713        env.insert(
714            "CLAUDE_PLUGIN_ROOT".to_string(),
715            "/tmp/claude-plugin".to_string(),
716        );
717
718        let resolved = resolve_runtime_roots_with_env("claude", &root, None, None, env);
719
720        assert_eq!(
721            resolved.plugin_root,
722            Some(PathBuf::from("/tmp/claude-plugin"))
723        );
724    }
725}