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, ParsedHookInputBuilder, 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        ParsedHookInputBuilder::new(json)
137            .session_id("thread-id")
138            .tool_use_id("turn-id")
139            .cwd("cwd")
140            .build()
141        // Note: Codex only provides basic fields via notify hooks;
142        // rich telemetry comes from OTEL instead
143    }
144
145    fn map_event_type(&self, framework_event: &str) -> EventType {
146        match framework_event {
147            // approval-requested maps to PermissionRequest
148            "approval-requested" => EventType::PermissionRequest,
149            // agent-turn-complete has no direct canonical equivalent
150            // Store as Custom for now
151            "agent-turn-complete" => EventType::Custom("TurnComplete".to_string()),
152            // Try canonical parsing, fall back to custom
153            other => other
154                .parse()
155                .unwrap_or_else(|_| EventType::Custom(other.to_string())),
156        }
157    }
158
159    fn supported_events(&self) -> Vec<&'static str> {
160        // Codex only supports 2 notify hook events
161        vec!["agent-turn-complete", "approval-requested"]
162    }
163
164    fn framework_specific_events(&self) -> Vec<&'static str> {
165        // agent-turn-complete has no canonical equivalent
166        vec!["agent-turn-complete"]
167    }
168
169    fn detection_env_vars(&self) -> &[&'static str] {
170        // Environment variables that Codex CLI may set.
171        // Note: These are inferred from Codex documentation and need verification
172        // with actual Codex CLI behavior. If detection fails, users can explicitly
173        // specify --framework codex.
174        &["CODEX_SESSION_ID", "CODEX_PROJECT_DIR", "CODEX_THREAD_ID"]
175    }
176
177    fn is_installed(&self) -> bool {
178        common::is_framework_installed(self.user_config_path(), "codex")
179    }
180
181    fn otel_support(&self) -> super::OtelSupport {
182        use super::OtelSupport;
183        // Codex supports OTEL via [otel] section in config.toml
184        let Some(config_path) = self.user_config_path() else {
185            return OtelSupport::Disabled;
186        };
187        let Ok(contents) = std::fs::read_to_string(&config_path) else {
188            return OtelSupport::Disabled;
189        };
190        let Ok(toml_val) = toml::from_str::<toml::Value>(&contents) else {
191            return OtelSupport::Disabled;
192        };
193
194        // Check for [otel] section with exporter configuration
195        if let Some(otel) = toml_val.get("otel") {
196            // Codex uses nested format: otel.exporter.otlp-http.endpoint
197            if otel.get("exporter").is_some() {
198                return OtelSupport::Enabled;
199            }
200        }
201        OtelSupport::Disabled
202    }
203
204    fn remove_hooks(&self, existing: serde_json::Value) -> Option<serde_json::Value> {
205        let mut settings = existing;
206        let mut modified = false;
207
208        // Remove notify command if it contains "mi6"
209        if let Some(notify) = settings.get("notify")
210            && let Some(arr) = notify.as_array()
211        {
212            // Check if any element contains "mi6"
213            let has_mi6_hook = arr
214                .iter()
215                .any(|v| v.as_str().is_some_and(|s| s.contains("mi6")));
216            if has_mi6_hook && let Some(obj) = settings.as_object_mut() {
217                obj.remove("notify");
218                modified = true;
219            }
220        }
221
222        // Remove otel section
223        if let Some(obj) = settings.as_object_mut()
224            && obj.remove("otel").is_some()
225        {
226            modified = true;
227        }
228
229        if modified { Some(settings) } else { None }
230    }
231
232    fn resume_command(&self, session_id: &str) -> Option<String> {
233        Some(format!("codex resume {}", session_id))
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn test_name() {
243        let adapter = CodexAdapter;
244        assert_eq!(adapter.name(), "codex");
245        assert_eq!(adapter.display_name(), "Codex CLI");
246    }
247
248    #[test]
249    fn test_config_format() {
250        let adapter = CodexAdapter;
251        assert_eq!(adapter.config_format(), ConfigFormat::Toml);
252    }
253
254    #[test]
255    fn test_project_config_path() {
256        let adapter = CodexAdapter;
257        assert_eq!(
258            adapter.project_config_path(),
259            PathBuf::from(".codex/config.toml")
260        );
261    }
262
263    #[test]
264    fn test_map_event_type() {
265        let adapter = CodexAdapter;
266
267        // Codex-specific mappings
268        assert_eq!(
269            adapter.map_event_type("approval-requested"),
270            EventType::PermissionRequest
271        );
272        assert_eq!(
273            adapter.map_event_type("agent-turn-complete"),
274            EventType::Custom("TurnComplete".to_string())
275        );
276
277        // Unknown events become Custom
278        assert_eq!(
279            adapter.map_event_type("unknown-event"),
280            EventType::Custom("unknown-event".to_string())
281        );
282    }
283
284    #[test]
285    fn test_parse_hook_input() {
286        let adapter = CodexAdapter;
287        let input = serde_json::json!({
288            "type": "agent-turn-complete",
289            "thread-id": "codex-thread-123",
290            "turn-id": "turn-456",
291            "cwd": "/projects/test"
292        });
293
294        let parsed = adapter.parse_hook_input("agent-turn-complete", &input);
295
296        assert_eq!(parsed.session_id, Some("codex-thread-123".to_string()));
297        assert_eq!(parsed.tool_use_id, Some("turn-456".to_string()));
298        assert_eq!(parsed.cwd, Some("/projects/test".to_string()));
299        // Codex doesn't provide these
300        assert_eq!(parsed.tool_name, None);
301        assert_eq!(parsed.permission_mode, None);
302        assert_eq!(parsed.transcript_path, None);
303    }
304
305    #[test]
306    fn test_generate_hooks_config() -> Result<(), String> {
307        let adapter = CodexAdapter;
308        let events = vec![EventType::PermissionRequest];
309
310        let config = adapter.generate_hooks_config(&events, "mi6", false, 4318);
311
312        // Should have notify array
313        let notify = config
314            .get("notify")
315            .ok_or("missing notify")?
316            .as_array()
317            .ok_or("notify not an array")?;
318        assert_eq!(notify[0], "mi6");
319        assert_eq!(notify[1], "ingest");
320        assert_eq!(notify[2], "event");
321        assert_eq!(notify[3], "--framework");
322        assert_eq!(notify[4], "codex");
323
324        // No otel section when disabled
325        assert!(config.get("otel").is_none());
326        Ok(())
327    }
328
329    #[test]
330    fn test_generate_hooks_config_with_otel() -> Result<(), String> {
331        let adapter = CodexAdapter;
332        let events = vec![];
333
334        let config = adapter.generate_hooks_config(&events, "mi6", true, 4318);
335
336        // Should have otel section with nested exporter format
337        let otel = config.get("otel").ok_or("missing otel")?;
338        assert_eq!(
339            otel["exporter"]["otlp-http"]["endpoint"],
340            "http://127.0.0.1:4318"
341        );
342        assert_eq!(otel["exporter"]["otlp-http"]["protocol"], "json");
343        Ok(())
344    }
345
346    #[test]
347    fn test_merge_config_new() -> Result<(), String> {
348        let adapter = CodexAdapter;
349        let generated = serde_json::json!({
350            "notify": ["mi6", "ingest", "event", "--framework", "codex"]
351        });
352
353        let merged = adapter.merge_config(generated, None);
354
355        let notify = merged
356            .get("notify")
357            .ok_or("missing notify")?
358            .as_array()
359            .ok_or("notify not an array")?;
360        assert_eq!(notify.len(), 5);
361        Ok(())
362    }
363
364    #[test]
365    fn test_merge_config_existing() {
366        let adapter = CodexAdapter;
367        let generated = serde_json::json!({
368            "notify": ["mi6", "ingest", "event", "--framework", "codex"],
369            "otel": {
370                "exporter": {
371                    "otlp-http": {
372                        "endpoint": "http://127.0.0.1:4318",
373                        "protocol": "json"
374                    }
375                }
376            }
377        });
378        let existing = serde_json::json!({
379            "model": "gpt-4",
380            "otel": {
381                "headers": { "x-api-key": "secret" }
382            }
383        });
384
385        let merged = adapter.merge_config(generated, Some(existing));
386
387        // Should preserve existing settings
388        assert_eq!(merged["model"], "gpt-4");
389        // Should have notify
390        assert!(merged.get("notify").is_some());
391        // Should merge otel - nested exporter format
392        assert_eq!(
393            merged["otel"]["exporter"]["otlp-http"]["endpoint"],
394            "http://127.0.0.1:4318"
395        );
396        // Existing otel keys are preserved
397        assert_eq!(merged["otel"]["headers"]["x-api-key"], "secret");
398    }
399
400    #[test]
401    fn test_supported_events() {
402        let adapter = CodexAdapter;
403        let events = adapter.supported_events();
404
405        assert!(events.contains(&"agent-turn-complete"));
406        assert!(events.contains(&"approval-requested"));
407        assert_eq!(events.len(), 2);
408    }
409
410    #[test]
411    fn test_framework_specific_events() {
412        let adapter = CodexAdapter;
413        let events = adapter.framework_specific_events();
414
415        assert!(events.contains(&"agent-turn-complete"));
416        assert_eq!(events.len(), 1);
417    }
418
419    #[test]
420    fn test_detection_env_vars() {
421        let adapter = CodexAdapter;
422        let vars = adapter.detection_env_vars();
423
424        assert!(vars.contains(&"CODEX_SESSION_ID"));
425        assert!(vars.contains(&"CODEX_PROJECT_DIR"));
426        assert!(vars.contains(&"CODEX_THREAD_ID"));
427    }
428}