mi6_core/framework/
codex.rs

1//! OpenAI Codex CLI framework adapter.
2//!
3//! This adapter handles integration with OpenAI's Codex CLI tool.
4//! Codex has a unique integration model compared to other frameworks:
5//!
6//! - **Limited notify hooks**: Only 2 events (`agent-turn-complete`, `approval-requested`)
7//! - **TOML configuration**: Config is in TOML format, not JSON
8//! - **CLI argument JSON**: JSON payload passed as command-line argument, not stdin
9//! - **Rich OTEL telemetry**: Built-in OTLP support for detailed API request tracking
10//!
11//! # Integration Strategy
12//!
13//! Due to limited notify hook coverage (only 2 events), OTEL telemetry is the
14//! primary data source for Codex. The notify hooks provide basic events, while
15//! OTEL captures rich telemetry including token usage, cost, and model info.
16//!
17//! The recommended integration approach is dual:
18//! 1. notify hooks for the 2 available events (turn completion, approval requests)
19//! 2. OTEL ingestion for rich telemetry data (`codex.api_request`, etc.)
20//!
21//! Users cannot track session lifecycle, tool execution, or user prompts via
22//! notify hooks - this data is only available through OTEL telemetry.
23
24use super::{ConfigFormat, FrameworkAdapter, ParsedHookInput, common};
25use crate::model::EventType;
26use std::path::PathBuf;
27
28/// OpenAI Codex CLI framework adapter.
29///
30/// Handles hook generation and event parsing for Codex CLI.
31pub struct CodexAdapter;
32
33impl FrameworkAdapter for CodexAdapter {
34    fn name(&self) -> &'static str {
35        "codex"
36    }
37
38    fn display_name(&self) -> &'static str {
39        "Codex CLI"
40    }
41
42    fn project_config_path(&self) -> PathBuf {
43        PathBuf::from(".codex/config.toml")
44    }
45
46    fn user_config_path(&self) -> Option<PathBuf> {
47        dirs::home_dir().map(|h| h.join(".codex/config.toml"))
48    }
49
50    fn config_format(&self) -> ConfigFormat {
51        ConfigFormat::Toml
52    }
53
54    fn generate_hooks_config(
55        &self,
56        _enabled_events: &[EventType],
57        mi6_bin: &str,
58        otel_enabled: bool,
59        otel_port: u16,
60    ) -> serde_json::Value {
61        // Note: _enabled_events is intentionally ignored because Codex uses a single
62        // notify command for all events, rather than per-event hooks like other frameworks.
63        //
64        // Codex uses TOML config, so we generate a JSON representation
65        // that will be converted to TOML by the init command.
66        //
67        // Codex notify hook format: notify = ["command", "arg1", "arg2"]
68        // The hook receives JSON as the last CLI argument, not via stdin.
69        let notify_command = vec![
70            mi6_bin.to_string(),
71            "ingest".to_string(),
72            "event".to_string(),
73            "--framework".to_string(),
74            "codex".to_string(),
75        ];
76
77        let mut config = serde_json::json!({
78            "notify": notify_command
79        });
80
81        // Add OTEL configuration if enabled
82        // Codex expects: exporter = { otlp-http = { endpoint = "...", protocol = "json" } }
83        if otel_enabled {
84            config["otel"] = serde_json::json!({
85                "exporter": {
86                    "otlp-http": {
87                        "endpoint": format!("http://127.0.0.1:{}", otel_port),
88                        "protocol": "json"
89                    }
90                }
91            });
92        }
93
94        config
95    }
96
97    fn merge_config(
98        &self,
99        generated: serde_json::Value,
100        existing: Option<serde_json::Value>,
101    ) -> serde_json::Value {
102        let mut settings = existing.unwrap_or_else(|| serde_json::json!({}));
103
104        // Set notify command
105        if let Some(notify) = generated.get("notify") {
106            settings["notify"] = notify.clone();
107        }
108
109        // Merge otel settings
110        if let Some(new_otel) = generated.get("otel") {
111            if let Some(existing_otel) = settings.get_mut("otel") {
112                if let (Some(existing_obj), Some(new_obj)) =
113                    (existing_otel.as_object_mut(), new_otel.as_object())
114                {
115                    for (key, value) in new_obj {
116                        existing_obj.insert(key.clone(), value.clone());
117                    }
118                }
119            } else {
120                settings["otel"] = new_otel.clone();
121            }
122        }
123
124        settings
125    }
126
127    fn parse_hook_input(&self, _event_type: &str, json: &serde_json::Value) -> ParsedHookInput {
128        // Codex JSON payload schema (from notify hook):
129        // {
130        //   "type": "agent-turn-complete" | "approval-requested",
131        //   "thread-id": "abc123",
132        //   "turn-id": "turn-1",
133        //   "cwd": "/path/to/project",
134        //   ...
135        // }
136        ParsedHookInput {
137            // thread-id maps to session_id
138            session_id: json
139                .get("thread-id")
140                .and_then(|v| v.as_str())
141                .map(String::from),
142            // turn-id can be used for correlation (similar to tool_use_id)
143            tool_use_id: json
144                .get("turn-id")
145                .and_then(|v| v.as_str())
146                .map(String::from),
147            cwd: json.get("cwd").and_then(|v| v.as_str()).map(String::from),
148            // Codex doesn't provide these fields
149            tool_name: None,
150            subagent_type: None,
151            spawned_agent_id: None,
152            permission_mode: None,
153            transcript_path: None,
154            session_source: None,
155            agent_id: None,
156            agent_transcript_path: None,
157            compact_trigger: None,
158        }
159    }
160
161    fn map_event_type(&self, framework_event: &str) -> EventType {
162        match framework_event {
163            // approval-requested maps to PermissionRequest
164            "approval-requested" => EventType::PermissionRequest,
165            // agent-turn-complete has no direct canonical equivalent
166            // Store as Custom for now
167            "agent-turn-complete" => EventType::Custom("TurnComplete".to_string()),
168            // Try canonical parsing, fall back to custom
169            other => other
170                .parse()
171                .unwrap_or_else(|_| EventType::Custom(other.to_string())),
172        }
173    }
174
175    fn supported_events(&self) -> Vec<&'static str> {
176        // Codex only supports 2 notify hook events
177        vec!["agent-turn-complete", "approval-requested"]
178    }
179
180    fn framework_specific_events(&self) -> Vec<&'static str> {
181        // agent-turn-complete has no canonical equivalent
182        vec!["agent-turn-complete"]
183    }
184
185    fn detection_env_vars(&self) -> &[&'static str] {
186        // Environment variables that Codex CLI may set.
187        // Note: These are inferred from Codex documentation and need verification
188        // with actual Codex CLI behavior. If detection fails, users can explicitly
189        // specify --framework codex.
190        &["CODEX_SESSION_ID", "CODEX_PROJECT_DIR", "CODEX_THREAD_ID"]
191    }
192
193    fn is_installed(&self) -> bool {
194        common::is_framework_installed(self.user_config_path(), "codex")
195    }
196
197    fn remove_hooks(&self, existing: serde_json::Value) -> Option<serde_json::Value> {
198        let mut settings = existing;
199        let mut modified = false;
200
201        // Remove notify command if it contains "mi6"
202        if let Some(notify) = settings.get("notify")
203            && let Some(arr) = notify.as_array()
204        {
205            // Check if any element contains "mi6"
206            let has_mi6_hook = arr
207                .iter()
208                .any(|v| v.as_str().is_some_and(|s| s.contains("mi6")));
209            if has_mi6_hook && let Some(obj) = settings.as_object_mut() {
210                obj.remove("notify");
211                modified = true;
212            }
213        }
214
215        // Remove otel section
216        if let Some(obj) = settings.as_object_mut()
217            && obj.remove("otel").is_some()
218        {
219            modified = true;
220        }
221
222        if modified { Some(settings) } else { None }
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn test_name() {
232        let adapter = CodexAdapter;
233        assert_eq!(adapter.name(), "codex");
234        assert_eq!(adapter.display_name(), "Codex CLI");
235    }
236
237    #[test]
238    fn test_config_format() {
239        let adapter = CodexAdapter;
240        assert_eq!(adapter.config_format(), ConfigFormat::Toml);
241    }
242
243    #[test]
244    fn test_project_config_path() {
245        let adapter = CodexAdapter;
246        assert_eq!(
247            adapter.project_config_path(),
248            PathBuf::from(".codex/config.toml")
249        );
250    }
251
252    #[test]
253    fn test_map_event_type() {
254        let adapter = CodexAdapter;
255
256        // Codex-specific mappings
257        assert_eq!(
258            adapter.map_event_type("approval-requested"),
259            EventType::PermissionRequest
260        );
261        assert_eq!(
262            adapter.map_event_type("agent-turn-complete"),
263            EventType::Custom("TurnComplete".to_string())
264        );
265
266        // Unknown events become Custom
267        assert_eq!(
268            adapter.map_event_type("unknown-event"),
269            EventType::Custom("unknown-event".to_string())
270        );
271    }
272
273    #[test]
274    fn test_parse_hook_input() {
275        let adapter = CodexAdapter;
276        let input = serde_json::json!({
277            "type": "agent-turn-complete",
278            "thread-id": "codex-thread-123",
279            "turn-id": "turn-456",
280            "cwd": "/projects/test"
281        });
282
283        let parsed = adapter.parse_hook_input("agent-turn-complete", &input);
284
285        assert_eq!(parsed.session_id, Some("codex-thread-123".to_string()));
286        assert_eq!(parsed.tool_use_id, Some("turn-456".to_string()));
287        assert_eq!(parsed.cwd, Some("/projects/test".to_string()));
288        // Codex doesn't provide these
289        assert_eq!(parsed.tool_name, None);
290        assert_eq!(parsed.permission_mode, None);
291        assert_eq!(parsed.transcript_path, None);
292    }
293
294    #[test]
295    fn test_generate_hooks_config() -> Result<(), String> {
296        let adapter = CodexAdapter;
297        let events = vec![EventType::PermissionRequest];
298
299        let config = adapter.generate_hooks_config(&events, "mi6", false, 4318);
300
301        // Should have notify array
302        let notify = config
303            .get("notify")
304            .ok_or("missing notify")?
305            .as_array()
306            .ok_or("notify not an array")?;
307        assert_eq!(notify[0], "mi6");
308        assert_eq!(notify[1], "ingest");
309        assert_eq!(notify[2], "event");
310        assert_eq!(notify[3], "--framework");
311        assert_eq!(notify[4], "codex");
312
313        // No otel section when disabled
314        assert!(config.get("otel").is_none());
315        Ok(())
316    }
317
318    #[test]
319    fn test_generate_hooks_config_with_otel() -> Result<(), String> {
320        let adapter = CodexAdapter;
321        let events = vec![];
322
323        let config = adapter.generate_hooks_config(&events, "mi6", true, 4318);
324
325        // Should have otel section with nested exporter format
326        let otel = config.get("otel").ok_or("missing otel")?;
327        assert_eq!(
328            otel["exporter"]["otlp-http"]["endpoint"],
329            "http://127.0.0.1:4318"
330        );
331        assert_eq!(otel["exporter"]["otlp-http"]["protocol"], "json");
332        Ok(())
333    }
334
335    #[test]
336    fn test_merge_config_new() -> Result<(), String> {
337        let adapter = CodexAdapter;
338        let generated = serde_json::json!({
339            "notify": ["mi6", "ingest", "event", "--framework", "codex"]
340        });
341
342        let merged = adapter.merge_config(generated, None);
343
344        let notify = merged
345            .get("notify")
346            .ok_or("missing notify")?
347            .as_array()
348            .ok_or("notify not an array")?;
349        assert_eq!(notify.len(), 5);
350        Ok(())
351    }
352
353    #[test]
354    fn test_merge_config_existing() {
355        let adapter = CodexAdapter;
356        let generated = serde_json::json!({
357            "notify": ["mi6", "ingest", "event", "--framework", "codex"],
358            "otel": {
359                "exporter": {
360                    "otlp-http": {
361                        "endpoint": "http://127.0.0.1:4318",
362                        "protocol": "json"
363                    }
364                }
365            }
366        });
367        let existing = serde_json::json!({
368            "model": "gpt-4",
369            "otel": {
370                "headers": { "x-api-key": "secret" }
371            }
372        });
373
374        let merged = adapter.merge_config(generated, Some(existing));
375
376        // Should preserve existing settings
377        assert_eq!(merged["model"], "gpt-4");
378        // Should have notify
379        assert!(merged.get("notify").is_some());
380        // Should merge otel - nested exporter format
381        assert_eq!(
382            merged["otel"]["exporter"]["otlp-http"]["endpoint"],
383            "http://127.0.0.1:4318"
384        );
385        // Existing otel keys are preserved
386        assert_eq!(merged["otel"]["headers"]["x-api-key"], "secret");
387    }
388
389    #[test]
390    fn test_supported_events() {
391        let adapter = CodexAdapter;
392        let events = adapter.supported_events();
393
394        assert!(events.contains(&"agent-turn-complete"));
395        assert!(events.contains(&"approval-requested"));
396        assert_eq!(events.len(), 2);
397    }
398
399    #[test]
400    fn test_framework_specific_events() {
401        let adapter = CodexAdapter;
402        let events = adapter.framework_specific_events();
403
404        assert!(events.contains(&"agent-turn-complete"));
405        assert_eq!(events.len(), 1);
406    }
407
408    #[test]
409    fn test_detection_env_vars() {
410        let adapter = CodexAdapter;
411        let vars = adapter.detection_env_vars();
412
413        assert!(vars.contains(&"CODEX_SESSION_ID"));
414        assert!(vars.contains(&"CODEX_PROJECT_DIR"));
415        assert!(vars.contains(&"CODEX_THREAD_ID"));
416    }
417}