1use 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 Some(&[
27 "SessionStart",
28 "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
138fn 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 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
240fn 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 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#[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 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}