1use std::path::Path;
22use std::process::Command;
23use std::time::Duration;
24
25use crate::agent::AgentId;
26use crate::consts::{ENV_NETSKY_PROMPT_FILE, NETSKY_BIN, 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, NETSKY_BIN]
71}
72
73pub(super) fn build_command(
89 agent: AgentId,
90 cfg: &CodexConfig,
91 _mcp_config: &Path,
92 _startup: &str,
93) -> String {
94 let mut parts: Vec<String> = Vec::with_capacity(10);
95 parts.push(CODEX_BIN.to_string());
96
97 parts.push("-m".to_string());
98 parts.push(shell_escape(&cfg.model));
99
100 parts.push("-s".to_string());
101 parts.push(shell_escape(&cfg.sandbox));
102
103 parts.push("-a".to_string());
104 parts.push(shell_escape(&cfg.approval));
105
106 parts.push("--no-alt-screen".to_string());
111
112 parts.push(format!("\"$(cat \"${ENV_NETSKY_PROMPT_FILE}\")\""));
115
116 let command = parts.join(" ");
117 let session = agent.name();
118 let log = format!("/tmp/netsky-{session}-codex-outbox-forwarder.log");
119 format!(
123 "{NETSKY_BIN} channel forward-outbox {} >{} 2>&1 & {command}",
124 shell_escape(&session),
125 shell_escape(&log)
126 )
127}
128
129pub trait PaneIo {
133 fn send_text(&self, session: &str, text: &str) -> Result<()>;
134 fn send_enter(&self, session: &str) -> Result<()>;
135 fn capture(&self, session: &str, lines: Option<usize>) -> Result<String>;
136}
137
138pub struct TmuxPaneIo;
141
142impl PaneIo for TmuxPaneIo {
143 fn send_text(&self, session: &str, text: &str) -> Result<()> {
144 let status = Command::new(TMUX_BIN)
148 .args(["send-keys", "-t", session, text])
149 .status()?;
150 if !status.success() {
151 return Err(Error::Tmux(format!("send-keys text to '{session}' failed")));
152 }
153 Ok(())
154 }
155
156 fn send_enter(&self, session: &str) -> Result<()> {
157 let status = Command::new(TMUX_BIN)
160 .args(["send-keys", "-t", session, "C-m"])
161 .status()?;
162 if !status.success() {
163 return Err(Error::Tmux(format!("send-keys C-m to '{session}' failed")));
164 }
165 Ok(())
166 }
167
168 fn capture(&self, session: &str, lines: Option<usize>) -> Result<String> {
169 let start;
170 let mut args: Vec<&str> = vec!["capture-pane", "-t", session, "-p"];
171 if let Some(n) = lines {
172 start = format!("-{n}");
173 args.extend(["-S", &start]);
174 }
175 let out = Command::new(TMUX_BIN).args(&args).output()?;
176 if !out.status.success() {
177 return Err(Error::Tmux(format!("capture-pane '{session}' failed")));
178 }
179 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
180 }
181}
182
183const CODEX_TRUST_DIALOG_PROBE: &str = "Do you trust the contents";
184const PASTE_ATTEMPTS: u32 = 6;
185
186pub fn paste_startup<I: PaneIo>(io: &I, session: &str, startup: &str, attempts: u32) -> Result<()> {
197 let text = startup.trim().to_string();
198 if text.is_empty() {
199 return Ok(());
200 }
201
202 for _ in 0..3 {
208 let pane = io.capture(session, None).unwrap_or_default();
209 if pane.contains(CODEX_TRUST_DIALOG_PROBE) {
210 io.send_enter(session)?;
211 delay(Duration::from_secs(1));
212 } else {
213 break;
214 }
215 }
216
217 for _ in 0..attempts {
218 io.send_text(session, &text)?;
219 delay(Duration::from_millis(500));
220 io.send_enter(session)?;
221 delay(Duration::from_millis(1500));
222 let pane = io.capture(session, Some(2000)).unwrap_or_default();
223 if pane.contains(&text) {
224 return Ok(());
225 }
226 }
227 Err(Error::Tmux(format!(
228 "codex pane '{session}' never echoed startup prompt within {attempts} paste attempts"
229 )))
230}
231
232pub(super) fn post_spawn(session: &str, startup: &str) -> Result<()> {
237 paste_startup(&TmuxPaneIo, session, startup, PASTE_ATTEMPTS)
238}
239
240#[cfg(not(test))]
243fn delay(d: Duration) {
244 std::thread::sleep(d);
245}
246#[cfg(test)]
247fn delay(_d: Duration) {}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 #[test]
254 fn default_config_picks_documented_defaults() {
255 unsafe {
257 std::env::remove_var(ENV_AGENT_CODEX_MODEL);
258 std::env::remove_var(ENV_AGENT_CODEX_SANDBOX);
259 std::env::remove_var(ENV_AGENT_CODEX_APPROVAL);
260 }
261 let cfg = CodexConfig::defaults_for();
262 assert_eq!(cfg.model, DEFAULT_CODEX_MODEL);
263 assert_eq!(cfg.sandbox, DEFAULT_CODEX_SANDBOX);
264 assert_eq!(cfg.approval, DEFAULT_CODEX_APPROVAL);
265 }
266
267 #[test]
268 fn cmd_for_clone_invokes_codex_with_prompt_file_cat() {
269 let cfg = CodexConfig {
270 model: "gpt-5.4".to_string(),
271 sandbox: DEFAULT_CODEX_SANDBOX.to_string(),
272 approval: DEFAULT_CODEX_APPROVAL.to_string(),
273 };
274 let cmd = build_command(
275 AgentId::Clone(42),
276 &cfg,
277 Path::new("/tmp/mcp-config.json"),
278 "/up",
279 );
280 assert!(cmd.contains("channel forward-outbox 'agent42'"));
281 assert!(cmd.contains(" codex "), "unexpected codex command: {cmd}");
282 assert!(cmd.contains("'gpt-5.4'"));
283 assert!(cmd.contains("-s 'danger-full-access'"));
284 assert!(cmd.contains("-a 'never'"));
285 assert!(cmd.contains("--no-alt-screen"));
286 assert!(
287 cmd.contains("$(cat \"$NETSKY_PROMPT_FILE\")"),
288 "cmd must read prompt from NETSKY_PROMPT_FILE: {cmd}"
289 );
290 }
291
292 #[test]
293 fn cmd_shell_escapes_injection_attempts() {
294 let cfg = CodexConfig {
295 model: "gpt;touch /tmp/pwned".to_string(),
296 sandbox: DEFAULT_CODEX_SANDBOX.to_string(),
297 approval: DEFAULT_CODEX_APPROVAL.to_string(),
298 };
299 let cmd = build_command(
300 AgentId::Clone(1),
301 &cfg,
302 Path::new("/tmp/mcp-config.json"),
303 "",
304 );
305 assert!(
306 cmd.contains("'gpt;touch /tmp/pwned'"),
307 "model not shell-escaped: {cmd}"
308 );
309 assert!(
310 !cmd.contains(" gpt;touch "),
311 "model leaked unescaped: {cmd}"
312 );
313 }
314
315 use std::cell::RefCell;
318
319 struct EchoingPaneIo {
324 events: RefCell<Vec<String>>,
325 pane: RefCell<String>,
326 }
327
328 impl EchoingPaneIo {
329 fn new() -> Self {
330 Self {
331 events: RefCell::new(Vec::new()),
332 pane: RefCell::new(String::new()),
333 }
334 }
335 }
336
337 impl PaneIo for EchoingPaneIo {
338 fn send_text(&self, session: &str, text: &str) -> Result<()> {
339 self.events
340 .borrow_mut()
341 .push(format!("text:{session}:{text}"));
342 self.pane.borrow_mut().push_str(text);
343 Ok(())
344 }
345 fn send_enter(&self, session: &str) -> Result<()> {
346 self.events.borrow_mut().push(format!("enter:{session}"));
347 Ok(())
348 }
349 fn capture(&self, session: &str, _lines: Option<usize>) -> Result<String> {
350 self.events.borrow_mut().push(format!("capture:{session}"));
351 Ok(self.pane.borrow().clone())
352 }
353 }
354
355 #[test]
356 fn paste_startup_sends_text_then_enter_and_returns_on_echo() {
357 let io = EchoingPaneIo::new();
358 paste_startup(&io, "agent998", "/up", 3).expect("paste should succeed");
359 let events = io.events.borrow();
360 let text_idx = events
363 .iter()
364 .position(|e| e == "text:agent998:/up")
365 .expect("text event missing");
366 let enter_idx = events
367 .iter()
368 .skip(text_idx)
369 .position(|e| e == "enter:agent998")
370 .expect("enter event after text missing");
371 assert!(enter_idx > 0, "enter must follow text: {events:?}");
372 }
373
374 #[test]
375 fn paste_startup_errors_when_pane_never_echoes() {
376 struct SilentPaneIo;
377 impl PaneIo for SilentPaneIo {
378 fn send_text(&self, _: &str, _: &str) -> Result<()> {
379 Ok(())
380 }
381 fn send_enter(&self, _: &str) -> Result<()> {
382 Ok(())
383 }
384 fn capture(&self, _: &str, _: Option<usize>) -> Result<String> {
385 Ok(String::new())
386 }
387 }
388 let err = paste_startup(&SilentPaneIo, "agent998", "/up", 2)
389 .expect_err("silent pane must yield an error");
390 match err {
391 Error::Tmux(msg) => {
392 assert!(msg.contains("never echoed"), "unexpected tmux error: {msg}")
393 }
394 other => panic!("expected Error::Tmux, got {other:?}"),
395 }
396 }
397
398 #[test]
399 fn paste_startup_dismisses_trust_dialog_before_pasting() {
400 struct TrustDialogIo {
401 captures_seen: RefCell<u32>,
402 events: RefCell<Vec<String>>,
403 }
404 impl PaneIo for TrustDialogIo {
405 fn send_text(&self, _: &str, text: &str) -> Result<()> {
406 self.events.borrow_mut().push(format!("text:{text}"));
407 Ok(())
408 }
409 fn send_enter(&self, _: &str) -> Result<()> {
410 self.events.borrow_mut().push("enter".to_string());
411 Ok(())
412 }
413 fn capture(&self, _: &str, _: Option<usize>) -> Result<String> {
414 let mut n = self.captures_seen.borrow_mut();
415 *n += 1;
416 if *n == 1 {
419 Ok("Do you trust the contents of this directory?".to_string())
420 } else {
421 Ok("> /up".to_string())
422 }
423 }
424 }
425 let io = TrustDialogIo {
426 captures_seen: RefCell::new(0),
427 events: RefCell::new(Vec::new()),
428 };
429 paste_startup(&io, "agent0", "/up", 3).expect("paste should succeed");
430 let events = io.events.borrow();
431 assert_eq!(
434 events.first().map(String::as_str),
435 Some("enter"),
436 "first event should dismiss trust dialog: {events:?}"
437 );
438 assert!(
439 events.iter().any(|e| e == "text:/up"),
440 "startup text was never sent: {events:?}"
441 );
442 }
443
444 #[test]
445 fn paste_startup_noop_on_empty_startup() {
446 let io = EchoingPaneIo::new();
447 paste_startup(&io, "agent998", "", 3).expect("empty startup should no-op");
448 assert!(
449 io.events.borrow().is_empty(),
450 "empty startup must touch no pane: {:?}",
451 io.events.borrow()
452 );
453 }
454}