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::error::{
OlError, ERR_BUG, ERR_BUNDLE_FETCH_FAILED, ERR_BUNDLE_STALE, 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, LivenessReport};
#[allow(dead_code)] pub(crate) struct DoctorReport {
pub report: Report,
pub daemon_alive: bool,
pub daemon_uptime_secs: Option<u64>,
pub port: u16,
}
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 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 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 proxy discover` (or `openlatch proxy set <url>` if the source is manual)."
}
ERR_PROXY_AUTH_FAILED => {
"Re-enter the proxy credentials with `openlatch 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 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 proxy set --spn <spn>`."
}
_ => {
"Check this host can reach app.openlatch.ai, or ask IT for an egress rule; `openlatch 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 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 = "boundary")]
check_boundary(&cfg, &agents, &probe, &mut report);
#[cfg(not(feature = "boundary"))]
report.push(Check::not_applicable(
Section::Boundary,
"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, &probe, &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,
})
}
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")]
{
let resolved = crate::crash_report::current_state(ol_dir);
let label = match resolved.decided_by {
crate::crash_report::consent::DecidedBy::SentryDisabledEnv => {
"off (SENTRY_DISABLED env)"
}
crate::crash_report::consent::DecidedBy::NoBakedDsn => "off (no DSN baked)",
crate::crash_report::consent::DecidedBy::ConfigFile => {
if resolved.enabled() {
"on (config.toml)"
} else {
"off (config.toml)"
}
}
crate::crash_report::consent::DecidedBy::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 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 mut any_inspected = false;
for a in agents {
let settings_path = a.settings_path();
let settings_path = settings_path.as_path();
let agent = a.agent_type();
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;
}
any_inspected = true;
check_hook_binding(
settings_path,
&*a.binding,
daemon_token,
cfg.port,
probe.reachable,
report,
);
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),
)
}
}
}
if any_inspected {
check_fallback_activity(ol_dir, probe, report);
}
}
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 Some(uptime) = probe.uptime_secs else {
return;
};
let fallback_path = ol_dir.join("logs").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 Ok(mtime) = meta.modified() else {
return;
};
let daemon_start = std::time::SystemTime::now()
.checked_sub(std::time::Duration::from_secs(uptime))
.unwrap_or(std::time::UNIX_EPOCH);
if mtime > daemon_start {
let age_secs = mtime
.duration_since(daemon_start)
.map(|d| d.as_secs())
.unwrap_or(0);
report.push(
Check::failed(
Section::Hooks,
format!(
"Fallback log: last written {} into the daemon's uptime",
human_duration(age_secs)
),
)
.code(ERR_HOOK_CONFLICT)
.detail(
"The hook binary is spooling to fallback.jsonl while the daemon is up — it \
cannot reach its own daemon.",
)
.detail("Likely causes: token mismatch, wrong port, or stale settings.json.")
.remedy("Fix the Token/Port checks above, then run `openlatch init` to repair."),
);
} else {
report.push(Check::ok(
Section::Hooks,
"Fallback log: quiet since daemon start",
));
}
}
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 = "boundary")]
fn boundary_endpoint_name(w: &crate::hooks::binding::BoundaryWiring) -> 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 = "boundary")]
fn toml_provider_proxy_note(w: &crate::hooks::binding::BoundaryWiring) -> &'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 boundary's loopback base must not leave through the \
corporate proxy."
}
}
}
#[cfg(feature = "boundary")]
fn check_boundary(
cfg: &config::Config,
agents: &[hooks::DetectedAgent],
probe: &DaemonProbe,
report: &mut Report,
) {
use crate::cli::commands::boundary::{classify_boundary, read_agent_wiring, BoundaryState};
use crate::error::{
ERR_BOUNDARY_NOT_RUNNING, ERR_BOUNDARY_PORT_FOREIGN, ERR_BOUNDARY_PORT_IN_USE,
ERR_BOUNDARY_PREFLIGHT_FAILED,
};
let port = cfg.boundary.port;
let config_source = config::openlatch_dir().join("config.toml");
let mut pushed_any = false;
for a in agents {
let Some(w) = a.binding.boundary_wiring() else {
continue;
};
pushed_any = true;
let wiring_path =
crate::hooks::boundary_config_path(&*a.binding).unwrap_or_else(|| a.settings_path());
let wiring_path = wiring_path.as_path();
let agent = a.agent_type();
let display_name = a.binding.display_name();
let endpoint_name = boundary_endpoint_name(&w);
let upstream = cfg.boundary.upstream_for(w.wire_format);
let proxy_note = toml_provider_proxy_note(&w);
let wired = read_agent_wiring(&*a.binding);
let url = wired.as_deref().unwrap_or_default().to_string();
match classify_boundary(cfg, agent, wired.as_deref()) {
BoundaryState::Disabled => {
report.push(
Check::off(
Section::Boundary,
"Disabled in config — model calls bypass OpenLatch entirely",
)
.code(ERR_BOUNDARY_NOT_RUNNING)
.source(format!(
"{} [boundary] enabled = false",
config_source.display()
))
.detail("Nothing is captured and no policy is enforced on model traffic.")
.remedy(
"Run `openlatch boundary enable` (it offers the restart that applies it).",
)
.agent(agent),
);
}
BoundaryState::Isolated => {
report.push(
Check::ok(
Section::Boundary,
format!("Isolated instance on port {port}"),
)
.detail(format!(
"{} is machine-global and is not managed by this instance.",
wiring_path.display()
))
.agent(agent),
);
}
BoundaryState::Wired => {
report.push(
Check::ok(
Section::Boundary,
format!("Agent wired to {url} and the listener is up"),
)
.agent(agent),
);
}
BoundaryState::WiredButDown => {
report.push(
Check::failed(
Section::Boundary,
format!("Agent is wired to {url} but nothing is listening there"),
)
.code(ERR_BOUNDARY_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 boundary back up, or `openlatch stop` \
to clear the wiring and go direct.{proxy_note}"
))
.agent(agent),
);
}
BoundaryState::WiredToForeign => {
report.push(
Check::failed(
Section::Boundary,
format!("Agent is wired to {url}, held by a process that is NOT OpenLatch"),
)
.code(ERR_BOUNDARY_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),
);
}
BoundaryState::PreflightFailed(why) => {
report.push(
Check::failed(
Section::Boundary,
format!("Listener is up but its preflight failed — {why}"),
)
.code(ERR_BOUNDARY_PREFLIGHT_FAILED)
.detail(format!(
"The boundary 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),
);
}
BoundaryState::PreflightPending => {
report.push(
Check::pending(
Section::Boundary,
"Listener is up, its preflight has not finished yet",
)
.code(ERR_BOUNDARY_PREFLIGHT_FAILED)
.detail(format!(
"The boundary 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),
);
}
BoundaryState::UpUnwired => {
report.push(
Check::degraded(
Section::Boundary,
"Listener is up but the agent is not wired to it",
)
.code(ERR_BOUNDARY_NOT_RUNNING)
.detail(format!(
"The boundary 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),
);
}
BoundaryState::Down => {
let (state_check, why) = if probe.alive {
(
Check::failed(
Section::Boundary,
"Enabled in config, but no listener holds the port",
),
"The daemon is up and the boundary is not — its listener task did not bind.",
)
} else {
(
Check::unknown(Section::Boundary, Section::Daemon)
.headline("Listener down — the daemon that owns it is not running"),
"The daemon is down, so its boundary listener is too.",
)
};
report.push(
state_check
.code(ERR_BOUNDARY_NOT_RUNNING)
.detail(why)
.remedy(if probe.alive {
"Check the newest ~/.openlatch/logs/daemon.log.<date> for OL-BND-PORT, \
then `openlatch restart`."
} else {
"Run `openlatch start`."
})
.agent(agent),
);
}
BoundaryState::ForeignIdle => {
report.push(
Check::failed(
Section::Boundary,
format!("127.0.0.1:{port} is held by another process"),
)
.code(ERR_BOUNDARY_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::Boundary, 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 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 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 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 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 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 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 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 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 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 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;
}
if !probe.metric_bool("policy_has_bundle").unwrap_or(false) {
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());
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!("Bundle {revision} fresh (polled {secs}s ago)"),
));
}
None => {
report.push(
Check::degraded(
Section::Policy,
format!("Bundle {revision} is enforcing, but no poll has ever succeeded"),
)
.code(ERR_BUNDLE_STALE)
.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);
if manifest {
report.push(Check::ok(
Section::Inventory,
format!("Monitor up, {sources} source(s) tracked"),
));
} else {
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 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, probe: &DaemonProbe, 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 Some(uptime) = probe.uptime_secs else {
return;
};
let Ok(meta) = std::fs::metadata(&tamper_path) else {
return;
};
let Ok(mtime) = meta.modified() else {
return;
};
let daemon_start = std::time::SystemTime::now()
.checked_sub(std::time::Duration::from_secs(uptime))
.unwrap_or(std::time::UNIX_EPOCH);
if mtime > daemon_start {
report.push(
Check::degraded(
Section::Integrity,
"Tamper events recorded since the daemon started",
)
.code(ERR_TAMPER_DETECTED)
.detail("Something rewrote the agent's hook entries; the reconciler healed them.")
.remedy("Inspect them with `openlatch logs --tamper`."),
);
}
}
pub(crate) fn print_diagnostic_results(report: &DoctorReport, output: &OutputConfig) {
report.report.record_verdict();
if output.format == OutputFormat::Json {
output.print_json(&report.report.to_json());
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 auth login") {
Some("run 'openlatch auth login' — nothing reaches the platform without a credential")
} else if mentions("openlatch boundary enable") {
Some("run 'openlatch boundary enable' to route model calls through OpenLatch")
} else if mentions("openlatch supervision") {
Some("run 'openlatch 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::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 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 = "boundary")]
fn boundary_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_boundary(
&config::Config::defaults(),
&agents,
&dead_probe(),
&mut report,
);
let checks: Vec<&Check> = report.section_checks(Section::Boundary).collect();
assert!(
!checks.iter().any(|c| c.agent == Some("cursor")),
"no per-agent Boundary 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 = "boundary")]
fn boundary_check_reads_the_toml_convention() {
use crate::boundary::wire_format::WireFormat;
use crate::hooks::binding::{BoundaryWiring, EndpointConvention};
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(),
boundary_wiring: Some(BoundaryWiring {
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 boundary\"\n\
base_url = \"http://127.0.0.1:7600/v1\"\n",
)
.expect("seed config.toml");
let mut report = Report::new();
check_boundary(&cfg, &[codex_agent(tmp.path())], &dead_probe(), &mut report);
let checks: Vec<&Check> = report.section_checks(Section::Boundary).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_boundary(&cfg, &[codex_agent(tmp.path())], &dead_probe(), &mut report);
let checks: Vec<&Check> = report.section_checks(Section::Boundary).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 Boundary 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 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::Boundary, "Disabled in config")
.code(crate::error::ERR_BOUNDARY_NOT_RUNNING)
.remedy("`openlatch boundary enable`"),
);
report.push(
Check::off(Section::Persistence, "Disabled at your request")
.code(ERR_NO_SUPERVISOR)
.remedy("`openlatch supervision enable`"),
);
report.push(
Check::off(Section::Policy, "Disabled in config")
.code(ERR_POLICY_DISABLED)
.remedy("remove [policy] enabled = false"),
);
for section in [Section::Boundary, 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_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
);
}
}