netsky-core 0.1.7

netsky core: agent model, prompt loader, spawner, config
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
//! Codex runtime: build the `codex` CLI invocation for a resident agent.
//!
//! Codex (0.120.0+) has no `--append-system-prompt` / `--base-instructions-file`
//! flag. We pass the rendered netsky prompt as codex's positional `[PROMPT]`
//! arg instead, read at exec time from `$NETSKY_PROMPT_FILE` via the same
//! `$(cat ...)` shell trick the claude runtime uses to avoid tmux's
//! `command too long` argv limit.
//!
//! The rendered prompt includes the full base.md + per-agent identity
//! stanza + cwd addendum, so the Codex session receives the same
//! "you are agent<N>" shape Claude does. The separate sidecar path in
//! `codex_agent.rs` remains for one-off prompt / drain invocations; the
//! resident path lives here.
//!
//! Startup (`/up`) parity: codex's positional `[PROMPT]` arg is already
//! spoken for by the rendered netsky prompt — codex treats it as the
//! first user turn. The startup prompt therefore lands as a follow-on
//! user turn, pasted into the tmux pane via [`paste_startup`] once the
//! session is up. Claude passes startup inline as a second positional.

use std::path::Path;
use std::process::Command;
use std::time::Duration;

use crate::agent::AgentId;
use crate::consts::{ENV_NETSKY_PROMPT_FILE, NETSKY_BIN, TMUX_BIN};
use crate::error::{Error, Result};

use super::claude::shell_escape;

/// Per-agent codex-CLI configuration.
#[derive(Debug, Clone)]
pub struct CodexConfig {
    pub model: String,
    pub sandbox: String,
    pub approval: String,
}

impl CodexConfig {
    /// Defaults for a resident codex agent: `gpt-5.4`,
    /// `danger-full-access` sandbox, `never` approval policy. Matches
    /// the sidecar adapter's choices (codex_agent.rs) so swapping
    /// sidecar <-> resident for the same agent N produces comparable
    /// behavior. Overrides land via `AGENT_CODEX_MODEL`,
    /// `AGENT_CODEX_SANDBOX`, and `AGENT_CODEX_APPROVAL` env vars.
    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";
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, NETSKY_BIN]
}

/// Build the shell command string tmux will run inside the detached
/// session for a codex-resident agent. `codex -m <model> -s <sandbox>
/// -a <approval> --no-alt-screen "$(cat "$NETSKY_PROMPT_FILE")"`.
///
/// The mcp_config path is accepted for signature parity with the claude
/// runtime but ignored: codex reads MCP servers from its own
/// `~/.codex/config.toml`. The agent bus MCP source is configured
/// there with `env_vars = ["AGENT_N"]` so it inherits the agent
/// identity from the tmux session environment.
///
/// The `startup` arg is ignored HERE because codex's positional slot
/// is already spoken for by the rendered prompt. Startup (`/up`) lands
/// as a follow-on user turn via [`paste_startup`], invoked by the
/// runtime's `post_spawn` hook after the tmux session exists. See this
/// module's top-level doc for the rationale.
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));

    // Inline-mode TUI: preserves scrollback so `tmux capture-pane`
    // returns the full session content (alt-screen buffers would be
    // blank outside the alt screen). Critical for the smoke test +
    // post-mortem debugging.
    parts.push("--no-alt-screen".to_string());

    // Initial prompt read at exec time from the file the spawner wrote.
    // Same trick the claude path uses for --append-system-prompt.
    parts.push(format!("\"$(cat \"${ENV_NETSKY_PROMPT_FILE}\")\""));

    let command = parts.join(" ");
    let session = agent.name();
    let log = format!("/tmp/netsky-{session}-codex-outbox-forwarder.log");
    // The forwarder is started from the clone's tmux shell and also
    // self-exits when the matching tmux session disappears. That covers
    // both normal pane lifetime and forced `tmux kill-session`.
    format!(
        "{NETSKY_BIN} channel forward-outbox {} >{} 2>&1 & {command}",
        shell_escape(&session),
        shell_escape(&log)
    )
}

/// Minimal pane-IO abstraction used by [`paste_startup`]. A live impl
/// ([`TmuxPaneIo`]) shells out to `tmux`; tests provide a mock that
/// records calls and simulates capture output.
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>;
}

/// Live `PaneIo` that shells out to the `tmux` CLI. Used by the
/// production `post_spawn` hook.
pub struct TmuxPaneIo;

impl PaneIo for TmuxPaneIo {
    fn send_text(&self, session: &str, text: &str) -> Result<()> {
        // No `-l` (literal) flag: codex's TUI composer treats bracketed-paste
        // input differently from keystrokes and drops Enter-to-submit when
        // bracketed. Plain keystroke-by-keystroke matches the smoke test.
        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<()> {
        // Codex's composer treats C-m (Enter alone) as submit; Shift+Enter
        // as newline. We want submit.
        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;

/// Paste `startup` into a live codex tmux pane as a follow-on user
/// turn. Dismisses codex's per-cwd "Do you trust" dialog if present,
/// then retries send-text + send-Enter until `capture` echoes the text
/// or `attempts` is exhausted. Matches the retry pattern in
/// `bin/test-resident-codex` (the smoke test paste of `netsky channel drain`).
///
/// Used by [`post_spawn`] to close the resident-codex /up parity gap
/// (round-1 review B1): the rendered netsky prompt already occupies
/// codex's positional `[PROMPT]`, so startup must be delivered as a
/// second user turn rather than a CLI flag.
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(());
    }

    // Pre-check: codex shows a per-cwd "Do you trust" dialog on first
    // spawn. Send Enter to accept if present. The claude path handles
    // its own TOS dialog via `spawn::dismiss_tos`; codex's dialog is
    // handled here so the paste that follows lands on the composer, not
    // the trust prompt.
    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"
    )))
}

/// Runtime-dispatched post-spawn hook for codex: pastes startup into
/// the fresh tmux pane as a follow-on user turn. The caller
/// (`spawn::spawn`) invokes this immediately after
/// `tmux::new_session_detached` returns.
pub(super) fn post_spawn(session: &str, startup: &str) -> Result<()> {
    paste_startup(&TmuxPaneIo, session, startup, PASTE_ATTEMPTS)
}

// Real sleep in production; no-op in tests. Tests run the state
// machine without wall-clock delays.
#[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() {
        // Lock in against env bleed from the test runner's shell.
        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.contains("channel forward-outbox 'agent42'"));
        assert!(cmd.contains(" codex "), "unexpected codex command: {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}"
        );
    }

    // ----- paste_startup tests: mock PaneIo, no real tmux touched. -----

    use std::cell::RefCell;

    /// Mock PaneIo that appends every `send_text` call to a fake pane
    /// buffer, so the first capture after the first paste contains the
    /// pasted text (happy path). Events are recorded for sequence
    /// assertions.
    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();
        // First attempt should paste the text, then press Enter, then
        // the capture that follows echoes it back.
        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;
                // First capture: dialog up. Second+ captures: text
                // echoed (so the main paste loop can succeed).
                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();
        // First event must be an Enter (trust-dialog dismissal), before
        // any text paste.
        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()
        );
    }
}