use std::path::{Path, PathBuf};
use crate::error::{OlError, ERR_MODEL_RELAY_FOREIGN_PROVIDER};
use crate::hooks::binding::TrustOccasion;
pub(crate) const CONFIG_DIR_ENV: &str = "CODEX_HOME";
#[cfg(test)]
pub(crate) static CONFIG_DIR_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn relocated_dir() -> Option<PathBuf> {
std::env::var_os(CONFIG_DIR_ENV)
.filter(|value| !value.is_empty())
.map(PathBuf::from)
}
pub fn config_dir() -> Option<PathBuf> {
match relocated_dir() {
Some(relocated) => Some(relocated),
None => Some(dirs::home_dir()?.join(".codex")),
}
}
pub fn detect() -> Option<PathBuf> {
let codex_dir = config_dir()?;
codex_dir.is_dir().then_some(codex_dir)
}
pub fn config_is_machine_global() -> bool {
let Some(resolved) = config_dir() else {
return true;
};
let Some(default) = dirs::home_dir().map(|home| home.join(".codex")) else {
return true;
};
let canonical = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
canonical(&resolved) == canonical(&default)
}
pub fn hooks_json_path(codex_dir: &Path) -> PathBuf {
codex_dir.join("hooks.json")
}
pub fn config_toml_path(codex_dir: &Path) -> PathBuf {
codex_dir.join("config.toml")
}
pub fn requirements_toml_path() -> Option<PathBuf> {
if cfg!(windows) {
None
} else {
Some(PathBuf::from("/etc/codex/requirements.toml")) }
}
#[derive(Debug, Default, serde::Deserialize)]
struct CodexConfig {
#[serde(default)]
hooks: HooksSection,
#[serde(default)]
features: FeaturesSection,
}
#[derive(Debug, Default, serde::Deserialize)]
struct HooksSection {
#[serde(default)]
state: std::collections::BTreeMap<String, HookStateToml>,
#[serde(default)]
allow_managed_hooks_only: Option<bool>,
}
#[derive(Debug, Default, serde::Deserialize)]
struct HookStateToml {
enabled: Option<bool>,
trusted_hash: Option<String>,
}
#[derive(Debug, Default, serde::Deserialize)]
struct FeaturesSection {
hooks: Option<bool>,
codex_hooks: Option<bool>,
plugin_hooks: Option<bool>,
}
enum ConfigLayer {
Absent,
Read(CodexConfig),
Unreadable,
}
fn read_config_layer(path: &Path) -> ConfigLayer {
let raw = match std::fs::read_to_string(path) {
Ok(raw) => raw,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return ConfigLayer::Absent,
Err(_) => return ConfigLayer::Unreadable,
};
match toml::from_str::<CodexConfig>(&raw) {
Ok(config) => ConfigLayer::Read(config),
Err(_) => ConfigLayer::Unreadable,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstalledHandler {
pub command: String,
pub timeout_secs: Option<u64>,
pub group_index: usize,
pub handler_index: usize,
}
pub fn installed_handler(codex_dir: &Path, event: &str) -> Option<InstalledHandler> {
own_handler_in(&read_hooks_json(codex_dir)?, event)
}
fn read_hooks_json(codex_dir: &Path) -> Option<serde_json::Value> {
let raw = std::fs::read_to_string(hooks_json_path(codex_dir)).ok()?;
super::jsonc::parse_settings_value(&raw).ok()
}
fn own_handler_in(parsed: &serde_json::Value, event: &str) -> Option<InstalledHandler> {
let groups = parsed
.get("hooks")
.and_then(|hooks| hooks.get(event))
.and_then(serde_json::Value::as_array)?;
for (group_index, group) in groups.iter().enumerate() {
let marked = matches!(
group.get("_openlatch"),
Some(serde_json::Value::Bool(true)) | Some(serde_json::Value::Object(_))
);
let Some(handlers) = group.get("hooks").and_then(serde_json::Value::as_array) else {
continue;
};
for (handler_index, handler) in handlers.iter().enumerate() {
let Some(command) = handler.get("command").and_then(serde_json::Value::as_str) else {
continue;
};
if !marked && !command.contains("openlatch-hook") {
continue;
}
return Some(InstalledHandler {
command: command.to_string(),
timeout_secs: handler.get("timeout").and_then(serde_json::Value::as_u64),
group_index,
handler_index,
});
}
}
None
}
pub fn trust_key(codex_dir: &Path, event: &str, handler: &InstalledHandler) -> String {
format!(
"{}:{}:{}:{}",
hooks_json_path(codex_dir).display(),
super::claude_code::pascal_to_snake(event),
handler.group_index,
handler.handler_index
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookTrust {
Managed,
Trusted,
Modified,
NeverTrusted,
Disabled,
}
pub fn hook_trust(codex_dir: &Path, event: &str, handler: &InstalledHandler) -> Option<HookTrust> {
match read_config_layer(&config_toml_path(codex_dir)) {
ConfigLayer::Unreadable => None,
ConfigLayer::Absent => Some(HookTrust::NeverTrusted),
ConfigLayer::Read(config) => Some(trust_in(&config, codex_dir, event, handler)),
}
}
fn trust_in(
config: &CodexConfig,
codex_dir: &Path,
event: &str,
handler: &InstalledHandler,
) -> HookTrust {
let wire_event = super::claude_code::pascal_to_snake(event);
let ours = hooks_json_path(codex_dir);
let found = config.hooks.state.iter().find(|(key, _)| {
let Some(parsed) = split_state_key(key) else {
return false;
};
parsed.event == wire_event
&& parsed.group_index == handler.group_index
&& parsed.handler_index == handler.handler_index
&& is_same_file(Path::new(parsed.source_path), &ours)
});
match found {
None => HookTrust::NeverTrusted,
Some((_, state)) if state.enabled == Some(false) => HookTrust::Disabled,
Some((_, state)) if state.trusted_hash.is_some() => HookTrust::Trusted,
Some(_) => HookTrust::NeverTrusted,
}
}
struct StateKey<'a> {
source_path: &'a str,
event: &'a str,
group_index: usize,
handler_index: usize,
}
fn split_state_key(key: &str) -> Option<StateKey<'_>> {
let mut parts = key.rsplitn(4, ':');
let handler_index = parts.next()?.parse().ok()?;
let group_index = parts.next()?.parse().ok()?;
let event = parts.next()?;
let source_path = parts.next()?;
Some(StateKey {
source_path,
event,
group_index,
handler_index,
})
}
pub(crate) fn key_names_file(key: &str, hooks_json: &Path) -> bool {
split_state_key(key).is_some_and(|k| is_same_file(Path::new(k.source_path), hooks_json))
}
fn is_same_file(a: &Path, b: &Path) -> bool {
let resolved = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
crate::core::path_compat::dedup_key(&resolved(a))
== crate::core::path_compat::dedup_key(&resolved(b))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TrustStatus {
Managed,
Trusted,
Modified,
Untrusted,
#[serde(other)]
Unrecognised,
}
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ListedHook {
pub key: String,
#[serde(default)]
pub command: Option<String>,
pub source_path: PathBuf,
#[serde(default = "enabled_by_default")]
pub enabled: bool,
#[serde(default)]
pub is_managed: bool,
pub current_hash: String,
pub trust_status: TrustStatus,
}
fn enabled_by_default() -> bool {
true
}
impl ListedHook {
fn is_ours(&self, codex_dir: &Path) -> bool {
self.command
.as_deref()
.is_some_and(|c| c.contains("openlatch-hook"))
&& is_same_file(&self.source_path, &hooks_json_path(codex_dir))
}
fn trust(&self) -> Option<HookTrust> {
if !self.enabled {
return Some(HookTrust::Disabled);
}
match self.trust_status {
_ if self.is_managed => Some(HookTrust::Managed),
TrustStatus::Managed => Some(HookTrust::Managed),
TrustStatus::Trusted => Some(HookTrust::Trusted),
TrustStatus::Modified => Some(HookTrust::Modified),
TrustStatus::Untrusted => Some(HookTrust::NeverTrusted),
TrustStatus::Unrecognised => None,
}
}
}
pub type HookLister = std::sync::Arc<dyn Fn(&Path) -> Option<Vec<ListedHook>> + Send + Sync>;
pub fn app_server_lister() -> HookLister {
std::sync::Arc::new(list_hooks)
}
const APP_SERVER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
const LIST_REQUEST_ID: u64 = 2;
pub fn list_hooks(codex_dir: &Path) -> Option<Vec<ListedHook>> {
use std::io::{BufRead as _, Write as _};
let mut command = if cfg!(windows) {
let mut c = std::process::Command::new("codex");
c.arg("app-server").env(CONFIG_DIR_ENV, codex_dir);
c
} else {
let mut c = std::process::Command::new("sh");
c.args(["-lc", "CODEX_HOME=\"$1\" exec codex app-server", "sh"])
.arg(codex_dir);
c
};
let mut child = command
.current_dir(codex_dir)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.ok()?;
let stdout = child.stdout.take()?;
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
for line in std::io::BufReader::new(stdout).lines() {
let Ok(line) = line else { break };
let Ok(message) = serde_json::from_str::<serde_json::Value>(&line) else {
continue;
};
if message.get("id").and_then(serde_json::Value::as_u64) == Some(LIST_REQUEST_ID) {
let _ = tx.send(message);
break;
}
}
});
let mut stdin = child.stdin.take()?;
let requests = [
serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"clientInfo": {"name": "openlatch", "version": env!("CARGO_PKG_VERSION")}}}),
serde_json::json!({"jsonrpc": "2.0", "method": "initialized"}),
serde_json::json!({"jsonrpc": "2.0", "id": LIST_REQUEST_ID, "method": "hooks/list",
"params": {}}),
];
let wrote = requests
.iter()
.try_for_each(|r| writeln!(stdin, "{r}"))
.and_then(|()| stdin.flush());
let response = wrote
.ok()
.and_then(|()| rx.recv_timeout(APP_SERVER_TIMEOUT).ok());
drop(stdin);
let _ = child.kill();
let _ = child.wait();
parse_hooks_list(&response?)
}
pub fn parse_hooks_list(response: &serde_json::Value) -> Option<Vec<ListedHook>> {
let data = response.get("result")?.get("data")?.as_array()?;
Some(
data.iter()
.filter_map(|dir| dir.get("hooks")?.as_array())
.flatten()
.filter_map(|hook| serde_json::from_value(hook.clone()).ok())
.collect(),
)
}
pub fn listed_trust<'a>(
listing: &'a [ListedHook],
codex_dir: &Path,
event: &str,
handler: &InstalledHandler,
) -> Option<(HookTrust, &'a ListedHook)> {
let wire_event = super::claude_code::pascal_to_snake(event);
let hook = listing.iter().find(|hook| {
hook.is_ours(codex_dir)
&& split_state_key(&hook.key).is_some_and(|k| {
k.event == wire_event
&& k.group_index == handler.group_index
&& k.handler_index == handler.handler_index
})
})?;
Some((hook.trust()?, hook))
}
pub fn own_hooks_need_trust(codex_dir: &Path) -> bool {
let Some(parsed) = read_hooks_json(codex_dir) else {
return false;
};
let config = match read_config_layer(&config_toml_path(codex_dir)) {
ConfigLayer::Read(config) => config,
ConfigLayer::Absent => CodexConfig::default(),
ConfigLayer::Unreadable => return false,
};
let Some(events) = parsed.get("hooks").and_then(serde_json::Value::as_object) else {
return false;
};
events.keys().any(|event| {
own_handler_in(&parsed, event).is_some_and(|handler| {
trust_in(&config, codex_dir, event, &handler) == HookTrust::NeverTrusted
})
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Grant {
pub key: String,
pub trusted_hash: String,
}
pub fn grant_own_hook_trust(
codex_dir: &Path,
listing: &[ListedHook],
occasion: TrustOccasion,
) -> Result<Vec<Grant>, OlError> {
let wanted: Vec<&ListedHook> = listing
.iter()
.filter(|hook| hook.is_ours(codex_dir) && !hook.is_managed)
.filter(|hook| {
let untrusted = matches!(
hook.trust_status,
TrustStatus::Untrusted | TrustStatus::Modified
);
untrusted || (occasion == TrustOccasion::Install && !hook.enabled)
})
.collect();
if wanted.is_empty() {
return Ok(Vec::new());
}
let config_toml = config_toml_path(codex_dir);
crate::hooks::atomic::atomic_rewrite_toml(&config_toml, |doc| {
let state = implicit_table(doc.as_table_mut(), "hooks", &config_toml)
.and_then(|hooks| implicit_table(hooks, "state", &config_toml))?;
for hook in &wanted {
let entry = state
.entry(&hook.key)
.or_insert_with(|| toml_edit::Item::Table(toml_edit::Table::new()));
let Some(entry) = entry.as_table_mut() else {
return Err(not_a_table(&config_toml, &hook.key));
};
entry.insert("trusted_hash", toml_edit::value(&hook.current_hash));
if occasion == TrustOccasion::Install {
entry.insert("enabled", toml_edit::value(true));
}
}
Ok(())
})?;
Ok(wanted
.into_iter()
.map(|hook| Grant {
key: hook.key.clone(),
trusted_hash: hook.current_hash.clone(),
})
.collect())
}
pub fn revoke_granted_trust(codex_dir: &Path, grants: &[Grant]) -> Result<(), OlError> {
let config_toml = config_toml_path(codex_dir);
if grants.is_empty() || !config_toml.exists() {
return Ok(());
}
crate::hooks::atomic::atomic_rewrite_toml(&config_toml, |doc| {
let Some(hooks) = doc.get_mut("hooks").and_then(toml_edit::Item::as_table_mut) else {
return Ok(());
};
if let Some(state) = hooks
.get_mut("state")
.and_then(toml_edit::Item::as_table_mut)
{
for grant in grants {
let still_ours = state
.get(&grant.key)
.and_then(|entry| entry.get("trusted_hash"))
.and_then(toml_edit::Item::as_str)
== Some(grant.trusted_hash.as_str());
if still_ours {
state.remove(&grant.key);
}
}
if state.is_empty() {
hooks.remove("state");
}
}
if hooks.is_empty() {
doc.remove("hooks");
}
Ok(())
})
}
fn implicit_table<'a>(
parent: &'a mut toml_edit::Table,
name: &str,
config_toml: &Path,
) -> Result<&'a mut toml_edit::Table, OlError> {
parent
.entry(name)
.or_insert_with(|| {
let mut table = toml_edit::Table::new();
table.set_implicit(true);
toml_edit::Item::Table(table)
})
.as_table_mut()
.ok_or_else(|| not_a_table(config_toml, name))
}
fn not_a_table(config_toml: &Path, key: &str) -> OlError {
OlError::new(
crate::error::ERR_HOOK_MALFORMED_TOML,
format!(
"'{}' declares `{key}` as something other than a table",
crate::core::path_compat::display_path(config_toml)
),
)
.with_suggestion(
"Fix or remove that key, then run `openlatch doctor --fix` — OpenLatch will not \
rewrite a value it did not write.",
)
}
pub fn suppressing_feature_flag(codex_dir: &Path) -> Option<&'static str> {
let ConfigLayer::Read(config) = read_config_layer(&config_toml_path(codex_dir)) else {
return None;
};
[
("hooks", config.features.hooks),
("codex_hooks", config.features.codex_hooks),
("plugin_hooks", config.features.plugin_hooks),
]
.into_iter()
.find_map(|(name, value)| (value == Some(false)).then_some(name))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ManagedHooksOnly {
Observed(bool),
Unobservable,
}
pub fn managed_hooks_only(requirements_toml: Option<&Path>) -> ManagedHooksOnly {
let Some(path) = requirements_toml else {
return ManagedHooksOnly::Unobservable;
};
match read_config_layer(path) {
ConfigLayer::Absent => ManagedHooksOnly::Observed(false),
ConfigLayer::Read(config) => {
ManagedHooksOnly::Observed(config.hooks.allow_managed_hooks_only.unwrap_or(false))
}
ConfigLayer::Unreadable => ManagedHooksOnly::Unobservable,
}
}
const PROVIDER_LABEL: &str = "OpenLatch model relay";
const MODEL_PROVIDER_KEY: &str = "model_provider";
const MODEL_PROVIDERS_TABLE: &str = "model_providers";
fn provider_base_url<'a>(doc: &'a toml_edit::DocumentMut, provider_name: &str) -> Option<&'a str> {
doc.get(MODEL_PROVIDERS_TABLE)
.and_then(toml_edit::Item::as_table_like)
.and_then(|providers| providers.get(provider_name))
.and_then(toml_edit::Item::as_table_like)
.and_then(|provider| provider.get("base_url"))
.and_then(toml_edit::Item::as_str)
}
pub(crate) fn provider_table_is_ours(
doc: &toml_edit::DocumentMut,
provider_name: &str,
) -> Option<bool> {
provider_base_url(doc, provider_name).map(crate::hooks::is_openlatch_loopback_base_url)
}
pub fn read_provider_base_url(config_toml: &Path, provider_name: &str) -> Option<String> {
let raw = std::fs::read_to_string(config_toml).ok()?;
let doc = raw.parse::<toml_edit::DocumentMut>().ok()?;
let url = provider_base_url(&doc, provider_name)?;
crate::hooks::is_openlatch_loopback_base_url(url).then(|| url.to_string())
}
fn set_root_value(doc: &mut toml_edit::DocumentMut, key: &str, v: &str) {
let decor = doc
.get(key)
.and_then(toml_edit::Item::as_value)
.map(|existing| existing.decor().clone());
let mut item = toml_edit::value(v);
if let (Some(decor), Some(value)) = (decor, item.as_value_mut()) {
*value.decor_mut() = decor;
}
doc[key] = item;
}
pub fn write_provider_table(
config_toml: &Path,
provider_name: &str,
wire_api: &str,
install_id_header: &str,
port: u16,
install_id: &str,
) -> Result<Prior, OlError> {
let mut prior = Prior::Ours;
crate::hooks::atomic::atomic_rewrite_toml(config_toml, |doc| {
if provider_table_is_ours(doc, provider_name) == Some(false) {
return Err(OlError::new(
ERR_MODEL_RELAY_FOREIGN_PROVIDER,
format!(
"'{}' already declares a [model_providers.{provider_name}] table that does \
not point at the OpenLatch model_relay",
crate::core::path_compat::display_path(config_toml)
),
)
.with_suggestion(
"Rename or remove that provider table, or disable the model relay for \
Codex CLI — OpenLatch will not overwrite a provider entry it did not write.",
));
}
prior = prior_from_document(doc, provider_name);
let mut provider = toml_edit::Table::new();
provider.insert("name", toml_edit::value(PROVIDER_LABEL));
provider.insert(
"base_url",
toml_edit::value(format!("http://127.0.0.1:{port}/v1")),
);
provider.insert("wire_api", toml_edit::value(wire_api));
provider.insert("requires_openai_auth", toml_edit::value(true));
let mut headers = toml_edit::Table::new();
headers.insert(install_id_header, toml_edit::value(install_id));
provider.insert("http_headers", toml_edit::Item::Table(headers));
let parent = doc
.entry(MODEL_PROVIDERS_TABLE)
.or_insert(toml_edit::Item::Table({
let mut t = toml_edit::Table::new();
t.set_implicit(true);
t
}));
let Some(parent) = parent.as_table_mut() else {
return Err(OlError::new(
ERR_MODEL_RELAY_FOREIGN_PROVIDER,
format!(
"'{}' declares `{MODEL_PROVIDERS_TABLE}` as something other than a table",
crate::core::path_compat::display_path(config_toml)
),
)
.with_suggestion(
"Fix or remove that key — OpenLatch will not rewrite a value it did not write.",
));
};
parent.insert(provider_name, toml_edit::Item::Table(provider));
set_root_value(doc, MODEL_PROVIDER_KEY, provider_name);
Ok(())
})?;
Ok(prior)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Prior {
Ours,
Theirs(Option<String>),
}
fn prior_from_document(doc: &toml_edit::DocumentMut, provider_name: &str) -> Prior {
match doc
.get(MODEL_PROVIDER_KEY)
.and_then(toml_edit::Item::as_str)
{
Some(current) if current == provider_name => Prior::Ours,
Some(current) => Prior::Theirs(Some(current.to_string())),
None => Prior::Theirs(None),
}
}
pub fn read_prior_provider(config_toml: &Path, provider_name: &str) -> Result<Prior, OlError> {
if !config_toml.exists() {
return Ok(Prior::Theirs(None));
}
let raw = std::fs::read_to_string(config_toml).map_err(|e| {
OlError::new(
ERR_MODEL_RELAY_FOREIGN_PROVIDER,
format!("cannot read {}: {e}", config_toml.display()),
)
})?;
let doc = raw.parse::<toml_edit::DocumentMut>().map_err(|e| {
OlError::new(
ERR_MODEL_RELAY_FOREIGN_PROVIDER,
format!("{} is not valid TOML: {e}", config_toml.display()),
)
})?;
Ok(prior_from_document(&doc, provider_name))
}
pub fn remove_provider_table(
config_toml: &Path,
provider_name: &str,
prior: Option<Option<String>>,
) -> Result<(), OlError> {
if !config_toml.exists() {
return Ok(());
}
crate::hooks::atomic::atomic_rewrite_toml(config_toml, |doc| {
if provider_table_is_ours(doc, provider_name) != Some(true) {
return Ok(());
}
let mut parent_is_empty = false;
if let Some(providers) = doc
.get_mut(MODEL_PROVIDERS_TABLE)
.and_then(toml_edit::Item::as_table_mut)
{
providers.remove(provider_name);
parent_is_empty = providers.is_empty();
}
if parent_is_empty {
doc.remove(MODEL_PROVIDERS_TABLE);
}
match prior {
Some(Some(ref v))
if doc
.get(MODEL_PROVIDER_KEY)
.and_then(toml_edit::Item::as_str)
== Some(provider_name) =>
{
set_root_value(doc, MODEL_PROVIDER_KEY, v)
}
Some(Some(_)) => {}
Some(None) | None => {
if doc
.get(MODEL_PROVIDER_KEY)
.and_then(toml_edit::Item::as_str)
== Some(provider_name)
{
doc.remove(MODEL_PROVIDER_KEY);
}
}
}
Ok(())
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn codex_paths_are_derived_from_the_config_dir() {
let root = Path::new("/home/test/.codex");
assert_eq!(hooks_json_path(root), root.join("hooks.json"));
assert_eq!(config_toml_path(root), root.join("config.toml"));
}
#[test]
fn codex_trust_key_uses_the_found_indices() {
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path();
std::fs::write(
hooks_json_path(root),
r#"{
"hooks": {
"PreToolUse": [
{"matcher": "", "hooks": [{"type": "command", "command": "echo mine", "timeout": 5}]},
{"matcher": "Bash", "_openlatch": {"v": 1, "id": "x"},
"hooks": [{"type": "command", "command": "\"/ol/bin/openlatch-hook\" --agent codex-cli --event pre_tool_use", "timeout": 10}]}
]
}
}"#,
)
.expect("write hooks.json");
let ours = installed_handler(root, "PreToolUse").expect("our appended group must be found");
assert_eq!(ours.group_index, 1, "ours is the SECOND group: {ours:?}");
assert_eq!(ours.handler_index, 0);
assert_eq!(ours.timeout_secs, Some(10));
assert!(ours.command.contains("openlatch-hook"));
let key = trust_key(root, "PreToolUse", &ours);
assert!(
key.ends_with(":1:0"),
"the key must carry the indices we FOUND, not :0:0 — {key}"
);
assert!(
!key.ends_with(":0:0"),
"a :0:0 key reads the customer's trust state as ours — {key}"
);
assert!(key.contains(":pre_tool_use:"), "{key}");
let at_zero = InstalledHandler {
group_index: 0,
handler_index: 0,
..ours.clone()
};
let documented_root = Path::new("/home/u/.codex");
let documented_key = format!(
"{}:pre_tool_use:0:0",
documented_root.join("hooks.json").display()
);
assert_eq!(
trust_key(documented_root, "PreToolUse", &at_zero),
documented_key
);
assert_eq!(
hook_trust(root, "PreToolUse", &ours),
Some(HookTrust::NeverTrusted),
"an absent config.toml is an answer, not an unknown"
);
let real_key = trust_key(root, "PreToolUse", &ours);
let customer_key = trust_key(root, "PreToolUse", &at_zero);
std::fs::write(
config_toml_path(root),
format!(
"[hooks.state.'{customer_key}']\n\
trusted_hash = \"customer-hash\"\n\
\n\
[hooks.state.'{real_key}']\n\
trusted_hash = \"ours\"\n"
),
)
.expect("write config.toml");
assert_eq!(
hook_trust(root, "PreToolUse", &ours),
Some(HookTrust::Trusted)
);
std::fs::write(
config_toml_path(root),
format!(
"[hooks.state.'{customer_key}']\n\
trusted_hash = \"customer-hash\"\n\
\n\
[hooks.state.'{real_key}']\n"
),
)
.expect("write config.toml");
assert_eq!(
hook_trust(root, "PreToolUse", &ours),
Some(HookTrust::NeverTrusted),
"a present key with no trusted_hash is a re-armed review"
);
std::fs::write(
config_toml_path(root),
format!(
"[hooks.state.'{real_key}']\n\
enabled = false\n\
trusted_hash = \"ours\"\n"
),
)
.expect("write config.toml");
assert_eq!(
hook_trust(root, "PreToolUse", &ours),
Some(HookTrust::Disabled)
);
std::fs::write(config_toml_path(root), "[hooks.state\n").expect("write config.toml");
assert_eq!(hook_trust(root, "PreToolUse", &ours), None);
std::fs::write(
config_toml_path(root),
format!("[hooks.state.'{real_key}']\ntrusted_hash = \"ours\"\n"),
)
.expect("write config.toml");
let link_parent = tempfile::tempdir().expect("temp dir");
let link = link_parent.path().join("codex-link");
symlink_dir(root, &link).expect("symlink");
assert_ne!(
trust_key(&link, "PreToolUse", &ours),
real_key,
"the fixture is pointless unless the two spellings differ"
);
assert_eq!(
hook_trust(&link, "PreToolUse", &ours),
Some(HookTrust::Trusted),
"a symlinked $CODEX_HOME must still find the entry Codex wrote"
);
}
#[cfg(unix)]
fn symlink_dir(target: &Path, link: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(target, link)
}
#[cfg(windows)]
fn symlink_dir(target: &Path, link: &Path) -> std::io::Result<()> {
std::os::windows::fs::symlink_dir(target, link)
}
fn customer_then_ours(root: &Path) {
std::fs::write(
hooks_json_path(root),
r#"{"hooks":{
"PreToolUse":[
{"matcher":"","hooks":[{"type":"command","command":"echo mine","timeout":5}]},
{"matcher":"Bash","_openlatch":{"v":1,"id":"x"},
"hooks":[{"type":"command","command":"\"/ol/bin/openlatch-hook\" --event pre_tool_use","timeout":900}]}],
"SessionStart":[
{"matcher":"","_openlatch":{"v":1,"id":"y"},
"hooks":[{"type":"command","command":"\"/ol/bin/openlatch-hook\" --event session_start","timeout":10}]}]}}"#,
)
.expect("write hooks.json");
}
fn captured_response(root: &Path, statuses: [&str; 3]) -> serde_json::Value {
let file = hooks_json_path(root);
let key = |rest: &str| format!("{}:{rest}", file.display());
serde_json::json!({"id": 2, "result": {"data": [{
"cwd": root,
"hooks": [
{"key": key("pre_tool_use:0:0"), "eventName": "preToolUse",
"command": "echo mine", "sourcePath": file, "source": "user",
"enabled": true, "isManaged": false,
"currentHash": "sha256:customer", "trustStatus": statuses[0]},
{"key": key("pre_tool_use:1:0"), "eventName": "preToolUse",
"command": "\"/ol/bin/openlatch-hook\" --event pre_tool_use",
"sourcePath": file, "source": "user", "enabled": true, "isManaged": false,
"currentHash": "sha256:ours-pre", "trustStatus": statuses[1]},
{"key": key("session_start:0:0"), "eventName": "sessionStart",
"command": "\"/ol/bin/openlatch-hook\" --event session_start",
"sourcePath": file, "source": "user", "enabled": true, "isManaged": false,
"currentHash": "sha256:ours-start", "trustStatus": statuses[2]},
{"key": "malformed — no hash, skipped rather than failing the listing"}
],
"warnings": [], "errors": []}]}})
}
#[test]
fn hooks_list_response_parses_and_errors_do_not() {
let dir = tempfile::tempdir().expect("temp dir");
let listing = parse_hooks_list(&captured_response(
dir.path(),
["untrusted", "trusted", "modified"],
))
.expect("a result");
assert_eq!(
listing.len(),
3,
"the malformed hook is skipped: {listing:?}"
);
assert_eq!(listing[2].trust_status, TrustStatus::Modified);
let error = serde_json::json!({"id": 2, "error": {"code": -32601, "message": "no"}});
assert!(parse_hooks_list(&error).is_none());
let unknown = serde_json::json!({"id": 2, "result": {"data": [{"hooks": [{
"key": "k:pre_tool_use:0:0", "sourcePath": "/x", "currentHash": "h",
"trustStatus": "someFutureStatus"}]}]}});
assert_eq!(
parse_hooks_list(&unknown).expect("a result")[0].trust_status,
TrustStatus::Unrecognised,
"a status this build does not know is not a parse failure"
);
}
#[test]
fn listed_trust_reads_our_position_not_the_customers() {
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path();
customer_then_ours(root);
let listing = parse_hooks_list(&captured_response(
root,
["untrusted", "trusted", "untrusted"],
))
.expect("a result");
let ours = installed_handler(root, "PreToolUse").expect("ours");
let (trust, hook) = listed_trust(&listing, root, "PreToolUse", &ours).expect("listed");
assert_eq!(trust, HookTrust::Trusted);
assert_eq!(hook.current_hash, "sha256:ours-pre");
assert!(listed_trust(&[], root, "PreToolUse", &ours).is_none());
}
#[test]
fn grant_writes_only_our_keys_and_preserves_the_rest() {
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path();
customer_then_ours(root);
let file = hooks_json_path(root).display().to_string();
let seed = format!(
"# mine\nmodel = \"gpt-5-codex\" # trailing\n\n\
[hooks.state.'{file}:pre_tool_use:0:0']\ntrusted_hash = \"sha256:customer\"\n\n\
[tui]\ntheme = \"dark\"\n"
);
std::fs::write(config_toml_path(root), &seed).expect("seed");
let listing = parse_hooks_list(&captured_response(
root,
["trusted", "untrusted", "modified"],
))
.expect("a result");
let grants = grant_own_hook_trust(root, &listing, TrustOccasion::Install).expect("grant");
assert_eq!(
grants
.iter()
.map(|g| g.trusted_hash.as_str())
.collect::<Vec<_>>(),
["sha256:ours-pre", "sha256:ours-start"],
"untrusted and modified are both ours to grant, the customer's is not"
);
let after = std::fs::read_to_string(config_toml_path(root)).expect("read back");
assert!(
after.starts_with(&seed[..seed.find("[tui]").expect("tui")]),
"{after}"
);
assert!(after.contains("[tui]\ntheme = \"dark\""), "{after}");
assert!(
after.contains("trusted_hash = \"sha256:customer\""),
"{after}"
);
assert!(
!after.contains("[hooks]\n"),
"no bare parent header: {after}"
);
let parsed: toml::Value = toml::from_str(&after).expect("valid TOML");
let state = &parsed["hooks"]["state"];
assert_eq!(
state[&format!("{file}:pre_tool_use:1:0")]["trusted_hash"].as_str(),
Some("sha256:ours-pre")
);
assert_eq!(
state[&format!("{file}:session_start:0:0")]["enabled"].as_bool(),
Some(true),
"an install switches our hook on"
);
assert!(state[&format!("{file}:pre_tool_use:0:0")]
.get("enabled")
.is_none());
let trusted = parse_hooks_list(&captured_response(
root,
["untrusted", "trusted", "trusted"],
))
.expect("a result");
assert!(grant_own_hook_trust(root, &trusted, TrustOccasion::Drift)
.expect("no-op")
.is_empty());
assert_eq!(
std::fs::read_to_string(config_toml_path(root)).expect("read back"),
after
);
}
#[test]
fn revoke_takes_back_our_grants_only() {
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path();
customer_then_ours(root);
let seed = "model = \"gpt-5-codex\"\n";
std::fs::write(config_toml_path(root), seed).expect("seed");
let listing = parse_hooks_list(&captured_response(
root,
["trusted", "untrusted", "untrusted"],
))
.expect("a result");
let grants = grant_own_hook_trust(root, &listing, TrustOccasion::Install).expect("grant");
revoke_granted_trust(root, &grants).expect("revoke");
assert_eq!(
std::fs::read_to_string(config_toml_path(root)).expect("read back"),
seed,
"no bare [hooks] or [hooks.state] header survives"
);
grant_own_hook_trust(root, &listing, TrustOccasion::Install).expect("grant");
let file = hooks_json_path(root).display().to_string();
let path = config_toml_path(root);
let regranted = std::fs::read_to_string(&path).expect("read").replacen(
"sha256:ours-pre",
"sha256:reviewed-by-hand",
1,
);
std::fs::write(&path, regranted).expect("write");
revoke_granted_trust(root, &grants).expect("revoke");
let after = std::fs::read_to_string(&path).expect("read back");
assert!(
after.contains("sha256:reviewed-by-hand") && !after.contains("sha256:ours-start"),
"{after}"
);
assert!(
after.contains(&format!("{file}:pre_tool_use:1:0")),
"{after}"
);
}
#[test]
fn an_index_shift_is_seen_as_a_trust_gap() {
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path();
customer_then_ours(root);
let file = hooks_json_path(root).display().to_string();
std::fs::write(
config_toml_path(root),
format!(
"[hooks.state.'{file}:pre_tool_use:1:0']\ntrusted_hash = \"a\"\n\
[hooks.state.'{file}:session_start:0:0']\ntrusted_hash = \"b\"\n"
),
)
.expect("config");
let ours = installed_handler(root, "PreToolUse").expect("ours");
assert_eq!(
hook_trust(root, "PreToolUse", &ours),
Some(HookTrust::Trusted),
"the fixture must be readable TOML that trusts ours"
);
assert!(
!own_hooks_need_trust(root),
"every handler of ours is trusted"
);
let raw = std::fs::read_to_string(hooks_json_path(root)).expect("read");
let mut parsed: serde_json::Value = serde_json::from_str(&raw).expect("json");
parsed["hooks"]["PreToolUse"]
.as_array_mut()
.expect("array")
.remove(0);
std::fs::write(hooks_json_path(root), parsed.to_string()).expect("write");
assert!(own_hooks_need_trust(root), "the shift left ours untrusted");
std::fs::write(
config_toml_path(root),
format!(
"[hooks.state.'{file}:pre_tool_use:0:0']\nenabled = false\n\
[hooks.state.'{file}:session_start:0:0']\ntrusted_hash = \"b\"\n"
),
)
.expect("config");
assert!(!own_hooks_need_trust(root));
}
const SEED: &str = "\
# my config, hand written
model = \"gpt-5-codex\" # trailing comment
# a customer provider they actually use
model_provider = \"corporate-gateway\"
[model_providers.corporate-gateway]
name = \"ACME\"
base_url = \"https://llm.acme.internal/v1\"
wire_api = \"responses\"
[tui]
theme = \"dark\"
";
fn seeded_config(seed: &str) -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let path = config_toml_path(dir.path());
std::fs::write(&path, seed).expect("seed config.toml");
(dir, path)
}
fn install(path: &Path, port: u16) -> Result<Prior, OlError> {
write_provider_table(
path,
"openlatch",
"responses",
"x-openlatch-install-id",
port,
"agt_demo",
)
}
#[test]
fn toml_writer_preserves_comments_and_key_order() {
let (_dir, path) = seeded_config(SEED);
install(&path, 7600).expect("install");
let after = std::fs::read_to_string(&path).expect("read back");
for fragment in [
"# my config, hand written",
"model = \"gpt-5-codex\" # trailing comment",
"# a customer provider they actually use",
"[model_providers.corporate-gateway]",
"name = \"ACME\"",
"base_url = \"https://llm.acme.internal/v1\"",
"[tui]",
"theme = \"dark\"",
] {
assert!(
after.contains(fragment),
"install ate `{fragment}`:\n{after}"
);
}
assert!(
after.contains("[model_providers.openlatch]"),
"our table must be an explicit header, never an inline table:\n{after}"
);
assert!(
after.contains("base_url = \"http://127.0.0.1:7600/v1\""),
"the RESOLVED port, and Codex's `/v1` suffix:\n{after}"
);
assert!(
after.contains("name = \"OpenLatch model relay\""),
"`name` is MANDATORY — Codex rejects the whole config without it:\n{after}"
);
assert!(
after.contains("wire_api = \"responses\""),
"the wire_api comes from the binding:\n{after}"
);
assert!(
after.contains("[model_providers.openlatch.http_headers]"),
"the install-id header rides on the provider table:\n{after}"
);
assert_eq!(
after.matches("model_provider = ").count(),
1,
"the pointer is replaced in place, never duplicated:\n{after}"
);
assert!(
after.contains("model_provider = \"openlatch\""),
"and it names us after an install:\n{after}"
);
assert_eq!(
read_provider_base_url(&path, "openlatch").as_deref(),
Some("http://127.0.0.1:7600/v1")
);
assert_eq!(
read_provider_base_url(&path, "corporate-gateway"),
None,
"a customer's own provider table is not evidence that we wired anything"
);
}
#[test]
fn toml_uninstall_is_byte_identical_to_the_seed() {
let (_dir, path) = seeded_config(SEED);
let prior = install(&path, 7600).expect("install");
assert_eq!(
prior,
Prior::Theirs(Some("corporate-gateway".into())),
"the writer must hand back what the file named before it"
);
remove_provider_table(&path, "openlatch", Some(Some("corporate-gateway".into())))
.expect("uninstall");
let after = std::fs::read_to_string(&path).expect("read back");
assert_eq!(after, SEED, "uninstall must be byte-identical to the seed");
assert!(
!after.contains("[model_providers]"),
"a bare parent header must not survive uninstall:\n{after}"
);
}
#[test]
fn a_provider_the_customer_chose_after_install_is_not_overwritten() {
let (_dir, path) = seeded_config(SEED);
install(&path, 7600).expect("install");
let installed = std::fs::read_to_string(&path).expect("read");
std::fs::write(
&path,
installed.replace(
r#"model_provider = "openlatch""#,
r#"model_provider = "new-gateway""#,
),
)
.expect("customer edit");
remove_provider_table(&path, "openlatch", Some(Some("corporate-gateway".into())))
.expect("uninstall");
let after = std::fs::read_to_string(&path).expect("read back");
assert!(
after.contains(r#"model_provider = "new-gateway""#),
"the customer's later choice must survive uninstall:\n{after}"
);
assert!(
!after.contains(r#"model_provider = "corporate-gateway""#),
"uninstall must not resurrect the pointer we displaced once the customer \
has moved on:\n{after}"
);
assert!(
after.contains("[model_providers.corporate-gateway]"),
"the customer's own provider table is theirs and must survive:\n{after}"
);
assert!(
!after.contains("[model_providers.openlatch]"),
"our own provider table must still be removed:\n{after}"
);
}
#[test]
fn toml_restores_a_customer_model_provider() {
let (_dir, path) = seeded_config(SEED);
install(&path, 7600).expect("install");
remove_provider_table(&path, "openlatch", Some(Some("corporate-gateway".into())))
.expect("uninstall");
assert!(
std::fs::read_to_string(&path)
.expect("read back")
.contains("model_provider = \"corporate-gateway\""),
"the customer's pointer must come back"
);
let fresh = "model = \"gpt-5-codex\"\n";
let (_dir2, path2) = seeded_config(fresh);
assert_eq!(
install(&path2, 7600).expect("install"),
Prior::Theirs(None),
"a file that named no provider records an absence, not a value"
);
remove_provider_table(&path2, "openlatch", Some(None)).expect("uninstall");
let after = std::fs::read_to_string(&path2).expect("read back");
assert_eq!(after, fresh, "and the file comes back byte-identical");
assert!(!after.contains("model_provider"));
}
#[test]
fn reinstall_does_not_destroy_the_recorded_prior() {
let _guard = crate::config::OPENLATCH_DIR_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let state = tempfile::tempdir().expect("state dir");
let prev = std::env::var_os("OPENLATCH_DIR");
std::env::set_var("OPENLATCH_DIR", state.path());
let (_dir, path) = seeded_config(SEED);
let binding = crate::hooks::binding::test_support::FakeBinding {
agent_type: "codex-cli",
config_dir: path.parent().expect("parent").to_path_buf(),
model_relay_wiring: Some(crate::hooks::binding::ModelRelayWiring {
wire_format: crate::model_relay::wire_format::WireFormat::OpenAiResponses,
endpoint: crate::hooks::binding::EndpointConvention::TomlProvider {
provider_name: "openlatch",
wire_api: "responses",
},
install_id_header: "x-openlatch-install-id",
}),
..Default::default()
};
crate::hooks::write_model_relay_config(&binding, 7600, "agt_demo").expect("install");
crate::hooks::write_model_relay_config(&binding, 7600, "agt_demo").expect("re-install");
crate::hooks::remove_model_relay_config(&binding).expect("uninstall");
crate::hooks::remove_model_relay_config(&binding).expect("second uninstall pass");
let after = std::fs::read_to_string(&path).expect("read back");
match prev {
Some(v) => std::env::set_var("OPENLATCH_DIR", v),
None => std::env::remove_var("OPENLATCH_DIR"),
}
assert_eq!(
after, SEED,
"after TWO installs the uninstall must still restore the CUSTOMER's pointer"
);
}
#[test]
fn foreign_openlatch_table_refuses() {
let foreign = "\
# somebody else got here first
model_provider = \"openlatch\"
[model_providers.openlatch]
name = \"Someone else's openlatch\"
base_url = \"https://openlatch.internal.example/v1\"
wire_api = \"responses\"
";
let (_dir, path) = seeded_config(foreign);
let err = install(&path, 7600).expect_err("a foreign table must refuse");
assert_eq!(err.code, ERR_MODEL_RELAY_FOREIGN_PROVIDER);
assert!(
err.suggestion.is_some(),
"a refusal the operator cannot act on is not a remedy"
);
assert_eq!(
std::fs::read_to_string(&path).expect("read back"),
foreign,
"the file must be untouched after the refusal"
);
remove_provider_table(&path, "openlatch", Some(Some("whatever".into())))
.expect("removal is a no-op on a foreign table");
assert_eq!(
std::fs::read_to_string(&path).expect("read back"),
foreign,
"uninstall must not eat a provider table it did not write"
);
}
}