use std::collections::HashMap; use std::collections::VecDeque; use std::sync::Arc; use std::time::Duration; use codex_app_server_protocol::FsCopyParams; use codex_app_server_protocol::FsCopyResponse; use codex_app_server_protocol::FsCreateDirectoryParams; use codex_app_server_protocol::FsCreateDirectoryResponse; use codex_app_server_protocol::FsGetMetadataParams; use codex_app_server_protocol::FsGetMetadataResponse; use codex_app_server_protocol::FsReadDirectoryParams; use codex_app_server_protocol::FsReadDirectoryResponse; use codex_app_server_protocol::FsReadFileParams; use codex_app_server_protocol::FsReadFileResponse; use codex_app_server_protocol::FsRemoveParams; use codex_app_server_protocol::FsRemoveResponse; use codex_app_server_protocol::FsWriteFileParams; use codex_app_server_protocol::FsWriteFileResponse; use codex_utils_pty::ExecCommandSession; use codex_utils_pty::TerminalSize; use tokio::sync::Mutex; use tokio::sync::Notify; use tokio::sync::mpsc; use tracing::warn; use crate::protocol::ExecExitedNotification; use crate::protocol::ExecOutputDeltaNotification; use crate::protocol::ExecOutputStream; use crate::protocol::ExecResponse; use crate::protocol::ExecSandboxMode; use crate::protocol::InitializeResponse; use crate::protocol::PROTOCOL_VERSION; use crate::protocol::ProcessOutputChunk; use crate::protocol::ReadResponse; use crate::protocol::TerminateResponse; use crate::protocol::WriteResponse; use crate::server::filesystem::ExecServerFileSystem; use crate::server::internal_error; use crate::server::invalid_params; use crate::server::invalid_request; use crate::server::unauthorized; const RETAINED_OUTPUT_BYTES_PER_PROCESS: usize = 1024 * 1024; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum ExecServerServerNotification { OutputDelta(ExecOutputDeltaNotification), Exited(ExecExitedNotification), } #[derive(Clone)] struct RetainedOutputChunk { seq: u64, stream: ExecOutputStream, chunk: Vec, } struct RunningProcess { session: ExecCommandSession, tty: bool, output: VecDeque, retained_bytes: usize, next_seq: u64, exit_code: Option, output_notify: Arc, } pub(crate) struct ExecServerHandler { notification_tx: mpsc::Sender, file_system: ExecServerFileSystem, required_auth_token: Option, // Keyed by client-chosen logical `processId` scoped to this connection. // This is a protocol handle, not an OS pid. processes: Arc>>, initialize_requested: bool, initialized: bool, } impl ExecServerHandler { pub(crate) fn new( notification_tx: mpsc::Sender, required_auth_token: Option, ) -> Self { Self { notification_tx, file_system: ExecServerFileSystem::default(), required_auth_token, processes: Arc::new(Mutex::new(HashMap::new())), initialize_requested: false, initialized: false, } } pub(crate) async fn shutdown(&self) { let remaining = { let mut processes = self.processes.lock().await; processes .drain() .map(|(_, process)| process) .collect::>() }; for process in remaining { process.session.terminate(); } } pub(crate) fn initialized(&mut self) -> Result<(), String> { if !self.initialize_requested { return Err("received `initialized` notification before `initialize`".into()); } self.initialized = true; Ok(()) } pub(crate) fn initialize( &mut self, params: crate::protocol::InitializeParams, ) -> Result { if self.initialize_requested { return Err(invalid_request( "initialize may only be sent once per connection".to_string(), )); } if let Some(required_auth_token) = &self.required_auth_token && params.auth_token.as_deref() != Some(required_auth_token.as_str()) { return Err(unauthorized("invalid exec-server auth token".to_string())); } self.initialize_requested = true; Ok(InitializeResponse { protocol_version: PROTOCOL_VERSION.to_string(), }) } fn require_initialized(&self) -> Result<(), codex_app_server_protocol::JSONRPCErrorError> { if !self.initialize_requested { return Err(invalid_request( "client must call initialize before using exec methods".to_string(), )); } if !self.initialized { return Err(invalid_request( "client must send initialized before using exec methods".to_string(), )); } Ok(()) } pub(crate) async fn exec( &self, params: crate::protocol::ExecParams, ) -> Result { self.require_initialized()?; let process_id = params.process_id.clone(); // Same-connection requests are serialized by the RPC processor, and the // in-process client holds the handler mutex across this full call. That // makes this pre-spawn duplicate check safe for the current entrypoints. { let process_map = self.processes.lock().await; if process_map.contains_key(&process_id) { return Err(invalid_request(format!( "process {process_id} already exists" ))); } } if matches!( params.sandbox.as_ref().map(|sandbox| sandbox.mode), Some(ExecSandboxMode::HostDefault) ) { return Err(invalid_request( "sandbox mode `hostDefault` is not supported by exec-server yet".to_string(), )); } let (program, args) = params .argv .split_first() .ok_or_else(|| invalid_params("argv must not be empty".to_string()))?; let spawned = if params.tty { codex_utils_pty::spawn_pty_process( program, args, params.cwd.as_path(), ¶ms.env, ¶ms.arg0, TerminalSize::default(), ) .await } else { codex_utils_pty::spawn_pipe_process_no_stdin( program, args, params.cwd.as_path(), ¶ms.env, ¶ms.arg0, ) .await } .map_err(|err| internal_error(err.to_string()))?; let output_notify = Arc::new(Notify::new()); { let mut process_map = self.processes.lock().await; process_map.insert( process_id.clone(), RunningProcess { session: spawned.session, tty: params.tty, output: std::collections::VecDeque::new(), retained_bytes: 0, next_seq: 1, exit_code: None, output_notify: Arc::clone(&output_notify), }, ); } tokio::spawn(stream_output( process_id.clone(), if params.tty { ExecOutputStream::Pty } else { ExecOutputStream::Stdout }, spawned.stdout_rx, self.notification_tx.clone(), Arc::clone(&self.processes), Arc::clone(&output_notify), )); tokio::spawn(stream_output( process_id.clone(), if params.tty { ExecOutputStream::Pty } else { ExecOutputStream::Stderr }, spawned.stderr_rx, self.notification_tx.clone(), Arc::clone(&self.processes), Arc::clone(&output_notify), )); tokio::spawn(watch_exit( process_id.clone(), spawned.exit_rx, self.notification_tx.clone(), Arc::clone(&self.processes), output_notify, )); Ok(ExecResponse { process_id }) } pub(crate) async fn fs_read_file( &self, params: FsReadFileParams, ) -> Result { self.require_initialized()?; self.file_system.read_file(params).await } pub(crate) async fn fs_write_file( &self, params: FsWriteFileParams, ) -> Result { self.require_initialized()?; self.file_system.write_file(params).await } pub(crate) async fn fs_create_directory( &self, params: FsCreateDirectoryParams, ) -> Result { self.require_initialized()?; self.file_system.create_directory(params).await } pub(crate) async fn fs_get_metadata( &self, params: FsGetMetadataParams, ) -> Result { self.require_initialized()?; self.file_system.get_metadata(params).await } pub(crate) async fn fs_read_directory( &self, params: FsReadDirectoryParams, ) -> Result { self.require_initialized()?; self.file_system.read_directory(params).await } pub(crate) async fn fs_remove( &self, params: FsRemoveParams, ) -> Result { self.require_initialized()?; self.file_system.remove(params).await } pub(crate) async fn fs_copy( &self, params: FsCopyParams, ) -> Result { self.require_initialized()?; self.file_system.copy(params).await } pub(crate) async fn read( &self, params: crate::protocol::ReadParams, ) -> Result { self.require_initialized()?; let after_seq = params.after_seq.unwrap_or(0); let max_bytes = params.max_bytes.unwrap_or(usize::MAX); let wait = Duration::from_millis(params.wait_ms.unwrap_or(0)); let deadline = tokio::time::Instant::now() + wait; loop { let (response, output_notify) = { let process_map = self.processes.lock().await; let process = process_map.get(¶ms.process_id).ok_or_else(|| { invalid_request(format!("unknown process id {}", params.process_id)) })?; let mut chunks = Vec::new(); let mut total_bytes = 0; let mut next_seq = process.next_seq; for retained in process.output.iter().filter(|chunk| chunk.seq > after_seq) { let chunk_len = retained.chunk.len(); if !chunks.is_empty() && total_bytes + chunk_len > max_bytes { break; } total_bytes += chunk_len; chunks.push(ProcessOutputChunk { seq: retained.seq, stream: retained.stream, chunk: retained.chunk.clone().into(), }); next_seq = retained.seq + 1; if total_bytes >= max_bytes { break; } } ( ReadResponse { chunks, next_seq, exited: process.exit_code.is_some(), exit_code: process.exit_code, }, Arc::clone(&process.output_notify), ) }; if !response.chunks.is_empty() || response.exited || tokio::time::Instant::now() >= deadline { return Ok(response); } let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); if remaining.is_zero() { return Ok(response); } let _ = tokio::time::timeout(remaining, output_notify.notified()).await; } } pub(crate) async fn write( &self, params: crate::protocol::WriteParams, ) -> Result { self.require_initialized()?; let writer_tx = { let process_map = self.processes.lock().await; let process = process_map.get(¶ms.process_id).ok_or_else(|| { invalid_request(format!("unknown process id {}", params.process_id)) })?; if !process.tty { return Err(invalid_request(format!( "stdin is closed for process {}", params.process_id ))); } process.session.writer_sender() }; writer_tx .send(params.chunk.into_inner()) .await .map_err(|_| internal_error("failed to write to process stdin".to_string()))?; Ok(WriteResponse { accepted: true }) } pub(crate) async fn terminate( &self, params: crate::protocol::TerminateParams, ) -> Result { self.require_initialized()?; let running = { let process_map = self.processes.lock().await; if let Some(process) = process_map.get(¶ms.process_id) { process.session.terminate(); true } else { false } }; Ok(TerminateResponse { running }) } } async fn stream_output( process_id: String, stream: ExecOutputStream, mut receiver: tokio::sync::mpsc::Receiver>, notification_tx: mpsc::Sender, processes: Arc>>, output_notify: Arc, ) { while let Some(chunk) = receiver.recv().await { let notification = { let mut processes = processes.lock().await; let Some(process) = processes.get_mut(&process_id) else { break; }; let seq = process.next_seq; process.next_seq += 1; process.retained_bytes += chunk.len(); process.output.push_back(RetainedOutputChunk { seq, stream, chunk: chunk.clone(), }); while process.retained_bytes > RETAINED_OUTPUT_BYTES_PER_PROCESS { let Some(evicted) = process.output.pop_front() else { break; }; process.retained_bytes = process.retained_bytes.saturating_sub(evicted.chunk.len()); warn!( "retained output cap exceeded for process {process_id}; dropping oldest output" ); } ExecOutputDeltaNotification { process_id: process_id.clone(), stream, chunk: chunk.into(), } }; output_notify.notify_waiters(); if notification_tx .send(ExecServerServerNotification::OutputDelta(notification)) .await .is_err() { break; } } } async fn watch_exit( process_id: String, exit_rx: tokio::sync::oneshot::Receiver, notification_tx: mpsc::Sender, processes: Arc>>, output_notify: Arc, ) { let exit_code = exit_rx.await.unwrap_or(-1); { let mut processes = processes.lock().await; if let Some(process) = processes.get_mut(&process_id) { process.exit_code = Some(exit_code); } } output_notify.notify_waiters(); let _ = notification_tx .send(ExecServerServerNotification::Exited( ExecExitedNotification { process_id, exit_code, }, )) .await; } #[cfg(test)] mod tests;