devflow_core/agents/codex.rs
1//! OpenAI Codex agent driver.
2//!
3//! Launches `codex -a never exec "<prompt>"` in non-interactive mode with JSON
4//! output. `-a never` is the GLOBAL approval flag and must precede `exec` —
5//! verified against the installed CLI (a `codex exec -a never` placement is
6//! rejected as an unknown argument).
7
8use super::{AgentDriver, InteractivityMode};
9use crate::phase_id::PhaseId;
10use std::path::PathBuf;
11
12/// The modular driver for Codex (37-03): owns the launch argv, the JSONL
13/// completion parsing, the signing-disable environment, and the Codex-native
14/// workflow-reference prompt.
15pub struct CodexDriver;
16
17impl AgentDriver for CodexDriver {
18 fn name(&self) -> &'static str {
19 "OpenAI Codex"
20 }
21
22 fn render_prompt(&self, intent: &crate::prompt::StageIntent) -> String {
23 crate::prompt::render_workflow_style(intent, &self.workflow_root())
24 }
25
26 fn build_command(
27 &self,
28 _phase: PhaseId,
29 prompt: &str,
30 extra_writable_roots: &[PathBuf],
31 ) -> (&'static str, Vec<String>) {
32 let mut args: Vec<String> = vec![
33 // `-a never` is the GLOBAL non-interactive approval flag (must
34 // precede `exec`); spawn-tested against the installed CLI.
35 "-a".into(),
36 "never".into(),
37 "exec".into(),
38 "--sandbox".into(),
39 "workspace-write".into(),
40 "--json".into(),
41 ];
42 // Linked-worktree commits write git metadata outside the
43 // workspace-write sandbox (13-06 dogfood finding: Code stage
44 // implemented and tested, then could not commit). Grant every extra
45 // root in one TOML list value; escape backslashes, quotes, and control
46 // characters so a hostile path cannot corrupt the array (999.107 #2).
47 if !extra_writable_roots.is_empty() {
48 let list = extra_writable_roots
49 .iter()
50 .map(|root| {
51 // Refuse (panic) on a non-UTF-8 path: TOML basic strings are
52 // UTF-8-only, so a non-UTF-8 root cannot be serialized
53 // losslessly. A lossy U+FFFD would name a different,
54 // nonexistent path and silently drop the sandbox write grant
55 // (999.107 #2 review).
56 let path = root
57 .to_str()
58 .expect("non-UTF-8 writable root path — refusing Codex launch");
59 format!("\"{}\"", escape_toml_basic_string(path))
60 })
61 .collect::<Vec<_>>()
62 .join(",");
63 args.push("-c".into());
64 args.push(format!("sandbox_workspace_write.writable_roots=[{list}]"));
65 }
66 args.push(prompt.to_string());
67 ("codex", args)
68 }
69
70 /// Relocate the Codex JSONL completion parsing under driver ownership: the
71 /// function body lives in `agent_result.rs` (where the result-evaluation
72 /// path and its fixtures live), and this method is the driver's contract
73 /// entry point for it.
74 fn parse_completion(&self, output: &str) -> Option<crate::agent_result::AgentResult> {
75 crate::agent_result::parse_codex_event_result(output)
76 }
77
78 fn environment(&self) -> Vec<(String, String)> {
79 // The sandbox has no route to the operator's signing agent, so signed
80 // commits/tags fail headless (`ssh-keygen -Y sign` → passphrase error).
81 // Disable signing via env, scoped to this agent's process tree only.
82 vec![
83 ("GIT_CONFIG_COUNT".into(), "2".into()),
84 ("GIT_CONFIG_KEY_0".into(), "commit.gpgsign".into()),
85 ("GIT_CONFIG_VALUE_0".into(), "false".into()),
86 ("GIT_CONFIG_KEY_1".into(), "tag.gpgsign".into()),
87 ("GIT_CONFIG_VALUE_1".into(), "false".into()),
88 ]
89 }
90
91 fn interactivity_mode(&self, stage: crate::stage::Stage) -> InteractivityMode {
92 use crate::stage::Stage;
93 match stage {
94 // Codex cannot run the interactive discuss-phase interview or the
95 // interactive plan-phase decision headless — its Define/Plan stages
96 // need the artifact to pre-exist (13-06 dogfood finding).
97 Stage::Define | Stage::Plan => InteractivityMode::RequiresExistingArtifact,
98 _ => InteractivityMode::HeadlessSafe,
99 }
100 }
101}
102
103/// Escape a string for embedding inside a TOML basic (double-quoted) string.
104///
105/// 999.107 #2: the previous serializer escaped only `\` and `"`, so a path
106/// containing a newline or other control character produced malformed TOML and
107/// a corrupt `sandbox_workspace_write.writable_roots` override. Control
108/// characters are escaped as `\n`/`\t`/`\r` (or `\uXXXX` for the rest) so the
109/// array value stays a valid TOML string no matter what a path contains.
110fn escape_toml_basic_string(s: &str) -> String {
111 let mut out = String::with_capacity(s.len());
112 for c in s.chars() {
113 match c {
114 '\\' => out.push_str("\\\\"),
115 '"' => out.push_str("\\\""),
116 '\n' => out.push_str("\\n"),
117 '\t' => out.push_str("\\t"),
118 '\r' => out.push_str("\\r"),
119 c if (c as u32) < 0x20 || c == '\u{7F}' => {
120 out.push_str(&format!("\\u{:04X}", c as u32))
121 }
122 c => out.push(c),
123 }
124 }
125 out
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131 use crate::phase_id::PhaseId;
132
133 fn writable_roots_flag(args: &[String]) -> &str {
134 let idx = args
135 .iter()
136 .position(|a| a == "-c")
137 .expect("-c flag present");
138 &args[idx + 1]
139 }
140
141 /// 999.107 #2: a path containing a quote, backslash, and newline must
142 /// serialize to valid TOML — the newline becomes `\n`, the quote `\"`,
143 /// the backslash `\\` — never a raw control character that would corrupt
144 /// the `writable_roots` array value.
145 #[test]
146 fn codex_writable_roots_escape_hostile_paths() {
147 let roots = vec![PathBuf::from("/repo/a\"b\\c\nd")];
148 let (_, args) = CodexDriver.build_command(PhaseId::new(7), "prompt", &roots);
149 let flag = writable_roots_flag(&args);
150 assert!(
151 !flag.contains('\n'),
152 "raw newline must be escaped: {flag:?}"
153 );
154 assert!(flag.contains(r#"\n"#), "newline must be `\\n`: {flag:?}");
155 assert!(flag.contains(r#"\""#), "quote must be escaped: {flag:?}");
156 assert!(
157 flag.contains(r#"\\"#),
158 "backslash must be escaped: {flag:?}"
159 );
160 }
161
162 /// 999.107 #2 / review: DEL (U+007F) must be escaped as `\u007F`, not
163 /// emitted literally — TOML forbids a raw DEL in a basic string.
164 #[test]
165 fn codex_writable_roots_escape_del() {
166 let roots = vec![PathBuf::from("/repo/a\u{7F}b")];
167 let (_, args) = CodexDriver.build_command(PhaseId::new(7), "prompt", &roots);
168 let flag = writable_roots_flag(&args);
169 assert!(
170 !flag.contains('\u{7F}'),
171 "raw DEL must be escaped: {flag:?}"
172 );
173 assert!(
174 flag.contains(r#"\u007F"#),
175 "DEL must be `\\u007F`: {flag:?}"
176 );
177 }
178
179 /// 999.107 #2: a non-UTF-8 path cannot be serialized losslessly, so the
180 /// launch must refuse (panic) rather than emit a writable root that names
181 /// a different path.
182 #[cfg(unix)]
183 #[test]
184 #[should_panic(expected = "non-UTF-8 writable root")]
185 fn codex_writable_roots_refuses_non_utf8_paths() {
186 use std::os::unix::ffi::OsStringExt;
187 // 0xFF is not valid UTF-8.
188 let raw = std::ffi::OsString::from_vec(vec![b'/', b'r', b'e', b'p', b'o', 0xFF, b'x']);
189 let roots = vec![PathBuf::from(raw)];
190 let _ = CodexDriver.build_command(PhaseId::new(7), "prompt", &roots);
191 }
192}