use std::path::{Path, PathBuf};
use std::sync::Arc;
use serde_json::Value;
use crate::boundary::wire_format::WireFormat;
use crate::core::hook_state::marker::OpenlatchMarker;
use super::bindings::claude_code::ClaudeCodeBinding;
use super::bindings::codex_cli::CodexCliBinding;
use super::{AgentKind, DetectedAgent};
pub trait AgentBinding: Send + Sync {
fn agent_type(&self) -> &'static str;
fn display_name(&self) -> &'static str;
fn config_dir(&self) -> PathBuf;
fn hook_config_path(&self) -> PathBuf;
fn hook_event_types(&self) -> &'static [&'static str];
fn load_bearing_events(&self) -> &'static [&'static str];
fn daemon_channel(&self) -> DaemonChannel;
fn liveness(&self) -> LivenessReport;
fn build_hook_entry(
&self,
event: &str,
binary: &Path,
port: u16,
marker: &OpenlatchMarker,
) -> Value;
fn config_is_machine_global(&self) -> bool;
fn capabilities(&self) -> BindingCapabilities;
fn boundary_wiring(&self) -> Option<BoundaryWiring>;
}
#[derive(Debug, Clone)]
pub struct BoundaryWiring {
pub wire_format: WireFormat,
pub endpoint: EndpointConvention,
pub install_id_header: &'static str,
}
#[derive(Debug, Clone)]
pub enum EndpointConvention {
EnvVars {
base_url: &'static str,
headers: &'static str,
},
TomlProvider {
provider_name: &'static str,
wire_api: &'static str,
},
}
#[derive(Debug, Clone, Copy)]
pub enum DaemonChannel {
EnvVars {
token: &'static str,
port: &'static str,
},
OpenlatchDirArg,
}
#[derive(Debug, Clone)]
pub struct LivenessReport {
pub armed: Option<bool>,
pub detail: Option<String>,
pub remedy: Option<String>,
pub code: Option<&'static str>,
}
#[derive(Debug, Clone, Copy)]
pub struct BindingCapabilities {
pub expressible: &'static [&'static str],
pub can_mutate_arguments: bool,
pub native_failure_mode: FailureMode,
pub admin_owned_settings: bool,
pub declares_session_in_request: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FailureMode {
FailOpen,
FailClosed,
Unknown,
}
pub const DETECTABLE_AGENT_NAMES: &[&str] = &["Claude Code", "Codex CLI"];
pub fn detect_all() -> Vec<DetectedAgent> {
let mut found = Vec::new();
if let Some(b) = ClaudeCodeBinding::detect() {
found.push(DetectedAgent {
kind: AgentKind::ClaudeCode,
binding: Arc::new(b),
});
}
if let Some(b) = CodexCliBinding::detect() {
found.push(DetectedAgent {
kind: AgentKind::CodexCli,
binding: Arc::new(b),
});
}
found
}
#[cfg(test)]
pub mod test_support {
use super::*;
pub fn two_detected_agents(root: &std::path::Path) -> Vec<crate::hooks::DetectedAgent> {
let claude_dir = root.join("claude");
let cursor_dir = root.join("cursor");
for dir in [&claude_dir, &cursor_dir] {
std::fs::create_dir_all(dir).expect("fixture dirs");
}
vec![
crate::hooks::DetectedAgent {
kind: crate::hooks::AgentKind::ClaudeCode,
binding: std::sync::Arc::new(
crate::hooks::bindings::claude_code::ClaudeCodeBinding {
settings_path: claude_dir.join("settings.json"),
claude_dir,
},
),
},
crate::hooks::DetectedAgent {
kind: crate::hooks::AgentKind::ClaudeCode,
binding: std::sync::Arc::new(FakeBinding {
agent_type: "cursor",
display_name: "Cursor",
config_dir: cursor_dir,
..Default::default()
}),
},
]
}
pub struct FakeBinding {
pub agent_type: &'static str,
pub display_name: &'static str,
pub config_dir: PathBuf,
pub liveness: LivenessReport,
pub boundary_wiring: Option<BoundaryWiring>,
pub config_is_machine_global: bool,
}
impl Default for FakeBinding {
fn default() -> Self {
Self {
agent_type: "fake",
display_name: "Fake",
config_dir: std::env::temp_dir().join("openlatch-fake-binding"),
liveness: LivenessReport {
armed: None,
detail: None,
remedy: None,
code: None,
},
boundary_wiring: None,
config_is_machine_global: false,
}
}
}
impl AgentBinding for FakeBinding {
fn agent_type(&self) -> &'static str {
self.agent_type
}
fn display_name(&self) -> &'static str {
self.display_name
}
fn config_dir(&self) -> PathBuf {
self.config_dir.clone()
}
fn hook_config_path(&self) -> PathBuf {
self.config_dir.join("settings.json")
}
fn hook_event_types(&self) -> &'static [&'static str] {
&super::super::bindings::claude_code::EVENT_TYPES
}
fn load_bearing_events(&self) -> &'static [&'static str] {
&super::super::bindings::claude_code::LOAD_BEARING_EVENTS
}
fn daemon_channel(&self) -> DaemonChannel {
DaemonChannel::EnvVars {
token: crate::hooks::OPENLATCH_TOKEN_ENV,
port: crate::hooks::OPENLATCH_PORT_ENV,
}
}
fn liveness(&self) -> LivenessReport {
self.liveness.clone()
}
fn build_hook_entry(
&self,
event: &str,
binary: &Path,
port: u16,
marker: &OpenlatchMarker,
) -> Value {
crate::hooks::claude_code::build_hook_entry(
event,
port,
crate::hooks::OPENLATCH_TOKEN_ENV,
binary,
marker,
)
}
fn config_is_machine_global(&self) -> bool {
self.config_is_machine_global
}
fn capabilities(&self) -> BindingCapabilities {
unimplemented!(
"FakeBinding models no capability declaration — a test that reaches \
capabilities() is testing something this seam was not built for"
)
}
fn boundary_wiring(&self) -> Option<BoundaryWiring> {
self.boundary_wiring.clone()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
struct HomeGuard {
home: Option<std::ffi::OsString>,
config_dir: Option<std::ffi::OsString>,
codex_home: Option<std::ffi::OsString>,
}
impl HomeGuard {
fn take() -> Self {
let guard = Self {
home: std::env::var_os("HOME"),
config_dir: std::env::var_os(crate::hooks::claude_code::CONFIG_DIR_ENV),
codex_home: std::env::var_os(crate::hooks::codex_cli::CONFIG_DIR_ENV),
};
std::env::remove_var(crate::hooks::claude_code::CONFIG_DIR_ENV);
std::env::remove_var(crate::hooks::codex_cli::CONFIG_DIR_ENV);
guard
}
}
impl Drop for HomeGuard {
fn drop(&mut self) {
for (key, value) in [
("HOME", &self.home),
(crate::hooks::claude_code::CONFIG_DIR_ENV, &self.config_dir),
(crate::hooks::codex_cli::CONFIG_DIR_ENV, &self.codex_home),
] {
match value {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
}
}
}
#[test]
#[cfg(unix)]
fn detect_agents_returns_empty_without_an_agent() {
let _lock = crate::hooks::claude_code::CONFIG_DIR_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 _guard = HomeGuard::take();
let empty = tempfile::tempdir().expect("temp dir");
std::env::set_var("HOME", empty.path());
assert!(
detect_all().is_empty(),
"no agent on this host means an empty Vec, never an error"
);
}
#[test]
fn detect_agent_shim_takes_the_first() {
let _lock = crate::hooks::claude_code::CONFIG_DIR_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 _guard = HomeGuard::take();
let claude_dir = tempfile::tempdir().expect("temp dir");
std::env::set_var(crate::hooks::claude_code::CONFIG_DIR_ENV, claude_dir.path());
let all = detect_all();
assert!(
!all.is_empty(),
"a relocated Claude dir exists, so it detects"
);
assert_eq!(all[0].kind, AgentKind::ClaudeCode, "Claude Code is first");
let first = crate::hooks::detect_agent().expect("an agent was detected");
assert_eq!(first.kind, all[0].kind);
assert_eq!(first.agent_type(), all[0].agent_type());
assert_eq!(first.agent_type(), "claude-code");
}
#[test]
fn detect_all_orders_claude_before_codex() {
let _claude_lock = crate::hooks::claude_code::CONFIG_DIR_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _identity_lock = crate::daemon::identity::ENV_LOCK.blocking_lock();
let env = crate::daemon::identity::test_support::EnvGuard::clear();
let _codex_lock = crate::hooks::codex_cli::CONFIG_DIR_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let claude_dir = tempfile::tempdir().expect("temp dir");
let codex_dir = tempfile::tempdir().expect("temp dir");
env.set("CLAUDE_CONFIG_DIR", claude_dir.path());
let previous_codex = std::env::var_os(crate::hooks::codex_cli::CONFIG_DIR_ENV);
std::env::set_var(crate::hooks::codex_cli::CONFIG_DIR_ENV, codex_dir.path());
let kinds: Vec<AgentKind> = detect_all().iter().map(|a| a.kind).collect();
match previous_codex {
Some(v) => std::env::set_var(crate::hooks::codex_cli::CONFIG_DIR_ENV, v),
None => std::env::remove_var(crate::hooks::codex_cli::CONFIG_DIR_ENV),
}
assert_eq!(
kinds,
vec![AgentKind::ClaudeCode, AgentKind::CodexCli],
"declaration order is detection order, and the singular callers take the first"
);
}
#[test]
fn agent_not_found_remedy_names_every_detectable_agent() {
let err = super::super::agent_not_found_err();
assert_eq!(err.code, crate::error::ERR_HOOK_AGENT_NOT_FOUND);
let suggestion = err.suggestion.expect("OL-1400 carries a suggestion");
for name in DETECTABLE_AGENT_NAMES {
assert!(
suggestion.contains(name),
"the remedy must name every detectable agent; {name} is missing from {suggestion:?}"
);
}
assert!(
!suggestion.contains("claude.ai/download"),
"one download URL cannot serve a list of agents: {suggestion:?}"
);
}
#[test]
fn agent_binding_is_object_safe() {
fn _assert_object_safe(_: &dyn AgentBinding) {}
}
#[test]
fn agent_binding_is_send_sync() {
fn _assert_send_sync<T: Send + Sync>() {}
_assert_send_sync::<Arc<dyn AgentBinding>>();
}
}