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, McpServerEntry, TargetAdapter, hook_command};
15
16#[derive(Debug)]
17pub struct CodexAdapter;
18
19impl TargetAdapter for CodexAdapter {
20    fn name(&self) -> &str {
21        ".codex"
22    }
23
24    fn skill_variant_key(&self) -> Option<&str> {
25        Some("codex")
26    }
27
28    fn default_dest_path(&self, kind: ItemKind, name: &str) -> Option<DestPath> {
29        match kind {
30            ItemKind::Skill => Some(DestPath::from(format!("skills/{name}").as_str())),
31            _ => None,
32        }
33    }
34
35    fn write_config_entries(
36        &self,
37        entries: &[ConfigEntry],
38        target_dir: &Path,
39    ) -> Result<Vec<PathBuf>, MarsError> {
40        let mut written = Vec::new();
41
42        let mcp_servers: Vec<&McpServerEntry> = entries
43            .iter()
44            .filter_map(|e| {
45                if let ConfigEntry::McpServer(s) = e {
46                    Some(s)
47                } else {
48                    None
49                }
50            })
51            .collect();
52
53        let hooks: Vec<&HookEntry> = entries
54            .iter()
55            .filter_map(|e| {
56                if let ConfigEntry::Hook(h) = e {
57                    Some(h)
58                } else {
59                    None
60                }
61            })
62            .collect();
63
64        if !mcp_servers.is_empty() {
65            let path = write_codex_mcp_json(target_dir, &mcp_servers)?;
66            written.push(path);
67        }
68
69        if !hooks.is_empty() {
70            let path = write_codex_hooks_json(target_dir, &hooks)?;
71            written.push(path);
72        }
73
74        Ok(written)
75    }
76
77    fn remove_config_entries(
78        &self,
79        entry_keys: &[String],
80        target_dir: &Path,
81    ) -> Result<(), MarsError> {
82        remove_codex_mcp_entries(entry_keys, target_dir)?;
83        remove_codex_hook_entries(entry_keys, target_dir)?;
84        Ok(())
85    }
86}
87
88// ---------------------------------------------------------------------------
89// Codex MCP — `codex_mcp.json` format
90// ---------------------------------------------------------------------------
91//
92// Codex uses plain environment variable names (no interpolation syntax).
93// Format:
94// {
95//   "mcpServers": {
96//     "server-name": {
97//       "command": "...",
98//       "args": [...],
99//       "env": ["ENV_VAR_NAME", ...]   ← list of var names, not map
100//     }
101//   }
102// }
103
104fn write_codex_mcp_json(
105    target_dir: &Path,
106    servers: &[&McpServerEntry],
107) -> Result<PathBuf, MarsError> {
108    let path = target_dir.join("codex_mcp.json");
109
110    let mut root: serde_json::Value = if path.is_file() {
111        let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
112        serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}))
113    } else {
114        serde_json::json!({})
115    };
116
117    let mcp_obj = root
118        .as_object_mut()
119        .ok_or_else(|| {
120            MarsError::Config(crate::error::ConfigError::Invalid {
121                message: format!("{} is not a JSON object", path.display()),
122            })
123        })?
124        .entry("mcpServers")
125        .or_insert_with(|| serde_json::json!({}));
126
127    let mcp_map = mcp_obj.as_object_mut().ok_or_else(|| {
128        MarsError::Config(crate::error::ConfigError::Invalid {
129            message: format!("{}: mcpServers is not an object", path.display()),
130        })
131    })?;
132
133    for server in servers {
134        let mut entry = serde_json::json!({
135            "command": server.command,
136            "args": server.args,
137        });
138
139        // Codex env: list of variable names (not a map with values).
140        if !server.env.is_empty() {
141            let env_list: Vec<serde_json::Value> = server
142                .env
143                .values()
144                .map(|v| serde_json::Value::String(v.clone()))
145                .collect();
146            entry["env"] = serde_json::Value::Array(env_list);
147        }
148
149        mcp_map.insert(server.name.clone(), entry);
150    }
151
152    let content = serde_json::to_string_pretty(&root).map_err(|e| {
153        MarsError::Config(crate::error::ConfigError::Invalid {
154            message: format!("failed to serialize {}: {e}", path.display()),
155        })
156    })?;
157    crate::fs::atomic_write(&path, content.as_bytes())?;
158
159    Ok(path)
160}
161
162fn remove_codex_mcp_entries(entry_keys: &[String], target_dir: &Path) -> Result<(), MarsError> {
163    let path = target_dir.join("codex_mcp.json");
164    if !path.is_file() {
165        return Ok(());
166    }
167
168    let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
169    let mut root: serde_json::Value =
170        serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}));
171
172    if let Some(mcp_map) = root
173        .as_object_mut()
174        .and_then(|o| o.get_mut("mcpServers"))
175        .and_then(|v| v.as_object_mut())
176    {
177        for key in entry_keys {
178            if let Some(name) = key.strip_prefix("mcp:") {
179                mcp_map.remove(name);
180            }
181        }
182    }
183
184    let content = serde_json::to_string_pretty(&root).map_err(|e| {
185        MarsError::Config(crate::error::ConfigError::Invalid {
186            message: format!("failed to serialize {}: {e}", path.display()),
187        })
188    })?;
189    crate::fs::atomic_write(&path, content.as_bytes())?;
190    Ok(())
191}
192
193// ---------------------------------------------------------------------------
194// Codex hooks — `hooks.json` format
195// ---------------------------------------------------------------------------
196//
197// Codex command hook entries.
198// {
199//   "hooks": {
200//     "PreToolUse": [
201//       {
202//         "matcher": "Bash",
203//         "hooks": [
204//           { "type": "command", "command": "bash /path/to/script.sh" }
205//         ]
206//       }
207//     ]
208//   }
209// }
210
211fn write_codex_hooks_json(target_dir: &Path, hooks: &[&HookEntry]) -> Result<PathBuf, MarsError> {
212    let path = target_dir.join("hooks.json");
213
214    let mut root: serde_json::Value = if path.is_file() {
215        let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
216        serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}))
217    } else {
218        serde_json::json!({})
219    };
220
221    let hooks_section = root
222        .as_object_mut()
223        .ok_or_else(|| {
224            MarsError::Config(crate::error::ConfigError::Invalid {
225                message: format!("{} is not a JSON object", path.display()),
226            })
227        })?
228        .entry("hooks")
229        .or_insert_with(|| serde_json::json!({}));
230
231    let hooks_map = hooks_section.as_object_mut().ok_or_else(|| {
232        MarsError::Config(crate::error::ConfigError::Invalid {
233            message: format!("{}: hooks is not an object", path.display()),
234        })
235    })?;
236
237    for hook in hooks {
238        let native_event = hook.native_event.clone();
239        let command_entry = serde_json::json!({
240            "type": "command",
241            "command": hook_command(&hook.script_path),
242        });
243        let hook_binding = serde_json::json!({
244            "matcher": codex_hook_matcher(&native_event),
245            "hooks": [command_entry],
246        });
247        let event_hooks = hooks_map
248            .entry(native_event.clone())
249            .or_insert_with(|| serde_json::json!([]))
250            .as_array_mut()
251            .ok_or_else(|| {
252                MarsError::Config(crate::error::ConfigError::Invalid {
253                    message: format!("{}: hooks.{native_event} is not an array", path.display()),
254                })
255            })?;
256        remove_managed_hook_bindings(event_hooks, &hook.name);
257        event_hooks.push(hook_binding);
258    }
259
260    let content = serde_json::to_string_pretty(&root).map_err(|e| {
261        MarsError::Config(crate::error::ConfigError::Invalid {
262            message: format!("failed to serialize {}: {e}", path.display()),
263        })
264    })?;
265    crate::fs::atomic_write(&path, content.as_bytes())?;
266
267    remove_stale_codex_hooks_json(target_dir, hooks)?;
268
269    Ok(path)
270}
271
272fn codex_hook_matcher(native_event: &str) -> &'static str {
273    match native_event {
274        "PreToolUse" | "PostToolUse" | "PermissionRequest" => "Bash",
275        _ => "",
276    }
277}
278
279fn remove_managed_hook_bindings(bindings: &mut Vec<serde_json::Value>, hook_name: &str) {
280    bindings.retain_mut(|binding| {
281        let Some(hooks) = binding.get_mut("hooks").and_then(|v| v.as_array_mut()) else {
282            return true;
283        };
284        hooks.retain(|hook| {
285            hook.get("command")
286                .and_then(|v| v.as_str())
287                .map(|command| !is_managed_hook_command_for(command, hook_name))
288                .unwrap_or(true)
289        });
290        !hooks.is_empty()
291    });
292}
293
294fn is_managed_hook_command_for(command: &str, hook_name: &str) -> bool {
295    let normalized = command.replace('\\', "/").replace("//", "/");
296    normalized.contains(&format!("/hooks/{hook_name}/"))
297}
298
299fn remove_codex_hook_entries(entry_keys: &[String], target_dir: &Path) -> Result<(), MarsError> {
300    let hook_keys: Vec<(String, &str)> = entry_keys
301        .iter()
302        .filter_map(|k| {
303            let rest = k.strip_prefix("hook:")?;
304            let (event, name) = rest.split_once(':')?;
305            Some((codex_hook_event(event)?.to_string(), name))
306        })
307        .collect();
308
309    if hook_keys.is_empty() {
310        return Ok(());
311    }
312
313    remove_codex_hook_entries_from_hooks_json(&hook_keys, target_dir)?;
314    remove_codex_hook_entries_from_legacy_codex_hooks_json(&hook_keys, target_dir)?;
315    Ok(())
316}
317
318fn remove_codex_hook_entries_from_hooks_json(
319    hook_keys: &[(String, &str)],
320    target_dir: &Path,
321) -> Result<(), MarsError> {
322    let path = target_dir.join("hooks.json");
323    if !path.is_file() {
324        return Ok(());
325    }
326
327    let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
328    let mut root: serde_json::Value =
329        serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}));
330
331    if let Some(hooks_map) = root
332        .as_object_mut()
333        .and_then(|o| o.get_mut("hooks"))
334        .and_then(|v| v.as_object_mut())
335    {
336        for (event, name) in hook_keys {
337            if let Some(arr) = hooks_map.get_mut(event).and_then(|v| v.as_array_mut()) {
338                remove_managed_hook_bindings(arr, name);
339            }
340        }
341    }
342
343    let content = serde_json::to_string_pretty(&root).map_err(|e| {
344        MarsError::Config(crate::error::ConfigError::Invalid {
345            message: format!("failed to serialize {}: {e}", path.display()),
346        })
347    })?;
348    crate::fs::atomic_write(&path, content.as_bytes())?;
349    Ok(())
350}
351
352fn remove_codex_hook_entries_from_legacy_codex_hooks_json(
353    hook_keys: &[(String, &str)],
354    target_dir: &Path,
355) -> Result<(), MarsError> {
356    let path = target_dir.join("codex_hooks.json");
357    if !path.is_file() {
358        return Ok(());
359    }
360
361    let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
362    let mut root: serde_json::Value =
363        serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}));
364
365    if let Some(hooks_map) = root
366        .as_object_mut()
367        .and_then(|o| o.get_mut("hooks"))
368        .and_then(|v| v.as_object_mut())
369    {
370        for (event, name) in hook_keys {
371            let legacy_event = legacy_codex_hook_event(event);
372            if let Some(arr) = hooks_map
373                .get_mut(legacy_event)
374                .and_then(|v| v.as_array_mut())
375            {
376                arr.retain(|cmd| {
377                    let cmd_str = cmd.as_str().unwrap_or("");
378                    !is_managed_hook_command_for(cmd_str, name)
379                });
380            }
381        }
382    }
383
384    let content = serde_json::to_string_pretty(&root).map_err(|e| {
385        MarsError::Config(crate::error::ConfigError::Invalid {
386            message: format!("failed to serialize {}: {e}", path.display()),
387        })
388    })?;
389    crate::fs::atomic_write(&path, content.as_bytes())?;
390    Ok(())
391}
392
393fn remove_stale_codex_hooks_json(target_dir: &Path, hooks: &[&HookEntry]) -> Result<(), MarsError> {
394    let hook_keys: Vec<(String, &str)> = hooks
395        .iter()
396        .map(|hook| (hook.native_event.clone(), hook.name.as_str()))
397        .collect();
398    remove_codex_hook_entries_from_legacy_codex_hooks_json(&hook_keys, target_dir)
399}
400
401fn legacy_codex_hook_event(event: &str) -> &str {
402    match event {
403        "SessionStart" => "start",
404        "Stop" => "stop",
405        "PreToolUse" => "pre-exec",
406        "PostToolUse" => "post-exec",
407        other => other,
408    }
409}
410
411fn codex_hook_event(event: &str) -> Option<&'static str> {
412    match event {
413        "session.start" => Some("SessionStart"),
414        "session.end" => Some("Stop"),
415        "tool.pre" => Some("PreToolUse"),
416        "tool.post" => Some("PostToolUse"),
417        _ => None,
418    }
419}
420
421// ---------------------------------------------------------------------------
422// Tests
423// ---------------------------------------------------------------------------
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428    use indexmap::IndexMap;
429    use tempfile::TempDir;
430
431    fn make_mcp_entry(name: &str) -> ConfigEntry {
432        ConfigEntry::McpServer(McpServerEntry {
433            name: name.to_string(),
434            command: "npx".to_string(),
435            args: vec!["-y".to_string(), "some-mcp@latest".to_string()],
436            env: IndexMap::new(),
437        })
438    }
439
440    fn make_mcp_entry_with_env(name: &str) -> ConfigEntry {
441        let mut env = IndexMap::new();
442        env.insert("API_KEY".to_string(), "MY_SECRET".to_string());
443        ConfigEntry::McpServer(McpServerEntry {
444            name: name.to_string(),
445            command: "npx".to_string(),
446            args: vec![],
447            env,
448        })
449    }
450
451    fn make_hook_entry(name: &str, native: &str) -> ConfigEntry {
452        ConfigEntry::Hook(HookEntry {
453            name: name.to_string(),
454            event: "tool.pre".to_string(),
455            native_event: native.to_string(),
456            script_path: format!("/hooks/{name}/run.sh"),
457            order: 0,
458        })
459    }
460
461    fn make_hook_entry_with_path(name: &str, native: &str, script_path: &str) -> ConfigEntry {
462        ConfigEntry::Hook(HookEntry {
463            name: name.to_string(),
464            event: "tool.pre".to_string(),
465            native_event: native.to_string(),
466            script_path: script_path.to_string(),
467            order: 0,
468        })
469    }
470
471    #[test]
472    fn write_mcp_creates_codex_mcp_json() {
473        let tmp = TempDir::new().unwrap();
474        let adapter = CodexAdapter;
475        let entries = vec![make_mcp_entry("context7")];
476        let written = adapter.write_config_entries(&entries, tmp.path()).unwrap();
477        assert_eq!(written.len(), 1);
478        assert!(tmp.path().join("codex_mcp.json").exists());
479
480        let raw = std::fs::read_to_string(tmp.path().join("codex_mcp.json")).unwrap();
481        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
482        assert!(json["mcpServers"]["context7"].is_object());
483    }
484
485    #[test]
486    fn write_mcp_env_as_list_of_var_names() {
487        let tmp = TempDir::new().unwrap();
488        let adapter = CodexAdapter;
489        let entries = vec![make_mcp_entry_with_env("server")];
490        adapter.write_config_entries(&entries, tmp.path()).unwrap();
491
492        let raw = std::fs::read_to_string(tmp.path().join("codex_mcp.json")).unwrap();
493        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
494        // Codex: env is a list of variable names, not a map with values.
495        assert!(json["mcpServers"]["server"]["env"].is_array());
496        let env_arr = json["mcpServers"]["server"]["env"].as_array().unwrap();
497        assert!(env_arr.iter().any(|v| v.as_str() == Some("MY_SECRET")));
498    }
499
500    #[test]
501    fn write_hooks_creates_hooks_json() {
502        let tmp = TempDir::new().unwrap();
503        let adapter = CodexAdapter;
504        let entries = vec![make_hook_entry("audit", "PreToolUse")];
505        adapter.write_config_entries(&entries, tmp.path()).unwrap();
506
507        let raw = std::fs::read_to_string(tmp.path().join("hooks.json")).unwrap();
508        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
509        let hooks = json["hooks"]["PreToolUse"].as_array().unwrap();
510        assert_eq!(hooks[0]["matcher"], "Bash");
511        assert_eq!(hooks[0]["hooks"][0]["type"], "command");
512        assert!(
513            hooks[0]["hooks"][0]["command"]
514                .as_str()
515                .unwrap()
516                .contains("/hooks/audit/")
517        );
518    }
519
520    #[test]
521    fn write_hooks_replaces_existing_managed_hook_with_same_event_and_name() {
522        let tmp = TempDir::new().unwrap();
523        let adapter = CodexAdapter;
524        adapter
525            .write_config_entries(
526                &[make_hook_entry_with_path(
527                    "audit",
528                    "PreToolUse",
529                    "/old/hooks/audit/run.sh",
530                )],
531                tmp.path(),
532            )
533            .unwrap();
534        adapter
535            .write_config_entries(
536                &[make_hook_entry_with_path(
537                    "audit",
538                    "PreToolUse",
539                    "/new/hooks/audit/run.sh",
540                )],
541                tmp.path(),
542            )
543            .unwrap();
544
545        let raw = std::fs::read_to_string(tmp.path().join("hooks.json")).unwrap();
546        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
547        let hooks = json["hooks"]["PreToolUse"].as_array().unwrap();
548        assert_eq!(hooks.len(), 1);
549        assert!(
550            hooks[0]["hooks"][0]["command"]
551                .as_str()
552                .unwrap()
553                .contains("/new/hooks/audit/")
554        );
555    }
556
557    #[test]
558    fn remove_mcp_entries_removes_by_name() {
559        let tmp = TempDir::new().unwrap();
560        let adapter = CodexAdapter;
561        let entries = vec![make_mcp_entry("to-remove"), make_mcp_entry("to-keep")];
562        adapter.write_config_entries(&entries, tmp.path()).unwrap();
563
564        adapter
565            .remove_config_entries(&["mcp:to-remove".to_string()], tmp.path())
566            .unwrap();
567
568        let raw = std::fs::read_to_string(tmp.path().join("codex_mcp.json")).unwrap();
569        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
570        assert!(json["mcpServers"]["to-remove"].is_null());
571        assert!(json["mcpServers"]["to-keep"].is_object());
572    }
573
574    #[test]
575    fn remove_hook_entries_matches_backslash_commands() {
576        let tmp = TempDir::new().unwrap();
577        let existing = serde_json::json!({
578            "hooks": {
579                "PreToolUse": [
580                    {
581                        "matcher": "Bash",
582                        "hooks": [
583                            { "type": "command", "command": "bash \"C:\\\\pkg\\\\hooks\\\\audit\\\\run.sh\"" }
584                        ]
585                    },
586                    {
587                        "matcher": "Bash",
588                        "hooks": [
589                            { "type": "command", "command": "bash \"C:\\\\pkg\\\\hooks\\\\audit-extended\\\\run.sh\"" }
590                        ]
591                    }
592                ]
593            }
594        });
595        std::fs::write(
596            tmp.path().join("hooks.json"),
597            serde_json::to_string_pretty(&existing).unwrap(),
598        )
599        .unwrap();
600
601        remove_codex_hook_entries(&["hook:tool.pre:audit".to_string()], tmp.path()).unwrap();
602
603        let raw = std::fs::read_to_string(tmp.path().join("hooks.json")).unwrap();
604        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
605        let hooks = json["hooks"]["PreToolUse"].as_array().unwrap();
606        assert_eq!(hooks.len(), 1);
607        assert!(
608            hooks[0]["hooks"][0]["command"]
609                .as_str()
610                .unwrap()
611                .contains("audit-extended")
612        );
613    }
614
615    #[test]
616    fn remove_hook_entries_preserves_unmanaged_handler_in_same_binding() {
617        let tmp = TempDir::new().unwrap();
618        let existing = serde_json::json!({
619            "hooks": {
620                "PreToolUse": [
621                    {
622                        "matcher": "Bash",
623                        "hooks": [
624                            { "type": "command", "command": "bash \"/pkg/hooks/audit/run.sh\"" },
625                            { "type": "command", "command": "bash \"/user/hooks/custom.sh\"" }
626                        ]
627                    }
628                ]
629            }
630        });
631        std::fs::write(
632            tmp.path().join("hooks.json"),
633            serde_json::to_string_pretty(&existing).unwrap(),
634        )
635        .unwrap();
636
637        remove_codex_hook_entries(&["hook:tool.pre:audit".to_string()], tmp.path()).unwrap();
638
639        let raw = std::fs::read_to_string(tmp.path().join("hooks.json")).unwrap();
640        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
641        let bindings = json["hooks"]["PreToolUse"].as_array().unwrap();
642        assert_eq!(bindings.len(), 1);
643        let hooks = bindings[0]["hooks"].as_array().unwrap();
644        assert_eq!(hooks.len(), 1);
645        assert!(
646            hooks[0]["command"]
647                .as_str()
648                .unwrap()
649                .contains("/user/hooks/custom.sh")
650        );
651    }
652
653    #[test]
654    fn remove_hook_entries_scopes_by_universal_event() {
655        let tmp = TempDir::new().unwrap();
656        let existing = serde_json::json!({
657            "hooks": {
658                "PreToolUse": [
659                    {
660                        "matcher": "Bash",
661                        "hooks": [
662                            { "type": "command", "command": "bash \"/pkg/hooks/audit/run.sh\"" }
663                        ]
664                    }
665                ],
666                "PostToolUse": [
667                    {
668                        "matcher": "Bash",
669                        "hooks": [
670                            { "type": "command", "command": "bash \"/pkg/hooks/audit/run.sh\"" }
671                        ]
672                    }
673                ]
674            }
675        });
676        std::fs::write(
677            tmp.path().join("hooks.json"),
678            serde_json::to_string_pretty(&existing).unwrap(),
679        )
680        .unwrap();
681
682        remove_codex_hook_entries(&["hook:tool.pre:audit".to_string()], tmp.path()).unwrap();
683
684        let raw = std::fs::read_to_string(tmp.path().join("hooks.json")).unwrap();
685        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
686        assert!(json["hooks"]["PreToolUse"].as_array().unwrap().is_empty());
687        assert_eq!(json["hooks"]["PostToolUse"].as_array().unwrap().len(), 1);
688    }
689}