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