use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::cli::commands::doctor::{print_diagnostic_results, run_all_checks, DoctorReport};
use crate::cli::commands::{doctor_restore, lifecycle};
use crate::cli::output::{OutputConfig, OutputFormat};
use crate::cli::DoctorArgs;
use crate::config;
use crate::error::OlError;
use crate::hooks;
use crate::hooks::binding::HookSurface;
use crate::hooks::DetectedAgent;
use crate::telemetry::{self, Event};
pub(crate) const JOURNAL_FILENAME: &str = "fix-journal.json";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum FixKind {
ConfigRewrite,
TokenRegenerate,
AgentIdInsert,
TelemetryReset,
PidStaleRemove,
HookReinstall,
DaemonRestart,
BinaryCopy,
SupervisionInstall,
HookTrustGrant,
RelayCaTrust,
RelayCaRotate,
RelayCaRotateSkipped,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FixAction {
pub ol_code: String,
pub kind: FixKind,
pub file: PathBuf,
pub backup: Option<PathBuf>,
pub reversible: bool,
pub applied_at: DateTime<Utc>,
pub note: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Journal {
pub run_id: String,
pub started_at: DateTime<Utc>,
pub actions: Vec<FixAction>,
}
impl Journal {
pub fn new() -> Self {
Self {
run_id: uuid::Uuid::new_v4().simple().to_string(),
started_at: Utc::now(),
actions: Vec::new(),
}
}
pub fn save(&self, ol_dir: &Path) -> Result<PathBuf, OlError> {
let path = ol_dir.join(JOURNAL_FILENAME);
let raw = serde_json::to_string_pretty(self).map_err(|e| {
OlError::new(
crate::error::ERR_DOCTOR_JOURNAL_CORRUPT,
format!("cannot serialize fix journal: {e}"),
)
})?;
std::fs::write(&path, raw).map_err(|e| {
OlError::new(
crate::error::ERR_DOCTOR_JOURNAL_CORRUPT,
format!("cannot write fix journal '{}': {e}", path.display()),
)
})?;
Ok(path)
}
pub fn load(ol_dir: &Path) -> Result<Self, OlError> {
let path = ol_dir.join(JOURNAL_FILENAME);
let raw = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(OlError::new(
crate::error::ERR_DOCTOR_RESTORE_NO_JOURNAL,
format!("no prior --fix run found at '{}'", path.display()),
)
.with_suggestion(
"Run `openlatch doctor --fix` first; --restore reverses the most recent run.",
));
}
Err(e) => {
return Err(OlError::new(
crate::error::ERR_DOCTOR_JOURNAL_CORRUPT,
format!("cannot read fix journal '{}': {e}", path.display()),
))
}
};
serde_json::from_str(&raw).map_err(|e| {
OlError::new(
crate::error::ERR_DOCTOR_JOURNAL_CORRUPT,
format!("fix journal at '{}' is malformed: {e}", path.display()),
)
.with_suggestion(
"Delete `fix-journal.json` and re-run `openlatch init` to reset state.",
)
})
}
}
impl Default for Journal {
fn default() -> Self {
Self::new()
}
}
pub fn run(_args: &DoctorArgs, output: &OutputConfig) -> Result<(), OlError> {
let started = std::time::Instant::now();
let ol_dir = config::openlatch_dir();
std::fs::create_dir_all(&ol_dir).map_err(|e| {
OlError::new(
crate::error::ERR_INVALID_CONFIG,
format!("cannot create openlatch dir '{}': {e}", ol_dir.display()),
)
})?;
crate::cli::header::print(output, &["doctor", "--fix"]);
let before = run_all_checks(output).ok();
let pre_fix = capture_pre_fix_state(&ol_dir);
let mut journal = Journal::new();
let daemon_confirmed_stopped = if pre_fix.was_running {
stop_daemon_for_fix(&pre_fix, &ol_dir)
} else {
true
};
journal.actions.extend(heal_state(&ol_dir));
journal.actions.extend(heal_binaries(&ol_dir));
journal.actions.extend(heal_hooks(&ol_dir));
journal.actions.extend(heal_supervision(&ol_dir));
#[cfg(feature = "model-relay")]
journal.actions.extend(heal_relay_ca(
&ol_dir,
&hooks::detect_agents(),
time::OffsetDateTime::now_utc(),
daemon_confirmed_stopped,
));
let mut auto_rollback_triggered = false;
let (daemon_actions, restart_failed) = heal_daemon(&ol_dir, &pre_fix, &journal.actions);
journal.actions.extend(daemon_actions);
if restart_failed {
auto_rollback_triggered = true;
tracing::error!("doctor --fix: daemon restart failed — rolling back this run's actions");
let _ = doctor_restore::restore_actions(&journal.actions, &ol_dir);
if pre_fix.was_running && !lifecycle::start_via_supervisor(pre_fix.port) {
let token = std::fs::read_to_string(ol_dir.join("daemon.token"))
.map(|s| s.trim().to_string())
.unwrap_or_default();
if !token.is_empty() {
let _ = lifecycle::spawn_daemon_background(pre_fix.port, &token);
}
}
}
let journal_path = journal.save(&ol_dir)?;
let after = run_all_checks(output)?;
let categories: Vec<&str> = {
let mut cats: Vec<&str> = Vec::new();
for a in &journal.actions {
let cat = match a.kind {
FixKind::ConfigRewrite
| FixKind::TokenRegenerate
| FixKind::AgentIdInsert
| FixKind::TelemetryReset
| FixKind::PidStaleRemove => "state",
FixKind::HookReinstall | FixKind::HookTrustGrant => "hooks",
FixKind::DaemonRestart => "daemon",
FixKind::BinaryCopy => "binary",
FixKind::SupervisionInstall => "supervision",
FixKind::RelayCaTrust | FixKind::RelayCaRotate | FixKind::RelayCaRotateSkipped => {
"relay"
}
};
if !cats.contains(&cat) {
cats.push(cat);
}
}
cats
};
let unresolved = after.unresolved();
let unfixable: Vec<&str> = unresolved.iter().map(String::as_str).collect();
let (before_pass, before_fail) = before
.as_ref()
.map(|r| (r.pass_count(), r.fail_count()))
.unwrap_or((0, 0));
telemetry::capture_global(Event::doctor_fix_run(
categories,
before_pass,
before_fail,
after.pass_count(),
after.fail_count(),
unfixable,
started.elapsed().as_millis() as u64,
auto_rollback_triggered,
));
print_fix_results(
&journal,
&journal_path,
before.as_ref(),
&after,
auto_rollback_triggered,
output,
);
if after.all_pass() && !auto_rollback_triggered {
Ok(())
} else {
std::process::exit(1);
}
}
#[derive(Debug, Clone)]
#[allow(dead_code)] pub(crate) struct PreFixState {
pub was_running: bool,
pub was_healthy: bool,
pub port: u16,
pub pid: Option<u32>,
}
pub(crate) fn capture_pre_fix_state(_ol_dir: &Path) -> PreFixState {
let port = config::Config::load(None, None, false)
.map(|c| c.port)
.unwrap_or(config::PORT_RANGE_START);
let pid = lifecycle::read_pid_file();
let was_running = pid.map(lifecycle::is_process_alive).unwrap_or(false);
let was_healthy = if was_running {
lifecycle::check_health(port)
} else {
false
};
PreFixState {
was_running,
was_healthy,
port,
pid,
}
}
fn stop_daemon_for_fix(state: &PreFixState, ol_dir: &Path) -> bool {
if lifecycle::stop_via_supervisor() {
let _ = std::fs::remove_file(ol_dir.join("daemon.pid"));
return true;
}
let Some(pid) = state.pid else {
return true;
};
let token = std::fs::read_to_string(ol_dir.join("daemon.token"))
.map(|s| s.trim().to_string())
.unwrap_or_default();
if !token.is_empty() {
let _ = lifecycle::send_shutdown_request(state.port, &token);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while std::time::Instant::now() < deadline && lifecycle::is_process_alive(pid) {
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
if lifecycle::is_process_alive(pid) {
lifecycle::force_kill(pid);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
while std::time::Instant::now() < deadline && lifecycle::is_process_alive(pid) {
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
let _ = std::fs::remove_file(ol_dir.join("daemon.pid"));
!lifecycle::is_process_alive(pid)
}
pub(crate) fn heal_daemon(
ol_dir: &Path,
pre_fix: &PreFixState,
prior_actions: &[FixAction],
) -> (Vec<FixAction>, bool) {
let mut actions = Vec::new();
let touched_critical = prior_actions.iter().any(|a| {
matches!(
a.kind,
FixKind::ConfigRewrite
| FixKind::TokenRegenerate
| FixKind::AgentIdInsert
| FixKind::HookReinstall
| FixKind::RelayCaTrust
| FixKind::RelayCaRotate
)
});
let should_restart = pre_fix.was_running || touched_critical;
if !should_restart {
return (actions, false);
}
let port = config::probe_free_port(pre_fix.port, config::PORT_RANGE_END)
.or_else(|_| config::probe_free_port(config::PORT_RANGE_START, config::PORT_RANGE_END))
.unwrap_or(pre_fix.port);
if port != pre_fix.port {
let _ = config::write_port_file(port);
}
let token = match std::fs::read_to_string(ol_dir.join("daemon.token")) {
Ok(s) if !s.trim().is_empty() => s.trim().to_string(),
_ => {
tracing::error!(
"doctor --fix: cannot restart daemon — daemon.token missing/empty after heal_state"
);
return (actions, true);
}
};
if lifecycle::start_via_supervisor(port) {
actions.push(FixAction {
ol_code: crate::error::ERR_DAEMON_START_FAILED.to_string(),
kind: FixKind::DaemonRestart,
file: PathBuf::new(),
backup: None,
reversible: false,
applied_at: Utc::now(),
note: format!("supervised daemon restarted on port {port} (/health=200)"),
});
return (actions, false);
}
let pid = match lifecycle::spawn_daemon_background(port, &token) {
Ok(pid) => pid,
Err(e) => {
tracing::error!(error = %e.message, code = e.code, "doctor --fix: daemon spawn failed");
return (actions, true);
}
};
if !lifecycle::wait_for_health(port, 3) {
tracing::error!(
pid = pid,
port = port,
"doctor --fix: daemon spawned but /health did not return 200 within 3s"
);
return (actions, true);
}
actions.push(FixAction {
ol_code: crate::error::ERR_DAEMON_START_FAILED.to_string(),
kind: FixKind::DaemonRestart,
file: PathBuf::new(),
backup: None,
reversible: false,
applied_at: Utc::now(),
note: format!("daemon restarted on port {port} (PID {pid}, /health=200)"),
});
(actions, false)
}
pub(crate) fn heal_state(ol_dir: &Path) -> Vec<FixAction> {
let mut actions = Vec::new();
let config_path = ol_dir.join("config.toml");
let config_needs_rewrite = match std::fs::read_to_string(&config_path) {
Ok(raw) => toml::from_str::<toml::Value>(&raw).is_err(),
Err(_) => true,
};
if config_needs_rewrite {
let backup = if config_path.exists() {
backup_file(&config_path).ok()
} else {
None
};
let content = config::generate_default_config_toml(config::PORT_RANGE_START);
if let Err(e) = std::fs::write(&config_path, content) {
tracing::warn!(error = %e, path = %config_path.display(), "doctor --fix: config rewrite failed");
} else {
actions.push(FixAction {
ol_code: crate::error::ERR_INVALID_CONFIG.to_string(),
kind: FixKind::ConfigRewrite,
file: config_path.clone(),
backup,
reversible: true,
applied_at: Utc::now(),
note: format!("regenerated {} from defaults", config_path.display()),
});
}
}
let token_path = ol_dir.join("daemon.token");
let token_needs_regen = match std::fs::read_to_string(&token_path) {
Ok(raw) => raw.trim().is_empty(),
Err(_) => true,
};
if token_needs_regen {
let backup = if token_path.exists() {
backup_file(&token_path).ok()
} else {
None
};
if token_path.exists() {
let _ = std::fs::remove_file(&token_path);
}
match config::ensure_token(ol_dir) {
Ok(_) => {
actions.push(FixAction {
ol_code: crate::error::ERR_INVALID_CONFIG.to_string(),
kind: FixKind::TokenRegenerate,
file: token_path.clone(),
backup,
reversible: true,
applied_at: Utc::now(),
note: format!("regenerated {} (mode 0600 on Unix)", token_path.display()),
});
}
Err(e) => {
tracing::warn!(error = %e.message, code = e.code, "doctor --fix: token regenerate failed");
}
}
}
if config_path.exists() {
let needs_insert = std::fs::read_to_string(&config_path)
.map(|raw| !raw.contains("agent_id"))
.unwrap_or(false);
if needs_insert {
let backup = backup_file(&config_path).ok();
match config::ensure_agent_id(&config_path) {
Ok(id) => {
actions.push(FixAction {
ol_code: crate::error::ERR_INVALID_CONFIG.to_string(),
kind: FixKind::AgentIdInsert,
file: config_path.clone(),
backup,
reversible: true,
applied_at: Utc::now(),
note: format!("inserted agent_id={id} into [daemon] section"),
});
}
Err(e) => {
tracing::warn!(error = %e.message, code = e.code, "doctor --fix: agent_id insert failed");
}
}
}
}
let telem_path = ol_dir.join("telemetry.json");
if telem_path.exists() {
let valid = std::fs::read_to_string(&telem_path)
.ok()
.and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
.is_some();
if !valid {
let backup = backup_file(&telem_path).ok();
let reset = serde_json::json!({
"enabled": false,
"schema_version": 1,
"notice_shown_at": null,
});
match serde_json::to_string_pretty(&reset)
.map_err(std::io::Error::other)
.and_then(|s| std::fs::write(&telem_path, s))
{
Ok(_) => {
actions.push(FixAction {
ol_code: crate::error::ERR_TELEMETRY_CONFIG_CORRUPT.to_string(),
kind: FixKind::TelemetryReset,
file: telem_path.clone(),
backup,
reversible: true,
applied_at: Utc::now(),
note: format!(
"reset {} to enabled=false (re-consent required via init)",
telem_path.display()
),
});
}
Err(e) => {
tracing::warn!(error = %e, "doctor --fix: telemetry reset failed");
}
}
}
}
let pid_path = ol_dir.join("daemon.pid");
if pid_path.exists() {
let pid_alive = std::fs::read_to_string(&pid_path)
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
.map(lifecycle::is_process_alive)
.unwrap_or(false);
if !pid_alive {
if let Err(e) = std::fs::remove_file(&pid_path) {
tracing::warn!(error = %e, path = %pid_path.display(), "doctor --fix: stale PID removal failed");
} else {
actions.push(FixAction {
ol_code: crate::error::ERR_ALREADY_RUNNING.to_string(),
kind: FixKind::PidStaleRemove,
file: pid_path.clone(),
backup: None,
reversible: false,
applied_at: Utc::now(),
note: format!("removed stale {} (process not alive)", pid_path.display()),
});
}
}
}
actions
}
pub(crate) fn heal_hooks(ol_dir: &Path) -> Vec<FixAction> {
heal_hooks_for(&hooks::detect_agents(), ol_dir)
}
fn heal_hooks_for(detected: &[DetectedAgent], ol_dir: &Path) -> Vec<FixAction> {
let mut actions = Vec::new();
if detected.is_empty() {
return actions;
}
let token_path = ol_dir.join("daemon.token");
let token = match std::fs::read_to_string(&token_path) {
Ok(s) if !s.trim().is_empty() => s.trim().to_string(),
_ => {
tracing::warn!(
path = %token_path.display(),
"doctor --fix: skipping hook reinstall — daemon.token missing/empty after heal_state"
);
return actions;
}
};
let port = config::Config::load(None, None, false)
.map(|c| c.port)
.unwrap_or(config::PORT_RANGE_START);
if let Err(e) = hooks::staging::stage_hook_binary(ol_dir) {
tracing::warn!(
error = %e.message,
code = e.code,
"doctor --fix: cannot stage hook binary — leaving hooks alone rather than rewriting a dead command"
);
return actions;
}
for agent in detected {
if !agent.installable() {
continue;
}
let settings_path = match agent.hook_surface() {
HookSurface::ConfigFile(path) => path,
HookSurface::Directory(hooks_dir) => {
if let Some(action) = heal_hook_files(agent, &hooks_dir, ol_dir, port, &token) {
actions.push(action);
}
continue;
}
};
let needs_reinstall = match hooks::health::inspect_file(&settings_path, &*agent.binding) {
Ok(health) => health.needs_reinstall(),
Err(_) => true, };
if !needs_reinstall {
match agent
.binding
.trust_own_hooks(hooks::binding::TrustOccasion::Install)
{
Ok(0) => {}
Ok(granted) => actions.push(FixAction {
ol_code: crate::error::ERR_HOOK_NOT_ARMED.to_string(),
kind: FixKind::HookTrustGrant,
file: settings_path.clone(),
backup: None,
reversible: false,
applied_at: Utc::now(),
note: format!(
"trusted {granted} OpenLatch hook(s) in {}",
agent.binding.display_name()
),
}),
Err(e) => tracing::warn!(
error = %e.message,
code = e.code,
"doctor --fix: could not grant the agent's trust to our hooks"
),
}
continue;
}
let backup = if settings_path.exists() {
backup_file(&settings_path).ok()
} else {
None
};
match hooks::install_hooks(&*agent.binding, port, &token) {
Ok(_) => {
actions.push(FixAction {
ol_code: crate::error::ERR_HOOK_WRITE_FAILED.to_string(),
kind: FixKind::HookReinstall,
file: settings_path.clone(),
backup,
reversible: true,
applied_at: Utc::now(),
note: format!("reinstalled hooks in {}", settings_path.display()),
});
}
Err(e) => {
tracing::warn!(
error = %e.message,
code = e.code,
"doctor --fix: hook reinstall failed"
);
}
}
}
actions
}
fn heal_hook_files(
agent: &DetectedAgent,
hooks_dir: &Path,
ol_dir: &Path,
port: u16,
token: &str,
) -> Option<FixAction> {
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(_) => Default::default(),
};
let health = hooks::health::inspect_directory(hooks_dir, &*agent.binding, &descriptors);
if !health.needs_reinstall() {
return None;
}
match hooks::install_hooks(&*agent.binding, port, token) {
Ok(result) => {
let incomplete = !result.left_alone.is_empty();
if incomplete {
tracing::warn!(
rewrote = result.entries.len(),
left_alone = ?result.left_alone,
dir = %hooks_dir.display(),
"doctor --fix: hook file reinstall INCOMPLETE — a file we do not own holds \
a hook name; its drift is unresolved"
);
}
Some(FixAction {
ol_code: crate::error::ERR_HOOK_WRITE_FAILED.to_string(),
kind: FixKind::HookReinstall,
file: hooks_dir.to_path_buf(),
backup: None,
reversible: false,
applied_at: Utc::now(),
note: if incomplete {
format!(
"INCOMPLETE: rewrote {} hook script(s) in {}, and left {} alone \
because we did not write them — their drift is NOT resolved. \
Inspect them by hand, or remove them and re-run: {}",
result.entries.len(),
crate::core::path_compat::display_path(hooks_dir),
result.left_alone.len(),
result
.left_alone
.iter()
.map(|p| crate::core::path_compat::display_path(p))
.collect::<Vec<_>>()
.join(", ")
)
} else {
format!(
"rewrote {} hook script(s) in {} (each replaced script backed up beside \
it as <name>.bak; files we did not write were left alone)",
result.entries.len(),
hooks_dir.display()
)
},
})
}
Err(e) => {
tracing::warn!(
error = %e.message,
code = e.code,
"doctor --fix: hook file reinstall failed"
);
None
}
}
}
pub(crate) fn heal_binaries(ol_dir: &Path) -> Vec<FixAction> {
let mut actions = Vec::new();
match hooks::staging::stage_hook_binary(ol_dir) {
Ok(hooks::staging::StageOutcome::AlreadyStaged { .. }) => {}
Ok(hooks::staging::StageOutcome::Staged { target, source }) => {
actions.push(FixAction {
ol_code: crate::error::ERR_HOOK_BINARY_UNRESOLVABLE.to_string(),
kind: FixKind::BinaryCopy,
file: target.clone(),
backup: None, reversible: true,
applied_at: Utc::now(),
note: format!("staged {} from {}", target.display(), source.display()),
});
}
Err(e) => {
tracing::warn!(
error = %e.message,
code = e.code,
"doctor --fix: cannot stage hook binary"
);
}
}
actions
}
pub(crate) fn heal_supervision(ol_dir: &Path) -> Vec<FixAction> {
use crate::supervision::{select_supervisor, SupervisionMode};
let mut actions = Vec::new();
let cfg = match config::Config::load(None, None, false) {
Ok(c) => c,
Err(_) => return actions,
};
if !matches!(cfg.supervision.mode, SupervisionMode::Active) {
return actions;
}
let Some(supervisor) = select_supervisor() else {
return actions;
};
let status_ok = supervisor
.status()
.map(|s| s.installed && s.unit_current)
.unwrap_or(false);
if status_ok {
return actions;
}
let exe_path =
std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("openlatch"));
if let Err(e) = supervisor.install(&exe_path) {
tracing::warn!(error = %e.message, code = %e.code, "doctor --fix: supervisor reinstall failed");
return actions;
}
let config_path = ol_dir.join("config.toml");
let _ = config::persist_supervision_state(
&config_path,
&SupervisionMode::Active,
&supervisor.kind(),
None,
);
actions.push(FixAction {
ol_code: crate::supervision::ERR_SUPERVISION_INSTALL_FAILED.to_string(),
kind: FixKind::SupervisionInstall,
file: std::path::PathBuf::new(),
backup: None,
reversible: false,
applied_at: Utc::now(),
note: "Reinstalled missing or outdated OS supervisor (config said active)".to_string(),
});
actions
}
#[cfg(feature = "model-relay")]
fn reinstall_relay_ca_if_untrusted(
ol_dir: &Path,
cfg: &config::Config,
detected: &[DetectedAgent],
owner: bool,
store: &dyn crate::model_relay::trust_store::TrustStore,
) -> Option<FixAction> {
use crate::model_relay::ca;
let info = ca::inspect(&ca::ca_dir(ol_dir))?;
if store.is_trusted(&info.sha256_hex).unwrap_or(false) {
return None;
}
let _ = crate::daemon::ensure_proxy_env_trust(cfg, detected, owner);
if !store.is_trusted(&info.sha256_hex).unwrap_or(false) {
return None;
}
Some(FixAction {
ol_code: crate::error::ERR_MODEL_RELAY_CA_UNTRUSTED.to_string(),
kind: FixKind::RelayCaTrust,
file: ca::ca_pem_path(&ca::ca_dir(ol_dir)),
backup: None,
reversible: false,
applied_at: Utc::now(),
note: format!(
"Relay certificate authority (SHA-256 {}) installed into your user trust store.",
info.sha256_hex
),
})
}
#[cfg(feature = "model-relay")]
pub(crate) fn heal_relay_ca(
ol_dir: &Path,
detected: &[DetectedAgent],
now: time::OffsetDateTime,
daemon_confirmed_stopped: bool,
) -> Vec<FixAction> {
use crate::hooks::binding::EndpointConvention;
if !crate::supervision::owns_machine_supervision() {
return Vec::new(); }
let Ok(cfg) = config::Config::load(None, None, false) else {
return Vec::new();
};
let owner = crate::supervision::owns_machine_supervision()
&& detected.iter().any(|a| {
a.binding
.model_relay_wiring()
.is_some_and(|w| matches!(w.endpoint, EndpointConvention::ProxyEnv { .. }))
&& crate::daemon::owns_wiring_for(&cfg, &*a.binding)
});
heal_relay_ca_with(
ol_dir,
&cfg,
detected,
owner,
now,
&*crate::model_relay::trust_store::store(),
&|staging| prove_in_runtime(detected, staging),
daemon_confirmed_stopped,
)
}
#[cfg(feature = "model-relay")]
#[allow(clippy::too_many_arguments)]
pub(crate) fn heal_relay_ca_with(
ol_dir: &Path,
cfg: &config::Config,
detected: &[DetectedAgent],
owner: bool,
now: time::OffsetDateTime,
store: &dyn crate::model_relay::trust_store::TrustStore,
prove: &dyn Fn(&Path) -> Result<(), String>,
daemon_confirmed_stopped: bool,
) -> Vec<FixAction> {
use crate::model_relay::{ca, ca_lifecycle};
let mut actions = Vec::new();
if !owner {
let _ = ca_lifecycle::retire_prev(ol_dir, store);
return actions;
}
actions.extend(reinstall_relay_ca_if_untrusted(
ol_dir, cfg, detected, owner, store,
));
if !daemon_confirmed_stopped {
let due = ca::inspect(&ca_lifecycle::prev_dir(ol_dir)).is_some()
|| ca::inspect(&ca::ca_dir(ol_dir))
.is_some_and(|info| ca_lifecycle::days_left(&info, now) <= ca::CA_WARN_DAYS);
if due {
actions.push(FixAction {
ol_code: crate::error::ERR_MODEL_RELAY_CA_UNTRUSTED.to_string(),
kind: FixKind::RelayCaRotateSkipped,
file: ca::ca_pem_path(&ca::ca_dir(ol_dir)),
backup: None,
reversible: false,
applied_at: Utc::now(),
note: "Relay certificate authority rotation is due but was skipped: the daemon \
could not be confirmed stopped, and rotating under a live daemon would \
leave it serving leaves the trust store no longer trusts. Stop \
`openlatch` and run `openlatch doctor --fix` again."
.to_string(),
});
}
return actions;
}
match ca_lifecycle::rotate(ol_dir, store, prove, now) {
ca_lifecycle::RotateOutcome::NotDue => {}
ca_lifecycle::RotateOutcome::Rotated { old_sha, new_sha } => {
actions.push(FixAction {
ol_code: crate::error::ERR_MODEL_RELAY_CA_UNTRUSTED.to_string(),
kind: FixKind::RelayCaRotate,
file: ca::ca_pem_path(&ca::ca_dir(ol_dir)),
backup: None,
reversible: false,
applied_at: Utc::now(),
note: format!(
"Relay certificate authority rotated (was {old_sha}, now {new_sha}). \
Restart any CLI agent launched before now. It read the previous \
certificate at start."
),
});
}
ca_lifecycle::RotateOutcome::PromotedButStuck {
old_sha,
new_sha,
why,
} => {
actions.push(FixAction {
ol_code: crate::error::ERR_MODEL_RELAY_CA_UNTRUSTED.to_string(),
kind: FixKind::RelayCaRotate,
file: ca::ca_pem_path(&ca::ca_dir(ol_dir)),
backup: None,
reversible: false,
applied_at: Utc::now(),
note: format!(
"Relay certificate authority rotated (was {old_sha}, now {new_sha}), but the \
previous certificate authority is still trusted — {why}"
),
});
}
ca_lifecycle::RotateOutcome::InstallDeclined(why) => {
tracing::warn!(
reason = %why,
"doctor --fix: the rotated certificate authority was declined by the trust store"
);
}
ca_lifecycle::RotateOutcome::ProofFailed(_why) => {
tracing::warn!(
"doctor --fix: the new certificate authority could not be proven; nothing changed"
);
}
ca_lifecycle::RotateOutcome::Failed(why) => {
tracing::warn!(
reason = %why,
"doctor --fix: certificate authority rotation failed"
);
}
}
actions
}
#[cfg(feature = "model-relay")]
fn prove_in_runtime(detected: &[DetectedAgent], staging: &Path) -> Result<(), String> {
use crate::hooks::binding::EndpointConvention;
let targets: Vec<(crate::model_relay::wire_format::WireFormat, &'static str)> = detected
.iter()
.filter_map(|a| {
let w = a.binding.model_relay_wiring()?;
match w.endpoint {
EndpointConvention::ProxyEnv {
intercept_hosts, ..
} => intercept_hosts.first().map(|h| (w.wire_format, *h)),
_ => None,
}
})
.collect();
if targets.is_empty() {
return Ok(());
}
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| e.to_string())?;
rt.block_on(async {
let cfg = config::Config::load(None, None, false).map_err(|e| e.message)?;
let upstream = cfg.model_relay.upstream_for(targets[0].0);
let upstream_url = reqwest::Url::parse(&upstream).map_err(|e| e.to_string())?;
let state = crate::model_relay::ModelRelayState::new_with_egress(
upstream_url,
0,
8,
&[],
&cfg.egress,
);
crate::model_relay::ca_lifecycle::prove_with_ephemeral_relay(
&cfg,
state,
staging,
&targets,
&*crate::model_relay::trust_store::store(),
)
.await
})
}
pub(crate) fn backup_file(path: &Path) -> Result<PathBuf, OlError> {
let bak = bak_path_for(path);
std::fs::copy(path, &bak).map_err(|e| {
OlError::new(
crate::error::ERR_INVALID_CONFIG,
format!(
"cannot create backup '{}' for '{}': {e}",
bak.display(),
path.display()
),
)
})?;
Ok(bak)
}
pub(crate) fn bak_path_for(path: &Path) -> PathBuf {
let mut bak = path.to_path_buf();
let new_name = match path.file_name() {
Some(name) => format!("{}.bak", name.to_string_lossy()),
None => "unknown.bak".to_string(),
};
bak.set_file_name(new_name);
bak
}
fn print_fix_results(
journal: &Journal,
journal_path: &Path,
before: Option<&DoctorReport>,
after: &DoctorReport,
auto_rollback_triggered: bool,
output: &OutputConfig,
) {
let unresolved = after.unresolved();
let unfixable: Vec<&str> = unresolved.iter().map(String::as_str).collect();
if output.format == OutputFormat::Json {
let actions_json: Vec<serde_json::Value> = journal
.actions
.iter()
.map(|a| {
serde_json::json!({
"ol_code": a.ol_code,
"kind": a.kind,
"file": a.file.display().to_string(),
"backup": a.backup.as_ref().map(|p| p.display().to_string()),
"reversible": a.reversible,
"applied_at": a.applied_at,
"note": a.note,
})
})
.collect();
let backups: Vec<String> = journal
.actions
.iter()
.filter_map(|a| a.backup.as_ref().map(|p| p.display().to_string()))
.collect();
let exit_code = if after.all_pass() && !auto_rollback_triggered {
0
} else {
1
};
output.print_json(&serde_json::json!({
"command": "doctor_fix",
"run_id": journal.run_id,
"started_at": journal.started_at,
"journal_path": journal_path.display().to_string(),
"checks_before": before.map(|r| serde_json::json!({
"pass": r.pass_count(),
"fail": r.fail_count(),
})),
"checks_after": serde_json::json!({
"pass": after.pass_count(),
"fail": after.fail_count(),
}),
"fixes_applied": actions_json,
"fixes_count": journal.actions.len(),
"unfixable": unfixable,
"backups": backups,
"auto_rollback_triggered": auto_rollback_triggered,
"exit_code": exit_code,
}));
return;
}
if output.quiet {
return;
}
print_diagnostic_results(after, output);
if auto_rollback_triggered {
eprintln!();
eprintln!("Fix attempted but daemon failed to restart — rolled back to pre-fix state.");
eprintln!(" Run `openlatch doctor --rescue` to file a bug with diagnostics.");
return;
}
if !journal.actions.is_empty() {
eprintln!();
eprintln!("Fixes applied ({}):", journal.actions.len());
for action in &journal.actions {
eprintln!(" • {} [{}]", action.note, action.ol_code);
}
let backups: Vec<String> = journal
.actions
.iter()
.filter_map(|a| a.backup.as_ref().map(|p| p.display().to_string()))
.collect();
if !backups.is_empty() {
eprintln!();
eprintln!("Backups created: {}", backups.join(", "));
eprintln!("Roll back with: openlatch doctor --restore");
}
}
eprintln!();
if after.all_pass() {
eprintln!(
"Summary: {} fix{} applied, 0 issues remaining.",
journal.actions.len(),
if journal.actions.len() == 1 { "" } else { "es" }
);
} else {
eprintln!(
"Summary: {} fix{} applied, {} issue{} remaining.",
journal.actions.len(),
if journal.actions.len() == 1 { "" } else { "es" },
after.fail_count(),
if after.fail_count() == 1 { "" } else { "s" }
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hooks::staging::HOOK_BIN_ENV_LOCK as ENV_LOCK;
use tempfile::TempDir;
fn empty_dir() -> TempDir {
TempDir::new().expect("tempdir must be created")
}
#[cfg(feature = "model-relay")]
use crate::model_relay::trust_store::test_support::FakeStore;
#[cfg(feature = "model-relay")]
fn proxy_env_fake_agent() -> DetectedAgent {
use crate::hooks::binding::test_support::proxy_env_wiring;
use crate::hooks::binding::ProxyDelivery;
const DELIVERY: &[ProxyDelivery] = &[ProxyDelivery::EnvFile {
commands: &["fakecli"],
}];
DetectedAgent {
kind: crate::hooks::AgentKind::ClaudeCode,
binding: std::sync::Arc::new(crate::hooks::binding::test_support::FakeBinding {
agent_type: "fake-relay-agent",
display_name: "Fake",
model_relay_wiring: Some(proxy_env_wiring(&["a.test"], DELIVERY)),
..Default::default()
}),
}
}
#[cfg(feature = "model-relay")]
struct RelayCaFixture {
_trust_guard: crate::model_relay::trust_store::TrustStoreGuard,
_env: crate::hooks::cline::EnvOverride,
_ol_dir: TempDir,
_dir_lock: std::sync::MutexGuard<'static, ()>,
store: std::sync::Arc<FakeStore>,
}
#[cfg(feature = "model-relay")]
impl RelayCaFixture {
fn new() -> Self {
let _dir_lock = crate::config::OPENLATCH_DIR_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let ol_dir = TempDir::new().expect("tempdir");
let _env = crate::hooks::cline::EnvOverride::apply([(
"OPENLATCH_DIR",
Some(ol_dir.path().as_os_str().to_os_string()),
)]);
let store = std::sync::Arc::new(FakeStore::default());
let _trust_guard = crate::model_relay::trust_store::install_for_tests(store.clone());
Self {
_trust_guard,
_env,
_ol_dir: ol_dir,
_dir_lock,
store,
}
}
fn ol_dir(&self) -> &Path {
self._ol_dir.path()
}
}
#[test]
#[cfg(feature = "model-relay")]
fn rotation_is_a_no_op_outside_the_warning_window() {
let fx = RelayCaFixture::new();
let ca_dir = crate::model_relay::ca::ca_dir(fx.ol_dir());
let old = crate::model_relay::ca::LocalCa::generate_into(&ca_dir).expect("generate");
fx.store.trust(&old.sha256_hex());
let now = old.not_after() - time::Duration::days(60);
let cfg = config::Config::defaults();
let detected = [proxy_env_fake_agent()];
let actions = heal_relay_ca_with(
fx.ol_dir(),
&cfg,
&detected,
true,
now,
&*fx.store,
&|_| Ok(()),
true,
);
assert_eq!(
fx.store.calls(),
vec![("is_trusted", old.sha256_hex())],
"outside the window, only job (a)'s trust check may touch the store"
);
assert!(actions.is_empty(), "nothing to do outside the window");
}
#[test]
#[cfg(feature = "model-relay")]
fn an_untrusted_ca_is_reinstalled_by_fix() {
let cfg = config::Config::defaults();
{
let fx = RelayCaFixture::new();
let ca_dir = crate::model_relay::ca::ca_dir(fx.ol_dir());
let old = crate::model_relay::ca::LocalCa::generate_into(&ca_dir).expect("generate");
let now = old.not_after() - time::Duration::days(300); let detected = [proxy_env_fake_agent()];
let actions = heal_relay_ca_with(
fx.ol_dir(),
&cfg,
&detected,
true,
now,
&*fx.store,
&|_| Ok(()),
true,
);
assert_eq!(
actions.len(),
1,
"the reinstall must be recorded: {actions:?}"
);
assert_eq!(actions[0].kind, FixKind::RelayCaTrust);
assert!(fx
.store
.calls()
.iter()
.any(|(m, sha)| *m == "install" && sha == &old.sha256_hex()));
}
{
let fx2 = RelayCaFixture::new();
let ca_dir2 = crate::model_relay::ca::ca_dir(fx2.ol_dir());
let old2 = crate::model_relay::ca::LocalCa::generate_into(&ca_dir2).expect("generate");
fx2.store.trust(&old2.sha256_hex());
let now2 = old2.not_after() - time::Duration::days(300);
let detected2 = [proxy_env_fake_agent()];
let actions2 = heal_relay_ca_with(
fx2.ol_dir(),
&cfg,
&detected2,
true,
now2,
&*fx2.store,
&|_| Ok(()),
true,
);
assert!(
!fx2.store.calls().iter().any(|(m, _)| *m == "install"),
"an already-trusted CA must never be reinstalled"
);
assert!(actions2.is_empty());
}
}
#[test]
#[cfg(feature = "model-relay")]
fn heal_relay_ca_is_a_no_op_without_the_owner() {
let cfg = config::Config::defaults();
{
let fx = RelayCaFixture::new();
let ca_dir = crate::model_relay::ca::ca_dir(fx.ol_dir());
let old = crate::model_relay::ca::LocalCa::generate_into(&ca_dir).expect("generate");
let now = old.not_after() - time::Duration::days(20);
let detected = [proxy_env_fake_agent()];
let actions = heal_relay_ca_with(
fx.ol_dir(),
&cfg,
&detected,
false,
now,
&*fx.store,
&|_| Ok(()),
true,
);
assert!(
fx.store.calls().is_empty(),
"no owner, no ca.prev — the store must never be touched: {:?}",
fx.store.calls()
);
assert!(actions.is_empty());
}
{
let fx2 = RelayCaFixture::new();
let ca_dir2 = crate::model_relay::ca::ca_dir(fx2.ol_dir());
let live = crate::model_relay::ca::LocalCa::generate_into(&ca_dir2).expect("generate");
let prev_dir = crate::model_relay::ca_lifecycle::prev_dir(fx2.ol_dir());
let prev = crate::model_relay::ca::LocalCa::generate_into(&prev_dir).expect("generate");
fx2.store.trust(&prev.sha256_hex());
let now2 = live.not_after() - time::Duration::days(20);
let detected2 = [proxy_env_fake_agent()];
let actions2 = heal_relay_ca_with(
fx2.ol_dir(),
&cfg,
&detected2,
false,
now2,
&*fx2.store,
&|_| Ok(()),
true,
);
assert_eq!(
fx2.store.calls(),
vec![
("remove", prev.sha256_hex()),
("is_trusted", prev.sha256_hex()),
],
"the carve-out retries the stuck removal and nothing more"
);
assert!(!prev_dir.exists(), "ca.prev must be gone once retired");
assert!(actions2.is_empty());
}
}
#[test]
#[cfg(feature = "model-relay")]
fn rotation_promotes_only_after_install_and_proof() {
let fx = RelayCaFixture::new();
let ca_dir = crate::model_relay::ca::ca_dir(fx.ol_dir());
let old = crate::model_relay::ca::LocalCa::generate_into(&ca_dir).expect("generate");
fx.store.trust(&old.sha256_hex());
let now = old.not_after() - time::Duration::days(20); let cfg = config::Config::defaults();
let detected = [proxy_env_fake_agent()];
let actions = heal_relay_ca_with(
fx.ol_dir(),
&cfg,
&detected,
true,
now,
&*fx.store,
&|_| Ok(()),
true,
);
let calls = fx.store.calls();
let new_sha = calls
.iter()
.find(|(m, _)| *m == "install")
.map(|(_, s)| s.clone())
.expect("rotation must install the new CA");
assert_eq!(
calls,
vec![
("is_trusted", old.sha256_hex()),
("install", new_sha.clone()),
("remove", old.sha256_hex()),
("is_trusted", old.sha256_hex()),
],
"the exact rotation call order"
);
let now_info = crate::model_relay::ca::inspect(&ca_dir).expect("ca.pem must exist");
assert_eq!(now_info.sha256_hex, new_sha);
assert!(
!crate::model_relay::ca_lifecycle::staging_dir(fx.ol_dir()).exists(),
"ca.next must be gone after a clean rotation"
);
assert_eq!(
actions
.iter()
.filter(|a| a.kind == FixKind::RelayCaRotate)
.count(),
1
);
}
#[test]
#[cfg(feature = "model-relay")]
fn a_failed_proof_keeps_the_old_ca_and_takes_back_the_new_trust() {
let fx = RelayCaFixture::new();
let ca_dir = crate::model_relay::ca::ca_dir(fx.ol_dir());
let old = crate::model_relay::ca::LocalCa::generate_into(&ca_dir).expect("generate");
fx.store.trust(&old.sha256_hex());
let now = old.not_after() - time::Duration::days(20);
let cfg = config::Config::defaults();
let detected = [proxy_env_fake_agent()];
let actions = heal_relay_ca_with(
fx.ol_dir(),
&cfg,
&detected,
true,
now,
&*fx.store,
&|_| Err("proof failed (fixture)".to_string()),
true,
);
let calls = fx.store.calls();
let new_sha = calls
.iter()
.find(|(m, _)| *m == "install")
.map(|(_, s)| s.clone())
.expect("the staged CA is still installed before proof runs");
assert!(
calls.contains(&("remove", new_sha.clone())),
"a failed proof must take back only what THIS run added: {calls:?}"
);
assert!(
!calls.contains(&("remove", old.sha256_hex())),
"the old CA must never be touched on a failed proof: {calls:?}"
);
let now_info = crate::model_relay::ca::inspect(&ca_dir).expect("ca.pem must exist");
assert_eq!(
now_info.sha256_hex,
old.sha256_hex(),
"the old CA is still the live one"
);
assert!(!crate::model_relay::ca_lifecycle::staging_dir(fx.ol_dir()).exists());
assert!(
actions.is_empty(),
"a failed proof takes no reportable action"
);
}
#[test]
#[cfg(feature = "model-relay")]
fn a_declined_install_changes_nothing() {
use crate::model_relay::trust_store::InstallOutcome;
for outcome in [
InstallOutcome::NoGuiSession,
InstallOutcome::Refused("no".to_string()),
] {
let fx = RelayCaFixture::new();
let ca_dir = crate::model_relay::ca::ca_dir(fx.ol_dir());
let old = crate::model_relay::ca::LocalCa::generate_into(&ca_dir).expect("generate");
fx.store.trust(&old.sha256_hex()); fx.store.set_install_outcome(outcome.clone());
let now = old.not_after() - time::Duration::days(20);
let cfg = config::Config::defaults();
let detected = [proxy_env_fake_agent()];
let prove_calls = std::sync::atomic::AtomicUsize::new(0);
let actions = heal_relay_ca_with(
fx.ol_dir(),
&cfg,
&detected,
true,
now,
&*fx.store,
&|_| {
prove_calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(())
},
true,
);
assert_eq!(
prove_calls.load(std::sync::atomic::Ordering::SeqCst),
0,
"a declined install must never reach the proof step, for {outcome:?}"
);
assert!(
!fx.store.calls().iter().any(|(m, _)| *m == "remove"),
"nothing was promoted, so nothing may be removed, for {outcome:?}"
);
let now_info = crate::model_relay::ca::inspect(&ca_dir).expect("ca.pem must exist");
assert_eq!(
now_info.sha256_hex,
old.sha256_hex(),
"the old CA is intact, for {outcome:?}"
);
assert!(actions.is_empty(), "no action, for {outcome:?}");
}
}
#[test]
#[cfg(feature = "model-relay")]
fn a_refused_old_root_removal_keeps_ca_prev_and_the_next_run_retries_it() {
let fx = RelayCaFixture::new();
let ca_dir = crate::model_relay::ca::ca_dir(fx.ol_dir());
let old = crate::model_relay::ca::LocalCa::generate_into(&ca_dir).expect("generate");
fx.store.trust(&old.sha256_hex());
fx.store.refuse_removal_of(&old.sha256_hex());
let now = old.not_after() - time::Duration::days(20);
let cfg = config::Config::defaults();
let detected = [proxy_env_fake_agent()];
let actions = heal_relay_ca_with(
fx.ol_dir(),
&cfg,
&detected,
true,
now,
&*fx.store,
&|_| Ok(()),
true,
);
let calls = fx.store.calls();
let new_sha = calls
.iter()
.find(|(m, _)| *m == "install")
.map(|(_, s)| s.clone())
.expect("the new CA is installed before the stuck removal");
assert_eq!(
calls,
vec![
("is_trusted", old.sha256_hex()),
("install", new_sha.clone()),
("remove", old.sha256_hex()),
],
"the refused remove short-circuits the read-back in the SAME expression"
);
assert_eq!(
actions
.iter()
.filter(|a| a.kind == FixKind::RelayCaRotate)
.count(),
1,
"the stuck-root case still reports one action, never silently"
);
assert!(
actions[0].note.contains(old.sha256_hex().as_str())
&& actions[0].note.contains(new_sha.as_str())
);
let prev_dir = crate::model_relay::ca_lifecycle::prev_dir(fx.ol_dir());
assert!(prev_dir.exists(), "ca.prev must be kept for the next run");
let live_info = crate::model_relay::ca::inspect(&ca_dir).expect("ca.pem must exist");
assert_eq!(
live_info.sha256_hex, new_sha,
"ca/ already holds the NEW CA"
);
assert!(
fx.store.trusted_directly(&old.sha256_hex()),
"the old root is still trusted"
);
assert!(
fx.store.trusted_directly(&new_sha),
"the new root is trusted"
);
fx.store.allow_removal_of(&old.sha256_hex());
let new_info =
crate::model_relay::ca::inspect(&ca_dir).expect("ca.pem must exist after run 1");
let now2 = new_info.not_after - time::Duration::days(60); let actions2 = heal_relay_ca_with(
fx.ol_dir(),
&cfg,
&detected,
true,
now2,
&*fx.store,
&|_| Ok(()),
true,
);
assert!(
actions2.is_empty(),
"job (a) sees the new CA already trusted; job (b) is NotDue outside its own window: {actions2:?}"
);
assert!(!prev_dir.exists(), "ca.prev must be gone once retired");
assert!(
!crate::model_relay::ca_lifecycle::staging_dir(fx.ol_dir()).exists(),
"run 2 must mint nothing"
);
let final_info = crate::model_relay::ca::inspect(&ca_dir).expect("ca.pem must exist");
assert_eq!(
final_info.sha256_hex, new_sha,
"run 2 rotates nothing further"
);
}
#[test]
#[cfg(feature = "model-relay")]
fn rotation_is_skipped_when_the_daemon_is_not_confirmed_stopped() {
let fx = RelayCaFixture::new();
let ca_dir = crate::model_relay::ca::ca_dir(fx.ol_dir());
let old = crate::model_relay::ca::LocalCa::generate_into(&ca_dir).expect("generate");
fx.store.trust(&old.sha256_hex()); let cfg = config::Config::defaults();
let detected = [proxy_env_fake_agent()];
let now = old.not_after() - time::Duration::days(20);
let actions = heal_relay_ca_with(
fx.ol_dir(),
&cfg,
&detected,
true,
now,
&*fx.store,
&|_| panic!("a skipped rotation must never reach the proof step"),
false, );
assert!(
!fx.store.calls().iter().any(|(m, _)| *m == "install"),
"rotation must never mint or install a new CA while the daemon might still be \
alive: {:?}",
fx.store.calls()
);
assert_eq!(
actions.len(),
1,
"the skip must be reported, never silently: {actions:?}"
);
assert_eq!(actions[0].kind, FixKind::RelayCaRotateSkipped);
let now_info = crate::model_relay::ca::inspect(&ca_dir).expect("ca.pem must exist");
assert_eq!(
now_info.sha256_hex,
old.sha256_hex(),
"the old CA is untouched"
);
let far_now = old.not_after() - time::Duration::days(60);
let actions2 = heal_relay_ca_with(
fx.ol_dir(),
&cfg,
&detected,
true,
far_now,
&*fx.store,
&|_| panic!("must never prove outside the window either"),
false,
);
assert!(
actions2.is_empty(),
"outside the window there was nothing rotation would have done: {actions2:?}"
);
}
#[test]
fn test_bak_path_for_appends_bak_suffix() {
let p = Path::new("/tmp/config.toml");
assert_eq!(bak_path_for(p), Path::new("/tmp/config.toml.bak"));
}
#[test]
fn test_bak_path_for_handles_no_extension() {
let p = Path::new("/tmp/daemon.token");
assert_eq!(bak_path_for(p), Path::new("/tmp/daemon.token.bak"));
}
#[test]
fn test_backup_file_round_trip() {
let tmp = empty_dir();
let src = tmp.path().join("config.toml");
std::fs::write(&src, "port = 7443\n").unwrap();
let bak = backup_file(&src).expect("backup must succeed");
assert_eq!(bak, src.with_file_name("config.toml.bak"));
assert_eq!(std::fs::read_to_string(&bak).unwrap(), "port = 7443\n");
}
#[test]
fn test_heal_state_creates_config_when_missing() {
let tmp = empty_dir();
let actions = heal_state(tmp.path());
let config_path = tmp.path().join("config.toml");
assert!(config_path.exists(), "config.toml must be created");
assert!(
actions
.iter()
.any(|a| a.kind == FixKind::ConfigRewrite && a.backup.is_none()),
"expected ConfigRewrite action with no backup (no prior file)"
);
}
#[test]
fn test_heal_state_rewrites_corrupt_config_with_backup() {
let tmp = empty_dir();
let config_path = tmp.path().join("config.toml");
std::fs::write(&config_path, "this is not valid TOML {{{").unwrap();
let actions = heal_state(tmp.path());
let bak = config_path.with_file_name("config.toml.bak");
assert!(bak.exists(), ".bak must be created for corrupt config");
let raw = std::fs::read_to_string(&config_path).unwrap();
assert!(toml::from_str::<toml::Value>(&raw).is_ok());
assert!(actions
.iter()
.any(|a| a.kind == FixKind::ConfigRewrite && a.backup.is_some()));
}
#[test]
fn test_heal_state_regenerates_missing_token() {
let tmp = empty_dir();
let token_path = tmp.path().join("daemon.token");
let actions = heal_state(tmp.path());
assert!(token_path.exists(), "token must be regenerated");
let token = std::fs::read_to_string(&token_path).unwrap();
assert_eq!(token.trim().len(), 64, "token must be 64 hex chars");
assert!(actions.iter().any(|a| a.kind == FixKind::TokenRegenerate));
}
#[test]
fn test_heal_state_regenerates_empty_token_with_backup() {
let tmp = empty_dir();
let token_path = tmp.path().join("daemon.token");
std::fs::write(&token_path, "").unwrap();
let actions = heal_state(tmp.path());
let bak = token_path.with_file_name("daemon.token.bak");
assert!(bak.exists(), "empty token must be backed up");
let token = std::fs::read_to_string(&token_path).unwrap();
assert_eq!(token.trim().len(), 64);
assert!(actions.iter().any(|a| a.kind == FixKind::TokenRegenerate));
}
#[test]
fn test_heal_state_inserts_agent_id_into_existing_config() {
let tmp = empty_dir();
let config_path = tmp.path().join("config.toml");
std::fs::write(&config_path, "[daemon]\nport = 7443\n").unwrap();
let actions = heal_state(tmp.path());
let raw = std::fs::read_to_string(&config_path).unwrap();
assert!(raw.contains("agent_id"), "agent_id must be inserted");
assert!(actions.iter().any(|a| a.kind == FixKind::AgentIdInsert));
}
#[test]
fn test_heal_state_resets_corrupt_telemetry_json() {
let tmp = empty_dir();
let telem_path = tmp.path().join("telemetry.json");
std::fs::write(&telem_path, "{ broken json").unwrap();
let actions = heal_state(tmp.path());
let raw = std::fs::read_to_string(&telem_path).unwrap();
let parsed: serde_json::Value =
serde_json::from_str(&raw).expect("telemetry must be valid JSON");
assert_eq!(parsed.get("enabled"), Some(&serde_json::json!(false)));
assert!(actions.iter().any(|a| a.kind == FixKind::TelemetryReset));
}
#[test]
fn test_heal_state_removes_stale_pid_file() {
let tmp = empty_dir();
let pid_path = tmp.path().join("daemon.pid");
std::fs::write(&pid_path, "0").unwrap();
let actions = heal_state(tmp.path());
assert!(!pid_path.exists(), "stale PID file must be removed");
assert!(actions.iter().any(|a| a.kind == FixKind::PidStaleRemove));
}
#[test]
fn test_heal_state_idempotent_on_clean_install() {
let tmp = empty_dir();
let first = heal_state(tmp.path());
assert!(!first.is_empty(), "first run must apply at least one fix");
let second = heal_state(tmp.path());
assert!(
second.is_empty(),
"second run on a healthy install must apply zero fixes (got {second:?})"
);
}
#[test]
fn test_heal_binaries_noop_when_target_exists() {
let tmp = empty_dir();
let bin_dir = tmp.path().join("bin");
std::fs::create_dir_all(&bin_dir).unwrap();
let bin_name = if cfg!(windows) {
"openlatch-hook.exe"
} else {
"openlatch-hook"
};
std::fs::write(bin_dir.join(bin_name), b"existing").unwrap();
let actions = heal_binaries(tmp.path());
assert!(actions.is_empty(), "no action when target already exists");
}
#[test]
fn test_heal_binaries_copies_from_env_override() {
let tmp = empty_dir();
let src_dir = empty_dir();
let bin_name = if cfg!(windows) {
"openlatch-hook.exe"
} else {
"openlatch-hook"
};
let src = src_dir.path().join(bin_name);
std::fs::write(&src, b"hook bytes").unwrap();
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let prev = std::env::var("OPENLATCH_HOOK_BIN").ok();
std::env::set_var("OPENLATCH_HOOK_BIN", &src);
let actions = heal_binaries(tmp.path());
if let Some(p) = prev {
std::env::set_var("OPENLATCH_HOOK_BIN", p);
} else {
std::env::remove_var("OPENLATCH_HOOK_BIN");
}
let target = tmp.path().join("bin").join(bin_name);
assert!(target.exists(), "binary must be staged");
assert_eq!(std::fs::read(&target).unwrap(), b"hook bytes");
assert!(actions.iter().any(|a| a.kind == FixKind::BinaryCopy));
}
#[test]
fn test_heal_binaries_no_action_when_no_source_locatable() {
let tmp = empty_dir();
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let prev = std::env::var("OPENLATCH_HOOK_BIN").ok();
std::env::set_var("OPENLATCH_HOOK_BIN", "");
let actions = heal_binaries(tmp.path());
if let Some(p) = prev {
std::env::set_var("OPENLATCH_HOOK_BIN", p);
} else {
std::env::remove_var("OPENLATCH_HOOK_BIN");
}
for a in &actions {
assert!(a.file.starts_with(tmp.path()));
}
}
#[test]
fn test_journal_save_and_load_round_trip() {
let tmp = empty_dir();
let mut journal = Journal::new();
journal.actions.push(FixAction {
ol_code: crate::error::ERR_INVALID_CONFIG.to_string(),
kind: FixKind::ConfigRewrite,
file: tmp.path().join("config.toml"),
backup: Some(tmp.path().join("config.toml.bak")),
reversible: true,
applied_at: Utc::now(),
note: "test".to_string(),
});
journal.save(tmp.path()).expect("save must succeed");
let loaded = Journal::load(tmp.path()).expect("load must succeed");
assert_eq!(loaded.run_id, journal.run_id);
assert_eq!(loaded.actions.len(), 1);
assert_eq!(loaded.actions[0].kind, FixKind::ConfigRewrite);
}
#[test]
fn test_journal_load_returns_no_journal_error_when_absent() {
let tmp = empty_dir();
let err = Journal::load(tmp.path()).expect_err("load must fail when absent");
assert_eq!(err.code, crate::error::ERR_DOCTOR_RESTORE_NO_JOURNAL);
}
#[test]
fn test_journal_load_returns_corrupt_error_when_unparsable() {
let tmp = empty_dir();
std::fs::write(tmp.path().join(JOURNAL_FILENAME), "{ broken").unwrap();
let err = Journal::load(tmp.path()).expect_err("load must fail on bad JSON");
assert_eq!(err.code, crate::error::ERR_DOCTOR_JOURNAL_CORRUPT);
}
#[test]
fn heal_hooks_rewrites_a_command_that_points_at_a_missing_binary() {
let ol = empty_dir();
let claude = empty_dir();
let src = empty_dir();
let hook_src = src.path().join(crate::hooks::staging::hook_bin_name());
std::fs::write(&hook_src, b"hook bytes").unwrap();
std::fs::write(ol.path().join("daemon.token"), "live-session-token").unwrap();
let settings_path = claude.path().join("settings.json");
let dead = serde_json::json!({
"env": { "OPENLATCH_TOKEN": "live-session-token", "OPENLATCH_PORT": "7443" },
"hooks": {
"PreToolUse": [{ "matcher": "*", "_openlatch": { "entry_id": "a" },
"hooks": [{ "type": "command", "command": "\"openlatch-hook\" --event PreToolUse" }] }],
"UserPromptSubmit": [{ "matcher": "*", "_openlatch": { "entry_id": "b" },
"hooks": [{ "type": "command", "command": "\"openlatch-hook\" --event UserPromptSubmit" }] }],
"Stop": [{ "matcher": "*", "_openlatch": { "entry_id": "c" },
"hooks": [{ "type": "command", "command": "\"openlatch-hook\" --event Stop" }] }],
}
});
std::fs::write(&settings_path, serde_json::to_string_pretty(&dead).unwrap()).unwrap();
let _dir_env = crate::config::OPENLATCH_DIR_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _env = crate::hooks::claude_code::CONFIG_DIR_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _codex_env = crate::hooks::codex_cli::CONFIG_DIR_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _cline_env = crate::hooks::cline::SEAM_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let prev = (
std::env::var("OPENLATCH_DIR").ok(),
std::env::var("CLAUDE_CONFIG_DIR").ok(),
std::env::var("OPENLATCH_HOOK_BIN").ok(),
std::env::var(crate::hooks::codex_cli::CONFIG_DIR_ENV).ok(),
);
std::env::set_var("OPENLATCH_DIR", ol.path());
std::env::set_var("CLAUDE_CONFIG_DIR", claude.path());
std::env::set_var("OPENLATCH_HOOK_BIN", &hook_src);
std::env::set_var(
crate::hooks::codex_cli::CONFIG_DIR_ENV,
ol.path().join("absent-codex"),
);
let _cline_seams = crate::hooks::cline::EnvOverride::absent_cline_seams(ol.path());
let actions = heal_hooks(ol.path());
let restore = |key: &str, v: Option<String>| match v {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
};
restore("OPENLATCH_DIR", prev.0);
restore("CLAUDE_CONFIG_DIR", prev.1);
restore("OPENLATCH_HOOK_BIN", prev.2);
restore(crate::hooks::codex_cli::CONFIG_DIR_ENV, prev.3);
assert!(
actions.iter().any(|a| a.kind == FixKind::HookReinstall),
"a dangling command must be repaired, not declared healthy"
);
let settings: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&settings_path).unwrap()).unwrap();
let commands = hook_commands(&settings);
assert!(
!commands.is_empty(),
"the repair must leave hook commands behind: {settings:#}"
);
for command in &commands {
assert!(
!command.starts_with("\"openlatch-hook\""),
"the bare-name command must be gone: {command}"
);
assert!(
command.contains(&hook_src.display().to_string()),
"commands must now name an existing binary: {command}"
);
}
assert_eq!(
settings
.pointer("/env/OPENLATCH_TOKEN")
.and_then(serde_json::Value::as_str),
Some("live-session-token"),
"the token must NOT be rotated — running sessions still hold it"
);
}
#[test]
fn a_windows_path_survives_the_json_round_trip_only_when_decoded() {
let windows_path = r"C:\Users\RUNNER~1\AppData\Local\Temp\.tmpZBZOCa\openlatch-hook.exe";
let settings = serde_json::json!({
"hooks": {
"PreToolUse": [{
"hooks": [{
"type": "command",
"command": format!("\"{windows_path}\" --event PreToolUse"),
}]
}]
}
});
let serialized = serde_json::to_string_pretty(&settings).unwrap();
assert!(
!serialized.contains(windows_path),
"the premise: JSON escapes every separator, so the raw path is not \
in the serialized text — this is exactly what the old assertion \
searched for:\n{serialized}"
);
let parsed: serde_json::Value = serde_json::from_str(&serialized).unwrap();
let commands = hook_commands(&parsed);
assert_eq!(commands.len(), 1);
assert!(
commands[0].contains(windows_path),
"decoded first, the path is found: {}",
commands[0]
);
}
fn hook_commands(settings: &serde_json::Value) -> Vec<String> {
settings
.get("hooks")
.and_then(serde_json::Value::as_object)
.map(|events| {
events
.values()
.filter_map(serde_json::Value::as_array)
.flatten()
.filter_map(|entry| entry.get("hooks")?.as_array())
.flatten()
.filter_map(|h| h.get("command")?.as_str())
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
#[test]
fn heal_hooks_leaves_a_healthy_install_alone() {
let ol = empty_dir();
let claude = empty_dir();
let staged_dir = ol.path().join("bin");
std::fs::create_dir_all(&staged_dir).unwrap();
let staged = staged_dir.join(crate::hooks::staging::hook_bin_name());
std::fs::write(&staged, b"hook bytes").unwrap();
std::fs::write(ol.path().join("daemon.token"), "tok").unwrap();
let settings_path = claude.path().join("settings.json");
let healthy = serde_json::json!({
"hooks": {
"PreToolUse": [{ "_openlatch": { "entry_id": "a" },
"hooks": [current_hook(&staged, "PreToolUse")] }],
"UserPromptSubmit": [{ "_openlatch": { "entry_id": "b" },
"hooks": [current_hook(&staged, "UserPromptSubmit")] }],
"Stop": [{ "_openlatch": { "entry_id": "c" },
"hooks": [current_hook(&staged, "Stop")] }],
}
});
let before = serde_json::to_string_pretty(&healthy).unwrap();
std::fs::write(&settings_path, &before).unwrap();
let _dir_env = crate::config::OPENLATCH_DIR_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _env = crate::hooks::claude_code::CONFIG_DIR_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _codex_env = crate::hooks::codex_cli::CONFIG_DIR_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _cline_env = crate::hooks::cline::SEAM_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let prev = (
std::env::var("OPENLATCH_DIR").ok(),
std::env::var("CLAUDE_CONFIG_DIR").ok(),
std::env::var(crate::hooks::codex_cli::CONFIG_DIR_ENV).ok(),
);
std::env::set_var("OPENLATCH_DIR", ol.path());
std::env::set_var("CLAUDE_CONFIG_DIR", claude.path());
std::env::set_var(
crate::hooks::codex_cli::CONFIG_DIR_ENV,
ol.path().join("absent-codex"),
);
let _cline_seams = crate::hooks::cline::EnvOverride::absent_cline_seams(ol.path());
let actions = heal_hooks(ol.path());
match prev.0 {
Some(v) => std::env::set_var("OPENLATCH_DIR", v),
None => std::env::remove_var("OPENLATCH_DIR"),
}
match prev.1 {
Some(v) => std::env::set_var("CLAUDE_CONFIG_DIR", v),
None => std::env::remove_var("CLAUDE_CONFIG_DIR"),
}
match prev.2 {
Some(v) => std::env::set_var(crate::hooks::codex_cli::CONFIG_DIR_ENV, v),
None => std::env::remove_var(crate::hooks::codex_cli::CONFIG_DIR_ENV),
}
assert!(actions.is_empty(), "healthy install must not be rewritten");
assert_eq!(std::fs::read_to_string(&settings_path).unwrap(), before);
}
struct HookEnvGuard {
saved: Vec<(&'static str, Option<std::ffi::OsString>)>,
_hook_bin_lock: std::sync::MutexGuard<'static, ()>,
_dir_lock: std::sync::MutexGuard<'static, ()>,
}
impl HookEnvGuard {
fn set(ol_dir: &Path, hook_bin: &Path) -> Self {
let dir_lock = crate::config::OPENLATCH_DIR_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let hook_bin_lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let pairs: [(&'static str, std::ffi::OsString); 3] = [
("OPENLATCH_DIR", ol_dir.as_os_str().to_owned()),
("OPENLATCH_HOOK_BIN", hook_bin.as_os_str().to_owned()),
("OPENLATCH_SKIP_KEYRING", std::ffi::OsString::from("1")),
];
let saved = pairs
.iter()
.map(|(key, _)| (*key, std::env::var_os(key)))
.collect();
for (key, value) in pairs {
std::env::set_var(key, value);
}
Self {
saved,
_hook_bin_lock: hook_bin_lock,
_dir_lock: dir_lock,
}
}
}
impl Drop for HookEnvGuard {
fn drop(&mut self) {
for (key, value) in &self.saved {
match value {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
}
}
}
use crate::hooks::binding::test_support::two_detected_agents as two_agents;
use crate::hooks::binding::test_support::non_installable_agent;
fn write_dead_settings(path: &Path) {
let dead = serde_json::json!({
"env": { "OPENLATCH_TOKEN": "live-session-token", "OPENLATCH_PORT": "7443" },
"hooks": {
"PreToolUse": [{ "matcher": "*", "_openlatch": { "entry_id": "a" },
"hooks": [{ "type": "command", "command": "\"openlatch-hook\" --event PreToolUse" }] }],
"UserPromptSubmit": [{ "matcher": "*", "_openlatch": { "entry_id": "b" },
"hooks": [{ "type": "command", "command": "\"openlatch-hook\" --event UserPromptSubmit" }] }],
"Stop": [{ "matcher": "*", "_openlatch": { "entry_id": "c" },
"hooks": [{ "type": "command", "command": "\"openlatch-hook\" --event Stop" }] }],
}
});
std::fs::write(path, serde_json::to_string_pretty(&dead).unwrap()).unwrap();
}
fn current_hook(bin: &Path, event: &str) -> serde_json::Value {
let marker = crate::core::hook_state::marker::OpenlatchMarker::new("m".into());
crate::hooks::claude_code::build_hook_entry(
event,
7443,
crate::hooks::OPENLATCH_TOKEN_ENV,
bin,
&marker,
)["hooks"][0]
.clone()
}
fn write_healthy_settings(path: &Path, bin: &Path) {
let healthy = serde_json::json!({
"hooks": {
"PreToolUse": [{ "_openlatch": { "entry_id": "a" },
"hooks": [current_hook(bin, "PreToolUse")] }],
"UserPromptSubmit": [{ "_openlatch": { "entry_id": "b" },
"hooks": [current_hook(bin, "UserPromptSubmit")] }],
"Stop": [{ "_openlatch": { "entry_id": "c" },
"hooks": [current_hook(bin, "Stop")] }],
}
});
std::fs::write(path, serde_json::to_string_pretty(&healthy).unwrap()).unwrap();
}
#[test]
fn heal_hooks_trusts_a_healthy_but_untrusted_codex_install() {
let ol = empty_dir();
let src = empty_dir();
let codex = empty_dir();
let hook_src = src.path().join(crate::hooks::staging::hook_bin_name());
std::fs::write(&hook_src, b"hook bytes").unwrap();
std::fs::write(ol.path().join("daemon.token"), "tok").unwrap();
let _env = HookEnvGuard::set(ol.path(), &hook_src);
crate::hooks::staging::stage_hook_binary(ol.path()).expect("stage");
let root = codex.path().to_path_buf();
let binding = |listing: Option<Vec<crate::hooks::codex_cli::ListedHook>>| {
crate::hooks::bindings::codex_cli::CodexCliBinding {
codex_dir: root.clone(),
hooks_path: root.join("hooks.json"),
requirements_toml: None,
hooks_list: std::sync::Arc::new(move |_| listing.clone()),
}
};
crate::hooks::install_hooks(&binding(None), 7443, "tok").expect("install");
let ours = crate::hooks::codex_cli::installed_handler(&root, "PreToolUse").expect("ours");
let untrusted: crate::hooks::codex_cli::ListedHook =
serde_json::from_value(serde_json::json!({
"key": crate::hooks::codex_cli::trust_key(&root, "PreToolUse", &ours),
"command": ours.command,
"sourcePath": root.join("hooks.json"),
"currentHash": "sha256:current",
"trustStatus": "untrusted",
}))
.expect("listed hook");
let before = std::fs::read_to_string(root.join("hooks.json")).unwrap();
let detected = vec![DetectedAgent {
kind: crate::hooks::AgentKind::CodexCli,
binding: std::sync::Arc::new(binding(Some(vec![untrusted]))),
}];
let actions = heal_hooks_for(&detected, ol.path());
assert_eq!(
actions.iter().map(|a| &a.kind).collect::<Vec<_>>(),
[&FixKind::HookTrustGrant],
"a healthy file is not rewritten, and the trust is granted"
);
assert_eq!(
std::fs::read_to_string(root.join("hooks.json")).unwrap(),
before
);
let config = std::fs::read_to_string(root.join("config.toml")).expect("config.toml");
assert!(
config.contains("trusted_hash = \"sha256:current\""),
"{config}"
);
}
#[test]
fn heal_hooks_repairs_every_detected_agent() {
let ol = empty_dir();
let src = empty_dir();
let agents = empty_dir();
let hook_src = src.path().join(crate::hooks::staging::hook_bin_name());
std::fs::write(&hook_src, b"hook bytes").unwrap();
std::fs::write(ol.path().join("daemon.token"), "tok").unwrap();
let _env = HookEnvGuard::set(ol.path(), &hook_src);
let detected = two_agents(agents.path());
for agent in &detected {
write_dead_settings(&agent.settings_path());
}
let actions = heal_hooks_for(&detected, ol.path());
let healed: Vec<PathBuf> = actions
.iter()
.filter(|a| a.kind == FixKind::HookReinstall)
.map(|a| a.file.clone())
.collect();
assert_eq!(
healed,
detected
.iter()
.map(DetectedAgent::settings_path)
.collect::<Vec<_>>(),
"every dead agent must be repaired — `file` is what names which one"
);
}
#[test]
fn heal_hooks_skips_a_healthy_agent_without_abandoning_the_rest() {
let ol = empty_dir();
let src = empty_dir();
let agents = empty_dir();
let hook_src = src.path().join(crate::hooks::staging::hook_bin_name());
std::fs::write(&hook_src, b"hook bytes").unwrap();
std::fs::write(ol.path().join("daemon.token"), "tok").unwrap();
let _env = HookEnvGuard::set(ol.path(), &hook_src);
let detected = two_agents(agents.path());
write_healthy_settings(&detected[0].settings_path(), &hook_src);
write_dead_settings(&detected[1].settings_path());
assert!(
!hooks::health::inspect_file(&detected[0].settings_path(), &*detected[0].binding)
.expect("the healthy settings file must parse")
.needs_reinstall(),
"the first agent must start out healthy"
);
let actions = heal_hooks_for(&detected, ol.path());
assert_eq!(
actions.iter().map(|a| a.file.clone()).collect::<Vec<_>>(),
vec![detected[1].settings_path()],
"a healthy first agent must not end the walk — the broken one behind it \
still needs its fix"
);
}
#[test]
fn doctor_fix_does_not_reinstall_cline() {
let ol = empty_dir();
let src = empty_dir();
let agents = empty_dir();
let hook_src = src.path().join(crate::hooks::staging::hook_bin_name());
std::fs::write(&hook_src, b"hook bytes").unwrap();
std::fs::write(ol.path().join("daemon.token"), "tok").unwrap();
let _env = HookEnvGuard::set(ol.path(), &hook_src);
let cline_dir = agents.path().join("cline");
std::fs::create_dir_all(&cline_dir).unwrap();
let detected = vec![
two_agents(agents.path()).remove(0),
non_installable_agent("cline", &cline_dir),
];
let cline_settings = detected[1].settings_path();
write_dead_settings(&detected[0].settings_path());
write_dead_settings(&cline_settings);
let first = heal_hooks_for(&detected, ol.path());
let second = heal_hooks_for(&detected, ol.path());
for (run, actions) in [("first", &first), ("second", &second)] {
assert!(
!actions.iter().any(|a| a.file == cline_settings),
"the {run} --fix run must record no action for a non-installable \
agent, got {:?}",
actions.iter().map(|a| a.file.clone()).collect::<Vec<_>>()
);
}
let backups: Vec<PathBuf> = std::fs::read_dir(&cline_dir)
.expect("the agent directory is readable")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "bak"))
.collect();
assert!(
backups.is_empty(),
"no `.bak` may be written beside a hook path this build never repairs, got {backups:?}"
);
assert_eq!(
first
.iter()
.filter(|a| a.kind == FixKind::HookReinstall)
.map(|a| a.file.clone())
.collect::<Vec<_>>(),
vec![detected[0].settings_path()],
"the installable agent must still be repaired"
);
assert!(
bak_path_for(&detected[0].settings_path()).exists(),
"the control agent's backup proves `backup_file` is reachable in this fixture"
);
}
}