Skip to main content

borg/harness/
codex.rs

1//! Codex CLI: native command hooks in `~/.codex/hooks.json` (`$CODEX_HOME`), each running
2//! `<borg> hook codex <event>`. The file has Claude Code's shape, and so do the events and the answers
3//! Codex reads back (`hookSpecificOutput.additionalContext`, `{"decision":"block","reason"}`), except
4//! the edit tool: `apply_patch` sends a patch, which [`Codex::normalise`] turns into a canonical `Edit`.
5//!
6//! Codex runs a new or changed hook only once the engineer trusted it (`/hooks` in codex), and skips an
7//! untrusted one without a word: `borg install` says so, and `borg doctor` asks Codex itself which of
8//! borg's hooks are not trusted (`codex app-server`, `hooks/list`). borg never writes that trust.
9
10use std::io::{BufRead as _, Write as _};
11use std::path::{Path, PathBuf};
12use std::process::{Command, Stdio};
13use std::time::Duration;
14
15use serde_json::{Value, json};
16
17use super::wiring::Hooks;
18use super::{Caps, DroneWake, Harness, Level, Probe, Run, SubAgent, Transcript, Wiring};
19
20/// Codex hook events and the canonical borg events they become. Codex has no failure event (a failed
21/// tool call fires no PostToolUse at all), so denials are not captured. `Interrupt` (an Esc) ends the
22/// turn of a session that listens for wakes (listen.rs), which a turn with no stop and a tool call that
23/// never ended would hold off for `listen::TOOL_MS`.
24pub const EVENTS: [(&str, &str); 6] = [
25    ("UserPromptSubmit", "prompt"),
26    ("PreToolUse", "pre_tool"),
27    ("PostToolUse", "post_tool"),
28    ("Stop", "stop"),
29    ("SessionEnd", "end"),
30    ("Interrupt", "halted"),
31];
32
33/// Hooks an older borg installed and this one does not.
34pub const RETIRED: [(&str, &str); 0] = [];
35
36/// Seconds Codex gives each hook, written explicitly (its own default is 600 s, and 1 s for SessionEnd,
37/// which it clamps to 3 s). The trust Codex keeps covers the timeout too: changing one here makes every
38/// engineer trust borg's hooks again.
39pub const TIMEOUTS: [(&str, u64); 6] = [("prompt", 30), ("pre_tool", 10), ("post_tool", 30), ("stop", STOP_TIMEOUT_S), ("end", 3), ("halted", 3)];
40
41/// The stop's timeout, and so its budget: the live wait, the stop's model call and the expert drone keep
42/// inside it.
43pub const STOP_TIMEOUT_S: u64 = 90;
44
45/// How long `borg doctor` waits for Codex to list its hooks.
46const LIST_TIMEOUT: Duration = Duration::from_secs(3);
47
48pub struct Codex;
49
50impl Harness for Codex {
51    fn name(&self) -> &'static str {
52        "codex"
53    }
54
55    fn caps(&self) -> Caps {
56        Caps {
57            // Both reach the next model request as a developer message (in code mode, after the whole
58            // `exec` script ran).
59            prompt_context: true,
60            tool_context: true,
61            early_delivery: false,
62            question_dialog: false,
63            // `transcript_path` is a Codex rollout, not a Claude transcript (`normalise` drops it).
64            transcript: Transcript::None,
65            deny_capture: false,
66            sends_pid: false,
67            stop_budget: Some(Duration::from_secs(STOP_TIMEOUT_S)),
68            // Also inside its sandbox; equal to the hooks' `session_id` and to `codex queue --thread`'s
69            // thread (0.153.2, against a local mock model).
70            session_env: Some("CODEX_THREAD_ID"),
71            pid_env: None,
72            drone_wake: DroneWake::Queue,
73        }
74    }
75
76    fn wiring(&self) -> Wiring {
77        Wiring::CommandHooks { events: &EVENTS, timeouts: &TIMEOUTS, retired: &RETIRED, through_links: true }
78    }
79
80    fn target(&self, home: &Path, var: &dyn Fn(&str) -> Option<PathBuf>) -> PathBuf {
81        var("CODEX_HOME").unwrap_or_else(|| home.join(".codex")).join("hooks.json")
82    }
83
84    fn probe(&self) -> Probe {
85        Probe { cli: "codex", tested: " (hooks tested with 0.153.2)" }
86    }
87
88    /// `apply_patch` becomes a canonical `Edit` (see [`patch_edit`]); the rollout path is dropped.
89    fn normalise(&self, mut raw: Value) -> Value {
90        let Some(m) = raw.as_object_mut() else { return raw };
91        m.shift_remove("transcript_path");
92        if m.get("tool_name").and_then(Value::as_str) == Some("apply_patch") {
93            let cwd = m.get("cwd").and_then(Value::as_str).unwrap_or_default().to_string();
94            let patch = match m.get("tool_input") {
95                Some(Value::String(p)) => p.clone(),
96                Some(v) => v.get("command").and_then(Value::as_str).unwrap_or_default().to_string(),
97                None => String::new(),
98            };
99            if let Some(edit) = patch_edit(&patch, Path::new(&cwd)) {
100                m.insert("tool_name".into(), json!("Edit"));
101                m.insert("tool_input".into(), edit);
102            }
103        }
104        raw
105    }
106
107    /// Each session's spawned agents ([`spawn_runs`]) under `$CODEX_HOME/sessions`, else
108    /// `home/.codex/sessions`.
109    fn sub_runs(&self, home: &Path, session: Option<&str>, since: Option<i64>) -> Vec<Run> {
110        let codex = std::env::var_os("CODEX_HOME").filter(|v| !v.is_empty()).map_or_else(|| home.join(".codex"), PathBuf::from);
111        spawn_runs(&codex.join("sessions"), session, since)
112    }
113
114    fn install_notes(&self) -> Vec<String> {
115        vec!["open codex and run /hooks to trust borg's hooks (codex skips untrusted hooks silently)".into()]
116    }
117
118    /// Which of borg's hooks Codex does not trust, as Codex itself reports it. Only once borg's hooks are
119    /// wired; doctor asks only when codex is installed and answers `--version`. A failed call is a note,
120    /// never a failure.
121    fn checks(&self, target: &Path) -> Vec<(Level, String)> {
122        let Some(hooks) = Hooks::of(self) else { return Vec::new() };
123        if hooks.wired(std::fs::read_to_string(target).ok().as_deref()).is_empty() {
124            return Vec::new();
125        }
126        let home = target.parent().unwrap_or(Path::new("."));
127        let cwd = std::env::current_dir().unwrap_or_else(|_| home.to_path_buf());
128        match hooks_list(home, &cwd, LIST_TIMEOUT) {
129            Ok(list) => trust_findings(&list, &hooks),
130            Err(why) => vec![(Level::Ok, format!("codex: could not ask codex whether it trusts borg's hooks ({why}); run /hooks in codex to check"))],
131        }
132    }
133}
134
135/// One `apply_patch` as the canonical edit: the first file the patch names (`*** Add|Update|Delete
136/// File:`) as `file_path` with its added lines as `new_string`, each further file in `edits` the same
137/// way. A rename (`*** Move to:` after an Update) names its destination as a further file, which gets
138/// the hunk's added lines. Headers are matched on the trimmed line, as Codex does. Relative paths are
139/// taken from `cwd`. None for a patch naming no file.
140pub fn patch_edit(patch: &str, cwd: &Path) -> Option<Value> {
141    let mut files: Vec<(String, Vec<&str>)> = Vec::new();
142    for line in patch.lines() {
143        let header = ["*** Add File: ", "*** Update File: ", "*** Delete File: ", "*** Move to: "].iter().find_map(|h| line.trim().strip_prefix(h));
144        if let Some(path) = header {
145            let p = Path::new(path.trim());
146            files.push((if p.is_absolute() { p.to_path_buf() } else { cwd.join(p) }.display().to_string(), Vec::new()));
147        } else if let (Some((_, added)), Some(text)) = (files.last_mut(), line.strip_prefix('+')) {
148            added.push(text);
149        }
150    }
151    let mut edits = files.into_iter().map(|(file_path, added)| json!({"file_path": file_path, "new_string": added.join("\n")}));
152    let mut edit = edits.next()?;
153    let rest: Vec<Value> = edits.collect();
154    if !rest.is_empty() {
155        edit["edits"] = json!(rest);
156    }
157    Some(edit)
158}
159
160// ---------------------------------------------------------------- spawned agents
161
162/// One run per session whose agent spawned others (`spawn_agent`), oldest first: of `session` only when
163/// given, from sessions that started at or after `since`. Every thread keeps its own rollout
164/// (`<sessions>/YYYY/MM/DD/rollout-*.jsonl`), whose first line, `session_meta`, names a spawned
165/// thread's parent (`source.subagent.thread_spawn.parent_thread_id`, with its `agent_path`, nickname
166/// and role; checked against the serde names in codex 0.153.2). An agent spawned by a spawned agent
167/// belongs to its root session's run, which is [`Run::loose`]. Codex has no structured output, so
168/// findings come from an agent's text. This reader is built from codex's serde names and hand-made
169/// rollouts, not checked against a real spawned one yet.
170pub fn spawn_runs(sessions: &Path, session: Option<&str>, since: Option<i64>) -> Vec<Run> {
171    let metas: Vec<(PathBuf, Value)> = rollouts(sessions).into_iter().filter_map(|f| Some((f.clone(), first_meta(&f)?))).collect();
172    let file: std::collections::HashMap<&str, &Path> = metas.iter().filter_map(|(f, m)| Some((m["id"].as_str()?, f.as_path()))).collect();
173    let parent = |m: &Value| m["source"]["subagent"]["thread_spawn"]["parent_thread_id"].as_str().map(String::from);
174    let parents: std::collections::HashMap<String, String> = metas.iter().filter_map(|(_, m)| Some((m["id"].as_str()?.to_string(), parent(m)?))).collect();
175    let mut runs: std::collections::BTreeMap<String, Run> = std::collections::BTreeMap::new();
176    for (f, m) in metas.iter().filter(|(_, m)| parent(m).is_some()) {
177        let id = root_of(&parents, m["id"].as_str().unwrap_or_default());
178        if session.is_some_and(|s| s != id) {
179            continue;
180        }
181        let from = parent(m).unwrap_or_default();
182        let mut a = spawned(m, &id, &from);
183        // A forked agent's rollout may open with the history it inherited (`fork_turns`): its parent's.
184        let inherited = m["forked_from_id"].as_str().unwrap_or(&from);
185        read_rollout(f, file.get(inherited).copied(), &mut a);
186        runs.entry(id.clone()).or_insert_with(|| Run { id: id.clone(), session: id, loose: true, ..Default::default() }).agents.push(a);
187    }
188    let started: std::collections::HashMap<&str, i64> = metas.iter().filter_map(|(_, m)| Some((m["id"].as_str()?, ts(&m["timestamp"])))).collect();
189    let mut out: Vec<Run> = runs
190        .into_values()
191        .map(|mut r| {
192            r.agents.sort_by_key(|a| a.started);
193            r.started = started.get(r.id.as_str()).copied().filter(|t| *t > 0).unwrap_or(r.agents[0].started);
194            r
195        })
196        .filter(|r| since.is_none_or(|s| r.started >= s))
197        .collect();
198    out.sort_by_key(|r| r.started);
199    out
200}
201
202/// The root of spawned thread `id`: its `parents` followed up to a thread nobody spawned.
203fn root_of(parents: &std::collections::HashMap<String, String>, id: &str) -> String {
204    let mut id = id.to_string();
205    for _ in 0..parents.len() {
206        match parents.get(&id) {
207            Some(p) => id = p.clone(),
208            None => break,
209        }
210    }
211    id
212}
213
214/// The spawned agent of `session_meta` payload `m`, in the run of root thread `root`, spawned by
215/// thread `from` (no parent agent when the root's own agent spawned it); its work not read yet.
216fn spawned(m: &Value, root: &str, from: &str) -> SubAgent {
217    let spawn = &m["source"]["subagent"]["thread_spawn"];
218    let s = |k: &str| spawn[k].as_str().or_else(|| m[k].as_str()).unwrap_or_default().to_string();
219    let path = s("agent_path");
220    let label = path.rsplit('/').next().filter(|l| !l.is_empty()).map_or_else(|| s("agent_nickname"), String::from);
221    SubAgent {
222        id: m["id"].as_str().unwrap_or_default().to_string(),
223        parent: if from == root { String::new() } else { from.to_string() },
224        label,
225        kind: s("agent_role"),
226        started: ts(&m["timestamp"]),
227        cwd: s("cwd"),
228        ..Default::default()
229    }
230}
231
232/// The `rollout-*.jsonl` files under `dir`, at any depth.
233fn rollouts(dir: &Path) -> Vec<PathBuf> {
234    let mut entries: Vec<PathBuf> = std::fs::read_dir(dir).into_iter().flatten().filter_map(|e| e.ok().map(|e| e.path())).collect();
235    entries.sort();
236    let mut out = Vec::new();
237    for p in entries {
238        if p.is_dir() {
239            out.extend(rollouts(&p));
240        } else if p.file_name().is_some_and(|n| n.to_string_lossy().starts_with("rollout-")) && p.extension().is_some_and(|e| e == "jsonl") {
241            out.push(p);
242        }
243    }
244    out
245}
246
247/// A rollout's `session_meta` payload, from its first line only.
248fn first_meta(f: &Path) -> Option<Value> {
249    let line = std::io::BufReader::new(std::fs::File::open(f).ok()?).lines().next()?.ok()?;
250    let v: Value = serde_json::from_str(&line).ok()?;
251    (v["type"] == "session_meta").then(|| v["payload"].clone())
252}
253
254fn ts(v: &Value) -> i64 {
255    v.as_str().map_or(0, borg_trace::parse_ts)
256}
257
258/// A rollout's response items, in order.
259fn items(f: &Path) -> Vec<Value> {
260    let text = std::fs::read_to_string(f).unwrap_or_default();
261    text.lines().filter_map(|l| serde_json::from_str::<Value>(l).ok()).filter(|v| v["type"] == "response_item").map(|v| v["payload"].clone()).collect()
262}
263
264/// Read one spawned agent's rollout into `a`: its task (its first user message that Codex did not
265/// inject: AGENTS.md, environment and other tagged context), every tool input and text it produced.
266/// The items it shares, from the start, with its parent's rollout (`parent`) are history it inherited,
267/// never its own task or work.
268fn read_rollout(f: &Path, parent: Option<&Path>, a: &mut SubAgent) {
269    let own = items(f);
270    let inherited = parent.map(items).unwrap_or_default();
271    let skip = own.iter().zip(&inherited).take_while(|(o, i)| o == i).count();
272    for p in &own[skip..] {
273        let said = || p["content"].as_array().into_iter().flatten().filter_map(|c| c["text"].as_str()).collect::<Vec<_>>().join("\n");
274        match (p["type"].as_str(), p["role"].as_str()) {
275            (Some("message"), Some("user")) if a.prompt.is_empty() => {
276                let t = said();
277                let t = t.trim_start();
278                if !t.starts_with('<') && !t.starts_with("# AGENTS.md") {
279                    a.prompt = t.to_string();
280                }
281            }
282            (Some("message"), Some("assistant")) => {
283                let t = said();
284                if !t.trim().is_empty() {
285                    a.said(&t);
286                }
287            }
288            (Some("function_call"), _) => {
289                let args = p["arguments"].as_str().unwrap_or_default();
290                a.called(&serde_json::from_str(args).unwrap_or_else(|_| Value::String(args.into())));
291            }
292            (Some("custom_tool_call"), _) => a.called(&p["input"]),
293            (Some("local_shell_call"), _) => a.called(&p["action"]),
294            _ => {}
295        }
296    }
297}
298
299/// Codex's own list of its hooks for `cwd` (`codex app-server`, JSON-RPC over stdio: `initialize`,
300/// `initialized`, `hooks/list`), with `CODEX_HOME` at `home`; the whole process group is killed after
301/// the answer or `timeout`.
302fn hooks_list(home: &Path, cwd: &Path, timeout: Duration) -> Result<Value, String> {
303    use std::os::unix::process::CommandExt as _;
304    let mut child = Command::new("codex")
305        .arg("app-server")
306        .env("CODEX_HOME", home)
307        .current_dir(cwd)
308        .stdin(Stdio::piped())
309        .stdout(Stdio::piped())
310        .stderr(Stdio::null())
311        .process_group(0)
312        .spawn()
313        .map_err(|e| format!("codex app-server: {e}"))?;
314    let mut stdin = child.stdin.take().ok_or("no stdin")?;
315    let stdout = child.stdout.take().ok_or("no stdout")?;
316    let msgs = [
317        json!({"id": 1, "method": "initialize", "params": {"clientInfo": {"name": "borg-doctor", "title": null, "version": env!("CARGO_PKG_VERSION")}}}),
318        json!({"method": "initialized"}),
319        json!({"id": 2, "method": "hooks/list", "params": {"cwds": [cwd]}}),
320    ];
321    let sent = msgs.iter().try_for_each(|m| writeln!(stdin, "{m}")).and_then(|_| stdin.flush());
322    let answers = answer_to(2, stdout);
323    let answer = sent.map_err(|e| e.to_string()).and_then(|_| answers.recv_timeout(timeout).map_err(|_| format!("no answer within {} s", timeout.as_secs())));
324    // SAFETY: a plain kill(2) of the child's own process group (the npm launcher and the native binary).
325    unsafe {
326        libc::kill(-(child.id() as i32), libc::SIGKILL);
327    }
328    let _ = child.wait();
329    drop(stdin);
330    let answer = answer?;
331    match answer.get("error") {
332        Some(e) => Err(format!("hooks/list: {e}")),
333        None => Ok(answer),
334    }
335}
336
337/// The JSON-RPC answer to request `id` among the lines `out` prints, read on a thread of its own.
338fn answer_to(id: u64, out: impl std::io::Read + Send + 'static) -> std::sync::mpsc::Receiver<Value> {
339    let (tx, rx) = std::sync::mpsc::channel();
340    std::thread::spawn(move || {
341        let answer = std::io::BufReader::new(out).lines().map_while(Result::ok).filter_map(|l| serde_json::from_str::<Value>(&l).ok()).find(|v| v["id"] == id);
342        if let Some(v) = answer {
343            let _ = tx.send(v);
344        }
345    });
346    rx
347}
348
349/// `borg doctor`'s findings from a `hooks/list` answer: borg's hooks (`hooks` recognises their commands)
350/// that Codex would skip (untrusted, changed since trusted, disabled), and Codex's load warnings.
351pub fn trust_findings(list: &Value, hooks: &Hooks) -> Vec<(Level, String)> {
352    let data = list["result"]["data"].as_array().cloned().unwrap_or_default();
353    let mut out: Vec<(Level, String)> = Vec::new();
354    let (mut trusted, mut skipped) = (0, Vec::new());
355    for h in data.iter().flat_map(|d| d["hooks"].as_array().cloned().unwrap_or_default()) {
356        let Some((_, ev)) = h["command"].as_str().and_then(|c| hooks.recognise(c)) else { continue };
357        let status = h["trustStatus"].as_str().unwrap_or("unknown");
358        match (h["enabled"].as_bool() != Some(false), status) {
359            (true, "trusted" | "managed") => trusted += 1,
360            (false, _) => skipped.push(format!("{ev} disabled")),
361            (true, "modified") => skipped.push(format!("{ev} changed since trusted")),
362            (true, s) => skipped.push(format!("{ev} {s}")),
363        }
364    }
365    for w in data.iter().flat_map(|d| d["warnings"].as_array().cloned().unwrap_or_default()) {
366        out.push((Level::Warn, format!("codex: {}", w.as_str().map_or_else(|| w.to_string(), str::to_string))));
367    }
368    if !skipped.is_empty() {
369        out.push((Level::Warn, format!("codex: skips borg's hooks silently ({}): open codex and run /hooks to trust borg's hooks", skipped.join(", "))));
370    } else if trusted == 0 {
371        out.push((Level::Warn, "codex: lists none of borg's hooks (are hooks off in codex's config, features.hooks = false?)".into()));
372    } else {
373        out.push((Level::Ok, format!("codex: trusts borg's {trusted} hooks")));
374    }
375    out
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    fn fixture(name: &str) -> Value {
383        let p = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/codex").join(name);
384        serde_json::from_str(&std::fs::read_to_string(p).unwrap()).unwrap()
385    }
386
387    #[test]
388    fn a_forked_agents_inherited_history_is_neither_its_task_nor_its_work() {
389        let sessions = std::env::temp_dir().join(format!("borg-codex-fork-{}", std::process::id()));
390        let _ = std::fs::remove_dir_all(&sessions);
391        let day = sessions.join("2026/09/14");
392        std::fs::create_dir_all(&day).unwrap();
393        let rollout = |thread: &str, ts: &str, meta: Value, items: &[Value]| {
394            let mut lines = vec![json!({"timestamp": ts, "type": "session_meta", "payload": meta})];
395            lines.extend(items.iter().map(|p| json!({"timestamp": ts, "type": "response_item", "payload": p})));
396            std::fs::write(day.join(format!("rollout-{ts}-{thread}.jsonl")), lines.iter().map(|v| format!("{v}\n")).collect::<String>()).unwrap();
397        };
398        let msg = |role: &str, text: &str| json!({"type": "message", "role": role, "content": [{"type": "input_text", "text": text}]});
399        let edit = json!({"type": "custom_tool_call", "name": "apply_patch", "input": "*** Update File: src/parent.rs"});
400        let parent = [msg("user", "build the net"), edit.clone(), msg("assistant", "Built.")];
401        rollout("p", "2026-09-14T13-14-00Z", json!({"id": "p", "timestamp": "2026-09-14T13:14:00Z", "source": "cli"}), &parent);
402        let source = json!({"subagent": {"thread_spawn": {"parent_thread_id": "p", "agent_path": "/root/fix_net", "agent_role": "worker"}}});
403        let meta = json!({"id": "c", "timestamp": "2026-09-14T13:15:00Z", "forked_from_id": "p", "source": source});
404        let mut own = parent.to_vec();
405        own.extend([msg("user", "fix the arc"), json!({"type": "custom_tool_call", "name": "apply_patch", "input": "*** Update File: src/arc.rs"})]);
406        rollout("c", "2026-09-14T13-15-00Z", meta, &own);
407        let runs = spawn_runs(&sessions, None, None);
408        assert_eq!(runs.len(), 1);
409        let a = &runs[0].agents[0];
410        assert_eq!((runs[0].id.as_str(), runs[0].loose, a.id.as_str(), a.prompt.as_str()), ("p", true, "c", "fix the arc"));
411        assert!(a.work.contains("src/arc.rs") && !a.work.contains("src/parent.rs"), "{}", a.work);
412        let _ = std::fs::remove_dir_all(sessions);
413    }
414
415    #[test]
416    fn a_one_file_patch_is_an_edit_of_that_file_and_the_rollout_is_dropped() {
417        let raw = json!({"session_id": "s", "cwd": "/w", "transcript_path": "/x/rollout.jsonl", "tool_name": "apply_patch",
418            "tool_input": {"command": "*** Begin Patch\n*** Update File: src/net.rs\n@@\n-drain\n+reset arc\n+on the place\n*** End Patch\n"},
419            "tool_response": "Exit code: 0\nOutput:\nSuccess. Updated the following files:\nM src/net.rs\n"});
420        let ev = Codex.normalise(raw);
421        assert_eq!(ev["tool_name"], "Edit");
422        assert_eq!(ev["tool_input"], json!({"file_path": "/w/src/net.rs", "new_string": "reset arc\non the place"}));
423        assert!(ev.get("transcript_path").is_none(), "a Codex rollout is no Claude transcript: {ev}");
424        assert!(ev["tool_response"].is_string(), "the tool's output stays as Codex sent it");
425    }
426
427    #[test]
428    fn a_patch_of_several_files_names_every_file() {
429        let raw = fixture("multi_patch.json");
430        let ev = Codex.normalise(raw);
431        assert_eq!(ev["tool_name"], "Edit");
432        assert_eq!(ev["tool_input"]["file_path"], "/work/b.txt");
433        assert_eq!(ev["tool_input"]["new_string"], "new file line");
434        assert_eq!(ev["tool_input"]["edits"], json!([{"file_path": "/work/a.txt", "new_string": "hello world"}]));
435        let gone = patch_edit("*** Begin Patch\n*** Delete File: /abs/old.rs\n*** End Patch\n", Path::new("/w")).unwrap();
436        assert_eq!(gone, json!({"file_path": "/abs/old.rs", "new_string": ""}), "an absolute path is kept, a deletion adds nothing");
437        assert_eq!(patch_edit("not a patch", Path::new("/w")), None);
438        let moved = patch_edit("*** Begin Patch\n*** Update File: a.rs\n*** Move to: b.rs\n@@\n-old\n+new\n*** End Patch\n", Path::new("/w")).unwrap();
439        assert_eq!(
440            moved,
441            json!({"file_path": "/w/a.rs", "new_string": "", "edits": [{"file_path": "/w/b.rs", "new_string": "new"}]}),
442            "a rename names its destination"
443        );
444        let indented = patch_edit("*** Begin Patch\n  *** Update File: x.rs\n+y\n*** End Patch\n", Path::new("/w")).unwrap();
445        assert_eq!(indented, json!({"file_path": "/w/x.rs", "new_string": "y"}), "a header is matched trimmed, as Codex does");
446    }
447
448    #[test]
449    fn a_shell_call_passes_through_and_a_stop_keeps_its_round_flag() {
450        let bash = fixture("bash.json");
451        let ev = Codex.normalise(bash.clone());
452        assert_eq!((&ev["tool_name"], &ev["tool_input"], &ev["tool_response"]), (&bash["tool_name"], &bash["tool_input"], &bash["tool_response"]));
453        assert!(ev.get("transcript_path").is_none());
454        let stop = Codex.normalise(fixture("stop_again.json"));
455        assert_eq!(stop["stop_hook_active"], true);
456        assert_eq!(Codex.normalise(json!("not an object")), json!("not an object"));
457    }
458
459    #[test]
460    fn every_hook_gets_its_timeout_and_the_file_is_codex_home_hooks_json() {
461        let k = Hooks::of(&Codex).unwrap();
462        let v: Value = serde_json::from_str(&k.install(None, Path::new("/b/borg")).unwrap().unwrap()).unwrap();
463        let timeout = |native: &str| v["hooks"][native][0]["hooks"][0]["timeout"].as_u64();
464        let got: Vec<Option<u64>> = ["UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop", "SessionEnd"].iter().map(|n| timeout(n)).collect();
465        assert_eq!(got, [Some(30), Some(10), Some(30), Some(90), Some(3)]);
466        assert_eq!(v["hooks"]["Stop"][0]["hooks"][0]["command"], "/b/borg hook codex stop");
467        assert_eq!(Codex.caps().stop_budget, Some(Duration::from_secs(90)), "the stop's budget is its timeout");
468        let none = |_: &str| None;
469        assert_eq!(Codex.target(Path::new("/h"), &none), Path::new("/h/.codex/hooks.json"));
470        let set = |k: &str| (k == "CODEX_HOME").then(|| PathBuf::from("/c"));
471        assert_eq!(Codex.target(Path::new("/h"), &set), Path::new("/c/hooks.json"));
472    }
473
474    #[test]
475    fn doctor_reports_borgs_untrusted_hooks_from_codexs_own_list() {
476        let k = Hooks::of(&Codex).unwrap();
477        let found = trust_findings(&fixture("hooks-list.json"), &k);
478        let warn: Vec<&String> = found.iter().filter(|(l, _)| *l == Level::Warn).map(|(_, m)| m).collect();
479        assert_eq!(warn.len(), 1, "{found:?}");
480        assert!(warn[0].contains("prompt untrusted") && warn[0].contains("stop changed since trusted") && warn[0].contains("/hooks"), "{}", warn[0]);
481        assert!(!warn[0].contains("post_tool"), "a trusted hook is fine: {}", warn[0]);
482        assert!(!warn[0].contains("notify"), "someone else's hook is not borg's: {}", warn[0]);
483        let mut all = fixture("hooks-list.json");
484        for h in all["result"]["data"][0]["hooks"].as_array_mut().unwrap() {
485            h["trustStatus"] = json!("trusted");
486        }
487        assert!(matches!(trust_findings(&all, &k).as_slice(), [(Level::Ok, m)] if m.contains("trusts borg's")), "{:?}", trust_findings(&all, &k));
488        assert_eq!(trust_findings(&json!({"result": {"data": [{"hooks": []}]}}), &k)[0].0, Level::Warn, "wired, but codex lists none");
489    }
490}