From f59dcafb49dc06cc9b6566539be3fea794e3b5e1 Mon Sep 17 00:00:00 2001 From: Ruslan Nigmatullin Date: Thu, 5 Mar 2026 21:43:09 -0800 Subject: [PATCH 1/6] utils/pty: add streaming spawn and terminal sizing primitives Split stdout and stderr routing into reusable output sinks, expose explicit streaming spawn helpers, and thread terminal sizing through the shared PTY layer. This keeps the legacy spawn helpers intact so existing callers only need mechanical call-site updates while app-server gains the lower-level primitives it needs for interactive command execution. --- .../core/src/unified_exec/process_manager.rs | 1 + .../tui/tests/suite/model_availability_nux.rs | 1 + .../tui/tests/suite/no_panic_on_startup.rs | 1 + codex-rs/utils/pty/README.md | 12 +- codex-rs/utils/pty/src/lib.rs | 8 + codex-rs/utils/pty/src/pipe.rs | 61 +++-- codex-rs/utils/pty/src/process.rs | 211 +++++++++++++++--- codex-rs/utils/pty/src/pty.rs | 49 ++-- codex-rs/utils/pty/src/tests.rs | 96 +++++++- 9 files changed, 369 insertions(+), 71 deletions(-) diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 4ba125009e1..770379afa42 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -543,6 +543,7 @@ impl UnifiedExecProcessManager { env.cwd.as_path(), &env.env, &env.arg0, + codex_utils_pty::TerminalSize::default(), ) .await } else { diff --git a/codex-rs/tui/tests/suite/model_availability_nux.rs b/codex-rs/tui/tests/suite/model_availability_nux.rs index a76ef02bbc0..647a4f1a2f3 100644 --- a/codex-rs/tui/tests/suite/model_availability_nux.rs +++ b/codex-rs/tui/tests/suite/model_availability_nux.rs @@ -124,6 +124,7 @@ trust_level = "trusted" &repo_root, &env, &None, + codex_utils_pty::TerminalSize::default(), ) .await?; diff --git a/codex-rs/tui/tests/suite/no_panic_on_startup.rs b/codex-rs/tui/tests/suite/no_panic_on_startup.rs index 3984e771df1..a02b0435108 100644 --- a/codex-rs/tui/tests/suite/no_panic_on_startup.rs +++ b/codex-rs/tui/tests/suite/no_panic_on_startup.rs @@ -71,6 +71,7 @@ async fn run_codex_cli( cwd.as_ref(), &env, &None, + codex_utils_pty::TerminalSize::default(), ) .await?; let mut output = Vec::new(); diff --git a/codex-rs/utils/pty/README.md b/codex-rs/utils/pty/README.md index d0f77268a21..2e4035f588f 100644 --- a/codex-rs/utils/pty/README.md +++ b/codex-rs/utils/pty/README.md @@ -4,15 +4,21 @@ Lightweight helpers for spawning interactive processes either under a PTY (pseud ## API surface -- `spawn_pty_process(program, args, cwd, env, arg0)` → `SpawnedProcess` +- `spawn_pty_process(program, args, cwd, env, arg0, size)` → `SpawnedProcess` - `spawn_pipe_process(program, args, cwd, env, arg0)` → `SpawnedProcess` - `spawn_pipe_process_no_stdin(program, args, cwd, env, arg0)` → `SpawnedProcess` +- `pty::spawn_streaming_process(program, args, cwd, env, arg0, size, output_sink)` → `SpawnedStreamingProcess` +- `pipe::spawn_streaming_process(program, args, cwd, env, arg0, stdin_mode, output_sink)` → `SpawnedStreamingProcess` - `conpty_supported()` → `bool` (Windows only; always true elsewhere) +- `TerminalSize { rows, cols }` selects PTY dimensions in character cells. - `ProcessHandle` exposes: - `writer_sender()` → `mpsc::Sender>` (stdin) - `output_receiver()` → `broadcast::Receiver>` (stdout/stderr merged) + - `resize(TerminalSize)` + - `close_stdin()` - `has_exited()`, `exit_code()`, `terminate()` -- `SpawnedProcess` bundles `handle`, `output_rx`, and `exit_rx` (oneshot exit code). +- `SpawnedProcess` bundles `session`, `output_rx`, and `exit_rx` (oneshot exit code). +- `SpawnedStreamingProcess` bundles `session` and `exit_rx`; callers own the output sink/receivers. ## Usage examples @@ -20,6 +26,7 @@ Lightweight helpers for spawning interactive processes either under a PTY (pseud use std::collections::HashMap; use std::path::Path; use codex_utils_pty::spawn_pty_process; +use codex_utils_pty::TerminalSize; # tokio_test::block_on(async { let env_map: HashMap = std::env::vars().collect(); @@ -29,6 +36,7 @@ let spawned = spawn_pty_process( Path::new("."), &env_map, &None, + TerminalSize::default(), ).await?; let writer = spawned.session.writer_sender(); diff --git a/codex-rs/utils/pty/src/lib.rs b/codex-rs/utils/pty/src/lib.rs index 590770e2ae0..9cd689f2e30 100644 --- a/codex-rs/utils/pty/src/lib.rs +++ b/codex-rs/utils/pty/src/lib.rs @@ -7,14 +7,22 @@ mod tests; #[cfg(windows)] mod win; +pub const DEFAULT_OUTPUT_BYTES_CAP: usize = 1024 * 1024; + /// Spawn a non-interactive process using regular pipes for stdin/stdout/stderr. pub use pipe::spawn_process as spawn_pipe_process; /// Spawn a non-interactive process using regular pipes, but close stdin immediately. pub use pipe::spawn_process_no_stdin as spawn_pipe_process_no_stdin; +/// Output routing configuration for spawned processes. +pub use process::OutputSink; /// Handle for interacting with a spawned process (PTY or pipe). pub use process::ProcessHandle; /// Bundle of process handles plus output and exit receivers returned by spawn helpers. pub use process::SpawnedProcess; +/// Bundle of process handles and exit receiver returned by streaming spawn helpers. +pub use process::SpawnedStreamingProcess; +/// Terminal size in character cells used for PTY spawn and resize operations. +pub use process::TerminalSize; /// Backwards-compatible alias for ProcessHandle. pub type ExecCommandSession = ProcessHandle; /// Backwards-compatible alias for SpawnedProcess. diff --git a/codex-rs/utils/pty/src/pipe.rs b/codex-rs/utils/pty/src/pipe.rs index d77c11383cf..a7dbc92ec40 100644 --- a/codex-rs/utils/pty/src/pipe.rs +++ b/codex-rs/utils/pty/src/pipe.rs @@ -13,14 +13,16 @@ use tokio::io::AsyncReadExt; use tokio::io::AsyncWriteExt; use tokio::io::BufReader; use tokio::process::Command; -use tokio::sync::broadcast; use tokio::sync::mpsc; use tokio::sync::oneshot; use tokio::task::JoinHandle; use crate::process::ChildTerminator; +use crate::process::OutputSink; +use crate::process::OutputStream; use crate::process::ProcessHandle; use crate::process::SpawnedProcess; +use crate::process::SpawnedStreamingProcess; #[cfg(target_os = "linux")] use libc; @@ -73,7 +75,7 @@ fn kill_process(pid: u32) -> io::Result<()> { } } -async fn read_output_stream(mut reader: R, output_tx: broadcast::Sender>) +async fn read_output_stream(mut reader: R, output_sink: OutputSink, stream: OutputStream) where R: AsyncRead + Unpin, { @@ -82,7 +84,7 @@ where match reader.read(&mut buf).await { Ok(0) => break, Ok(n) => { - let _ = output_tx.send(buf[..n].to_vec()); + output_sink.send(buf[..n].to_vec(), stream).await; } Err(ref e) if e.kind() == ErrorKind::Interrupted => continue, Err(_) => break, @@ -90,20 +92,21 @@ where } } -#[derive(Clone, Copy)] -enum PipeStdinMode { +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PipeStdinMode { Piped, Null, } -async fn spawn_process_with_stdin_mode( +pub async fn spawn_streaming_process( program: &str, args: &[String], cwd: &Path, env: &HashMap, arg0: &Option, stdin_mode: PipeStdinMode, -) -> Result { + output_sink: OutputSink, +) -> Result { if program.is_empty() { anyhow::bail!("missing program for pipe spawn"); } @@ -157,9 +160,7 @@ async fn spawn_process_with_stdin_mode( let stderr = child.stderr.take(); let (writer_tx, mut writer_rx) = mpsc::channel::>(128); - let (output_tx, _) = broadcast::channel::>(256); - let initial_output_rx = output_tx.subscribe(); - + let output_tx = output_sink.combined_sender(); let writer_handle = if let Some(stdin) = stdin { let writer = Arc::new(tokio::sync::Mutex::new(stdin)); tokio::spawn(async move { @@ -175,15 +176,15 @@ async fn spawn_process_with_stdin_mode( }; let stdout_handle = stdout.map(|stdout| { - let output_tx = output_tx.clone(); + let output_sink = output_sink.clone(); tokio::spawn(async move { - read_output_stream(BufReader::new(stdout), output_tx).await; + read_output_stream(BufReader::new(stdout), output_sink, OutputStream::Stdout).await; }) }); let stderr_handle = stderr.map(|stderr| { - let output_tx = output_tx.clone(); + let output_sink = output_sink.clone(); tokio::spawn(async move { - read_output_stream(BufReader::new(stderr), output_tx).await; + read_output_stream(BufReader::new(stderr), output_sink, OutputStream::Stderr).await; }) }); let mut reader_abort_handles = Vec::new(); @@ -219,10 +220,9 @@ async fn spawn_process_with_stdin_mode( let _ = exit_tx.send(code); }); - let (handle, output_rx) = ProcessHandle::new( + let handle = ProcessHandle::new( writer_tx, output_tx, - initial_output_rx, Box::new(PipeChildTerminator { #[cfg(windows)] pid, @@ -238,9 +238,8 @@ async fn spawn_process_with_stdin_mode( None, ); - Ok(SpawnedProcess { + Ok(SpawnedStreamingProcess { session: handle, - output_rx, exit_rx, }) } @@ -253,7 +252,18 @@ pub async fn spawn_process( env: &HashMap, arg0: &Option, ) -> Result { - spawn_process_with_stdin_mode(program, args, cwd, env, arg0, PipeStdinMode::Piped).await + let (output_sink, output_rx) = OutputSink::broadcast_combined(); + let spawned = spawn_streaming_process( + program, + args, + cwd, + env, + arg0, + PipeStdinMode::Piped, + output_sink, + ) + .await?; + Ok(spawned.into_spawned_process(output_rx)) } /// Spawn a process using regular pipes, but close stdin immediately. @@ -264,5 +274,16 @@ pub async fn spawn_process_no_stdin( env: &HashMap, arg0: &Option, ) -> Result { - spawn_process_with_stdin_mode(program, args, cwd, env, arg0, PipeStdinMode::Null).await + let (output_sink, output_rx) = OutputSink::broadcast_combined(); + let spawned = spawn_streaming_process( + program, + args, + cwd, + env, + arg0, + PipeStdinMode::Null, + output_sink, + ) + .await?; + Ok(spawned.into_spawned_process(output_rx)) } diff --git a/codex-rs/utils/pty/src/process.rs b/codex-rs/utils/pty/src/process.rs index 5c487fd3866..c1dd8e22174 100644 --- a/codex-rs/utils/pty/src/process.rs +++ b/codex-rs/utils/pty/src/process.rs @@ -4,7 +4,9 @@ use std::sync::atomic::AtomicBool; use std::sync::Arc; use std::sync::Mutex as StdMutex; +use anyhow::anyhow; use portable_pty::MasterPty; +use portable_pty::PtySize; use portable_pty::SlavePty; use tokio::sync::broadcast; use tokio::sync::mpsc; @@ -16,6 +18,29 @@ pub(crate) trait ChildTerminator: Send + Sync { fn kill(&mut self) -> io::Result<()>; } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TerminalSize { + pub rows: u16, + pub cols: u16, +} + +impl Default for TerminalSize { + fn default() -> Self { + Self { rows: 24, cols: 80 } + } +} + +impl From for PtySize { + fn from(value: TerminalSize) -> Self { + Self { + rows: value.rows, + cols: value.cols, + pixel_width: 0, + pixel_height: 0, + } + } +} + pub struct PtyHandles { pub _slave: Option>, pub _master: Box, @@ -29,11 +54,14 @@ impl fmt::Debug for PtyHandles { /// Handle for driving an interactive process (PTY or pipe). pub struct ProcessHandle { - writer_tx: mpsc::Sender>, - output_tx: broadcast::Sender>, + writer_tx: StdMutex>>>, + output_tx: Option>>, killer: StdMutex>>, + #[allow(dead_code)] reader_handle: StdMutex>>, + #[allow(dead_code)] reader_abort_handles: StdMutex>, + #[allow(dead_code)] writer_handle: StdMutex>>, wait_handle: StdMutex>>, exit_status: Arc, @@ -53,8 +81,7 @@ impl ProcessHandle { #[allow(clippy::too_many_arguments)] pub(crate) fn new( writer_tx: mpsc::Sender>, - output_tx: broadcast::Sender>, - initial_output_rx: broadcast::Receiver>, + output_tx: Option>>, killer: Box, reader_handle: JoinHandle<()>, reader_abort_handles: Vec, @@ -63,32 +90,44 @@ impl ProcessHandle { exit_status: Arc, exit_code: Arc>>, pty_handles: Option, - ) -> (Self, broadcast::Receiver>) { - ( - Self { - writer_tx, - output_tx, - killer: StdMutex::new(Some(killer)), - reader_handle: StdMutex::new(Some(reader_handle)), - reader_abort_handles: StdMutex::new(reader_abort_handles), - writer_handle: StdMutex::new(Some(writer_handle)), - wait_handle: StdMutex::new(Some(wait_handle)), - exit_status, - exit_code, - _pty_handles: StdMutex::new(pty_handles), - }, - initial_output_rx, - ) + ) -> Self { + Self { + writer_tx: StdMutex::new(Some(writer_tx)), + output_tx, + killer: StdMutex::new(Some(killer)), + reader_handle: StdMutex::new(Some(reader_handle)), + reader_abort_handles: StdMutex::new(reader_abort_handles), + writer_handle: StdMutex::new(Some(writer_handle)), + wait_handle: StdMutex::new(Some(wait_handle)), + exit_status, + exit_code, + _pty_handles: StdMutex::new(pty_handles), + } } /// Returns a channel sender for writing raw bytes to the child stdin. pub fn writer_sender(&self) -> mpsc::Sender> { - self.writer_tx.clone() + if let Ok(writer_tx) = self.writer_tx.lock() { + if let Some(writer_tx) = writer_tx.as_ref() { + return writer_tx.clone(); + } + } + + let (writer_tx, writer_rx) = mpsc::channel(1); + drop(writer_rx); + writer_tx } - /// Returns a broadcast receiver that yields stdout/stderr chunks. + /// Returns a broadcast receiver that yields stdout/stderr chunks when + /// combined output routing is configured. pub fn output_receiver(&self) -> broadcast::Receiver> { - self.output_tx.subscribe() + if let Some(output_tx) = self.output_tx.as_ref() { + return output_tx.subscribe(); + } + + let (output_tx, output_rx) = broadcast::channel(1); + drop(output_tx); + output_rx } /// True if the child process has exited. @@ -101,13 +140,38 @@ impl ProcessHandle { self.exit_code.lock().ok().and_then(|guard| *guard) } - /// Attempts to kill the child and abort helper tasks. - pub fn terminate(&self) { + /// Resize the PTY in character cells. + pub fn resize(&self, size: TerminalSize) -> anyhow::Result<()> { + let handles = self + ._pty_handles + .lock() + .map_err(|_| anyhow!("failed to lock PTY handles"))?; + let handles = handles + .as_ref() + .ok_or_else(|| anyhow!("process is not attached to a PTY"))?; + handles._master.resize(size.into()) + } + + /// Close the child's stdin channel. + pub fn close_stdin(&self) { + if let Ok(mut writer_tx) = self.writer_tx.lock() { + writer_tx.take(); + } + } + + /// Attempts to kill the child while leaving the reader/writer tasks alive + /// so callers can still drain output until EOF. + pub fn request_terminate(&self) { if let Ok(mut killer_opt) = self.killer.lock() { if let Some(mut killer) = killer_opt.take() { let _ = killer.kill(); } } + } + + /// Attempts to kill the child and abort helper tasks. + pub fn terminate(&self) { + self.request_terminate(); if let Ok(mut h) = self.reader_handle.lock() { if let Some(handle) = h.take() { @@ -138,7 +202,102 @@ impl Drop for ProcessHandle { } } -/// Return value from spawn helpers (PTY or pipe). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum OutputStream { + Stdout, + Stderr, +} + +#[derive(Clone, Debug)] +pub enum OutputSink { + BroadcastCombined(broadcast::Sender>), + GuaranteedSeparate { + stdout: mpsc::Sender>, + stderr: mpsc::Sender>, + }, +} + +impl OutputSink { + pub fn broadcast_combined() -> (Self, broadcast::Receiver>) { + let (tx, rx) = broadcast::channel(256); + (OutputSink::BroadcastCombined(tx), rx) + } + + pub fn guaranteed_separate() -> (Self, mpsc::Receiver>, mpsc::Receiver>) { + let (stdout_tx, stdout_rx) = mpsc::channel(128); + let (stderr_tx, stderr_rx) = mpsc::channel(128); + ( + OutputSink::GuaranteedSeparate { + stdout: stdout_tx, + stderr: stderr_tx, + }, + stdout_rx, + stderr_rx, + ) + } + + pub(crate) async fn send(&self, chunk: Vec, stream: OutputStream) { + match self { + OutputSink::BroadcastCombined(tx) => { + let _ = tx.send(chunk); + } + OutputSink::GuaranteedSeparate { stdout, stderr } => match stream { + OutputStream::Stdout => { + let _ = stdout.send(chunk).await; + } + OutputStream::Stderr => { + let _ = stderr.send(chunk).await; + } + }, + } + } + + pub(crate) fn send_blocking(&self, chunk: Vec, stream: OutputStream) { + match self { + OutputSink::BroadcastCombined(tx) => { + let _ = tx.send(chunk); + } + OutputSink::GuaranteedSeparate { stdout, stderr } => match stream { + OutputStream::Stdout => { + let _ = stdout.blocking_send(chunk); + } + OutputStream::Stderr => { + let _ = stderr.blocking_send(chunk); + } + }, + } + } + + pub(crate) fn combined_sender(&self) -> Option>> { + match self { + OutputSink::BroadcastCombined(tx) => Some(tx.clone()), + OutputSink::GuaranteedSeparate { .. } => None, + } + } +} + +/// Return value from explicit streaming spawn helpers (PTY or pipe). +#[derive(Debug)] +pub struct SpawnedStreamingProcess { + pub session: ProcessHandle, + pub exit_rx: oneshot::Receiver, +} + +impl SpawnedStreamingProcess { + pub(crate) fn into_spawned_process( + self, + output_rx: broadcast::Receiver>, + ) -> SpawnedProcess { + let Self { session, exit_rx } = self; + SpawnedProcess { + session, + output_rx, + exit_rx, + } + } +} + +/// Return value from backwards-compatible spawn helpers (PTY or pipe). #[derive(Debug)] pub struct SpawnedProcess { pub session: ProcessHandle, diff --git a/codex-rs/utils/pty/src/pty.rs b/codex-rs/utils/pty/src/pty.rs index 367cb5b1dc3..135234481c3 100644 --- a/codex-rs/utils/pty/src/pty.rs +++ b/codex-rs/utils/pty/src/pty.rs @@ -10,16 +10,18 @@ use anyhow::Result; #[cfg(not(windows))] use portable_pty::native_pty_system; use portable_pty::CommandBuilder; -use portable_pty::PtySize; -use tokio::sync::broadcast; use tokio::sync::mpsc; use tokio::sync::oneshot; use tokio::task::JoinHandle; use crate::process::ChildTerminator; +use crate::process::OutputSink; +use crate::process::OutputStream; use crate::process::ProcessHandle; use crate::process::PtyHandles; use crate::process::SpawnedProcess; +use crate::process::SpawnedStreamingProcess; +use crate::process::TerminalSize; /// Returns true when ConPTY support is available (Windows only). #[cfg(windows)] @@ -72,25 +74,23 @@ fn platform_native_pty_system() -> Box { } } -/// Spawn a process attached to a PTY, returning handles for stdin, output, and exit. -pub async fn spawn_process( +/// Spawn a process attached to a PTY, returning handles for stdin and exit +/// while routing output through the caller-provided sink. +pub async fn spawn_streaming_process( program: &str, args: &[String], cwd: &Path, env: &HashMap, arg0: &Option, -) -> Result { + size: TerminalSize, + output_sink: OutputSink, +) -> Result { if program.is_empty() { anyhow::bail!("missing program for PTY spawn"); } let pty_system = platform_native_pty_system(); - let pair = pty_system.openpty(PtySize { - rows: 24, - cols: 80, - pixel_width: 0, - pixel_height: 0, - })?; + let pair = pty_system.openpty(size.into())?; let mut command_builder = CommandBuilder::new(arg0.as_ref().unwrap_or(&program.to_string())); command_builder.cwd(cwd); @@ -111,18 +111,15 @@ pub async fn spawn_process( let killer = child.clone_killer(); let (writer_tx, mut writer_rx) = mpsc::channel::>(128); - let (output_tx, _) = broadcast::channel::>(256); - let initial_output_rx = output_tx.subscribe(); - + let output_tx = output_sink.combined_sender(); let mut reader = pair.master.try_clone_reader()?; - let output_tx_clone = output_tx.clone(); let reader_handle: JoinHandle<()> = tokio::task::spawn_blocking(move || { let mut buf = [0u8; 8_192]; loop { match reader.read(&mut buf) { Ok(0) => break, Ok(n) => { - let _ = output_tx_clone.send(buf[..n].to_vec()); + output_sink.send_blocking(buf[..n].to_vec(), OutputStream::Stdout); } Err(ref e) if e.kind() == ErrorKind::Interrupted => continue, Err(ref e) if e.kind() == ErrorKind::WouldBlock => { @@ -174,10 +171,9 @@ pub async fn spawn_process( _master: pair.master, }; - let (handle, output_rx) = ProcessHandle::new( + let handle = ProcessHandle::new( writer_tx, output_tx, - initial_output_rx, Box::new(PtyChildTerminator { killer, #[cfg(unix)] @@ -192,9 +188,22 @@ pub async fn spawn_process( Some(handles), ); - Ok(SpawnedProcess { + Ok(SpawnedStreamingProcess { session: handle, - output_rx, exit_rx, }) } + +/// Spawn a process attached to a PTY, returning handles for stdin, output, and exit. +pub async fn spawn_process( + program: &str, + args: &[String], + cwd: &Path, + env: &HashMap, + arg0: &Option, + size: TerminalSize, +) -> Result { + let (output_sink, output_rx) = OutputSink::broadcast_combined(); + let spawned = spawn_streaming_process(program, args, cwd, env, arg0, size, output_sink).await?; + Ok(spawned.into_spawned_process(output_rx)) +} diff --git a/codex-rs/utils/pty/src/tests.rs b/codex-rs/utils/pty/src/tests.rs index ce50adddc9d..0b0a5447f20 100644 --- a/codex-rs/utils/pty/src/tests.rs +++ b/codex-rs/utils/pty/src/tests.rs @@ -3,8 +3,12 @@ use std::path::Path; use pretty_assertions::assert_eq; +use crate::pipe::spawn_streaming_process as spawn_pipe_streaming_process; +use crate::pipe::PipeStdinMode; use crate::spawn_pipe_process; use crate::spawn_pty_process; +use crate::OutputSink; +use crate::TerminalSize; fn find_python() -> Option { for candidate in ["python3", "python"] { @@ -51,6 +55,18 @@ fn echo_sleep_command(marker: &str) -> String { } } +fn split_stdout_stderr_command() -> String { + "printf 'split-out\\n'; printf 'split-err\\n' >&2".to_string() +} + +async fn collect_split_output(mut output_rx: tokio::sync::mpsc::Receiver>) -> Vec { + let mut collected = Vec::new(); + while let Some(chunk) = output_rx.recv().await { + collected.extend_from_slice(&chunk); + } + collected +} + async fn collect_output_until_exit( mut output_rx: tokio::sync::broadcast::Receiver>, exit_rx: tokio::sync::oneshot::Receiver, @@ -219,7 +235,15 @@ async fn pty_python_repl_emits_output_and_exits() -> anyhow::Result<()> { }; let env_map: HashMap = std::env::vars().collect(); - let spawned = spawn_pty_process(&python, &[], Path::new("."), &env_map, &None).await?; + let spawned = spawn_pty_process( + &python, + &[], + Path::new("."), + &env_map, + &None, + TerminalSize::default(), + ) + .await?; let writer = spawned.session.writer_sender(); let mut output_rx = spawned.output_rx; let newline = if cfg!(windows) { "\r\n" } else { "\n" }; @@ -327,7 +351,15 @@ async fn pipe_and_pty_share_interface() -> anyhow::Result<()> { let pipe = spawn_pipe_process(&pipe_program, &pipe_args, Path::new("."), &env_map, &None).await?; - let pty = spawn_pty_process(&pty_program, &pty_args, Path::new("."), &env_map, &None).await?; + let pty = spawn_pty_process( + &pty_program, + &pty_args, + Path::new("."), + &env_map, + &None, + TerminalSize::default(), + ) + .await?; let timeout_ms = if cfg!(windows) { 10_000 } else { 3_000 }; let (pipe_out, pipe_code) = @@ -370,6 +402,56 @@ async fn pipe_drains_stderr_without_stdout_activity() -> anyhow::Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pipe_process_can_expose_split_stdout_and_stderr() -> anyhow::Result<()> { + let env_map: HashMap = std::env::vars().collect(); + let (program, args) = if cfg!(windows) { + let Some(python) = find_python() else { + eprintln!("python not found; skipping pipe_process_can_expose_split_stdout_and_stderr"); + return Ok(()); + }; + ( + python, + vec![ + "-c".to_string(), + "import sys; sys.stdout.buffer.write(b'split-out\\n'); sys.stdout.buffer.flush(); sys.stderr.buffer.write(b'split-err\\n'); sys.stderr.buffer.flush()".to_string(), + ], + ) + } else { + shell_command(&split_stdout_stderr_command()) + }; + let (output_sink, stdout_rx, stderr_rx) = OutputSink::guaranteed_separate(); + let spawned = spawn_pipe_streaming_process( + &program, + &args, + Path::new("."), + &env_map, + &None, + PipeStdinMode::Null, + output_sink, + ) + .await?; + + let stdout_task = tokio::spawn(async move { collect_split_output(stdout_rx).await }); + let stderr_task = tokio::spawn(async move { collect_split_output(stderr_rx).await }); + let code = tokio::time::timeout(tokio::time::Duration::from_secs(2), spawned.exit_rx) + .await + .map_err(|_| anyhow::anyhow!("timed out waiting for split process exit"))? + .unwrap_or(-1); + let stdout = tokio::time::timeout(tokio::time::Duration::from_secs(2), stdout_task) + .await + .map_err(|_| anyhow::anyhow!("timed out waiting to drain split stdout"))??; + let stderr = tokio::time::timeout(tokio::time::Duration::from_secs(2), stderr_task) + .await + .map_err(|_| anyhow::anyhow!("timed out waiting to drain split stderr"))??; + + assert_eq!(stdout, b"split-out\n".to_vec()); + assert_eq!(stderr, b"split-err\n".to_vec()); + assert_eq!(code, 0); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn pipe_terminate_aborts_detached_readers() -> anyhow::Result<()> { if !setsid_available() { @@ -416,7 +498,15 @@ async fn pty_terminate_kills_background_children_in_same_process_group() -> anyh let marker = "__codex_bg_pid:"; let script = format!("sleep 1000 & bg=$!; echo {marker}$bg; wait"); let (program, args) = shell_command(&script); - let mut spawned = spawn_pty_process(&program, &args, Path::new("."), &env_map, &None).await?; + let mut spawned = spawn_pty_process( + &program, + &args, + Path::new("."), + &env_map, + &None, + TerminalSize::default(), + ) + .await?; let bg_pid = match wait_for_marker_pid(&mut spawned.output_rx, marker, 2_000).await { Ok(pid) => pid, From 9dbf1a14d3768627e6eb13df225a012f4aff268c Mon Sep 17 00:00:00 2001 From: Ruslan Nigmatullin Date: Fri, 6 Mar 2026 11:43:18 -0800 Subject: [PATCH 2/6] cr --- codex-rs/core/src/unified_exec/process.rs | 10 +- .../tui/tests/suite/model_availability_nux.rs | 14 +- .../tui/tests/suite/no_panic_on_startup.rs | 14 +- codex-rs/utils/pty/README.md | 10 +- codex-rs/utils/pty/src/lib.rs | 8 +- codex-rs/utils/pty/src/pipe.rs | 58 ++----- codex-rs/utils/pty/src/process.rs | 145 ++++-------------- codex-rs/utils/pty/src/pty.rs | 35 ++--- codex-rs/utils/pty/src/tests.rs | 98 +++++++----- 9 files changed, 153 insertions(+), 239 deletions(-) diff --git a/codex-rs/core/src/unified_exec/process.rs b/codex-rs/core/src/unified_exec/process.rs index ba5352ff696..9fc81a6ba81 100644 --- a/codex-rs/core/src/unified_exec/process.rs +++ b/codex-rs/core/src/unified_exec/process.rs @@ -5,6 +5,7 @@ use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use tokio::sync::Mutex; use tokio::sync::Notify; +use tokio::sync::broadcast; use tokio::sync::mpsc; use tokio::sync::oneshot::error::TryRecvError; use tokio::task::JoinHandle; @@ -47,6 +48,7 @@ pub(crate) struct OutputHandles { #[derive(Debug)] pub(crate) struct UnifiedExecProcess { process_handle: ExecCommandSession, + output_rx: broadcast::Receiver>, output_buffer: OutputBuffer, output_notify: Arc, output_closed: Arc, @@ -72,6 +74,7 @@ impl UnifiedExecProcess { let cancellation_token = CancellationToken::new(); let output_drained = Arc::new(Notify::new()); let mut receiver = initial_output_rx; + let output_rx = receiver.resubscribe(); let buffer_clone = Arc::clone(&output_buffer); let notify_clone = Arc::clone(&output_notify); let output_closed_clone = Arc::clone(&output_closed); @@ -97,6 +100,7 @@ impl UnifiedExecProcess { Self { process_handle, + output_rx, output_buffer, output_notify, output_closed, @@ -124,7 +128,7 @@ impl UnifiedExecProcess { } pub(super) fn output_receiver(&self) -> tokio::sync::broadcast::Receiver> { - self.process_handle.output_receiver() + self.output_rx.resubscribe() } pub(super) fn cancellation_token(&self) -> CancellationToken { @@ -214,9 +218,11 @@ impl UnifiedExecProcess { ) -> Result { let SpawnedPty { session: process_handle, - output_rx, + stdout_rx, + stderr_rx, mut exit_rx, } = spawned; + let output_rx = codex_utils_pty::combine_output_receivers(stdout_rx, stderr_rx); let managed = Self::new(process_handle, output_rx, sandbox_type, spawn_lifecycle); let exit_ready = matches!(exit_rx.try_recv(), Ok(_) | Err(TryRecvError::Closed)); diff --git a/codex-rs/tui/tests/suite/model_availability_nux.rs b/codex-rs/tui/tests/suite/model_availability_nux.rs index 647a4f1a2f3..512db979748 100644 --- a/codex-rs/tui/tests/suite/model_availability_nux.rs +++ b/codex-rs/tui/tests/suite/model_availability_nux.rs @@ -129,9 +129,15 @@ trust_level = "trusted" .await?; let mut output = Vec::new(); - let mut output_rx = spawned.output_rx; - let mut exit_rx = spawned.exit_rx; - let writer_tx = spawned.session.writer_sender(); + let codex_utils_pty::SpawnedProcess { + session, + stdout_rx, + stderr_rx, + exit_rx, + } = spawned; + let mut output_rx = codex_utils_pty::combine_output_receivers(stdout_rx, stderr_rx); + let mut exit_rx = exit_rx; + let writer_tx = session.writer_sender(); let interrupt_writer = writer_tx.clone(); let interrupt_task = tokio::spawn(async move { sleep(Duration::from_secs(2)).await; @@ -166,7 +172,7 @@ trust_level = "trusted" Ok(Ok(code)) => code, Ok(Err(err)) => return Err(err.into()), Err(_) => { - spawned.session.terminate(); + session.terminate(); anyhow::bail!("timed out waiting for codex resume to exit"); } }; diff --git a/codex-rs/tui/tests/suite/no_panic_on_startup.rs b/codex-rs/tui/tests/suite/no_panic_on_startup.rs index a02b0435108..6b9a3696994 100644 --- a/codex-rs/tui/tests/suite/no_panic_on_startup.rs +++ b/codex-rs/tui/tests/suite/no_panic_on_startup.rs @@ -75,9 +75,15 @@ async fn run_codex_cli( ) .await?; let mut output = Vec::new(); - let mut output_rx = spawned.output_rx; - let mut exit_rx = spawned.exit_rx; - let writer_tx = spawned.session.writer_sender(); + let codex_utils_pty::SpawnedProcess { + session, + stdout_rx, + stderr_rx, + exit_rx, + } = spawned; + let mut output_rx = codex_utils_pty::combine_output_receivers(stdout_rx, stderr_rx); + let mut exit_rx = exit_rx; + let writer_tx = session.writer_sender(); let exit_code_result = timeout(Duration::from_secs(10), async { // Read PTY output until the process exits while replying to cursor // position queries so the TUI can initialize without a real terminal. @@ -104,7 +110,7 @@ async fn run_codex_cli( Ok(Ok(code)) => code, Ok(Err(err)) => return Err(err.into()), Err(_) => { - spawned.session.terminate(); + session.terminate(); anyhow::bail!("timed out waiting for codex CLI to exit"); } }; diff --git a/codex-rs/utils/pty/README.md b/codex-rs/utils/pty/README.md index 2e4035f588f..e70d7bc6afa 100644 --- a/codex-rs/utils/pty/README.md +++ b/codex-rs/utils/pty/README.md @@ -7,24 +7,22 @@ Lightweight helpers for spawning interactive processes either under a PTY (pseud - `spawn_pty_process(program, args, cwd, env, arg0, size)` → `SpawnedProcess` - `spawn_pipe_process(program, args, cwd, env, arg0)` → `SpawnedProcess` - `spawn_pipe_process_no_stdin(program, args, cwd, env, arg0)` → `SpawnedProcess` -- `pty::spawn_streaming_process(program, args, cwd, env, arg0, size, output_sink)` → `SpawnedStreamingProcess` -- `pipe::spawn_streaming_process(program, args, cwd, env, arg0, stdin_mode, output_sink)` → `SpawnedStreamingProcess` +- `combine_output_receivers(stdout_rx, stderr_rx)` → `broadcast::Receiver>` - `conpty_supported()` → `bool` (Windows only; always true elsewhere) - `TerminalSize { rows, cols }` selects PTY dimensions in character cells. - `ProcessHandle` exposes: - `writer_sender()` → `mpsc::Sender>` (stdin) - - `output_receiver()` → `broadcast::Receiver>` (stdout/stderr merged) - `resize(TerminalSize)` - `close_stdin()` - `has_exited()`, `exit_code()`, `terminate()` -- `SpawnedProcess` bundles `session`, `output_rx`, and `exit_rx` (oneshot exit code). -- `SpawnedStreamingProcess` bundles `session` and `exit_rx`; callers own the output sink/receivers. +- `SpawnedProcess` bundles `session`, `stdout_rx`, `stderr_rx`, and `exit_rx` (oneshot exit code). ## Usage examples ```rust use std::collections::HashMap; use std::path::Path; +use codex_utils_pty::combine_output_receivers; use codex_utils_pty::spawn_pty_process; use codex_utils_pty::TerminalSize; @@ -43,7 +41,7 @@ let writer = spawned.session.writer_sender(); writer.send(b"exit\n".to_vec()).await?; // Collect output until the process exits. -let mut output_rx = spawned.output_rx; +let mut output_rx = combine_output_receivers(spawned.stdout_rx, spawned.stderr_rx); let mut collected = Vec::new(); while let Ok(chunk) = output_rx.try_recv() { collected.extend_from_slice(&chunk); diff --git a/codex-rs/utils/pty/src/lib.rs b/codex-rs/utils/pty/src/lib.rs index 9cd689f2e30..0eba23e3f87 100644 --- a/codex-rs/utils/pty/src/lib.rs +++ b/codex-rs/utils/pty/src/lib.rs @@ -13,14 +13,12 @@ pub const DEFAULT_OUTPUT_BYTES_CAP: usize = 1024 * 1024; pub use pipe::spawn_process as spawn_pipe_process; /// Spawn a non-interactive process using regular pipes, but close stdin immediately. pub use pipe::spawn_process_no_stdin as spawn_pipe_process_no_stdin; -/// Output routing configuration for spawned processes. -pub use process::OutputSink; +/// Combine stdout/stderr receivers into a single broadcast receiver. +pub use process::combine_output_receivers; /// Handle for interacting with a spawned process (PTY or pipe). pub use process::ProcessHandle; -/// Bundle of process handles plus output and exit receivers returned by spawn helpers. +/// Bundle of process handles plus split output and exit receivers returned by spawn helpers. pub use process::SpawnedProcess; -/// Bundle of process handles and exit receiver returned by streaming spawn helpers. -pub use process::SpawnedStreamingProcess; /// Terminal size in character cells used for PTY spawn and resize operations. pub use process::TerminalSize; /// Backwards-compatible alias for ProcessHandle. diff --git a/codex-rs/utils/pty/src/pipe.rs b/codex-rs/utils/pty/src/pipe.rs index a7dbc92ec40..077048b3642 100644 --- a/codex-rs/utils/pty/src/pipe.rs +++ b/codex-rs/utils/pty/src/pipe.rs @@ -18,11 +18,8 @@ use tokio::sync::oneshot; use tokio::task::JoinHandle; use crate::process::ChildTerminator; -use crate::process::OutputSink; -use crate::process::OutputStream; use crate::process::ProcessHandle; use crate::process::SpawnedProcess; -use crate::process::SpawnedStreamingProcess; #[cfg(target_os = "linux")] use libc; @@ -75,7 +72,7 @@ fn kill_process(pid: u32) -> io::Result<()> { } } -async fn read_output_stream(mut reader: R, output_sink: OutputSink, stream: OutputStream) +async fn read_output_stream(mut reader: R, output_tx: mpsc::Sender>) where R: AsyncRead + Unpin, { @@ -84,7 +81,7 @@ where match reader.read(&mut buf).await { Ok(0) => break, Ok(n) => { - output_sink.send(buf[..n].to_vec(), stream).await; + let _ = output_tx.send(buf[..n].to_vec()).await; } Err(ref e) if e.kind() == ErrorKind::Interrupted => continue, Err(_) => break, @@ -93,20 +90,19 @@ where } #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum PipeStdinMode { +enum PipeStdinMode { Piped, Null, } -pub async fn spawn_streaming_process( +async fn spawn_process_with_stdin_mode( program: &str, args: &[String], cwd: &Path, env: &HashMap, arg0: &Option, stdin_mode: PipeStdinMode, - output_sink: OutputSink, -) -> Result { +) -> Result { if program.is_empty() { anyhow::bail!("missing program for pipe spawn"); } @@ -160,7 +156,8 @@ pub async fn spawn_streaming_process( let stderr = child.stderr.take(); let (writer_tx, mut writer_rx) = mpsc::channel::>(128); - let output_tx = output_sink.combined_sender(); + let (stdout_tx, stdout_rx) = mpsc::channel::>(128); + let (stderr_tx, stderr_rx) = mpsc::channel::>(128); let writer_handle = if let Some(stdin) = stdin { let writer = Arc::new(tokio::sync::Mutex::new(stdin)); tokio::spawn(async move { @@ -176,15 +173,15 @@ pub async fn spawn_streaming_process( }; let stdout_handle = stdout.map(|stdout| { - let output_sink = output_sink.clone(); + let stdout_tx = stdout_tx.clone(); tokio::spawn(async move { - read_output_stream(BufReader::new(stdout), output_sink, OutputStream::Stdout).await; + read_output_stream(BufReader::new(stdout), stdout_tx).await; }) }); let stderr_handle = stderr.map(|stderr| { - let output_sink = output_sink.clone(); + let stderr_tx = stderr_tx.clone(); tokio::spawn(async move { - read_output_stream(BufReader::new(stderr), output_sink, OutputStream::Stderr).await; + read_output_stream(BufReader::new(stderr), stderr_tx).await; }) }); let mut reader_abort_handles = Vec::new(); @@ -222,7 +219,6 @@ pub async fn spawn_streaming_process( let handle = ProcessHandle::new( writer_tx, - output_tx, Box::new(PipeChildTerminator { #[cfg(windows)] pid, @@ -238,13 +234,15 @@ pub async fn spawn_streaming_process( None, ); - Ok(SpawnedStreamingProcess { + Ok(SpawnedProcess { session: handle, + stdout_rx, + stderr_rx, exit_rx, }) } -/// Spawn a process using regular pipes (no PTY), returning handles for stdin, output, and exit. +/// Spawn a process using regular pipes (no PTY), returning handles for stdin, split output, and exit. pub async fn spawn_process( program: &str, args: &[String], @@ -252,18 +250,7 @@ pub async fn spawn_process( env: &HashMap, arg0: &Option, ) -> Result { - let (output_sink, output_rx) = OutputSink::broadcast_combined(); - let spawned = spawn_streaming_process( - program, - args, - cwd, - env, - arg0, - PipeStdinMode::Piped, - output_sink, - ) - .await?; - Ok(spawned.into_spawned_process(output_rx)) + spawn_process_with_stdin_mode(program, args, cwd, env, arg0, PipeStdinMode::Piped).await } /// Spawn a process using regular pipes, but close stdin immediately. @@ -274,16 +261,5 @@ pub async fn spawn_process_no_stdin( env: &HashMap, arg0: &Option, ) -> Result { - let (output_sink, output_rx) = OutputSink::broadcast_combined(); - let spawned = spawn_streaming_process( - program, - args, - cwd, - env, - arg0, - PipeStdinMode::Null, - output_sink, - ) - .await?; - Ok(spawned.into_spawned_process(output_rx)) + spawn_process_with_stdin_mode(program, args, cwd, env, arg0, PipeStdinMode::Null).await } diff --git a/codex-rs/utils/pty/src/process.rs b/codex-rs/utils/pty/src/process.rs index c1dd8e22174..d7a0addc3bc 100644 --- a/codex-rs/utils/pty/src/process.rs +++ b/codex-rs/utils/pty/src/process.rs @@ -55,13 +55,9 @@ impl fmt::Debug for PtyHandles { /// Handle for driving an interactive process (PTY or pipe). pub struct ProcessHandle { writer_tx: StdMutex>>>, - output_tx: Option>>, killer: StdMutex>>, - #[allow(dead_code)] reader_handle: StdMutex>>, - #[allow(dead_code)] reader_abort_handles: StdMutex>, - #[allow(dead_code)] writer_handle: StdMutex>>, wait_handle: StdMutex>>, exit_status: Arc, @@ -81,7 +77,6 @@ impl ProcessHandle { #[allow(clippy::too_many_arguments)] pub(crate) fn new( writer_tx: mpsc::Sender>, - output_tx: Option>>, killer: Box, reader_handle: JoinHandle<()>, reader_abort_handles: Vec, @@ -93,7 +88,6 @@ impl ProcessHandle { ) -> Self { Self { writer_tx: StdMutex::new(Some(writer_tx)), - output_tx, killer: StdMutex::new(Some(killer)), reader_handle: StdMutex::new(Some(reader_handle)), reader_abort_handles: StdMutex::new(reader_abort_handles), @@ -118,18 +112,6 @@ impl ProcessHandle { writer_tx } - /// Returns a broadcast receiver that yields stdout/stderr chunks when - /// combined output routing is configured. - pub fn output_receiver(&self) -> broadcast::Receiver> { - if let Some(output_tx) = self.output_tx.as_ref() { - return output_tx.subscribe(); - } - - let (output_tx, output_rx) = broadcast::channel(1); - drop(output_tx); - output_rx - } - /// True if the child process has exited. pub fn has_exited(&self) -> bool { self.exit_status.load(std::sync::atomic::Ordering::SeqCst) @@ -202,105 +184,46 @@ impl Drop for ProcessHandle { } } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum OutputStream { - Stdout, - Stderr, -} - -#[derive(Clone, Debug)] -pub enum OutputSink { - BroadcastCombined(broadcast::Sender>), - GuaranteedSeparate { - stdout: mpsc::Sender>, - stderr: mpsc::Sender>, - }, -} - -impl OutputSink { - pub fn broadcast_combined() -> (Self, broadcast::Receiver>) { - let (tx, rx) = broadcast::channel(256); - (OutputSink::BroadcastCombined(tx), rx) - } - - pub fn guaranteed_separate() -> (Self, mpsc::Receiver>, mpsc::Receiver>) { - let (stdout_tx, stdout_rx) = mpsc::channel(128); - let (stderr_tx, stderr_rx) = mpsc::channel(128); - ( - OutputSink::GuaranteedSeparate { - stdout: stdout_tx, - stderr: stderr_tx, - }, - stdout_rx, - stderr_rx, - ) - } - - pub(crate) async fn send(&self, chunk: Vec, stream: OutputStream) { - match self { - OutputSink::BroadcastCombined(tx) => { - let _ = tx.send(chunk); - } - OutputSink::GuaranteedSeparate { stdout, stderr } => match stream { - OutputStream::Stdout => { - let _ = stdout.send(chunk).await; - } - OutputStream::Stderr => { - let _ = stderr.send(chunk).await; - } - }, - } - } - - pub(crate) fn send_blocking(&self, chunk: Vec, stream: OutputStream) { - match self { - OutputSink::BroadcastCombined(tx) => { - let _ = tx.send(chunk); +/// Combine split stdout/stderr receivers into a single broadcast receiver. +pub fn combine_output_receivers( + mut stdout_rx: mpsc::Receiver>, + mut stderr_rx: mpsc::Receiver>, +) -> broadcast::Receiver> { + let (combined_tx, combined_rx) = broadcast::channel(256); + tokio::spawn(async move { + let mut stdout_open = true; + let mut stderr_open = true; + + loop { + tokio::select! { + stdout = stdout_rx.recv(), if stdout_open => match stdout { + Some(chunk) => { + let _ = combined_tx.send(chunk); + } + None => { + stdout_open = false; + } + }, + stderr = stderr_rx.recv(), if stderr_open => match stderr { + Some(chunk) => { + let _ = combined_tx.send(chunk); + } + None => { + stderr_open = false; + } + }, + else => break, } - OutputSink::GuaranteedSeparate { stdout, stderr } => match stream { - OutputStream::Stdout => { - let _ = stdout.blocking_send(chunk); - } - OutputStream::Stderr => { - let _ = stderr.blocking_send(chunk); - } - }, - } - } - - pub(crate) fn combined_sender(&self) -> Option>> { - match self { - OutputSink::BroadcastCombined(tx) => Some(tx.clone()), - OutputSink::GuaranteedSeparate { .. } => None, } - } -} - -/// Return value from explicit streaming spawn helpers (PTY or pipe). -#[derive(Debug)] -pub struct SpawnedStreamingProcess { - pub session: ProcessHandle, - pub exit_rx: oneshot::Receiver, -} - -impl SpawnedStreamingProcess { - pub(crate) fn into_spawned_process( - self, - output_rx: broadcast::Receiver>, - ) -> SpawnedProcess { - let Self { session, exit_rx } = self; - SpawnedProcess { - session, - output_rx, - exit_rx, - } - } + }); + combined_rx } -/// Return value from backwards-compatible spawn helpers (PTY or pipe). +/// Return value from PTY or pipe spawn helpers. #[derive(Debug)] pub struct SpawnedProcess { pub session: ProcessHandle, - pub output_rx: broadcast::Receiver>, + pub stdout_rx: mpsc::Receiver>, + pub stderr_rx: mpsc::Receiver>, pub exit_rx: oneshot::Receiver, } diff --git a/codex-rs/utils/pty/src/pty.rs b/codex-rs/utils/pty/src/pty.rs index 135234481c3..63ea838d867 100644 --- a/codex-rs/utils/pty/src/pty.rs +++ b/codex-rs/utils/pty/src/pty.rs @@ -15,12 +15,9 @@ use tokio::sync::oneshot; use tokio::task::JoinHandle; use crate::process::ChildTerminator; -use crate::process::OutputSink; -use crate::process::OutputStream; use crate::process::ProcessHandle; use crate::process::PtyHandles; use crate::process::SpawnedProcess; -use crate::process::SpawnedStreamingProcess; use crate::process::TerminalSize; /// Returns true when ConPTY support is available (Windows only). @@ -74,17 +71,15 @@ fn platform_native_pty_system() -> Box { } } -/// Spawn a process attached to a PTY, returning handles for stdin and exit -/// while routing output through the caller-provided sink. -pub async fn spawn_streaming_process( +/// Spawn a process attached to a PTY, returning handles for stdin, split output, and exit. +pub async fn spawn_process( program: &str, args: &[String], cwd: &Path, env: &HashMap, arg0: &Option, size: TerminalSize, - output_sink: OutputSink, -) -> Result { +) -> Result { if program.is_empty() { anyhow::bail!("missing program for PTY spawn"); } @@ -111,7 +106,8 @@ pub async fn spawn_streaming_process( let killer = child.clone_killer(); let (writer_tx, mut writer_rx) = mpsc::channel::>(128); - let output_tx = output_sink.combined_sender(); + let (stdout_tx, stdout_rx) = mpsc::channel::>(128); + let (_stderr_tx, stderr_rx) = mpsc::channel::>(1); let mut reader = pair.master.try_clone_reader()?; let reader_handle: JoinHandle<()> = tokio::task::spawn_blocking(move || { let mut buf = [0u8; 8_192]; @@ -119,7 +115,7 @@ pub async fn spawn_streaming_process( match reader.read(&mut buf) { Ok(0) => break, Ok(n) => { - output_sink.send_blocking(buf[..n].to_vec(), OutputStream::Stdout); + let _ = stdout_tx.blocking_send(buf[..n].to_vec()); } Err(ref e) if e.kind() == ErrorKind::Interrupted => continue, Err(ref e) if e.kind() == ErrorKind::WouldBlock => { @@ -173,7 +169,6 @@ pub async fn spawn_streaming_process( let handle = ProcessHandle::new( writer_tx, - output_tx, Box::new(PtyChildTerminator { killer, #[cfg(unix)] @@ -188,22 +183,10 @@ pub async fn spawn_streaming_process( Some(handles), ); - Ok(SpawnedStreamingProcess { + Ok(SpawnedProcess { session: handle, + stdout_rx, + stderr_rx, exit_rx, }) } - -/// Spawn a process attached to a PTY, returning handles for stdin, output, and exit. -pub async fn spawn_process( - program: &str, - args: &[String], - cwd: &Path, - env: &HashMap, - arg0: &Option, - size: TerminalSize, -) -> Result { - let (output_sink, output_rx) = OutputSink::broadcast_combined(); - let spawned = spawn_streaming_process(program, args, cwd, env, arg0, size, output_sink).await?; - Ok(spawned.into_spawned_process(output_rx)) -} diff --git a/codex-rs/utils/pty/src/tests.rs b/codex-rs/utils/pty/src/tests.rs index 0b0a5447f20..528bdf9890f 100644 --- a/codex-rs/utils/pty/src/tests.rs +++ b/codex-rs/utils/pty/src/tests.rs @@ -3,11 +3,11 @@ use std::path::Path; use pretty_assertions::assert_eq; -use crate::pipe::spawn_streaming_process as spawn_pipe_streaming_process; -use crate::pipe::PipeStdinMode; +use crate::combine_output_receivers; use crate::spawn_pipe_process; +use crate::spawn_pipe_process_no_stdin; use crate::spawn_pty_process; -use crate::OutputSink; +use crate::SpawnedProcess; use crate::TerminalSize; fn find_python() -> Option { @@ -67,6 +67,26 @@ async fn collect_split_output(mut output_rx: tokio::sync::mpsc::Receiver collected } +fn combine_spawned_output( + spawned: SpawnedProcess, +) -> ( + crate::ProcessHandle, + tokio::sync::broadcast::Receiver>, + tokio::sync::oneshot::Receiver, +) { + let SpawnedProcess { + session, + stdout_rx, + stderr_rx, + exit_rx, + } = spawned; + ( + session, + combine_output_receivers(stdout_rx, stderr_rx), + exit_rx, + ) +} + async fn collect_output_until_exit( mut output_rx: tokio::sync::broadcast::Receiver>, exit_rx: tokio::sync::oneshot::Receiver, @@ -244,8 +264,8 @@ async fn pty_python_repl_emits_output_and_exits() -> anyhow::Result<()> { TerminalSize::default(), ) .await?; - let writer = spawned.session.writer_sender(); - let mut output_rx = spawned.output_rx; + let (session, mut output_rx, exit_rx) = combine_spawned_output(spawned); + let writer = session.writer_sender(); let newline = if cfg!(windows) { "\r\n" } else { "\n" }; let startup_timeout_ms = if cfg!(windows) { 10_000 } else { 5_000 }; let mut output = @@ -256,8 +276,7 @@ async fn pty_python_repl_emits_output_and_exits() -> anyhow::Result<()> { writer.send(format!("exit(){newline}").into_bytes()).await?; let timeout_ms = if cfg!(windows) { 10_000 } else { 5_000 }; - let (remaining_output, code) = - collect_output_until_exit(output_rx, spawned.exit_rx, timeout_ms).await; + let (remaining_output, code) = collect_output_until_exit(output_rx, exit_rx, timeout_ms).await; output.extend_from_slice(&remaining_output); let text = String::from_utf8_lossy(&output); @@ -284,10 +303,11 @@ async fn pipe_process_round_trips_stdin() -> anyhow::Result<()> { ]; let env_map: HashMap = std::env::vars().collect(); let spawned = spawn_pipe_process(&python, &args, Path::new("."), &env_map, &None).await?; - let writer = spawned.session.writer_sender(); + let (session, output_rx, exit_rx) = combine_spawned_output(spawned); + let writer = session.writer_sender(); writer.send(b"roundtrip\n".to_vec()).await?; - let (output, code) = collect_output_until_exit(spawned.output_rx, spawned.exit_rx, 5_000).await; + let (output, code) = collect_output_until_exit(output_rx, exit_rx, 5_000).await; let text = String::from_utf8_lossy(&output); assert!( @@ -312,7 +332,7 @@ async fn pipe_process_detaches_from_parent_session() -> anyhow::Result<()> { let (program, args) = shell_command(script); let spawned = spawn_pipe_process(&program, &args, Path::new("."), &env_map, &None).await?; - let mut output_rx = spawned.output_rx; + let (_session, mut output_rx, exit_rx) = combine_spawned_output(spawned); let pid_bytes = tokio::time::timeout(tokio::time::Duration::from_millis(500), output_rx.recv()).await??; let pid_text = String::from_utf8_lossy(&pid_bytes); @@ -333,7 +353,7 @@ async fn pipe_process_detaches_from_parent_session() -> anyhow::Result<()> { "expected child to be detached from parent session" ); - let exit_code = spawned.exit_rx.await.unwrap_or(-1); + let exit_code = exit_rx.await.unwrap_or(-1); assert_eq!( exit_code, 0, "expected detached pipe process to exit cleanly" @@ -360,12 +380,14 @@ async fn pipe_and_pty_share_interface() -> anyhow::Result<()> { TerminalSize::default(), ) .await?; + let (_pipe_session, pipe_output_rx, pipe_exit_rx) = combine_spawned_output(pipe); + let (_pty_session, pty_output_rx, pty_exit_rx) = combine_spawned_output(pty); let timeout_ms = if cfg!(windows) { 10_000 } else { 3_000 }; let (pipe_out, pipe_code) = - collect_output_until_exit(pipe.output_rx, pipe.exit_rx, timeout_ms).await; + collect_output_until_exit(pipe_output_rx, pipe_exit_rx, timeout_ms).await; let (pty_out, pty_code) = - collect_output_until_exit(pty.output_rx, pty.exit_rx, timeout_ms).await; + collect_output_until_exit(pty_output_rx, pty_exit_rx, timeout_ms).await; assert_eq!(pipe_code, 0); assert_eq!(pty_code, 0); @@ -392,9 +414,9 @@ async fn pipe_drains_stderr_without_stdout_activity() -> anyhow::Result<()> { let args = vec!["-c".to_string(), script.to_string()]; let env_map: HashMap = std::env::vars().collect(); let spawned = spawn_pipe_process(&python, &args, Path::new("."), &env_map, &None).await?; + let (_session, output_rx, exit_rx) = combine_spawned_output(spawned); - let (output, code) = - collect_output_until_exit(spawned.output_rx, spawned.exit_rx, 10_000).await; + let (output, code) = collect_output_until_exit(output_rx, exit_rx, 10_000).await; assert_eq!(code, 0, "expected python to exit cleanly"); assert!(!output.is_empty(), "expected stderr output to be drained"); @@ -420,21 +442,18 @@ async fn pipe_process_can_expose_split_stdout_and_stderr() -> anyhow::Result<()> } else { shell_command(&split_stdout_stderr_command()) }; - let (output_sink, stdout_rx, stderr_rx) = OutputSink::guaranteed_separate(); - let spawned = spawn_pipe_streaming_process( - &program, - &args, - Path::new("."), - &env_map, - &None, - PipeStdinMode::Null, - output_sink, - ) - .await?; + let spawned = + spawn_pipe_process_no_stdin(&program, &args, Path::new("."), &env_map, &None).await?; + let SpawnedProcess { + session: _session, + stdout_rx, + stderr_rx, + exit_rx, + } = spawned; let stdout_task = tokio::spawn(async move { collect_split_output(stdout_rx).await }); let stderr_task = tokio::spawn(async move { collect_split_output(stderr_rx).await }); - let code = tokio::time::timeout(tokio::time::Duration::from_secs(2), spawned.exit_rx) + let code = tokio::time::timeout(tokio::time::Duration::from_secs(2), exit_rx) .await .map_err(|_| anyhow::anyhow!("timed out waiting for split process exit"))? .unwrap_or(-1); @@ -463,17 +482,15 @@ async fn pipe_terminate_aborts_detached_readers() -> anyhow::Result<()> { let script = "setsid sh -c 'i=0; while [ $i -lt 200 ]; do echo tick; sleep 0.01; i=$((i+1)); done' &"; let (program, args) = shell_command(script); - let mut spawned = spawn_pipe_process(&program, &args, Path::new("."), &env_map, &None).await?; + let spawned = spawn_pipe_process(&program, &args, Path::new("."), &env_map, &None).await?; + let (session, mut output_rx, _exit_rx) = combine_spawned_output(spawned); - let _ = tokio::time::timeout( - tokio::time::Duration::from_millis(500), - spawned.output_rx.recv(), - ) - .await - .map_err(|_| anyhow::anyhow!("expected detached output before terminate"))??; + let _ = tokio::time::timeout(tokio::time::Duration::from_millis(500), output_rx.recv()) + .await + .map_err(|_| anyhow::anyhow!("expected detached output before terminate"))??; - spawned.session.terminate(); - let mut post_rx = spawned.session.output_receiver(); + session.terminate(); + let mut post_rx = output_rx.resubscribe(); let post_terminate = tokio::time::timeout(tokio::time::Duration::from_millis(200), post_rx.recv()).await; @@ -498,7 +515,7 @@ async fn pty_terminate_kills_background_children_in_same_process_group() -> anyh let marker = "__codex_bg_pid:"; let script = format!("sleep 1000 & bg=$!; echo {marker}$bg; wait"); let (program, args) = shell_command(&script); - let mut spawned = spawn_pty_process( + let spawned = spawn_pty_process( &program, &args, Path::new("."), @@ -507,11 +524,12 @@ async fn pty_terminate_kills_background_children_in_same_process_group() -> anyh TerminalSize::default(), ) .await?; + let (session, mut output_rx, _exit_rx) = combine_spawned_output(spawned); - let bg_pid = match wait_for_marker_pid(&mut spawned.output_rx, marker, 2_000).await { + let bg_pid = match wait_for_marker_pid(&mut output_rx, marker, 2_000).await { Ok(pid) => pid, Err(err) => { - spawned.session.terminate(); + session.terminate(); return Err(err); } }; @@ -520,7 +538,7 @@ async fn pty_terminate_kills_background_children_in_same_process_group() -> anyh "expected background child pid {bg_pid} to exist before terminate" ); - spawned.session.terminate(); + session.terminate(); let exited = wait_for_process_exit(bg_pid, 3_000).await?; if !exited { From f319913d25a736d974c82e7ae4e3261805d6e7cd Mon Sep 17 00:00:00 2001 From: Ruslan Nigmatullin Date: Fri, 6 Mar 2026 12:06:23 -0800 Subject: [PATCH 3/6] rm extra unused attrs --- codex-rs/utils/pty/src/pipe.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/utils/pty/src/pipe.rs b/codex-rs/utils/pty/src/pipe.rs index 077048b3642..f4b6d68a41c 100644 --- a/codex-rs/utils/pty/src/pipe.rs +++ b/codex-rs/utils/pty/src/pipe.rs @@ -89,7 +89,7 @@ where } } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy)] enum PipeStdinMode { Piped, Null, From 627a5510a0c7472b1b30c6fe7ca75743470f55d4 Mon Sep 17 00:00:00 2001 From: Ruslan Nigmatullin Date: Thu, 5 Mar 2026 21:43:17 -0800 Subject: [PATCH 4/6] app-server: add streaming command/exec contract and implementation Define the v2 command/exec contract and wire it through app-server and core in one slice: processId, env overrides, timeout and output-cap controls, streaming notifications, and PTY write/resize/terminate support. Keep the generated schema, README updates, backend plumbing, and test harness changes together so reviewers can read the API and runtime behavior in the same commit. --- codex-rs/Cargo.lock | 1 + .../schema/json/ClientRequest.json | 188 +++ .../schema/json/ServerNotification.json | 50 + .../codex_app_server_protocol.schemas.json | 261 +++++ .../codex_app_server_protocol.v2.schemas.json | 261 +++++ .../CommandExecOutputDeltaNotification.json | 34 + .../schema/json/v2/CommandExecParams.json | 70 ++ .../json/v2/CommandExecResizeParams.json | 38 + .../json/v2/CommandExecResizeResponse.json | 5 + .../json/v2/CommandExecTerminateParams.json | 13 + .../json/v2/CommandExecTerminateResponse.json | 5 + .../json/v2/CommandExecWriteParams.json | 22 + .../json/v2/CommandExecWriteResponse.json | 5 + .../schema/typescript/ClientRequest.ts | 5 +- .../schema/typescript/ServerNotification.ts | 3 +- .../v2/CommandExecOutputDeltaNotification.ts | 6 + .../typescript/v2/CommandExecOutputStream.ts | 5 + .../schema/typescript/v2/CommandExecParams.ts | 3 +- .../typescript/v2/CommandExecResizeParams.ts | 6 + .../v2/CommandExecResizeResponse.ts | 5 + .../typescript/v2/CommandExecTerminalSize.ts | 5 + .../v2/CommandExecTerminateParams.ts | 5 + .../v2/CommandExecTerminateResponse.ts | 5 + .../typescript/v2/CommandExecWriteParams.ts | 5 + .../typescript/v2/CommandExecWriteResponse.ts | 5 + .../schema/typescript/v2/index.ts | 9 + .../src/protocol/common.rs | 13 + .../src/protocol/mappers.rs | 10 +- .../app-server-protocol/src/protocol/v2.rs | 380 +++++++ codex-rs/app-server-test-client/src/lib.rs | 1 + codex-rs/app-server/Cargo.toml | 3 +- codex-rs/app-server/README.md | 78 +- .../app-server/src/codex_message_processor.rs | 247 +++- codex-rs/app-server/src/command_exec.rs | 1004 +++++++++++++++++ codex-rs/app-server/src/lib.rs | 1 + .../app-server/tests/common/mcp_process.rs | 40 + .../app-server/tests/suite/v2/command_exec.rs | 839 ++++++++++++++ .../suite/v2/connection_handling_websocket.rs | 2 +- codex-rs/app-server/tests/suite/v2/mod.rs | 2 + codex-rs/core/src/exec.rs | 47 +- codex-rs/core/src/lib.rs | 1 + 41 files changed, 3630 insertions(+), 58 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/CommandExecOutputDeltaNotification.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/CommandExecResizeParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/CommandExecResizeResponse.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/CommandExecTerminateParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/CommandExecTerminateResponse.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/CommandExecWriteParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/CommandExecWriteResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/CommandExecOutputDeltaNotification.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/CommandExecOutputStream.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/CommandExecResizeParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/CommandExecResizeResponse.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminalSize.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminateParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminateResponse.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/CommandExecWriteParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/CommandExecWriteResponse.ts create mode 100644 codex-rs/app-server/src/command_exec.rs create mode 100644 codex-rs/app-server/tests/suite/v2/command_exec.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d8d0005330d..eff7beda33a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1436,6 +1436,7 @@ dependencies = [ "codex-utils-cargo-bin", "codex-utils-cli", "codex-utils-json-to-toml", + "codex-utils-pty", "core_test_support", "futures", "owo-colors", diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index ff25b72a30e..d6b08d0b635 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -161,6 +161,38 @@ "null" ] }, + "disableOutputCap": { + "type": "boolean" + }, + "disableTimeout": { + "type": "boolean" + }, + "env": { + "additionalProperties": { + "type": [ + "string", + "null" + ] + }, + "type": [ + "object", + "null" + ] + }, + "outputBytesCap": { + "format": "uint", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "processId": { + "type": [ + "string", + "null" + ] + }, "sandboxPolicy": { "anyOf": [ { @@ -171,12 +203,31 @@ } ] }, + "size": { + "anyOf": [ + { + "$ref": "#/definitions/CommandExecTerminalSize" + }, + { + "type": "null" + } + ] + }, + "streamStdin": { + "type": "boolean" + }, + "streamStdoutStderr": { + "type": "boolean" + }, "timeoutMs": { "format": "int64", "type": [ "integer", "null" ] + }, + "tty": { + "type": "boolean" } }, "required": [ @@ -184,6 +235,71 @@ ], "type": "object" }, + "CommandExecResizeParams": { + "properties": { + "processId": { + "type": "string" + }, + "size": { + "$ref": "#/definitions/CommandExecTerminalSize" + } + }, + "required": [ + "processId", + "size" + ], + "type": "object" + }, + "CommandExecTerminalSize": { + "properties": { + "cols": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "rows": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "cols", + "rows" + ], + "type": "object" + }, + "CommandExecTerminateParams": { + "properties": { + "processId": { + "type": "string" + } + }, + "required": [ + "processId" + ], + "type": "object" + }, + "CommandExecWriteParams": { + "properties": { + "closeStdin": { + "type": "boolean" + }, + "deltaBase64": { + "type": [ + "string", + "null" + ] + }, + "processId": { + "type": "string" + } + }, + "required": [ + "processId" + ], + "type": "object" + }, "ConfigBatchWriteParams": { "properties": { "edits": { @@ -3799,6 +3915,78 @@ "title": "Command/execRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "command/exec/write" + ], + "title": "Command/exec/writeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/CommandExecWriteParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Command/exec/writeRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "command/exec/terminate" + ], + "title": "Command/exec/terminateRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/CommandExecTerminateParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Command/exec/terminateRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "command/exec/resize" + ], + "title": "Command/exec/resizeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/CommandExecResizeParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Command/exec/resizeRequest", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 79f191ee666..2dd206b57d3 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -670,6 +670,36 @@ } ] }, + "CommandExecOutputDeltaNotification": { + "properties": { + "capReached": { + "type": "boolean" + }, + "deltaBase64": { + "type": "string" + }, + "processId": { + "type": "string" + }, + "stream": { + "$ref": "#/definitions/CommandExecOutputStream" + } + }, + "required": [ + "capReached", + "deltaBase64", + "processId", + "stream" + ], + "type": "object" + }, + "CommandExecOutputStream": { + "enum": [ + "stdout", + "stderr" + ], + "type": "string" + }, "CommandExecutionOutputDeltaNotification": { "properties": { "delta": { @@ -3468,6 +3498,26 @@ "title": "Item/plan/deltaNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "command/exec/outputDelta" + ], + "title": "Command/exec/outputDeltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/CommandExecOutputDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Command/exec/outputDeltaNotification", + "type": "object" + }, { "properties": { "method": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 0bebb007cb8..cbf5560550e 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -1242,6 +1242,78 @@ "title": "Command/execRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "command/exec/write" + ], + "title": "Command/exec/writeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/CommandExecWriteParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Command/exec/writeRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "command/exec/terminate" + ], + "title": "Command/exec/terminateRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/CommandExecTerminateParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Command/exec/terminateRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "command/exec/resize" + ], + "title": "Command/exec/resizeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/CommandExecResizeParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Command/exec/resizeRequest", + "type": "object" + }, { "properties": { "id": { @@ -7081,6 +7153,26 @@ "title": "Item/plan/deltaNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "command/exec/outputDelta" + ], + "title": "Command/exec/outputDeltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/CommandExecOutputDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Command/exec/outputDeltaNotification", + "type": "object" + }, { "properties": { "method": { @@ -9319,6 +9411,38 @@ } ] }, + "CommandExecOutputDeltaNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "capReached": { + "type": "boolean" + }, + "deltaBase64": { + "type": "string" + }, + "processId": { + "type": "string" + }, + "stream": { + "$ref": "#/definitions/v2/CommandExecOutputStream" + } + }, + "required": [ + "capReached", + "deltaBase64", + "processId", + "stream" + ], + "title": "CommandExecOutputDeltaNotification", + "type": "object" + }, + "CommandExecOutputStream": { + "enum": [ + "stdout", + "stderr" + ], + "type": "string" + }, "CommandExecParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -9334,6 +9458,38 @@ "null" ] }, + "disableOutputCap": { + "type": "boolean" + }, + "disableTimeout": { + "type": "boolean" + }, + "env": { + "additionalProperties": { + "type": [ + "string", + "null" + ] + }, + "type": [ + "object", + "null" + ] + }, + "outputBytesCap": { + "format": "uint", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "processId": { + "type": [ + "string", + "null" + ] + }, "sandboxPolicy": { "anyOf": [ { @@ -9344,12 +9500,31 @@ } ] }, + "size": { + "anyOf": [ + { + "$ref": "#/definitions/v2/CommandExecTerminalSize" + }, + { + "type": "null" + } + ] + }, + "streamStdin": { + "type": "boolean" + }, + "streamStdoutStderr": { + "type": "boolean" + }, "timeoutMs": { "format": "int64", "type": [ "integer", "null" ] + }, + "tty": { + "type": "boolean" } }, "required": [ @@ -9358,6 +9533,28 @@ "title": "CommandExecParams", "type": "object" }, + "CommandExecResizeParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "processId": { + "type": "string" + }, + "size": { + "$ref": "#/definitions/v2/CommandExecTerminalSize" + } + }, + "required": [ + "processId", + "size" + ], + "title": "CommandExecResizeParams", + "type": "object" + }, + "CommandExecResizeResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CommandExecResizeResponse", + "type": "object" + }, "CommandExecResponse": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -9380,6 +9577,70 @@ "title": "CommandExecResponse", "type": "object" }, + "CommandExecTerminalSize": { + "properties": { + "cols": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "rows": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "cols", + "rows" + ], + "type": "object" + }, + "CommandExecTerminateParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "processId": { + "type": "string" + } + }, + "required": [ + "processId" + ], + "title": "CommandExecTerminateParams", + "type": "object" + }, + "CommandExecTerminateResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CommandExecTerminateResponse", + "type": "object" + }, + "CommandExecWriteParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "closeStdin": { + "type": "boolean" + }, + "deltaBase64": { + "type": [ + "string", + "null" + ] + }, + "processId": { + "type": "string" + } + }, + "required": [ + "processId" + ], + "title": "CommandExecWriteParams", + "type": "object" + }, + "CommandExecWriteResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CommandExecWriteResponse", + "type": "object" + }, "CommandExecutionOutputDeltaNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index da67d650c40..a216bdfd331 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -1765,6 +1765,78 @@ "title": "Command/execRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "command/exec/write" + ], + "title": "Command/exec/writeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/CommandExecWriteParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Command/exec/writeRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "command/exec/terminate" + ], + "title": "Command/exec/terminateRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/CommandExecTerminateParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Command/exec/terminateRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "command/exec/resize" + ], + "title": "Command/exec/resizeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/CommandExecResizeParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Command/exec/resizeRequest", + "type": "object" + }, { "properties": { "id": { @@ -2359,6 +2431,38 @@ } ] }, + "CommandExecOutputDeltaNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "capReached": { + "type": "boolean" + }, + "deltaBase64": { + "type": "string" + }, + "processId": { + "type": "string" + }, + "stream": { + "$ref": "#/definitions/CommandExecOutputStream" + } + }, + "required": [ + "capReached", + "deltaBase64", + "processId", + "stream" + ], + "title": "CommandExecOutputDeltaNotification", + "type": "object" + }, + "CommandExecOutputStream": { + "enum": [ + "stdout", + "stderr" + ], + "type": "string" + }, "CommandExecParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -2374,6 +2478,38 @@ "null" ] }, + "disableOutputCap": { + "type": "boolean" + }, + "disableTimeout": { + "type": "boolean" + }, + "env": { + "additionalProperties": { + "type": [ + "string", + "null" + ] + }, + "type": [ + "object", + "null" + ] + }, + "outputBytesCap": { + "format": "uint", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "processId": { + "type": [ + "string", + "null" + ] + }, "sandboxPolicy": { "anyOf": [ { @@ -2384,12 +2520,31 @@ } ] }, + "size": { + "anyOf": [ + { + "$ref": "#/definitions/CommandExecTerminalSize" + }, + { + "type": "null" + } + ] + }, + "streamStdin": { + "type": "boolean" + }, + "streamStdoutStderr": { + "type": "boolean" + }, "timeoutMs": { "format": "int64", "type": [ "integer", "null" ] + }, + "tty": { + "type": "boolean" } }, "required": [ @@ -2398,6 +2553,28 @@ "title": "CommandExecParams", "type": "object" }, + "CommandExecResizeParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "processId": { + "type": "string" + }, + "size": { + "$ref": "#/definitions/CommandExecTerminalSize" + } + }, + "required": [ + "processId", + "size" + ], + "title": "CommandExecResizeParams", + "type": "object" + }, + "CommandExecResizeResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CommandExecResizeResponse", + "type": "object" + }, "CommandExecResponse": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -2420,6 +2597,70 @@ "title": "CommandExecResponse", "type": "object" }, + "CommandExecTerminalSize": { + "properties": { + "cols": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "rows": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "cols", + "rows" + ], + "type": "object" + }, + "CommandExecTerminateParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "processId": { + "type": "string" + } + }, + "required": [ + "processId" + ], + "title": "CommandExecTerminateParams", + "type": "object" + }, + "CommandExecTerminateResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CommandExecTerminateResponse", + "type": "object" + }, + "CommandExecWriteParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "closeStdin": { + "type": "boolean" + }, + "deltaBase64": { + "type": [ + "string", + "null" + ] + }, + "processId": { + "type": "string" + } + }, + "required": [ + "processId" + ], + "title": "CommandExecWriteParams", + "type": "object" + }, + "CommandExecWriteResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CommandExecWriteResponse", + "type": "object" + }, "CommandExecutionOutputDeltaNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -10591,6 +10832,26 @@ "title": "Item/plan/deltaNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "command/exec/outputDelta" + ], + "title": "Command/exec/outputDeltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/CommandExecOutputDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Command/exec/outputDeltaNotification", + "type": "object" + }, { "properties": { "method": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecOutputDeltaNotification.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecOutputDeltaNotification.json new file mode 100644 index 00000000000..d9f829aa497 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecOutputDeltaNotification.json @@ -0,0 +1,34 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "CommandExecOutputStream": { + "enum": [ + "stdout", + "stderr" + ], + "type": "string" + } + }, + "properties": { + "capReached": { + "type": "boolean" + }, + "deltaBase64": { + "type": "string" + }, + "processId": { + "type": "string" + }, + "stream": { + "$ref": "#/definitions/CommandExecOutputStream" + } + }, + "required": [ + "capReached", + "deltaBase64", + "processId", + "stream" + ], + "title": "CommandExecOutputDeltaNotification", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json index 08f1a9a15a8..06140b91cb2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json @@ -5,6 +5,25 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "CommandExecTerminalSize": { + "properties": { + "cols": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "rows": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "cols", + "rows" + ], + "type": "object" + }, "NetworkAccess": { "enum": [ "restricted", @@ -192,6 +211,38 @@ "null" ] }, + "disableOutputCap": { + "type": "boolean" + }, + "disableTimeout": { + "type": "boolean" + }, + "env": { + "additionalProperties": { + "type": [ + "string", + "null" + ] + }, + "type": [ + "object", + "null" + ] + }, + "outputBytesCap": { + "format": "uint", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "processId": { + "type": [ + "string", + "null" + ] + }, "sandboxPolicy": { "anyOf": [ { @@ -202,12 +253,31 @@ } ] }, + "size": { + "anyOf": [ + { + "$ref": "#/definitions/CommandExecTerminalSize" + }, + { + "type": "null" + } + ] + }, + "streamStdin": { + "type": "boolean" + }, + "streamStdoutStderr": { + "type": "boolean" + }, "timeoutMs": { "format": "int64", "type": [ "integer", "null" ] + }, + "tty": { + "type": "boolean" } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecResizeParams.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecResizeParams.json new file mode 100644 index 00000000000..031aacb449e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecResizeParams.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "CommandExecTerminalSize": { + "properties": { + "cols": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "rows": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "cols", + "rows" + ], + "type": "object" + } + }, + "properties": { + "processId": { + "type": "string" + }, + "size": { + "$ref": "#/definitions/CommandExecTerminalSize" + } + }, + "required": [ + "processId", + "size" + ], + "title": "CommandExecResizeParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecResizeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecResizeResponse.json new file mode 100644 index 00000000000..66440314ad0 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecResizeResponse.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CommandExecResizeResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecTerminateParams.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecTerminateParams.json new file mode 100644 index 00000000000..4a110c40b82 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecTerminateParams.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "processId": { + "type": "string" + } + }, + "required": [ + "processId" + ], + "title": "CommandExecTerminateParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecTerminateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecTerminateResponse.json new file mode 100644 index 00000000000..39e7ac00e5b --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecTerminateResponse.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CommandExecTerminateResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecWriteParams.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecWriteParams.json new file mode 100644 index 00000000000..397a72e6929 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecWriteParams.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "closeStdin": { + "type": "boolean" + }, + "deltaBase64": { + "type": [ + "string", + "null" + ] + }, + "processId": { + "type": "string" + } + }, + "required": [ + "processId" + ], + "title": "CommandExecWriteParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecWriteResponse.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecWriteResponse.json new file mode 100644 index 00000000000..155078eb4f4 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecWriteResponse.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CommandExecWriteResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index d402cf87b8e..715a51d57e8 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -10,6 +10,9 @@ import type { RequestId } from "./RequestId"; import type { AppsListParams } from "./v2/AppsListParams"; import type { CancelLoginAccountParams } from "./v2/CancelLoginAccountParams"; import type { CommandExecParams } from "./v2/CommandExecParams"; +import type { CommandExecResizeParams } from "./v2/CommandExecResizeParams"; +import type { CommandExecTerminateParams } from "./v2/CommandExecTerminateParams"; +import type { CommandExecWriteParams } from "./v2/CommandExecWriteParams"; import type { ConfigBatchWriteParams } from "./v2/ConfigBatchWriteParams"; import type { ConfigReadParams } from "./v2/ConfigReadParams"; import type { ConfigValueWriteParams } from "./v2/ConfigValueWriteParams"; @@ -50,4 +53,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "skills/remote/list", id: RequestId, params: SkillsRemoteReadParams, } | { "method": "skills/remote/export", id: RequestId, params: SkillsRemoteWriteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "skills/remote/list", id: RequestId, params: SkillsRemoteReadParams, } | { "method": "skills/remote/export", id: RequestId, params: SkillsRemoteWriteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts index 8157ba2f48f..daf23faa2cb 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts @@ -8,6 +8,7 @@ import type { AccountRateLimitsUpdatedNotification } from "./v2/AccountRateLimit import type { AccountUpdatedNotification } from "./v2/AccountUpdatedNotification"; import type { AgentMessageDeltaNotification } from "./v2/AgentMessageDeltaNotification"; import type { AppListUpdatedNotification } from "./v2/AppListUpdatedNotification"; +import type { CommandExecOutputDeltaNotification } from "./v2/CommandExecOutputDeltaNotification"; import type { CommandExecutionOutputDeltaNotification } from "./v2/CommandExecutionOutputDeltaNotification"; import type { ConfigWarningNotification } from "./v2/ConfigWarningNotification"; import type { ContextCompactedNotification } from "./v2/ContextCompactedNotification"; @@ -49,4 +50,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW /** * Notification sent from the server to the client. */ -export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; +export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecOutputDeltaNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecOutputDeltaNotification.ts new file mode 100644 index 00000000000..dc19080de3a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecOutputDeltaNotification.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CommandExecOutputStream } from "./CommandExecOutputStream"; + +export type CommandExecOutputDeltaNotification = { processId: string, stream: CommandExecOutputStream, deltaBase64: string, capReached: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecOutputStream.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecOutputStream.ts new file mode 100644 index 00000000000..fd70f68612c --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecOutputStream.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type CommandExecOutputStream = "stdout" | "stderr"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts index 847e19d6939..27f87551e79 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CommandExecTerminalSize } from "./CommandExecTerminalSize"; import type { SandboxPolicy } from "./SandboxPolicy"; -export type CommandExecParams = { command: Array, timeoutMs?: number | null, cwd?: string | null, sandboxPolicy?: SandboxPolicy | null, }; +export type CommandExecParams = { command: Array, processId?: string | null, tty?: boolean, streamStdin?: boolean, streamStdoutStderr?: boolean, outputBytesCap?: number | null, disableOutputCap?: boolean, disableTimeout?: boolean, timeoutMs?: number | null, cwd?: string | null, env?: { [key in string]?: string | null } | null, size?: CommandExecTerminalSize | null, sandboxPolicy?: SandboxPolicy | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecResizeParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecResizeParams.ts new file mode 100644 index 00000000000..350bf07700e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecResizeParams.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CommandExecTerminalSize } from "./CommandExecTerminalSize"; + +export type CommandExecResizeParams = { processId: string, size: CommandExecTerminalSize, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecResizeResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecResizeResponse.ts new file mode 100644 index 00000000000..681f26b0170 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecResizeResponse.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type CommandExecResizeResponse = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminalSize.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminalSize.ts new file mode 100644 index 00000000000..9ca9982e2c7 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminalSize.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type CommandExecTerminalSize = { rows: number, cols: number, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminateParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminateParams.ts new file mode 100644 index 00000000000..376dd4cf04f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminateParams.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type CommandExecTerminateParams = { processId: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminateResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminateResponse.ts new file mode 100644 index 00000000000..6c36ab7c9d1 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminateResponse.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type CommandExecTerminateResponse = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecWriteParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecWriteParams.ts new file mode 100644 index 00000000000..b4b61baf1c5 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecWriteParams.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type CommandExecWriteParams = { processId: string, deltaBase64?: string | null, closeStdin?: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecWriteResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecWriteResponse.ts new file mode 100644 index 00000000000..a333aba1d48 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecWriteResponse.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type CommandExecWriteResponse = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 50e489017a0..1acf2b6b19f 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -38,8 +38,17 @@ export type { CollabAgentTool } from "./CollabAgentTool"; export type { CollabAgentToolCallStatus } from "./CollabAgentToolCallStatus"; export type { CollaborationModeMask } from "./CollaborationModeMask"; export type { CommandAction } from "./CommandAction"; +export type { CommandExecOutputDeltaNotification } from "./CommandExecOutputDeltaNotification"; +export type { CommandExecOutputStream } from "./CommandExecOutputStream"; export type { CommandExecParams } from "./CommandExecParams"; +export type { CommandExecResizeParams } from "./CommandExecResizeParams"; +export type { CommandExecResizeResponse } from "./CommandExecResizeResponse"; export type { CommandExecResponse } from "./CommandExecResponse"; +export type { CommandExecTerminalSize } from "./CommandExecTerminalSize"; +export type { CommandExecTerminateParams } from "./CommandExecTerminateParams"; +export type { CommandExecTerminateResponse } from "./CommandExecTerminateResponse"; +export type { CommandExecWriteParams } from "./CommandExecWriteParams"; +export type { CommandExecWriteResponse } from "./CommandExecWriteResponse"; export type { CommandExecutionApprovalDecision } from "./CommandExecutionApprovalDecision"; export type { CommandExecutionOutputDeltaNotification } from "./CommandExecutionOutputDeltaNotification"; export type { CommandExecutionRequestApprovalParams } from "./CommandExecutionRequestApprovalParams"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 463084ae93e..b62b667ec09 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -382,6 +382,18 @@ client_request_definitions! { params: v2::CommandExecParams, response: v2::CommandExecResponse, }, + CommandExecWrite => "command/exec/write" { + params: v2::CommandExecWriteParams, + response: v2::CommandExecWriteResponse, + }, + CommandExecTerminate => "command/exec/terminate" { + params: v2::CommandExecTerminateParams, + response: v2::CommandExecTerminateResponse, + }, + CommandExecResize => "command/exec/resize" { + params: v2::CommandExecResizeParams, + response: v2::CommandExecResizeResponse, + }, ConfigRead => "config/read" { params: v2::ConfigReadParams, @@ -781,6 +793,7 @@ server_notification_definitions! { AgentMessageDelta => "item/agentMessage/delta" (v2::AgentMessageDeltaNotification), /// EXPERIMENTAL - proposed plan streaming deltas for plan items. PlanDelta => "item/plan/delta" (v2::PlanDeltaNotification), + CommandExecOutputDelta => "command/exec/outputDelta" (v2::CommandExecOutputDeltaNotification), CommandExecutionOutputDelta => "item/commandExecution/outputDelta" (v2::CommandExecutionOutputDeltaNotification), TerminalInteraction => "item/commandExecution/terminalInteraction" (v2::TerminalInteractionNotification), FileChangeOutputDelta => "item/fileChange/outputDelta" (v2::FileChangeOutputDeltaNotification), diff --git a/codex-rs/app-server-protocol/src/protocol/mappers.rs b/codex-rs/app-server-protocol/src/protocol/mappers.rs index f708c1fa855..93f12691be5 100644 --- a/codex-rs/app-server-protocol/src/protocol/mappers.rs +++ b/codex-rs/app-server-protocol/src/protocol/mappers.rs @@ -1,14 +1,22 @@ use crate::protocol::v1; use crate::protocol::v2; - impl From for v2::CommandExecParams { fn from(value: v1::ExecOneOffCommandParams) -> Self { Self { command: value.command, + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, timeout_ms: value .timeout_ms .map(|timeout| i64::try_from(timeout).unwrap_or(60_000)), cwd: value.cwd, + env: None, + size: None, sandbox_policy: value.sandbox_policy.map(std::convert::Into::into), } } diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index c65c41d1a72..2ebed2d1213 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1804,17 +1804,44 @@ pub struct FeedbackUploadResponse { pub thread_id: String, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecTerminalSize { + pub rows: u16, + pub cols: u16, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct CommandExecParams { pub command: Vec, + #[ts(optional = nullable)] + pub process_id: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub tty: bool, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub stream_stdin: bool, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub stream_stdout_stderr: bool, + #[ts(type = "number | null")] + #[ts(optional = nullable)] + pub output_bytes_cap: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub disable_output_cap: bool, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub disable_timeout: bool, #[ts(type = "number | null")] #[ts(optional = nullable)] pub timeout_ms: Option, #[ts(optional = nullable)] pub cwd: Option, #[ts(optional = nullable)] + pub env: Option>>, + #[ts(optional = nullable)] + pub size: Option, + #[ts(optional = nullable)] pub sandbox_policy: Option, } @@ -1827,6 +1854,55 @@ pub struct CommandExecResponse { pub stderr: String, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecWriteParams { + pub process_id: String, + #[ts(optional = nullable)] + pub delta_base64: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub close_stdin: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecWriteResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecTerminateParams { + pub process_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecTerminateResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecResizeParams { + pub process_id: String, + pub size: CommandExecTerminalSize, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecResizeResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum CommandExecOutputStream { + Stdout, + Stderr, +} + // === Threads, Turns, and Items === // Thread APIs #[derive( @@ -3965,6 +4041,16 @@ pub struct CommandExecutionOutputDeltaNotification { pub delta: String, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecOutputDeltaNotification { + pub process_id: String, + pub stream: CommandExecOutputStream, + pub delta_base64: String, + pub cap_reached: bool, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -4947,6 +5033,300 @@ mod tests { ); } + #[test] + fn command_exec_params_default_optional_streaming_flags() { + let params = serde_json::from_value::(json!({ + "command": ["ls", "-la"], + "timeoutMs": 1000, + "cwd": "/tmp" + })) + .expect("command/exec payload should deserialize"); + + assert_eq!( + params, + CommandExecParams { + command: vec!["ls".to_string(), "-la".to_string()], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: Some(1000), + cwd: Some(PathBuf::from("/tmp")), + env: None, + size: None, + sandbox_policy: None, + } + ); + } + + #[test] + fn command_exec_params_round_trips_disable_timeout() { + let params = CommandExecParams { + command: vec!["sleep".to_string(), "30".to_string()], + process_id: Some("sleep-1".to_string()), + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: true, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); + assert_eq!( + value, + json!({ + "command": ["sleep", "30"], + "processId": "sleep-1", + "disableTimeout": true, + "timeoutMs": null, + "cwd": null, + "env": null, + "size": null, + "sandboxPolicy": null, + "outputBytesCap": null, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize round-trip"); + assert_eq!(decoded, params); + } + + #[test] + fn command_exec_params_round_trips_disable_output_cap() { + let params = CommandExecParams { + command: vec!["yes".to_string()], + process_id: Some("yes-1".to_string()), + tty: false, + stream_stdin: false, + stream_stdout_stderr: true, + output_bytes_cap: None, + disable_output_cap: true, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); + assert_eq!( + value, + json!({ + "command": ["yes"], + "processId": "yes-1", + "streamStdoutStderr": true, + "outputBytesCap": null, + "disableOutputCap": true, + "timeoutMs": null, + "cwd": null, + "env": null, + "size": null, + "sandboxPolicy": null, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize round-trip"); + assert_eq!(decoded, params); + } + + #[test] + fn command_exec_params_round_trips_env_overrides_and_unsets() { + let params = CommandExecParams { + command: vec!["printenv".to_string(), "FOO".to_string()], + process_id: Some("env-1".to_string()), + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: Some(HashMap::from([ + ("FOO".to_string(), Some("override".to_string())), + ("BAR".to_string(), Some("added".to_string())), + ("BAZ".to_string(), None), + ])), + size: None, + sandbox_policy: None, + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); + assert_eq!( + value, + json!({ + "command": ["printenv", "FOO"], + "processId": "env-1", + "outputBytesCap": null, + "timeoutMs": null, + "cwd": null, + "env": { + "FOO": "override", + "BAR": "added", + "BAZ": null, + }, + "size": null, + "sandboxPolicy": null, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize round-trip"); + assert_eq!(decoded, params); + } + + #[test] + fn command_exec_write_round_trips_close_only_payload() { + let params = CommandExecWriteParams { + process_id: "proc-7".to_string(), + delta_base64: None, + close_stdin: true, + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec/write params"); + assert_eq!( + value, + json!({ + "processId": "proc-7", + "deltaBase64": null, + "closeStdin": true, + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize round-trip"); + assert_eq!(decoded, params); + } + + #[test] + fn command_exec_terminate_round_trips() { + let params = CommandExecTerminateParams { + process_id: "proc-8".to_string(), + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec/terminate params"); + assert_eq!( + value, + json!({ + "processId": "proc-8", + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize round-trip"); + assert_eq!(decoded, params); + } + + #[test] + fn command_exec_params_round_trip_with_size() { + let params = CommandExecParams { + command: vec!["top".to_string()], + process_id: Some("pty-1".to_string()), + tty: true, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: Some(CommandExecTerminalSize { + rows: 40, + cols: 120, + }), + sandbox_policy: None, + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); + assert_eq!( + value, + json!({ + "command": ["top"], + "processId": "pty-1", + "tty": true, + "outputBytesCap": null, + "timeoutMs": null, + "cwd": null, + "env": null, + "size": { + "rows": 40, + "cols": 120, + }, + "sandboxPolicy": null, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize round-trip"); + assert_eq!(decoded, params); + } + + #[test] + fn command_exec_resize_round_trips() { + let params = CommandExecResizeParams { + process_id: "proc-9".to_string(), + size: CommandExecTerminalSize { + rows: 50, + cols: 160, + }, + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec/resize params"); + assert_eq!( + value, + json!({ + "processId": "proc-9", + "size": { + "rows": 50, + "cols": 160, + }, + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize round-trip"); + assert_eq!(decoded, params); + } + + #[test] + fn command_exec_output_delta_round_trips() { + let notification = CommandExecOutputDeltaNotification { + process_id: "proc-1".to_string(), + stream: CommandExecOutputStream::Stdout, + delta_base64: "AQI=".to_string(), + cap_reached: false, + }; + + let value = serde_json::to_value(¬ification) + .expect("serialize command/exec/outputDelta notification"); + assert_eq!( + value, + json!({ + "processId": "proc-1", + "stream": "stdout", + "deltaBase64": "AQI=", + "capReached": false, + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize round-trip"); + assert_eq!(decoded, notification); + } + #[test] fn sandbox_policy_round_trips_external_sandbox_network_access() { let v2_policy = SandboxPolicy::ExternalSandbox { diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index 28579c6c8fe..51931d6a80f 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -96,6 +96,7 @@ const NOTIFICATIONS_TO_OPT_OUT: &[&str] = &[ "codex/event/item_started", "codex/event/item_completed", // v2 item deltas. + "command/exec/outputDelta", "item/agentMessage/delta", "item/plan/delta", "item/commandExecution/outputDelta", diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index c8bcdfcea62..b2126f19476 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -18,12 +18,14 @@ workspace = true [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } +base64 = { workspace = true } codex-arg0 = { workspace = true } codex-cloud-requirements = { workspace = true } codex-core = { workspace = true } codex-otel = { workspace = true } codex-shell-command = { workspace = true } codex-utils-cli = { workspace = true } +codex-utils-pty = { workspace = true } codex-backend-client = { workspace = true } codex-file-search = { workspace = true } codex-chatgpt = { workspace = true } @@ -64,7 +66,6 @@ axum = { workspace = true, default-features = false, features = [ "json", "tokio", ] } -base64 = { workspace = true } core_test_support = { workspace = true } codex-utils-cargo-bin = { workspace = true } pretty_assertions = { workspace = true } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 49d216099cd..d30f627da98 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -144,6 +144,7 @@ Example with notification opt-out: - `thread/realtime/stop` — stop the active realtime session for the thread (experimental); returns `{}`. - `review/start` — kick off Codex’s automated reviewer for a thread; responds like `turn/start` and emits `item/started`/`item/completed` notifications with `enteredReviewMode` and `exitedReviewMode` items, plus a final assistant `agentMessage` containing the review. - `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation). +- `command/exec/resize` — resize a running PTY-backed `command/exec` session by `processId`; returns `{}`. - `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. @@ -613,11 +614,21 @@ Run a standalone command (argv vector) in the server’s sandbox without creatin ```json { "method": "command/exec", "id": 32, "params": { "command": ["ls", "-la"], + "processId": "ls-1", // optional string; required for streaming and ability to terminate the process "cwd": "/Users/me/project", // optional; defaults to server cwd + "env": { "FOO": "override" }, // optional; merges into the server env and overrides matching names + "size": { "rows": 40, "cols": 120 }, // optional; PTY size in character cells, only valid with tty=true "sandboxPolicy": { "type": "workspaceWrite" }, // optional; defaults to user config - "timeoutMs": 10000 // optional; ms timeout; defaults to server timeout + "outputBytesCap": 1048576, // optional; per-stream capture cap + "disableOutputCap": false, // optional; cannot be combined with outputBytesCap + "timeoutMs": 10000, // optional; ms timeout; defaults to server timeout + "disableTimeout": false // optional; cannot be combined with timeoutMs +} } +{ "id": 32, "result": { + "exitCode": 0, + "stdout": "...", + "stderr": "" } } -{ "id": 32, "result": { "exitCode": 0, "stdout": "...", "stderr": "" } } ``` - For clients that are already sandboxed externally, set `sandboxPolicy` to `{"type":"externalSandbox","networkAccess":"enabled"}` (or omit `networkAccess` to keep it restricted). Codex will not enforce its own sandbox in this mode; it tells the model it has full file-system access and passes the `networkAccess` state through `environment_context`. @@ -626,7 +637,70 @@ Notes: - Empty `command` arrays are rejected. - `sandboxPolicy` accepts the same shape used by `turn/start` (e.g., `dangerFullAccess`, `readOnly`, `workspaceWrite` with flags, `externalSandbox` with `networkAccess` `restricted|enabled`). +- `env` merges into the environment produced by the server's shell environment policy. Matching names are overridden; unspecified variables are left intact. - When omitted, `timeoutMs` falls back to the server default. +- When omitted, `outputBytesCap` falls back to the server default of 1 MiB per stream. +- `disableOutputCap: true` disables stdout/stderr capture truncation for that `command/exec` request. It cannot be combined with `outputBytesCap`. +- `disableTimeout: true` disables the timeout entirely for that `command/exec` request. It cannot be combined with `timeoutMs`. +- `processId` is optional for buffered execution. When omitted, Codex generates an internal id for lifecycle tracking, but `tty`, `streamStdin`, and `streamStdoutStderr` must stay disabled and follow-up `command/exec/write` / `command/exec/terminate` calls are not available for that command. +- `size` is only valid when `tty: true`. It sets the initial PTY size in character cells. +- Buffered Windows sandbox execution accepts `processId` for correlation, but `command/exec/write` and `command/exec/terminate` are still unsupported for those requests. +- Buffered Windows sandbox execution also requires the default output cap; custom `outputBytesCap` and `disableOutputCap` are unsupported there. +- `tty`, `streamStdin`, and `streamStdoutStderr` are optional booleans. Legacy requests that omit them continue to use buffered execution. +- `tty: true` implies PTY mode plus `streamStdin: true` and `streamStdoutStderr: true`. +- `tty` and `streamStdin` do not disable the timeout on their own; omit `timeoutMs` to use the server default timeout, or set `disableTimeout: true` to keep the process alive until exit or explicit termination. +- `outputBytesCap` applies independently to `stdout` and `stderr`, and streamed bytes are not duplicated into the final response. +- The `command/exec` response is deferred until the process exits and is sent only after all `command/exec/outputDelta` notifications for that connection have been emitted. +- `command/exec/outputDelta` notifications are connection-scoped. If the originating connection closes, the server terminates the process. + +Streaming stdin/stdout uses base64 so PTY sessions can carry arbitrary bytes: + +```json +{ "method": "command/exec", "id": 33, "params": { + "command": ["bash", "-i"], + "processId": "bash-1", + "tty": true, + "outputBytesCap": 32768 +} } +{ "method": "command/exec/outputDelta", "params": { + "processId": "bash-1", + "stream": "stdout", + "deltaBase64": "YmFzaC00LjQkIA==", + "capReached": false +} } +{ "method": "command/exec/write", "id": 34, "params": { + "processId": "bash-1", + "deltaBase64": "cHdkCg==" +} } +{ "id": 34, "result": {} } +{ "method": "command/exec/write", "id": 35, "params": { + "processId": "bash-1", + "closeStdin": true +} } +{ "id": 35, "result": {} } +{ "method": "command/exec/resize", "id": 36, "params": { + "processId": "bash-1", + "size": { "rows": 48, "cols": 160 } +} } +{ "id": 36, "result": {} } +{ "method": "command/exec/terminate", "id": 37, "params": { + "processId": "bash-1" +} } +{ "id": 37, "result": {} } +{ "id": 33, "result": { + "exitCode": 137, + "stdout": "", + "stderr": "" +} } +``` + +- `command/exec/write` accepts either `deltaBase64`, `closeStdin`, or both. +- Clients may supply a connection-scoped string `processId` in `command/exec`; `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` only accept those client-supplied string ids. +- `command/exec/outputDelta.processId` is always the client-supplied string id from the original `command/exec` request. +- `command/exec/outputDelta.stream` is `stdout` or `stderr`. PTY mode multiplexes terminal output through `stdout`. +- `command/exec/outputDelta.capReached` is `true` on the final streamed chunk for a stream when `outputBytesCap` truncates that stream; later output on that stream is dropped. +- `command/exec.params.env` overrides the server-computed environment per key; set a key to `null` to unset an inherited variable. +- `command/exec/resize` is only supported for PTY-backed `command/exec` sessions. ## Events diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 7898e2ffbbd..785198cbdb0 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1,4 +1,6 @@ use crate::bespoke_event_handling::apply_bespoke_event_handling; +use crate::command_exec::CommandExecManager; +use crate::command_exec::StartCommandExecParams; use crate::error_code::INPUT_TOO_LARGE_ERROR_CODE; use crate::error_code::INTERNAL_ERROR_CODE; use crate::error_code::INVALID_PARAMS_ERROR_CODE; @@ -34,10 +36,12 @@ use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::CommandExecParams; +use codex_app_server_protocol::CommandExecResizeParams; +use codex_app_server_protocol::CommandExecTerminateParams; +use codex_app_server_protocol::CommandExecWriteParams; use codex_app_server_protocol::ConversationGitInfo; use codex_app_server_protocol::ConversationSummary; use codex_app_server_protocol::DynamicToolSpec as ApiDynamicToolSpec; -use codex_app_server_protocol::ExecOneOffCommandResponse; use codex_app_server_protocol::ExperimentalFeature as ApiExperimentalFeature; use codex_app_server_protocol::ExperimentalFeatureListParams; use codex_app_server_protocol::ExperimentalFeatureListResponse; @@ -192,6 +196,7 @@ use codex_core::connectors::filter_disallowed_connectors; use codex_core::connectors::merge_plugin_apps; use codex_core::default_client::set_default_client_residency_requirement; use codex_core::error::CodexErr; +use codex_core::exec::ExecExpiration; use codex_core::exec::ExecParams; use codex_core::exec_env::create_env; use codex_core::features::FEATURES; @@ -263,6 +268,7 @@ use codex_state::StateRuntime; use codex_state::ThreadMetadataBuilder; use codex_state::log_db::LogDbLayer; use codex_utils_json_to_toml::json_to_toml; +use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; use std::collections::HashMap; use std::collections::HashSet; use std::ffi::OsStr; @@ -281,6 +287,7 @@ use tokio::sync::Mutex; use tokio::sync::broadcast; use tokio::sync::oneshot; use tokio::sync::watch; +use tokio_util::sync::CancellationToken; use toml::Value as TomlValue; use tracing::error; use tracing::info; @@ -368,6 +375,7 @@ pub(crate) struct CodexMessageProcessor { pending_thread_unloads: Arc>>, thread_state_manager: ThreadStateManager, thread_watch_manager: ThreadWatchManager, + command_exec_manager: CommandExecManager, pending_fuzzy_searches: Arc>>>, fuzzy_search_sessions: Arc>>, feedback: CodexFeedback, @@ -466,6 +474,7 @@ impl CodexMessageProcessor { pending_thread_unloads: Arc::new(Mutex::new(HashSet::new())), thread_state_manager: ThreadStateManager::new(), thread_watch_manager: ThreadWatchManager::new_with_outgoing(outgoing), + command_exec_manager: CommandExecManager::default(), pending_fuzzy_searches: Arc::new(Mutex::new(HashMap::new())), fuzzy_search_sessions: Arc::new(Mutex::new(HashMap::new())), feedback, @@ -815,6 +824,18 @@ impl CodexMessageProcessor { self.exec_one_off_command(to_connection_request_id(request_id), params) .await; } + ClientRequest::CommandExecWrite { request_id, params } => { + self.command_exec_write(to_connection_request_id(request_id), params) + .await; + } + ClientRequest::CommandExecResize { request_id, params } => { + self.command_exec_resize(to_connection_request_id(request_id), params) + .await; + } + ClientRequest::CommandExecTerminate { request_id, params } => { + self.command_exec_terminate(to_connection_request_id(request_id), params) + .await; + } ClientRequest::ConfigRead { .. } | ClientRequest::ConfigValueWrite { .. } | ClientRequest::ConfigBatchWrite { .. } => { @@ -1487,11 +1508,84 @@ impl CodexMessageProcessor { return; } - let cwd = params.cwd.unwrap_or_else(|| self.config.cwd.clone()); - let env = create_env(&self.config.permissions.shell_environment_policy, None); - let timeout_ms = params - .timeout_ms - .and_then(|timeout_ms| u64::try_from(timeout_ms).ok()); + let CommandExecParams { + command, + process_id, + tty, + stream_stdin, + stream_stdout_stderr, + output_bytes_cap, + disable_output_cap, + disable_timeout, + timeout_ms, + cwd, + env: env_overrides, + size, + sandbox_policy, + } = params; + + if size.is_some() && !tty { + let error = JSONRPCErrorError { + code: INVALID_PARAMS_ERROR_CODE, + message: "command/exec size requires tty: true".to_string(), + data: None, + }; + self.outgoing.send_error(request, error).await; + return; + } + + if disable_output_cap && output_bytes_cap.is_some() { + let error = JSONRPCErrorError { + code: INVALID_PARAMS_ERROR_CODE, + message: "command/exec cannot set both outputBytesCap and disableOutputCap" + .to_string(), + data: None, + }; + self.outgoing.send_error(request, error).await; + return; + } + + if disable_timeout && timeout_ms.is_some() { + let error = JSONRPCErrorError { + code: INVALID_PARAMS_ERROR_CODE, + message: "command/exec cannot set both timeoutMs and disableTimeout".to_string(), + data: None, + }; + self.outgoing.send_error(request, error).await; + return; + } + + let cwd = cwd.unwrap_or_else(|| self.config.cwd.clone()); + let mut env = create_env(&self.config.permissions.shell_environment_policy, None); + if let Some(env_overrides) = env_overrides { + for (key, value) in env_overrides { + match value { + Some(value) => { + env.insert(key, value); + } + None => { + env.remove(&key); + } + } + } + } + let timeout_ms = match timeout_ms { + Some(timeout_ms) => match u64::try_from(timeout_ms) { + Ok(timeout_ms) => Some(timeout_ms), + Err(_) => { + let error = JSONRPCErrorError { + code: INVALID_PARAMS_ERROR_CODE, + message: format!( + "command/exec timeoutMs must be non-negative, got {timeout_ms}" + ), + data: None, + }; + self.outgoing.send_error(request, error).await; + return; + } + }, + None => None, + }; let managed_network_requirements_enabled = self.config.managed_network_requirements_enabled(); let started_network_proxy = match self.config.permissions.network.as_ref() { @@ -1519,10 +1613,23 @@ impl CodexMessageProcessor { None => None, }; let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config); + let output_bytes_cap = if disable_output_cap { + None + } else { + Some(output_bytes_cap.unwrap_or(DEFAULT_OUTPUT_BYTES_CAP)) + }; + let expiration = if disable_timeout { + ExecExpiration::Cancellation(CancellationToken::new()) + } else { + match timeout_ms { + Some(timeout_ms) => timeout_ms.into(), + None => ExecExpiration::DefaultTimeout, + } + }; let exec_params = ExecParams { - command: params.command, + command, cwd, - expiration: timeout_ms.into(), + expiration, env, network: started_network_proxy .as_ref() @@ -1533,7 +1640,7 @@ impl CodexMessageProcessor { arg0: None, }; - let requested_policy = params.sandbox_policy.map(|policy| policy.to_core()); + let requested_policy = sandbox_policy.map(|policy| policy.to_core()); let effective_policy = match requested_policy { Some(policy) => match self.config.permissions.sandbox_policy.can_set(&policy) { Ok(()) => policy, @@ -1552,39 +1659,104 @@ impl CodexMessageProcessor { let codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone(); let outgoing = self.outgoing.clone(); - let request_for_task = request; + let request_for_task = request.clone(); let sandbox_cwd = self.config.cwd.clone(); let started_network_proxy_for_task = started_network_proxy; let use_linux_sandbox_bwrap = self.config.features.enabled(Feature::UseLinuxSandboxBwrap); + let size = match size.map(crate::command_exec::terminal_size_from_protocol) { + Some(Ok(size)) => Some(size), + Some(Err(error)) => { + self.outgoing.send_error(request, error).await; + return; + } + None => None, + }; + + match codex_core::exec::build_exec_request( + exec_params, + &effective_policy, + sandbox_cwd.as_path(), + &codex_linux_sandbox_exe, + use_linux_sandbox_bwrap, + ) { + Ok(exec_request) => { + if let Err(error) = self + .command_exec_manager + .start(StartCommandExecParams { + outgoing, + request_id: request_for_task, + process_id, + exec_request, + started_network_proxy: started_network_proxy_for_task, + tty, + stream_stdin, + stream_stdout_stderr, + output_bytes_cap, + size, + }) + .await + { + self.outgoing.send_error(request, error).await; + } + } + Err(err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("exec failed: {err}"), + data: None, + }; + self.outgoing.send_error(request, error).await; + } + } + } + async fn command_exec_write( + &self, + request_id: ConnectionRequestId, + params: CommandExecWriteParams, + ) { + let command_exec_manager = self.command_exec_manager.clone(); + let outgoing = self.outgoing.clone(); tokio::spawn(async move { - let _started_network_proxy = started_network_proxy_for_task; - match codex_core::exec::process_exec_tool_call( - exec_params, - &effective_policy, - sandbox_cwd.as_path(), - &codex_linux_sandbox_exe, - use_linux_sandbox_bwrap, - None, - ) - .await + match command_exec_manager.write(request_id.clone(), params).await { + Ok(response) => outgoing.send_response(request_id, response).await, + Err(error) => outgoing.send_error(request_id, error).await, + } + }); + } + + async fn command_exec_resize( + &self, + request_id: ConnectionRequestId, + params: CommandExecResizeParams, + ) { + let command_exec_manager = self.command_exec_manager.clone(); + let outgoing = self.outgoing.clone(); + tokio::spawn(async move { + match command_exec_manager + .resize(request_id.clone(), params) + .await { - Ok(output) => { - let response = ExecOneOffCommandResponse { - exit_code: output.exit_code, - stdout: output.stdout.text, - stderr: output.stderr.text, - }; - outgoing.send_response(request_for_task, response).await; - } - Err(err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("exec failed: {err}"), - data: None, - }; - outgoing.send_error(request_for_task, error).await; - } + Ok(response) => outgoing.send_response(request_id, response).await, + Err(error) => outgoing.send_error(request_id, error).await, + } + }); + } + + async fn command_exec_terminate( + &self, + request_id: ConnectionRequestId, + params: CommandExecTerminateParams, + ) { + let command_exec_manager = self.command_exec_manager.clone(); + let outgoing = self.outgoing.clone(); + tokio::spawn(async move { + match command_exec_manager + .terminate(request_id.clone(), params) + .await + { + Ok(response) => outgoing.send_response(request_id, response).await, + Err(error) => outgoing.send_error(request_id, error).await, } }); } @@ -2856,6 +3028,9 @@ impl CodexMessageProcessor { } pub(crate) async fn connection_closed(&mut self, connection_id: ConnectionId) { + self.command_exec_manager + .connection_closed(connection_id) + .await; self.thread_state_manager .remove_connection(connection_id) .await; diff --git a/codex-rs/app-server/src/command_exec.rs b/codex-rs/app-server/src/command_exec.rs new file mode 100644 index 00000000000..85448ea9635 --- /dev/null +++ b/codex-rs/app-server/src/command_exec.rs @@ -0,0 +1,1004 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::AtomicI64; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use codex_app_server_protocol::CommandExecOutputDeltaNotification; +use codex_app_server_protocol::CommandExecOutputStream; +use codex_app_server_protocol::CommandExecResizeParams; +use codex_app_server_protocol::CommandExecResizeResponse; +use codex_app_server_protocol::CommandExecResponse; +use codex_app_server_protocol::CommandExecTerminalSize; +use codex_app_server_protocol::CommandExecTerminateParams; +use codex_app_server_protocol::CommandExecTerminateResponse; +use codex_app_server_protocol::CommandExecWriteParams; +use codex_app_server_protocol::CommandExecWriteResponse; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::ServerNotification; +use codex_core::bytes_to_string_smart; +use codex_core::config::StartedNetworkProxy; +use codex_core::exec::DEFAULT_EXEC_COMMAND_TIMEOUT_MS; +use codex_core::exec::ExecExpiration; +use codex_core::exec::IO_DRAIN_TIMEOUT_MS; +use codex_core::exec::SandboxType; +use codex_core::sandboxing::ExecRequest; +use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; +use codex_utils_pty::ProcessHandle; +use codex_utils_pty::SpawnedProcess; +use codex_utils_pty::TerminalSize; +use tokio::sync::Mutex; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio::sync::watch; + +use crate::error_code::INTERNAL_ERROR_CODE; +use crate::error_code::INVALID_PARAMS_ERROR_CODE; +use crate::error_code::INVALID_REQUEST_ERROR_CODE; +use crate::outgoing_message::ConnectionId; +use crate::outgoing_message::ConnectionRequestId; +use crate::outgoing_message::OutgoingMessageSender; + +const EXEC_TIMEOUT_EXIT_CODE: i32 = 124; + +#[derive(Clone)] +pub(crate) struct CommandExecManager { + sessions: Arc>>, + next_generated_process_id: Arc, +} + +impl Default for CommandExecManager { + fn default() -> Self { + Self { + sessions: Arc::new(Mutex::new(HashMap::new())), + next_generated_process_id: Arc::new(AtomicI64::new(1)), + } + } +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct ConnectionProcessId { + connection_id: ConnectionId, + process_id: InternalProcessId, +} + +#[derive(Clone)] +enum CommandExecSession { + Active { + control_tx: mpsc::Sender, + }, + UnsupportedWindowsSandbox, +} + +enum CommandControl { + Write { delta: Vec, close_stdin: bool }, + Resize { size: TerminalSize }, + Terminate, +} + +struct CommandControlRequest { + control: CommandControl, + response_tx: Option>>, +} + +pub(crate) struct StartCommandExecParams { + pub(crate) outgoing: Arc, + pub(crate) request_id: ConnectionRequestId, + pub(crate) process_id: Option, + pub(crate) exec_request: ExecRequest, + pub(crate) started_network_proxy: Option, + pub(crate) tty: bool, + pub(crate) stream_stdin: bool, + pub(crate) stream_stdout_stderr: bool, + pub(crate) output_bytes_cap: Option, + pub(crate) size: Option, +} + +struct RunCommandParams { + outgoing: Arc, + request_id: ConnectionRequestId, + process_id: Option, + spawned: SpawnedProcess, + control_rx: mpsc::Receiver, + stream_stdin: bool, + stream_stdout_stderr: bool, + expiration: ExecExpiration, + output_bytes_cap: Option, +} + +struct SpawnProcessOutputParams { + connection_id: ConnectionId, + process_id: Option, + output_rx: mpsc::Receiver>, + stdio_timeout_rx: watch::Receiver, + outgoing: Arc, + stream: CommandExecOutputStream, + stream_output: bool, + output_bytes_cap: Option, +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +enum InternalProcessId { + Generated(i64), + Client(String), +} + +trait InternalProcessIdExt { + fn error_repr(&self) -> String; +} + +impl InternalProcessIdExt for InternalProcessId { + fn error_repr(&self) -> String { + match self { + Self::Generated(id) => id.to_string(), + Self::Client(id) => serde_json::to_string(id).unwrap_or_else(|_| format!("{id:?}")), + } + } +} + +impl CommandExecManager { + pub(crate) async fn start( + &self, + params: StartCommandExecParams, + ) -> Result<(), JSONRPCErrorError> { + let StartCommandExecParams { + outgoing, + request_id, + process_id, + exec_request, + started_network_proxy, + tty, + stream_stdin, + stream_stdout_stderr, + output_bytes_cap, + size, + } = params; + if process_id.is_none() && (tty || stream_stdin || stream_stdout_stderr) { + return Err(invalid_request( + "command/exec tty or streaming requires a client-supplied processId".to_string(), + )); + } + let process_id = process_id.map_or_else( + || { + InternalProcessId::Generated( + self.next_generated_process_id + .fetch_add(1, Ordering::Relaxed), + ) + }, + InternalProcessId::Client, + ); + let process_key = ConnectionProcessId { + connection_id: request_id.connection_id, + process_id: process_id.clone(), + }; + + if matches!(exec_request.sandbox, SandboxType::WindowsRestrictedToken) { + if tty || stream_stdin || stream_stdout_stderr { + return Err(invalid_request( + "streaming command/exec is not supported with windows sandbox".to_string(), + )); + } + if output_bytes_cap != Some(DEFAULT_OUTPUT_BYTES_CAP) { + return Err(invalid_request( + "custom outputBytesCap is not supported with windows sandbox".to_string(), + )); + } + if let InternalProcessId::Client(_) = &process_id { + let mut sessions = self.sessions.lock().await; + if sessions.contains_key(&process_key) { + return Err(invalid_request(format!( + "duplicate active command/exec process id: {}", + process_key.process_id.error_repr(), + ))); + } + sessions.insert( + process_key.clone(), + CommandExecSession::UnsupportedWindowsSandbox, + ); + } + let sessions = Arc::clone(&self.sessions); + tokio::spawn(async move { + let _started_network_proxy = started_network_proxy; + match codex_core::sandboxing::execute_env(exec_request, None).await { + Ok(output) => { + outgoing + .send_response( + request_id, + CommandExecResponse { + exit_code: output.exit_code, + stdout: output.stdout.text, + stderr: output.stderr.text, + }, + ) + .await; + } + Err(err) => { + outgoing + .send_error(request_id, internal_error(format!("exec failed: {err}"))) + .await; + } + } + sessions.lock().await.remove(&process_key); + }); + return Ok(()); + } + + let ExecRequest { + command, + cwd, + env, + expiration, + sandbox: _sandbox, + arg0, + .. + } = exec_request; + + let stream_stdin = tty || stream_stdin; + let stream_stdout_stderr = tty || stream_stdout_stderr; + let (control_tx, control_rx) = mpsc::channel(32); + let notification_process_id = match &process_id { + InternalProcessId::Generated(_) => None, + InternalProcessId::Client(process_id) => Some(process_id.clone()), + }; + + let sessions = Arc::clone(&self.sessions); + let (program, args) = command + .split_first() + .ok_or_else(|| invalid_request("command must not be empty".to_string()))?; + { + let mut sessions = self.sessions.lock().await; + if sessions.contains_key(&process_key) { + return Err(invalid_request(format!( + "duplicate active command/exec process id: {}", + process_key.process_id.error_repr(), + ))); + } + sessions.insert( + process_key.clone(), + CommandExecSession::Active { control_tx }, + ); + } + let spawned = if tty { + codex_utils_pty::spawn_pty_process( + program, + args, + cwd.as_path(), + &env, + &arg0, + size.unwrap_or_default(), + ) + .await + } else if stream_stdin { + codex_utils_pty::spawn_pipe_process(program, args, cwd.as_path(), &env, &arg0).await + } else { + codex_utils_pty::spawn_pipe_process_no_stdin(program, args, cwd.as_path(), &env, &arg0) + .await + }; + let spawned = match spawned { + Ok(spawned) => spawned, + Err(err) => { + self.sessions.lock().await.remove(&process_key); + return Err(internal_error(format!("failed to spawn command: {err}"))); + } + }; + tokio::spawn(async move { + let _started_network_proxy = started_network_proxy; + run_command(RunCommandParams { + outgoing, + request_id: request_id.clone(), + process_id: notification_process_id, + spawned, + control_rx, + stream_stdin, + stream_stdout_stderr, + expiration, + output_bytes_cap, + }) + .await; + sessions.lock().await.remove(&process_key); + }); + Ok(()) + } + + pub(crate) async fn write( + &self, + request_id: ConnectionRequestId, + params: CommandExecWriteParams, + ) -> Result { + if params.delta_base64.is_none() && !params.close_stdin { + return Err(invalid_params( + "command/exec/write requires deltaBase64 or closeStdin".to_string(), + )); + } + + let delta = match params.delta_base64 { + Some(delta_base64) => STANDARD + .decode(delta_base64) + .map_err(|err| invalid_params(format!("invalid deltaBase64: {err}")))?, + None => Vec::new(), + }; + + let target_process_id = ConnectionProcessId { + connection_id: request_id.connection_id, + process_id: InternalProcessId::Client(params.process_id), + }; + self.send_control( + target_process_id, + CommandControl::Write { + delta, + close_stdin: params.close_stdin, + }, + ) + .await?; + + Ok(CommandExecWriteResponse {}) + } + + pub(crate) async fn terminate( + &self, + request_id: ConnectionRequestId, + params: CommandExecTerminateParams, + ) -> Result { + let target_process_id = ConnectionProcessId { + connection_id: request_id.connection_id, + process_id: InternalProcessId::Client(params.process_id), + }; + self.send_control(target_process_id, CommandControl::Terminate) + .await?; + Ok(CommandExecTerminateResponse {}) + } + + pub(crate) async fn resize( + &self, + request_id: ConnectionRequestId, + params: CommandExecResizeParams, + ) -> Result { + let target_process_id = ConnectionProcessId { + connection_id: request_id.connection_id, + process_id: InternalProcessId::Client(params.process_id), + }; + self.send_control( + target_process_id, + CommandControl::Resize { + size: terminal_size_from_protocol(params.size)?, + }, + ) + .await?; + Ok(CommandExecResizeResponse {}) + } + + pub(crate) async fn connection_closed(&self, connection_id: ConnectionId) { + let controls = { + let mut sessions = self.sessions.lock().await; + let process_ids = sessions + .keys() + .filter(|process_id| process_id.connection_id == connection_id) + .cloned() + .collect::>(); + let mut controls = Vec::with_capacity(process_ids.len()); + for process_id in process_ids { + if let Some(control) = sessions.remove(&process_id) { + controls.push(control); + } + } + controls + }; + + for control in controls { + if let CommandExecSession::Active { control_tx } = control { + let _ = control_tx + .send(CommandControlRequest { + control: CommandControl::Terminate, + response_tx: None, + }) + .await; + } + } + } + + async fn send_control( + &self, + process_id: ConnectionProcessId, + control: CommandControl, + ) -> Result<(), JSONRPCErrorError> { + let session = { + self.sessions + .lock() + .await + .get(&process_id) + .cloned() + .ok_or_else(|| { + invalid_request(format!( + "no active command/exec for process id {}", + process_id.process_id.error_repr(), + )) + })? + }; + let CommandExecSession::Active { control_tx } = session else { + return Err(invalid_request( + "command/exec/write, command/exec/terminate, and command/exec/resize are not supported for windows sandbox processes".to_string(), + )); + }; + let (response_tx, response_rx) = oneshot::channel(); + let request = CommandControlRequest { + control, + response_tx: Some(response_tx), + }; + control_tx + .send(request) + .await + .map_err(|_| command_no_longer_running_error(&process_id.process_id))?; + response_rx + .await + .map_err(|_| command_no_longer_running_error(&process_id.process_id))? + } +} + +async fn run_command(params: RunCommandParams) { + let RunCommandParams { + outgoing, + request_id, + process_id, + spawned, + control_rx, + stream_stdin, + stream_stdout_stderr, + expiration, + output_bytes_cap, + } = params; + let mut control_rx = control_rx; + let mut control_open = true; + let expiration = async { + match expiration { + ExecExpiration::Timeout(duration) => tokio::time::sleep(duration).await, + ExecExpiration::DefaultTimeout => { + tokio::time::sleep(Duration::from_millis(DEFAULT_EXEC_COMMAND_TIMEOUT_MS)).await; + } + ExecExpiration::Cancellation(cancel) => { + cancel.cancelled().await; + } + } + }; + tokio::pin!(expiration); + let SpawnedProcess { + session, + stdout_rx, + stderr_rx, + exit_rx, + } = spawned; + tokio::pin!(exit_rx); + let mut timed_out = false; + let (stdio_timeout_tx, stdio_timeout_rx) = watch::channel(false); + + let stdout_handle = spawn_process_output(SpawnProcessOutputParams { + connection_id: request_id.connection_id, + process_id: process_id.clone(), + output_rx: stdout_rx, + stdio_timeout_rx: stdio_timeout_rx.clone(), + outgoing: Arc::clone(&outgoing), + stream: CommandExecOutputStream::Stdout, + stream_output: stream_stdout_stderr, + output_bytes_cap, + }); + let stderr_handle = spawn_process_output(SpawnProcessOutputParams { + connection_id: request_id.connection_id, + process_id, + output_rx: stderr_rx, + stdio_timeout_rx, + outgoing: Arc::clone(&outgoing), + stream: CommandExecOutputStream::Stderr, + stream_output: stream_stdout_stderr, + output_bytes_cap, + }); + + let exit_code = loop { + tokio::select! { + control = control_rx.recv(), if control_open => { + match control { + Some(CommandControlRequest { control, response_tx }) => { + let result = match control { + CommandControl::Write { delta, close_stdin } => { + handle_process_write( + &session, + stream_stdin, + delta, + close_stdin, + ).await + } + CommandControl::Resize { size } => { + handle_process_resize(&session, size) + } + CommandControl::Terminate => { + session.request_terminate(); + Ok(()) + } + }; + if let Some(response_tx) = response_tx { + let _ = response_tx.send(result); + } + }, + None => { + control_open = false; + session.request_terminate(); + } + } + } + _ = &mut expiration, if !timed_out => { + timed_out = true; + session.request_terminate(); + } + exit = &mut exit_rx => { + if timed_out { + break EXEC_TIMEOUT_EXIT_CODE; + } else { + break exit.unwrap_or(-1); + } + } + } + }; + + let timeout_handle = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(IO_DRAIN_TIMEOUT_MS)).await; + let _ = stdio_timeout_tx.send(true); + }); + + let stdout = stdout_handle.await.unwrap_or_default(); + let stderr = stderr_handle.await.unwrap_or_default(); + timeout_handle.abort(); + + outgoing + .send_response( + request_id, + CommandExecResponse { + exit_code, + stdout, + stderr, + }, + ) + .await; +} + +fn spawn_process_output(params: SpawnProcessOutputParams) -> tokio::task::JoinHandle { + let SpawnProcessOutputParams { + connection_id, + process_id, + mut output_rx, + mut stdio_timeout_rx, + outgoing, + stream, + stream_output, + output_bytes_cap, + } = params; + tokio::spawn(async move { + let mut buffer: Vec = Vec::new(); + let mut observed_num_bytes = 0usize; + loop { + let chunk = tokio::select! { + chunk = output_rx.recv() => match chunk { + Some(chunk) => chunk, + None => break, + }, + _ = stdio_timeout_rx.wait_for(|&v| v) => break, + }; + let capped_chunk = match output_bytes_cap { + Some(output_bytes_cap) => { + let capped_chunk_len = output_bytes_cap + .saturating_sub(observed_num_bytes) + .min(chunk.len()); + observed_num_bytes += capped_chunk_len; + &chunk[0..capped_chunk_len] + } + None => chunk.as_slice(), + }; + let cap_reached = Some(observed_num_bytes) == output_bytes_cap; + if let (true, Some(process_id)) = (stream_output, process_id.as_ref()) { + outgoing + .send_server_notification_to_connections( + &[connection_id], + ServerNotification::CommandExecOutputDelta( + CommandExecOutputDeltaNotification { + process_id: process_id.clone(), + stream, + delta_base64: STANDARD.encode(capped_chunk), + cap_reached, + }, + ), + ) + .await; + } else if !stream_output { + buffer.extend_from_slice(capped_chunk); + } + if cap_reached { + break; + } + } + bytes_to_string_smart(&buffer) + }) +} + +async fn handle_process_write( + session: &ProcessHandle, + stream_stdin: bool, + delta: Vec, + close_stdin: bool, +) -> Result<(), JSONRPCErrorError> { + if !stream_stdin { + return Err(invalid_request( + "stdin streaming is not enabled for this command/exec".to_string(), + )); + } + if !delta.is_empty() { + session + .writer_sender() + .send(delta) + .await + .map_err(|_| invalid_request("stdin is already closed".to_string()))?; + } + if close_stdin { + session.close_stdin(); + } + Ok(()) +} + +fn handle_process_resize( + session: &ProcessHandle, + size: TerminalSize, +) -> Result<(), JSONRPCErrorError> { + session + .resize(size) + .map_err(|err| invalid_request(format!("failed to resize PTY: {err}"))) +} + +pub(crate) fn terminal_size_from_protocol( + size: CommandExecTerminalSize, +) -> Result { + if size.rows == 0 || size.cols == 0 { + return Err(invalid_params( + "command/exec size rows and cols must be greater than 0".to_string(), + )); + } + Ok(TerminalSize { + rows: size.rows, + cols: size.cols, + }) +} + +fn command_no_longer_running_error(process_id: &InternalProcessId) -> JSONRPCErrorError { + invalid_request(format!( + "command/exec {} is no longer running", + process_id.error_repr(), + )) +} + +fn invalid_request(message: String) -> JSONRPCErrorError { + JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message, + data: None, + } +} + +fn invalid_params(message: String) -> JSONRPCErrorError { + JSONRPCErrorError { + code: INVALID_PARAMS_ERROR_CODE, + message, + data: None, + } +} + +fn internal_error(message: String) -> JSONRPCErrorError { + JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message, + data: None, + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::path::PathBuf; + + use codex_protocol::config_types::WindowsSandboxLevel; + use codex_protocol::protocol::ReadOnlyAccess; + use codex_protocol::protocol::SandboxPolicy; + use pretty_assertions::assert_eq; + #[cfg(not(target_os = "windows"))] + use tokio::time::Duration; + #[cfg(not(target_os = "windows"))] + use tokio::time::timeout; + #[cfg(not(target_os = "windows"))] + use tokio_util::sync::CancellationToken; + + use super::*; + #[cfg(not(target_os = "windows"))] + use crate::outgoing_message::OutgoingEnvelope; + #[cfg(not(target_os = "windows"))] + use crate::outgoing_message::OutgoingMessage; + + fn windows_sandbox_exec_request() -> ExecRequest { + ExecRequest { + command: vec!["cmd".to_string()], + cwd: PathBuf::from("."), + env: HashMap::new(), + network: None, + expiration: ExecExpiration::DefaultTimeout, + sandbox: SandboxType::WindowsRestrictedToken, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + sandbox_permissions: codex_core::sandboxing::SandboxPermissions::UseDefault, + sandbox_policy: SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::FullAccess, + network_access: false, + }, + justification: None, + arg0: None, + } + } + + #[tokio::test] + async fn windows_sandbox_streaming_exec_is_rejected() { + let (tx, _rx) = mpsc::channel(1); + let manager = CommandExecManager::default(); + let err = manager + .start(StartCommandExecParams { + outgoing: Arc::new(OutgoingMessageSender::new(tx)), + request_id: ConnectionRequestId { + connection_id: ConnectionId(1), + request_id: codex_app_server_protocol::RequestId::Integer(42), + }, + process_id: Some("proc-42".to_string()), + exec_request: windows_sandbox_exec_request(), + started_network_proxy: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: true, + output_bytes_cap: None, + size: None, + }) + .await + .expect_err("streaming windows sandbox exec should be rejected"); + + assert_eq!(err.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!( + err.message, + "streaming command/exec is not supported with windows sandbox" + ); + } + + #[cfg(not(target_os = "windows"))] + #[tokio::test] + async fn windows_sandbox_non_streaming_exec_uses_execution_path() { + let (tx, mut rx) = mpsc::channel(1); + let manager = CommandExecManager::default(); + let request_id = ConnectionRequestId { + connection_id: ConnectionId(7), + request_id: codex_app_server_protocol::RequestId::Integer(99), + }; + + manager + .start(StartCommandExecParams { + outgoing: Arc::new(OutgoingMessageSender::new(tx)), + request_id: request_id.clone(), + process_id: Some("proc-99".to_string()), + exec_request: windows_sandbox_exec_request(), + started_network_proxy: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: Some(DEFAULT_OUTPUT_BYTES_CAP), + size: None, + }) + .await + .expect("non-streaming windows sandbox exec should start"); + + let envelope = timeout(Duration::from_secs(1), rx.recv()) + .await + .expect("timed out waiting for outgoing message") + .expect("channel closed before outgoing message"); + let OutgoingEnvelope::ToConnection { + connection_id, + message, + } = envelope + else { + panic!("expected connection-scoped outgoing message"); + }; + assert_eq!(connection_id, request_id.connection_id); + let OutgoingMessage::Error(error) = message else { + panic!("expected execution failure to be reported as an error"); + }; + assert_eq!(error.id, request_id.request_id); + assert!(error.error.message.starts_with("exec failed:")); + } + + #[cfg(not(target_os = "windows"))] + #[tokio::test] + async fn cancellation_expiration_keeps_process_alive_until_terminated() { + let (tx, mut rx) = mpsc::channel(4); + let manager = CommandExecManager::default(); + let request_id = ConnectionRequestId { + connection_id: ConnectionId(8), + request_id: codex_app_server_protocol::RequestId::Integer(100), + }; + + manager + .start(StartCommandExecParams { + outgoing: Arc::new(OutgoingMessageSender::new(tx)), + request_id: request_id.clone(), + process_id: Some("proc-100".to_string()), + exec_request: ExecRequest { + command: vec!["sh".to_string(), "-lc".to_string(), "sleep 30".to_string()], + cwd: PathBuf::from("."), + env: HashMap::new(), + network: None, + expiration: ExecExpiration::Cancellation(CancellationToken::new()), + sandbox: SandboxType::None, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + sandbox_permissions: codex_core::sandboxing::SandboxPermissions::UseDefault, + sandbox_policy: SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::FullAccess, + network_access: false, + }, + justification: None, + arg0: None, + }, + started_network_proxy: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: Some(DEFAULT_OUTPUT_BYTES_CAP), + size: None, + }) + .await + .expect("cancellation-based exec should start"); + + assert!( + timeout(Duration::from_millis(250), rx.recv()) + .await + .is_err(), + "command/exec should remain active until explicit termination", + ); + + manager + .terminate( + request_id.clone(), + CommandExecTerminateParams { + process_id: "proc-100".to_string(), + }, + ) + .await + .expect("terminate should succeed"); + + let envelope = timeout(Duration::from_secs(1), rx.recv()) + .await + .expect("timed out waiting for outgoing message") + .expect("channel closed before outgoing message"); + let OutgoingEnvelope::ToConnection { + connection_id, + message, + } = envelope + else { + panic!("expected connection-scoped outgoing message"); + }; + assert_eq!(connection_id, request_id.connection_id); + let OutgoingMessage::Response(response) = message else { + panic!("expected execution response after termination"); + }; + assert_eq!(response.id, request_id.request_id); + let response: CommandExecResponse = + serde_json::from_value(response.result).expect("deserialize command/exec response"); + assert_ne!(response.exit_code, 0); + assert_eq!(response.stdout, ""); + // The deferred response now drains any already-emitted stderr before + // replying, so shell startup noise is allowed here. + } + + #[tokio::test] + async fn windows_sandbox_process_ids_reject_write_requests() { + let manager = CommandExecManager::default(); + let request_id = ConnectionRequestId { + connection_id: ConnectionId(11), + request_id: codex_app_server_protocol::RequestId::Integer(1), + }; + let process_id = ConnectionProcessId { + connection_id: request_id.connection_id, + process_id: InternalProcessId::Client("proc-11".to_string()), + }; + manager + .sessions + .lock() + .await + .insert(process_id, CommandExecSession::UnsupportedWindowsSandbox); + + let err = manager + .write( + request_id, + CommandExecWriteParams { + process_id: "proc-11".to_string(), + delta_base64: Some(STANDARD.encode("hello")), + close_stdin: false, + }, + ) + .await + .expect_err("windows sandbox process ids should reject command/exec/write"); + + assert_eq!(err.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!( + err.message, + "command/exec/write, command/exec/terminate, and command/exec/resize are not supported for windows sandbox processes" + ); + } + + #[tokio::test] + async fn windows_sandbox_process_ids_reject_terminate_requests() { + let manager = CommandExecManager::default(); + let request_id = ConnectionRequestId { + connection_id: ConnectionId(12), + request_id: codex_app_server_protocol::RequestId::Integer(2), + }; + let process_id = ConnectionProcessId { + connection_id: request_id.connection_id, + process_id: InternalProcessId::Client("proc-12".to_string()), + }; + manager + .sessions + .lock() + .await + .insert(process_id, CommandExecSession::UnsupportedWindowsSandbox); + + let err = manager + .terminate( + request_id, + CommandExecTerminateParams { + process_id: "proc-12".to_string(), + }, + ) + .await + .expect_err("windows sandbox process ids should reject command/exec/terminate"); + + assert_eq!(err.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!( + err.message, + "command/exec/write, command/exec/terminate, and command/exec/resize are not supported for windows sandbox processes" + ); + } + + #[tokio::test] + async fn dropped_control_request_is_reported_as_not_running() { + let manager = CommandExecManager::default(); + let request_id = ConnectionRequestId { + connection_id: ConnectionId(13), + request_id: codex_app_server_protocol::RequestId::Integer(3), + }; + let process_id = InternalProcessId::Client("proc-13".to_string()); + let (control_tx, mut control_rx) = mpsc::channel(1); + manager.sessions.lock().await.insert( + ConnectionProcessId { + connection_id: request_id.connection_id, + process_id: process_id.clone(), + }, + CommandExecSession::Active { control_tx }, + ); + + tokio::spawn(async move { + let _request = control_rx + .recv() + .await + .expect("expected queued control request"); + }); + + let err = manager + .terminate( + request_id, + CommandExecTerminateParams { + process_id: "proc-13".to_string(), + }, + ) + .await + .expect_err("dropped control request should be treated as not running"); + + assert_eq!(err.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!(err.message, "command/exec \"proc-13\" is no longer running"); + } +} diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 3505f432d5c..b08bbbb983f 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -58,6 +58,7 @@ use tracing_subscriber::util::SubscriberInitExt; mod app_server_tracing; mod bespoke_event_handling; mod codex_message_processor; +mod command_exec; mod config_api; mod dynamic_tools; mod error_code; diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 58514f39f00..a52338b2a43 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -16,6 +16,10 @@ use codex_app_server_protocol::CancelLoginAccountParams; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientNotification; use codex_app_server_protocol::CollaborationModeListParams; +use codex_app_server_protocol::CommandExecParams; +use codex_app_server_protocol::CommandExecResizeParams; +use codex_app_server_protocol::CommandExecTerminateParams; +use codex_app_server_protocol::CommandExecWriteParams; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigReadParams; use codex_app_server_protocol::ConfigValueWriteParams; @@ -494,6 +498,42 @@ impl McpProcess { self.send_request("turn/start", params).await } + /// Send a `command/exec` JSON-RPC request (v2). + pub async fn send_command_exec_request( + &mut self, + params: CommandExecParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("command/exec", params).await + } + + /// Send a `command/exec/write` JSON-RPC request (v2). + pub async fn send_command_exec_write_request( + &mut self, + params: CommandExecWriteParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("command/exec/write", params).await + } + + /// Send a `command/exec/resize` JSON-RPC request (v2). + pub async fn send_command_exec_resize_request( + &mut self, + params: CommandExecResizeParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("command/exec/resize", params).await + } + + /// Send a `command/exec/terminate` JSON-RPC request (v2). + pub async fn send_command_exec_terminate_request( + &mut self, + params: CommandExecTerminateParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("command/exec/terminate", params).await + } + /// Send a `turn/interrupt` JSON-RPC request (v2). pub async fn send_turn_interrupt_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/command_exec.rs b/codex-rs/app-server/tests/suite/v2/command_exec.rs new file mode 100644 index 00000000000..5d81e001b5b --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/command_exec.rs @@ -0,0 +1,839 @@ +use anyhow::Context; +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_mock_responses_server_sequence_unchecked; +use app_test_support::to_response; +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use codex_app_server_protocol::CommandExecOutputDeltaNotification; +use codex_app_server_protocol::CommandExecOutputStream; +use codex_app_server_protocol::CommandExecParams; +use codex_app_server_protocol::CommandExecResizeParams; +use codex_app_server_protocol::CommandExecResponse; +use codex_app_server_protocol::CommandExecTerminalSize; +use codex_app_server_protocol::CommandExecTerminateParams; +use codex_app_server_protocol::CommandExecWriteParams; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::RequestId; +use pretty_assertions::assert_eq; +use std::collections::HashMap; +use tempfile::TempDir; +use tokio::time::Duration; +use tokio::time::Instant; +use tokio::time::sleep; +use tokio::time::timeout; + +use super::connection_handling_websocket::DEFAULT_READ_TIMEOUT; +use super::connection_handling_websocket::assert_no_message; +use super::connection_handling_websocket::connect_websocket; +use super::connection_handling_websocket::create_config_toml; +use super::connection_handling_websocket::read_jsonrpc_message; +use super::connection_handling_websocket::reserve_local_addr; +use super::connection_handling_websocket::send_initialize_request; +use super::connection_handling_websocket::send_request; +use super::connection_handling_websocket::spawn_websocket_server; + +#[tokio::test] +async fn command_exec_without_streams_can_be_terminated() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let process_id = "sleep-1".to_string(); + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec!["sh".to_string(), "-lc".to_string(), "sleep 30".to_string()], + process_id: Some(process_id.clone()), + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + }) + .await?; + let terminate_request_id = mcp + .send_command_exec_terminate_request(CommandExecTerminateParams { process_id }) + .await?; + + let terminate_response = mcp + .read_stream_until_response_message(RequestId::Integer(terminate_request_id)) + .await?; + assert_eq!(terminate_response.result, serde_json::json!({})); + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_ne!( + response.exit_code, 0, + "terminated command should not succeed" + ); + assert_eq!(response.stdout, ""); + assert_eq!(response.stderr, ""); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_without_process_id_keeps_buffered_compatibility() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec![ + "sh".to_string(), + "-lc".to_string(), + "printf 'legacy-out'; printf 'legacy-err' >&2".to_string(), + ], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + }) + .await?; + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_eq!( + response, + CommandExecResponse { + exit_code: 0, + stdout: "legacy-out".to_string(), + stderr: "legacy-err".to_string(), + } + ); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_env_overrides_merge_with_server_environment_and_support_unset() -> Result<()> +{ + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[("COMMAND_EXEC_BASELINE", Some("server"))], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec![ + "/bin/sh".to_string(), + "-lc".to_string(), + "printf '%s|%s|%s|%s' \"$COMMAND_EXEC_BASELINE\" \"$COMMAND_EXEC_EXTRA\" \"${RUST_LOG-unset}\" \"$CODEX_HOME\"".to_string(), + ], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: Some(HashMap::from([ + ( + "COMMAND_EXEC_BASELINE".to_string(), + Some("request".to_string()), + ), + ("COMMAND_EXEC_EXTRA".to_string(), Some("added".to_string())), + ("RUST_LOG".to_string(), None), + ])), + size: None, + sandbox_policy: None, + }) + .await?; + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_eq!( + response, + CommandExecResponse { + exit_code: 0, + stdout: format!("request|added|unset|{}", codex_home.path().display()), + stderr: String::new(), + } + ); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_rejects_disable_timeout_with_timeout_ms() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec!["sh".to_string(), "-lc".to_string(), "sleep 1".to_string()], + process_id: Some("invalid-timeout-1".to_string()), + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: true, + timeout_ms: Some(1_000), + cwd: None, + env: None, + size: None, + sandbox_policy: None, + }) + .await?; + + let error = mcp + .read_stream_until_error_message(RequestId::Integer(command_request_id)) + .await?; + assert_eq!( + error.error.message, + "command/exec cannot set both timeoutMs and disableTimeout" + ); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_rejects_disable_output_cap_with_output_bytes_cap() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec!["sh".to_string(), "-lc".to_string(), "sleep 1".to_string()], + process_id: Some("invalid-cap-1".to_string()), + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: Some(1024), + disable_output_cap: true, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + }) + .await?; + + let error = mcp + .read_stream_until_error_message(RequestId::Integer(command_request_id)) + .await?; + assert_eq!( + error.error.message, + "command/exec cannot set both outputBytesCap and disableOutputCap" + ); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_rejects_negative_timeout_ms() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec!["sh".to_string(), "-lc".to_string(), "sleep 1".to_string()], + process_id: Some("negative-timeout-1".to_string()), + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: Some(-1), + cwd: None, + env: None, + size: None, + sandbox_policy: None, + }) + .await?; + + let error = mcp + .read_stream_until_error_message(RequestId::Integer(command_request_id)) + .await?; + assert_eq!( + error.error.message, + "command/exec timeoutMs must be non-negative, got -1" + ); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_without_process_id_rejects_streaming() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec!["sh".to_string(), "-lc".to_string(), "cat".to_string()], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: true, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + }) + .await?; + + let error = mcp + .read_stream_until_error_message(RequestId::Integer(command_request_id)) + .await?; + assert_eq!( + error.error.message, + "command/exec tty or streaming requires a client-supplied processId" + ); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_non_streaming_respects_output_cap() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec![ + "sh".to_string(), + "-lc".to_string(), + "printf 'abcdef'; printf 'uvwxyz' >&2".to_string(), + ], + process_id: Some("cap-1".to_string()), + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: Some(5), + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + }) + .await?; + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_eq!( + response, + CommandExecResponse { + exit_code: 0, + stdout: "abcde".to_string(), + stderr: "uvwxy".to_string(), + } + ); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_streaming_does_not_buffer_output() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let process_id = "stream-cap-1".to_string(); + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec![ + "sh".to_string(), + "-lc".to_string(), + "printf 'abcdefghij'; sleep 30".to_string(), + ], + process_id: Some(process_id.clone()), + tty: false, + stream_stdin: false, + stream_stdout_stderr: true, + output_bytes_cap: Some(5), + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + }) + .await?; + + let delta = read_command_exec_delta(&mut mcp).await?; + assert_eq!(delta.process_id, process_id.as_str()); + assert_eq!(delta.stream, CommandExecOutputStream::Stdout); + assert_eq!(STANDARD.decode(&delta.delta_base64)?, b"abcde"); + assert!(delta.cap_reached); + let terminate_request_id = mcp + .send_command_exec_terminate_request(CommandExecTerminateParams { + process_id: process_id.clone(), + }) + .await?; + let terminate_response = mcp + .read_stream_until_response_message(RequestId::Integer(terminate_request_id)) + .await?; + assert_eq!(terminate_response.result, serde_json::json!({})); + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_ne!( + response.exit_code, 0, + "terminated command should not succeed" + ); + assert_eq!(response.stdout, ""); + assert_eq!(response.stderr, ""); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_pipe_streams_output_and_accepts_write() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let process_id = "pipe-1".to_string(); + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec![ + "sh".to_string(), + "-lc".to_string(), + "printf 'out-start\\n'; printf 'err-start\\n' >&2; IFS= read line; printf 'out:%s\\n' \"$line\"; printf 'err:%s\\n' \"$line\" >&2".to_string(), + ], + process_id: Some(process_id.clone()), + tty: false, + stream_stdin: true, + stream_stdout_stderr: true, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + }) + .await?; + + let first_stdout = read_command_exec_delta(&mut mcp).await?; + let first_stderr = read_command_exec_delta(&mut mcp).await?; + let seen = [first_stdout, first_stderr]; + assert!( + seen.iter() + .all(|delta| delta.process_id == process_id.as_str()) + ); + assert!(seen.iter().any(|delta| { + delta.stream == CommandExecOutputStream::Stdout + && delta.delta_base64 == STANDARD.encode("out-start\n") + })); + assert!(seen.iter().any(|delta| { + delta.stream == CommandExecOutputStream::Stderr + && delta.delta_base64 == STANDARD.encode("err-start\n") + })); + + let write_request_id = mcp + .send_command_exec_write_request(CommandExecWriteParams { + process_id: process_id.clone(), + delta_base64: Some(STANDARD.encode("hello\n")), + close_stdin: true, + }) + .await?; + let write_response = mcp + .read_stream_until_response_message(RequestId::Integer(write_request_id)) + .await?; + assert_eq!(write_response.result, serde_json::json!({})); + + let next_delta = read_command_exec_delta(&mut mcp).await?; + let final_delta = read_command_exec_delta(&mut mcp).await?; + let seen = [next_delta, final_delta]; + assert!( + seen.iter() + .all(|delta| delta.process_id == process_id.as_str()) + ); + assert!(seen.iter().any(|delta| { + delta.stream == CommandExecOutputStream::Stdout + && delta.delta_base64 == STANDARD.encode("out:hello\n") + })); + assert!(seen.iter().any(|delta| { + delta.stream == CommandExecOutputStream::Stderr + && delta.delta_base64 == STANDARD.encode("err:hello\n") + })); + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_eq!( + response, + CommandExecResponse { + exit_code: 0, + stdout: String::new(), + stderr: String::new(), + } + ); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_tty_implies_streaming_and_reports_pty_output() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let process_id = "tty-1".to_string(); + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec![ + "sh".to_string(), + "-lc".to_string(), + "stty -echo; if [ -t 0 ]; then printf 'tty\\n'; else printf 'notty\\n'; fi; IFS= read line; printf 'echo:%s\\n' \"$line\"".to_string(), + ], + process_id: Some(process_id.clone()), + tty: true, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + }) + .await?; + + let started_delta = read_command_exec_delta(&mut mcp).await?; + assert_eq!(started_delta.process_id, process_id.as_str()); + assert_eq!(started_delta.stream, CommandExecOutputStream::Stdout); + assert!( + String::from_utf8(STANDARD.decode(&started_delta.delta_base64)?)? + .replace('\r', "") + .contains("tty\n"), + "expected TTY startup output, got {started_delta:?}" + ); + + let write_request_id = mcp + .send_command_exec_write_request(CommandExecWriteParams { + process_id: process_id.clone(), + delta_base64: Some(STANDARD.encode("world\n")), + close_stdin: true, + }) + .await?; + let write_response = mcp + .read_stream_until_response_message(RequestId::Integer(write_request_id)) + .await?; + assert_eq!(write_response.result, serde_json::json!({})); + + let echoed_delta = read_command_exec_delta(&mut mcp).await?; + assert_eq!(echoed_delta.process_id, process_id.as_str()); + assert_eq!(echoed_delta.stream, CommandExecOutputStream::Stdout); + assert!( + String::from_utf8(STANDARD.decode(&echoed_delta.delta_base64)?)? + .replace('\r', "") + .contains("echo:world\n"), + "expected TTY echo output, got {echoed_delta:?}" + ); + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_eq!(response.exit_code, 0); + assert_eq!(response.stdout, ""); + assert_eq!(response.stderr, ""); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_tty_supports_initial_size_and_resize() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let process_id = "tty-size-1".to_string(); + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec![ + "sh".to_string(), + "-lc".to_string(), + "stty -echo; printf 'start:%s\\n' \"$(stty size)\"; IFS= read _line; printf 'after:%s\\n' \"$(stty size)\"".to_string(), + ], + process_id: Some(process_id.clone()), + tty: true, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: Some(CommandExecTerminalSize { + rows: 31, + cols: 101, + }), + sandbox_policy: None, + }) + .await?; + + let started_delta = read_command_exec_delta(&mut mcp).await?; + let started_text = + String::from_utf8(STANDARD.decode(&started_delta.delta_base64)?)?.replace('\r', ""); + assert!( + started_text.contains("start:31 101\n"), + "unexpected initial size output: {started_text:?}" + ); + + let resize_request_id = mcp + .send_command_exec_resize_request(CommandExecResizeParams { + process_id: process_id.clone(), + size: CommandExecTerminalSize { + rows: 45, + cols: 132, + }, + }) + .await?; + let resize_response = mcp + .read_stream_until_response_message(RequestId::Integer(resize_request_id)) + .await?; + assert_eq!(resize_response.result, serde_json::json!({})); + + let write_request_id = mcp + .send_command_exec_write_request(CommandExecWriteParams { + process_id: process_id.clone(), + delta_base64: Some(STANDARD.encode("go\n")), + close_stdin: true, + }) + .await?; + let write_response = mcp + .read_stream_until_response_message(RequestId::Integer(write_request_id)) + .await?; + assert_eq!(write_response.result, serde_json::json!({})); + + let resized_delta = read_command_exec_delta(&mut mcp).await?; + let resized_text = + String::from_utf8(STANDARD.decode(&resized_delta.delta_base64)?)?.replace('\r', ""); + assert!( + resized_text.contains("after:45 132\n"), + "unexpected resized output: {resized_text:?}" + ); + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_eq!(response.exit_code, 0); + assert_eq!(response.stdout, ""); + assert_eq!(response.stderr, ""); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_process_ids_are_connection_scoped_and_disconnect_terminates_process() +-> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + + let bind_addr = reserve_local_addr()?; + let mut process = spawn_websocket_server(codex_home.path(), bind_addr).await?; + + let mut ws1 = connect_websocket(bind_addr).await?; + let mut ws2 = connect_websocket(bind_addr).await?; + + send_initialize_request(&mut ws1, 1, "ws_client_one").await?; + read_initialize_response(&mut ws1, 1).await?; + send_initialize_request(&mut ws2, 2, "ws_client_two").await?; + read_initialize_response(&mut ws2, 2).await?; + + send_request( + &mut ws1, + "command/exec", + 101, + Some(serde_json::json!({ + "command": ["sh", "-lc", "printf 'ready\\n%s\\n' $$; sleep 30"], + "processId": "shared-process", + "streamStdoutStderr": true, + })), + ) + .await?; + + let delta = read_command_exec_delta_ws(&mut ws1).await?; + assert_eq!(delta.process_id, "shared-process"); + assert_eq!(delta.stream, CommandExecOutputStream::Stdout); + let delta_text = String::from_utf8(STANDARD.decode(&delta.delta_base64)?)?; + let pid = delta_text + .lines() + .last() + .context("delta should include shell pid")? + .parse::() + .context("parse shell pid")?; + + send_request( + &mut ws2, + "command/exec/terminate", + 102, + Some(serde_json::json!({ + "processId": "shared-process", + })), + ) + .await?; + + let terminate_error = loop { + let message = read_jsonrpc_message(&mut ws2).await?; + if let JSONRPCMessage::Error(error) = message + && error.id == RequestId::Integer(102) + { + break error; + } + }; + assert_eq!( + terminate_error.error.message, + "no active command/exec for process id \"shared-process\"" + ); + assert!(process_is_alive(pid)?); + + assert_no_message(&mut ws2, Duration::from_millis(250)).await?; + ws1.close(None).await?; + + wait_for_process_exit(pid).await?; + + process + .kill() + .await + .context("failed to stop websocket app-server process")?; + Ok(()) +} + +async fn read_command_exec_delta( + mcp: &mut McpProcess, +) -> Result { + let notification = mcp + .read_stream_until_notification_message("command/exec/outputDelta") + .await?; + decode_delta_notification(notification) +} + +async fn read_command_exec_delta_ws( + stream: &mut super::connection_handling_websocket::WsClient, +) -> Result { + loop { + let message = read_jsonrpc_message(stream).await?; + let JSONRPCMessage::Notification(notification) = message else { + continue; + }; + if notification.method == "command/exec/outputDelta" { + return decode_delta_notification(notification); + } + } +} + +fn decode_delta_notification( + notification: JSONRPCNotification, +) -> Result { + let params = notification + .params + .context("command/exec/outputDelta notification should include params")?; + serde_json::from_value(params).context("deserialize command/exec/outputDelta notification") +} + +async fn read_initialize_response( + stream: &mut super::connection_handling_websocket::WsClient, + request_id: i64, +) -> Result<()> { + loop { + let message = read_jsonrpc_message(stream).await?; + if let JSONRPCMessage::Response(response) = message + && response.id == RequestId::Integer(request_id) + { + return Ok(()); + } + } +} + +async fn wait_for_process_exit(pid: u32) -> Result<()> { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if !process_is_alive(pid)? { + return Ok(()); + } + if Instant::now() >= deadline { + anyhow::bail!("process {pid} was still alive after websocket disconnect"); + } + sleep(Duration::from_millis(50)).await; + } +} + +fn process_is_alive(pid: u32) -> Result { + let status = std::process::Command::new("kill") + .arg("-0") + .arg(pid.to_string()) + .status() + .context("spawn kill -0")?; + Ok(status.success()) +} diff --git a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs index 26fffd8a13e..349e92df3cc 100644 --- a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs +++ b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs @@ -265,7 +265,7 @@ async fn read_error_for_id(stream: &mut WsClient, id: i64) -> Result Result { +pub(super) async fn read_jsonrpc_message(stream: &mut WsClient) -> Result { loop { let frame = timeout(DEFAULT_READ_TIMEOUT, stream.next()) .await diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index f85849250de..327b6a04d8f 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -2,6 +2,8 @@ mod account; mod analytics; mod app_list; mod collaboration_mode_list; +#[cfg(unix)] +mod command_exec; mod compaction; mod config_rpc; mod connection_handling_websocket; diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 8779b2e1c30..b88b4983495 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -34,6 +34,7 @@ use crate::spawn::StdioPolicy; use crate::spawn::spawn_child_async; use crate::text_encoding::bytes_to_string_smart; use codex_network_proxy::NetworkProxy; +use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; use codex_utils_pty::process_group::kill_child_process_group; pub const DEFAULT_EXEC_COMMAND_TIMEOUT_MS: u64 = 10_000; @@ -53,12 +54,21 @@ const AGGREGATE_BUFFER_INITIAL_CAPACITY: usize = 8 * 1024; // 8 KiB /// /// This mirrors unified exec's output cap so a single runaway command cannot /// OOM the process by dumping huge amounts of data to stdout/stderr. -const EXEC_OUTPUT_MAX_BYTES: usize = 1024 * 1024; // 1 MiB +const EXEC_OUTPUT_MAX_BYTES: usize = DEFAULT_OUTPUT_BYTES_CAP; /// Limit the number of ExecCommandOutputDelta events emitted per exec call. /// Aggregation still collects full output; only the live event stream is capped. pub(crate) const MAX_EXEC_OUTPUT_DELTAS_PER_CALL: usize = 10_000; +// Wait for the stdout/stderr collection tasks but guard against them +// hanging forever. In the normal case, both pipes are closed once the child +// terminates so the tasks exit quickly. However, if the child process +// spawned grandchildren that inherited its stdout/stderr file descriptors +// those pipes may stay open after we `kill` the direct child on timeout. +// That would cause the `read_capped` tasks to block on `read()` +// indefinitely, effectively hanging the whole agent. +pub const IO_DRAIN_TIMEOUT_MS: u64 = 2_000; // 2 s should be plenty for local pipes + #[derive(Debug)] pub struct ExecParams { pub command: Vec, @@ -157,6 +167,27 @@ pub async fn process_exec_tool_call( use_linux_sandbox_bwrap: bool, stdout_stream: Option, ) -> Result { + let exec_req = build_exec_request( + params, + sandbox_policy, + sandbox_cwd, + codex_linux_sandbox_exe, + use_linux_sandbox_bwrap, + )?; + + // Route through the sandboxing module for a single, unified execution path. + crate::sandboxing::execute_env(exec_req, stdout_stream).await +} + +/// Transform a portable exec request into the concrete argv/env that should be +/// spawned under the requested sandbox policy. +pub fn build_exec_request( + params: ExecParams, + sandbox_policy: &SandboxPolicy, + sandbox_cwd: &Path, + codex_linux_sandbox_exe: &Option, + use_linux_sandbox_bwrap: bool, +) -> Result { let windows_sandbox_level = params.windows_sandbox_level; let enforce_managed_network = params.network.is_some(); let sandbox_type = match &sandbox_policy { @@ -226,9 +257,7 @@ pub async fn process_exec_tool_call( windows_sandbox_level, }) .map_err(CodexErr::from)?; - - // Route through the sandboxing module for a single, unified execution path. - crate::sandboxing::execute_env(exec_req, stdout_stream).await + Ok(exec_req) } pub(crate) async fn execute_exec_request( @@ -796,16 +825,6 @@ async fn consume_truncated_output( } }; - // Wait for the stdout/stderr collection tasks but guard against them - // hanging forever. In the normal case, both pipes are closed once the child - // terminates so the tasks exit quickly. However, if the child process - // spawned grandchildren that inherited its stdout/stderr file descriptors - // those pipes may stay open after we `kill` the direct child on timeout. - // That would cause the `read_capped` tasks to block on `read()` - // indefinitely, effectively hanging the whole agent. - - const IO_DRAIN_TIMEOUT_MS: u64 = 2_000; // 2 s should be plenty for local pipes - // We need mutable bindings so we can `abort()` them on timeout. use tokio::task::JoinHandle; diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index d96654bf4c6..6fe1f9759a7 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -51,6 +51,7 @@ pub mod network_proxy_loader; pub use mcp_connection_manager::MCP_SANDBOX_STATE_CAPABILITY; pub use mcp_connection_manager::MCP_SANDBOX_STATE_METHOD; pub use mcp_connection_manager::SandboxState; +pub use text_encoding::bytes_to_string_smart; mod mcp_tool_call; mod memories; mod mentions; From 06280359889d3e4cd23937225da987859567fbbb Mon Sep 17 00:00:00 2001 From: Ruslan Nigmatullin Date: Fri, 6 Mar 2026 13:23:58 -0800 Subject: [PATCH 5/6] cr --- .../schema/json/ClientRequest.json | 41 ++++++++- .../schema/json/ServerNotification.json | 34 +++++-- .../codex_app_server_protocol.schemas.json | 82 +++++++++++++++-- .../codex_app_server_protocol.v2.schemas.json | 82 +++++++++++++++-- .../CommandExecOutputDeltaNotification.json | 33 +++++-- .../schema/json/v2/CommandExecParams.json | 21 ++++- .../json/v2/CommandExecResizeParams.json | 12 ++- .../json/v2/CommandExecResizeResponse.json | 1 + .../schema/json/v2/CommandExecResponse.json | 4 + .../json/v2/CommandExecTerminateParams.json | 2 + .../json/v2/CommandExecTerminateResponse.json | 1 + .../json/v2/CommandExecWriteParams.json | 4 + .../json/v2/CommandExecWriteResponse.json | 1 + .../v2/CommandExecOutputDeltaNotification.ts | 26 +++++- .../typescript/v2/CommandExecOutputStream.ts | 3 + .../schema/typescript/v2/CommandExecParams.ts | 92 ++++++++++++++++++- .../typescript/v2/CommandExecResizeParams.ts | 14 ++- .../v2/CommandExecResizeResponse.ts | 3 + .../typescript/v2/CommandExecResponse.ts | 21 ++++- .../typescript/v2/CommandExecTerminalSize.ts | 13 ++- .../v2/CommandExecTerminateParams.ts | 10 +- .../v2/CommandExecTerminateResponse.ts | 3 + .../typescript/v2/CommandExecWriteParams.ts | 19 +++- .../typescript/v2/CommandExecWriteResponse.ts | 3 + .../src/protocol/common.rs | 6 +- .../app-server-protocol/src/protocol/v2.rs | 89 ++++++++++++++++++ codex-rs/app-server/README.md | 6 +- .../app-server/src/codex_message_processor.rs | 54 +++++------ 28 files changed, 601 insertions(+), 79 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index d6b08d0b635..9795c7d356f 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -148,23 +148,28 @@ "type": "object" }, "CommandExecParams": { + "description": "Run a standalone command (argv vector) in the server sandbox without creating a thread or turn.\n\nThe final `command/exec` response is deferred until the process exits and is sent only after all `command/exec/outputDelta` notifications for that connection have been emitted.", "properties": { "command": { + "description": "Command argv vector. Empty arrays are rejected.", "items": { "type": "string" }, "type": "array" }, "cwd": { + "description": "Optional working directory. Defaults to the server cwd.", "type": [ "string", "null" ] }, "disableOutputCap": { + "description": "Disable stdout/stderr capture truncation for this request.\n\nCannot be combined with `outputBytesCap`.", "type": "boolean" }, "disableTimeout": { + "description": "Disable the timeout entirely for this request.\n\nCannot be combined with `timeoutMs`.", "type": "boolean" }, "env": { @@ -174,12 +179,14 @@ "null" ] }, + "description": "Optional environment overrides merged into the server-computed environment.\n\nMatching names override inherited values. Set a key to `null` to unset an inherited variable.", "type": [ "object", "null" ] }, "outputBytesCap": { + "description": "Optional per-stream stdout/stderr capture cap in bytes.\n\nWhen omitted, the server default applies. Cannot be combined with `disableOutputCap`.", "format": "uint", "minimum": 0.0, "type": [ @@ -188,6 +195,7 @@ ] }, "processId": { + "description": "Optional client-supplied, connection-scoped process id.\n\nRequired for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` calls. When omitted, buffered execution gets an internal id that is not exposed to the client.", "type": [ "string", "null" @@ -201,7 +209,8 @@ { "type": "null" } - ] + ], + "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted." }, "size": { "anyOf": [ @@ -211,15 +220,19 @@ { "type": "null" } - ] + ], + "description": "Optional initial PTY size in character cells. Only valid when `tty` is true." }, "streamStdin": { + "description": "Allow follow-up `command/exec/write` requests to write stdin bytes.\n\nRequires a client-supplied `processId`.", "type": "boolean" }, "streamStdoutStderr": { + "description": "Stream stdout/stderr via `command/exec/outputDelta` notifications.\n\nStreamed bytes are not duplicated into the final response and require a client-supplied `processId`.", "type": "boolean" }, "timeoutMs": { + "description": "Optional timeout in milliseconds.\n\nWhen omitted, the server default applies. Cannot be combined with `disableTimeout`.", "format": "int64", "type": [ "integer", @@ -227,6 +240,7 @@ ] }, "tty": { + "description": "Enable PTY mode.\n\nThis implies `streamStdin` and `streamStdoutStderr`.", "type": "boolean" } }, @@ -236,12 +250,19 @@ "type": "object" }, "CommandExecResizeParams": { + "description": "Resize a running PTY-backed `command/exec` session.", "properties": { "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", "type": "string" }, "size": { - "$ref": "#/definitions/CommandExecTerminalSize" + "allOf": [ + { + "$ref": "#/definitions/CommandExecTerminalSize" + } + ], + "description": "New PTY size in character cells." } }, "required": [ @@ -251,13 +272,16 @@ "type": "object" }, "CommandExecTerminalSize": { + "description": "PTY size in character cells for `command/exec` PTY sessions.", "properties": { "cols": { + "description": "Terminal width in character cells.", "format": "uint16", "minimum": 0.0, "type": "integer" }, "rows": { + "description": "Terminal height in character cells.", "format": "uint16", "minimum": 0.0, "type": "integer" @@ -270,8 +294,10 @@ "type": "object" }, "CommandExecTerminateParams": { + "description": "Terminate a running `command/exec` session.", "properties": { "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", "type": "string" } }, @@ -281,17 +307,21 @@ "type": "object" }, "CommandExecWriteParams": { + "description": "Write stdin bytes to a running `command/exec` session, close stdin, or both.", "properties": { "closeStdin": { + "description": "Close stdin after writing `deltaBase64`, if present.", "type": "boolean" }, "deltaBase64": { + "description": "Optional base64-encoded stdin bytes to write.", "type": [ "string", "null" ] }, "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", "type": "string" } }, @@ -3891,7 +3921,7 @@ "type": "object" }, { - "description": "Execute a command (argv vector) under the server's sandbox.", + "description": "Execute a standalone command (argv vector) under the server's sandbox.", "properties": { "id": { "$ref": "#/definitions/RequestId" @@ -3916,6 +3946,7 @@ "type": "object" }, { + "description": "Write stdin bytes to a running `command/exec` session or close stdin.", "properties": { "id": { "$ref": "#/definitions/RequestId" @@ -3940,6 +3971,7 @@ "type": "object" }, { + "description": "Terminate a running `command/exec` session by client-supplied `processId`.", "properties": { "id": { "$ref": "#/definitions/RequestId" @@ -3964,6 +3996,7 @@ "type": "object" }, { + "description": "Resize a running PTY-backed `command/exec` session by client-supplied `processId`.", "properties": { "id": { "$ref": "#/definitions/RequestId" diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 2dd206b57d3..fcb205db041 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -671,18 +671,27 @@ ] }, "CommandExecOutputDeltaNotification": { + "description": "Base64-encoded output chunk emitted for a streaming `command/exec` request.\n\nThese notifications are connection-scoped. If the originating connection closes, the server terminates the process.", "properties": { "capReached": { + "description": "`true` on the final streamed chunk for a stream when `outputBytesCap` truncated later output on that stream.", "type": "boolean" }, "deltaBase64": { + "description": "Base64-encoded output bytes.", "type": "string" }, "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", "type": "string" }, "stream": { - "$ref": "#/definitions/CommandExecOutputStream" + "allOf": [ + { + "$ref": "#/definitions/CommandExecOutputStream" + } + ], + "description": "Output stream for this chunk." } }, "required": [ @@ -694,11 +703,23 @@ "type": "object" }, "CommandExecOutputStream": { - "enum": [ - "stdout", - "stderr" - ], - "type": "string" + "description": "Stream label for `command/exec/outputDelta` notifications.", + "oneOf": [ + { + "description": "stdout stream. PTY mode multiplexes terminal output here.", + "enum": [ + "stdout" + ], + "type": "string" + }, + { + "description": "stderr stream.", + "enum": [ + "stderr" + ], + "type": "string" + } + ] }, "CommandExecutionOutputDeltaNotification": { "properties": { @@ -3499,6 +3520,7 @@ "type": "object" }, { + "description": "Stream base64-encoded stdout/stderr chunks for a running `command/exec` session.", "properties": { "method": { "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index cbf5560550e..81322da0773 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -1218,7 +1218,7 @@ "type": "object" }, { - "description": "Execute a command (argv vector) under the server's sandbox.", + "description": "Execute a standalone command (argv vector) under the server's sandbox.", "properties": { "id": { "$ref": "#/definitions/v2/RequestId" @@ -1243,6 +1243,7 @@ "type": "object" }, { + "description": "Write stdin bytes to a running `command/exec` session or close stdin.", "properties": { "id": { "$ref": "#/definitions/v2/RequestId" @@ -1267,6 +1268,7 @@ "type": "object" }, { + "description": "Terminate a running `command/exec` session by client-supplied `processId`.", "properties": { "id": { "$ref": "#/definitions/v2/RequestId" @@ -1291,6 +1293,7 @@ "type": "object" }, { + "description": "Resize a running PTY-backed `command/exec` session by client-supplied `processId`.", "properties": { "id": { "$ref": "#/definitions/v2/RequestId" @@ -7154,6 +7157,7 @@ "type": "object" }, { + "description": "Stream base64-encoded stdout/stderr chunks for a running `command/exec` session.", "properties": { "method": { "enum": [ @@ -9413,18 +9417,27 @@ }, "CommandExecOutputDeltaNotification": { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Base64-encoded output chunk emitted for a streaming `command/exec` request.\n\nThese notifications are connection-scoped. If the originating connection closes, the server terminates the process.", "properties": { "capReached": { + "description": "`true` on the final streamed chunk for a stream when `outputBytesCap` truncated later output on that stream.", "type": "boolean" }, "deltaBase64": { + "description": "Base64-encoded output bytes.", "type": "string" }, "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", "type": "string" }, "stream": { - "$ref": "#/definitions/v2/CommandExecOutputStream" + "allOf": [ + { + "$ref": "#/definitions/v2/CommandExecOutputStream" + } + ], + "description": "Output stream for this chunk." } }, "required": [ @@ -9437,31 +9450,48 @@ "type": "object" }, "CommandExecOutputStream": { - "enum": [ - "stdout", - "stderr" - ], - "type": "string" + "description": "Stream label for `command/exec/outputDelta` notifications.", + "oneOf": [ + { + "description": "stdout stream. PTY mode multiplexes terminal output here.", + "enum": [ + "stdout" + ], + "type": "string" + }, + { + "description": "stderr stream.", + "enum": [ + "stderr" + ], + "type": "string" + } + ] }, "CommandExecParams": { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Run a standalone command (argv vector) in the server sandbox without creating a thread or turn.\n\nThe final `command/exec` response is deferred until the process exits and is sent only after all `command/exec/outputDelta` notifications for that connection have been emitted.", "properties": { "command": { + "description": "Command argv vector. Empty arrays are rejected.", "items": { "type": "string" }, "type": "array" }, "cwd": { + "description": "Optional working directory. Defaults to the server cwd.", "type": [ "string", "null" ] }, "disableOutputCap": { + "description": "Disable stdout/stderr capture truncation for this request.\n\nCannot be combined with `outputBytesCap`.", "type": "boolean" }, "disableTimeout": { + "description": "Disable the timeout entirely for this request.\n\nCannot be combined with `timeoutMs`.", "type": "boolean" }, "env": { @@ -9471,12 +9501,14 @@ "null" ] }, + "description": "Optional environment overrides merged into the server-computed environment.\n\nMatching names override inherited values. Set a key to `null` to unset an inherited variable.", "type": [ "object", "null" ] }, "outputBytesCap": { + "description": "Optional per-stream stdout/stderr capture cap in bytes.\n\nWhen omitted, the server default applies. Cannot be combined with `disableOutputCap`.", "format": "uint", "minimum": 0.0, "type": [ @@ -9485,6 +9517,7 @@ ] }, "processId": { + "description": "Optional client-supplied, connection-scoped process id.\n\nRequired for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` calls. When omitted, buffered execution gets an internal id that is not exposed to the client.", "type": [ "string", "null" @@ -9498,7 +9531,8 @@ { "type": "null" } - ] + ], + "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted." }, "size": { "anyOf": [ @@ -9508,15 +9542,19 @@ { "type": "null" } - ] + ], + "description": "Optional initial PTY size in character cells. Only valid when `tty` is true." }, "streamStdin": { + "description": "Allow follow-up `command/exec/write` requests to write stdin bytes.\n\nRequires a client-supplied `processId`.", "type": "boolean" }, "streamStdoutStderr": { + "description": "Stream stdout/stderr via `command/exec/outputDelta` notifications.\n\nStreamed bytes are not duplicated into the final response and require a client-supplied `processId`.", "type": "boolean" }, "timeoutMs": { + "description": "Optional timeout in milliseconds.\n\nWhen omitted, the server default applies. Cannot be combined with `disableTimeout`.", "format": "int64", "type": [ "integer", @@ -9524,6 +9562,7 @@ ] }, "tty": { + "description": "Enable PTY mode.\n\nThis implies `streamStdin` and `streamStdoutStderr`.", "type": "boolean" } }, @@ -9535,12 +9574,19 @@ }, "CommandExecResizeParams": { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Resize a running PTY-backed `command/exec` session.", "properties": { "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", "type": "string" }, "size": { - "$ref": "#/definitions/v2/CommandExecTerminalSize" + "allOf": [ + { + "$ref": "#/definitions/v2/CommandExecTerminalSize" + } + ], + "description": "New PTY size in character cells." } }, "required": [ @@ -9552,20 +9598,25 @@ }, "CommandExecResizeResponse": { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Empty success response for `command/exec/resize`.", "title": "CommandExecResizeResponse", "type": "object" }, "CommandExecResponse": { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Final buffered result for `command/exec`.", "properties": { "exitCode": { + "description": "Process exit code.", "format": "int32", "type": "integer" }, "stderr": { + "description": "Buffered stderr capture.\n\nEmpty when stderr was streamed via `command/exec/outputDelta`.", "type": "string" }, "stdout": { + "description": "Buffered stdout capture.\n\nEmpty when stdout was streamed via `command/exec/outputDelta`.", "type": "string" } }, @@ -9578,13 +9629,16 @@ "type": "object" }, "CommandExecTerminalSize": { + "description": "PTY size in character cells for `command/exec` PTY sessions.", "properties": { "cols": { + "description": "Terminal width in character cells.", "format": "uint16", "minimum": 0.0, "type": "integer" }, "rows": { + "description": "Terminal height in character cells.", "format": "uint16", "minimum": 0.0, "type": "integer" @@ -9598,8 +9652,10 @@ }, "CommandExecTerminateParams": { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Terminate a running `command/exec` session.", "properties": { "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", "type": "string" } }, @@ -9611,22 +9667,27 @@ }, "CommandExecTerminateResponse": { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Empty success response for `command/exec/terminate`.", "title": "CommandExecTerminateResponse", "type": "object" }, "CommandExecWriteParams": { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Write stdin bytes to a running `command/exec` session, close stdin, or both.", "properties": { "closeStdin": { + "description": "Close stdin after writing `deltaBase64`, if present.", "type": "boolean" }, "deltaBase64": { + "description": "Optional base64-encoded stdin bytes to write.", "type": [ "string", "null" ] }, "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", "type": "string" } }, @@ -9638,6 +9699,7 @@ }, "CommandExecWriteResponse": { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Empty success response for `command/exec/write`.", "title": "CommandExecWriteResponse", "type": "object" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index a216bdfd331..688650678f5 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -1741,7 +1741,7 @@ "type": "object" }, { - "description": "Execute a command (argv vector) under the server's sandbox.", + "description": "Execute a standalone command (argv vector) under the server's sandbox.", "properties": { "id": { "$ref": "#/definitions/RequestId" @@ -1766,6 +1766,7 @@ "type": "object" }, { + "description": "Write stdin bytes to a running `command/exec` session or close stdin.", "properties": { "id": { "$ref": "#/definitions/RequestId" @@ -1790,6 +1791,7 @@ "type": "object" }, { + "description": "Terminate a running `command/exec` session by client-supplied `processId`.", "properties": { "id": { "$ref": "#/definitions/RequestId" @@ -1814,6 +1816,7 @@ "type": "object" }, { + "description": "Resize a running PTY-backed `command/exec` session by client-supplied `processId`.", "properties": { "id": { "$ref": "#/definitions/RequestId" @@ -2433,18 +2436,27 @@ }, "CommandExecOutputDeltaNotification": { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Base64-encoded output chunk emitted for a streaming `command/exec` request.\n\nThese notifications are connection-scoped. If the originating connection closes, the server terminates the process.", "properties": { "capReached": { + "description": "`true` on the final streamed chunk for a stream when `outputBytesCap` truncated later output on that stream.", "type": "boolean" }, "deltaBase64": { + "description": "Base64-encoded output bytes.", "type": "string" }, "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", "type": "string" }, "stream": { - "$ref": "#/definitions/CommandExecOutputStream" + "allOf": [ + { + "$ref": "#/definitions/CommandExecOutputStream" + } + ], + "description": "Output stream for this chunk." } }, "required": [ @@ -2457,31 +2469,48 @@ "type": "object" }, "CommandExecOutputStream": { - "enum": [ - "stdout", - "stderr" - ], - "type": "string" + "description": "Stream label for `command/exec/outputDelta` notifications.", + "oneOf": [ + { + "description": "stdout stream. PTY mode multiplexes terminal output here.", + "enum": [ + "stdout" + ], + "type": "string" + }, + { + "description": "stderr stream.", + "enum": [ + "stderr" + ], + "type": "string" + } + ] }, "CommandExecParams": { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Run a standalone command (argv vector) in the server sandbox without creating a thread or turn.\n\nThe final `command/exec` response is deferred until the process exits and is sent only after all `command/exec/outputDelta` notifications for that connection have been emitted.", "properties": { "command": { + "description": "Command argv vector. Empty arrays are rejected.", "items": { "type": "string" }, "type": "array" }, "cwd": { + "description": "Optional working directory. Defaults to the server cwd.", "type": [ "string", "null" ] }, "disableOutputCap": { + "description": "Disable stdout/stderr capture truncation for this request.\n\nCannot be combined with `outputBytesCap`.", "type": "boolean" }, "disableTimeout": { + "description": "Disable the timeout entirely for this request.\n\nCannot be combined with `timeoutMs`.", "type": "boolean" }, "env": { @@ -2491,12 +2520,14 @@ "null" ] }, + "description": "Optional environment overrides merged into the server-computed environment.\n\nMatching names override inherited values. Set a key to `null` to unset an inherited variable.", "type": [ "object", "null" ] }, "outputBytesCap": { + "description": "Optional per-stream stdout/stderr capture cap in bytes.\n\nWhen omitted, the server default applies. Cannot be combined with `disableOutputCap`.", "format": "uint", "minimum": 0.0, "type": [ @@ -2505,6 +2536,7 @@ ] }, "processId": { + "description": "Optional client-supplied, connection-scoped process id.\n\nRequired for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` calls. When omitted, buffered execution gets an internal id that is not exposed to the client.", "type": [ "string", "null" @@ -2518,7 +2550,8 @@ { "type": "null" } - ] + ], + "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted." }, "size": { "anyOf": [ @@ -2528,15 +2561,19 @@ { "type": "null" } - ] + ], + "description": "Optional initial PTY size in character cells. Only valid when `tty` is true." }, "streamStdin": { + "description": "Allow follow-up `command/exec/write` requests to write stdin bytes.\n\nRequires a client-supplied `processId`.", "type": "boolean" }, "streamStdoutStderr": { + "description": "Stream stdout/stderr via `command/exec/outputDelta` notifications.\n\nStreamed bytes are not duplicated into the final response and require a client-supplied `processId`.", "type": "boolean" }, "timeoutMs": { + "description": "Optional timeout in milliseconds.\n\nWhen omitted, the server default applies. Cannot be combined with `disableTimeout`.", "format": "int64", "type": [ "integer", @@ -2544,6 +2581,7 @@ ] }, "tty": { + "description": "Enable PTY mode.\n\nThis implies `streamStdin` and `streamStdoutStderr`.", "type": "boolean" } }, @@ -2555,12 +2593,19 @@ }, "CommandExecResizeParams": { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Resize a running PTY-backed `command/exec` session.", "properties": { "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", "type": "string" }, "size": { - "$ref": "#/definitions/CommandExecTerminalSize" + "allOf": [ + { + "$ref": "#/definitions/CommandExecTerminalSize" + } + ], + "description": "New PTY size in character cells." } }, "required": [ @@ -2572,20 +2617,25 @@ }, "CommandExecResizeResponse": { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Empty success response for `command/exec/resize`.", "title": "CommandExecResizeResponse", "type": "object" }, "CommandExecResponse": { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Final buffered result for `command/exec`.", "properties": { "exitCode": { + "description": "Process exit code.", "format": "int32", "type": "integer" }, "stderr": { + "description": "Buffered stderr capture.\n\nEmpty when stderr was streamed via `command/exec/outputDelta`.", "type": "string" }, "stdout": { + "description": "Buffered stdout capture.\n\nEmpty when stdout was streamed via `command/exec/outputDelta`.", "type": "string" } }, @@ -2598,13 +2648,16 @@ "type": "object" }, "CommandExecTerminalSize": { + "description": "PTY size in character cells for `command/exec` PTY sessions.", "properties": { "cols": { + "description": "Terminal width in character cells.", "format": "uint16", "minimum": 0.0, "type": "integer" }, "rows": { + "description": "Terminal height in character cells.", "format": "uint16", "minimum": 0.0, "type": "integer" @@ -2618,8 +2671,10 @@ }, "CommandExecTerminateParams": { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Terminate a running `command/exec` session.", "properties": { "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", "type": "string" } }, @@ -2631,22 +2686,27 @@ }, "CommandExecTerminateResponse": { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Empty success response for `command/exec/terminate`.", "title": "CommandExecTerminateResponse", "type": "object" }, "CommandExecWriteParams": { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Write stdin bytes to a running `command/exec` session, close stdin, or both.", "properties": { "closeStdin": { + "description": "Close stdin after writing `deltaBase64`, if present.", "type": "boolean" }, "deltaBase64": { + "description": "Optional base64-encoded stdin bytes to write.", "type": [ "string", "null" ] }, "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", "type": "string" } }, @@ -2658,6 +2718,7 @@ }, "CommandExecWriteResponse": { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Empty success response for `command/exec/write`.", "title": "CommandExecWriteResponse", "type": "object" }, @@ -10833,6 +10894,7 @@ "type": "object" }, { + "description": "Stream base64-encoded stdout/stderr chunks for a running `command/exec` session.", "properties": { "method": { "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecOutputDeltaNotification.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecOutputDeltaNotification.json index d9f829aa497..fff7e57d50d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/CommandExecOutputDeltaNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecOutputDeltaNotification.json @@ -2,25 +2,46 @@ "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { "CommandExecOutputStream": { - "enum": [ - "stdout", - "stderr" - ], - "type": "string" + "description": "Stream label for `command/exec/outputDelta` notifications.", + "oneOf": [ + { + "description": "stdout stream. PTY mode multiplexes terminal output here.", + "enum": [ + "stdout" + ], + "type": "string" + }, + { + "description": "stderr stream.", + "enum": [ + "stderr" + ], + "type": "string" + } + ] } }, + "description": "Base64-encoded output chunk emitted for a streaming `command/exec` request.\n\nThese notifications are connection-scoped. If the originating connection closes, the server terminates the process.", "properties": { "capReached": { + "description": "`true` on the final streamed chunk for a stream when `outputBytesCap` truncated later output on that stream.", "type": "boolean" }, "deltaBase64": { + "description": "Base64-encoded output bytes.", "type": "string" }, "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", "type": "string" }, "stream": { - "$ref": "#/definitions/CommandExecOutputStream" + "allOf": [ + { + "$ref": "#/definitions/CommandExecOutputStream" + } + ], + "description": "Output stream for this chunk." } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json index 06140b91cb2..986ec4cc1e0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json @@ -6,13 +6,16 @@ "type": "string" }, "CommandExecTerminalSize": { + "description": "PTY size in character cells for `command/exec` PTY sessions.", "properties": { "cols": { + "description": "Terminal width in character cells.", "format": "uint16", "minimum": 0.0, "type": "integer" }, "rows": { + "description": "Terminal height in character cells.", "format": "uint16", "minimum": 0.0, "type": "integer" @@ -198,23 +201,28 @@ ] } }, + "description": "Run a standalone command (argv vector) in the server sandbox without creating a thread or turn.\n\nThe final `command/exec` response is deferred until the process exits and is sent only after all `command/exec/outputDelta` notifications for that connection have been emitted.", "properties": { "command": { + "description": "Command argv vector. Empty arrays are rejected.", "items": { "type": "string" }, "type": "array" }, "cwd": { + "description": "Optional working directory. Defaults to the server cwd.", "type": [ "string", "null" ] }, "disableOutputCap": { + "description": "Disable stdout/stderr capture truncation for this request.\n\nCannot be combined with `outputBytesCap`.", "type": "boolean" }, "disableTimeout": { + "description": "Disable the timeout entirely for this request.\n\nCannot be combined with `timeoutMs`.", "type": "boolean" }, "env": { @@ -224,12 +232,14 @@ "null" ] }, + "description": "Optional environment overrides merged into the server-computed environment.\n\nMatching names override inherited values. Set a key to `null` to unset an inherited variable.", "type": [ "object", "null" ] }, "outputBytesCap": { + "description": "Optional per-stream stdout/stderr capture cap in bytes.\n\nWhen omitted, the server default applies. Cannot be combined with `disableOutputCap`.", "format": "uint", "minimum": 0.0, "type": [ @@ -238,6 +248,7 @@ ] }, "processId": { + "description": "Optional client-supplied, connection-scoped process id.\n\nRequired for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` calls. When omitted, buffered execution gets an internal id that is not exposed to the client.", "type": [ "string", "null" @@ -251,7 +262,8 @@ { "type": "null" } - ] + ], + "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted." }, "size": { "anyOf": [ @@ -261,15 +273,19 @@ { "type": "null" } - ] + ], + "description": "Optional initial PTY size in character cells. Only valid when `tty` is true." }, "streamStdin": { + "description": "Allow follow-up `command/exec/write` requests to write stdin bytes.\n\nRequires a client-supplied `processId`.", "type": "boolean" }, "streamStdoutStderr": { + "description": "Stream stdout/stderr via `command/exec/outputDelta` notifications.\n\nStreamed bytes are not duplicated into the final response and require a client-supplied `processId`.", "type": "boolean" }, "timeoutMs": { + "description": "Optional timeout in milliseconds.\n\nWhen omitted, the server default applies. Cannot be combined with `disableTimeout`.", "format": "int64", "type": [ "integer", @@ -277,6 +293,7 @@ ] }, "tty": { + "description": "Enable PTY mode.\n\nThis implies `streamStdin` and `streamStdoutStderr`.", "type": "boolean" } }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecResizeParams.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecResizeParams.json index 031aacb449e..57d3b6a30c6 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/CommandExecResizeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecResizeParams.json @@ -2,13 +2,16 @@ "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { "CommandExecTerminalSize": { + "description": "PTY size in character cells for `command/exec` PTY sessions.", "properties": { "cols": { + "description": "Terminal width in character cells.", "format": "uint16", "minimum": 0.0, "type": "integer" }, "rows": { + "description": "Terminal height in character cells.", "format": "uint16", "minimum": 0.0, "type": "integer" @@ -21,12 +24,19 @@ "type": "object" } }, + "description": "Resize a running PTY-backed `command/exec` session.", "properties": { "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", "type": "string" }, "size": { - "$ref": "#/definitions/CommandExecTerminalSize" + "allOf": [ + { + "$ref": "#/definitions/CommandExecTerminalSize" + } + ], + "description": "New PTY size in character cells." } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecResizeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecResizeResponse.json index 66440314ad0..def86b66d09 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/CommandExecResizeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecResizeResponse.json @@ -1,5 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Empty success response for `command/exec/resize`.", "title": "CommandExecResizeResponse", "type": "object" } \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecResponse.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecResponse.json index 8ca0f46b77d..1bbc5192380 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/CommandExecResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecResponse.json @@ -1,14 +1,18 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Final buffered result for `command/exec`.", "properties": { "exitCode": { + "description": "Process exit code.", "format": "int32", "type": "integer" }, "stderr": { + "description": "Buffered stderr capture.\n\nEmpty when stderr was streamed via `command/exec/outputDelta`.", "type": "string" }, "stdout": { + "description": "Buffered stdout capture.\n\nEmpty when stdout was streamed via `command/exec/outputDelta`.", "type": "string" } }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecTerminateParams.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecTerminateParams.json index 4a110c40b82..1f848770517 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/CommandExecTerminateParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecTerminateParams.json @@ -1,7 +1,9 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Terminate a running `command/exec` session.", "properties": { "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", "type": "string" } }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecTerminateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecTerminateResponse.json index 39e7ac00e5b..59bdb0cb304 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/CommandExecTerminateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecTerminateResponse.json @@ -1,5 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Empty success response for `command/exec/terminate`.", "title": "CommandExecTerminateResponse", "type": "object" } \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecWriteParams.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecWriteParams.json index 397a72e6929..440f2410bf1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/CommandExecWriteParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecWriteParams.json @@ -1,16 +1,20 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Write stdin bytes to a running `command/exec` session, close stdin, or both.", "properties": { "closeStdin": { + "description": "Close stdin after writing `deltaBase64`, if present.", "type": "boolean" }, "deltaBase64": { + "description": "Optional base64-encoded stdin bytes to write.", "type": [ "string", "null" ] }, "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", "type": "string" } }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecWriteResponse.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecWriteResponse.json index 155078eb4f4..dff8301ebbd 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/CommandExecWriteResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecWriteResponse.json @@ -1,5 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Empty success response for `command/exec/write`.", "title": "CommandExecWriteResponse", "type": "object" } \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecOutputDeltaNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecOutputDeltaNotification.ts index dc19080de3a..9a4b7280623 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecOutputDeltaNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecOutputDeltaNotification.ts @@ -3,4 +3,28 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { CommandExecOutputStream } from "./CommandExecOutputStream"; -export type CommandExecOutputDeltaNotification = { processId: string, stream: CommandExecOutputStream, deltaBase64: string, capReached: boolean, }; +/** + * Base64-encoded output chunk emitted for a streaming `command/exec` request. + * + * These notifications are connection-scoped. If the originating connection + * closes, the server terminates the process. + */ +export type CommandExecOutputDeltaNotification = { +/** + * Client-supplied, connection-scoped `processId` from the original + * `command/exec` request. + */ +processId: string, +/** + * Output stream for this chunk. + */ +stream: CommandExecOutputStream, +/** + * Base64-encoded output bytes. + */ +deltaBase64: string, +/** + * `true` on the final streamed chunk for a stream when `outputBytesCap` + * truncated later output on that stream. + */ +capReached: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecOutputStream.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecOutputStream.ts index fd70f68612c..a8c5b66711d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecOutputStream.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecOutputStream.ts @@ -2,4 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +/** + * Stream label for `command/exec/outputDelta` notifications. + */ export type CommandExecOutputStream = "stdout" | "stderr"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts index 27f87551e79..2e132f78184 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts @@ -4,4 +4,94 @@ import type { CommandExecTerminalSize } from "./CommandExecTerminalSize"; import type { SandboxPolicy } from "./SandboxPolicy"; -export type CommandExecParams = { command: Array, processId?: string | null, tty?: boolean, streamStdin?: boolean, streamStdoutStderr?: boolean, outputBytesCap?: number | null, disableOutputCap?: boolean, disableTimeout?: boolean, timeoutMs?: number | null, cwd?: string | null, env?: { [key in string]?: string | null } | null, size?: CommandExecTerminalSize | null, sandboxPolicy?: SandboxPolicy | null, }; +/** + * Run a standalone command (argv vector) in the server sandbox without + * creating a thread or turn. + * + * The final `command/exec` response is deferred until the process exits and is + * sent only after all `command/exec/outputDelta` notifications for that + * connection have been emitted. + */ +export type CommandExecParams = { +/** + * Command argv vector. Empty arrays are rejected. + */ +command: Array, +/** + * Optional client-supplied, connection-scoped process id. + * + * Required for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up + * `command/exec/write`, `command/exec/resize`, and + * `command/exec/terminate` calls. When omitted, buffered execution gets an + * internal id that is not exposed to the client. + */ +processId?: string | null, +/** + * Enable PTY mode. + * + * This implies `streamStdin` and `streamStdoutStderr`. + */ +tty?: boolean, +/** + * Allow follow-up `command/exec/write` requests to write stdin bytes. + * + * Requires a client-supplied `processId`. + */ +streamStdin?: boolean, +/** + * Stream stdout/stderr via `command/exec/outputDelta` notifications. + * + * Streamed bytes are not duplicated into the final response and require a + * client-supplied `processId`. + */ +streamStdoutStderr?: boolean, +/** + * Optional per-stream stdout/stderr capture cap in bytes. + * + * When omitted, the server default applies. Cannot be combined with + * `disableOutputCap`. + */ +outputBytesCap?: number | null, +/** + * Disable stdout/stderr capture truncation for this request. + * + * Cannot be combined with `outputBytesCap`. + */ +disableOutputCap?: boolean, +/** + * Disable the timeout entirely for this request. + * + * Cannot be combined with `timeoutMs`. + */ +disableTimeout?: boolean, +/** + * Optional timeout in milliseconds. + * + * When omitted, the server default applies. Cannot be combined with + * `disableTimeout`. + */ +timeoutMs?: number | null, +/** + * Optional working directory. Defaults to the server cwd. + */ +cwd?: string | null, +/** + * Optional environment overrides merged into the server-computed + * environment. + * + * Matching names override inherited values. Set a key to `null` to unset + * an inherited variable. + */ +env?: { [key in string]?: string | null } | null, +/** + * Optional initial PTY size in character cells. Only valid when `tty` is + * true. + */ +size?: CommandExecTerminalSize | null, +/** + * Optional sandbox policy for this command. + * + * Uses the same shape as thread/turn execution sandbox configuration and + * defaults to the user's configured policy when omitted. + */ +sandboxPolicy?: SandboxPolicy | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecResizeParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecResizeParams.ts index 350bf07700e..dde1417c0a3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecResizeParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecResizeParams.ts @@ -3,4 +3,16 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { CommandExecTerminalSize } from "./CommandExecTerminalSize"; -export type CommandExecResizeParams = { processId: string, size: CommandExecTerminalSize, }; +/** + * Resize a running PTY-backed `command/exec` session. + */ +export type CommandExecResizeParams = { +/** + * Client-supplied, connection-scoped `processId` from the original + * `command/exec` request. + */ +processId: string, +/** + * New PTY size in character cells. + */ +size: CommandExecTerminalSize, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecResizeResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecResizeResponse.ts index 681f26b0170..7b7f2be7006 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecResizeResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecResizeResponse.ts @@ -2,4 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +/** + * Empty success response for `command/exec/resize`. + */ export type CommandExecResizeResponse = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecResponse.ts index 6887a3e3c2c..c13efeffe6c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecResponse.ts @@ -2,4 +2,23 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type CommandExecResponse = { exitCode: number, stdout: string, stderr: string, }; +/** + * Final buffered result for `command/exec`. + */ +export type CommandExecResponse = { +/** + * Process exit code. + */ +exitCode: number, +/** + * Buffered stdout capture. + * + * Empty when stdout was streamed via `command/exec/outputDelta`. + */ +stdout: string, +/** + * Buffered stderr capture. + * + * Empty when stderr was streamed via `command/exec/outputDelta`. + */ +stderr: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminalSize.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminalSize.ts index 9ca9982e2c7..5181b154ffc 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminalSize.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminalSize.ts @@ -2,4 +2,15 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type CommandExecTerminalSize = { rows: number, cols: number, }; +/** + * PTY size in character cells for `command/exec` PTY sessions. + */ +export type CommandExecTerminalSize = { +/** + * Terminal height in character cells. + */ +rows: number, +/** + * Terminal width in character cells. + */ +cols: number, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminateParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminateParams.ts index 376dd4cf04f..8012d29e41e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminateParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminateParams.ts @@ -2,4 +2,12 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type CommandExecTerminateParams = { processId: string, }; +/** + * Terminate a running `command/exec` session. + */ +export type CommandExecTerminateParams = { +/** + * Client-supplied, connection-scoped `processId` from the original + * `command/exec` request. + */ +processId: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminateResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminateResponse.ts index 6c36ab7c9d1..dc6371fbdd6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminateResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminateResponse.ts @@ -2,4 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +/** + * Empty success response for `command/exec/terminate`. + */ export type CommandExecTerminateResponse = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecWriteParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecWriteParams.ts index b4b61baf1c5..b4df50f1bfb 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecWriteParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecWriteParams.ts @@ -2,4 +2,21 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type CommandExecWriteParams = { processId: string, deltaBase64?: string | null, closeStdin?: boolean, }; +/** + * Write stdin bytes to a running `command/exec` session, close stdin, or + * both. + */ +export type CommandExecWriteParams = { +/** + * Client-supplied, connection-scoped `processId` from the original + * `command/exec` request. + */ +processId: string, +/** + * Optional base64-encoded stdin bytes to write. + */ +deltaBase64?: string | null, +/** + * Close stdin after writing `deltaBase64`, if present. + */ +closeStdin?: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecWriteResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecWriteResponse.ts index a333aba1d48..6dbbddf4dd2 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecWriteResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecWriteResponse.ts @@ -2,4 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +/** + * Empty success response for `command/exec/write`. + */ export type CommandExecWriteResponse = Record; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index b62b667ec09..54c0425256c 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -377,19 +377,22 @@ client_request_definitions! { response: v2::FeedbackUploadResponse, }, - /// Execute a command (argv vector) under the server's sandbox. + /// Execute a standalone command (argv vector) under the server's sandbox. OneOffCommandExec => "command/exec" { params: v2::CommandExecParams, response: v2::CommandExecResponse, }, + /// Write stdin bytes to a running `command/exec` session or close stdin. CommandExecWrite => "command/exec/write" { params: v2::CommandExecWriteParams, response: v2::CommandExecWriteResponse, }, + /// Terminate a running `command/exec` session by client-supplied `processId`. CommandExecTerminate => "command/exec/terminate" { params: v2::CommandExecTerminateParams, response: v2::CommandExecTerminateResponse, }, + /// Resize a running PTY-backed `command/exec` session by client-supplied `processId`. CommandExecResize => "command/exec/resize" { params: v2::CommandExecResizeParams, response: v2::CommandExecResizeResponse, @@ -793,6 +796,7 @@ server_notification_definitions! { AgentMessageDelta => "item/agentMessage/delta" (v2::AgentMessageDeltaNotification), /// EXPERIMENTAL - proposed plan streaming deltas for plan items. PlanDelta => "item/plan/delta" (v2::PlanDeltaNotification), + /// Stream base64-encoded stdout/stderr chunks for a running `command/exec` session. CommandExecOutputDelta => "command/exec/outputDelta" (v2::CommandExecOutputDeltaNotification), CommandExecutionOutputDelta => "item/commandExecution/outputDelta" (v2::CommandExecutionOutputDeltaNotification), TerminalInteraction => "item/commandExecution/terminalInteraction" (v2::TerminalInteractionNotification), diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 2ebed2d1213..bda5b0df50c 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1804,102 +1804,181 @@ pub struct FeedbackUploadResponse { pub thread_id: String, } +/// PTY size in character cells for `command/exec` PTY sessions. #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct CommandExecTerminalSize { + /// Terminal height in character cells. pub rows: u16, + /// Terminal width in character cells. pub cols: u16, } +/// Run a standalone command (argv vector) in the server sandbox without +/// creating a thread or turn. +/// +/// The final `command/exec` response is deferred until the process exits and is +/// sent only after all `command/exec/outputDelta` notifications for that +/// connection have been emitted. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct CommandExecParams { + /// Command argv vector. Empty arrays are rejected. pub command: Vec, + /// Optional client-supplied, connection-scoped process id. + /// + /// Required for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up + /// `command/exec/write`, `command/exec/resize`, and + /// `command/exec/terminate` calls. When omitted, buffered execution gets an + /// internal id that is not exposed to the client. #[ts(optional = nullable)] pub process_id: Option, + /// Enable PTY mode. + /// + /// This implies `streamStdin` and `streamStdoutStderr`. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub tty: bool, + /// Allow follow-up `command/exec/write` requests to write stdin bytes. + /// + /// Requires a client-supplied `processId`. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub stream_stdin: bool, + /// Stream stdout/stderr via `command/exec/outputDelta` notifications. + /// + /// Streamed bytes are not duplicated into the final response and require a + /// client-supplied `processId`. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub stream_stdout_stderr: bool, + /// Optional per-stream stdout/stderr capture cap in bytes. + /// + /// When omitted, the server default applies. Cannot be combined with + /// `disableOutputCap`. #[ts(type = "number | null")] #[ts(optional = nullable)] pub output_bytes_cap: Option, + /// Disable stdout/stderr capture truncation for this request. + /// + /// Cannot be combined with `outputBytesCap`. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub disable_output_cap: bool, + /// Disable the timeout entirely for this request. + /// + /// Cannot be combined with `timeoutMs`. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub disable_timeout: bool, + /// Optional timeout in milliseconds. + /// + /// When omitted, the server default applies. Cannot be combined with + /// `disableTimeout`. #[ts(type = "number | null")] #[ts(optional = nullable)] pub timeout_ms: Option, + /// Optional working directory. Defaults to the server cwd. #[ts(optional = nullable)] pub cwd: Option, + /// Optional environment overrides merged into the server-computed + /// environment. + /// + /// Matching names override inherited values. Set a key to `null` to unset + /// an inherited variable. #[ts(optional = nullable)] pub env: Option>>, + /// Optional initial PTY size in character cells. Only valid when `tty` is + /// true. #[ts(optional = nullable)] pub size: Option, + /// Optional sandbox policy for this command. + /// + /// Uses the same shape as thread/turn execution sandbox configuration and + /// defaults to the user's configured policy when omitted. #[ts(optional = nullable)] pub sandbox_policy: Option, } +/// Final buffered result for `command/exec`. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct CommandExecResponse { + /// Process exit code. pub exit_code: i32, + /// Buffered stdout capture. + /// + /// Empty when stdout was streamed via `command/exec/outputDelta`. pub stdout: String, + /// Buffered stderr capture. + /// + /// Empty when stderr was streamed via `command/exec/outputDelta`. pub stderr: String, } +/// Write stdin bytes to a running `command/exec` session, close stdin, or +/// both. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct CommandExecWriteParams { + /// Client-supplied, connection-scoped `processId` from the original + /// `command/exec` request. pub process_id: String, + /// Optional base64-encoded stdin bytes to write. #[ts(optional = nullable)] pub delta_base64: Option, + /// Close stdin after writing `deltaBase64`, if present. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub close_stdin: bool, } +/// Empty success response for `command/exec/write`. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct CommandExecWriteResponse {} +/// Terminate a running `command/exec` session. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct CommandExecTerminateParams { + /// Client-supplied, connection-scoped `processId` from the original + /// `command/exec` request. pub process_id: String, } +/// Empty success response for `command/exec/terminate`. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct CommandExecTerminateResponse {} +/// Resize a running PTY-backed `command/exec` session. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct CommandExecResizeParams { + /// Client-supplied, connection-scoped `processId` from the original + /// `command/exec` request. pub process_id: String, + /// New PTY size in character cells. pub size: CommandExecTerminalSize, } +/// Empty success response for `command/exec/resize`. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct CommandExecResizeResponse {} +/// Stream label for `command/exec/outputDelta` notifications. #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub enum CommandExecOutputStream { + /// stdout stream. PTY mode multiplexes terminal output here. Stdout, + /// stderr stream. Stderr, } @@ -4041,13 +4120,23 @@ pub struct CommandExecutionOutputDeltaNotification { pub delta: String, } +/// Base64-encoded output chunk emitted for a streaming `command/exec` request. +/// +/// These notifications are connection-scoped. If the originating connection +/// closes, the server terminates the process. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct CommandExecOutputDeltaNotification { + /// Client-supplied, connection-scoped `processId` from the original + /// `command/exec` request. pub process_id: String, + /// Output stream for this chunk. pub stream: CommandExecOutputStream, + /// Base64-encoded output bytes. pub delta_base64: String, + /// `true` on the final streamed chunk for a stream when `outputBytesCap` + /// truncated later output on that stream. pub cap_reached: bool, } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index d30f627da98..5bba8bcb6bd 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -144,7 +144,10 @@ Example with notification opt-out: - `thread/realtime/stop` — stop the active realtime session for the thread (experimental); returns `{}`. - `review/start` — kick off Codex’s automated reviewer for a thread; responds like `turn/start` and emits `item/started`/`item/completed` notifications with `enteredReviewMode` and `exitedReviewMode` items, plus a final assistant `agentMessage` containing the review. - `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation). +- `command/exec/write` — write base64-decoded stdin bytes to a running `command/exec` session or close stdin; returns `{}`. - `command/exec/resize` — resize a running PTY-backed `command/exec` session by `processId`; returns `{}`. +- `command/exec/terminate` — terminate a running `command/exec` session by `processId`; returns `{}`. +- `command/exec/outputDelta` — notification emitted for base64-encoded stdout/stderr chunks from a streaming `command/exec` session. - `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. @@ -162,7 +165,6 @@ Example with notification opt-out: - `mcpServerStatus/list` — enumerate configured MCP servers with their tools, resources, resource templates, and auth status; supports cursor+limit pagination. - `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`. - `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id. -- `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation). - `config/read` — fetch the effective config on disk after resolving config layering. - `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home). - `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home). @@ -617,7 +619,7 @@ Run a standalone command (argv vector) in the server’s sandbox without creatin "processId": "ls-1", // optional string; required for streaming and ability to terminate the process "cwd": "/Users/me/project", // optional; defaults to server cwd "env": { "FOO": "override" }, // optional; merges into the server env and overrides matching names - "size": { "rows": 40, "cols": 120 }, // optional; PTY size in character cells, only valid with tty=true + "size": { "rows": 40, "cols": 120 }, // optional; PTY size in character cells, only valid with tty=true "sandboxPolicy": { "type": "workspaceWrite" }, // optional; defaults to user config "outputBytesCap": 1048576, // optional; per-stream capture cap "disableOutputCap": false, // optional; cannot be combined with outputBytesCap diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 785198cbdb0..c6e7fb2c99c 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1715,14 +1715,14 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: CommandExecWriteParams, ) { - let command_exec_manager = self.command_exec_manager.clone(); - let outgoing = self.outgoing.clone(); - tokio::spawn(async move { - match command_exec_manager.write(request_id.clone(), params).await { - Ok(response) => outgoing.send_response(request_id, response).await, - Err(error) => outgoing.send_error(request_id, error).await, - } - }); + match self + .command_exec_manager + .write(request_id.clone(), params) + .await + { + Ok(response) => self.outgoing.send_response(request_id, response).await, + Err(error) => self.outgoing.send_error(request_id, error).await, + } } async fn command_exec_resize( @@ -1730,17 +1730,14 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: CommandExecResizeParams, ) { - let command_exec_manager = self.command_exec_manager.clone(); - let outgoing = self.outgoing.clone(); - tokio::spawn(async move { - match command_exec_manager - .resize(request_id.clone(), params) - .await - { - Ok(response) => outgoing.send_response(request_id, response).await, - Err(error) => outgoing.send_error(request_id, error).await, - } - }); + match self + .command_exec_manager + .resize(request_id.clone(), params) + .await + { + Ok(response) => self.outgoing.send_response(request_id, response).await, + Err(error) => self.outgoing.send_error(request_id, error).await, + } } async fn command_exec_terminate( @@ -1748,17 +1745,14 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: CommandExecTerminateParams, ) { - let command_exec_manager = self.command_exec_manager.clone(); - let outgoing = self.outgoing.clone(); - tokio::spawn(async move { - match command_exec_manager - .terminate(request_id.clone(), params) - .await - { - Ok(response) => outgoing.send_response(request_id, response).await, - Err(error) => outgoing.send_error(request_id, error).await, - } - }); + match self + .command_exec_manager + .terminate(request_id.clone(), params) + .await + { + Ok(response) => self.outgoing.send_response(request_id, response).await, + Err(error) => self.outgoing.send_error(request_id, error).await, + } } async fn thread_start(&self, request_id: ConnectionRequestId, params: ThreadStartParams) { From 030d6a55b3c5192f6e62c8847bfa5383d5dc2d76 Mon Sep 17 00:00:00 2001 From: Ruslan Nigmatullin Date: Fri, 6 Mar 2026 16:00:00 -0800 Subject: [PATCH 6/6] deflake test --- .../app-server/tests/suite/v2/command_exec.rs | 81 ++++++++++++++----- 1 file changed, 61 insertions(+), 20 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/command_exec.rs b/codex-rs/app-server/tests/suite/v2/command_exec.rs index 5d81e001b5b..562e398925a 100644 --- a/codex-rs/app-server/tests/suite/v2/command_exec.rs +++ b/codex-rs/app-server/tests/suite/v2/command_exec.rs @@ -563,14 +563,16 @@ async fn command_exec_tty_implies_streaming_and_reports_pty_output() -> Result<( }) .await?; - let started_delta = read_command_exec_delta(&mut mcp).await?; - assert_eq!(started_delta.process_id, process_id.as_str()); - assert_eq!(started_delta.stream, CommandExecOutputStream::Stdout); + let started_text = read_command_exec_output_until_contains( + &mut mcp, + process_id.as_str(), + CommandExecOutputStream::Stdout, + "tty\n", + ) + .await?; assert!( - String::from_utf8(STANDARD.decode(&started_delta.delta_base64)?)? - .replace('\r', "") - .contains("tty\n"), - "expected TTY startup output, got {started_delta:?}" + started_text.contains("tty\n"), + "expected TTY startup output, got {started_text:?}" ); let write_request_id = mcp @@ -585,14 +587,16 @@ async fn command_exec_tty_implies_streaming_and_reports_pty_output() -> Result<( .await?; assert_eq!(write_response.result, serde_json::json!({})); - let echoed_delta = read_command_exec_delta(&mut mcp).await?; - assert_eq!(echoed_delta.process_id, process_id.as_str()); - assert_eq!(echoed_delta.stream, CommandExecOutputStream::Stdout); + let echoed_text = read_command_exec_output_until_contains( + &mut mcp, + process_id.as_str(), + CommandExecOutputStream::Stdout, + "echo:world\n", + ) + .await?; assert!( - String::from_utf8(STANDARD.decode(&echoed_delta.delta_base64)?)? - .replace('\r', "") - .contains("echo:world\n"), - "expected TTY echo output, got {echoed_delta:?}" + echoed_text.contains("echo:world\n"), + "expected TTY echo output, got {echoed_text:?}" ); let response = mcp @@ -640,9 +644,13 @@ async fn command_exec_tty_supports_initial_size_and_resize() -> Result<()> { }) .await?; - let started_delta = read_command_exec_delta(&mut mcp).await?; - let started_text = - String::from_utf8(STANDARD.decode(&started_delta.delta_base64)?)?.replace('\r', ""); + let started_text = read_command_exec_output_until_contains( + &mut mcp, + process_id.as_str(), + CommandExecOutputStream::Stdout, + "start:31 101\n", + ) + .await?; assert!( started_text.contains("start:31 101\n"), "unexpected initial size output: {started_text:?}" @@ -674,9 +682,13 @@ async fn command_exec_tty_supports_initial_size_and_resize() -> Result<()> { .await?; assert_eq!(write_response.result, serde_json::json!({})); - let resized_delta = read_command_exec_delta(&mut mcp).await?; - let resized_text = - String::from_utf8(STANDARD.decode(&resized_delta.delta_base64)?)?.replace('\r', ""); + let resized_text = read_command_exec_output_until_contains( + &mut mcp, + process_id.as_str(), + CommandExecOutputStream::Stdout, + "after:45 132\n", + ) + .await?; assert!( resized_text.contains("after:45 132\n"), "unexpected resized output: {resized_text:?}" @@ -779,6 +791,35 @@ async fn read_command_exec_delta( decode_delta_notification(notification) } +async fn read_command_exec_output_until_contains( + mcp: &mut McpProcess, + process_id: &str, + stream: CommandExecOutputStream, + expected: &str, +) -> Result { + let deadline = Instant::now() + DEFAULT_READ_TIMEOUT; + let mut collected = String::new(); + + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + let delta = timeout(remaining, read_command_exec_delta(mcp)) + .await + .with_context(|| { + format!( + "timed out waiting for {expected:?} in command/exec output for {process_id}; collected {collected:?}" + ) + })??; + assert_eq!(delta.process_id, process_id); + assert_eq!(delta.stream, stream); + + let delta_text = String::from_utf8(STANDARD.decode(&delta.delta_base64)?)?; + collected.push_str(&delta_text.replace('\r', "")); + if collected.contains(expected) { + return Ok(collected); + } + } +} + async fn read_command_exec_delta_ws( stream: &mut super::connection_handling_websocket::WsClient, ) -> Result {