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