Skip to main content

netsky_core/runtime/
codex.rs

1//! Codex runtime: build the `codex` CLI invocation for a resident agent.
2//!
3//! Codex (0.120.0+) has no `--append-system-prompt` / `--base-instructions-file`
4//! flag. We pass the rendered netsky prompt as codex's positional `[PROMPT]`
5//! arg instead, read at exec time from `$NETSKY_PROMPT_FILE` via the same
6//! `$(cat ...)` shell trick the claude runtime uses to avoid tmux's
7//! `command too long` argv limit.
8//!
9//! The rendered prompt includes the full base.md + per-agent identity
10//! stanza + cwd addendum, so the Codex session receives the same
11//! "you are agent<N>" shape Claude does. The separate sidecar path in
12//! `codex_agent.rs` remains for one-off prompt / drain invocations; the
13//! resident path lives here.
14//!
15//! Startup (`/up`) parity: codex's positional `[PROMPT]` arg is already
16//! spoken for by the rendered netsky prompt — codex treats it as the
17//! first user turn. The startup prompt therefore lands as a follow-on
18//! user turn, pasted into the tmux pane via [`paste_startup`] once the
19//! session is up. Claude passes startup inline as a second positional.
20
21use 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/// Per-agent codex-CLI configuration.
32#[derive(Debug, Clone)]
33pub struct CodexConfig {
34    pub model: String,
35    pub sandbox: String,
36    pub approval: String,
37}
38
39impl CodexConfig {
40    /// Defaults for a resident codex agent: `gpt-5.4`,
41    /// `danger-full-access` sandbox, `never` approval policy. Matches
42    /// the sidecar adapter's choices (codex_agent.rs) so swapping
43    /// sidecar <-> resident for the same agent N produces comparable
44    /// behavior. Overrides land via `AGENT_CODEX_MODEL`,
45    /// `AGENT_CODEX_SANDBOX`, and `AGENT_CODEX_APPROVAL` env vars.
46    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
73/// Build the shell command string tmux will run inside the detached
74/// session for a codex-resident agent. `codex -m <model> -s <sandbox>
75/// -a <approval> --no-alt-screen "$(cat "$NETSKY_PROMPT_FILE")"`.
76///
77/// The mcp_config path is accepted for signature parity with the claude
78/// runtime but ignored: codex has its own `~/.codex/config.toml` + `-c`
79/// override surface and the netsky agent-bus MCP source is not yet
80/// wired through the codex MCP client (tracked in
81/// `briefs/codex-side-by-side.md`). Resident codex reaches the bus via
82/// `netsky channel drain|send` shell-outs instead.
83///
84/// The `startup` arg is ignored HERE because codex's positional slot
85/// is already spoken for by the rendered prompt. Startup (`/up`) lands
86/// as a follow-on user turn via [`paste_startup`], invoked by the
87/// runtime's `post_spawn` hook after the tmux session exists. See this
88/// module's top-level doc for the rationale.
89pub(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    // Inline-mode TUI: preserves scrollback so `tmux capture-pane`
108    // returns the full session content (alt-screen buffers would be
109    // blank outside the alt screen). Critical for the smoke test +
110    // post-mortem debugging.
111    parts.push("--no-alt-screen".to_string());
112
113    // Initial prompt read at exec time from the file the spawner wrote.
114    // Same trick the claude path uses for --append-system-prompt.
115    parts.push(format!("\"$(cat \"${ENV_NETSKY_PROMPT_FILE}\")\""));
116
117    parts.join(" ")
118}
119
120/// Minimal pane-IO abstraction used by [`paste_startup`]. A live impl
121/// ([`TmuxPaneIo`]) shells out to `tmux`; tests provide a mock that
122/// records calls and simulates capture output.
123pub 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
129/// Live `PaneIo` that shells out to the `tmux` CLI. Used by the
130/// production `post_spawn` hook.
131pub struct TmuxPaneIo;
132
133impl PaneIo for TmuxPaneIo {
134    fn send_text(&self, session: &str, text: &str) -> Result<()> {
135        // No `-l` (literal) flag: codex's TUI composer treats bracketed-paste
136        // input differently from keystrokes and drops Enter-to-submit when
137        // bracketed. Plain keystroke-by-keystroke matches the smoke test.
138        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        // Codex's composer treats C-m (Enter alone) as submit; Shift+Enter
149        // as newline. We want submit.
150        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
177/// Paste `startup` into a live codex tmux pane as a follow-on user
178/// turn. Dismisses codex's per-cwd "Do you trust" dialog if present,
179/// then retries send-text + send-Enter until `capture` echoes the text
180/// or `attempts` is exhausted. Matches the retry pattern in
181/// `bin/test-resident-codex` (the smoke test paste of `netsky channel drain`).
182///
183/// Used by [`post_spawn`] to close the resident-codex /up parity gap
184/// (round-1 review B1): the rendered netsky prompt already occupies
185/// codex's positional `[PROMPT]`, so startup must be delivered as a
186/// second user turn rather than a CLI flag.
187pub 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    // Pre-check: codex shows a per-cwd "Do you trust" dialog on first
194    // spawn. Send Enter to accept if present. The claude path handles
195    // its own TOS dialog via `spawn::dismiss_tos`; codex's dialog is
196    // handled here so the paste that follows lands on the composer, not
197    // the trust prompt.
198    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
223/// Runtime-dispatched post-spawn hook for codex: pastes startup into
224/// the fresh tmux pane as a follow-on user turn. The caller
225/// (`spawn::spawn`) invokes this immediately after
226/// `tmux::new_session_detached` returns.
227pub(super) fn post_spawn(session: &str, startup: &str) -> Result<()> {
228    paste_startup(&TmuxPaneIo, session, startup, PASTE_ATTEMPTS)
229}
230
231// Real sleep in production; no-op in tests. Tests run the state
232// machine without wall-clock delays.
233#[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        // Lock in against env bleed from the test runner's shell.
247        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    // ----- paste_startup tests: mock PaneIo, no real tmux touched. -----
306
307    use std::cell::RefCell;
308
309    /// Mock PaneIo that appends every `send_text` call to a fake pane
310    /// buffer, so the first capture after the first paste contains the
311    /// pasted text (happy path). Events are recorded for sequence
312    /// assertions.
313    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        // First attempt should paste the text, then press Enter, then
351        // the capture that follows echoes it back.
352        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                // First capture: dialog up. Second+ captures: text
407                // echoed (so the main paste loop can succeed).
408                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        // First event must be an Enter (trust-dialog dismissal), before
422        // any text paste.
423        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}