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, 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)];
40
41pub const STOP_TIMEOUT_S: u64 = 90;
44
45const 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 prompt_context: true,
60 tool_context: true,
61 early_delivery: false,
62 question_dialog: false,
63 transcript: Transcript::None,
65 deny_capture: false,
66 sends_pid: false,
67 stop_budget: Some(Duration::from_secs(STOP_TIMEOUT_S)),
68 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 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 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 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
135pub 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
160pub 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 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
202fn 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
214fn 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
232fn 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
247fn 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
258fn 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
264fn 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
299fn 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 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
337fn 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
349pub 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}