Skip to main content

mars_agents/target/
codex.rs

1/// `.codex` target adapter.
2///
3/// Handles MCP server registration and hook binding for the Codex harness.
4///
5/// Codex-native lowering:
6/// - MCP: writes to `codex_mcp.json` (mcpServers section), env vars as plain names
7/// - Hooks: writes to `hooks.json` with Codex command hook entries
8use std::path::{Path, PathBuf};
9
10use crate::error::MarsError;
11use crate::lock::ItemKind;
12use crate::types::DestPath;
13
14use super::{ConfigEntry, HookEntry, HookFragmentMode, McpServerEntry, TargetAdapter};
15
16#[derive(Debug)]
17pub struct CodexAdapter;
18
19impl TargetAdapter for CodexAdapter {
20    fn name(&self) -> &str {
21        ".codex"
22    }
23
24    fn known_hook_events(&self) -> Option<&'static [&'static str]> {
25        // https://developers.openai.com/codex/hooks — verified 2026-07-24.
26        Some(&[
27            "SessionStart",
28            // SessionEnd is documented at developers.openai.com/codex/hooks but was
29            // runtime-verified non-firing in codex-cli 0.144.4 (2026-07-24). Re-add
30            // once verified functional; authors can use `unchecked = true` meanwhile.
31            "UserPromptSubmit",
32            "PreToolUse",
33            "PermissionRequest",
34            "PostToolUse",
35            "PreCompact",
36            "PostCompact",
37            "SubagentStart",
38            "SubagentStop",
39            "Stop",
40        ])
41    }
42
43    fn hook_fragment_mode(&self) -> Option<HookFragmentMode> {
44        Some(HookFragmentMode::MergeJson)
45    }
46
47    fn skill_variant_key(&self) -> Option<&str> {
48        Some("codex")
49    }
50
51    fn default_dest_path(&self, kind: ItemKind, name: &str) -> Option<DestPath> {
52        match kind {
53            ItemKind::Skill => Some(DestPath::from(format!("skills/{name}").as_str())),
54            _ => None,
55        }
56    }
57
58    fn write_config_entries(
59        &self,
60        write: crate::surface_ownership::retention::ConfigWrite<'_>,
61        project_root: &Path,
62    ) -> Result<Vec<PathBuf>, MarsError> {
63        let (target_dir, entries) = write.into_parts(project_root);
64        let mut written = Vec::new();
65
66        let mcp_servers: Vec<&McpServerEntry> = entries
67            .iter()
68            .filter_map(|e| {
69                if let ConfigEntry::McpServer(s) = e {
70                    Some(s)
71                } else {
72                    None
73                }
74            })
75            .collect();
76
77        let hooks: Vec<&HookEntry> = entries
78            .iter()
79            .filter_map(|e| {
80                if let ConfigEntry::Hook(h) = e {
81                    Some(h)
82                } else {
83                    None
84                }
85            })
86            .collect();
87
88        if !mcp_servers.is_empty() {
89            let path = (write_codex_mcp_json)(&target_dir, &mcp_servers)?;
90            written.push(path);
91        }
92
93        if !hooks.is_empty() {
94            let path = (write_hooks_json)(&target_dir, &hooks)?;
95            written.push(path);
96        }
97
98        Ok(written)
99    }
100
101    fn mcp_config_file_names(&self) -> &'static [&'static str] {
102        &["codex_mcp.json"]
103    }
104    fn hook_config_file_names(&self) -> &'static [&'static str] {
105        &["hooks.json"]
106    }
107
108    fn legacy_hook_config_file_names(&self) -> &'static [&'static str] {
109        &["codex_hooks.json"]
110    }
111
112    fn remove_owned_hook_entries(
113        &self,
114        operation: crate::surface_ownership::retention::RemovalOperation<'_>,
115        project_root: &Path,
116        diag: &mut crate::diagnostic::DiagnosticCollector,
117    ) -> crate::surface_ownership::retention::RemovalReport {
118        let (target_dir, removal) = operation.into_parts(project_root);
119        remove_owned_codex_hooks(&removal.prior_records, &target_dir, diag)
120    }
121
122    fn remove_config_entries(
123        &self,
124        operation: crate::surface_ownership::retention::RemovalOperation<'_>,
125        project_root: &Path,
126    ) -> crate::surface_ownership::retention::RemovalReport {
127        let (target_dir, removal) = operation.into_parts(project_root);
128        match remove_codex_mcp_entries(&removal.keys_to_remove, &target_dir) {
129            Ok(()) => crate::surface_ownership::retention::RemovalReport::confirmed(),
130            Err(error) => crate::surface_ownership::retention::RemovalReport::failed(
131                error,
132                removal.prior_records.clone(),
133            ),
134        }
135    }
136}
137
138// ---------------------------------------------------------------------------
139// Codex MCP — `codex_mcp.json` format
140// ---------------------------------------------------------------------------
141//
142// Codex uses plain environment variable names (no interpolation syntax).
143// Format:
144// {
145//   "mcpServers": {
146//     "server-name": {
147//       "command": "...",
148//       "args": [...],
149//       "env": ["ENV_VAR_NAME", ...]   ← list of var names, not map
150//     }
151//   }
152// }
153
154fn write_codex_mcp_json(
155    target_dir: &Path,
156    servers: &[&McpServerEntry],
157) -> Result<PathBuf, MarsError> {
158    let path = target_dir.join("codex_mcp.json");
159
160    let mut root: serde_json::Value = if path.is_file() {
161        super::parse_json_file(&path)?
162    } else {
163        serde_json::json!({})
164    };
165
166    let mcp_obj = root
167        .as_object_mut()
168        .ok_or_else(|| {
169            MarsError::Config(crate::error::ConfigError::Invalid {
170                message: format!("{} is not a JSON object", path.display()),
171            })
172        })?
173        .entry("mcpServers")
174        .or_insert_with(|| serde_json::json!({}));
175
176    let mcp_map = mcp_obj.as_object_mut().ok_or_else(|| {
177        MarsError::Config(crate::error::ConfigError::Invalid {
178            message: format!("{}: mcpServers is not an object", path.display()),
179        })
180    })?;
181
182    for server in servers {
183        let mut entry = serde_json::json!({
184            "command": server.command,
185            "args": server.args,
186        });
187
188        // Codex env: list of variable names (not a map with values).
189        if !server.env.is_empty() {
190            let env_list: Vec<serde_json::Value> = server
191                .env
192                .values()
193                .map(|v| serde_json::Value::String(v.clone()))
194                .collect();
195            entry["env"] = serde_json::Value::Array(env_list);
196        }
197
198        mcp_map.insert(server.name.clone(), entry);
199    }
200
201    let content = serde_json::to_string_pretty(&root).map_err(|e| {
202        MarsError::Config(crate::error::ConfigError::Invalid {
203            message: format!("failed to serialize {}: {e}", path.display()),
204        })
205    })?;
206    crate::fs::atomic_write(&path, content.as_bytes())?;
207
208    Ok(path)
209}
210
211fn remove_codex_mcp_entries(entry_keys: &[String], target_dir: &Path) -> Result<(), MarsError> {
212    let path = target_dir.join("codex_mcp.json");
213    if !path.is_file() {
214        return Ok(());
215    }
216
217    let mut root = super::parse_json_file(&path)?;
218
219    if let Some(mcp_map) = root
220        .as_object_mut()
221        .and_then(|o| o.get_mut("mcpServers"))
222        .and_then(|v| v.as_object_mut())
223    {
224        for key in entry_keys {
225            if let Some(name) = key.strip_prefix("mcp:") {
226                mcp_map.remove(name);
227            }
228        }
229    }
230
231    let content = serde_json::to_string_pretty(&root).map_err(|e| {
232        MarsError::Config(crate::error::ConfigError::Invalid {
233            message: format!("failed to serialize {}: {e}", path.display()),
234        })
235    })?;
236    crate::fs::atomic_write(&path, content.as_bytes())?;
237    Ok(())
238}
239
240// ---------------------------------------------------------------------------
241// Codex hooks — `hooks.json` format
242// ---------------------------------------------------------------------------
243//
244// Codex command hook entries.
245// {
246//   "hooks": {
247//     "PreToolUse": [
248//       {
249//         "matcher": "Bash",
250//         "hooks": [
251//           { "type": "command", "command": "bash /path/to/script.sh" }
252//         ]
253//       }
254//     ]
255//   }
256// }
257
258fn write_hooks_json(target_dir: &Path, hooks: &[&HookEntry]) -> Result<PathBuf, MarsError> {
259    let path = target_dir.join("hooks.json");
260
261    let mut root: serde_json::Value = if path.is_file() {
262        super::parse_json_file(&path)?
263    } else {
264        serde_json::json!({})
265    };
266
267    let hooks_section = root
268        .as_object_mut()
269        .ok_or_else(|| {
270            MarsError::Config(crate::error::ConfigError::Invalid {
271                message: format!("{} is not a JSON object", path.display()),
272            })
273        })?
274        .entry("hooks")
275        .or_insert_with(|| serde_json::json!({}));
276
277    let hooks_map = hooks_section.as_object_mut().ok_or_else(|| {
278        MarsError::Config(crate::error::ConfigError::Invalid {
279            message: format!("{}: hooks is not an object", path.display()),
280        })
281    })?;
282
283    for hook in hooks {
284        super::append_json_event_entries(hooks_map, &hook.native_event, &hook.entries, &path)?;
285    }
286
287    let content = serde_json::to_string_pretty(&root).map_err(|e| {
288        MarsError::Config(crate::error::ConfigError::Invalid {
289            message: format!("failed to serialize {}: {e}", path.display()),
290        })
291    })?;
292    crate::fs::atomic_write(&path, content.as_bytes())?;
293
294    Ok(path)
295}
296
297fn remove_managed_hook_entries(bindings: &mut Vec<serde_json::Value>, hook_name: &str) -> bool {
298    let mut removed = false;
299    bindings.retain_mut(|binding| {
300        if let Some(command) = binding.as_str() {
301            let is_managed = is_managed_hook_command_for(command, hook_name);
302            removed |= is_managed;
303            return !is_managed;
304        }
305
306        let Some(hooks) = binding.get_mut("hooks").and_then(|v| v.as_array_mut()) else {
307            return true;
308        };
309        let mut removed_from_binding = false;
310        hooks.retain(|hook| {
311            let is_managed = hook
312                .get("command")
313                .and_then(|v| v.as_str())
314                .map(|command| is_managed_hook_command_for(command, hook_name))
315                .unwrap_or(false);
316            removed_from_binding |= is_managed;
317            !is_managed
318        });
319        removed |= removed_from_binding;
320        !removed_from_binding || !hooks.is_empty()
321    });
322    removed
323}
324
325fn is_managed_hook_command_for(command: &str, hook_name: &str) -> bool {
326    let normalized = command.replace('\\', "/").replace("//", "/");
327    normalized.contains(&format!("/hooks/{hook_name}/"))
328}
329
330fn remove_owned_codex_hooks(
331    records: &std::collections::BTreeMap<String, crate::lock::ConfigEntryRecord>,
332    target_dir: &Path,
333    diag: &mut crate::diagnostic::DiagnosticCollector,
334) -> crate::surface_ownership::retention::RemovalReport {
335    if let Err(error) =
336        remove_owned_codex_hooks_from_file(records, &target_dir.join("hooks.json"), Some(diag))
337    {
338        return crate::surface_ownership::retention::RemovalReport::failed(error, records.clone());
339    }
340    // Existing legacy-Codex sweep remains until #130's next-release cleanup.
341    let legacy_records: std::collections::BTreeMap<_, _> = records
342        .iter()
343        .filter(|(_, record)| record.emitted_json.is_none())
344        .map(|(key, record)| (key.clone(), record.clone()))
345        .collect();
346    if legacy_records.is_empty() {
347        return crate::surface_ownership::retention::RemovalReport::confirmed();
348    }
349    match remove_owned_codex_hooks_from_file(
350        &legacy_records,
351        &target_dir.join("codex_hooks.json"),
352        None,
353    ) {
354        Ok(()) => crate::surface_ownership::retention::RemovalReport::confirmed(),
355        Err(error) => {
356            crate::surface_ownership::retention::RemovalReport::failed(error, legacy_records)
357        }
358    }
359}
360
361fn remove_owned_codex_hooks_from_file(
362    records: &std::collections::BTreeMap<String, crate::lock::ConfigEntryRecord>,
363    path: &Path,
364    mut diag: Option<&mut crate::diagnostic::DiagnosticCollector>,
365) -> Result<(), MarsError> {
366    if !path.is_file() {
367        return Ok(());
368    }
369    let mut root = super::parse_json_file(path)?;
370    let mut changed = false;
371    if let Some(hooks_map) = root
372        .as_object_mut()
373        .and_then(|o| o.get_mut("hooks"))
374        .and_then(|v| v.as_object_mut())
375    {
376        let mut emptied_events = std::collections::BTreeSet::new();
377        for (key, record) in records.iter().filter(|(key, _)| key.starts_with("hook:")) {
378            let Some((event, name)) = key
379                .strip_prefix("hook:")
380                .and_then(|rest| rest.split_once(':'))
381            else {
382                continue;
383            };
384            if let Some(expected) = record
385                .emitted_json
386                .as_deref()
387                .and_then(|json| serde_json::from_str::<Vec<serde_json::Value>>(json).ok())
388            {
389                let update = super::remove_json_event_entries(hooks_map, event, &expected);
390                changed |= update.changed;
391                if update.missing > 0
392                    && let Some(diag) = diag.as_deref_mut()
393                {
394                    diag.warn(
395                        "config-divergence",
396                        format!(
397                            "config-divergence: managed hook `{name}` diverged in target `.codex` at `{}`; preserving edited config and appending the package entry",
398                            path.display()
399                        ),
400                    );
401                }
402            } else {
403                for (event, value) in hooks_map.iter_mut() {
404                    if let Some(bindings) = value.as_array_mut() {
405                        let before = bindings.len();
406                        changed |= remove_managed_hook_entries(bindings, name);
407                        if before > 0 && bindings.is_empty() {
408                            emptied_events.insert(event.clone());
409                        }
410                    }
411                }
412            }
413        }
414        for event in emptied_events {
415            hooks_map.remove(&event);
416        }
417    }
418    if changed
419        && root
420            .get("hooks")
421            .and_then(|v| v.as_object())
422            .is_some_and(serde_json::Map::is_empty)
423    {
424        root.as_object_mut().unwrap().remove("hooks");
425    }
426    if !changed {
427        return Ok(());
428    }
429    crate::fs::atomic_write(
430        path,
431        serde_json::to_string_pretty(&root)
432            .map_err(|e| {
433                MarsError::Config(crate::error::ConfigError::Invalid {
434                    message: format!("failed to serialize {}: {e}", path.display()),
435                })
436            })?
437            .as_bytes(),
438    )
439}
440
441// ---------------------------------------------------------------------------
442// Tests
443// ---------------------------------------------------------------------------
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448    use crate::surface_ownership::retention::{Surface, WritePermit};
449
450    fn write_permit(entries: &[ConfigEntry]) -> WritePermit<'static> {
451        WritePermit::for_test("", entries[0].surface())
452    }
453    use indexmap::IndexMap;
454    use tempfile::TempDir;
455
456    fn make_mcp_entry(name: &str) -> ConfigEntry {
457        ConfigEntry::McpServer(McpServerEntry {
458            name: name.to_string(),
459            command: "npx".to_string(),
460            args: vec!["-y".to_string(), "some-mcp@latest".to_string()],
461            env: IndexMap::new(),
462        })
463    }
464
465    fn make_mcp_entry_with_env(name: &str) -> ConfigEntry {
466        let mut env = IndexMap::new();
467        env.insert("API_KEY".to_string(), "MY_SECRET".to_string());
468        ConfigEntry::McpServer(McpServerEntry {
469            name: name.to_string(),
470            command: "npx".to_string(),
471            args: vec![],
472            env,
473        })
474    }
475
476    fn make_hook_entry(name: &str, native: &str) -> ConfigEntry {
477        ConfigEntry::Hook(HookEntry {
478            name: name.to_string(),
479            native_event: native.to_string(),
480            entries: vec![
481                serde_json::json!({"matcher": "Bash", "hooks": [{"type": "command", "command": format!("bash '/hooks/{name}/run.sh'")} ]}),
482            ],
483        })
484    }
485
486    fn make_hook_entry_with_path(name: &str, native: &str, script_path: &str) -> ConfigEntry {
487        ConfigEntry::Hook(HookEntry {
488            name: name.to_string(),
489            native_event: native.to_string(),
490            entries: vec![
491                serde_json::json!({"hooks": [{"type": "command", "command": format!("bash '{script_path}'")} ]}),
492            ],
493        })
494    }
495
496    #[test]
497    fn write_mcp_creates_codex_mcp_json() {
498        let tmp = TempDir::new().unwrap();
499        let adapter = CodexAdapter;
500        let entries = vec![make_mcp_entry("context7")];
501        let written = adapter
502            .write_config_entries(
503                write_permit(&entries)
504                    .bind_config_entries(entries.clone())
505                    .unwrap(),
506                tmp.path(),
507            )
508            .unwrap();
509        assert_eq!(written.len(), 1);
510        assert!(tmp.path().join("codex_mcp.json").exists());
511
512        let raw = std::fs::read_to_string(tmp.path().join("codex_mcp.json")).unwrap();
513        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
514        assert!(json["mcpServers"]["context7"].is_object());
515    }
516
517    #[test]
518    fn write_mcp_env_as_list_of_var_names() {
519        let tmp = TempDir::new().unwrap();
520        let adapter = CodexAdapter;
521        let entries = vec![make_mcp_entry_with_env("server")];
522        adapter
523            .write_config_entries(
524                write_permit(&entries)
525                    .bind_config_entries(entries.clone())
526                    .unwrap(),
527                tmp.path(),
528            )
529            .unwrap();
530
531        let raw = std::fs::read_to_string(tmp.path().join("codex_mcp.json")).unwrap();
532        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
533        // Codex: env is a list of variable names, not a map with values.
534        assert!(json["mcpServers"]["server"]["env"].is_array());
535        let env_arr = json["mcpServers"]["server"]["env"].as_array().unwrap();
536        assert!(env_arr.iter().any(|v| v.as_str() == Some("MY_SECRET")));
537    }
538
539    #[test]
540    fn write_hooks_creates_hooks_json() {
541        let tmp = TempDir::new().unwrap();
542        let adapter = CodexAdapter;
543        let entries = vec![make_hook_entry("audit", "PreToolUse")];
544        adapter
545            .write_config_entries(
546                write_permit(&entries)
547                    .bind_config_entries(entries.clone())
548                    .unwrap(),
549                tmp.path(),
550            )
551            .unwrap();
552
553        let raw = std::fs::read_to_string(tmp.path().join("hooks.json")).unwrap();
554        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
555        let hooks = json["hooks"]["PreToolUse"].as_array().unwrap();
556        assert_eq!(hooks[0]["matcher"], "Bash");
557        assert_eq!(hooks[0]["hooks"][0]["type"], "command");
558        assert!(
559            hooks[0]["hooks"][0]["command"]
560                .as_str()
561                .unwrap()
562                .contains("/hooks/audit/")
563        );
564    }
565
566    #[test]
567    fn write_hooks_appends_opaque_entries_in_call_order() {
568        let tmp = TempDir::new().unwrap();
569        let adapter = CodexAdapter;
570        adapter
571            .write_config_entries(
572                WritePermit::for_test("", Surface::Hook)
573                    .bind_config_entries(vec![make_hook_entry_with_path(
574                        "audit",
575                        "PreToolUse",
576                        "/old/hooks/audit/run.sh",
577                    )])
578                    .unwrap(),
579                tmp.path(),
580            )
581            .unwrap();
582        adapter
583            .write_config_entries(
584                WritePermit::for_test("", Surface::Hook)
585                    .bind_config_entries(vec![make_hook_entry_with_path(
586                        "audit",
587                        "PreToolUse",
588                        "/new/hooks/audit/run.sh",
589                    )])
590                    .unwrap(),
591                tmp.path(),
592            )
593            .unwrap();
594
595        let raw = std::fs::read_to_string(tmp.path().join("hooks.json")).unwrap();
596        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
597        let hooks = json["hooks"]["PreToolUse"].as_array().unwrap();
598        assert_eq!(hooks.len(), 2);
599        assert!(
600            hooks[1]["hooks"][0]["command"]
601                .as_str()
602                .unwrap()
603                .contains("/new/hooks/audit/")
604        );
605    }
606
607    #[test]
608    fn remove_mcp_entries_removes_by_name() {
609        let tmp = TempDir::new().unwrap();
610        let adapter = CodexAdapter;
611        let entries = vec![make_mcp_entry("to-remove"), make_mcp_entry("to-keep")];
612        adapter
613            .write_config_entries(
614                write_permit(&entries)
615                    .bind_config_entries(entries.clone())
616                    .unwrap(),
617                tmp.path(),
618            )
619            .unwrap();
620
621        remove_codex_mcp_entries(&["mcp:to-remove".to_string()], tmp.path()).unwrap();
622
623        let raw = std::fs::read_to_string(tmp.path().join("codex_mcp.json")).unwrap();
624        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
625        assert!(json["mcpServers"]["to-remove"].is_null());
626        assert!(json["mcpServers"]["to-keep"].is_object());
627    }
628
629    #[test]
630    fn remove_hook_entries_matches_backslash_commands() {
631        let tmp = TempDir::new().unwrap();
632        let existing = serde_json::json!({
633            "hooks": {
634                "PreToolUse": [
635                    {
636                        "matcher": "Bash",
637                        "hooks": [
638                            { "type": "command", "command": "bash \"C:\\\\pkg\\\\hooks\\\\audit\\\\run.sh\"" }
639                        ]
640                    },
641                    {
642                        "matcher": "Bash",
643                        "hooks": [
644                            { "type": "command", "command": "bash \"C:\\\\pkg\\\\hooks\\\\audit-extended\\\\run.sh\"" }
645                        ]
646                    }
647                ]
648            }
649        });
650        std::fs::write(
651            tmp.path().join("hooks.json"),
652            serde_json::to_string_pretty(&existing).unwrap(),
653        )
654        .unwrap();
655
656        let records = std::collections::BTreeMap::from([(
657            "hook:tool.pre:audit".to_string(),
658            crate::lock::ConfigEntryRecord { emitted_json: None },
659        )]);
660        remove_owned_codex_hooks(
661            &records,
662            tmp.path(),
663            &mut crate::diagnostic::DiagnosticCollector::new(),
664        )
665        .unwrap();
666
667        let raw = std::fs::read_to_string(tmp.path().join("hooks.json")).unwrap();
668        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
669        let hooks = json["hooks"]["PreToolUse"].as_array().unwrap();
670        assert_eq!(hooks.len(), 1);
671        assert!(
672            hooks[0]["hooks"][0]["command"]
673                .as_str()
674                .unwrap()
675                .contains("audit-extended")
676        );
677    }
678
679    #[test]
680    fn divergent_structural_removal_does_not_rewrite_hooks() {
681        let tmp = TempDir::new().unwrap();
682        let path = tmp.path().join("hooks.json");
683        let original =
684            br#"{"hooks":{"SessionStart":[{"hooks":[{"command":"edited"}]}]},"keep":true}"#;
685        std::fs::write(&path, original).unwrap();
686        let before_modified = std::fs::metadata(&path).unwrap().modified().unwrap();
687        std::thread::sleep(std::time::Duration::from_millis(20));
688        let records = std::collections::BTreeMap::from([(
689            "hook:SessionStart:audit".to_string(),
690            crate::lock::ConfigEntryRecord {
691                emitted_json: Some(
692                    serde_json::json!([{"hooks":[{"command":"original"}]}]).to_string(),
693                ),
694            },
695        )]);
696
697        remove_owned_codex_hooks(
698            &records,
699            tmp.path(),
700            &mut crate::diagnostic::DiagnosticCollector::new(),
701        )
702        .unwrap();
703
704        assert_eq!(std::fs::read(&path).unwrap(), original);
705        assert_eq!(
706            std::fs::metadata(&path).unwrap().modified().unwrap(),
707            before_modified
708        );
709    }
710
711    #[test]
712    fn remove_hook_entries_cleans_real_legacy_codex_hooks_json_only_by_managed_path() {
713        let tmp = TempDir::new().unwrap();
714        let legacy = serde_json::json!({
715            "userSetting": "preserved",
716            "hooks": {
717                "pre-exec": [
718                    "bash \"/cache/pkg/hooks/audit/run.sh\"",
719                    "printf user-owned"
720                ],
721                "post-exec": ["bash \"/cache/pkg/hooks/audit/run.sh\""],
722                "user-empty": []
723            }
724        });
725        std::fs::write(
726            tmp.path().join("codex_hooks.json"),
727            serde_json::to_string_pretty(&legacy).unwrap(),
728        )
729        .unwrap();
730
731        let records = std::collections::BTreeMap::from([(
732            "hook:tool.pre:audit".to_string(),
733            crate::lock::ConfigEntryRecord { emitted_json: None },
734        )]);
735        remove_owned_codex_hooks(
736            &records,
737            tmp.path(),
738            &mut crate::diagnostic::DiagnosticCollector::new(),
739        )
740        .unwrap();
741
742        let raw = std::fs::read_to_string(tmp.path().join("codex_hooks.json")).unwrap();
743        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
744        assert_eq!(json["userSetting"], "preserved");
745        assert_eq!(
746            json["hooks"]["pre-exec"],
747            serde_json::json!(["printf user-owned"])
748        );
749        assert!(json["hooks"]["post-exec"].is_null());
750        assert_eq!(json["hooks"]["user-empty"], serde_json::json!([]));
751    }
752}