1use std::path::Path;
22use std::process::Command;
23use std::time::Duration;
24
25use crate::agent::AgentId;
26use crate::consts::{ENV_NETSKY_PROMPT_FILE, TMUX_BIN};
27use crate::error::{Error, Result};
28
29use super::claude::shell_escape;
30
31#[derive(Debug, Clone)]
33pub struct CodexConfig {
34 pub model: String,
35 pub sandbox: String,
36 pub approval: String,
37}
38
39impl CodexConfig {
40 pub fn defaults_for() -> Self {
47 let model = std::env::var(ENV_AGENT_CODEX_MODEL)
48 .unwrap_or_else(|_| DEFAULT_CODEX_MODEL.to_string());
49 let sandbox = std::env::var(ENV_AGENT_CODEX_SANDBOX)
50 .unwrap_or_else(|_| DEFAULT_CODEX_SANDBOX.to_string());
51 let approval = std::env::var(ENV_AGENT_CODEX_APPROVAL)
52 .unwrap_or_else(|_| DEFAULT_CODEX_APPROVAL.to_string());
53 Self {
54 model,
55 sandbox,
56 approval,
57 }
58 }
59}
60
61pub const CODEX_BIN: &str = "codex";
62const DEFAULT_CODEX_MODEL: &str = "gpt-5.4";
63const DEFAULT_CODEX_SANDBOX: &str = "danger-full-access";
64const DEFAULT_CODEX_APPROVAL: &str = "never";
65const ENV_AGENT_CODEX_MODEL: &str = "AGENT_CODEX_MODEL";
66const ENV_AGENT_CODEX_SANDBOX: &str = "AGENT_CODEX_SANDBOX";
67const ENV_AGENT_CODEX_APPROVAL: &str = "AGENT_CODEX_APPROVAL";
68
69pub(super) fn required_deps() -> Vec<&'static str> {
70 vec![CODEX_BIN, crate::consts::TMUX_BIN]
71}
72
73pub(super) fn build_command(
90 _agent: AgentId,
91 cfg: &CodexConfig,
92 _mcp_config: &Path,
93 _startup: &str,
94) -> String {
95 let mut parts: Vec<String> = Vec::with_capacity(10);
96 parts.push(CODEX_BIN.to_string());
97
98 parts.push("-m".to_string());
99 parts.push(shell_escape(&cfg.model));
100
101 parts.push("-s".to_string());
102 parts.push(shell_escape(&cfg.sandbox));
103
104 parts.push("-a".to_string());
105 parts.push(shell_escape(&cfg.approval));
106
107 parts.push("--no-alt-screen".to_string());
112
113 parts.push(format!("\"$(cat \"${ENV_NETSKY_PROMPT_FILE}\")\""));
116
117 parts.join(" ")
118}
119
120pub trait PaneIo {
124 fn send_text(&self, session: &str, text: &str) -> Result<()>;
125 fn send_enter(&self, session: &str) -> Result<()>;
126 fn capture(&self, session: &str, lines: Option<usize>) -> Result<String>;
127}
128
129pub struct TmuxPaneIo;
132
133impl PaneIo for TmuxPaneIo {
134 fn send_text(&self, session: &str, text: &str) -> Result<()> {
135 let status = Command::new(TMUX_BIN)
139 .args(["send-keys", "-t", session, text])
140 .status()?;
141 if !status.success() {
142 return Err(Error::Tmux(format!("send-keys text to '{session}' failed")));
143 }
144 Ok(())
145 }
146
147 fn send_enter(&self, session: &str) -> Result<()> {
148 let status = Command::new(TMUX_BIN)
151 .args(["send-keys", "-t", session, "C-m"])
152 .status()?;
153 if !status.success() {
154 return Err(Error::Tmux(format!("send-keys C-m to '{session}' failed")));
155 }
156 Ok(())
157 }
158
159 fn capture(&self, session: &str, lines: Option<usize>) -> Result<String> {
160 let start;
161 let mut args: Vec<&str> = vec!["capture-pane", "-t", session, "-p"];
162 if let Some(n) = lines {
163 start = format!("-{n}");
164 args.extend(["-S", &start]);
165 }
166 let out = Command::new(TMUX_BIN).args(&args).output()?;
167 if !out.status.success() {
168 return Err(Error::Tmux(format!("capture-pane '{session}' failed")));
169 }
170 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
171 }
172}
173
174const CODEX_TRUST_DIALOG_PROBE: &str = "Do you trust the contents";
175const PASTE_ATTEMPTS: u32 = 6;
176
177pub fn paste_startup<I: PaneIo>(io: &I, session: &str, startup: &str, attempts: u32) -> Result<()> {
188 let text = startup.trim().to_string();
189 if text.is_empty() {
190 return Ok(());
191 }
192
193 for _ in 0..3 {
199 let pane = io.capture(session, None).unwrap_or_default();
200 if pane.contains(CODEX_TRUST_DIALOG_PROBE) {
201 io.send_enter(session)?;
202 delay(Duration::from_secs(1));
203 } else {
204 break;
205 }
206 }
207
208 for _ in 0..attempts {
209 io.send_text(session, &text)?;
210 delay(Duration::from_millis(500));
211 io.send_enter(session)?;
212 delay(Duration::from_millis(1500));
213 let pane = io.capture(session, Some(2000)).unwrap_or_default();
214 if pane.contains(&text) {
215 return Ok(());
216 }
217 }
218 Err(Error::Tmux(format!(
219 "codex pane '{session}' never echoed startup prompt within {attempts} paste attempts"
220 )))
221}
222
223pub(super) fn post_spawn(session: &str, startup: &str) -> Result<()> {
228 paste_startup(&TmuxPaneIo, session, startup, PASTE_ATTEMPTS)
229}
230
231#[cfg(not(test))]
234fn delay(d: Duration) {
235 std::thread::sleep(d);
236}
237#[cfg(test)]
238fn delay(_d: Duration) {}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 #[test]
245 fn default_config_picks_documented_defaults() {
246 unsafe {
248 std::env::remove_var(ENV_AGENT_CODEX_MODEL);
249 std::env::remove_var(ENV_AGENT_CODEX_SANDBOX);
250 std::env::remove_var(ENV_AGENT_CODEX_APPROVAL);
251 }
252 let cfg = CodexConfig::defaults_for();
253 assert_eq!(cfg.model, DEFAULT_CODEX_MODEL);
254 assert_eq!(cfg.sandbox, DEFAULT_CODEX_SANDBOX);
255 assert_eq!(cfg.approval, DEFAULT_CODEX_APPROVAL);
256 }
257
258 #[test]
259 fn cmd_for_clone_invokes_codex_with_prompt_file_cat() {
260 let cfg = CodexConfig {
261 model: "gpt-5.4".to_string(),
262 sandbox: DEFAULT_CODEX_SANDBOX.to_string(),
263 approval: DEFAULT_CODEX_APPROVAL.to_string(),
264 };
265 let cmd = build_command(
266 AgentId::Clone(42),
267 &cfg,
268 Path::new("/tmp/mcp-config.json"),
269 "/up",
270 );
271 assert!(cmd.starts_with("codex "), "unexpected prefix: {cmd}");
272 assert!(cmd.contains("'gpt-5.4'"));
273 assert!(cmd.contains("-s 'danger-full-access'"));
274 assert!(cmd.contains("-a 'never'"));
275 assert!(cmd.contains("--no-alt-screen"));
276 assert!(
277 cmd.contains("$(cat \"$NETSKY_PROMPT_FILE\")"),
278 "cmd must read prompt from NETSKY_PROMPT_FILE: {cmd}"
279 );
280 }
281
282 #[test]
283 fn cmd_shell_escapes_injection_attempts() {
284 let cfg = CodexConfig {
285 model: "gpt;touch /tmp/pwned".to_string(),
286 sandbox: DEFAULT_CODEX_SANDBOX.to_string(),
287 approval: DEFAULT_CODEX_APPROVAL.to_string(),
288 };
289 let cmd = build_command(
290 AgentId::Clone(1),
291 &cfg,
292 Path::new("/tmp/mcp-config.json"),
293 "",
294 );
295 assert!(
296 cmd.contains("'gpt;touch /tmp/pwned'"),
297 "model not shell-escaped: {cmd}"
298 );
299 assert!(
300 !cmd.contains(" gpt;touch "),
301 "model leaked unescaped: {cmd}"
302 );
303 }
304
305 use std::cell::RefCell;
308
309 struct EchoingPaneIo {
314 events: RefCell<Vec<String>>,
315 pane: RefCell<String>,
316 }
317
318 impl EchoingPaneIo {
319 fn new() -> Self {
320 Self {
321 events: RefCell::new(Vec::new()),
322 pane: RefCell::new(String::new()),
323 }
324 }
325 }
326
327 impl PaneIo for EchoingPaneIo {
328 fn send_text(&self, session: &str, text: &str) -> Result<()> {
329 self.events
330 .borrow_mut()
331 .push(format!("text:{session}:{text}"));
332 self.pane.borrow_mut().push_str(text);
333 Ok(())
334 }
335 fn send_enter(&self, session: &str) -> Result<()> {
336 self.events.borrow_mut().push(format!("enter:{session}"));
337 Ok(())
338 }
339 fn capture(&self, session: &str, _lines: Option<usize>) -> Result<String> {
340 self.events.borrow_mut().push(format!("capture:{session}"));
341 Ok(self.pane.borrow().clone())
342 }
343 }
344
345 #[test]
346 fn paste_startup_sends_text_then_enter_and_returns_on_echo() {
347 let io = EchoingPaneIo::new();
348 paste_startup(&io, "agent998", "/up", 3).expect("paste should succeed");
349 let events = io.events.borrow();
350 let text_idx = events
353 .iter()
354 .position(|e| e == "text:agent998:/up")
355 .expect("text event missing");
356 let enter_idx = events
357 .iter()
358 .skip(text_idx)
359 .position(|e| e == "enter:agent998")
360 .expect("enter event after text missing");
361 assert!(enter_idx > 0, "enter must follow text: {events:?}");
362 }
363
364 #[test]
365 fn paste_startup_errors_when_pane_never_echoes() {
366 struct SilentPaneIo;
367 impl PaneIo for SilentPaneIo {
368 fn send_text(&self, _: &str, _: &str) -> Result<()> {
369 Ok(())
370 }
371 fn send_enter(&self, _: &str) -> Result<()> {
372 Ok(())
373 }
374 fn capture(&self, _: &str, _: Option<usize>) -> Result<String> {
375 Ok(String::new())
376 }
377 }
378 let err = paste_startup(&SilentPaneIo, "agent998", "/up", 2)
379 .expect_err("silent pane must yield an error");
380 match err {
381 Error::Tmux(msg) => {
382 assert!(msg.contains("never echoed"), "unexpected tmux error: {msg}")
383 }
384 other => panic!("expected Error::Tmux, got {other:?}"),
385 }
386 }
387
388 #[test]
389 fn paste_startup_dismisses_trust_dialog_before_pasting() {
390 struct TrustDialogIo {
391 captures_seen: RefCell<u32>,
392 events: RefCell<Vec<String>>,
393 }
394 impl PaneIo for TrustDialogIo {
395 fn send_text(&self, _: &str, text: &str) -> Result<()> {
396 self.events.borrow_mut().push(format!("text:{text}"));
397 Ok(())
398 }
399 fn send_enter(&self, _: &str) -> Result<()> {
400 self.events.borrow_mut().push("enter".to_string());
401 Ok(())
402 }
403 fn capture(&self, _: &str, _: Option<usize>) -> Result<String> {
404 let mut n = self.captures_seen.borrow_mut();
405 *n += 1;
406 if *n == 1 {
409 Ok("Do you trust the contents of this directory?".to_string())
410 } else {
411 Ok("> /up".to_string())
412 }
413 }
414 }
415 let io = TrustDialogIo {
416 captures_seen: RefCell::new(0),
417 events: RefCell::new(Vec::new()),
418 };
419 paste_startup(&io, "agent0", "/up", 3).expect("paste should succeed");
420 let events = io.events.borrow();
421 assert_eq!(
424 events.first().map(String::as_str),
425 Some("enter"),
426 "first event should dismiss trust dialog: {events:?}"
427 );
428 assert!(
429 events.iter().any(|e| e == "text:/up"),
430 "startup text was never sent: {events:?}"
431 );
432 }
433
434 #[test]
435 fn paste_startup_noop_on_empty_startup() {
436 let io = EchoingPaneIo::new();
437 paste_startup(&io, "agent998", "", 3).expect("empty startup should no-op");
438 assert!(
439 io.events.borrow().is_empty(),
440 "empty startup must touch no pane: {:?}",
441 io.events.borrow()
442 );
443 }
444}