pub mod atomic;
pub mod binding;
pub mod bindings;
pub mod claude_code;
pub mod cline;
pub mod cline_plugin;
pub mod cline_providers;
pub mod codex_cli;
pub mod health;
pub mod hook_files;
pub mod jsonc;
pub mod model_relay_endpoints;
pub mod provider_endpoints;
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, HookSurface};
#[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,
Cline,
}
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 hook_surface(&self) -> HookSurface {
self.binding.hook_surface()
}
pub fn agent_type(&self) -> &'static str {
self.binding.agent_type()
}
pub fn display_name(&self) -> &'static str {
self.binding.display_name()
}
pub fn installable(&self) -> bool {
self.binding.installable()
}
}
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>,
pub left_alone: Vec<std::path::PathBuf>,
}
#[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> {
if !binding.installable() {
return Ok(HookInstallResult {
entries: Vec::new(),
left_alone: Vec::new(),
});
}
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 settings_path = match binding.hook_surface() {
HookSurface::ConfigFile(path) => path,
HookSurface::Directory(dir) => {
return install_hook_files(binding, &dir, &hook_bin, &openlatch_dir, port, token);
}
};
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 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(),
descriptor: None,
v: hook_state::STATE_ENTRY_VERSION,
});
}
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,
left_alone: Vec::new(),
})
}
fn install_hook_files(
binding: &dyn AgentBinding,
hooks_dir: &std::path::Path,
hook_bin: &std::path::Path,
openlatch_dir: &std::path::Path,
port: u16,
token: &str,
) -> Result<HookInstallResult, OlError> {
let report = hook_files::write_all(hooks_dir, hook_bin, openlatch_dir)?;
let written = &report.written;
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(hooks_dir);
let mut state =
HookStateFile::load(openlatch_dir)?.unwrap_or_else(|| HookStateFile::new("kid-01".into()));
for file in written {
let descriptor = serde_json::to_value(&file.descriptor)
.expect("FileDescriptor is a plain struct and serializes");
let hmac_value = compute_entry_hmac(&descriptor, &hmac_key)?;
state.upsert_entry(StateEntry {
id: file.entry_id.clone(),
agent: binding.agent_type().into(),
settings_path_hash: settings_path_hash.clone(),
hook_event: file.event.clone(),
expected_entry_hmac: hmac_value,
daemon_port_at_install: port,
daemon_token_fp: token_fp.clone(),
descriptor: Some(file.descriptor.clone()),
v: hook_state::STATE_ENTRY_VERSION,
});
}
let entries = report
.written
.iter()
.map(|file| HookEntryStatus {
action: if file.replaced {
HookAction::Replaced
} else {
HookAction::Added
},
event_type: file.event.clone(),
})
.collect();
let mut left_alone = report.left;
let mut plugin_failure: Option<OlError> = None;
if let Some(plugin_dir) = binding.plugin_surface() {
match cline_plugin::install(&plugin_dir, openlatch_dir) {
Ok(cline_plugin::PluginWrite::Written { descriptor, .. })
| Ok(cline_plugin::PluginWrite::AlreadyCurrent(descriptor)) => {
if let Err(e) = record_plugin_entry(
&mut state,
binding,
&descriptor,
&hmac_key,
port,
&token_fp,
) {
plugin_failure = Some(e);
}
}
Ok(cline_plugin::PluginWrite::LeftAlone(path)) => left_alone.push(path),
Err(e) => plugin_failure = Some(e),
}
}
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"
);
}
if let Some(e) = plugin_failure {
return Err(e);
}
Ok(HookInstallResult {
entries,
left_alone,
})
}
fn record_plugin_entry(
state: &mut HookStateFile,
binding: &dyn AgentBinding,
descriptor: &crate::core::hook_state::FileDescriptor,
hmac_key: &[u8],
port: u16,
token_fp: &str,
) -> Result<(), OlError> {
let agent = binding.agent_type();
let settings_path_hash = hook_state::hash_settings_path(std::path::Path::new(&descriptor.path));
let id = state
.entries
.iter()
.find(|e| {
e.agent == agent
&& e.settings_path_hash == settings_path_hash
&& e.hook_event == cline_plugin::PLUGIN_ENTRY_EVENT
})
.map_or_else(|| uuid::Uuid::now_v7().to_string(), |e| e.id.clone());
let value =
serde_json::to_value(descriptor).expect("FileDescriptor is a plain struct and serializes");
let expected_entry_hmac = compute_entry_hmac(&value, hmac_key)?;
state.upsert_entry(StateEntry {
id,
agent: agent.into(),
settings_path_hash,
hook_event: cline_plugin::PLUGIN_ENTRY_EVENT.into(),
expected_entry_hmac,
daemon_port_at_install: port,
daemon_token_fp: token_fp.into(),
descriptor: Some(descriptor.clone()),
v: hook_state::STATE_ENTRY_VERSION,
});
Ok(())
}
pub fn remove_hooks(binding: &dyn AgentBinding) -> Result<(), OlError> {
if !binding.installable() {
return Ok(());
}
let settings_path = match binding.hook_surface() {
HookSurface::ConfigFile(path) => path,
HookSurface::Directory(dir) => return remove_hook_files(binding, &dir),
};
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(())
}
fn remove_hook_files(
binding: &dyn AgentBinding,
hooks_dir: &std::path::Path,
) -> Result<(), OlError> {
let report = hook_files::remove_all(hooks_dir)?;
if !report.left.is_empty() {
tracing::warn!(
dir = %hooks_dir.display(),
removed = report.removed.len(),
left = report.left.len(),
"left hook files we did not write in place; uninstall removed only our own"
);
}
if let Some(plugin_dir) = binding.plugin_surface() {
match cline_plugin::remove(&plugin_dir)? {
cline_plugin::PluginRemoval::Removed(_) | cline_plugin::PluginRemoval::Nothing => {}
cline_plugin::PluginRemoval::LeftAlone(path) => tracing::warn!(
path = %path.display(),
"left a plugin we did not write in place; uninstall removed only our own"
),
}
forget_plugin_entry(binding, &cline_plugin::entry_path(&plugin_dir));
}
Ok(())
}
fn forget_plugin_entry(binding: &dyn AgentBinding, entry_path: &std::path::Path) {
let openlatch_dir = crate::config::openlatch_dir();
let mut state = match HookStateFile::load(&openlatch_dir) {
Ok(Some(state)) => state,
Ok(None) => return,
Err(e) => {
tracing::warn!(
error = %e,
"uninstall: cannot read the hook state file to drop the plugin's row"
);
return;
}
};
if !state.remove_entry(
binding.agent_type(),
&hook_state::hash_settings_path(entry_path),
cline_plugin::PLUGIN_ENTRY_EVENT,
) {
return;
}
if let Err(e) = state.save(&openlatch_dir) {
tracing::warn!(
code = crate::error::ERR_STATE_FILE_WRITE_FAILED,
error = %e,
"uninstall: the plugin is gone but its state row could not be dropped โ \
the reconciler may re-create it"
);
}
}
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 isolated_wiring_hint(binding: &dyn AgentBinding, port: u16) -> String {
match binding.model_relay_wiring().map(|w| w.endpoint) {
Some(binding::EndpointConvention::EnvVars { base_url, .. }) => {
format!("{base_url}=http://127.0.0.1:{port}")
}
Some(binding::EndpointConvention::TomlProvider { provider_name, .. }) => format!(
"a [model_providers.{provider_name}] table with base_url = \"http://127.0.0.1:{port}/v1\""
),
None => "this agent has no request plane".to_string(),
}
}
pub fn model_relay_config_path(binding: &dyn AgentBinding) -> Option<PathBuf> {
match binding.model_relay_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_model_relay_config(
binding: &dyn AgentBinding,
port: u16,
install_id: &str,
) -> Result<(), OlError> {
let Some(wiring) = binding.model_relay_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 we_wired_it_before = model_relay_endpoints::peek(agent).is_some();
let already_ours = we_wired_it_before
&& current
.as_deref()
.map(is_openlatch_loopback_base_url)
.unwrap_or(false);
if !already_ours {
if let Some(ref endpoint) = current {
model_relay_endpoints::record(
&upstream_record_key(wiring.wire_format),
Some(endpoint.clone()),
)?;
}
model_relay_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 {
model_relay_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(_)) {
model_relay_endpoints::forget(agent);
}
write.map(|_| ())
}
}
}
pub fn remove_model_relay_config(binding: &dyn AgentBinding) -> Result<(), OlError> {
let Some(wiring) = binding.model_relay_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 model_relay_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(())
})?;
model_relay_endpoints::forget(agent);
model_relay_endpoints::forget(&upstream_record_key(wiring.wire_format));
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,
model_relay_endpoints::peek(agent),
);
if result.is_ok() {
model_relay_endpoints::forget(agent);
}
result
}
}
}
pub(crate) fn upstream_record_key(fmt: crate::model_relay::wire_format::WireFormat) -> String {
format!("upstream:{}", fmt.as_str())
}
#[cfg(test)]
mod tests {
#[test]
#[cfg(feature = "model-relay")]
fn claude_and_codex_wiring_unchanged() {
use crate::hooks::binding::{AgentBinding, EndpointConvention};
use crate::model_relay::wire_format::WireFormat;
let root = tempfile::tempdir().expect("temp dir");
let claude = crate::hooks::bindings::claude_code::ClaudeCodeBinding {
claude_dir: root.path().to_path_buf(),
settings_path: root.path().join("settings.json"),
};
let w = claude
.model_relay_wiring()
.expect("Claude Code has a request plane");
assert_eq!(w.wire_format, WireFormat::AnthropicMessages);
assert_eq!(w.install_id_header, "x-openlatch-install-id");
assert!(
matches!(
w.endpoint,
EndpointConvention::EnvVars {
base_url: "ANTHROPIC_BASE_URL",
headers: "ANTHROPIC_CUSTOM_HEADERS",
}
),
"Claude Code's two environment variable names, unchanged: {:?}",
w.endpoint
);
let codex = crate::hooks::bindings::codex_cli::CodexCliBinding {
codex_dir: root.path().to_path_buf(),
hooks_path: root.path().join("hooks.json"),
requirements_toml: None,
};
let w = codex
.model_relay_wiring()
.expect("Codex CLI has a request plane");
assert_eq!(w.wire_format, WireFormat::OpenAiResponses);
assert_eq!(w.install_id_header, "x-openlatch-install-id");
assert!(
matches!(
w.endpoint,
EndpointConvention::TomlProvider {
provider_name: "openlatch",
wire_api: "responses",
}
),
"Codex's provider table name and wire_api, unchanged: {:?}",
w.endpoint
);
assert!(crate::hooks::model_relay_config_path(&claude)
.expect("a wiring path")
.ends_with("settings.json"));
assert!(crate::hooks::model_relay_config_path(&codex)
.expect("a wiring path")
.ends_with("config.toml"));
}
#[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 _cline_env = crate::hooks::cline::SEAM_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let cline_root = tempfile::tempdir().unwrap();
let _cline_seams = crate::hooks::cline::EnvOverride::absent_cline_seams(cline_root.path());
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_model_relay_config, write_model_relay_config, ANTHROPIC_CUSTOM_HEADERS_ENV,
};
use crate::hooks::binding::test_support::FakeBinding;
use crate::hooks::binding::{EndpointConvention, ModelRelayWiring};
fn envvars_agent(dir: &std::path::Path) -> FakeBinding {
FakeBinding {
agent_type: "claude-code",
config_dir: dir.to_path_buf(),
model_relay_wiring: Some(ModelRelayWiring {
wire_format: crate::model_relay::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 model_relay_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_model_relay_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 model_relay_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_model_relay_config(&agent, 7600, "agt_x").unwrap();
remove_model_relay_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 model_relay_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_model_relay_config(&agent, 7600, "agt_x").unwrap();
remove_model_relay_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 model_relay_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_model_relay_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_model_relay_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_model_relay_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_model_relay_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 a_customers_own_loopback_base_url_is_theirs_not_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_BASE_URL":"http://127.0.0.1:8787"}}"#,
)
.unwrap();
let recorded = with_openlatch_dir(|| {
write_model_relay_config(&agent, 7600, "agt_x").unwrap();
let seen = super::model_relay_endpoints::peek(&super::upstream_record_key(
crate::model_relay::wire_format::WireFormat::AnthropicMessages,
));
remove_model_relay_config(&agent).unwrap();
seen
});
assert_eq!(
recorded,
Some(Some("http://127.0.0.1:8787".to_string())),
"a local endpoint we did not write is the customer's, and the relay needs it"
);
assert_eq!(
read_env(&path)["env"]["ANTHROPIC_BASE_URL"],
"http://127.0.0.1:8787",
"and uninstall must put their own endpoint 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_model_relay_config(&agent, 7600, "agt_x").unwrap();
write_model_relay_config(&agent, 7600, "agt_x").unwrap();
remove_model_relay_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 _cline_lock = crate::hooks::cline::SEAM_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 cline_root = tempfile::tempdir().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 _cline_seams = crate::hooks::cline::EnvOverride::absent_cline_seams(cline_root.path());
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 _cline_lock = crate::hooks::cline::SEAM_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 cline_root = tempfile::tempdir().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 _cline_seams = crate::hooks::cline::EnvOverride::absent_cline_seams(cline_root.path());
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(), "{}");
});
}
#[test]
fn install_hooks_refuses_non_installable() {
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 ol = tempfile::tempdir().unwrap();
let agent = 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")),
]);
let refused = FakeBinding {
config_dir: agent.path().join("refused"),
installable: false,
..Default::default()
};
let result = super::install_hooks(&refused, 7443, "a-token")
.expect("a non-installable agent is skipped, never an error");
assert!(
result.entries.is_empty(),
"an install that registered nothing must say so, not report entries"
);
assert!(
!refused.hook_config_path().exists(),
"and it must not have created the settings file: {}",
refused.hook_config_path().display()
);
let left_behind: Vec<std::ffi::OsString> = std::fs::read_dir(ol.path())
.expect("the temp $OPENLATCH_DIR is readable")
.map(|entry| entry.expect("a readable directory entry").file_name())
.collect();
assert!(
left_behind.is_empty(),
"a skipped install must not create an HMAC key or a state file: {left_behind:?}"
);
let writable = FakeBinding {
config_dir: agent.path().join("writable"),
installable: true,
..Default::default()
};
let wrote = super::install_hooks(&writable, 7443, "a-token").expect("install must succeed");
assert!(
!wrote.entries.is_empty() && writable.hook_config_path().exists(),
"the fixture does write when it is installable โ otherwise the assertions \
above prove nothing"
);
}
#[test]
fn remove_hooks_refuses_non_installable() {
let agent = tempfile::tempdir().unwrap();
let refused = FakeBinding {
config_dir: agent.path().to_path_buf(),
installable: false,
..Default::default()
};
let hook_path = refused.hook_config_path();
std::fs::create_dir_all(&hook_path).unwrap();
let theirs = hook_path.join("their-hook");
std::fs::write(&theirs, b"a file we have no business touching").unwrap();
super::remove_hooks(&refused).expect("a non-installable agent is skipped, never an error");
assert!(
hook_path.is_dir(),
"the directory must still be a directory"
);
assert_eq!(
std::fs::read_to_string(&theirs).unwrap(),
"a file we have no business touching",
"and nothing inside it may be touched"
);
let writable = FakeBinding {
config_dir: agent.path().to_path_buf(),
installable: true,
..Default::default()
};
let err = super::remove_hooks(&writable)
.expect_err("the rewrite cannot read a directory as JSONC");
assert_eq!(err.code, crate::error::ERR_HOOK_WRITE_FAILED);
}
struct DirFixture {
_env: EnvVars,
_seams: crate::hooks::cline::EnvOverride,
_dir_lock: std::sync::MutexGuard<'static, ()>,
_home_lock: std::sync::MutexGuard<'static, ()>,
_bin_lock: std::sync::MutexGuard<'static, ()>,
_seam_lock: std::sync::MutexGuard<'static, ()>,
ol: tempfile::TempDir,
store: tempfile::TempDir,
_staged: tempfile::TempDir,
_cline_root: tempfile::TempDir,
hook_bin: std::path::PathBuf,
}
impl DirFixture {
fn new() -> Self {
let dir_lock = crate::config::OPENLATCH_DIR_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let home_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 seam_lock = crate::hooks::cline::SEAM_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let ol = tempfile::tempdir().expect("a temp $OPENLATCH_DIR");
let store = tempfile::tempdir().expect("a temp agent store");
let staged = tempfile::tempdir().expect("a temp staging directory");
let cline_root = tempfile::tempdir().expect("a temp seam root");
let hook_bin = staged.path().join("openlatch-hook");
std::fs::write(&hook_bin, b"#!/bin/sh\nexit 0\n").expect("a staged hook binary");
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")),
]);
let seams = crate::hooks::cline::EnvOverride::absent_cline_seams(cline_root.path());
Self {
_env: env,
_seams: seams,
_dir_lock: dir_lock,
_home_lock: home_lock,
_bin_lock: bin_lock,
_seam_lock: seam_lock,
ol,
store,
_staged: staged,
_cline_root: cline_root,
hook_bin,
}
}
fn hooks_dir(&self) -> std::path::PathBuf {
self.store.path().join("Hooks")
}
fn binding(&self) -> crate::hooks::binding::test_support::FakeBinding {
crate::hooks::binding::test_support::FakeBinding {
agent_type: "cline",
display_name: "Directory Agent",
hook_surface_dir: Some(self.hooks_dir()),
hook_event_types: &[],
load_bearing_events: &[],
daemon_channel: Some(crate::hooks::binding::DaemonChannel::OpenlatchDirArg),
..Default::default()
}
}
fn plugin_dir(&self) -> std::path::PathBuf {
self.store.path().join("plugins").join("openlatch")
}
fn binding_with_plugin(&self) -> crate::hooks::binding::test_support::FakeBinding {
crate::hooks::binding::test_support::FakeBinding {
plugin_surface_dir: Some(self.plugin_dir()),
..self.binding()
}
}
fn state(&self) -> crate::core::hook_state::HookStateFile {
crate::core::hook_state::HookStateFile::load(self.ol.path())
.expect("the state file parses")
.expect("the install wrote a state file")
}
fn listing(&self) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(self.hooks_dir())
.expect("the installer created the hook directory")
.map(|entry| {
entry
.expect("a readable directory entry")
.file_name()
.to_string_lossy()
.into_owned()
})
.collect();
names.sort();
names
}
}
fn expected_listing() -> Vec<String> {
let mut names: Vec<String> = super::hook_files::CLINE_HOOK_FILES
.iter()
.map(|event| super::hook_files::hook_file_name(event))
.collect();
names.sort();
names
}
#[test]
fn installing_a_directory_surface_writes_the_ten_scripts() {
let fx = DirFixture::new();
let hooks_dir = fx.hooks_dir();
assert!(
!hooks_dir.exists(),
"the fixture must not pre-create the directory the installer is being asked to create"
);
let result =
super::install_hooks(&fx.binding(), 7443, "a-token").expect("the directory arm writes");
assert_eq!(
fx.listing(),
expected_listing(),
"ten files, exactly these names, in THIS platform's shape"
);
for name in expected_listing() {
let path = hooks_dir.join(&name);
let body = std::fs::read_to_string(&path).expect("a readable shim");
assert!(
super::hook_files::is_ours(&body),
"{name} has to be recognisable as ours from its own bytes, with no state \
file present: {body}"
);
assert!(
body.contains(&fx.hook_bin.display().to_string()),
"{name} must name the staged absolute binary, never a bare name: {body}"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path)
.expect("a stat-able shim")
.permissions()
.mode()
& 0o777;
assert_eq!(
mode, 0o755,
"{name} is executed by the agent; 0600 would be permission-denied on \
every event"
);
}
}
assert_eq!(
result.entries.len(),
10,
"one status per file written: {:?}",
result.entries
);
assert!(
result
.entries
.iter()
.all(|e| e.action == super::HookAction::Added),
"a first install adds all ten: {:?}",
result.entries
);
let mut reported: Vec<&str> = result
.entries
.iter()
.map(|e| e.event_type.as_str())
.collect();
reported.sort_unstable();
let mut want = super::hook_files::CLINE_HOOK_FILES;
want.sort_unstable();
assert_eq!(
reported,
want.to_vec(),
"the statuses name the ten hook FILES โ and they came from the writer's list, \
since this binding declares no event types at all"
);
}
#[test]
fn each_installed_script_gets_a_state_row_carrying_its_descriptor() {
let fx = DirFixture::new();
super::install_hooks(&fx.binding(), 7443, "a-token").expect("the directory arm writes");
let key = crate::core::hook_state::key::HmacKeyStore::new(fx.ol.path())
.load_or_create()
.expect("the install created an HMAC key");
let surface_hash = crate::core::hook_state::hash_settings_path(&fx.hooks_dir());
let state = fx.state();
assert_eq!(state.entries.len(), 10, "one row per script");
for row in &state.entries {
assert_eq!(
row.settings_path_hash, surface_hash,
"the rows are keyed to the DIRECTORY, so the surface the binding reports \
finds all ten"
);
assert_eq!(row.v, crate::core::hook_state::STATE_ENTRY_VERSION);
assert!(
super::hook_files::CLINE_HOOK_FILES.contains(&row.hook_event.as_str()),
"the row's event is the hook file name: {}",
row.hook_event
);
let descriptor = row
.descriptor
.as_ref()
.expect("a directory surface records what it wrote");
let body = std::fs::read_to_string(&descriptor.path)
.expect("the descriptor names a file that exists");
assert_eq!(
descriptor.sha256,
super::hook_files::sha256_hex(&body),
"the stored hash is the whole body, which is what a later read hashes"
);
assert_eq!(descriptor.mode, 0o755);
assert!(
body.contains(&row.id),
"the marker line in the file and its state row must name the SAME uuid โ \
the writer mints it once, before the body can be hashed"
);
let signed = serde_json::to_value(descriptor).expect("a descriptor serializes");
assert!(
crate::core::hook_state::hmac::verify_entry_hmac(
&signed,
&row.expected_entry_hmac,
&key
)
.expect("the descriptor is a JSON object, so canonicalization succeeds"),
"the HMAC has to be over the descriptor, or the row is not tamper-evident"
);
}
}
#[test]
fn no_token_reaches_the_hook_directory() {
const TOKEN: &str = "a-daemon-bearer-token-nobody-may-copy";
let fx = DirFixture::new();
let binding = fx.binding();
assert!(
matches!(
super::binding::AgentBinding::daemon_channel(&binding),
super::binding::DaemonChannel::OpenlatchDirArg
),
"the premise: a directory surface has no env block to carry a token"
);
super::install_hooks(&binding, 7443, TOKEN).expect("the directory arm writes");
let store_entries: Vec<String> = std::fs::read_dir(fx.store.path())
.expect("a readable store")
.map(|e| {
e.expect("a readable entry")
.file_name()
.to_string_lossy()
.into_owned()
})
.collect();
assert_eq!(store_entries, vec!["Hooks".to_string()]);
for name in fx.listing() {
let bytes = std::fs::read(fx.hooks_dir().join(&name)).expect("a readable shim");
assert!(
!String::from_utf8_lossy(&bytes).contains(TOKEN),
"{name} carries the daemon's bearer token"
);
}
let state = fx.state();
assert_eq!(state.entries.len(), 10);
assert!(
state
.entries
.iter()
.all(|e| !e.daemon_token_fp.is_empty() && !e.daemon_token_fp.contains(TOKEN)),
"the row keeps a fingerprint of the token, never the token"
);
}
#[test]
fn a_file_we_did_not_write_is_never_overwritten_by_install() {
const THEIRS: &str = "#!/bin/sh\n# the developer's own PreToolUse hook\necho hi\n";
let fx = DirFixture::new();
let hooks_dir = fx.hooks_dir();
std::fs::create_dir_all(&hooks_dir).expect("the developer's own hook directory");
let name = super::hook_files::hook_file_name("PreToolUse");
std::fs::write(hooks_dir.join(&name), THEIRS).expect("the developer's own hook");
let result = super::install_hooks(&fx.binding(), 7443, "a-token")
.expect("a collision is a decision about someone else's file, never a failure");
assert_eq!(
std::fs::read_to_string(hooks_dir.join(&name)).expect("still readable"),
THEIRS,
"never overwritten"
);
assert!(
!hooks_dir.join(format!("{name}.bak")).exists(),
"and never backed up either โ a backup is for a file of ours we are about to \
rewrite"
);
assert_eq!(
result.entries.len(),
9,
"the install continues with the other nine: {:?}",
result.entries
);
assert!(
!result.entries.iter().any(|e| e.event_type == "PreToolUse"),
"an install that did not write a file must not report one: {:?}",
result.entries
);
let state = fx.state();
assert_eq!(state.entries.len(), 9);
assert!(
!state.entries.iter().any(|e| e.hook_event == "PreToolUse"),
"no state row for a file we did not write"
);
}
#[test]
fn re_installing_replaces_our_own_and_leaves_a_backup() {
let fx = DirFixture::new();
let first = super::install_hooks(&fx.binding(), 7443, "a-token").expect("first install");
assert!(first
.entries
.iter()
.all(|e| e.action == super::HookAction::Added));
let second = super::install_hooks(&fx.binding(), 7443, "a-token").expect("second install");
assert_eq!(second.entries.len(), 10);
assert!(
second
.entries
.iter()
.all(|e| e.action == super::HookAction::Replaced),
"a re-install must not report ten fresh additions: {:?}",
second.entries
);
for name in expected_listing() {
let backup = fx.hooks_dir().join(format!("{name}.bak"));
let body = std::fs::read_to_string(&backup)
.unwrap_or_else(|e| panic!("{name} has no backup beside it: {e}"));
assert!(
super::hook_files::is_ours(&body),
"the backup is the script we replaced, which was ours"
);
}
assert_eq!(
fx.state().entries.len(),
10,
"the rows are upserted on (agent, surface, event), never appended"
);
}
#[test]
fn uninstall_removes_our_scripts_and_only_ours() {
const MINE_NOW: &str = "#!/bin/sh\n# I replaced yours\n";
let fx = DirFixture::new();
super::install_hooks(&fx.binding(), 7443, "a-token").expect("install");
std::fs::write(fx.hooks_dir().join("MyOwn"), "# mine\n").expect("the developer's own hook");
let taken = super::hook_files::hook_file_name("PostToolUse");
std::fs::write(fx.hooks_dir().join(&taken), MINE_NOW).expect("a hook taken over");
super::remove_hooks(&fx.binding())
.expect("a partial removal is an Ok โ the relay teardown is gated on it");
assert_eq!(
fx.listing(),
{
let mut want = vec!["MyOwn".to_string(), taken.clone()];
want.sort();
want
},
"our nine are gone; both of the developer's files are still there"
);
assert_eq!(
std::fs::read_to_string(fx.hooks_dir().join(&taken)).expect("still readable"),
MINE_NOW,
"and the one under one of our names was not even modified"
);
super::remove_hooks(&fx.binding())
.expect("removing again, with nothing of ours left, is still an Ok");
assert_eq!(fx.listing().len(), 2, "and it took nothing on the way past");
}
#[test]
fn installing_a_directory_surface_writes_the_plugin_too() {
let fx = DirFixture::new();
let plugin_dir = fx.plugin_dir();
assert!(
!plugin_dir.exists(),
"the fixture must not pre-create the directory the installer is being asked \
to create"
);
super::install_hooks(&fx.binding_with_plugin(), 7443, "a-token")
.expect("the directory arm writes");
assert_eq!(
fx.listing(),
expected_listing(),
"the plugin must not land among the ten, and must not displace one"
);
let entry = super::cline_plugin::entry_path(&plugin_dir);
let body = std::fs::read_to_string(&entry).expect("a readable plugin");
assert!(
super::hook_files::is_ours(&body),
"the plugin shares the ten's ownership predicate: {body}"
);
let ol_dir_literal = serde_json::to_string(&fx.ol.path().display().to_string())
.expect("a path renders as a JSON string");
assert!(
body.contains(&ol_dir_literal),
"the plugin must carry the openlatch directory it talks to \
as {ol_dir_literal}: {body}"
);
assert!(
!body.contains("__OPENLATCH_DIR__"),
"the template placeholder reached disk: {body}"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&entry)
.expect("a stat-able plugin")
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o644, "Node imports this file; it is never executed");
}
}
#[test]
fn no_token_reaches_the_plugin_directory() {
const TOKEN: &str = "a-token-that-must-not-be-written";
let fx = DirFixture::new();
super::install_hooks(&fx.binding_with_plugin(), 7443, TOKEN).expect("install");
let body = std::fs::read_to_string(super::cline_plugin::entry_path(&fx.plugin_dir()))
.expect("a readable plugin");
assert!(
!body.contains(TOKEN),
"the daemon's bearer token reached the agent's own store: {body}"
);
}
#[test]
fn a_binding_with_no_plugin_surface_writes_no_plugin() {
let fx = DirFixture::new();
super::install_hooks(&fx.binding(), 7443, "a-token").expect("install");
assert!(
!fx.plugin_dir().exists(),
"a binding that declared no plugin surface had one written for it"
);
}
#[test]
fn uninstall_removes_the_plugin_as_well_as_the_ten() {
let fx = DirFixture::new();
super::install_hooks(&fx.binding_with_plugin(), 7443, "a-token").expect("install");
assert!(super::cline_plugin::entry_path(&fx.plugin_dir()).exists());
super::remove_hooks(&fx.binding_with_plugin()).expect("uninstall");
assert!(
!super::cline_plugin::entry_path(&fx.plugin_dir()).exists(),
"the one artefact that can refuse a tool call outlived the uninstall"
);
assert_eq!(fx.listing(), Vec::<String>::new(), "and so did the ten");
}
#[test]
fn uninstall_drops_the_plugin_state_entry() {
let fx = DirFixture::new();
super::install_hooks(&fx.binding_with_plugin(), 7443, "a-token").expect("install");
let entry = super::cline_plugin::entry_path(&fx.plugin_dir());
let row = fx
.state()
.entries
.into_iter()
.find(|e| e.hook_event == super::cline_plugin::PLUGIN_ENTRY_EVENT)
.expect("the install recorded a row for the plugin");
assert_eq!(
row.settings_path_hash,
crate::core::hook_state::hash_settings_path(&entry),
"the row must be keyed on the plugin FILE, not on the directory that names it"
);
let descriptor = row.descriptor.as_ref().expect("a stored descriptor");
assert_eq!(descriptor.path, entry.to_string_lossy());
assert_eq!(
descriptor.sha256,
super::hook_files::sha256_hex(
&std::fs::read_to_string(&entry).expect("a readable plugin")
),
"the stored hash is what heal compares against; a stale one heals forever"
);
assert_eq!(
descriptor.mode, 0o644,
"Node imports this file; 0755 would record an executable that is not one"
);
assert_eq!(
fx.state().entries.len(),
super::hook_files::CLINE_HOOK_FILES.len() + 1,
"eleven artefacts, eleven rows"
);
super::remove_hooks(&fx.binding_with_plugin()).expect("uninstall");
assert!(
fx.state()
.entries
.iter()
.all(|e| e.hook_event != super::cline_plugin::PLUGIN_ENTRY_EVENT),
"the plugin's row outlived its file; the next reconciler pass re-creates it"
);
}
#[test]
fn a_plugin_we_did_not_write_is_never_overwritten_by_install() {
const THEIRS: &str = "export default { name: 'mine' };\n";
let fx = DirFixture::new();
let entry = super::cline_plugin::entry_path(&fx.plugin_dir());
std::fs::create_dir_all(fx.plugin_dir()).expect("the developer's own plugin directory");
std::fs::write(&entry, THEIRS).expect("their plugin");
let result = super::install_hooks(&fx.binding_with_plugin(), 7443, "a-token")
.expect("a collision is not an error");
assert_eq!(
std::fs::read_to_string(&entry).expect("still readable"),
THEIRS,
"their plugin was rewritten"
);
assert!(
result.left_alone.contains(&entry),
"an install that refused to write a path must say which: {:?}",
result.left_alone
);
assert_eq!(
result.entries.len(),
10,
"the ten are unaffected by a collision on the eleventh"
);
super::remove_hooks(&fx.binding_with_plugin()).expect("uninstall");
assert!(
entry.exists(),
"uninstall removed a plugin that is not ours"
);
}
#[test]
fn uninstall_never_creates_the_hook_directory() {
let fx = DirFixture::new();
assert!(!fx.hooks_dir().exists(), "nothing was installed");
super::remove_hooks(&fx.binding()).expect("nothing to remove is not a failure");
assert!(
!fx.hooks_dir().exists(),
"uninstall must not conjure the directory it was asked to clean"
);
}
}