pub mod atomic;
pub mod binding;
pub mod bindings;
pub mod boundary_endpoints;
pub mod claude_code;
pub mod codex_cli;
pub mod health;
pub mod jsonc;
pub mod staging;
use std::path::PathBuf;
use std::sync::Arc;
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};
use crate::hooks::binding::AgentBinding;
#[derive(Clone)]
pub struct DetectedAgent {
pub kind: AgentKind,
pub binding: Arc<dyn AgentBinding>,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentKind {
ClaudeCode,
CodexCli,
}
impl DetectedAgent {
pub fn config_dir(&self) -> PathBuf {
self.binding.config_dir()
}
pub fn settings_path(&self) -> PathBuf {
self.binding.hook_config_path()
}
pub fn agent_type(&self) -> &'static str {
self.binding.agent_type()
}
pub fn display_name(&self) -> &'static str {
self.binding.display_name()
}
}
impl std::fmt::Debug for DetectedAgent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DetectedAgent")
.field("kind", &self.kind)
.field("config_dir", &self.binding.config_dir())
.finish()
}
}
#[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_agents() -> Vec<DetectedAgent> {
binding::detect_all()
}
pub fn detect_agent() -> Result<DetectedAgent, OlError> {
detect_agents()
.into_iter()
.next()
.ok_or_else(agent_not_found_err)
}
pub(crate) fn agent_not_found_err() -> OlError {
OlError::new(ERR_HOOK_AGENT_NOT_FOUND, "No AI agents detected")
.with_suggestion(format!(
"Install a supported agent ({}) and try again.",
binding::DETECTABLE_AGENT_NAMES.join(", ")
))
.with_docs("https://docs.openlatch.ai/errors/OL-1400")
}
pub fn select_agents(
agents: Vec<DetectedAgent>,
wanted: &[String],
) -> Result<Vec<DetectedAgent>, OlError> {
if wanted.is_empty() {
return Ok(agents);
}
let known = crate::generated::known_values::SCHEMA_AGENT_TYPES;
for name in wanted {
if !known.contains(&name.as_str()) {
return Err(OlError::new(
ERR_HOOK_AGENT_NOT_FOUND,
format!("Unknown agent type '{name}'"),
)
.with_suggestion(format!("Valid values: {}.", known.join(", ")))
.with_docs("https://docs.openlatch.ai/errors/OL-1400"));
}
if !agents.iter().any(|a| a.agent_type() == name) {
return Err(OlError::new(
ERR_HOOK_AGENT_NOT_FOUND,
format!("Agent '{name}' was not detected on this machine"),
)
.with_suggestion(format!(
"Detected agents: {}. Omit --agent to cover every one of them.",
if agents.is_empty() {
"none".to_string()
} else {
agents
.iter()
.map(DetectedAgent::agent_type)
.collect::<Vec<_>>()
.join(", ")
}
))
.with_docs("https://docs.openlatch.ai/errors/OL-1400"));
}
}
Ok(agents
.into_iter()
.filter(|a| wanted.iter().any(|w| w == a.agent_type()))
.collect())
}
pub const OPENLATCH_TOKEN_ENV: &str = "OPENLATCH_TOKEN";
pub const OPENLATCH_PORT_ENV: &str = "OPENLATCH_PORT";
pub fn install_hooks(
binding: &dyn AgentBinding,
port: u16,
token: &str,
) -> Result<HookInstallResult, OlError> {
let settings_path = binding.hook_config_path();
let event_types = binding.hook_event_types();
let env_keys = match binding.daemon_channel() {
binding::DaemonChannel::EnvVars {
token: token_env,
port: port_env,
} => Some((token_env, port_env)),
binding::DaemonChannel::OpenlatchDirArg => None,
};
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 = binding.build_hook_entry(et, &hook_bin, port, &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)?;
if let Some((token_env, port_env)) = env_keys {
jsonc::set_env_var_cst(root, token_env, &token_owned)?;
jsonc::set_env_var_cst(root, port_env, &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: binding.agent_type().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(binding: &dyn AgentBinding) -> Result<(), OlError> {
let settings_path = binding.hook_config_path();
if !settings_path.exists() {
return Ok(());
}
let env_keys = match binding.daemon_channel() {
binding::DaemonChannel::EnvVars {
token: token_env,
port: port_env,
} => Some((token_env, port_env)),
binding::DaemonChannel::OpenlatchDirArg => None,
};
atomic::atomic_rewrite_jsonc(&settings_path, |root| {
jsonc::remove_owned_entries_cst(root)?;
if let Some((token_env, port_env)) = env_keys {
jsonc::remove_env_var_cst(root, token_env)?;
jsonc::remove_env_var_cst(root, port_env)?;
}
Ok(())
})?;
Ok(())
}
pub const ANTHROPIC_BASE_URL_ENV: &str = "ANTHROPIC_BASE_URL";
pub const ANTHROPIC_CUSTOM_HEADERS_ENV: &str = "ANTHROPIC_CUSTOM_HEADERS";
const NO_PROXY_ENV_KEYS: [&str; 2] = ["NO_PROXY", "no_proxy"];
const LOOPBACK_BYPASS_ENTRIES: [&str; 2] = ["127.0.0.1", "localhost"];
fn merge_loopback_entries(existing: Option<&str>) -> String {
let mut entries: Vec<String> = existing
.unwrap_or_default()
.split(',')
.map(str::trim)
.filter(|e| !e.is_empty())
.map(str::to_string)
.collect();
for wanted in LOOPBACK_BYPASS_ENTRIES {
if !entries.iter().any(|e| e.eq_ignore_ascii_case(wanted)) {
entries.push(wanted.to_string());
}
}
entries.join(",")
}
fn is_install_id_line(line: &str, header: &str) -> bool {
line.split_once(':')
.map(|(name, _)| name.trim().eq_ignore_ascii_case(header))
.unwrap_or(false)
}
fn merge_install_id_header(existing: Option<&str>, header: &str, install_id: &str) -> String {
let kept = existing
.map(|value| strip_install_id_header(value, header))
.unwrap_or_default();
let our_line = format!("{header}: {install_id}");
if kept.is_empty() {
our_line
} else {
format!("{kept}\n{our_line}")
}
}
fn strip_install_id_header(existing: &str, header: &str) -> String {
existing
.split('\n')
.filter(|line| !line.trim().is_empty() && !is_install_id_line(line, header))
.collect::<Vec<_>>()
.join("\n")
}
pub(crate) 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 boundary_config_path(binding: &dyn AgentBinding) -> Option<PathBuf> {
match binding.boundary_wiring()?.endpoint {
binding::EndpointConvention::EnvVars { .. } => Some(binding.hook_config_path()),
binding::EndpointConvention::TomlProvider { .. } => {
Some(codex_cli::config_toml_path(&binding.config_dir()))
}
}
}
pub fn write_boundary_config(
binding: &dyn AgentBinding,
port: u16,
install_id: &str,
) -> Result<(), OlError> {
let Some(wiring) = binding.boundary_wiring() else {
return Ok(());
};
let agent = binding.agent_type();
match wiring.endpoint {
binding::EndpointConvention::EnvVars { base_url, headers } => {
let settings_path = binding.hook_config_path();
let our_base_url = format!("http://127.0.0.1:{port}");
atomic::atomic_rewrite_jsonc(&settings_path, |root| {
let current = jsonc::get_env_var_cst(root, base_url);
let already_ours = current
.as_deref()
.map(is_openlatch_loopback_base_url)
.unwrap_or(false);
if !already_ours {
boundary_endpoints::record(agent, current)?;
}
jsonc::set_env_var_cst(root, base_url, &our_base_url)?;
let existing = jsonc::get_env_var_cst(root, headers);
let merged = merge_install_id_header(
existing.as_deref(),
wiring.install_id_header,
install_id,
);
jsonc::set_env_var_cst(root, headers, &merged)?;
for key in NO_PROXY_ENV_KEYS {
let existing = jsonc::get_env_var_cst(root, key);
let merged = merge_loopback_entries(existing.as_deref());
jsonc::set_env_var_cst(root, key, &merged)?;
}
Ok(())
})
}
binding::EndpointConvention::TomlProvider {
provider_name,
wire_api,
} => {
let config_toml = codex_cli::config_toml_path(&binding.config_dir());
let prior = codex_cli::read_prior_provider(&config_toml, provider_name)?;
if let codex_cli::Prior::Theirs(ref p) = prior {
boundary_endpoints::record(agent, p.clone())?;
}
let write = codex_cli::write_provider_table(
&config_toml,
provider_name,
wire_api,
wiring.install_id_header,
port,
install_id,
);
if write.is_err() && matches!(prior, codex_cli::Prior::Theirs(_)) {
boundary_endpoints::forget(agent);
}
write.map(|_| ())
}
}
}
pub fn remove_boundary_config(binding: &dyn AgentBinding) -> Result<(), OlError> {
let Some(wiring) = binding.boundary_wiring() else {
return Ok(());
};
let agent = binding.agent_type();
match wiring.endpoint {
binding::EndpointConvention::EnvVars { base_url, headers } => {
let settings_path = binding.hook_config_path();
if !settings_path.exists() {
return Ok(());
}
atomic::atomic_rewrite_jsonc(&settings_path, |root| {
if let Some(current) = jsonc::get_env_var_cst(root, base_url) {
if is_openlatch_loopback_base_url(¤t) {
match boundary_endpoints::peek(agent) {
Some(Some(prior)) => jsonc::set_env_var_cst(root, base_url, &prior)?,
Some(None) | None => jsonc::remove_env_var_cst(root, base_url)?,
}
}
}
if let Some(current) = jsonc::get_env_var_cst(root, headers) {
let remainder = strip_install_id_header(¤t, wiring.install_id_header);
if remainder.trim().is_empty() {
jsonc::remove_env_var_cst(root, headers)?;
} else {
jsonc::set_env_var_cst(root, headers, &remainder)?;
}
}
Ok(())
})?;
boundary_endpoints::forget(agent);
Ok(())
}
binding::EndpointConvention::TomlProvider { provider_name, .. } => {
let config_toml = codex_cli::config_toml_path(&binding.config_dir());
let is_ours = std::fs::read_to_string(&config_toml)
.ok()
.and_then(|raw| raw.parse::<toml_edit::DocumentMut>().ok())
.and_then(|doc| codex_cli::provider_table_is_ours(&doc, provider_name))
== Some(true);
if !is_ours {
return Ok(());
}
let result = codex_cli::remove_provider_table(
&config_toml,
provider_name,
boundary_endpoints::peek(agent),
);
if result.is_ok() {
boundary_endpoints::forget(agent);
}
result
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn loopback_entries_are_added_when_the_list_is_empty() {
assert_eq!(super::merge_loopback_entries(None), "127.0.0.1,localhost");
assert_eq!(
super::merge_loopback_entries(Some("")),
"127.0.0.1,localhost"
);
}
#[test]
fn customer_entries_survive_byte_for_byte() {
assert_eq!(
super::merge_loopback_entries(Some("internal.corp,10.0.0.0/8,.corp.example")),
"internal.corp,10.0.0.0/8,.corp.example,127.0.0.1,localhost"
);
}
#[test]
fn the_merge_is_idempotent() {
let once = super::merge_loopback_entries(Some("internal.corp"));
let twice = super::merge_loopback_entries(Some(&once));
assert_eq!(once, twice);
assert_eq!(
super::merge_loopback_entries(Some("LOCALHOST,internal.corp")),
"LOCALHOST,internal.corp,127.0.0.1"
);
}
#[test]
fn spacing_does_not_produce_duplicates() {
assert_eq!(
super::merge_loopback_entries(Some(" localhost , internal.corp ")),
"localhost,internal.corp,127.0.0.1"
);
}
#[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 _env = crate::hooks::claude_code::CONFIG_DIR_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 dir = tempfile::tempdir().unwrap();
let prev_claude = std::env::var(crate::hooks::claude_code::CONFIG_DIR_ENV).ok();
let prev_codex = std::env::var(crate::hooks::codex_cli::CONFIG_DIR_ENV).ok();
std::env::remove_var(crate::hooks::claude_code::CONFIG_DIR_ENV);
std::env::remove_var(crate::hooks::codex_cli::CONFIG_DIR_ENV);
std::env::set_var("HOME", dir.path());
let result = detect_agent();
std::env::remove_var("HOME");
if let Some(v) = prev_claude {
std::env::set_var(crate::hooks::claude_code::CONFIG_DIR_ENV, v);
}
if let Some(v) = prev_codex {
std::env::set_var(crate::hooks::codex_cli::CONFIG_DIR_ENV, v);
}
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};
use crate::hooks::binding::test_support::FakeBinding;
use crate::hooks::binding::{BoundaryWiring, EndpointConvention};
fn envvars_agent(dir: &std::path::Path) -> FakeBinding {
FakeBinding {
agent_type: "claude-code",
config_dir: dir.to_path_buf(),
boundary_wiring: Some(BoundaryWiring {
wire_format: crate::boundary::wire_format::WireFormat::AnthropicMessages,
endpoint: EndpointConvention::EnvVars {
base_url: super::ANTHROPIC_BASE_URL_ENV,
headers: super::ANTHROPIC_CUSTOM_HEADERS_ENV,
},
install_id_header: "x-openlatch-install-id",
}),
..Default::default()
}
}
fn with_openlatch_dir<T>(f: impl FnOnce() -> T) -> T {
let _guard = crate::config::OPENLATCH_DIR_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().expect("tempdir");
let prev = std::env::var_os("OPENLATCH_DIR");
std::env::set_var("OPENLATCH_DIR", tmp.path());
let out = f();
match prev {
Some(v) => std::env::set_var("OPENLATCH_DIR", v),
None => std::env::remove_var("OPENLATCH_DIR"),
}
out
}
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 agent = envvars_agent(dir.path());
let path = agent.hook_config_path();
std::fs::write(
&path,
r#"{"env":{"ANTHROPIC_CUSTOM_HEADERS":"x-corp-proxy: foo"}}"#,
)
.unwrap();
with_openlatch_dir(|| write_boundary_config(&agent, 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 agent = envvars_agent(dir.path());
let path = agent.hook_config_path();
std::fs::write(
&path,
r#"{"env":{"ANTHROPIC_CUSTOM_HEADERS":"x-corp-proxy: foo"}}"#,
)
.unwrap();
with_openlatch_dir(|| {
write_boundary_config(&agent, 7600, "agt_x").unwrap();
remove_boundary_config(&agent).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 agent = envvars_agent(dir.path());
let path = agent.hook_config_path();
std::fs::write(&path, "{}").unwrap();
with_openlatch_dir(|| {
write_boundary_config(&agent, 7600, "agt_x").unwrap();
remove_boundary_config(&agent).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 agent = envvars_agent(dir.path());
let path = agent.hook_config_path();
std::fs::write(
&path,
r#"{"env":{"ANTHROPIC_BASE_URL":"https://gateway.corp.example"}}"#,
)
.unwrap();
with_openlatch_dir(|| remove_boundary_config(&agent).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}"
);
}
#[test]
fn claude_boundary_config_restores_a_prior_base_url() {
let dir = tempfile::tempdir().unwrap();
let agent = envvars_agent(dir.path());
let path = agent.hook_config_path();
std::fs::write(
&path,
r#"{"env":{"ANTHROPIC_BASE_URL":"https://gw.example"}}"#,
)
.unwrap();
with_openlatch_dir(|| {
write_boundary_config(&agent, 7600, "agt_x").unwrap();
assert_eq!(
read_env(&path)["env"]["ANTHROPIC_BASE_URL"],
"http://127.0.0.1:7600",
"precondition: install really did point the agent at us"
);
remove_boundary_config(&agent).unwrap();
});
assert_eq!(
read_env(&path)["env"]["ANTHROPIC_BASE_URL"],
"https://gw.example",
"uninstall must put the customer's own gateway back"
);
}
#[test]
fn reinstall_keeps_the_first_installs_recorded_prior() {
let dir = tempfile::tempdir().unwrap();
let agent = envvars_agent(dir.path());
let path = agent.hook_config_path();
std::fs::write(
&path,
r#"{"env":{"ANTHROPIC_BASE_URL":"https://gw.example"}}"#,
)
.unwrap();
with_openlatch_dir(|| {
write_boundary_config(&agent, 7600, "agt_x").unwrap();
write_boundary_config(&agent, 7600, "agt_x").unwrap();
remove_boundary_config(&agent).unwrap();
});
assert_eq!(
read_env(&path)["env"]["ANTHROPIC_BASE_URL"],
"https://gw.example",
"the second install must not have overwritten the recorded prior"
);
}
use super::{remove_hooks, AgentKind, DetectedAgent, OPENLATCH_PORT_ENV, OPENLATCH_TOKEN_ENV};
fn installed_settings(dir: &std::path::Path) -> DetectedAgent {
let settings_path = dir.join("settings.json");
std::fs::write(
&settings_path,
r#"{
"env": {
"ANOTHER_TOOL_TOKEN": "keep-me",
"OPENLATCH_TOKEN": "not-a-real-token",
"OPENLATCH_PORT": "7443"
},
"hooks": {
"Stop": [
{"_openlatch": {"v": 1, "id": "x"}, "hooks": [{"type": "command", "command": "openlatch-hook"}]},
{"hooks": [{"type": "command", "command": "sh other-tool.sh"}]}
]
}
}"#,
)
.unwrap();
DetectedAgent {
kind: AgentKind::ClaudeCode,
binding: std::sync::Arc::new(crate::hooks::bindings::claude_code::ClaudeCodeBinding {
claude_dir: dir.to_path_buf(),
settings_path,
}),
}
}
struct EnvVars(Vec<(&'static str, Option<std::ffi::OsString>)>);
impl EnvVars {
fn set<const N: usize>(pairs: [(&'static str, &std::ffi::OsStr); N]) -> Self {
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)
}
}
impl Drop for EnvVars {
fn drop(&mut self) {
for (key, value) in self.0.drain(..) {
match value {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
}
}
}
#[test]
fn env_channel_still_writes_the_env_block() {
use crate::hooks::binding::AgentBinding;
use crate::hooks::bindings::claude_code::ClaudeCodeBinding;
let _dir_lock = crate::config::OPENLATCH_DIR_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _lock = crate::hooks::claude_code::CONFIG_DIR_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _bin_lock = crate::hooks::staging::HOOK_BIN_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let ol = tempfile::tempdir().unwrap();
let claude = tempfile::tempdir().unwrap();
let staged = tempfile::tempdir().unwrap();
let hook_bin = staged.path().join("openlatch-hook");
std::fs::write(&hook_bin, b"#!/bin/sh\nexit 0\n").unwrap();
let _env = EnvVars::set([
("OPENLATCH_DIR", ol.path().as_os_str()),
("CLAUDE_CONFIG_DIR", claude.path().as_os_str()),
("OPENLATCH_HOOK_BIN", hook_bin.as_os_str()),
("OPENLATCH_SKIP_KEYRING", std::ffi::OsStr::new("1")),
]);
let binding = ClaudeCodeBinding {
claude_dir: claude.path().to_path_buf(),
settings_path: claude.path().join("settings.json"),
};
assert!(
matches!(
binding.daemon_channel(),
super::binding::DaemonChannel::EnvVars { .. }
),
"the premise of this test: Claude Code forwards named env vars"
);
super::install_hooks(&binding, 7443, "a-token").unwrap();
let v = read_env(&binding.settings_path);
assert_eq!(
v["env"][OPENLATCH_TOKEN_ENV], "a-token",
"an EnvVars channel still pins the token: {v}"
);
assert_eq!(
v["env"][OPENLATCH_PORT_ENV], "7443",
"and the port beside it: {v}"
);
}
#[test]
fn remove_hooks_takes_the_env_keys_install_wrote() {
let dir = tempfile::tempdir().unwrap();
let agent = installed_settings(dir.path());
let settings_path = agent.settings_path();
remove_hooks(&*agent.binding).unwrap();
let v = read_env(&settings_path);
for key in [OPENLATCH_TOKEN_ENV, OPENLATCH_PORT_ENV] {
assert!(
v["env"].get(key).is_none(),
"install wrote {key}; uninstall must take it back: {v}"
);
}
}
#[test]
fn remove_hooks_leaves_every_env_key_that_is_not_ours() {
let dir = tempfile::tempdir().unwrap();
let agent = installed_settings(dir.path());
let settings_path = agent.settings_path();
remove_hooks(&*agent.binding).unwrap();
let v = read_env(&settings_path);
assert_eq!(
v["env"]["ANOTHER_TOOL_TOKEN"], "keep-me",
"a key we never wrote must survive uninstall: {v}"
);
let stop = v["hooks"]["Stop"].as_array().unwrap();
assert_eq!(stop.len(), 1, "only the owned entry may go: {v}");
assert_eq!(stop[0]["hooks"][0]["command"], "sh other-tool.sh");
}
const CUSTOMER_GROUP: &str = r#"{"hooks":{"PostToolUse":[{"matcher":"","hooks":[{"type":"command","command":"echo mine","timeout":5}]}]}}"#;
use crate::hooks::binding::AgentBinding as _;
fn with_codex_install_env<T>(
f: impl FnOnce(&crate::hooks::bindings::codex_cli::CodexCliBinding) -> T,
) -> T {
use crate::hooks::bindings::codex_cli::CodexCliBinding;
let _dir_lock = crate::config::OPENLATCH_DIR_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _bin_lock = crate::hooks::staging::HOOK_BIN_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _codex_lock = crate::hooks::codex_cli::CONFIG_DIR_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let ol = tempfile::tempdir().unwrap();
let codex = tempfile::tempdir().unwrap();
let staged = tempfile::tempdir().unwrap();
let hook_bin = staged.path().join("openlatch-hook");
std::fs::write(&hook_bin, b"#!/bin/sh\nexit 0\n").unwrap();
let _env = EnvVars::set([
("OPENLATCH_DIR", ol.path().as_os_str()),
("OPENLATCH_HOOK_BIN", hook_bin.as_os_str()),
("OPENLATCH_SKIP_KEYRING", std::ffi::OsStr::new("1")),
("CODEX_HOME", codex.path().as_os_str()),
]);
let binding =
CodexCliBinding::detect().expect("a $CODEX_HOME that exists must be detected");
f(&binding)
}
#[test]
fn install_writes_twelve_events() {
with_codex_install_env(|binding| {
super::install_hooks(binding, 7443, "a-token").expect("install must succeed");
let v = read_env(&binding.hook_config_path());
let hooks = v["hooks"].as_object().expect("a hooks object: {v}");
assert_eq!(hooks.len(), 12, "twelve event keys: {v}");
assert!(
hooks.contains_key("PreToolUse"),
"PreToolUse is the registration — without it no Codex deny is ever \
evaluated: {v}"
);
for (event, groups) in hooks {
let group = &groups.as_array().unwrap()[0];
let expected = if event == "PreToolUse" { "Bash" } else { "" };
assert_eq!(
group["matcher"].as_str(),
Some(expected),
"{event} carries the wrong matcher: {group}"
);
}
let top: Vec<&String> = v.as_object().unwrap().keys().collect();
assert_eq!(top, vec!["hooks"], "top level must carry only `hooks`: {v}");
for (event, groups) in hooks {
let group = &groups.as_array().unwrap()[0];
let handler = &group["hooks"][0];
assert!(
handler["timeout"].is_number(),
"{event} has no explicit timeout: {group}"
);
assert!(
handler.get("async").is_none(),
"{event} sets async: {group}"
);
let command = handler["command"].as_str().unwrap();
assert!(
command.contains("--openlatch-dir"),
"{event} must carry the directory the hook reads its port and \
token from: {command}"
);
assert!(
!command.contains("--event unknown"),
"{event} installed as `--event unknown` — pascal_to_snake is \
missing an arm: {command}"
);
}
});
}
#[test]
fn install_appends_and_preserves_a_customer_group() {
with_codex_install_env(|binding| {
let path = binding.hook_config_path();
std::fs::write(&path, CUSTOMER_GROUP).unwrap();
let seeded: serde_json::Value = serde_json::from_str(CUSTOMER_GROUP).unwrap();
let theirs = seeded["hooks"]["PostToolUse"][0].clone();
super::install_hooks(binding, 7443, "a-token").expect("install must succeed");
let v = read_env(&path);
let arr = v["hooks"]["PostToolUse"].as_array().unwrap();
assert_eq!(arr.len(), 2, "ours is appended beside theirs: {v}");
assert_eq!(
arr[0], theirs,
"the customer's group must be untouched: {v}"
);
assert!(
arr[1].get("_openlatch").is_some(),
"ours must be the appended one: {v}"
);
});
}
#[test]
fn reinstall_does_not_move_a_later_customer_group() {
with_codex_install_env(|binding| {
let path = binding.hook_config_path();
super::install_hooks(binding, 7443, "a-token").expect("first install");
let mut v = read_env(&path);
v["hooks"]["PostToolUse"]
.as_array_mut()
.expect("PostToolUse is one of the twelve")
.push(serde_json::json!({
"matcher": "",
"hooks": [{"type": "command", "command": "echo later", "timeout": 5}],
}));
std::fs::write(&path, serde_json::to_string_pretty(&v).unwrap()).unwrap();
super::install_hooks(binding, 7443, "a-token").expect("re-install");
let v = read_env(&path);
let arr = v["hooks"]["PostToolUse"].as_array().unwrap();
assert_eq!(arr.len(), 2, "a re-install replaces, never duplicates: {v}");
assert!(
arr[0].get("_openlatch").is_some(),
"ours must be replaced IN PLACE at index 0: {v}"
);
assert_eq!(
arr[1]["hooks"][0]["command"], "echo later",
"the customer's later group must still be at index 1 — \
remove-then-append would have moved it to 0: {v}"
);
});
}
#[test]
fn uninstall_restores_the_seeded_file_byte_for_byte() {
with_codex_install_env(|binding| {
let path = binding.hook_config_path();
std::fs::write(&path, CUSTOMER_GROUP).unwrap();
let seeded: serde_json::Value = serde_json::from_str(CUSTOMER_GROUP).unwrap();
super::install_hooks(binding, 7443, "a-token").expect("install must succeed");
super::remove_hooks(binding).expect("uninstall must succeed");
let customer_group = CUSTOMER_GROUP
.split_once("\"PostToolUse\":[")
.and_then(|(_, rest)| rest.rsplit_once("]"))
.map(|(group, _)| group)
.expect("the seed's customer group");
assert!(
std::fs::read_to_string(&path)
.unwrap()
.contains(customer_group),
"uninstall must give the customer's own group back as the exact text \
they wrote, not a reserialisation of it"
);
let restored = read_env(&path);
assert_eq!(
restored, seeded,
"uninstall must give the customer their file back: every group they \
owned, no leftovers, and none of the ten event keys install added"
);
let keys: Vec<&String> = restored["hooks"].as_object().unwrap().keys().collect();
assert_eq!(
keys,
vec!["PostToolUse"],
"an event key we emptied must be pruned, not left holding []"
);
});
}
#[test]
fn agent_flag_narrows_coverage_and_refuses_a_name_that_is_not_here() {
let root = tempfile::tempdir().unwrap();
let detected = crate::hooks::binding::test_support::two_detected_agents(root.path());
let types = |v: Vec<DetectedAgent>| {
v.iter()
.map(DetectedAgent::agent_type)
.collect::<Vec<&str>>()
};
assert_eq!(
types(super::select_agents(detected.clone(), &[]).unwrap()),
vec!["claude-code", "cursor"],
"no --agent means every detected agent"
);
assert_eq!(
types(super::select_agents(detected.clone(), &["cursor".to_string()]).unwrap()),
vec!["cursor"],
"a detected agent nobody named is skipped, silently"
);
assert_eq!(
types(
super::select_agents(
detected.clone(),
&["cursor".to_string(), "claude-code".to_string()],
)
.unwrap()
),
vec!["claude-code", "cursor"],
"detection order wins over flag order — it is load-bearing elsewhere"
);
let err = super::select_agents(detected.clone(), &["gemini-cli".to_string()])
.expect_err("a named-but-undetected agent must fail");
assert_eq!(err.code, crate::error::ERR_HOOK_AGENT_NOT_FOUND);
assert!(
err.message.contains("gemini-cli"),
"the error must NAME the agent, or the operator cannot see their typo: {}",
err.message
);
let err = super::select_agents(detected, &["claude_code".to_string()])
.expect_err("an unknown agent type must fail");
assert_eq!(err.code, crate::error::ERR_HOOK_AGENT_NOT_FOUND);
assert!(
err.suggestion
.as_deref()
.is_some_and(|s| s.contains("claude-code")),
"the remedy must list the valid values: {err:?}"
);
}
#[test]
fn uninstall_of_an_unseeded_file_leaves_an_empty_object() {
with_codex_install_env(|binding| {
let path = binding.hook_config_path();
assert!(!path.exists(), "the fixture starts with no hooks.json");
super::install_hooks(binding, 7443, "a-token").expect("install must succeed");
super::remove_hooks(binding).expect("uninstall must succeed");
assert_eq!(std::fs::read_to_string(&path).unwrap(), "{}");
});
}
}