1use 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
20pub 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
33pub const RETIRED: [(&str, &str); 0] = [];
35
36pub const TIMEOUTS: [(&str, u64); 6] = [("prompt", 30), ("pre_tool", 10), ("post_tool", 30), ("stop", STOP_TIMEOUT_S), ("end", 3), ("halted", 3)];
42
43pub const CONTEXT_CHARS: usize = 10_000;
49
50const 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 prompt_context: true,
65 tool_context: true,
66 early_delivery: false,
67 question_dialog: false,
68 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 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 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 fn sub_runs(&self, home: &Path, session: Option<&str>, since: Option<i64>) -> Vec<Run> {
116 spawn_runs(&sessions(home), session, since)
117 }
118
119 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 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
145pub 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
170pub 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 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
212fn 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
224fn 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
242pub fn sessions(home: &Path) -> PathBuf {
247 super::env_dir("CODEX_HOME").unwrap_or_else(|| home.join(".codex")).join("sessions")
248}
249
250pub 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
257pub(crate) fn main_session(meta: &Value) -> bool {
260 meta["source"].get("subagent").is_none() && meta["source"] != "exec"
261}
262
263pub(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
278pub(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
289pub(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
295pub(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
300fn 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
335fn 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 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
373fn 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
385pub 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}