Skip to main content

agent_harness_adapter_core/codex/
adapter.rs

1//! OpenAI Codex CLI.
2//!
3//! Follows <https://learn.chatgpt.com/docs/agent-configuration/agents-md>,
4//! <https://learn.chatgpt.com/docs/hooks>,
5//! <https://learn.chatgpt.com/docs/config-file/config-basic>,
6//! <https://learn.chatgpt.com/docs/extend/mcp>,
7//! <https://learn.chatgpt.com/docs/build-skills> and
8//! <https://learn.chatgpt.com/docs/agent-configuration/subagents>
9//! (checked 2026-10-06).
10// @zen-component: HAR-Codex
11
12use serde_json::{Value, json};
13
14use crate::{
15    Result,
16    common::{
17        parts::{self, GroupHooks},
18        protocol, toml_out,
19    },
20    harness::{Context, Harness, Part, PartResult, Reads, Scope},
21    hook::{Answer, Event, HookInput, Output, ToolCall, ToolKind, claude_names},
22    integration::{Integration, Item, McpServer, Transport},
23};
24
25/// OpenAI Codex CLI (`codex`).
26#[derive(Debug, Clone, Copy, Default)]
27pub struct Codex;
28
29const ID: &str = "codex";
30
31/// Codex's name for `event`: Claude Code's names.
32// @zen-impl: HAR-2_AC-3
33fn event_name(event: Event) -> Option<&'static str> {
34    Some(match event {
35        Event::SessionStart => "SessionStart",
36        Event::SessionEnd => "SessionEnd",
37        Event::PromptSubmit => "UserPromptSubmit",
38        Event::PreTool => "PreToolUse",
39        Event::PostTool => "PostToolUse",
40        Event::Stop => "Stop",
41        Event::PreCompact => "PreCompact",
42    })
43}
44
45/// Only the shell tool's name is confirmed.
46fn matcher(kind: ToolKind) -> Option<&'static str> {
47    (kind == ToolKind::Shell).then_some("^Bash$")
48}
49
50fn tool_kind(name: &str) -> ToolKind {
51    match name {
52        "Bash" | "shell" | "local_shell" => ToolKind::Shell,
53        "apply_patch" => ToolKind::Write,
54        n if n.starts_with("mcp__") => ToolKind::Mcp,
55        _ => ToolKind::Other,
56    }
57}
58
59const LAYOUT: GroupHooks = GroupHooks {
60    path: |e| vec!["hooks".into(), e.into()],
61    event: event_name,
62    matcher,
63    timeout: parts::seconds,
64    harness: ID,
65};
66
67/// An MCP server as a `[mcp_servers.<name>]` table.
68// @zen-impl: HAR-2_AC-5
69fn mcp_table(s: &McpServer) -> Value {
70    match s.transport() {
71        Transport::Stdio { .. } => s.to_json(),
72        Transport::Http { url, headers } => {
73            let mut v = json!({ "url": url });
74            if !headers.is_empty() {
75                v["http_headers"] = s.to_json()["headers"].clone();
76            }
77            v
78        }
79    }
80}
81
82fn location(item: Item, cx: &Context) -> Option<&'static str> {
83    let user = cx.scope == Scope::User;
84    Some(match item {
85        Item::Instructions if user => ".codex/AGENTS.md",
86        Item::Instructions => "AGENTS.md",
87        Item::Skills => ".agents/skills",
88        Item::Hooks => ".codex/hooks.json",
89        Item::Mcp => ".codex/config.toml",
90        Item::Agents => ".codex/agents",
91        // Allowed commands: rule-file syntax not confirmed. Commands:
92        // deprecated in Codex for skills.
93        _ => return None,
94    })
95}
96
97/// Claude Code's name for one of its tools (HAR-15_AC-2).
98// @zen-impl: HAR-15_AC-2
99fn claude_name(name: &str) -> Option<&'static str> {
100    match name {
101        "shell" | "local_shell" | "Bash" => Some("Bash"),
102        "apply_patch" => Some("Edit"),
103        _ => None,
104    }
105}
106
107impl Harness for Codex {
108    fn id(&self) -> &str {
109        ID
110    }
111
112    // @zen-impl: HAR-15_AC-1
113    fn program(&self) -> Option<&str> {
114        Some("codex")
115    }
116
117    fn claude_tool(&self, call: &ToolCall) -> ToolCall {
118        claude_names::named(call, claude_name(&call.name))
119    }
120
121    // @zen-impl: HAR-2_AC-1
122    fn scopes(&self) -> &[Scope] {
123        &[Scope::Project, Scope::User]
124    }
125
126    // @zen-impl: HAR-2_AC-2
127    // @zen-impl: HAR-2_AC-4
128    // @zen-impl: HAR-2_AC-6
129    fn render(&self, integration: &Integration, cx: &Context) -> Result<Vec<Part>> {
130        let mut out = Vec::new();
131        for item in integration.items() {
132            let Some(at) = location(item, cx) else {
133                continue;
134            };
135            out.push(match item {
136                Item::Instructions => {
137                    parts::instructions(at, integration.instructions_block().unwrap_or_default())
138                }
139                Item::Skills => parts::skills(at, integration),
140                Item::Hooks => {
141                    Part::merge(item.as_str(), at, parts::group_hooks(&LAYOUT, integration)?)
142                }
143                Item::Mcp => parts::mcp(at, "mcp_servers", integration, mcp_table),
144                Item::Agents => parts::agents(at, ".toml", integration, |a| {
145                    toml_out::strings(&[
146                        ("name", a.name()),
147                        ("description", a.description()),
148                        ("developer_instructions", a.prompt()),
149                    ])
150                }),
151                _ => continue,
152            });
153        }
154        Ok(out)
155    }
156
157    fn reads(&self, item: Item, cx: &Context) -> Result<Reads> {
158        Ok(Reads::always(location(item, cx)))
159    }
160
161    fn parse_hook(&self, event: Event, text: &str) -> serde_json::Result<HookInput> {
162        protocol::parse(ID, event, text, tool_kind, "last_assistant_message")
163    }
164
165    fn answer(&self, event: Event, answer: &Answer) -> Result<Output> {
166        protocol::answer(ID, event, event_name(event).unwrap_or_default(), answer)
167    }
168
169    // @zen-impl: HAR-2_AC-7
170    fn notes(&self, cx: &Context, parts: &[PartResult]) -> Vec<String> {
171        let written = |name: &str| parts.iter().any(|p| p.part == name && p.wrote());
172        let mut out = Vec::new();
173        if !parts.iter().any(PartResult::changed) {
174            return out;
175        }
176        // Trust and approval matter for what was written, not removed.
177        if cx.scope == Scope::Project && parts.iter().any(PartResult::wrote) {
178            out.push(
179                "Codex reads the project's .codex files only once you trust the project".into(),
180            );
181        }
182        if written("hooks") {
183            out.push("new hooks run only once approved in Codex's /hooks".into());
184        }
185        out.push("restart Codex to load the changes".into());
186        out
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use std::time::Duration;
193
194    use super::*;
195    use crate::{
196        harness::{Action, MergeOp, Profile, State},
197        integration::{Agent, Command, Hook, Skill},
198    };
199
200    fn full() -> Integration {
201        Integration::new()
202            .instructions("Use t.\n")
203            .skill(Skill::new("t", "Use t.", "# T\n"))
204            .hook(Hook::new(Event::Stop, "t hook {harness} {event}"))
205            .hook(
206                Hook::new(Event::PreTool, "t hook {harness} {event}")
207                    .tools(ToolKind::Shell)
208                    .timeout(Duration::from_secs(9)),
209            )
210            .hook(Hook::new(Event::PostTool, "t hook {harness} {event}").tools(ToolKind::Write))
211            .mcp_server(McpServer::stdio("t", "t", ["mcp"]))
212            .mcp_server(McpServer::http("w", "https://w").header("A", "b"))
213            .allow_command("t")
214            .agent(Agent::new("r", "Reviews.", "Review.\n"))
215            .command(Command::new("c", "Checks.", "Check.\n"))
216    }
217
218    fn rendered(scope: Scope) -> Profile {
219        let cx = Context::new("t", scope, "/nowhere", None);
220        Profile::new(Codex.render(&full(), &cx).unwrap())
221    }
222
223    // @zen-test: HAR-2_AC-1
224    // @zen-test: HAR-2_AC-2
225    // @zen-test: HAR-2_AC-3
226    // @zen-test: HAR-2_AC-4
227    // @zen-test: HAR-2_AC-5
228    // @zen-test: HAR-2_AC-6
229    #[test]
230    fn renders_every_item_it_takes() {
231        let names = |s| -> Vec<String> {
232            rendered(s)
233                .parts()
234                .iter()
235                .map(|p| format!("{} {}", p.name(), p.location()))
236                .collect()
237        };
238        assert_eq!(
239            names(Scope::Project),
240            [
241                "instructions AGENTS.md",
242                "skills .agents/skills",
243                "hooks .codex/hooks.json",
244                "mcp .codex/config.toml",
245                "agents .codex/agents"
246            ]
247        );
248        assert_eq!(names(Scope::User)[0], "instructions .codex/AGENTS.md");
249        assert_eq!(Codex.scopes(), [Scope::Project, Scope::User]);
250        let p = rendered(Scope::Project);
251        let hooks = p.part("hooks").unwrap().ops();
252        assert_eq!(hooks.len(), 7);
253        assert_eq!(
254            hooks[3].value(),
255            &json!([{ "matcher": "^Bash$", "hooks": [{ "type": "command", "command": "t hook codex pre-tool", "timeout": 9 }] }])
256        );
257        // No confirmed matcher for writes: every tool.
258        assert_eq!(
259            hooks[4].value(),
260            &json!([{ "hooks": [{ "type": "command", "command": "t hook codex post-tool" }] }])
261        );
262        assert_eq!(
263            p.part("mcp").unwrap().ops(),
264            &[
265                MergeOp::object_member(
266                    ["mcp_servers"],
267                    "t",
268                    json!({ "command": "t", "args": ["mcp"] })
269                ),
270                MergeOp::object_member(
271                    ["mcp_servers"],
272                    "w",
273                    json!({ "url": "https://w", "http_headers": { "A": "b" } })
274                ),
275            ]
276        );
277        let cx = Context::new("t", Scope::Project, "/nowhere", None);
278        assert_eq!(
279            Codex.reads(Item::Permissions, &cx).unwrap(),
280            Reads::default()
281        );
282        assert_eq!(
283            Codex.reads(Item::Skills, &cx).unwrap(),
284            Reads::always([".agents/skills"])
285        );
286    }
287
288    #[test]
289    fn agents_are_toml_files() {
290        let i = Integration::new().agent(Agent::new("r", "Reviews.", "Review.\n"));
291        let cx = Context::new("t", Scope::Project, "/nowhere", None);
292        let parts = Codex.render(&i, &cx).unwrap();
293        assert_eq!(parts[0].location(), ".codex/agents");
294        assert!(parts[0].same_content(&Part::files(
295            "agents",
296            ".codex/agents",
297            vec![(
298                "r.toml".into(),
299                "name = \"r\"\ndescription = \"Reviews.\"\ndeveloper_instructions = \"\"\"\nReview.\n\"\"\"\n".into()
300            )]
301        )));
302    }
303
304    #[test]
305    fn input_and_answers_are_claudes() {
306        let i = Codex
307            .parse_hook(
308                Event::PreTool,
309                r#"{"tool_name":"apply_patch","tool_input":{}}"#,
310            )
311            .unwrap();
312        assert_eq!(
313            (i.harness.as_str(), i.tool.unwrap().kind),
314            ("codex", ToolKind::Write)
315        );
316        assert_eq!(tool_kind("shell"), ToolKind::Shell);
317        assert_eq!(tool_kind("mcp__a__b"), ToolKind::Mcp);
318        assert_eq!(tool_kind("x"), ToolKind::Other);
319        assert_eq!(
320            Codex
321                .answer(Event::Stop, &Answer::Continue { reason: "r".into() })
322                .unwrap()
323                .stdout,
324            r#"{"decision":"block","reason":"r"}"#
325        );
326    }
327
328    // @zen-test: HAR-2_AC-7
329    #[test]
330    fn notes_after_writes() {
331        let cx = Context::new("t", Scope::Project, "/nowhere", None);
332        let part = |name: &str, action| PartResult {
333            part: name.into(),
334            state: State::Absent,
335            action,
336            path: String::new(),
337            by: None,
338            in_place: false,
339        };
340        assert!(Codex.notes(&cx, &[part("hooks", None)]).is_empty());
341        assert_eq!(
342            Codex.notes(&cx, &[part("hooks", Some(Action::Created))]),
343            [
344                "Codex reads the project's .codex files only once you trust the project",
345                "new hooks run only once approved in Codex's /hooks",
346                "restart Codex to load the changes"
347            ]
348        );
349        let user = Context::new("t", Scope::User, "/nowhere", None);
350        assert_eq!(
351            Codex.notes(&user, &[part("skills", Some(Action::Created))]),
352            ["restart Codex to load the changes"]
353        );
354        // After an uninstall: no trust or approval, only the restart.
355        assert_eq!(
356            Codex.notes(&cx, &[part("hooks", Some(Action::Removed))]),
357            ["restart Codex to load the changes"]
358        );
359        assert!(
360            Codex
361                .notes(&cx, &[part("hooks", Some(Action::Kept))])
362                .is_empty()
363        );
364    }
365}