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, NETSKY_BIN, 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, NETSKY_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 reads MCP servers from its own
79/// `~/.codex/config.toml`. The agent bus MCP source is configured
80/// there with `env_vars = ["AGENT_N"]` so it inherits the agent
81/// identity from the tmux session environment.
82///
83/// The `startup` arg is ignored HERE because codex's positional slot
84/// is already spoken for by the rendered prompt. Startup (`/up`) lands
85/// as a follow-on user turn via [`paste_startup`], invoked by the
86/// runtime's `post_spawn` hook after the tmux session exists. See this
87/// module's top-level doc for the rationale.
88pub(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    // Inline-mode TUI: preserves scrollback so `tmux capture-pane`
107    // returns the full session content (alt-screen buffers would be
108    // blank outside the alt screen). Critical for the smoke test +
109    // post-mortem debugging.
110    parts.push("--no-alt-screen".to_string());
111
112    // Initial prompt read at exec time from the file the spawner wrote.
113    // Same trick the claude path uses for --append-system-prompt.
114    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    // The forwarder is started from the clone's tmux shell and also
120    // self-exits when the matching tmux session disappears. That covers
121    // both normal pane lifetime and forced `tmux kill-session`.
122    format!(
123        "{NETSKY_BIN} channel forward-outbox {} >{} 2>&1 & {command}",
124        shell_escape(&session),
125        shell_escape(&log)
126    )
127}
128
129/// Minimal pane-IO abstraction used by [`paste_startup`]. A live impl
130/// ([`TmuxPaneIo`]) shells out to `tmux`; tests provide a mock that
131/// records calls and simulates capture output.
132pub 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
138/// Live `PaneIo` that shells out to the `tmux` CLI. Used by the
139/// production `post_spawn` hook.
140pub struct TmuxPaneIo;
141
142impl PaneIo for TmuxPaneIo {
143    fn send_text(&self, session: &str, text: &str) -> Result<()> {
144        // No `-l` (literal) flag: codex's TUI composer treats bracketed-paste
145        // input differently from keystrokes and drops Enter-to-submit when
146        // bracketed. Plain keystroke-by-keystroke matches the smoke test.
147        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        // Codex's composer treats C-m (Enter alone) as submit; Shift+Enter
158        // as newline. We want submit.
159        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
186/// Paste `startup` into a live codex tmux pane as a follow-on user
187/// turn. Dismisses codex's per-cwd "Do you trust" dialog if present,
188/// then retries send-text + send-Enter until `capture` echoes the text
189/// or `attempts` is exhausted. Matches the retry pattern in
190/// `bin/test-resident-codex` (the smoke test paste of `netsky channel drain`).
191///
192/// Used by [`post_spawn`] to close the resident-codex /up parity gap
193/// (round-1 review B1): the rendered netsky prompt already occupies
194/// codex's positional `[PROMPT]`, so startup must be delivered as a
195/// second user turn rather than a CLI flag.
196pub 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    // Pre-check: codex shows a per-cwd "Do you trust" dialog on first
203    // spawn. Send Enter to accept if present. The claude path handles
204    // its own TOS dialog via `spawn::dismiss_tos`; codex's dialog is
205    // handled here so the paste that follows lands on the composer, not
206    // the trust prompt.
207    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
232/// Runtime-dispatched post-spawn hook for codex: pastes startup into
233/// the fresh tmux pane as a follow-on user turn. The caller
234/// (`spawn::spawn`) invokes this immediately after
235/// `tmux::new_session_detached` returns.
236pub(super) fn post_spawn(session: &str, startup: &str) -> Result<()> {
237    paste_startup(&TmuxPaneIo, session, startup, PASTE_ATTEMPTS)
238}
239
240// Real sleep in production; no-op in tests. Tests run the state
241// machine without wall-clock delays.
242#[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        // Lock in against env bleed from the test runner's shell.
256        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    // ----- paste_startup tests: mock PaneIo, no real tmux touched. -----
316
317    use std::cell::RefCell;
318
319    /// Mock PaneIo that appends every `send_text` call to a fake pane
320    /// buffer, so the first capture after the first paste contains the
321    /// pasted text (happy path). Events are recorded for sequence
322    /// assertions.
323    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        // First attempt should paste the text, then press Enter, then
361        // the capture that follows echoes it back.
362        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                // First capture: dialog up. Second+ captures: text
417                // echoed (so the main paste loop can succeed).
418                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        // First event must be an Enter (trust-dialog dismissal), before
432        // any text paste.
433        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}