1use 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#[derive(Debug, Clone, Copy, Default)]
27pub struct Codex;
28
29const ID: &str = "codex";
30
31fn 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
45fn 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
67fn 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 _ => return None,
94 })
95}
96
97fn 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 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 fn scopes(&self) -> &[Scope] {
123 &[Scope::Project, Scope::User]
124 }
125
126 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 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 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 #[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 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 #[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 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}