pub mod atomic;
pub mod binding;
pub mod bindings;
pub mod claude_code;
pub mod health;
pub mod jsonc;
pub mod staging;
use std::path::PathBuf;
use crate::core::hook_state::hmac::compute_entry_hmac;
use crate::core::hook_state::key::HmacKeyStore;
use crate::core::hook_state::marker::OpenlatchMarker;
use crate::core::hook_state::{self, HookStateFile, StateEntry};
use crate::error::{OlError, ERR_HOOK_AGENT_NOT_FOUND, ERR_HOOK_BINARY_UNRESOLVABLE};
#[derive(Debug, Clone)]
pub enum DetectedAgent {
ClaudeCode {
claude_dir: PathBuf,
settings_path: PathBuf,
},
}
#[derive(Debug)]
pub struct HookInstallResult {
pub entries: Vec<HookEntryStatus>,
}
#[derive(Debug)]
pub struct HookEntryStatus {
pub event_type: String,
pub action: HookAction,
}
#[derive(Debug, Clone, PartialEq)]
pub enum HookAction {
Added,
Replaced,
}
pub fn resolve_hook_binary_path() -> PathBuf {
let bin_name = if cfg!(windows) {
"openlatch-hook.exe"
} else {
"openlatch-hook"
};
if let Ok(override_path) = std::env::var("OPENLATCH_HOOK_BIN") {
if !override_path.is_empty() {
return PathBuf::from(override_path);
}
}
let candidate = crate::config::openlatch_dir().join("bin").join(bin_name);
if candidate.exists() {
return candidate;
}
if let Ok(current_exe) = std::env::current_exe() {
if let Some(dir) = current_exe.parent() {
let candidate = dir.join(bin_name);
if candidate.exists() {
return candidate;
}
}
}
PathBuf::from(bin_name)
}
pub fn detect_agent() -> Result<DetectedAgent, OlError> {
claude_code::detect()
.map(|claude_dir| DetectedAgent::ClaudeCode {
settings_path: claude_code::settings_json_path(&claude_dir),
claude_dir,
})
.ok_or_else(|| {
OlError::new(ERR_HOOK_AGENT_NOT_FOUND, "No AI agents detected")
.with_suggestion("Install Claude Code (https://claude.ai/download) and try again.")
.with_docs("https://docs.openlatch.ai/errors/OL-1400")
})
}
pub fn install_hooks(
agent: &DetectedAgent,
port: u16,
token: &str,
) -> Result<HookInstallResult, OlError> {
match agent {
DetectedAgent::ClaudeCode { settings_path, .. } => {
const TOKEN_ENV_VAR: &str = "OPENLATCH_TOKEN";
const PORT_ENV_VAR: &str = "OPENLATCH_PORT";
const EVENT_TYPES: [&str; 12] = [
"PreToolUse",
"PostToolUse",
"UserPromptSubmit",
"Notification",
"Stop",
"SubagentStop",
"PreCompact",
"SessionStart",
"SessionEnd",
"ConfigChange",
"InstructionsLoaded",
"FileChanged",
];
let openlatch_dir = crate::config::openlatch_dir();
let hook_bin = resolve_hook_binary_path();
if !hook_bin.is_file() {
return Err(OlError::new(
ERR_HOOK_BINARY_UNRESOLVABLE,
format!(
"Refusing to install hooks: '{}' is not an existing file",
hook_bin.display()
),
)
.with_suggestion(
"Run 'openlatch doctor --fix' to stage the hook binary, or point \
OPENLATCH_HOOK_BIN at an existing one.",
));
}
let key_store = HmacKeyStore::new(&openlatch_dir);
let hmac_key = key_store.load_or_create()?;
let token_fp = crate::core::hook_state::key::key_fingerprint(token.as_bytes());
let settings_path_hash = hook_state::hash_settings_path(settings_path);
let mut entries_with_markers: Vec<(String, serde_json::Value, String)> = Vec::new();
for &et in &EVENT_TYPES {
let entry_id = uuid::Uuid::now_v7().to_string();
let mut marker = OpenlatchMarker::new(entry_id.clone());
let mut entry =
claude_code::build_hook_entry(et, port, TOKEN_ENV_VAR, &hook_bin, &marker);
let hmac_value = compute_entry_hmac(&entry, &hmac_key)?;
marker = marker.with_hmac(hmac_value.clone());
let marker_value =
serde_json::to_value(&marker).expect("OpenlatchMarker serializes");
entry["_openlatch"] = marker_value;
entries_with_markers.push((et.to_string(), entry, entry_id));
}
let jsonc_entries: Vec<(String, serde_json::Value)> = entries_with_markers
.iter()
.map(|(et, entry, _)| (et.clone(), entry.clone()))
.collect();
let token_owned = token.to_string();
let actions = std::cell::RefCell::new(Vec::new());
atomic::atomic_rewrite_jsonc(settings_path, |root| {
let a = jsonc::insert_hook_entries_cst(root, &jsonc_entries)?;
jsonc::set_env_var_cst(root, TOKEN_ENV_VAR, &token_owned)?;
jsonc::set_env_var_cst(root, PORT_ENV_VAR, &port.to_string())?;
*actions.borrow_mut() = a;
Ok(())
})?;
let actions = actions.into_inner();
let mut state = HookStateFile::load(&openlatch_dir)?
.unwrap_or_else(|| HookStateFile::new("kid-01".into()));
for (et, entry, entry_id) in &entries_with_markers {
let hmac_val = entry["_openlatch"]["hmac"]
.as_str()
.map(str::to_string)
.unwrap_or_default();
state.upsert_entry(StateEntry {
id: entry_id.clone(),
agent: "claude-code".into(),
settings_path_hash: settings_path_hash.clone(),
hook_event: et.clone(),
expected_entry_hmac: hmac_val,
daemon_port_at_install: port,
daemon_token_fp: token_fp.clone(),
v: 1,
});
}
if let Err(e) = state.save(&openlatch_dir) {
tracing::warn!(
code = crate::error::ERR_STATE_FILE_WRITE_FAILED,
error = %e,
"failed to write hook state file — hooks installed but state file out of sync"
);
}
let entries = EVENT_TYPES
.iter()
.zip(actions)
.map(|(&et, action)| HookEntryStatus {
event_type: et.to_string(),
action,
})
.collect();
Ok(HookInstallResult { entries })
}
}
}
pub fn remove_hooks(agent: &DetectedAgent) -> Result<(), OlError> {
match agent {
DetectedAgent::ClaudeCode { settings_path, .. } => {
if !settings_path.exists() {
return Ok(());
}
atomic::atomic_rewrite_jsonc(settings_path, |root| {
jsonc::remove_owned_entries_cst(root)
})?;
Ok(())
}
}
}
pub const ANTHROPIC_BASE_URL_ENV: &str = "ANTHROPIC_BASE_URL";
pub const ANTHROPIC_CUSTOM_HEADERS_ENV: &str = "ANTHROPIC_CUSTOM_HEADERS";
const INSTALL_ID_HEADER: &str = "x-openlatch-install-id";
fn is_install_id_line(line: &str) -> bool {
line.split_once(':')
.map(|(name, _)| name.trim().eq_ignore_ascii_case(INSTALL_ID_HEADER))
.unwrap_or(false)
}
fn merge_install_id_header(existing: Option<&str>, install_id: &str) -> String {
let kept = existing.map(strip_install_id_header).unwrap_or_default();
let our_line = format!("{INSTALL_ID_HEADER}: {install_id}");
if kept.is_empty() {
our_line
} else {
format!("{kept}\n{our_line}")
}
}
fn strip_install_id_header(existing: &str) -> String {
existing
.split('\n')
.filter(|line| !line.trim().is_empty() && !is_install_id_line(line))
.collect::<Vec<_>>()
.join("\n")
}
fn is_openlatch_loopback_base_url(value: &str) -> bool {
reqwest::Url::parse(value.trim())
.ok()
.and_then(|u| u.host_str().map(|h| h == "127.0.0.1"))
.unwrap_or(false)
}
pub fn write_boundary_config(
settings_path: &std::path::Path,
port: u16,
install_id: &str,
) -> Result<(), OlError> {
let base_url = format!("http://127.0.0.1:{port}");
atomic::atomic_rewrite_jsonc(settings_path, |root| {
jsonc::set_env_var_cst(root, ANTHROPIC_BASE_URL_ENV, &base_url)?;
let existing = jsonc::get_env_var_cst(root, ANTHROPIC_CUSTOM_HEADERS_ENV);
let merged = merge_install_id_header(existing.as_deref(), install_id);
jsonc::set_env_var_cst(root, ANTHROPIC_CUSTOM_HEADERS_ENV, &merged)?;
Ok(())
})
}
pub fn remove_boundary_config(settings_path: &std::path::Path) -> Result<(), OlError> {
if !settings_path.exists() {
return Ok(());
}
atomic::atomic_rewrite_jsonc(settings_path, |root| {
if let Some(current) = jsonc::get_env_var_cst(root, ANTHROPIC_BASE_URL_ENV) {
if is_openlatch_loopback_base_url(¤t) {
jsonc::remove_env_var_cst(root, ANTHROPIC_BASE_URL_ENV)?;
}
}
if let Some(current) = jsonc::get_env_var_cst(root, ANTHROPIC_CUSTOM_HEADERS_ENV) {
let remainder = strip_install_id_header(¤t);
if remainder.trim().is_empty() {
jsonc::remove_env_var_cst(root, ANTHROPIC_CUSTOM_HEADERS_ENV)?;
} else {
jsonc::set_env_var_cst(root, ANTHROPIC_CUSTOM_HEADERS_ENV, &remainder)?;
}
}
Ok(())
})
}
#[cfg(test)]
mod tests {
#[test]
#[cfg(unix)]
fn test_detect_agent_returns_ol_1400_when_no_claude_dir() {
use super::detect_agent;
use crate::error::ERR_HOOK_AGENT_NOT_FOUND;
let dir = tempfile::tempdir().unwrap();
std::env::set_var("HOME", dir.path());
let result = detect_agent();
std::env::remove_var("HOME");
let err = result.unwrap_err();
assert_eq!(
err.code, ERR_HOOK_AGENT_NOT_FOUND,
"Expected OL-1400, got {}",
err.code
);
}
use super::{remove_boundary_config, write_boundary_config, ANTHROPIC_CUSTOM_HEADERS_ENV};
fn read_env(path: &std::path::Path) -> serde_json::Value {
let raw = std::fs::read_to_string(path).unwrap();
serde_json::from_str(&raw).unwrap()
}
#[test]
fn boundary_enable_preserves_existing_custom_headers() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
std::fs::write(
&path,
r#"{"env":{"ANTHROPIC_CUSTOM_HEADERS":"x-corp-proxy: foo"}}"#,
)
.unwrap();
write_boundary_config(&path, 7600, "agt_x").unwrap();
let v = read_env(&path);
let headers = v["env"][ANTHROPIC_CUSTOM_HEADERS_ENV].as_str().unwrap();
assert!(
headers.contains("x-corp-proxy: foo"),
"customer header must survive enable: {headers}"
);
assert!(
headers.contains("x-openlatch-install-id: agt_x"),
"our install-id line must be added: {headers}"
);
assert_eq!(v["env"]["ANTHROPIC_BASE_URL"], "http://127.0.0.1:7600");
}
#[test]
fn boundary_disable_keeps_customer_headers_and_drops_ours() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
std::fs::write(
&path,
r#"{"env":{"ANTHROPIC_CUSTOM_HEADERS":"x-corp-proxy: foo"}}"#,
)
.unwrap();
write_boundary_config(&path, 7600, "agt_x").unwrap();
remove_boundary_config(&path).unwrap();
let v = read_env(&path);
let headers = v["env"][ANTHROPIC_CUSTOM_HEADERS_ENV].as_str().unwrap();
assert!(
headers.contains("x-corp-proxy: foo"),
"customer header must remain after disable: {headers}"
);
assert!(
!headers.contains("x-openlatch-install-id"),
"our install-id line must be gone: {headers}"
);
assert!(v["env"].get("ANTHROPIC_BASE_URL").is_none());
}
#[test]
fn boundary_disable_removes_headers_key_when_only_ours_existed() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
std::fs::write(&path, "{}").unwrap();
write_boundary_config(&path, 7600, "agt_x").unwrap();
remove_boundary_config(&path).unwrap();
let v = read_env(&path);
assert!(
v["env"].get(ANTHROPIC_CUSTOM_HEADERS_ENV).is_none(),
"an all-ours header value must be removed entirely: {v}"
);
}
#[test]
fn boundary_disable_leaves_non_loopback_base_url_untouched() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
std::fs::write(
&path,
r#"{"env":{"ANTHROPIC_BASE_URL":"https://gateway.corp.example"}}"#,
)
.unwrap();
remove_boundary_config(&path).unwrap();
let v = read_env(&path);
assert_eq!(
v["env"]["ANTHROPIC_BASE_URL"], "https://gateway.corp.example",
"a non-loopback base URL must be left untouched: {v}"
);
}
}