use std::path::Path;
use std::process::Command;
use std::time::Duration;
use crate::agent::AgentId;
use crate::consts::{ENV_NETSKY_PROMPT_FILE, TMUX_BIN};
use crate::error::{Error, Result};
use super::claude::shell_escape;
#[derive(Debug, Clone)]
pub struct CodexConfig {
pub model: String,
pub sandbox: String,
pub approval: String,
}
impl CodexConfig {
pub fn defaults_for() -> Self {
let model = std::env::var(ENV_AGENT_CODEX_MODEL)
.unwrap_or_else(|_| DEFAULT_CODEX_MODEL.to_string());
let sandbox = std::env::var(ENV_AGENT_CODEX_SANDBOX)
.unwrap_or_else(|_| DEFAULT_CODEX_SANDBOX.to_string());
let approval = std::env::var(ENV_AGENT_CODEX_APPROVAL)
.unwrap_or_else(|_| DEFAULT_CODEX_APPROVAL.to_string());
Self {
model,
sandbox,
approval,
}
}
}
pub const CODEX_BIN: &str = "codex";
const DEFAULT_CODEX_MODEL: &str = "gpt-5.4-mini";
const DEFAULT_CODEX_SANDBOX: &str = "danger-full-access";
const DEFAULT_CODEX_APPROVAL: &str = "never";
const ENV_AGENT_CODEX_MODEL: &str = "AGENT_CODEX_MODEL";
const ENV_AGENT_CODEX_SANDBOX: &str = "AGENT_CODEX_SANDBOX";
const ENV_AGENT_CODEX_APPROVAL: &str = "AGENT_CODEX_APPROVAL";
pub(super) fn required_deps() -> Vec<&'static str> {
vec![CODEX_BIN, crate::consts::TMUX_BIN]
}
pub(super) fn build_command(
_agent: AgentId,
cfg: &CodexConfig,
_mcp_config: &Path,
_startup: &str,
) -> String {
let mut parts: Vec<String> = Vec::with_capacity(10);
parts.push(CODEX_BIN.to_string());
parts.push("-m".to_string());
parts.push(shell_escape(&cfg.model));
parts.push("-s".to_string());
parts.push(shell_escape(&cfg.sandbox));
parts.push("-a".to_string());
parts.push(shell_escape(&cfg.approval));
parts.push("--no-alt-screen".to_string());
parts.push(format!("\"$(cat \"${ENV_NETSKY_PROMPT_FILE}\")\""));
parts.join(" ")
}
pub trait PaneIo {
fn send_text(&self, session: &str, text: &str) -> Result<()>;
fn send_enter(&self, session: &str) -> Result<()>;
fn capture(&self, session: &str, lines: Option<usize>) -> Result<String>;
}
pub struct TmuxPaneIo;
impl PaneIo for TmuxPaneIo {
fn send_text(&self, session: &str, text: &str) -> Result<()> {
let status = Command::new(TMUX_BIN)
.args(["send-keys", "-t", session, text])
.status()?;
if !status.success() {
return Err(Error::Tmux(format!("send-keys text to '{session}' failed")));
}
Ok(())
}
fn send_enter(&self, session: &str) -> Result<()> {
let status = Command::new(TMUX_BIN)
.args(["send-keys", "-t", session, "C-m"])
.status()?;
if !status.success() {
return Err(Error::Tmux(format!("send-keys C-m to '{session}' failed")));
}
Ok(())
}
fn capture(&self, session: &str, lines: Option<usize>) -> Result<String> {
let start;
let mut args: Vec<&str> = vec!["capture-pane", "-t", session, "-p"];
if let Some(n) = lines {
start = format!("-{n}");
args.extend(["-S", &start]);
}
let out = Command::new(TMUX_BIN).args(&args).output()?;
if !out.status.success() {
return Err(Error::Tmux(format!("capture-pane '{session}' failed")));
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
}
const CODEX_TRUST_DIALOG_PROBE: &str = "Do you trust the contents";
const PASTE_ATTEMPTS: u32 = 6;
pub fn paste_startup<I: PaneIo>(io: &I, session: &str, startup: &str, attempts: u32) -> Result<()> {
let text = startup.trim().to_string();
if text.is_empty() {
return Ok(());
}
for _ in 0..3 {
let pane = io.capture(session, None).unwrap_or_default();
if pane.contains(CODEX_TRUST_DIALOG_PROBE) {
io.send_enter(session)?;
delay(Duration::from_secs(1));
} else {
break;
}
}
for _ in 0..attempts {
io.send_text(session, &text)?;
delay(Duration::from_millis(500));
io.send_enter(session)?;
delay(Duration::from_millis(1500));
let pane = io.capture(session, Some(2000)).unwrap_or_default();
if pane.contains(&text) {
return Ok(());
}
}
Err(Error::Tmux(format!(
"codex pane '{session}' never echoed startup prompt within {attempts} paste attempts"
)))
}
pub(super) fn post_spawn(session: &str, startup: &str) -> Result<()> {
paste_startup(&TmuxPaneIo, session, startup, PASTE_ATTEMPTS)
}
#[cfg(not(test))]
fn delay(d: Duration) {
std::thread::sleep(d);
}
#[cfg(test)]
fn delay(_d: Duration) {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_picks_documented_defaults() {
unsafe {
std::env::remove_var(ENV_AGENT_CODEX_MODEL);
std::env::remove_var(ENV_AGENT_CODEX_SANDBOX);
std::env::remove_var(ENV_AGENT_CODEX_APPROVAL);
}
let cfg = CodexConfig::defaults_for();
assert_eq!(cfg.model, DEFAULT_CODEX_MODEL);
assert_eq!(cfg.sandbox, DEFAULT_CODEX_SANDBOX);
assert_eq!(cfg.approval, DEFAULT_CODEX_APPROVAL);
}
#[test]
fn cmd_for_clone_invokes_codex_with_prompt_file_cat() {
let cfg = CodexConfig {
model: "gpt-5.4".to_string(),
sandbox: DEFAULT_CODEX_SANDBOX.to_string(),
approval: DEFAULT_CODEX_APPROVAL.to_string(),
};
let cmd = build_command(
AgentId::Clone(42),
&cfg,
Path::new("/tmp/mcp-config.json"),
"/up",
);
assert!(cmd.starts_with("codex "), "unexpected prefix: {cmd}");
assert!(cmd.contains("'gpt-5.4'"));
assert!(cmd.contains("-s 'danger-full-access'"));
assert!(cmd.contains("-a 'never'"));
assert!(cmd.contains("--no-alt-screen"));
assert!(
cmd.contains("$(cat \"$NETSKY_PROMPT_FILE\")"),
"cmd must read prompt from NETSKY_PROMPT_FILE: {cmd}"
);
}
#[test]
fn cmd_shell_escapes_injection_attempts() {
let cfg = CodexConfig {
model: "gpt;touch /tmp/pwned".to_string(),
sandbox: DEFAULT_CODEX_SANDBOX.to_string(),
approval: DEFAULT_CODEX_APPROVAL.to_string(),
};
let cmd = build_command(
AgentId::Clone(1),
&cfg,
Path::new("/tmp/mcp-config.json"),
"",
);
assert!(
cmd.contains("'gpt;touch /tmp/pwned'"),
"model not shell-escaped: {cmd}"
);
assert!(
!cmd.contains(" gpt;touch "),
"model leaked unescaped: {cmd}"
);
}
use std::cell::RefCell;
struct EchoingPaneIo {
events: RefCell<Vec<String>>,
pane: RefCell<String>,
}
impl EchoingPaneIo {
fn new() -> Self {
Self {
events: RefCell::new(Vec::new()),
pane: RefCell::new(String::new()),
}
}
}
impl PaneIo for EchoingPaneIo {
fn send_text(&self, session: &str, text: &str) -> Result<()> {
self.events
.borrow_mut()
.push(format!("text:{session}:{text}"));
self.pane.borrow_mut().push_str(text);
Ok(())
}
fn send_enter(&self, session: &str) -> Result<()> {
self.events.borrow_mut().push(format!("enter:{session}"));
Ok(())
}
fn capture(&self, session: &str, _lines: Option<usize>) -> Result<String> {
self.events.borrow_mut().push(format!("capture:{session}"));
Ok(self.pane.borrow().clone())
}
}
#[test]
fn paste_startup_sends_text_then_enter_and_returns_on_echo() {
let io = EchoingPaneIo::new();
paste_startup(&io, "agent998", "/up", 3).expect("paste should succeed");
let events = io.events.borrow();
let text_idx = events
.iter()
.position(|e| e == "text:agent998:/up")
.expect("text event missing");
let enter_idx = events
.iter()
.skip(text_idx)
.position(|e| e == "enter:agent998")
.expect("enter event after text missing");
assert!(enter_idx > 0, "enter must follow text: {events:?}");
}
#[test]
fn paste_startup_errors_when_pane_never_echoes() {
struct SilentPaneIo;
impl PaneIo for SilentPaneIo {
fn send_text(&self, _: &str, _: &str) -> Result<()> {
Ok(())
}
fn send_enter(&self, _: &str) -> Result<()> {
Ok(())
}
fn capture(&self, _: &str, _: Option<usize>) -> Result<String> {
Ok(String::new())
}
}
let err = paste_startup(&SilentPaneIo, "agent998", "/up", 2)
.expect_err("silent pane must yield an error");
match err {
Error::Tmux(msg) => {
assert!(msg.contains("never echoed"), "unexpected tmux error: {msg}")
}
other => panic!("expected Error::Tmux, got {other:?}"),
}
}
#[test]
fn paste_startup_dismisses_trust_dialog_before_pasting() {
struct TrustDialogIo {
captures_seen: RefCell<u32>,
events: RefCell<Vec<String>>,
}
impl PaneIo for TrustDialogIo {
fn send_text(&self, _: &str, text: &str) -> Result<()> {
self.events.borrow_mut().push(format!("text:{text}"));
Ok(())
}
fn send_enter(&self, _: &str) -> Result<()> {
self.events.borrow_mut().push("enter".to_string());
Ok(())
}
fn capture(&self, _: &str, _: Option<usize>) -> Result<String> {
let mut n = self.captures_seen.borrow_mut();
*n += 1;
if *n == 1 {
Ok("Do you trust the contents of this directory?".to_string())
} else {
Ok("> /up".to_string())
}
}
}
let io = TrustDialogIo {
captures_seen: RefCell::new(0),
events: RefCell::new(Vec::new()),
};
paste_startup(&io, "agent0", "/up", 3).expect("paste should succeed");
let events = io.events.borrow();
assert_eq!(
events.first().map(String::as_str),
Some("enter"),
"first event should dismiss trust dialog: {events:?}"
);
assert!(
events.iter().any(|e| e == "text:/up"),
"startup text was never sent: {events:?}"
);
}
#[test]
fn paste_startup_noop_on_empty_startup() {
let io = EchoingPaneIo::new();
paste_startup(&io, "agent998", "", 3).expect("empty startup should no-op");
assert!(
io.events.borrow().is_empty(),
"empty startup must touch no pane: {:?}",
io.events.borrow()
);
}
}