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 `codex_hooks.json` with structural 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 — `codex_hooks.json` format
195// ---------------------------------------------------------------------------
196//
197// Structural hook entries — Codex uses event → command list mapping.
198// {
199//   "hooks": {
200//     "pre-exec": ["bash /path/to/script.sh"],
201//     "post-exec": [...]
202//   }
203// }
204
205fn write_codex_hooks_json(target_dir: &Path, hooks: &[&HookEntry]) -> Result<PathBuf, MarsError> {
206    let path = target_dir.join("codex_hooks.json");
207
208    let mut root: serde_json::Value = if path.is_file() {
209        let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
210        serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}))
211    } else {
212        serde_json::json!({})
213    };
214
215    let hooks_section = root
216        .as_object_mut()
217        .ok_or_else(|| {
218            MarsError::Config(crate::error::ConfigError::Invalid {
219                message: format!("{} is not a JSON object", path.display()),
220            })
221        })?
222        .entry("hooks")
223        .or_insert_with(|| serde_json::json!({}));
224
225    let hooks_map = hooks_section.as_object_mut().ok_or_else(|| {
226        MarsError::Config(crate::error::ConfigError::Invalid {
227            message: format!("{}: hooks is not an object", path.display()),
228        })
229    })?;
230
231    for hook in hooks {
232        let command = hook_command(&hook.script_path);
233        let native_event = hook.native_event.clone();
234        let event_hooks = hooks_map
235            .entry(native_event.clone())
236            .or_insert_with(|| serde_json::json!([]))
237            .as_array_mut()
238            .ok_or_else(|| {
239                MarsError::Config(crate::error::ConfigError::Invalid {
240                    message: format!("{}: hooks.{native_event} is not an array", path.display()),
241                })
242            })?;
243        remove_managed_hook_commands(event_hooks, &hook.name);
244        event_hooks.push(serde_json::Value::String(command));
245    }
246
247    let content = serde_json::to_string_pretty(&root).map_err(|e| {
248        MarsError::Config(crate::error::ConfigError::Invalid {
249            message: format!("failed to serialize {}: {e}", path.display()),
250        })
251    })?;
252    crate::fs::atomic_write(&path, content.as_bytes())?;
253
254    Ok(path)
255}
256
257fn remove_managed_hook_commands(commands: &mut Vec<serde_json::Value>, hook_name: &str) {
258    commands.retain(|cmd| {
259        cmd.as_str()
260            .map(|cmd| !is_managed_hook_command_for(cmd, hook_name))
261            .unwrap_or(true)
262    });
263}
264
265fn is_managed_hook_command_for(command: &str, hook_name: &str) -> bool {
266    let normalized = command.replace('\\', "/").replace("//", "/");
267    normalized.contains(&format!("/hooks/{hook_name}/"))
268}
269
270fn remove_codex_hook_entries(entry_keys: &[String], target_dir: &Path) -> Result<(), MarsError> {
271    let path = target_dir.join("codex_hooks.json");
272    if !path.is_file() {
273        return Ok(());
274    }
275
276    let hook_keys: Vec<(String, &str)> = entry_keys
277        .iter()
278        .filter_map(|k| {
279            let rest = k.strip_prefix("hook:")?;
280            let (event, name) = rest.split_once(':')?;
281            Some((codex_hook_event(event)?.to_string(), name))
282        })
283        .collect();
284
285    if hook_keys.is_empty() {
286        return Ok(());
287    }
288
289    let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
290    let mut root: serde_json::Value =
291        serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}));
292
293    if let Some(hooks_map) = root
294        .as_object_mut()
295        .and_then(|o| o.get_mut("hooks"))
296        .and_then(|v| v.as_object_mut())
297    {
298        for (event, name) in &hook_keys {
299            if let Some(arr) = hooks_map.get_mut(event).and_then(|v| v.as_array_mut()) {
300                arr.retain(|cmd| {
301                    let cmd_str = cmd.as_str().unwrap_or("");
302                    // Exact path-segment match to avoid partial name collisions.
303                    !is_managed_hook_command_for(cmd_str, name)
304                });
305            }
306        }
307    }
308
309    let content = serde_json::to_string_pretty(&root).map_err(|e| {
310        MarsError::Config(crate::error::ConfigError::Invalid {
311            message: format!("failed to serialize {}: {e}", path.display()),
312        })
313    })?;
314    crate::fs::atomic_write(&path, content.as_bytes())?;
315    Ok(())
316}
317
318fn codex_hook_event(event: &str) -> Option<&'static str> {
319    match event {
320        "session.start" => Some("start"),
321        "session.end" => Some("stop"),
322        "tool.pre" => Some("pre-exec"),
323        "tool.post" => Some("post-exec"),
324        _ => None,
325    }
326}
327
328// ---------------------------------------------------------------------------
329// Tests
330// ---------------------------------------------------------------------------
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335    use indexmap::IndexMap;
336    use tempfile::TempDir;
337
338    fn make_mcp_entry(name: &str) -> ConfigEntry {
339        ConfigEntry::McpServer(McpServerEntry {
340            name: name.to_string(),
341            command: "npx".to_string(),
342            args: vec!["-y".to_string(), "some-mcp@latest".to_string()],
343            env: IndexMap::new(),
344        })
345    }
346
347    fn make_mcp_entry_with_env(name: &str) -> ConfigEntry {
348        let mut env = IndexMap::new();
349        env.insert("API_KEY".to_string(), "MY_SECRET".to_string());
350        ConfigEntry::McpServer(McpServerEntry {
351            name: name.to_string(),
352            command: "npx".to_string(),
353            args: vec![],
354            env,
355        })
356    }
357
358    fn make_hook_entry(name: &str, native: &str) -> ConfigEntry {
359        ConfigEntry::Hook(HookEntry {
360            name: name.to_string(),
361            event: "tool.pre".to_string(),
362            native_event: native.to_string(),
363            script_path: format!("/hooks/{name}/run.sh"),
364            order: 0,
365        })
366    }
367
368    fn make_hook_entry_with_path(name: &str, native: &str, script_path: &str) -> ConfigEntry {
369        ConfigEntry::Hook(HookEntry {
370            name: name.to_string(),
371            event: "tool.pre".to_string(),
372            native_event: native.to_string(),
373            script_path: script_path.to_string(),
374            order: 0,
375        })
376    }
377
378    #[test]
379    fn write_mcp_creates_codex_mcp_json() {
380        let tmp = TempDir::new().unwrap();
381        let adapter = CodexAdapter;
382        let entries = vec![make_mcp_entry("context7")];
383        let written = adapter.write_config_entries(&entries, tmp.path()).unwrap();
384        assert_eq!(written.len(), 1);
385        assert!(tmp.path().join("codex_mcp.json").exists());
386
387        let raw = std::fs::read_to_string(tmp.path().join("codex_mcp.json")).unwrap();
388        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
389        assert!(json["mcpServers"]["context7"].is_object());
390    }
391
392    #[test]
393    fn write_mcp_env_as_list_of_var_names() {
394        let tmp = TempDir::new().unwrap();
395        let adapter = CodexAdapter;
396        let entries = vec![make_mcp_entry_with_env("server")];
397        adapter.write_config_entries(&entries, tmp.path()).unwrap();
398
399        let raw = std::fs::read_to_string(tmp.path().join("codex_mcp.json")).unwrap();
400        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
401        // Codex: env is a list of variable names, not a map with values.
402        assert!(json["mcpServers"]["server"]["env"].is_array());
403        let env_arr = json["mcpServers"]["server"]["env"].as_array().unwrap();
404        assert!(env_arr.iter().any(|v| v.as_str() == Some("MY_SECRET")));
405    }
406
407    #[test]
408    fn write_hooks_creates_codex_hooks_json() {
409        let tmp = TempDir::new().unwrap();
410        let adapter = CodexAdapter;
411        let entries = vec![make_hook_entry("audit", "pre-exec")];
412        adapter.write_config_entries(&entries, tmp.path()).unwrap();
413
414        let raw = std::fs::read_to_string(tmp.path().join("codex_hooks.json")).unwrap();
415        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
416        assert!(json["hooks"]["pre-exec"].is_array());
417    }
418
419    #[test]
420    fn write_hooks_replaces_existing_managed_hook_with_same_event_and_name() {
421        let tmp = TempDir::new().unwrap();
422        let adapter = CodexAdapter;
423        adapter
424            .write_config_entries(
425                &[make_hook_entry_with_path(
426                    "audit",
427                    "pre-exec",
428                    "/old/hooks/audit/run.sh",
429                )],
430                tmp.path(),
431            )
432            .unwrap();
433        adapter
434            .write_config_entries(
435                &[make_hook_entry_with_path(
436                    "audit",
437                    "pre-exec",
438                    "/new/hooks/audit/run.sh",
439                )],
440                tmp.path(),
441            )
442            .unwrap();
443
444        let raw = std::fs::read_to_string(tmp.path().join("codex_hooks.json")).unwrap();
445        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
446        let hooks = json["hooks"]["pre-exec"].as_array().unwrap();
447        assert_eq!(hooks.len(), 1);
448        assert!(hooks[0].as_str().unwrap().contains("/new/hooks/audit/"));
449    }
450
451    #[test]
452    fn remove_mcp_entries_removes_by_name() {
453        let tmp = TempDir::new().unwrap();
454        let adapter = CodexAdapter;
455        let entries = vec![make_mcp_entry("to-remove"), make_mcp_entry("to-keep")];
456        adapter.write_config_entries(&entries, tmp.path()).unwrap();
457
458        adapter
459            .remove_config_entries(&["mcp:to-remove".to_string()], tmp.path())
460            .unwrap();
461
462        let raw = std::fs::read_to_string(tmp.path().join("codex_mcp.json")).unwrap();
463        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
464        assert!(json["mcpServers"]["to-remove"].is_null());
465        assert!(json["mcpServers"]["to-keep"].is_object());
466    }
467
468    #[test]
469    fn remove_hook_entries_matches_backslash_commands() {
470        let tmp = TempDir::new().unwrap();
471        let existing = serde_json::json!({
472            "hooks": {
473                "pre-exec": [
474                    "bash \"C:\\\\pkg\\\\hooks\\\\audit\\\\run.sh\"",
475                    "bash \"C:\\\\pkg\\\\hooks\\\\audit-extended\\\\run.sh\""
476                ]
477            }
478        });
479        std::fs::write(
480            tmp.path().join("codex_hooks.json"),
481            serde_json::to_string_pretty(&existing).unwrap(),
482        )
483        .unwrap();
484
485        remove_codex_hook_entries(&["hook:tool.pre:audit".to_string()], tmp.path()).unwrap();
486
487        let raw = std::fs::read_to_string(tmp.path().join("codex_hooks.json")).unwrap();
488        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
489        let hooks = json["hooks"]["pre-exec"].as_array().unwrap();
490        assert_eq!(hooks.len(), 1);
491        assert!(hooks[0].as_str().unwrap().contains("audit-extended"));
492    }
493
494    #[test]
495    fn remove_hook_entries_scopes_by_universal_event() {
496        let tmp = TempDir::new().unwrap();
497        let existing = serde_json::json!({
498            "hooks": {
499                "pre-exec": ["bash \"/pkg/hooks/audit/run.sh\""],
500                "post-exec": ["bash \"/pkg/hooks/audit/run.sh\""]
501            }
502        });
503        std::fs::write(
504            tmp.path().join("codex_hooks.json"),
505            serde_json::to_string_pretty(&existing).unwrap(),
506        )
507        .unwrap();
508
509        remove_codex_hook_entries(&["hook:tool.pre:audit".to_string()], tmp.path()).unwrap();
510
511        let raw = std::fs::read_to_string(tmp.path().join("codex_hooks.json")).unwrap();
512        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
513        assert!(json["hooks"]["pre-exec"].as_array().unwrap().is_empty());
514        assert_eq!(json["hooks"]["post-exec"].as_array().unwrap().len(), 1);
515    }
516}