use crate::cli::commands::lifecycle;
use crate::cli::commands::{doctor_fix, doctor_rescue, doctor_restore};
use crate::cli::output::{OutputConfig, OutputFormat};
use crate::cli::report::{Check, Report, Section, State};
use crate::cli::DoctorArgs;
use crate::config;
use crate::core::cloud::offset::{count_entries_from, read_offset};
use crate::error::{
OlError, ERR_BUG, ERR_BUNDLE_FETCH_FAILED, ERR_BUNDLE_INVALID, ERR_BUNDLE_STALE,
ERR_CLINE_SURFACE_ABSENT, ERR_CLINE_SURFACE_UNDETERMINED, ERR_CLOUD_AUTH_FAILED,
ERR_CLOUD_UNREACHABLE, ERR_CONFIG_NOT_APPLIED, ERR_DAEMON_START_FAILED, ERR_DIRECT_FORBIDDEN,
ERR_EGRESS_TLS_FAILED, ERR_EGRESS_UNREACHABLE, ERR_HMAC_KEY_UNAVAILABLE,
ERR_HOOK_AGENT_NOT_FOUND, ERR_HOOK_BINARY_UNRESOLVABLE, ERR_HOOK_CONFLICT,
ERR_HOOK_MALFORMED_JSONC, ERR_HOOK_WRITE_FAILED, ERR_INVALID_CONFIG, ERR_INVENTORY_DISABLED,
ERR_INVENTORY_INIT_FAILED, ERR_NEGOTIATE_NO_TICKET, ERR_NO_CREDENTIALS, ERR_NO_SUPERVISOR,
ERR_PAC_UNAVAILABLE, ERR_POLICY_DISABLED, ERR_PROXY_AUTH_FAILED, ERR_PROXY_CONFIG_INVALID,
ERR_PROXY_SCHEME_UNSUPPORTED, ERR_PROXY_UNREACHABLE, ERR_SUBSYSTEM_DEGRADED,
ERR_TAMPER_DETECTED, ERR_VERSION_OUTDATED,
};
use crate::hooks;
use crate::hooks::binding::{DaemonChannel, HookSurface, LivenessReport};
use crate::hooks::cline::{ClineAttestation, Surface, SurfaceState};
#[allow(dead_code)] pub(crate) struct DoctorReport {
pub report: Report,
pub daemon_alive: bool,
pub daemon_uptime_secs: Option<u64>,
pub port: u16,
pub cline: Option<ClineAttestation>,
}
impl DoctorReport {
pub(crate) fn all_pass(&self) -> bool {
self.report.counts().failed == 0
}
pub(crate) fn fail_count(&self) -> usize {
self.report.counts().failed
}
pub(crate) fn pass_count(&self) -> usize {
self.report.counts().ok
}
pub(crate) fn agents_json(&self) -> serde_json::Value {
let mut agents = serde_json::Map::new();
if let Some(cline) = &self.cline {
agents.insert("cline".to_string(), cline.to_json());
}
serde_json::Value::Object(agents)
}
pub(crate) fn unresolved(&self) -> Vec<String> {
self.report
.checks()
.iter()
.filter(|c| c.state.requires_remedy())
.map(|c| format!("{}: {}", c.section.title(), c.headline))
.collect()
}
}
pub fn run_doctor(args: &DoctorArgs, output: &OutputConfig) -> Result<(), OlError> {
if args.trigger_panic {
panic!("openlatch crash-report validation panic");
}
if args.rescue && args.fix {
doctor_rescue::run(args, output, true)?;
return doctor_fix::run(args, output);
}
if args.rescue && args.restore {
doctor_rescue::run(args, output, false)?;
return doctor_restore::run(args, output);
}
if args.fix {
return doctor_fix::run(args, output);
}
if args.restore {
return doctor_restore::run(args, output);
}
if args.rescue {
return doctor_rescue::run(args, output, false);
}
crate::cli::header::print(output, &["doctor"]);
let report = run_all_checks(output)?;
print_diagnostic_results(&report, output);
Ok(())
}
struct DaemonProbe {
alive: bool,
reachable: bool,
health: Option<serde_json::Value>,
metrics: Option<serde_json::Value>,
egress: Option<serde_json::Value>,
uptime_secs: Option<u64>,
}
impl DaemonProbe {
fn metric_str<'a>(&'a self, key: &str) -> Option<&'a str> {
self.metrics.as_ref()?.get(key)?.as_str()
}
fn metric_u64(&self, key: &str) -> Option<u64> {
self.metrics.as_ref()?.get(key)?.as_u64()
}
fn metric_bool(&self, key: &str) -> Option<bool> {
self.metrics.as_ref()?.get(key)?.as_bool()
}
fn metric_strings(&self, key: &str) -> Vec<&str> {
self.metrics
.as_ref()
.and_then(|metrics| metrics.get(key))
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.filter_map(serde_json::Value::as_str)
.collect()
}
fn egress_str<'a>(&'a self, key: &str) -> Option<&'a str> {
self.egress.as_ref()?.get(key)?.as_str()
}
fn egress_error_message(&self) -> Option<String> {
Some(
self.egress
.as_ref()?
.get("last_error")?
.get("message")?
.as_str()?
.to_string(),
)
}
fn egress_warnings(&self) -> Vec<String> {
self.egress
.as_ref()
.and_then(|e| e.get("warnings"))
.and_then(|w| w.as_array())
.map(|entries| {
entries
.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default()
}
}
fn egress_error_code(probe: &DaemonProbe) -> &'static str {
match probe
.egress
.as_ref()
.and_then(|e| e.get("last_error"))
.and_then(|e| e.get("code"))
.and_then(|c| c.as_str())
{
Some(ERR_PROXY_UNREACHABLE) => ERR_PROXY_UNREACHABLE,
Some(ERR_PROXY_AUTH_FAILED) => ERR_PROXY_AUTH_FAILED,
Some(ERR_PROXY_SCHEME_UNSUPPORTED) => ERR_PROXY_SCHEME_UNSUPPORTED,
Some(ERR_EGRESS_TLS_FAILED) => ERR_EGRESS_TLS_FAILED,
Some(ERR_PAC_UNAVAILABLE) => ERR_PAC_UNAVAILABLE,
Some(ERR_PROXY_CONFIG_INVALID) => ERR_PROXY_CONFIG_INVALID,
Some(ERR_DIRECT_FORBIDDEN) => ERR_DIRECT_FORBIDDEN,
Some(ERR_NEGOTIATE_NO_TICKET) => ERR_NEGOTIATE_NO_TICKET,
_ => ERR_EGRESS_UNREACHABLE,
}
}
fn egress_remedy(code: &'static str) -> &'static str {
match code {
ERR_PROXY_UNREACHABLE => {
"Check the proxy host and port are reachable, then `openlatch system proxy discover` (or `openlatch system proxy set <url>` if the source is manual)."
}
ERR_PROXY_AUTH_FAILED => {
"Re-enter the proxy credentials with `openlatch system proxy set <url>`; for Kerberos, check the ticket with `klist`."
}
ERR_PROXY_SCHEME_UNSUPPORTED => {
"The proxy is demanding a scheme this client cannot speak (NTLM, SAML). Ask IT for a Kerberos-capable or unauthenticated policy for this binary."
}
ERR_EGRESS_TLS_FAILED => {
"Likely TLS interception — install the intercepting CA in the OS trust store, or `openlatch system proxy set --ca-bundle <pem>`."
}
ERR_PAC_UNAVAILABLE => {
"Fix `[proxy] pac_url`, or set `[proxy] url` directly (PAC is not supported on Linux)."
}
ERR_PROXY_CONFIG_INVALID => {
"Fix the `[proxy]` key or environment variable named in the message, then `openlatch restart`."
}
ERR_DIRECT_FORBIDDEN => {
"`[proxy] allow_direct = false` forbids a direct connection and no proxy candidate works. Set a working `[proxy] url`, or allow direct."
}
ERR_NEGOTIATE_NO_TICKET => {
"Get a Kerberos ticket with `kinit`, or override the SPN with `openlatch system proxy set --spn <spn>`."
}
_ => {
"Check this host can reach app.openlatch.ai, or ask IT for an egress rule; `openlatch system proxy test` shows which hop fails."
}
}
}
fn egress_warning_line(probe: &DaemonProbe) -> String {
let warnings = probe.egress_warnings();
if warnings.is_empty() {
"The daemon resolved the proxy configuration with warnings.".to_string()
} else {
warnings.join("; ")
}
}
pub(crate) fn run_all_checks(_output: &OutputConfig) -> Result<DoctorReport, OlError> {
let cfg = config::Config::load(None, None, false)?;
let ol_dir = config::openlatch_dir();
let mut report = Report::new();
let agents = hooks::detect_agents();
let daemon_token = check_environment(&cfg, &ol_dir, &agents, &mut report);
let cline = check_cline_surface(&agents, &mut report);
let probe = probe_daemon(&cfg, daemon_token.as_deref(), &mut report);
check_hooks(
&ol_dir,
&agents,
daemon_token.as_deref(),
&cfg,
&probe,
&mut report,
);
#[cfg(feature = "model-relay")]
check_model_relay(&cfg, &agents, &probe, &mut report);
#[cfg(not(feature = "model-relay"))]
report.push(Check::not_applicable(
Section::ModelRelay,
"Not compiled into this build",
));
check_persistence(&cfg, &mut report);
check_connection(&cfg, &probe, &mut report);
check_cloud(&cfg, &probe, &mut report);
check_policy(&cfg, &probe, &mut report);
check_inventory(&cfg, &probe, &mut report);
check_telemetry(&ol_dir, &mut report);
check_update(&cfg, &probe, &mut report);
check_integrity(&ol_dir, &mut report);
for section in Section::ALL {
if report.section_checks(section).next().is_none() {
report.push(Check::not_applicable(
section,
format!("no check ran — please report this ({ERR_BUG})"),
));
}
}
Ok(DoctorReport {
report,
daemon_alive: probe.alive,
daemon_uptime_secs: probe.uptime_secs,
port: cfg.port,
cline,
})
}
fn check_environment(
_cfg: &config::Config,
ol_dir: &std::path::Path,
agents: &[hooks::DetectedAgent],
report: &mut Report,
) -> Option<String> {
if agents.is_empty() {
report.push(
Check::failed(Section::Environment, "Agent not found")
.code(ERR_HOOK_AGENT_NOT_FOUND)
.remedy(format!(
"Install a supported agent ({}), then run `openlatch init`.",
hooks::binding::DETECTABLE_AGENT_NAMES.join(", ")
)),
);
}
for a in agents {
report.push(
Check::ok(
Section::Environment,
format!("Agent: {} ({})", a.display_name(), a.config_dir().display()),
)
.agent(a.agent_type()),
);
}
let config_path = ol_dir.join("config.toml");
if config_path.exists() {
match config::Config::load(None, None, false) {
Ok(_) => report.push(Check::ok(
Section::Environment,
format!("Config: {}", config_path.display()),
)),
Err(e) => report.push(
Check::failed(
Section::Environment,
format!("Config invalid: {} ({})", e.message, e.code),
)
.code(ERR_INVALID_CONFIG)
.source(config_path.display().to_string())
.remedy("Delete config.toml and re-run `openlatch init`."),
),
}
} else {
report.push(
Check::failed(
Section::Environment,
format!("Config missing: {}", config_path.display()),
)
.code(ERR_INVALID_CONFIG)
.remedy("Run `openlatch init` to create it."),
);
}
#[cfg(feature = "crash-report")]
{
use crate::telemetry::consent::CrashDecidedBy;
let resolved = crate::telemetry::crash::current_state(ol_dir);
let label = match resolved.decided_by {
CrashDecidedBy::CrashReportingEnv => "off (OPENLATCH_CRASH_REPORTING env)",
CrashDecidedBy::NoProjectKey => "off (no project key)",
CrashDecidedBy::ConfigFile => {
if resolved.enabled() {
"on (config.toml)"
} else {
"off (config.toml)"
}
}
CrashDecidedBy::DefaultEnabled => "on (default)",
};
if resolved.enabled() {
report.push(Check::ok(
Section::Environment,
format!("Crash reporting: {label}"),
));
} else {
report.push(Check::not_applicable(
Section::Environment,
format!("Crash reporting: {label}"),
));
}
}
#[cfg(not(feature = "crash-report"))]
report.push(Check::not_applicable(
Section::Environment,
"Crash reporting: not compiled in",
));
let token_path = ol_dir.join("daemon.token");
if !token_path.exists() {
report.push(
Check::failed(
Section::Environment,
format!("Auth token missing: {}", token_path.display()),
)
.code(ERR_INVALID_CONFIG)
.remedy("Run `openlatch init` to generate one."),
);
return None;
}
match std::fs::read_to_string(&token_path) {
Ok(content) if !content.trim().is_empty() => {
report.push(Check::ok(
Section::Environment,
format!("Auth token: {}", token_path.display()),
));
Some(content.trim().to_string())
}
Ok(_) => {
report.push(
Check::failed(
Section::Environment,
format!("Auth token empty: {}", token_path.display()),
)
.code(ERR_INVALID_CONFIG)
.remedy("Run `openlatch init` to regenerate it."),
);
None
}
Err(e) => {
report.push(
Check::failed(
Section::Environment,
format!("Auth token unreadable: {} — {e}", token_path.display()),
)
.code(ERR_INVALID_CONFIG)
.remedy("Check the file permissions on ~/.openlatch/daemon.token."),
);
None
}
}
}
fn check_cline_surface(
agents: &[hooks::DetectedAgent],
report: &mut Report,
) -> Option<ClineAttestation> {
let cline = crate::hooks::bindings::cline::ClineBinding::AGENT_TYPE;
if !agents.iter().any(|agent| agent.agent_type() == cline) {
return None;
}
let attestation = hooks::cline::probe();
let remedy_for = |surface: Surface, fallback: &'static str| -> String {
attestation
.surfaces
.iter()
.find(|finding| finding.surface == surface)
.and_then(|finding| finding.remedy)
.unwrap_or(fallback)
.to_string()
};
let mut lines = attestation.human_lines();
let summary = if lines.is_empty() {
attestation.summary()
} else {
lines.remove(0)
};
let store = attestation.state_of_surface(Surface::Store);
let undetermined = Surface::ALL
.into_iter()
.find(|surface| attestation.state_of_surface(*surface) == Some(SurfaceState::Undetermined));
let check = if store != Some(SurfaceState::Present) {
Check::degraded(
Section::Environment,
format!(
"Cline store {}",
store.map_or("unresolved", SurfaceState::as_str)
),
)
.code(if store == Some(SurfaceState::Absent) {
ERR_CLINE_SURFACE_ABSENT
} else {
ERR_CLINE_SURFACE_UNDETERMINED
})
.remedy(remedy_for(
Surface::Store,
"Set CLINE_DIR to a store root this user can stat, then run `openlatch doctor` \
again.",
))
.detail(summary)
} else if attestation.store_without_extension() {
Check::degraded(
Section::Environment,
"Cline store present, no Cline-lineage extension in any known editor root",
)
.code(ERR_CLINE_SURFACE_ABSENT)
.remedy(remedy_for(
Surface::Extension,
"Report this host's editor root so the install guide can name it.",
))
.detail(summary)
} else if let Some(surface) = undetermined {
Check::degraded(
Section::Environment,
format!("Cline {} surface undetermined", surface.as_str()),
)
.code(ERR_CLINE_SURFACE_UNDETERMINED)
.remedy(remedy_for(
surface,
"Check the permissions on Cline's roots, or set CLINE_DIR, CLINE_DATA_DIR and \
OPENLATCH_CLINE_ASSETS_DIR to roots this user can stat.",
))
.detail(summary)
} else {
Check::ok(Section::Environment, summary)
};
let check = lines
.into_iter()
.fold(check, |check, line| check.detail(line))
.agent(cline);
report.push(check);
Some(attestation)
}
fn probe_daemon(
cfg: &config::Config,
daemon_token: Option<&str>,
report: &mut Report,
) -> DaemonProbe {
let pid = lifecycle::read_pid_file();
let alive = pid.map(lifecycle::is_process_alive).unwrap_or(false);
let bound_port = config::read_port_file();
if let (true, Some(bound)) = (alive, bound_port) {
if bound != cfg.port {
report.push(
Check::failed(
Section::Daemon,
format!(
"Identity: daemon.port says this instance is on {bound}, configured port is {}",
cfg.port
),
)
.code(ERR_INVALID_CONFIG)
.detail(format!(
"PID {} belongs to the daemon on port {bound}; port {} is served by a different process.",
pid.unwrap_or(0),
cfg.port
))
.remedy("Re-run with OPENLATCH_DIR set to the instance you mean."),
);
}
}
let mut probe = DaemonProbe {
alive,
reachable: false,
health: None,
metrics: None,
egress: None,
uptime_secs: None,
};
if !alive {
report.push(
Check::failed(Section::Daemon, format!("Not running (port {})", cfg.port))
.code(ERR_DAEMON_START_FAILED)
.remedy("Run `openlatch start`."),
);
return probe;
}
let probe_client = crate::egress::blocking_client_builder()
.timeout(std::time::Duration::from_secs(2))
.build()
.ok();
let probe_get = |path: &str| {
probe_client
.as_ref()?
.get(format!("http://127.0.0.1:{}/{path}", cfg.port))
.send()
.ok()
};
probe.health = probe_get("health")
.filter(|r| r.status().is_success())
.and_then(|r| r.json::<serde_json::Value>().ok());
probe.reachable = probe.health.is_some();
if !probe.reachable {
report.push(
Check::failed(
Section::Daemon,
format!(
"Process alive (PID {}) but /health unreachable on port {}",
pid.unwrap_or(0),
cfg.port
),
)
.code(ERR_DAEMON_START_FAILED)
.remedy("Run `openlatch restart`."),
);
return probe;
}
let same_instance = bound_port.map(|b| b == cfg.port).unwrap_or(true);
if same_instance {
report.push(Check::ok(
Section::Daemon,
format!("Running: PID {} on port {}", pid.unwrap_or(0), cfg.port),
));
} else {
report.push(Check::ok(
Section::Daemon,
format!(
"Port {} is serving (PID unknown — local daemon.pid {} is on port {})",
cfg.port,
pid.unwrap_or(0),
bound_port.unwrap_or(0)
),
));
}
probe.metrics = probe_get("metrics").and_then(|r| r.json::<serde_json::Value>().ok());
probe.uptime_secs = probe.metric_u64("uptime_secs");
probe.egress = daemon_token.and_then(|token| {
probe_client
.as_ref()?
.get(format!("http://127.0.0.1:{}/admin/egress/status", cfg.port))
.bearer_auth(token)
.send()
.ok()
.filter(|r| r.status().is_success())
.and_then(|r| r.json::<serde_json::Value>().ok())
});
let degraded = probe.metric_u64("subsystem_degraded_count").unwrap_or(0);
let restarts = probe.metric_u64("subsystem_restarts_total").unwrap_or(0);
if degraded > 0 {
let names = probe
.health
.as_ref()
.and_then(|h| h.get("subsystems"))
.and_then(|s| s.as_object())
.map(|m| {
m.iter()
.filter(|(_, v)| v.get("state").and_then(|s| s.as_str()) != Some("running"))
.map(|(k, _)| k.as_str())
.collect::<Vec<_>>()
.join(", ")
})
.filter(|s| !s.is_empty())
.unwrap_or_else(|| format!("{degraded} subsystem(s)"));
report.push(
Check::failed(Section::Daemon, format!("Subsystems down: {names}"))
.code(ERR_SUBSYSTEM_DEGRADED)
.detail(format!("{restarts} restart(s) since start"))
.remedy("Check the newest ~/.openlatch/logs/daemon.log.<date>, then `openlatch restart`."),
);
} else if probe.metrics.is_some() {
report.push(Check::ok(Section::Daemon, "Subsystems: all healthy"));
}
probe
}
fn check_hooks(
ol_dir: &std::path::Path,
agents: &[hooks::DetectedAgent],
daemon_token: Option<&str>,
cfg: &config::Config,
probe: &DaemonProbe,
report: &mut Report,
) {
if agents.is_empty() {
report.push(Check::unknown(Section::Hooks, Section::Environment));
return;
}
let hooks_checks_before = report.section_checks(Section::Hooks).count();
for a in agents {
if !a.installable() {
continue;
}
let agent = a.agent_type();
let settings_path = match a.hook_surface() {
HookSurface::ConfigFile(path) => path,
HookSurface::Directory(hooks_dir) => {
check_hook_files(ol_dir, &hooks_dir, a, report);
check_liveness(a, report);
continue;
}
};
let settings_path = settings_path.as_path();
if !settings_path.exists() {
report.push(
Check::failed(
Section::Hooks,
format!("settings.json not found at {}", settings_path.display()),
)
.code(ERR_HOOK_WRITE_FAILED)
.remedy("Run `openlatch init` to install the hooks.")
.agent(agent),
);
continue;
}
let inspected = match crate::hooks::health::inspect_file(settings_path, &*a.binding) {
Ok(health) if health.missing_events.is_empty() => {
report.push(
Check::ok(
Section::Hooks,
format!("Entries: all present in {}", settings_path.display()),
)
.agent(agent),
);
true
}
Ok(health) => {
report.push(
Check::failed(
Section::Hooks,
format!("Entries missing: {}", health.missing_events.join(", ")),
)
.code(ERR_HOOK_WRITE_FAILED)
.source(settings_path.display().to_string())
.remedy(
"Run `openlatch doctor --fix` to reinstall them (it keeps the current \
token, so running agent sessions keep capturing).",
)
.agent(agent),
);
true
}
Err(e) => {
report.push(
Check::failed(
Section::Hooks,
format!("Cannot read {} — {}", settings_path.display(), e.message),
)
.code(ERR_HOOK_MALFORMED_JSONC)
.remedy("Check the file permissions and that settings.json is valid JSONC.")
.agent(agent),
);
false
}
};
if !inspected {
continue;
}
check_hook_binding(
settings_path,
&*a.binding,
daemon_token,
cfg.port,
probe.reachable,
report,
);
check_liveness(a, report);
}
if report.section_checks(Section::Hooks).count() == hooks_checks_before {
report.push(
Check::unknown(Section::Hooks, Section::Environment)
.headline("No detected agent has a hook surface this build writes")
.remedy(
"Nothing to fix here: the detected agent declares its hook \
surface cannot be installed into, so no hooks were written. \
Install a supported agent to enforce on this host.",
),
);
}
check_fallback_activity(ol_dir, probe, report);
}
fn check_liveness(a: &hooks::DetectedAgent, report: &mut Report) {
let agent = a.agent_type();
match a.binding.liveness() {
LivenessReport { armed: None, .. } => {}
LivenessReport {
armed: Some(true),
detail,
..
} => report.push(
Check::ok(Section::Hooks, "Enforced")
.detail_opt(detail)
.agent(agent),
),
LivenessReport {
armed: Some(false),
detail,
remedy,
code,
} => {
debug_assert!(
code.is_some() && remedy.is_some(),
"a Some(false) liveness MUST carry a code and a remedy — see LivenessReport"
);
report.push(
Check::failed(
Section::Hooks,
"Monitored — installed and capturing, enforcing nothing",
)
.code(code.unwrap_or(ERR_BUG))
.detail_opt(detail)
.remedy(remedy.unwrap_or_else(|| {
format!(
"this agent's binding reported enforcement off without a remedy — \
please report this ({ERR_BUG})"
)
}))
.agent(agent),
)
}
}
}
fn check_hook_files(
ol_dir: &std::path::Path,
hooks_dir: &std::path::Path,
a: &hooks::DetectedAgent,
report: &mut Report,
) {
let agent = a.agent_type();
let descriptors = match crate::core::hook_state::HookStateFile::load(ol_dir) {
Ok(Some(state)) => hooks::health::tracked_descriptors(&state, hooks_dir),
Ok(None) => Default::default(),
Err(e) => {
tracing::debug!(
error = %e.message,
"doctor: hook state unreadable — checking ownership without descriptors"
);
Default::default()
}
};
let health = hooks::health::inspect_directory(hooks_dir, &*a.binding, &descriptors);
if health.files == 0 && health.foreign_files.is_empty() {
report.push(
Check::failed(
Section::Hooks,
format!("Hook files not found in {}", hooks_dir.display()),
)
.code(ERR_HOOK_WRITE_FAILED)
.remedy("Run `openlatch init` to install the hooks.")
.agent(agent),
);
return;
}
if health.is_healthy() {
let detail = (!health.unverified_files.is_empty()).then(|| {
format!(
"{} script(s) carry our ownership marker with no recorded descriptor: {}",
health.unverified_files.len(),
health.unverified_files.join(", ")
)
});
report.push(
Check::ok(
Section::Hooks,
format!(
"Hook files: all {} present in {}",
health.files,
hooks_dir.display()
),
)
.detail_opt(detail)
.agent(agent),
);
return;
}
let mut problems: Vec<String> = Vec::new();
if !health.missing_files.is_empty() {
problems.push(format!("missing: {}", health.missing_files.join(", ")));
}
if !health.drifted_files.is_empty() {
problems.push(format!(
"modified since install: {}",
health.drifted_files.join(", ")
));
}
if !health.foreign_files.is_empty() {
problems.push(format!(
"not ours and left alone: {}",
health.foreign_files.join(", ")
));
}
report.push(
Check::failed(
Section::Hooks,
format!("Hook files incomplete in {}", hooks_dir.display()),
)
.code(ERR_HOOK_WRITE_FAILED)
.detail(problems.join("; "))
.source(hooks_dir.display().to_string())
.remedy(
"Run `openlatch doctor --fix` to rewrite them (it keeps the current \
token, so running agent sessions keep capturing, and it never \
overwrites a hook file it did not write).",
)
.agent(agent),
);
}
fn check_hook_binding(
settings_path: &std::path::Path,
binding: &dyn crate::hooks::binding::AgentBinding,
daemon_token: Option<&str>,
daemon_port: u16,
daemon_reachable: bool,
report: &mut Report,
) {
let agent = binding.agent_type();
let (token_env, port_env) = match binding.daemon_channel() {
DaemonChannel::EnvVars { token, port } => (Some(token), Some(port)),
DaemonChannel::OpenlatchDirArg => (None, None),
};
let raw = match std::fs::read_to_string(settings_path) {
Ok(s) => s,
Err(_) => return,
};
let parsed = match crate::hooks::jsonc::parse_settings_value(&raw) {
Ok(v) => v,
Err(e) => {
report.push(
Check::failed(
Section::Hooks,
format!("Cannot parse settings.json ({})", e.code),
)
.code(ERR_HOOK_MALFORMED_JSONC)
.remedy("Repair the JSON, then run `openlatch doctor --fix`.")
.agent(agent),
);
return;
}
};
let health = crate::hooks::health::inspect(&parsed, binding);
if health.commands == 0 {
return;
}
let expected_bin = &health.expected_bin;
if health.missing_bin.is_empty() && health.drifted_bin.is_empty() {
report.push(
Check::ok(
Section::Hooks,
format!("Binary: {}", expected_bin.display()),
)
.agent(agent),
);
}
if !health.missing_bin.is_empty() {
report.push(
Check::failed(
Section::Hooks,
format!("Binary missing: {}", health.missing_bin.join(", ")),
)
.code(ERR_HOOK_BINARY_UNRESOLVABLE)
.remedy(
"Run `openlatch doctor --fix` to stage the binary and rewrite the hook command \
(it keeps the current token, so running agent sessions keep capturing).",
)
.agent(agent),
);
}
if !health.drifted_bin.is_empty() {
report.push(
Check::failed(
Section::Hooks,
format!(
"Binary drift: settings.json uses {}, current install is {}",
health.drifted_bin.join(", "),
expected_bin.display()
),
)
.code(ERR_HOOK_BINARY_UNRESOLVABLE)
.remedy("Run `openlatch doctor --fix` to re-link settings.json to the current install.")
.agent(agent),
);
}
if let (Some(token_env), Some(token)) = (token_env, daemon_token) {
let settings_token = parsed
.get("env")
.and_then(|e| e.get(token_env))
.and_then(|v| v.as_str());
match settings_token {
Some(t) if t == token => {
report.push(Check::ok(Section::Hooks, "Token: matches daemon.token").agent(agent));
}
Some(_) => {
report.push(
Check::failed(
Section::Hooks,
format!("Token: settings.json {token_env} does not match daemon.token"),
)
.code(ERR_HOOK_CONFLICT)
.detail("Hook subprocesses will be rejected with 401 and fail open silently.")
.remedy("Run `openlatch init` to re-sync the token.")
.agent(agent),
);
}
None => {
report.push(
Check::failed(
Section::Hooks,
format!("Token: {token_env} not set in settings.json env"),
)
.code(ERR_HOOK_CONFLICT)
.remedy("Run `openlatch init` to install it.")
.agent(agent),
);
}
}
}
let Some(port_env) = port_env else {
return;
};
let settings_port = parsed
.get("env")
.and_then(|e| e.get(port_env))
.and_then(|v| {
v.as_str()
.and_then(|s| s.trim().parse::<u16>().ok())
.or_else(|| v.as_u64().and_then(|n| u16::try_from(n).ok()))
});
let dir_is_default = std::env::var("OPENLATCH_DIR")
.ok()
.filter(|d| !d.is_empty())
.is_none();
match settings_port {
Some(p) if p < config::MIN_USER_PORT || daemon_port < config::MIN_USER_PORT => {
report.push(
Check::failed(
Section::Hooks,
format!("Port: {p} in settings.json / {daemon_port} configured — not usable"),
)
.code(ERR_HOOK_CONFLICT)
.remedy(format!(
"{port_env} must be between {} and {}. Re-run `openlatch init` with a \
valid {port_env}, or unset it to probe automatically.",
config::MIN_USER_PORT,
u16::MAX
))
.agent(agent),
);
}
Some(p) if p == daemon_port && !daemon_reachable => {
report.push(
Check::unknown(Section::Hooks, Section::Daemon)
.headline(format!(
"Port {daemon_port} matches settings.json, but no daemon answered there"
))
.agent(agent),
);
}
Some(p) if p == daemon_port => {
report.push(
Check::ok(
Section::Hooks,
format!("Port: matches daemon port ({daemon_port})"),
)
.agent(agent),
);
}
Some(p) => {
report.push(
Check::failed(
Section::Hooks,
format!("Port: settings.json says {p}, daemon is on {daemon_port}"),
)
.code(ERR_HOOK_CONFLICT)
.detail("Hook subprocesses connect to the wrong port and fail open silently.")
.remedy(format!(
"Set {port_env} to {daemon_port} in the agent's settings env."
))
.agent(agent),
);
}
None if dir_is_default => {
report.push(
Check::ok(
Section::Hooks,
format!("Port: resolved from daemon.port ({daemon_port})"),
)
.agent(agent),
);
}
None => {
report.push(
Check::failed(
Section::Hooks,
format!("Port: {port_env} unset while OPENLATCH_DIR is non-default"),
)
.code(ERR_HOOK_CONFLICT)
.detail(
"The hook cannot see OPENLATCH_DIR, so it reads the default directory's \
daemon.port instead of this instance's and fails open silently.",
)
.remedy(format!(
"Set {port_env} to {daemon_port} in the agent's settings env."
))
.agent(agent),
);
}
}
}
fn check_fallback_activity(ol_dir: &std::path::Path, probe: &DaemonProbe, report: &mut Report) {
if !probe.alive {
return;
}
let log_dir = ol_dir.join("logs");
let fallback_path = log_dir.join("fallback.jsonl");
if !fallback_path.exists() {
report.push(Check::ok(
Section::Hooks,
"Fallback log: no offline events recorded",
));
return;
}
let meta = match std::fs::metadata(&fallback_path) {
Ok(m) => m,
Err(e) => {
report.push(
Check::failed(
Section::Hooks,
format!(
"Fallback log: cannot stat {} — {e}",
fallback_path.display()
),
)
.code(ERR_HOOK_CONFLICT)
.remedy("Check the permissions on ~/.openlatch/logs/."),
);
return;
}
};
let offset_path = log_dir.join(FALLBACK_OFFSET_FILENAME);
let pending = count_entries_from(&fallback_path, read_offset(&offset_path));
if pending == 0 {
report.push(Check::ok(
Section::Hooks,
"Fallback log: every spooled event replayed",
));
return;
}
let progress_at = std::fs::metadata(&offset_path)
.and_then(|offset_meta| offset_meta.modified())
.or_else(|_| meta.modified());
let stalled_for = progress_at
.ok()
.and_then(|at| at.elapsed().ok())
.unwrap_or_default();
if stalled_for >= FALLBACK_STALL_AFTER {
report.push(
Check::failed(
Section::Hooks,
format!(
"Fallback log: {pending} event(s) unreplayed for {}",
human_duration(stalled_for.as_secs())
),
)
.code(ERR_HOOK_CONFLICT)
.detail(
"The daemon is up but is not draining the spool, so these events were \
captured and never delivered.",
)
.detail(
"Replay pauses while the cloud channel is saturated — check the Cloud check \
below.",
)
.remedy("Run `openlatch restart` to force a replay pass."),
);
} else {
report.push(Check::ok(
Section::Hooks,
format!("Fallback log: {pending} event(s) queued for replay"),
));
}
}
const FALLBACK_STALL_AFTER: std::time::Duration = std::time::Duration::from_secs(300);
const FALLBACK_OFFSET_FILENAME: &str = "fallback.jsonl.offset";
fn human_duration(secs: u64) -> String {
match secs {
0..=90 => format!("{secs}s"),
91..=5400 => format!("{}m", secs / 60),
5401..=172_800 => format!("{}h", secs / 3600),
_ => format!("{}d", secs / 86_400),
}
}
#[cfg(feature = "model-relay")]
fn model_relay_endpoint_name(w: &crate::hooks::binding::ModelRelayWiring) -> String {
use crate::hooks::binding::EndpointConvention;
match &w.endpoint {
EndpointConvention::EnvVars { base_url, .. } => base_url.to_string(),
EndpointConvention::TomlProvider { provider_name, .. } => {
format!("`model_provider = \"{provider_name}\"`")
}
}
}
#[cfg(feature = "model-relay")]
fn model_relay_proxy_note(w: &crate::hooks::binding::ModelRelayWiring) -> &'static str {
use crate::hooks::binding::EndpointConvention;
match &w.endpoint {
EndpointConvention::EnvVars { .. } => "",
EndpointConvention::TomlProvider { .. } => {
" If Codex runs behind a proxy, export NO_PROXY=127.0.0.1,localhost in the shell \
that launches it — the model relay's loopback base must not leave through the \
corporate proxy."
}
}
}
#[cfg(feature = "model-relay")]
pub(crate) fn check_provider_endpoints(
rows: &[crate::cli::commands::model_relay::EndpointRow],
report: &mut Report,
) -> bool {
use crate::cli::commands::model_relay::EndpointState;
use crate::error::{
ERR_MODEL_RELAY_ENDPOINT_PORTS, ERR_MODEL_RELAY_MISCONFIGURED, ERR_MODEL_RELAY_NEXT_START,
ERR_MODEL_RELAY_NOT_RUNNING, ERR_MODEL_RELAY_PORT_FOREIGN,
ERR_MODEL_RELAY_PREFLIGHT_FAILED, ERR_MODEL_RELAY_STATE_FILE, ERR_MODEL_RELAY_UNCOVERED,
};
for row in rows {
let at = row
.port
.map(|p| format!("relay endpoint 127.0.0.1:{p}"))
.unwrap_or_else(|| "a relay endpoint".to_string());
let source = row
.file
.as_deref()
.map(crate::core::path_compat::display_path);
let check = match &row.state {
EndpointState::Active => {
Check::ok(Section::ModelRelay, format!("{} → {at}, in use", row.label))
}
EndpointState::NextStart => Check::pending(
Section::ModelRelay,
format!("{} → {at}, waiting for the editor to restart", row.label),
)
.code(ERR_MODEL_RELAY_NEXT_START)
.detail(
"OpenLatch pointed this provider at its relay endpoint. A running editor \
keeps the settings it read when it started, so model calls reach the relay \
from its next start.",
)
.remedy("Nothing to do: Cline picks this up the next time VS Code starts."),
EndpointState::Unwired => Check::pending(
Section::ModelRelay,
format!("{} is configured and not wired yet", row.label),
)
.code(ERR_MODEL_RELAY_NEXT_START)
.detail("The daemon wires every configured provider on its wiring pass.")
.remedy(
"Nothing to do while the daemon runs — it wires this within a minute. If \
`openlatch status` shows no daemon, run `openlatch start`.",
),
EndpointState::Down => Check::failed(
Section::ModelRelay,
format!("{} names {at}, and nothing is listening", row.label),
)
.code(ERR_MODEL_RELAY_NOT_RUNNING)
.detail(
"Model calls for this provider fail until the daemon serves the endpoint again.",
)
.remedy("Run `openlatch start`."),
EndpointState::Foreign => Check::failed(
Section::ModelRelay,
format!(
"{} names {at}, held by something that is not this endpoint",
row.label
),
)
.code(ERR_MODEL_RELAY_PORT_FOREIGN)
.detail("This provider's requests, and the API key they carry, go to that process.")
.remedy(format!(
"Identify it (lsof -i :{}), stop it, then run `openlatch restart`.",
row.port.unwrap_or_default()
)),
EndpointState::Verdict { code, detail } => {
let headline = format!("{} is not wired — {detail}", row.label);
let check = if *code == ERR_MODEL_RELAY_MISCONFIGURED {
Check::failed(Section::ModelRelay, headline).detail(
"The editor loaded the relay URL and is using this provider, but no \
request reached the endpoint: a Cline organisation's remote \
configuration is overriding it.",
)
} else {
Check::degraded(Section::ModelRelay, headline).detail(
"The provider's calls go straight to it and keep working; nothing is \
captured for them until the daemon can wire the slot.",
)
};
let remedy = match *code {
ERR_MODEL_RELAY_PREFLIGHT_FAILED => {
"Nothing to do if the provider is meant to be offline; otherwise fix \
reachability to it — the daemon wires the slot as soon as a round \
trip succeeds."
}
ERR_MODEL_RELAY_ENDPOINT_PORTS => {
"Free the port named above (lsof -i :<port>), or run `openlatch restart`; \
the daemon retries on its next pass."
}
ERR_MODEL_RELAY_MISCONFIGURED => {
"Ask your Cline organisation administrator to leave this provider's base \
URL unmanaged, or accept that its calls are not observed."
}
_ => "Nothing to do: the daemon retries on its next pass.",
};
check.code(code).remedy(remedy)
}
EndpointState::Uncovered(reason) => {
use crate::hooks::cline_providers::UncoveredReason;
let unproven = matches!(
reason,
UncoveredReason::NextBundleOnly | UncoveredReason::ManagedOverride
);
Check::off(
Section::ModelRelay,
if unproven {
format!(
"{} → {at}, not confirmed — {}",
row.label,
reason.describe()
)
} else {
format!(
"{} is not carried by the relay — {}",
row.label,
reason.describe()
)
},
)
.code(ERR_MODEL_RELAY_UNCOVERED)
.detail(if unproven {
"OpenLatch pointed this provider at its relay endpoint, but Cline may not \
route by that setting, so its calls count as not observed until one \
arrives. Nothing is broken."
} else {
"These model calls go straight to the provider and are not observed. \
Nothing is broken."
})
.remedy(match reason {
UncoveredReason::DefaultUnknown => {
"Nothing to do; `openlatch update` picks up support for new providers as \
it ships."
}
UncoveredReason::NextBundleOnly | UncoveredReason::ManagedOverride => {
"Nothing to do: the first request through the endpoint confirms it."
}
_ => "Nothing to do: this provider keeps working and is simply not observed.",
})
}
EndpointState::StateFile(why) => Check::degraded(
Section::ModelRelay,
format!("Cannot read {} safely — {why}", row.label),
)
.code(ERR_MODEL_RELAY_STATE_FILE)
.detail(
"OpenLatch refuses to rewrite a settings file it cannot read whole, so the \
providers in it are not wired and their calls go direct.",
)
.remedy("Nothing to do: OpenLatch wires them as soon as the file is readable again."),
};
let check = match source {
Some(source) => check.source(source),
None => check,
};
report.push(check.agent(row.agent));
}
!rows.is_empty()
}
#[cfg(feature = "model-relay")]
fn check_model_relay(
cfg: &config::Config,
agents: &[hooks::DetectedAgent],
probe: &DaemonProbe,
report: &mut Report,
) {
use crate::cli::commands::model_relay::{
classify_model_relay, read_agent_wiring, ModelRelayState,
};
use crate::error::{
ERR_MODEL_RELAY_NOT_RUNNING, ERR_MODEL_RELAY_PORT_FOREIGN, ERR_MODEL_RELAY_PORT_IN_USE,
ERR_MODEL_RELAY_PREFLIGHT_FAILED,
};
let port = cfg.model_relay.port;
let config_source = config::openlatch_dir().join("config.toml");
let mut pushed_any = check_provider_endpoints(
&crate::cli::commands::model_relay::endpoint_rows_for(
cfg,
agents,
&crate::cli::commands::model_relay::verify_endpoint_ownership,
),
report,
);
for a in agents {
let Some(w) = a.binding.model_relay_wiring() else {
continue;
};
let (wired, wiring_path, endpoint_name, upstream, proxy_note) = (
read_agent_wiring(&*a.binding),
crate::hooks::model_relay_config_path(&*a.binding).unwrap_or_else(|| a.settings_path()),
model_relay_endpoint_name(&w),
cfg.model_relay.upstream_for(w.wire_format),
model_relay_proxy_note(&w),
);
pushed_any = true;
let wiring_path = wiring_path.as_path();
let agent = a.agent_type();
let display_name = a.binding.display_name();
let url = wired.as_deref().unwrap_or_default().to_string();
match classify_model_relay(cfg, agent, wired.as_deref()) {
ModelRelayState::Disabled => {
report.push(
Check::off(
Section::ModelRelay,
"Disabled in config — model calls bypass OpenLatch entirely",
)
.code(ERR_MODEL_RELAY_NOT_RUNNING)
.source(format!(
"{} [model_relay] enabled = false",
config_source.display()
))
.detail("Nothing is captured and no policy is enforced on model traffic.")
.remedy(
"Run `openlatch system model-relay enable` (it offers the restart that applies it).",
)
.agent(agent),
);
}
ModelRelayState::Isolated => {
report.push(
Check::ok(
Section::ModelRelay,
format!("Isolated instance on port {port}"),
)
.detail(format!(
"{} is machine-global and is not managed by this instance.",
wiring_path.display()
))
.agent(agent),
);
}
ModelRelayState::Wired => {
report.push(
Check::ok(
Section::ModelRelay,
format!("Agent wired to {url} and the listener is up"),
)
.agent(agent),
);
}
ModelRelayState::WiredButDown => {
report.push(
Check::failed(
Section::ModelRelay,
format!("Agent is wired to {url} but nothing is listening there"),
)
.code(ERR_MODEL_RELAY_PORT_IN_USE)
.detail(format!(
"{display_name} is pointed at 127.0.0.1:{port} — its model calls fail \
with ECONNREFUSED."
))
.remedy(format!(
"Run `openlatch start` to bring the model relay back up, or `openlatch stop` \
to clear the wiring and go direct.{proxy_note}"
))
.agent(agent),
);
}
ModelRelayState::WiredToForeign => {
report.push(
Check::failed(
Section::ModelRelay,
format!("Agent is wired to {url}, held by a process that is NOT OpenLatch"),
)
.code(ERR_MODEL_RELAY_PORT_FOREIGN)
.detail("Your provider API key is being sent to that process.")
.remedy(format!(
"Identify it (lsof -i :{port}), stop it, then run `openlatch restart`."
))
.agent(agent),
);
}
ModelRelayState::PreflightFailed(why) => {
report.push(
Check::failed(
Section::ModelRelay,
format!("Listener is up but its preflight failed — {why}"),
)
.code(ERR_MODEL_RELAY_PREFLIGHT_FAILED)
.detail(format!(
"The model_relay on 127.0.0.1:{port} cannot reach the provider, so the agent \
was deliberately left unwired: model calls go direct and keep working, but \
nothing is captured."
))
.remedy(format!(
"Fix reachability to {upstream} (proxy, VPN, TLS interception) — the \
daemon re-wires itself as soon as the check passes.{proxy_note}"
))
.agent(agent),
);
}
ModelRelayState::PreflightPending => {
report.push(
Check::pending(
Section::ModelRelay,
"Listener is up, its preflight has not finished yet",
)
.code(ERR_MODEL_RELAY_PREFLIGHT_FAILED)
.detail(format!(
"The model_relay on 127.0.0.1:{port} is still verifying it can reach the \
provider; the agent is wired only once it can."
))
.remedy("Re-run `openlatch doctor` in a moment.")
.agent(agent),
);
}
ModelRelayState::UpUnwired => {
report.push(
Check::degraded(
Section::ModelRelay,
"Listener is up but the agent is not wired to it",
)
.code(ERR_MODEL_RELAY_NOT_RUNNING)
.detail(format!(
"The model relay is listening on 127.0.0.1:{port} but {display_name} has no \
{endpoint_name} pointing at it, so model calls bypass it entirely."
))
.remedy("Run `openlatch restart` to re-wire.")
.agent(agent),
);
}
ModelRelayState::Down => {
let (state_check, why) = if probe.alive {
(
Check::failed(
Section::ModelRelay,
"Enabled in config, but no listener holds the port",
),
"The daemon is up and the model relay is not — its listener task did not bind.",
)
} else {
(
Check::unknown(Section::ModelRelay, Section::Daemon)
.headline("Listener down — the daemon that owns it is not running"),
"The daemon is down, so its model relay listener is too.",
)
};
report.push(
state_check
.code(ERR_MODEL_RELAY_NOT_RUNNING)
.detail(why)
.remedy(if probe.alive {
"Check the newest ~/.openlatch/logs/daemon.log.<date> for OL-RELAY-PORT, \
then `openlatch restart`."
} else {
"Run `openlatch start`."
})
.agent(agent),
);
}
ModelRelayState::ForeignIdle => {
report.push(
Check::failed(
Section::ModelRelay,
format!("127.0.0.1:{port} is held by another process"),
)
.code(ERR_MODEL_RELAY_PORT_FOREIGN)
.detail("The agent is not wired to it, but the next `openlatch start` will refuse to bind.")
.remedy(format!("Identify it (lsof -i :{port}) and stop it."))
.agent(agent),
);
}
}
}
if !pushed_any {
report.push(
Check::unknown(Section::ModelRelay, Section::Environment)
.headline("Wiring state unknown — no agent config to read"),
);
}
}
fn check_persistence(cfg: &config::Config, report: &mut Report) {
use crate::supervision::{select_supervisor, SupervisionMode, SupervisorKind};
let backend = match cfg.supervision.backend {
SupervisorKind::Launchd => "launchd",
SupervisorKind::Systemd => "systemd",
SupervisorKind::TaskScheduler => "task_scheduler",
SupervisorKind::None => "none",
};
match (&cfg.supervision.mode, select_supervisor()) {
(SupervisionMode::Active, Some(sup)) => {
match sup.status() {
Ok(s) if s.installed && s.running && !s.unit_current => {
report.push(
Check::failed(
Section::Persistence,
format!("Running an outdated unit ({backend}, {})", s.description),
)
.code(ERR_NO_SUPERVISOR)
.detail(
"The installed unit predates this client's restart semantics — it may \
still use the old never-fires restart policy.",
)
.remedy("Run `openlatch system supervision install` to regenerate it."),
);
}
Ok(s) if s.installed && s.running => {
report.push(Check::ok(
Section::Persistence,
format!("Active ({backend}, {})", s.description),
));
}
Ok(s) if s.installed => {
report.push(
Check::failed(
Section::Persistence,
format!("Installed but the supervisor is not running ({backend})"),
)
.code(ERR_NO_SUPERVISOR)
.remedy(
"Check the OS logs, or run `openlatch system supervision install` to rebuild \
the artifact.",
),
);
}
Ok(_) => {
report.push(
Check::failed(
Section::Persistence,
format!("Config says active but the OS artifact is missing ({backend})"),
)
.code(ERR_NO_SUPERVISOR)
.detail("The artifact was deleted while config still expects it.")
.remedy("Run `openlatch system supervision install`, or `openlatch doctor --fix`."),
);
}
Err(e) => {
report.push(
Check::failed(
Section::Persistence,
format!("Cannot query the supervisor ({}) — {}", e.code, e.message),
)
.code(ERR_NO_SUPERVISOR)
.remedy("Check that the OS supervisor is reachable from this session."),
);
}
}
}
(SupervisionMode::Deferred, _) => {
let reason = cfg
.supervision
.disabled_reason
.as_deref()
.unwrap_or("unknown");
report.push(
Check::degraded(Section::Persistence, format!("Deferred — {reason}"))
.code(ERR_NO_SUPERVISOR)
.source(format!("[supervision] disabled_reason = \"{reason}\""))
.detail(
"Nothing will restart the daemon after a crash or a reboot — and you \
did not choose this.",
)
.remedy(
"Run `openlatch system supervision install` once the underlying condition is \
resolved (headless session, missing systemd, and so on).",
),
);
}
(SupervisionMode::Disabled, _) => {
let reason = cfg
.supervision
.disabled_reason
.as_deref()
.unwrap_or("user_opt_out");
if reason == "isolated_instance" {
report.push(
Check::not_applicable(
Section::Persistence,
"Not applicable — an isolated instance is not machine-global",
)
.detail(
"Bring it up with `openlatch start`; it is not meant to survive a reboot.",
),
);
return;
}
let deliberate = crate::supervision::absence_is_deliberate(Some(reason));
let headline = if deliberate {
format!("Disabled at your request ({reason})")
} else {
format!("Disabled ({reason})")
};
report.push(
Check::off(Section::Persistence, headline)
.code(ERR_NO_SUPERVISOR)
.source(format!("[supervision] disabled_reason = \"{reason}\""))
.detail("The daemon will not restart after a logout, a reboot, or a crash.")
.remedy("Run `openlatch system supervision enable` to re-arm it."),
);
}
(SupervisionMode::Active, None) => {
report.push(
Check::failed(
Section::Persistence,
"Config says active but no supervisor is available on this OS",
)
.code(ERR_NO_SUPERVISOR)
.remedy("Run `openlatch system supervision disable` to clear the stale state."),
);
}
}
}
fn check_connection(cfg: &config::Config, probe: &DaemonProbe, report: &mut Report) {
let route = cli_route_line(cfg);
if !probe.alive {
report.push(
Check::unknown(Section::Connection, Section::Daemon)
.headline(format!("{route} (not confirmed)")),
);
return;
}
match probe.metric_str("egress_status").unwrap_or("unknown") {
"failed" => {
let code = egress_error_code(probe);
let mut check = Check::failed(Section::Connection, connection_failure(cfg, code))
.code(code)
.remedy(egress_remedy(code))
.detail(route);
if let Some(message) = probe.egress_error_message() {
check = check.detail(message);
}
report.push(check);
}
"degraded" => report.push(
Check::degraded(Section::Connection, route)
.code(ERR_PROXY_CONFIG_INVALID)
.detail(egress_warning_line(probe))
.remedy(
"Run `openlatch system proxy status` for the full resolution, then fix the setting it names.",
),
),
_ => {
if cfg.egress.url.as_deref() == probe.egress_str("proxy_url") {
report.push(Check::ok(Section::Connection, route));
return;
}
let live = daemon_route_line(probe);
report.push(
Check::degraded(Section::Connection, live)
.code(ERR_CONFIG_NOT_APPLIED)
.detail(format!("configured, not yet in use: {route}"))
.remedy("Run `openlatch restart` to apply it."),
);
}
}
}
pub fn cli_route_line(cfg: &config::Config) -> String {
let Some(url) = cfg.egress.url.as_deref() else {
return "direct — no proxy set".to_string();
};
let via = format!("via HTTP proxy {}", crate::egress::mask_userinfo(url));
match cfg.egress.source {
Some(source) => format!(
"{via} (set by: {})",
crate::cli::commands::proxy::source_str(source)
),
None => via,
}
}
fn daemon_route_line(probe: &DaemonProbe) -> String {
if let Some(url) = probe.egress_str("proxy_url") {
return format!("via HTTP proxy {}", crate::egress::mask_userinfo(url));
}
if probe.metric_bool("proxy_in_use").unwrap_or(false) {
return "via a proxy (run `openlatch system proxy status` for the route)".to_string();
}
"direct — no proxy set".to_string()
}
fn connection_failure(cfg: &config::Config, code: &'static str) -> String {
let hop = match cfg.egress.url.as_deref() {
Some(url) => format!("proxy {}", crate::egress::mask_userinfo(url)),
None => "the network".to_string(),
};
if code == ERR_CLOUD_UNREACHABLE {
format!("Cannot reach {} through {hop}", cfg.cloud.api_url)
} else {
format!("Cannot reach {hop}")
}
}
fn check_cloud(cfg: &config::Config, probe: &DaemonProbe, report: &mut Report) {
if !probe.alive {
report.push(
Check::unknown(Section::Cloud, Section::Daemon)
.headline("Forwarding state unknown — the daemon is not answering"),
);
return;
}
let Some(_) = probe.metrics.as_ref() else {
report.push(
Check::failed(
Section::Cloud,
"Could not read the daemon's /metrics — the daemon may be unhealthy",
)
.code(ERR_DAEMON_START_FAILED)
.remedy("Run `openlatch restart`."),
);
return;
};
let status = probe.metric_str("cloud_status").unwrap_or("unknown");
let api_url = probe
.metric_str("cloud_api_url")
.unwrap_or(cfg.cloud.api_url.as_str())
.to_string();
let drops = probe.metric_u64("cloud_drop_count").unwrap_or(0);
let pending = probe.metric_u64("outbox_pending_count").unwrap_or(0);
let egress = probe.metric_str("egress_status").unwrap_or("unknown");
if egress == "failed" {
report.push(
Check::unknown(Section::Cloud, Section::Connection)
.headline("Cannot confirm — this host has no working route out"),
);
return;
}
match status {
"connected" => {
let forwarded = probe.metric_u64("cloud_forwarded_count").unwrap_or(0);
let mut check = Check::ok(
Section::Cloud,
format!("Connected: {api_url} ({forwarded} event(s) forwarded)"),
);
if pending > 0 {
check = check.detail(format!("{pending} event(s) still draining from the outbox"));
}
report.push(check);
}
"network_error" => {
let outbox_on = cfg.cloud.outbox_enabled;
let headline = format!("Cannot reach {api_url}");
let check = if outbox_on {
Check::degraded(Section::Cloud, headline)
.detail(format!("{pending} event(s) buffered — nothing lost yet"))
} else {
Check::failed(Section::Cloud, headline)
.detail(format!("{drops} event(s) dropped — the outbox is disabled"))
};
report.push(
check
.code(ERR_CLOUD_UNREACHABLE)
.remedy(format!("Check network connectivity to {api_url}.")),
);
}
"auth_error" => {
report.push(
Check::failed(Section::Cloud, format!("Credential rejected by {api_url}"))
.code(ERR_CLOUD_AUTH_FAILED)
.remedy("Run `openlatch system auth login` to refresh the credential."),
);
}
"no_credential" | "not_configured" => {
report.push(
Check::failed(
Section::Cloud,
format!("No credential — forwarding paused ({api_url})"),
)
.code(ERR_NO_CREDENTIALS)
.detail(format!(
"{pending} event(s) are accumulating locally and reach nobody."
))
.remedy("Run `openlatch system auth login` to store an API key."),
);
}
other => {
report.push(
Check::failed(Section::Cloud, format!("Unknown status '{other}'"))
.code(ERR_BUG)
.remedy("Please report this with `openlatch doctor --rescue`."),
);
}
}
}
fn check_policy(cfg: &config::Config, probe: &DaemonProbe, report: &mut Report) {
if !cfg.policy.enabled {
report.push(
Check::off(
Section::Policy,
"Disabled in config — no rule is evaluated on this host",
)
.code(ERR_POLICY_DISABLED)
.source("[policy] enabled = false")
.detail("Every action is allowed; any bundle on disk is not consulted.")
.remedy("Remove `enabled = false` from the [policy] block, then `openlatch restart`."),
);
return;
}
if !probe.alive || probe.metrics.is_none() {
report.push(
Check::unknown(Section::Policy, Section::Daemon)
.headline("Enforcement state unknown — the daemon is not answering"),
);
return;
}
let has_bundle = probe.metric_bool("policy_has_bundle").unwrap_or(false);
let floor_blocked = probe.metric_bool("policy_floor_blocked").unwrap_or(false);
if floor_blocked {
let installed = probe.metric_str("policy_installed_version").unwrap_or("?");
let minimum = probe.metric_str("policy_min_client_version").unwrap_or("?");
report.push(
Check::failed(Section::Policy, "Client upgrade required")
.code(ERR_BUNDLE_FETCH_FAILED)
.detail(if has_bundle {
format!("Installed {installed}; minimum {minimum}. The last-known-good bundle remains active.")
} else {
format!("Installed {installed}; minimum {minimum}. No bundle has been activated on this install.")
})
.remedy("Run `openlatch update`, then restart the daemon."),
);
}
if !has_bundle {
if floor_blocked {
return;
}
const FIRST_POLL_GRACE_SECS: u64 = 120;
let check = if report.section_state(Section::Cloud) != State::Ok {
Check::unknown(Section::Policy, Section::Cloud)
.detail("No bundle can be fetched while the platform is unreachable.")
} else if probe.uptime_secs.unwrap_or(u64::MAX) < FIRST_POLL_GRACE_SECS {
Check::pending(Section::Policy, "First bundle poll has not landed yet")
.code(ERR_BUNDLE_FETCH_FAILED)
.remedy("Re-run `openlatch doctor` in a moment.")
} else {
Check::failed(
Section::Policy,
"No bundle resident — nothing is being enforced",
)
.code(ERR_BUNDLE_FETCH_FAILED)
.detail("The platform is reachable but no bundle has ever been activated.")
.remedy("Check the newest ~/.openlatch/logs/daemon.log.<date> for `target=policy`.")
};
report.push(check);
return;
}
let revision = probe
.metrics
.as_ref()
.and_then(|m| m.get("policy_revision"))
.map(|v| v.to_string())
.unwrap_or_else(|| "?".into());
let schema = probe.metric_u64("policy_schema_version").unwrap_or(0);
let digest = probe.metric_str("policy_bundle_digest").unwrap_or("?");
let omitted = probe.metric_strings("policy_omitted_kinds");
if !omitted.is_empty() {
report.push(
Check::degraded(
Section::Policy,
"Some policy kinds are not enforced on this client version",
)
.code(ERR_BUNDLE_INVALID)
.detail(format!("Omitted kinds: {}", omitted.join(", ")))
.remedy("Run `openlatch update` to install the newest policy capabilities."),
);
}
let skipped = probe.metric_u64("policy_skipped_items").unwrap_or(0);
if skipped > 0 {
report.push(
Check::degraded(
Section::Policy,
format!("{skipped} bundle item(s) skipped; the rest of the bundle activated"),
)
.code(ERR_BUNDLE_INVALID)
.remedy("Inspect the daemon policy logs for the skipped item ids and reasons."),
);
}
match probe.metric_u64("policy_last_poll_ok_secs") {
Some(secs) if secs > cfg.policy.stale_warn_after_secs => {
report.push(
Check::degraded(
Section::Policy,
format!(
"Bundle {revision} is enforcing, but no successful poll for {}h",
secs / 3600
),
)
.code(ERR_BUNDLE_STALE)
.detail("The resident bundle keeps enforcing; it may no longer match the platform.")
.remedy("Fix cloud connectivity above — the poller recovers on its own."),
);
}
Some(secs) => {
report.push(
Check::ok(
Section::Policy,
format!("Schema {schema} bundle {revision} fresh (polled {secs}s ago)"),
)
.detail(format!("Active digest: {digest}")),
);
}
None => {
report.push(
Check::degraded(
Section::Policy,
format!(
"Schema {schema} bundle {revision} is enforcing, but no poll has ever succeeded"
),
)
.code(ERR_BUNDLE_STALE)
.detail(format!("Active digest: {digest}"))
.remedy("Fix cloud connectivity above — the poller recovers on its own."),
);
}
}
}
fn check_inventory(cfg: &config::Config, probe: &DaemonProbe, report: &mut Report) {
if !cfg.inventory_monitor.enabled {
report.push(
Check::off(
Section::Inventory,
"Disabled in config — config drift is not observed",
)
.code(ERR_INVENTORY_DISABLED)
.source("[inventory_monitor] enabled = false")
.remedy(
"Remove `enabled = false` from the [inventory_monitor] block, then \
`openlatch restart`.",
),
);
return;
}
if !probe.alive || !probe.reachable {
report.push(
Check::unknown(Section::Inventory, Section::Daemon)
.headline("Monitor state unknown — the daemon is not answering"),
);
return;
}
let token = std::fs::read_to_string(config::openlatch_dir().join("daemon.token"))
.map(|s| s.trim().to_string())
.unwrap_or_default();
let url = format!("http://127.0.0.1:{}/admin/inventory/status", cfg.port);
let res = crate::egress::blocking_client()
.get(&url)
.bearer_auth(&token)
.timeout(std::time::Duration::from_secs(2))
.send();
match res {
Ok(r) if r.status().is_success() => {
let body: serde_json::Value = r.json().unwrap_or(serde_json::json!({}));
let sources = body.get("cache_size").and_then(|v| v.as_u64()).unwrap_or(0);
let manifest = body
.get("manifest_loaded")
.and_then(|v| v.as_bool())
.unwrap_or(false);
match body.get("state").and_then(|value| value.as_str()) {
Some("pending") => report.push(Check::pending(
Section::Inventory,
"Monitor initialization is still in progress",
)),
Some("failed") | Some("stopped") => {
let mut check = Check::failed(
Section::Inventory,
"Monitor failed to initialize or stopped unexpectedly",
)
.code(ERR_INVENTORY_INIT_FAILED)
.remedy("Check the newest ~/.openlatch/logs/daemon.log.<date>, then `openlatch restart`.");
if let Some(error) = body.get("error").and_then(|value| value.as_str()) {
check = check.detail(error);
}
report.push(check);
}
Some("running") | None if manifest => report.push(Check::ok(
Section::Inventory,
format!("Monitor up, {sources} source(s) tracked"),
)),
_ => {
report.push(
Check::degraded(
Section::Inventory,
format!(
"Monitor up but no manifest loaded ({sources} source(s) cached)"
),
)
.code(ERR_INVENTORY_INIT_FAILED)
.remedy("Run `openlatch system inventory rescan`."),
);
}
}
}
Ok(r) if r.status().as_u16() == 401 => {
report.push(
Check::failed(
Section::Inventory,
"Daemon rejected the admin token (401) — daemon.token has drifted",
)
.code(ERR_HOOK_CONFLICT)
.detail(
"The running daemon loaded a different token than the one now on disk; \
`init` regenerates it without restarting an already-running daemon.",
)
.remedy("Run `openlatch restart`."),
);
}
Ok(r) => {
report.push(
Check::failed(
Section::Inventory,
format!("Daemon responded HTTP {}", r.status().as_u16()),
)
.code(ERR_INVENTORY_INIT_FAILED)
.remedy("Check the newest ~/.openlatch/logs/daemon.log.<date>."),
);
}
Err(e) => {
report.push(
Check::failed(
Section::Inventory,
format!("Cannot reach the monitor — {e}"),
)
.code(ERR_INVENTORY_INIT_FAILED)
.remedy("Run `openlatch restart`."),
);
}
}
}
fn check_telemetry(ol_dir: &std::path::Path, report: &mut Report) {
let consent_path = crate::telemetry::consent_file_path(ol_dir);
let resolved = crate::telemetry::consent::resolve(&consent_path);
if resolved.enabled() {
report.push(Check::ok(Section::Telemetry, "Enabled — thank you"));
} else {
report.push(Check::not_applicable(
Section::Telemetry,
"Disabled by user (opt-out honoured)",
));
}
}
fn check_update(cfg: &config::Config, probe: &DaemonProbe, report: &mut Report) {
let installed = env!("OPENLATCH_VERSION");
match probe
.health
.as_ref()
.and_then(|h| h.get("version"))
.and_then(|v| v.as_str())
{
Some(running) if running != installed => {
report.push(
Check::degraded(
Section::Update,
format!("Daemon is serving {running}, {installed} is installed on disk"),
)
.code(ERR_VERSION_OUTDATED)
.detail("A package manager replaces the binary; it does not restart the process.")
.remedy("Run `openlatch restart`."),
);
}
Some(_) => report.push(Check::ok(
Section::Update,
format!("Daemon is serving the installed version ({installed})"),
)),
None => report.push(Check::ok(
Section::Update,
format!("Installed version {installed}"),
)),
}
check_auto_update_drift(cfg, report);
}
fn check_auto_update_drift(cfg: &config::Config, report: &mut Report) {
use crate::install_state::{InstallMethod, InstallState};
let state = InstallState::load_or_default();
let (Some(actual), Some(npm)) = (
state.actual_binary_version.as_deref(),
state.npm_reported_version.as_deref(),
) else {
return;
};
if !matches!(state.install_method, InstallMethod::Npm) {
return;
}
if actual == npm {
report.push(Check::ok(
Section::Update,
format!("Auto-update in sync (npm={npm}, binary={actual})"),
));
return;
}
let last_update = state.last_updated_at.as_deref();
let stale_drift = last_update
.and_then(|ts| chrono::DateTime::parse_from_rfc3339(ts).ok())
.map(|t| (chrono::Utc::now() - t.with_timezone(&chrono::Utc)).num_days() > 30)
.unwrap_or(false);
if stale_drift && cfg.update.auto_update {
report.push(
Check::failed(
Section::Update,
format!(
"Auto-update drift unchanged for >30d (npm={npm}, binary={actual}, last={})",
last_update.unwrap_or("?")
),
)
.code(ERR_VERSION_OUTDATED)
.detail("Auto-update appears stalled.")
.remedy(format!(
"Run `npm install -g @openlatch/client@{actual}` to resync, then check the \
daemon log for `target=update` errors."
)),
);
} else {
report.push(
Check::degraded(
Section::Update,
format!("Cosmetic drift (binary {actual} > npm {npm})"),
)
.code(ERR_VERSION_OUTDATED)
.detail("Expected right after an auto-update.")
.remedy(format!(
"Run `npm install -g @openlatch/client@{actual}` to sync the package manager."
)),
);
}
}
fn check_integrity(ol_dir: &std::path::Path, report: &mut Report) {
let key_path = ol_dir.join("hmac.key");
if key_path.exists() {
report.push(Check::ok(
Section::Integrity,
"HMAC key present — hook markers are signed",
));
} else {
report.push(
Check::failed(
Section::Integrity,
format!("HMAC key missing: {}", key_path.display()),
)
.code(ERR_HMAC_KEY_UNAVAILABLE)
.detail("Hook markers cannot be verified, so tampering cannot be detected.")
.remedy("Run `openlatch init` to regenerate it."),
);
}
let tamper_path = ol_dir.join("tamper.jsonl");
let Ok(contents) = std::fs::read_to_string(&tamper_path) else {
return;
};
let (records, unhealed) = summarise_tamper(&contents);
if records == 0 {
return;
}
if unhealed == 0 {
report.push(Check::ok(
Section::Integrity,
format!("Hook entries intact — {records} tamper event(s) healed"),
));
} else {
report.push(
Check::degraded(
Section::Integrity,
format!("{unhealed} hook entry(s) rewritten and still unhealed"),
)
.code(ERR_TAMPER_DETECTED)
.detail(
"Something rewrote the agent's hook entries and the reconciler could not \
restore them, so those events are not being captured.",
)
.remedy("Inspect them with `openlatch logs --tamper`, then run `openlatch init`."),
);
}
}
fn summarise_tamper(contents: &str) -> (usize, usize) {
let mut latest: std::collections::HashMap<(String, String), bool> =
std::collections::HashMap::new();
let mut records = 0usize;
for line in contents.lines() {
let Ok(value) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
continue;
};
let Some(tamper) = value.get("tamper") else {
continue;
};
if tamper
.get("detection_method")
.and_then(serde_json::Value::as_str)
.is_some_and(crate::core::cloud::tamper::is_relay_wiring_method)
{
continue;
}
records += 1;
let field = |name: &str| {
tamper
.get(name)
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string()
};
let healed = tamper
.get("heal")
.and_then(|heal| heal.get("outcome"))
.and_then(serde_json::Value::as_str)
== Some("succeeded");
latest.insert((field("settings_path_hash"), field("hook_event")), healed);
}
(records, latest.values().filter(|healed| !**healed).count())
}
fn json_document(report: &DoctorReport) -> serde_json::Value {
let mut document = report.report.to_json();
let Some(object) = document.as_object_mut() else {
return document;
};
let rollups = object
.get_mut("agents")
.and_then(serde_json::Value::as_object_mut);
let Some(rollups) = rollups else {
object.insert("agents".to_string(), report.agents_json());
return document;
};
if let serde_json::Value::Object(attested) = report.agents_json() {
for (agent, attestation) in attested {
let entry = rollups
.entry(agent)
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
match (entry.as_object_mut(), attestation) {
(Some(target), serde_json::Value::Object(fields)) => {
target.extend(fields);
}
(_, other) => {
*entry = other;
}
}
}
}
document
}
pub(crate) fn print_diagnostic_results(report: &DoctorReport, output: &OutputConfig) {
report.report.record_verdict();
if output.format == OutputFormat::Json {
output.print_json(&json_document(report));
return;
}
if output.quiet {
return;
}
report.report.render(output);
let counts = report.report.counts();
if counts.failed == 0 && counts.warned == 0 {
eprintln!("All checks passed.");
return;
}
if let Some(next) = suggested_next_step(report) {
eprintln!("Next: {next}");
}
}
pub fn run_section(section: Section, output: &OutputConfig) -> Result<(), OlError> {
run_section_inner(section, output, true)
}
pub fn append_section_verdict(section: Section, output: &OutputConfig) -> Result<(), OlError> {
let one = section_report(section, output)?;
if output.format == OutputFormat::Json || output.quiet {
return Ok(());
}
one.render_section(section, output);
Ok(())
}
pub fn with_section_verdict(
body: serde_json::Value,
section: Section,
output: &OutputConfig,
) -> Result<serde_json::Value, OlError> {
let one = section_report(section, output)?;
Ok(merge_verdict(body, section_verdict_value(&one, section)))
}
fn merge_verdict(body: serde_json::Value, verdict: serde_json::Value) -> serde_json::Value {
let mut doc = match body {
serde_json::Value::Object(map) => serde_json::Value::Object(map),
other => serde_json::json!({ "body": other }),
};
doc["verdict"] = verdict;
doc
}
fn section_report(section: Section, output: &OutputConfig) -> Result<Report, OlError> {
let full = run_all_checks(output)?;
let mut one = Report::new();
for check in full.report.section_checks(section) {
one.push(check.clone());
}
let blockers = blockers_of(&full.report, section);
for other in Section::ALL {
if other == section {
continue;
}
let carried: Vec<&Check> = if blockers.contains(&other) {
full.report.section_checks(other).collect()
} else {
Vec::new()
};
if carried.is_empty() {
one.push(Check::not_applicable(other, "not part of this view"));
} else {
for check in carried {
one.push(check.clone());
}
}
}
one.record_verdict();
Ok(one)
}
fn deferred_to(report: &Report, section: Section) -> Vec<Section> {
report
.section_checks(section)
.filter_map(|c| match c.state {
State::Unknown(blocker) => Some(blocker),
_ => None,
})
.collect()
}
fn blockers_of(report: &Report, section: Section) -> Vec<Section> {
let mut seen: Vec<Section> = Vec::new();
let mut pending = deferred_to(report, section);
while let Some(blocker) = pending.pop() {
if blocker == section || seen.contains(&blocker) {
continue;
}
seen.push(blocker);
pending.extend(deferred_to(report, blocker));
}
seen
}
fn section_verdict_value(one: &Report, section: Section) -> serde_json::Value {
let checks: Vec<serde_json::Value> = one.section_checks(section).map(|c| c.to_json()).collect();
serde_json::json!({
"section": section.key(),
"state": one.section_state(section).key(),
"checks": checks,
"exit_code": one.exit_code(),
})
}
fn run_section_inner(
section: Section,
output: &OutputConfig,
with_header: bool,
) -> Result<(), OlError> {
let one = section_report(section, output)?;
if output.format == OutputFormat::Json {
output.print_json(§ion_verdict_value(&one, section));
return Ok(());
}
if output.quiet {
return Ok(());
}
if with_header {
crate::cli::header::print(output, &[section.key(), "status"]);
}
one.render_section(section, output);
Ok(())
}
fn suggested_next_step(report: &DoctorReport) -> Option<&'static str> {
let issues = report.report.issues();
let mentions = |needle: &str| issues.iter().any(|i| i.contains(needle));
if mentions("openlatch restart") {
Some("run 'openlatch restart' — a daemon out of step with the installed binary explains several of these at once")
} else if mentions("openlatch start") {
Some("run 'openlatch start' to bring the daemon back up")
} else if mentions("openlatch system auth login") {
Some(
"run 'openlatch system auth login' — nothing reaches the platform without a credential",
)
} else if mentions("openlatch system model-relay enable") {
Some("run 'openlatch system model-relay enable' to route model calls through OpenLatch")
} else if mentions("openlatch system supervision") {
Some("run 'openlatch system supervision enable' to make the daemon survive a reboot")
} else if mentions("openlatch init") {
Some("run 'openlatch init' to repair the hook installation")
} else if report.fail_count() > 0 {
Some("run 'openlatch doctor --fix' to attempt an automatic repair")
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hooks::binding::test_support::{non_installable_agent, FakeBinding};
use crate::hooks::binding::LivenessReport;
use crate::hooks::{AgentKind, DetectedAgent};
use std::sync::Arc;
fn dead_probe() -> DaemonProbe {
DaemonProbe {
alive: false,
reachable: false,
health: None,
metrics: None,
egress: None,
uptime_secs: None,
}
}
fn live_probe() -> DaemonProbe {
DaemonProbe {
alive: true,
reachable: true,
health: None,
metrics: None,
egress: None,
uptime_secs: Some(600),
}
}
#[test]
fn fresh_install_floor_is_reported_before_no_bundle() {
let probe = DaemonProbe {
metrics: Some(serde_json::json!({
"policy_floor_blocked": true,
"policy_installed_version": "0.4.0",
"policy_min_client_version": "0.5.0",
"policy_has_bundle": false
})),
..live_probe()
};
let mut report = Report::new();
check_policy(&config::Config::defaults(), &probe, &mut report);
let checks: Vec<_> = report.section_checks(Section::Policy).collect();
assert_eq!(checks.len(), 1);
assert_eq!(checks[0].headline, "Client upgrade required");
assert_eq!(checks[0].state, State::Failed);
assert!(checks[0]
.detail
.iter()
.any(|line| line.contains("No bundle has been activated")));
}
#[test]
fn omitted_and_skipped_bundle_items_are_both_visible() {
let probe = DaemonProbe {
metrics: Some(serde_json::json!({
"policy_floor_blocked": false,
"policy_has_bundle": true,
"policy_revision": 12,
"policy_last_poll_ok_secs": 2,
"policy_omitted_kinds": ["t3_hold"],
"policy_skipped_items": 2
})),
..live_probe()
};
let mut report = Report::new();
check_policy(&config::Config::defaults(), &probe, &mut report);
let headlines: Vec<_> = report
.section_checks(Section::Policy)
.map(|check| check.headline.as_str())
.collect();
assert!(headlines.contains(&"Some policy kinds are not enforced on this client version"));
assert!(headlines.contains(&"2 bundle item(s) skipped; the rest of the bundle activated"));
}
fn write_spool(dir: &std::path::Path, lines: usize) -> u64 {
let log_dir = dir.join("logs");
std::fs::create_dir_all(&log_dir).expect("log dir");
let body: String = (0..lines).map(|i| format!("{{\"id\":{i}}}\n")).collect();
std::fs::write(log_dir.join("fallback.jsonl"), &body).expect("write spool");
body.len() as u64
}
fn fallback_checks(dir: &std::path::Path) -> Vec<Check> {
let mut report = Report::new();
check_fallback_activity(dir, &live_probe(), &mut report);
report.section_checks(Section::Hooks).cloned().collect()
}
#[test]
fn replayed_spool_is_not_a_finding() {
let dir = tempfile::tempdir().expect("temp dir");
let len = write_spool(dir.path(), 3);
std::fs::write(
dir.path().join("logs").join("fallback.jsonl.offset"),
format!("{len}\n"),
)
.expect("write offset");
let checks = fallback_checks(dir.path());
assert_eq!(checks.len(), 1);
assert_eq!(checks[0].state, State::Ok, "{:?}", checks[0]);
}
#[test]
fn undrained_spool_is_reported_but_passes_while_draining() {
let dir = tempfile::tempdir().expect("temp dir");
write_spool(dir.path(), 2);
let checks = fallback_checks(dir.path());
assert_eq!(checks.len(), 1);
assert_eq!(checks[0].state, State::Ok, "{:?}", checks[0]);
assert!(checks[0].headline.contains('2'), "{:?}", checks[0]);
}
#[test]
fn stale_backlog_fails() {
let dir = tempfile::tempdir().expect("temp dir");
write_spool(dir.path(), 1);
let aged = std::time::SystemTime::now()
.checked_sub(FALLBACK_STALL_AFTER + std::time::Duration::from_secs(60))
.expect("aged timestamp");
std::fs::File::options()
.write(true)
.open(dir.path().join("logs").join("fallback.jsonl"))
.expect("open spool")
.set_modified(aged)
.expect("age spool");
let checks = fallback_checks(dir.path());
assert_eq!(checks.len(), 1);
assert_eq!(checks[0].state, State::Failed, "{:?}", checks[0]);
}
#[test]
fn frozen_watermark_fails_even_while_the_spool_grows() {
let dir = tempfile::tempdir().expect("temp dir");
write_spool(dir.path(), 4);
let offset = dir.path().join("logs").join("fallback.jsonl.offset");
std::fs::write(&offset, "0\n").expect("write offset");
let aged = std::time::SystemTime::now()
.checked_sub(FALLBACK_STALL_AFTER + std::time::Duration::from_secs(60))
.expect("aged timestamp");
std::fs::File::options()
.write(true)
.open(&offset)
.expect("open offset")
.set_modified(aged)
.expect("age watermark");
let checks = fallback_checks(dir.path());
assert_eq!(checks.len(), 1);
assert_eq!(checks[0].state, State::Failed, "{:?}", checks[0]);
}
#[test]
fn absent_spool_is_ok() {
let dir = tempfile::tempdir().expect("temp dir");
let checks = fallback_checks(dir.path());
assert_eq!(checks.len(), 1);
assert_eq!(checks[0].state, State::Ok, "{:?}", checks[0]);
}
fn tamper_line(event: &str, outcome: &str) -> String {
serde_json::json!({
"tamper": {
"settings_path_hash": "sha256:abc",
"hook_event": event,
"heal": { "outcome": outcome, "attempt": 1, "circuit": "closed" }
}
})
.to_string()
}
#[test]
fn healed_tamper_records_leave_nothing_unhealed() {
let log = format!(
"{}\n{}\n",
tamper_line("Stop", "succeeded"),
tamper_line("PreToolUse", "succeeded")
);
assert_eq!(summarise_tamper(&log), (2, 0));
}
#[test]
fn later_heal_supersedes_earlier_failure() {
let log = format!(
"{}\n{}\n",
tamper_line("Stop", "failed"),
tamper_line("Stop", "succeeded")
);
assert_eq!(summarise_tamper(&log), (2, 0));
}
#[test]
fn latest_unhealed_entry_is_counted_once() {
let log = format!(
"{}\n{}\n{}\n",
tamper_line("Stop", "succeeded"),
tamper_line("Stop", "failed"),
tamper_line("PreToolUse", "succeeded")
);
assert_eq!(summarise_tamper(&log), (3, 1));
}
#[test]
fn relay_wiring_records_are_not_hook_tampering() {
let pending = serde_json::json!({
"tamper": {
"settings_path_hash": "sha256:state",
"hook_event": crate::core::cloud::tamper::RELAY_WIRING_HOOK_EVENT,
"detection_method": crate::core::cloud::tamper::DETECTION_RELAY_WIRING_PENDING,
"heal": { "outcome": "pending", "attempt": 0, "circuit": "closed" }
}
})
.to_string();
let log = format!("{pending}\n{}\n", tamper_line("Stop", "succeeded"));
assert_eq!(summarise_tamper(&log), (1, 0));
}
#[cfg(feature = "model-relay")]
#[test]
fn every_endpoint_state_carries_code_and_remedy() {
use crate::cli::commands::model_relay::{EndpointRow, EndpointState};
use crate::hooks::cline_providers::UncoveredReason;
let states = [
EndpointState::Active,
EndpointState::NextStart,
EndpointState::Unwired,
EndpointState::Down,
EndpointState::Foreign,
EndpointState::Verdict {
code: crate::error::ERR_MODEL_RELAY_PREFLIGHT_FAILED,
detail: "no round trip".into(),
},
EndpointState::Verdict {
code: crate::error::ERR_MODEL_RELAY_ENDPOINT_PORTS,
detail: "port 7601 is taken".into(),
},
EndpointState::Verdict {
code: crate::error::ERR_MODEL_RELAY_MISCONFIGURED,
detail: "no traffic".into(),
},
EndpointState::Uncovered(UncoveredReason::SignedHost),
EndpointState::Uncovered(UncoveredReason::DefaultUnknown),
EndpointState::Uncovered(UncoveredReason::NextBundleOnly),
EndpointState::Uncovered(UncoveredReason::ManagedOverride),
EndpointState::StateFile("too large".into()),
];
let rows: Vec<EndpointRow> = states
.into_iter()
.map(|state| EndpointRow {
agent: "cline",
key: "cline:gs:shared:geminiBaseUrl".into(),
label: "gemini (geminiBaseUrl)".into(),
file: None,
port: Some(7601),
state,
})
.collect();
let mut report = Report::new();
assert!(check_provider_endpoints(&rows, &mut report));
let checks: Vec<_> = report.checks().to_vec();
assert_eq!(checks.len(), rows.len());
for check in &checks {
assert_eq!(check.validate(), None, "{check:?}");
for text in std::iter::once(&check.headline)
.chain(&check.detail)
.chain(&check.remedy)
{
assert!(!text.contains(" "), "a run of spaces in {text:?}");
}
if check.state != State::Ok {
let remedy = check.remedy.as_deref().unwrap_or_default().to_lowercase();
assert!(
!remedy.contains("set the base url") && !remedy.contains("in cline's settings"),
"a remedy must never ask for wiring by hand: {remedy}"
);
}
}
assert_eq!(checks[0].state, State::Ok);
assert_eq!(checks[1].state, State::Pending);
assert!(
checks.iter().filter(|c| c.state == State::Ok).count() == 1,
"only a proven slot is green"
);
}
#[test]
fn malformed_tamper_lines_are_skipped() {
let log = format!("not json\n\n{}\n", tamper_line("Stop", "succeeded"));
assert_eq!(summarise_tamper(&log), (1, 0));
}
fn installed_agent(dir: &std::path::Path, liveness: LivenessReport) -> DetectedAgent {
let entry = || {
serde_json::json!({
"matcher": "",
"_openlatch": { "v": 1, "id": "x" },
"hooks": [{ "type": "command", "command": "\"openlatch-hook\" --event x" }]
})
};
std::fs::write(
dir.join("settings.json"),
serde_json::json!({
"hooks": {
"PreToolUse": [entry()],
"UserPromptSubmit": [entry()],
"Stop": [entry()],
}
})
.to_string(),
)
.expect("write settings.json");
DetectedAgent {
kind: AgentKind::ClaudeCode,
binding: Arc::new(FakeBinding {
agent_type: "cursor",
display_name: "Cursor",
config_dir: dir.to_path_buf(),
liveness,
..Default::default()
}),
}
}
fn hooks_checks(liveness: LivenessReport) -> Vec<Check> {
let dir = tempfile::tempdir().expect("temp dir");
let agents = vec![installed_agent(dir.path(), liveness)];
let mut report = Report::new();
check_hooks(
dir.path(),
&agents,
None,
&config::Config::defaults(),
&dead_probe(),
&mut report,
);
report.section_checks(Section::Hooks).cloned().collect()
}
#[test]
fn liveness_none_pushes_no_check() {
let silent = hooks_checks(LivenessReport {
armed: None,
detail: None,
remedy: None,
code: None,
});
let armed = hooks_checks(LivenessReport {
armed: Some(true),
detail: Some("policy bundle is enforcing".into()),
remedy: None,
code: None,
});
assert_eq!(
armed.len(),
silent.len() + 1,
"a proven-armed binding adds exactly one check, so `None` added none"
);
assert!(
!silent.iter().any(|c| c.headline == "Enforced"),
"`None` must not be rendered as a proven `true`: {:?}",
silent.iter().map(|c| &c.headline).collect::<Vec<_>>()
);
let enforced = armed
.iter()
.find(|c| c.headline == "Enforced")
.expect("the extra check is the Enforced one");
assert_eq!(enforced.state, State::Ok);
assert_eq!(enforced.agent, Some("cursor"));
assert_eq!(
enforced.detail,
vec!["policy bundle is enforcing".to_string()]
);
}
#[test]
fn liveness_some_false_is_failed_and_carries_its_code() {
let checks = hooks_checks(LivenessReport {
armed: Some(false),
detail: Some("the agent has not trusted this project".into()),
remedy: Some("Trust this project in the agent, then re-run `openlatch doctor`.".into()),
code: Some(ERR_HOOK_CONFLICT),
});
let monitored = checks
.iter()
.find(|c| c.headline.starts_with("Monitored"))
.expect("a Some(false) liveness renders a Monitored check");
assert_eq!(monitored.state, State::Failed);
assert_eq!(monitored.code, Some(ERR_HOOK_CONFLICT));
assert_eq!(
monitored.remedy.as_deref(),
Some("Trust this project in the agent, then re-run `openlatch doctor`.")
);
assert_eq!(
monitored.detail,
vec!["the agent has not trusted this project".to_string()]
);
assert_eq!(monitored.agent, Some("cursor"));
assert!(
monitored.validate().is_none(),
"a failed check must satisfy the code+remedy contract"
);
}
#[test]
#[cfg(feature = "model-relay")]
fn model_relay_check_skips_an_agent_without_a_request_plane() {
let agents = vec![DetectedAgent {
kind: AgentKind::ClaudeCode,
binding: Arc::new(FakeBinding {
agent_type: "cursor",
..Default::default()
}),
}];
let mut report = Report::new();
check_model_relay(
&config::Config::defaults(),
&agents,
&dead_probe(),
&mut report,
);
let checks: Vec<&Check> = report.section_checks(Section::ModelRelay).collect();
assert!(
!checks.iter().any(|c| c.agent == Some("cursor")),
"no per-agent Model relay check may be pushed for an agent with no request plane: {:?}",
checks.iter().map(|c| &c.headline).collect::<Vec<_>>()
);
assert_eq!(
checks.len(),
1,
"the section still carries exactly one check — an empty section trips the ERR_BUG net"
);
assert_eq!(
checks[0].headline,
"Wiring state unknown — no agent config to read"
);
assert_eq!(checks[0].state, State::Unknown(Section::Environment));
assert_eq!(checks[0].agent, None, "the fallback belongs to no agent");
}
#[test]
#[cfg(feature = "model-relay")]
fn model_relay_check_reads_the_toml_convention() {
use crate::hooks::binding::{EndpointConvention, ModelRelayWiring};
use crate::model_relay::wire_format::WireFormat;
fn codex_agent(dir: &std::path::Path) -> DetectedAgent {
DetectedAgent {
kind: AgentKind::ClaudeCode,
binding: Arc::new(FakeBinding {
agent_type: "codex-cli",
display_name: "Codex CLI",
config_dir: dir.to_path_buf(),
model_relay_wiring: Some(ModelRelayWiring {
wire_format: WireFormat::OpenAiResponses,
endpoint: EndpointConvention::TomlProvider {
provider_name: "openlatch",
wire_api: "responses",
},
install_id_header: "x-openlatch-install-id",
}),
..Default::default()
}),
}
}
let tmp = tempfile::tempdir().expect("tempdir");
let config_toml = tmp.path().join("config.toml");
let cfg = config::Config::defaults();
std::fs::write(
&config_toml,
"model_provider = \"openlatch\"\n\n\
[model_providers.openlatch]\n\
name = \"OpenLatch model_relay\"\n\
base_url = \"http://127.0.0.1:7600/v1\"\n",
)
.expect("seed config.toml");
let mut report = Report::new();
check_model_relay(&cfg, &[codex_agent(tmp.path())], &dead_probe(), &mut report);
let checks: Vec<&Check> = report.section_checks(Section::ModelRelay).collect();
assert_eq!(checks.len(), 1, "one row for the one agent with a plane");
assert_eq!(checks[0].agent, Some("codex-cli"));
assert!(
checks[0].headline.contains("127.0.0.1:7600"),
"the row must name what the agent is wired to, read through the TOML \
convention: {:?}",
checks[0].headline
);
std::fs::write(&config_toml, "model = \"gpt-5-codex\"\n").expect("rewrite");
let mut report = Report::new();
check_model_relay(&cfg, &[codex_agent(tmp.path())], &dead_probe(), &mut report);
let checks: Vec<&Check> = report.section_checks(Section::ModelRelay).collect();
assert_eq!(checks.len(), 1);
let row = checks[0];
assert_eq!(row.agent, Some("codex-cli"));
assert_ne!(
row.state,
State::Ok,
"an absent request plane is never green"
);
let remedy = row.remedy.clone().unwrap_or_default();
let detail = row.detail.join(" ");
assert!(
!remedy.is_empty(),
"a non-green Model relay row must carry a remedy"
);
assert!(
!remedy.contains("api.anthropic.com"),
"a Codex row must not send the user at Anthropic's host: {remedy}"
);
assert!(
!remedy.contains("ANTHROPIC_BASE_URL") && !detail.contains("ANTHROPIC_BASE_URL"),
"Codex has no such variable — its pointer is `model_provider`: {detail} / {remedy}"
);
assert!(
row.validate().is_none(),
"the row must satisfy the code+remedy contract"
);
}
#[test]
fn a_section_inherits_the_verdict_of_the_chain_it_waits_on() {
let mut report = Report::new();
report.push(
Check::failed(Section::Daemon, "not running")
.code(ERR_DAEMON_START_FAILED)
.remedy("Run `openlatch start`."),
);
report.push(Check::unknown(Section::Cloud, Section::Daemon));
report.push(Check::unknown(Section::Policy, Section::Cloud));
assert_eq!(blockers_of(&report, Section::Cloud), vec![Section::Daemon]);
let chain = blockers_of(&report, Section::Policy);
assert!(
chain.contains(&Section::Cloud) && chain.contains(&Section::Daemon),
"the walk must reach past the first hop: {chain:?}"
);
assert!(blockers_of(&report, Section::Daemon).is_empty());
}
#[test]
fn a_deferral_cycle_terminates() {
let mut report = Report::new();
report.push(Check::unknown(Section::Cloud, Section::Policy));
report.push(Check::unknown(Section::Policy, Section::Cloud));
let chain = blockers_of(&report, Section::Cloud);
assert_eq!(
chain,
vec![Section::Policy],
"self is never its own blocker"
);
}
#[test]
fn a_section_verdict_is_carried_inside_the_body_not_after_it() {
let mut one = Report::new();
one.push(
Check::off(Section::Persistence, "Disabled at your request")
.code(ERR_NO_SUPERVISOR)
.remedy("`openlatch system supervision enable`"),
);
for other in Section::ALL {
if other != Section::Persistence {
one.push(Check::not_applicable(other, "not part of this view"));
}
}
let doc = merge_verdict(
serde_json::json!({"mode": "active", "installed": true}),
section_verdict_value(&one, Section::Persistence),
);
assert_eq!(doc["mode"], "active");
assert_eq!(doc["installed"], true);
let verdict = &doc["verdict"];
assert_eq!(verdict["section"], Section::Persistence.key());
assert_eq!(verdict["state"], State::Off.key());
assert!(verdict["checks"].is_array(), "verdict carries its checks");
assert_eq!(verdict["exit_code"], one.exit_code());
let rendered = serde_json::to_string(&doc).expect("serializes");
let mut stream =
serde_json::Deserializer::from_str(&rendered).into_iter::<serde_json::Value>();
assert!(stream.next().is_some(), "one document parses");
assert!(
stream.next().is_none(),
"a second document would be the bug this test exists for"
);
}
#[test]
fn two_documents_back_to_back_are_not_parseable() {
let concatenated = format!(
"{}
{}",
serde_json::to_string_pretty(&serde_json::json!({"mode": "active"})).unwrap(),
serde_json::to_string_pretty(&serde_json::json!({"section": "persistence"})).unwrap(),
);
let err = serde_json::from_str::<serde_json::Value>(&concatenated)
.expect_err("two documents must not parse as one");
assert!(
err.to_string().contains("trailing characters"),
"unexpected parse error: {err}"
);
}
#[test]
fn a_non_object_body_is_nested_rather_than_dropped() {
let doc = merge_verdict(
serde_json::json!(["one", "two"]),
serde_json::json!({"section": "inventory"}),
);
assert_eq!(doc["body"], serde_json::json!(["one", "two"]));
assert_eq!(doc["verdict"]["section"], "inventory");
}
#[test]
fn a_disabled_subsystem_is_never_a_pass() {
let mut report = Report::new();
report.push(
Check::off(Section::ModelRelay, "Disabled in config")
.code(crate::error::ERR_MODEL_RELAY_NOT_RUNNING)
.remedy("`openlatch system model-relay enable`"),
);
report.push(
Check::off(Section::Persistence, "Disabled at your request")
.code(ERR_NO_SUPERVISOR)
.remedy("`openlatch system supervision enable`"),
);
report.push(
Check::off(Section::Policy, "Disabled in config")
.code(ERR_POLICY_DISABLED)
.remedy("remove [policy] enabled = false"),
);
for section in [Section::ModelRelay, Section::Persistence, Section::Policy] {
assert_eq!(
report.section_state(section),
State::Off,
"{} must warn when switched off, never pass",
section.title()
);
}
assert_eq!(report.counts().failed, 0);
assert_eq!(
report.exit_code(),
crate::cli::report::EXIT_DEGRADED,
"warnings must not exit 0"
);
}
#[test]
fn telemetry_opt_out_is_neutral_not_a_warning() {
let mut report = Report::new();
report.push(Check::not_applicable(
Section::Telemetry,
"Disabled by user (opt-out honoured)",
));
assert_eq!(
report.section_state(Section::Telemetry),
State::NotApplicable
);
assert_eq!(report.counts().warned, 0);
}
#[test]
fn cloud_has_no_off_state() {
let source = include_str!("doctor.rs");
let cloud_fn = source
.split("fn check_cloud(")
.nth(1)
.expect("check_cloud must exist")
.split("\n// ---")
.next()
.unwrap_or_default();
assert!(
!cloud_fn.contains("Check::off("),
"Cloud must never report Off — see AC-CLOUD-01"
);
}
#[test]
fn a_latched_auth_error_fails_the_cloud_section_not_green() {
use crate::cli::report::State;
let cfg = config::Config::defaults();
let probe = DaemonProbe {
alive: true,
reachable: true,
health: None,
metrics: Some(serde_json::json!({
"cloud_status": "auth_error",
"cloud_api_url": "https://app.openlatch.ai",
"egress_status": "ok",
})),
egress: Some(serde_json::json!({})),
uptime_secs: Some(60),
};
let mut report = Report::new();
check_cloud(&cfg, &probe, &mut report);
let check = report
.section_checks(Section::Cloud)
.next()
.expect("Cloud is always reported once the daemon answers");
assert_eq!(
check.state,
State::Failed,
"a latched auth_error must fail the section, never pass: {check:?}"
);
assert_eq!(check.code, Some(ERR_CLOUD_AUTH_FAILED));
assert_eq!(
report.exit_code(),
1,
"a failed section must not exit 0 — this is the number status and \
doctor's process exit actually carries via record_verdict()"
);
}
#[test]
fn a_route_written_but_not_yet_loaded_is_degraded_not_green() {
use crate::cli::report::State;
let mut cfg = config::Config::defaults();
cfg.egress.url = Some("http://10.0.0.1:1254".into());
cfg.egress.source = Some(crate::egress::ProxySource::Manual);
let probe = DaemonProbe {
alive: true,
reachable: true,
health: None,
metrics: Some(serde_json::json!({
"egress_status": "ok",
"proxy_in_use": false,
})),
egress: Some(serde_json::json!({})),
uptime_secs: Some(60),
};
let mut report = Report::new();
check_connection(&cfg, &probe, &mut report);
let check = report
.section_checks(Section::Connection)
.next()
.expect("Connection is always reported (P6)");
assert_eq!(check.state, State::Degraded, "not a pass: {check:?}");
assert_eq!(check.code, Some(ERR_CONFIG_NOT_APPLIED));
assert!(
check.headline.contains("direct"),
"the headline is the route traffic takes now: {}",
check.headline
);
assert!(
check.detail.iter().any(|d| d.contains("10.0.0.1:1254")),
"and the configured route is still named, as not yet in use: {:?}",
check.detail
);
assert!(
check
.remedy
.as_deref()
.unwrap_or_default()
.contains("openlatch restart"),
"with the one command that closes the gap: {:?}",
check.remedy
);
}
#[test]
fn a_route_the_daemon_already_loaded_is_green() {
use crate::cli::report::State;
let mut cfg = config::Config::defaults();
cfg.egress.url = Some("http://10.0.0.1:1254".into());
let probe = DaemonProbe {
alive: true,
reachable: true,
health: None,
metrics: Some(serde_json::json!({ "egress_status": "ok" })),
egress: Some(serde_json::json!({ "proxy_url": "http://10.0.0.1:1254" })),
uptime_secs: Some(60),
};
let mut report = Report::new();
check_connection(&cfg, &probe, &mut report);
let check = report.section_checks(Section::Connection).next().unwrap();
assert_eq!(check.state, State::Ok);
assert!(
check.detail.is_empty(),
"no aside to add: {:?}",
check.detail
);
}
fn run_check_hooks(agents: &[DetectedAgent], ol_dir: &std::path::Path) -> Report {
let mut report = Report::new();
check_hooks(
ol_dir,
agents,
None,
&config::Config::defaults(),
&dead_probe(),
&mut report,
);
report
}
#[test]
fn check_hooks_skips_non_installable_agents() {
let ol = tempfile::tempdir().expect("temp dir");
let absent = tempfile::tempdir().expect("temp dir");
let directory = tempfile::tempdir().expect("temp dir");
std::fs::create_dir_all(directory.path().join("settings.json")).expect("a hook DIRECTORY");
let skipped = vec![
non_installable_agent("cline", absent.path()),
non_installable_agent("cline-dir", directory.path()),
];
assert!(
!skipped[0].settings_path().exists() && skipped[1].settings_path().is_dir(),
"the fixture must carry one absent path and one directory, or neither arm is reached"
);
let report = run_check_hooks(&skipped, ol.path());
let stamped: Vec<&Check> = report
.section_checks(Section::Hooks)
.filter(|c| c.agent.is_some())
.collect();
assert!(
stamped.is_empty(),
"no per-agent Hooks finding may be raised for an agent this build \
writes nothing into: {:?}",
stamped.iter().map(|c| &c.headline).collect::<Vec<_>>()
);
let armed: Vec<DetectedAgent> = skipped
.iter()
.map(|a| DetectedAgent {
kind: a.kind,
binding: Arc::new(FakeBinding {
agent_type: a.agent_type(),
display_name: "Cline",
config_dir: a.config_dir(),
installable: true,
..Default::default()
}),
})
.collect();
let control = run_check_hooks(&armed, ol.path());
let codes: Vec<Option<&str>> = control
.section_checks(Section::Hooks)
.filter(|c| c.agent.is_some())
.map(|c| c.code)
.collect();
assert_eq!(
codes,
vec![Some(ERR_HOOK_WRITE_FAILED), Some(ERR_HOOK_MALFORMED_JSONC)],
"both arms are live for an installable agent at these very paths — \
the guard is the only thing keeping them quiet above"
);
}
#[test]
fn hooks_section_is_not_empty_when_cline_is_the_only_agent() {
let ol = tempfile::tempdir().expect("temp dir");
let cline = tempfile::tempdir().expect("temp dir");
let report = run_check_hooks(&[non_installable_agent("cline", cline.path())], ol.path());
let checks: Vec<&Check> = report.section_checks(Section::Hooks).collect();
assert_eq!(
checks.len(),
1,
"exactly one check — the section is neither empty nor doubled: {:?}",
checks.iter().map(|c| &c.headline).collect::<Vec<_>>()
);
assert_eq!(
checks[0].state,
State::Unknown(Section::Environment),
"it points at the section that actually carries this agent's answer"
);
assert_eq!(
checks[0].agent, None,
"it is a statement about the section, not a finding against an agent"
);
let remedy = checks[0].remedy.as_deref().unwrap_or_default();
assert!(
!remedy.contains("Fix Environment first"),
"the generated remedy is untrue here — Environment is green on a \
host whose only agent is non-installable; got: {remedy}"
);
assert!(
!remedy.is_empty() && checks[0].code.is_some(),
"State::Unknown is a warning, so it still owes a code and a remedy"
);
let installed = tempfile::tempdir().expect("temp dir");
let mixed = vec![
installed_agent(
installed.path(),
LivenessReport {
armed: None,
detail: None,
remedy: None,
code: None,
},
),
non_installable_agent("cline", cline.path()),
];
let mixed_report = run_check_hooks(&mixed, ol.path());
assert!(
!mixed_report
.section_checks(Section::Hooks)
.any(|c| matches!(c.state, State::Unknown(_))),
"the loop pushed a real result, so nothing may be added after it: {:?}",
mixed_report
.section_checks(Section::Hooks)
.map(|c| &c.headline)
.collect::<Vec<_>>()
);
}
#[test]
fn doctor_reports_degraded_not_failed_on_a_cline_only_host() {
let ol = tempfile::tempdir().expect("temp dir");
let cline = tempfile::tempdir().expect("temp dir");
let mut report = Report::new();
for section in Section::ALL {
if section != Section::Hooks {
report.push(Check::ok(section, "fine"));
}
}
check_hooks(
ol.path(),
&[non_installable_agent("cline", cline.path())],
None,
&config::Config::defaults(),
&dead_probe(),
&mut report,
);
assert!(
report.validate().is_empty(),
"every section must be reported — the empty-Hooks failure is the whole \
reason the unknown is pushed: {:?}",
report.validate()
);
assert_eq!(
report.exit_code(),
crate::cli::report::EXIT_DEGRADED,
"a host whose only agent cannot be enforced is degraded, not healthy \
and not broken"
);
}
use crate::hooks::cline::{
cline_isolated, ClineSeam, ASSETS_DIR_ENV, DATA_DIR_ENV, STORE_DIR_ENV,
};
struct ClineHost {
_seam: ClineSeam,
_home_lock: std::sync::MutexGuard<'static, ()>,
#[cfg_attr(not(unix), allow(dead_code))]
root: tempfile::TempDir,
}
impl ClineHost {
fn with_store() -> Self {
let home_lock = crate::hooks::claude_code::CONFIG_DIR_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let root = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(root.path().join("home")).expect("home");
std::fs::create_dir_all(root.path().join("store")).expect("store");
let seam = cline_isolated([
("HOME", Some(root.path().join("home").into_os_string())),
(
STORE_DIR_ENV,
Some(root.path().join("store").into_os_string()),
),
(
DATA_DIR_ENV,
Some(root.path().join("data").into_os_string()),
),
(
ASSETS_DIR_ENV,
Some(root.path().join("assets").into_os_string()),
),
]);
Self {
_seam: seam,
_home_lock: home_lock,
root,
}
}
}
#[cfg_attr(not(unix), allow(dead_code))]
impl ClineHost {
fn home(&self) -> std::path::PathBuf {
self.root.path().join("home")
}
fn store(&self) -> std::path::PathBuf {
self.root.path().join("store")
}
fn settings(&self) -> std::path::PathBuf {
self.root.path().join("data").join("settings")
}
fn install_extension(&self) {
std::fs::create_dir_all(
self.home()
.join(".cursor")
.join("extensions")
.join("saoudrizwan.claude-dev-3.0.0"),
)
.expect("extension");
}
fn agents(&self) -> Vec<DetectedAgent> {
vec![non_installable_agent("cline", &self.store())]
}
}
#[test]
fn cline_attestation_is_silent_when_no_cline_is_detected() {
let _host = ClineHost::with_store();
let mut report = Report::new();
let attestation = check_cline_surface(&[], &mut report);
assert!(
attestation.is_none(),
"nothing detected, so there is nothing to attest"
);
assert_eq!(
report.section_checks(Section::Environment).count(),
0,
"a row about an agent this host does not have is noise"
);
}
#[cfg(unix)]
#[test]
fn cline_attestation_warns_when_the_store_has_no_extension() {
let host = ClineHost::with_store();
let mut report = Report::new();
let attestation =
check_cline_surface(&host.agents(), &mut report).expect("a detected Cline attests");
assert!(
attestation.store_without_extension(),
"the fixture failed to produce AC-4's state, so this case proves nothing"
);
let checks: Vec<&Check> = report.section_checks(Section::Environment).collect();
assert_eq!(checks.len(), 1, "one attestation, one row");
let check = checks[0];
assert_ne!(check.state, State::Ok, "never green (AC-4)");
assert!(
check.state.requires_remedy(),
"a state nobody is asked to act on is not a warning"
);
assert!(
check.headline.contains("no Cline-lineage extension"),
"the warning must name the state, not just count absences: {}",
check.headline
);
assert_eq!(check.code, Some(ERR_CLINE_SURFACE_ABSENT));
assert!(
check
.remedy
.as_deref()
.is_some_and(|remedy| remedy.contains("editor root")),
"the remedy must be followable — report the fork's editor root: {:?}",
check.remedy
);
assert!(
check.validate().is_none(),
"a non-green check must carry both a code and a remedy: {:?}",
check.validate()
);
assert_eq!(check.agent, Some("cline"), "the row names its agent");
let store = crate::core::path_compat::display_path(&host.store());
assert!(
check.detail.iter().any(|line| line.contains(&store)),
"the human rendering must carry the resolved store root: {:?}",
check.detail
);
}
#[cfg(unix)]
#[test]
fn cline_attestation_is_not_green_when_a_surface_could_not_be_read() {
let host = ClineHost::with_store();
host.install_extension();
std::fs::create_dir_all(host.settings()).expect("settings dir");
let registry = host.settings().join("cline_mcp_settings.json");
std::fs::write(®istry, r#"{"mcpServers": "#).expect("a truncated registry");
assert!(
registry.is_file(),
"the registry has to BE there, or this case is about a missing file instead"
);
let mut report = Report::new();
let attestation =
check_cline_surface(&host.agents(), &mut report).expect("a detected Cline attests");
assert_eq!(
attestation.state_of_surface(Surface::McpSettings),
Some(SurfaceState::Undetermined),
"the fixture failed to produce an unreadable surface, so this case proves nothing"
);
assert_eq!(
attestation.state_of_surface(Surface::Extension),
Some(SurfaceState::Present),
"and the AC-4 warning is NOT what is being asserted below"
);
let checks: Vec<&Check> = report.section_checks(Section::Environment).collect();
assert_eq!(checks.len(), 1, "one attestation, one row");
let check = checks[0];
assert_ne!(
check.state,
State::Ok,
"green over a surface we could not read, beside a detail line that says so, is \
the machine rendering and the human one disagreeing inside one run: {:?}",
check.detail
);
assert_eq!(check.code, Some(ERR_CLINE_SURFACE_UNDETERMINED));
assert!(
check.validate().is_none(),
"a non-green check must carry both a code and a remedy: {:?}",
check.validate()
);
assert!(
check
.detail
.iter()
.chain(std::iter::once(&check.headline))
.any(|line| line.contains("servers: undetermined")),
"and the human rendering still names the list it could not read: {:?}",
check.detail
);
}
#[cfg(unix)]
#[test]
fn cline_attestation_is_green_when_the_extension_is_there_too() {
let host = ClineHost::with_store();
host.install_extension();
let mut report = Report::new();
let attestation =
check_cline_surface(&host.agents(), &mut report).expect("a detected Cline attests");
assert_eq!(
attestation.state_of_surface(Surface::Extension),
Some(SurfaceState::Present),
"the fixture failed to install an extension, so green here would prove nothing"
);
let checks: Vec<&Check> = report.section_checks(Section::Environment).collect();
assert_eq!(checks.len(), 1, "one attestation, one row");
let check = checks[0];
assert_eq!(check.state, State::Ok);
let store = crate::core::path_compat::display_path(&host.store());
assert!(
check.headline.contains(&store),
"the green headline carries the store root: {}",
check.headline
);
let rendered: Vec<&str> = std::iter::once(check.headline.as_str())
.chain(check.detail.iter().map(String::as_str))
.collect();
for line in attestation.human_lines() {
assert!(
rendered.contains(&line.as_str()),
"the attestation says `{line}`, and the check a human reads does not: {rendered:?}"
);
}
}
#[cfg(unix)]
#[test]
fn json_document_carries_the_attestation_beside_the_sections() {
let host = ClineHost::with_store();
let mut report = Report::new();
let cline = check_cline_surface(&host.agents(), &mut report);
assert!(cline.is_some(), "the fixture must produce an attestation");
let document = json_document(&DoctorReport {
report,
daemon_alive: false,
daemon_uptime_secs: None,
port: 0,
cline,
});
assert!(
document["sections"].is_array(),
"the section report is untouched: {document}"
);
assert!(
document["agents"]["cline"]["as_of"].is_string(),
"the attestation is merged under agents.cline: {document}"
);
assert_eq!(
document["agents"]["cline"]["store_root"]["path"],
serde_json::json!(crate::core::path_compat::display_path(&host.store())),
);
}
#[test]
fn the_rollup_survives_the_attestation_being_merged_into_it() {
let host = ClineHost::with_store();
let mut report = Report::new();
report.push(crate::cli::report::Check::ok(Section::Hooks, "Enforced").agent("claude-code"));
let cline = check_cline_surface(&host.agents(), &mut report);
let document = json_document(&DoctorReport {
report,
daemon_alive: false,
daemon_uptime_secs: None,
port: 0,
cline,
});
let agents = document["agents"]
.as_object()
.unwrap_or_else(|| panic!("agents must stay an object: {document}"));
assert!(
agents["claude-code"]["sections"].is_array(),
"another agent's rollup must survive the merge: {document}"
);
assert!(
agents["cline"]["as_of"].is_string(),
"and the attestation must land beside it: {document}"
);
}
#[test]
fn json_document_carries_an_empty_agents_object_without_cline() {
let document = json_document(&DoctorReport {
report: Report::new(),
daemon_alive: false,
daemon_uptime_secs: None,
port: 0,
cline: None,
});
assert_eq!(document["agents"], serde_json::json!({}));
}
}