From 95bdea93d2600aabef1b87ee5fab05a6022a7d45 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Tue, 17 Mar 2026 11:38:44 -0700 Subject: [PATCH 01/13] use framed IPC for elevated command runner (#14846) ## Summary This is PR 2 of the Windows sandbox runner split. PR 1 introduced the framed IPC runner foundation and related Windows sandbox infrastructure without changing the active elevated one-shot execution path. This PR switches that elevated one-shot path over to the new runner IPC transport and removes the old request-file bootstrap that PR 1 intentionally left in place. After this change, ordinary elevated Windows sandbox commands still behave as one-shot executions, but they now run as the simple case of the same helper/IPC transport that later unified_exec work will build on. ## Why this is needed for unified_exec Windows elevated sandboxed execution crosses a user boundary: the CLI launches a helper as the sandbox user and has to manage command execution from outside that security context. For one-shot commands, the old request-file/bootstrap flow was sufficient. For unified_exec, it is not. Unified_exec needs a long-lived bidirectional channel so the parent can: - send a spawn request - receive structured spawn success/failure - stream stdout and stderr incrementally - eventually support stdin writes, termination, and other session lifecycle events This PR does not add long-lived sessions yet. It converts the existing elevated one-shot path to use the same framed IPC transport so that PR 3 can add unified_exec session semantics on top of a transport that is already exercised by normal elevated command execution. ## Scope This PR: - updates `windows-sandbox-rs/src/elevated_impl.rs` to launch the runner with named pipes, send a framed `SpawnRequest`, wait for `SpawnReady`, and collect framed `Output`/`Exit` messages - removes the old `--request-file=...` execution path from `windows-sandbox-rs/src/elevated/command_runner_win.rs` - keeps the public behavior one-shot: no session reuse or interactive unified_exec behavior is introduced here This PR does not: - add Windows unified_exec session support - add background terminal reuse - add PTY session lifecycle management ## Why Windows needs this and Linux/macOS do not On Linux and macOS, the existing sandbox/process model composes much more directly with long-lived process control. The parent can generally spawn and own the child process (or PTY) directly inside the sandbox model we already use. Windows elevated sandboxing is different. The parent is not directly managing the sandboxed process in the same way; it launches across a different user/security context. That means long-lived control requires an explicit helper process plus IPC for spawn, output, exit, and later stdin/session control. So the extra machinery here is not because unified_exec is conceptually different on Windows. It is because the elevated Windows sandbox boundary requires a helper-mediated transport to support it cleanly. ## Validation - `cargo test -p codex-windows-sandbox` --- codex-rs/windows-sandbox-rs/src/conpty/mod.rs | 11 +- .../src/elevated/command_runner_win.rs | 191 +----------- .../src/elevated/ipc_framed.rs | 2 + .../windows-sandbox-rs/src/elevated_impl.rs | 279 ++++++++---------- codex-rs/windows-sandbox-rs/src/process.rs | 14 +- 5 files changed, 145 insertions(+), 352 deletions(-) diff --git a/codex-rs/windows-sandbox-rs/src/conpty/mod.rs b/codex-rs/windows-sandbox-rs/src/conpty/mod.rs index fafa1e4bbb..1f41c2c906 100644 --- a/codex-rs/windows-sandbox-rs/src/conpty/mod.rs +++ b/codex-rs/windows-sandbox-rs/src/conpty/mod.rs @@ -9,6 +9,7 @@ mod proc_thread_attr; use self::proc_thread_attr::ProcThreadAttributeList; +use crate::desktop::LaunchDesktop; use crate::winutil::format_last_error; use crate::winutil::quote_windows_arg; use crate::winutil::to_wide; @@ -36,6 +37,7 @@ pub struct ConptyInstance { pub hpc: HANDLE, pub input_write: HANDLE, pub output_read: HANDLE, + _desktop: LaunchDesktop, } impl Drop for ConptyInstance { @@ -74,6 +76,7 @@ pub fn create_conpty(cols: i16, rows: i16) -> Result { hpc: hpc as HANDLE, input_write: input_write as HANDLE, output_read: output_read as HANDLE, + _desktop: LaunchDesktop::prepare(false, None)?, }) } @@ -86,6 +89,8 @@ pub fn spawn_conpty_process_as_user( argv: &[String], cwd: &Path, env_map: &HashMap, + use_private_desktop: bool, + logs_base_dir: Option<&Path>, ) -> Result<(PROCESS_INFORMATION, ConptyInstance)> { let cmdline_str = argv .iter() @@ -100,8 +105,8 @@ pub fn spawn_conpty_process_as_user( si.StartupInfo.hStdInput = INVALID_HANDLE_VALUE; si.StartupInfo.hStdOutput = INVALID_HANDLE_VALUE; si.StartupInfo.hStdError = INVALID_HANDLE_VALUE; - let desktop = to_wide("Winsta0\\Default"); - si.StartupInfo.lpDesktop = desktop.as_ptr() as *mut u16; + let desktop = LaunchDesktop::prepare(use_private_desktop, logs_base_dir)?; + si.StartupInfo.lpDesktop = desktop.startup_info_desktop(); let conpty = create_conpty(80, 24)?; let mut attrs = ProcThreadAttributeList::new(1)?; @@ -135,5 +140,7 @@ pub fn spawn_conpty_process_as_user( env_block.len() )); } + let mut conpty = conpty; + conpty._desktop = desktop; Ok((pi, conpty)) } diff --git a/codex-rs/windows-sandbox-rs/src/elevated/command_runner_win.rs b/codex-rs/windows-sandbox-rs/src/elevated/command_runner_win.rs index f76e1a54ca..bbc4de6b36 100644 --- a/codex-rs/windows-sandbox-rs/src/elevated/command_runner_win.rs +++ b/codex-rs/windows-sandbox-rs/src/elevated/command_runner_win.rs @@ -13,7 +13,6 @@ use anyhow::Context; use anyhow::Result; use codex_windows_sandbox::allow_null_device; use codex_windows_sandbox::convert_string_sid_to_sid; -use codex_windows_sandbox::create_process_as_user; use codex_windows_sandbox::create_readonly_token_with_caps_from; use codex_windows_sandbox::create_workspace_write_token_with_caps_from; use codex_windows_sandbox::get_current_token_for_restriction; @@ -37,8 +36,6 @@ use codex_windows_sandbox::PipeSpawnHandles; use codex_windows_sandbox::SandboxPolicy; use codex_windows_sandbox::StderrMode; use codex_windows_sandbox::StdinMode; -use serde::Deserialize; -use std::collections::HashMap; use std::ffi::c_void; use std::fs::File; use std::os::windows::io::FromRawHandle; @@ -77,22 +74,6 @@ mod cwd_junction; #[path = "../read_acl_mutex.rs"] mod read_acl_mutex; -#[derive(Debug, Deserialize)] -struct RunnerRequest { - policy_json_or_preset: String, - codex_home: PathBuf, - real_codex_home: PathBuf, - cap_sids: Vec, - command: Vec, - cwd: PathBuf, - env_map: HashMap, - timeout_ms: Option, - use_private_desktop: bool, - stdin_pipe: String, - stdout_pipe: String, - stderr_pipe: String, -} - const WAIT_TIMEOUT: u32 = 0x0000_0102; struct IpcSpawnedProcess { @@ -144,13 +125,6 @@ fn open_pipe(name: &str, access: u32) -> Result { Ok(handle) } -fn read_request_file(req_path: &Path) -> Result { - let content = std::fs::read_to_string(req_path) - .with_context(|| format!("read request file {}", req_path.display())); - let _ = std::fs::remove_file(req_path); - content -} - /// Send an error frame back to the parent process. fn send_error(writer: &Arc>, code: &str, message: String) -> Result<()> { let msg = FramedMessage { @@ -288,6 +262,8 @@ fn spawn_ipc_process( &req.command, &effective_cwd, &req.env, + req.use_private_desktop, + Some(log_dir.as_path()), )?; let (hpc, input_write, output_read) = conpty.into_raw(); hpc_handle = Some(hpc); @@ -318,6 +294,7 @@ fn spawn_ipc_process( &req.env, stdin_mode, StderrMode::Separate, + req.use_private_desktop, )?; ( pipe_handles.process, @@ -435,175 +412,15 @@ fn spawn_input_loop( /// Entry point for the Windows command runner process. pub fn main() -> Result<()> { - let mut request_file = None; let mut pipe_in = None; let mut pipe_out = None; - let mut pipe_single = None; for arg in std::env::args().skip(1) { - if let Some(rest) = arg.strip_prefix("--request-file=") { - request_file = Some(rest.to_string()); - } else if let Some(rest) = arg.strip_prefix("--pipe-in=") { + if let Some(rest) = arg.strip_prefix("--pipe-in=") { pipe_in = Some(rest.to_string()); } else if let Some(rest) = arg.strip_prefix("--pipe-out=") { pipe_out = Some(rest.to_string()); - } else if let Some(rest) = arg.strip_prefix("--pipe=") { - pipe_single = Some(rest.to_string()); } } - if pipe_in.is_none() && pipe_out.is_none() { - if let Some(single) = pipe_single { - pipe_in = Some(single.clone()); - pipe_out = Some(single); - } - } - - if let Some(request_file) = request_file { - let req_path = PathBuf::from(request_file); - let input = read_request_file(&req_path)?; - let req: RunnerRequest = - serde_json::from_str(&input).context("parse runner request json")?; - let log_dir = Some(req.codex_home.as_path()); - hide_current_user_profile_dir(req.codex_home.as_path()); - log_note( - &format!( - "runner start cwd={} cmd={:?} real_codex_home={}", - req.cwd.display(), - req.command, - req.real_codex_home.display() - ), - Some(&req.codex_home), - ); - - let policy = - parse_policy(&req.policy_json_or_preset).context("parse policy_json_or_preset")?; - if !policy.has_full_disk_read_access() { - anyhow::bail!( - "Restricted read-only access is not yet supported by the Windows sandbox backend" - ); - } - let mut cap_psids: Vec<*mut c_void> = Vec::new(); - for sid in &req.cap_sids { - let Some(psid) = (unsafe { convert_string_sid_to_sid(sid) }) else { - anyhow::bail!("ConvertStringSidToSidW failed for capability SID"); - }; - cap_psids.push(psid); - } - if cap_psids.is_empty() { - anyhow::bail!("runner: empty capability SID list"); - } - - let base = unsafe { get_current_token_for_restriction()? }; - let token_res: Result = unsafe { - match &policy { - SandboxPolicy::ReadOnly { .. } => { - create_readonly_token_with_caps_from(base, &cap_psids) - } - SandboxPolicy::WorkspaceWrite { .. } => { - create_workspace_write_token_with_caps_from(base, &cap_psids) - } - SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { - unreachable!() - } - } - }; - let h_token = token_res?; - unsafe { - CloseHandle(base); - for psid in &cap_psids { - allow_null_device(*psid); - } - for psid in cap_psids { - if !psid.is_null() { - LocalFree(psid as HLOCAL); - } - } - } - - let h_stdin = open_pipe(&req.stdin_pipe, FILE_GENERIC_READ)?; - let h_stdout = open_pipe(&req.stdout_pipe, FILE_GENERIC_WRITE)?; - let h_stderr = open_pipe(&req.stderr_pipe, FILE_GENERIC_WRITE)?; - let stdio = Some((h_stdin, h_stdout, h_stderr)); - - let effective_cwd = effective_cwd(&req.cwd, log_dir); - log_note( - &format!( - "runner: effective cwd={} (requested {})", - effective_cwd.display(), - req.cwd.display() - ), - log_dir, - ); - - let spawn_result = unsafe { - create_process_as_user( - h_token, - &req.command, - &effective_cwd, - &req.env_map, - Some(&req.codex_home), - stdio, - req.use_private_desktop, - ) - }; - let created = match spawn_result { - Ok(v) => v, - Err(err) => { - log_note(&format!("runner: spawn failed: {err:?}"), log_dir); - unsafe { - CloseHandle(h_stdin); - CloseHandle(h_stdout); - CloseHandle(h_stderr); - CloseHandle(h_token); - } - return Err(err); - } - }; - let proc_info = created.process_info; - - let h_job = unsafe { create_job_kill_on_close().ok() }; - if let Some(job) = h_job { - unsafe { - let _ = AssignProcessToJobObject(job, proc_info.hProcess); - } - } - - let wait_res = unsafe { - WaitForSingleObject( - proc_info.hProcess, - req.timeout_ms.map(|ms| ms as u32).unwrap_or(INFINITE), - ) - }; - let timed_out = wait_res == WAIT_TIMEOUT; - - let exit_code: i32; - unsafe { - if timed_out { - let _ = TerminateProcess(proc_info.hProcess, 1); - exit_code = 128 + 64; - } else { - let mut raw_exit: u32 = 1; - GetExitCodeProcess(proc_info.hProcess, &mut raw_exit); - exit_code = raw_exit as i32; - } - if proc_info.hThread != 0 { - CloseHandle(proc_info.hThread); - } - if proc_info.hProcess != 0 { - CloseHandle(proc_info.hProcess); - } - CloseHandle(h_stdin); - CloseHandle(h_stdout); - CloseHandle(h_stderr); - CloseHandle(h_token); - if let Some(job) = h_job { - CloseHandle(job); - } - } - if exit_code != 0 { - eprintln!("runner child exited with code {exit_code}"); - } - std::process::exit(exit_code); - } let Some(pipe_in) = pipe_in else { anyhow::bail!("runner: no pipe-in provided"); diff --git a/codex-rs/windows-sandbox-rs/src/elevated/ipc_framed.rs b/codex-rs/windows-sandbox-rs/src/elevated/ipc_framed.rs index d39ed8a51b..37590c34b9 100644 --- a/codex-rs/windows-sandbox-rs/src/elevated/ipc_framed.rs +++ b/codex-rs/windows-sandbox-rs/src/elevated/ipc_framed.rs @@ -62,6 +62,8 @@ pub struct SpawnRequest { pub tty: bool, #[serde(default)] pub stdin_open: bool, + #[serde(default)] + pub use_private_desktop: bool, } /// Ack from runner after it spawns the child process. diff --git a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs index 89cf3ebec2..b925e83743 100644 --- a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs +++ b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs @@ -9,6 +9,13 @@ mod windows_impl { use crate::helper_materialization::resolve_helper_for_launch; use crate::helper_materialization::HelperExecutable; use crate::identity::require_logon_sandbox_creds; + use crate::ipc_framed::decode_bytes; + use crate::ipc_framed::read_frame; + use crate::ipc_framed::write_frame; + use crate::ipc_framed::FramedMessage; + use crate::ipc_framed::Message; + use crate::ipc_framed::OutputStream; + use crate::ipc_framed::SpawnRequest; use crate::logging::log_failure; use crate::logging::log_note; use crate::logging::log_start; @@ -26,8 +33,9 @@ mod windows_impl { use rand::SeedableRng; use std::collections::HashMap; use std::ffi::c_void; - use std::fs; + use std::fs::File; use std::io; + use std::os::windows::io::FromRawHandle; use std::path::Path; use std::path::PathBuf; use std::ptr; @@ -40,15 +48,12 @@ mod windows_impl { use windows_sys::Win32::System::Diagnostics::Debug::SetErrorMode; use windows_sys::Win32::System::Pipes::ConnectNamedPipe; use windows_sys::Win32::System::Pipes::CreateNamedPipeW; - // PIPE_ACCESS_DUPLEX is 0x00000003; not exposed in windows-sys 0.52, so use the value directly. - const PIPE_ACCESS_DUPLEX: u32 = 0x0000_0003; + const PIPE_ACCESS_INBOUND: u32 = 0x0000_0001; + const PIPE_ACCESS_OUTBOUND: u32 = 0x0000_0002; use windows_sys::Win32::System::Pipes::PIPE_READMODE_BYTE; use windows_sys::Win32::System::Pipes::PIPE_TYPE_BYTE; use windows_sys::Win32::System::Pipes::PIPE_WAIT; use windows_sys::Win32::System::Threading::CreateProcessWithLogonW; - use windows_sys::Win32::System::Threading::GetExitCodeProcess; - use windows_sys::Win32::System::Threading::WaitForSingleObject; - use windows_sys::Win32::System::Threading::INFINITE; use windows_sys::Win32::System::Threading::LOGON_WITH_PROFILE; use windows_sys::Win32::System::Threading::PROCESS_INFORMATION; use windows_sys::Win32::System::Threading::STARTUPINFOW; @@ -183,24 +188,16 @@ mod windows_impl { pub use crate::windows_impl::CaptureResult; - #[derive(serde::Serialize)] - struct RunnerPayload { - policy_json_or_preset: String, - sandbox_policy_cwd: PathBuf, - // Writable log dir for sandbox user (.codex in sandbox profile). - codex_home: PathBuf, - // Real user's CODEX_HOME for shared data (caps, config). - real_codex_home: PathBuf, - cap_sids: Vec, - request_file: Option, - command: Vec, - cwd: PathBuf, - env_map: HashMap, - timeout_ms: Option, - use_private_desktop: bool, - stdin_pipe: String, - stdout_pipe: String, - stderr_pipe: String, + fn read_spawn_ready(pipe_read: &mut File) -> Result<()> { + let msg = read_frame(pipe_read)? + .ok_or_else(|| anyhow::anyhow!("runner pipe closed before spawn_ready"))?; + match msg.message { + Message::SpawnReady { .. } => Ok(()), + Message::Error { payload } => Err(anyhow::anyhow!("runner error: {}", payload.message)), + other => Err(anyhow::anyhow!( + "expected spawn_ready from runner, got {other:?}" + )), + } } /// Launches the command runner under the sandbox user and captures its output. @@ -271,25 +268,10 @@ mod windows_impl { allow_null_device(psid_to_use); } - // Prepare named pipes for runner. - let stdin_name = pipe_name("stdin"); - let stdout_name = pipe_name("stdout"); - let stderr_name = pipe_name("stderr"); - let h_stdin_pipe = create_named_pipe( - &stdin_name, - PIPE_ACCESS_DUPLEX | PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT, - &sandbox_sid, - )?; - let h_stdout_pipe = create_named_pipe( - &stdout_name, - PIPE_ACCESS_DUPLEX | PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT, - &sandbox_sid, - )?; - let h_stderr_pipe = create_named_pipe( - &stderr_name, - PIPE_ACCESS_DUPLEX | PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT, - &sandbox_sid, - )?; + let pipe_in_name = pipe_name("in"); + let pipe_out_name = pipe_name("out"); + let h_pipe_in = create_named_pipe(&pipe_in_name, PIPE_ACCESS_OUTBOUND, &sandbox_sid)?; + let h_pipe_out = create_named_pipe(&pipe_out_name, PIPE_ACCESS_INBOUND, &sandbox_sid)?; // Launch runner as sandbox user via CreateProcessWithLogonW. let runner_exe = find_runner_exe(codex_home, logs_base_dir); @@ -297,40 +279,11 @@ mod windows_impl { .to_str() .map(|s| s.to_string()) .unwrap_or_else(|| "codex-command-runner.exe".to_string()); - // Write request to a file under the sandbox base dir for the runner to read. - // TODO(iceweasel) - use a different mechanism for invoking the runner. - let base_tmp = sandbox_base.join("requests"); - std::fs::create_dir_all(&base_tmp)?; - let mut rng = SmallRng::from_entropy(); - let req_file = base_tmp.join(format!("request-{:x}.json", rng.gen::())); - let payload = RunnerPayload { - policy_json_or_preset: policy_json_or_preset.to_string(), - sandbox_policy_cwd: sandbox_policy_cwd.to_path_buf(), - codex_home: sandbox_base.clone(), - real_codex_home: codex_home.to_path_buf(), - cap_sids: cap_sids.clone(), - request_file: Some(req_file.clone()), - command: command.clone(), - cwd: cwd.to_path_buf(), - env_map: env_map.clone(), - timeout_ms, - use_private_desktop, - stdin_pipe: stdin_name.clone(), - stdout_pipe: stdout_name.clone(), - stderr_pipe: stderr_name.clone(), - }; - let payload_json = serde_json::to_string(&payload)?; - if let Err(e) = fs::write(&req_file, &payload_json) { - log_note( - &format!("error writing request file {}: {}", req_file.display(), e), - logs_base_dir, - ); - return Err(e.into()); - } let runner_full_cmd = format!( - "{} {}", + "{} {} {}", quote_windows_arg(&runner_cmdline), - quote_windows_arg(&format!("--request-file={}", req_file.display())) + quote_windows_arg(&format!("--pipe-in={pipe_in_name}")), + quote_windows_arg(&format!("--pipe-out={pipe_out_name}")) ); let mut cmdline_vec: Vec = to_wide(&runner_full_cmd); let exe_w: Vec = to_wide(&runner_cmdline); @@ -390,73 +343,99 @@ mod windows_impl { return Err(anyhow::anyhow!("CreateProcessWithLogonW failed: {}", err)); } - // Pipes are no longer passed as std handles; no stdin payload is sent. - connect_pipe(h_stdin_pipe)?; - connect_pipe(h_stdout_pipe)?; - connect_pipe(h_stderr_pipe)?; - unsafe { - CloseHandle(h_stdin_pipe); + if let Err(err) = connect_pipe(h_pipe_in) { + unsafe { + CloseHandle(h_pipe_in); + CloseHandle(h_pipe_out); + if pi.hThread != 0 { + CloseHandle(pi.hThread); + } + if pi.hProcess != 0 { + CloseHandle(pi.hProcess); + } + } + return Err(err.into()); + } + if let Err(err) = connect_pipe(h_pipe_out) { + unsafe { + CloseHandle(h_pipe_in); + CloseHandle(h_pipe_out); + if pi.hThread != 0 { + CloseHandle(pi.hThread); + } + if pi.hProcess != 0 { + CloseHandle(pi.hProcess); + } + } + return Err(err.into()); } - // Read stdout/stderr. - let (tx_out, rx_out) = std::sync::mpsc::channel::>(); - let (tx_err, rx_err) = std::sync::mpsc::channel::>(); - let t_out = std::thread::spawn(move || { - let mut buf = Vec::new(); - let mut tmp = [0u8; 8192]; - loop { - let mut read_bytes: u32 = 0; - let ok = unsafe { - windows_sys::Win32::Storage::FileSystem::ReadFile( - h_stdout_pipe, - tmp.as_mut_ptr(), - tmp.len() as u32, - &mut read_bytes, - std::ptr::null_mut(), - ) - }; - if ok == 0 || read_bytes == 0 { - break; - } - buf.extend_from_slice(&tmp[..read_bytes as usize]); - } - let _ = tx_out.send(buf); - }); - let t_err = std::thread::spawn(move || { - let mut buf = Vec::new(); - let mut tmp = [0u8; 8192]; - loop { - let mut read_bytes: u32 = 0; - let ok = unsafe { - windows_sys::Win32::Storage::FileSystem::ReadFile( - h_stderr_pipe, - tmp.as_mut_ptr(), - tmp.len() as u32, - &mut read_bytes, - std::ptr::null_mut(), - ) - }; - if ok == 0 || read_bytes == 0 { - break; - } - buf.extend_from_slice(&tmp[..read_bytes as usize]); - } - let _ = tx_err.send(buf); - }); + let result = (|| -> Result { + let mut pipe_write = unsafe { File::from_raw_handle(h_pipe_in as _) }; + let mut pipe_read = unsafe { File::from_raw_handle(h_pipe_out as _) }; - let timeout = timeout_ms.map(|ms| ms as u32).unwrap_or(INFINITE); - let res = unsafe { WaitForSingleObject(pi.hProcess, timeout) }; - let timed_out = res == 0x0000_0102; - let mut exit_code_u32: u32 = 1; - if !timed_out { - unsafe { - GetExitCodeProcess(pi.hProcess, &mut exit_code_u32); + let spawn_request = FramedMessage { + version: 1, + message: Message::SpawnRequest { + payload: Box::new(SpawnRequest { + command: command.clone(), + cwd: cwd.to_path_buf(), + env: env_map.clone(), + policy_json_or_preset: policy_json_or_preset.to_string(), + sandbox_policy_cwd: sandbox_policy_cwd.to_path_buf(), + codex_home: sandbox_base.clone(), + real_codex_home: codex_home.to_path_buf(), + cap_sids, + timeout_ms, + tty: false, + stdin_open: false, + use_private_desktop, + }), + }, + }; + write_frame(&mut pipe_write, &spawn_request)?; + read_spawn_ready(&mut pipe_read)?; + drop(pipe_write); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let (exit_code, timed_out) = loop { + let msg = read_frame(&mut pipe_read)? + .ok_or_else(|| anyhow::anyhow!("runner pipe closed before exit"))?; + match msg.message { + Message::SpawnReady { .. } => {} + Message::Output { payload } => { + let bytes = decode_bytes(&payload.data_b64)?; + match payload.stream { + OutputStream::Stdout => stdout.extend_from_slice(&bytes), + OutputStream::Stderr => stderr.extend_from_slice(&bytes), + } + } + Message::Exit { payload } => break (payload.exit_code, payload.timed_out), + Message::Error { payload } => { + return Err(anyhow::anyhow!("runner error: {}", payload.message)); + } + other => { + return Err(anyhow::anyhow!( + "unexpected runner message during capture: {other:?}" + )); + } + } + }; + + if exit_code == 0 { + log_success(&command, logs_base_dir); + } else { + log_failure(&command, &format!("exit code {}", exit_code), logs_base_dir); } - } else { - unsafe { - windows_sys::Win32::System::Threading::TerminateProcess(pi.hProcess, 1); - } - } + + Ok(CaptureResult { + exit_code, + stdout, + stderr, + timed_out, + }) + })(); unsafe { if pi.hThread != 0 { @@ -465,31 +444,9 @@ mod windows_impl { if pi.hProcess != 0 { CloseHandle(pi.hProcess); } - CloseHandle(h_stdout_pipe); - CloseHandle(h_stderr_pipe); - } - let _ = t_out.join(); - let _ = t_err.join(); - let stdout = rx_out.recv().unwrap_or_default(); - let stderr = rx_err.recv().unwrap_or_default(); - let exit_code = if timed_out { - 128 + 64 - } else { - exit_code_u32 as i32 - }; - - if exit_code == 0 { - log_success(&command, logs_base_dir); - } else { - log_failure(&command, &format!("exit code {}", exit_code), logs_base_dir); } - Ok(CaptureResult { - exit_code, - stdout, - stderr, - timed_out, - }) + result } #[cfg(test)] diff --git a/codex-rs/windows-sandbox-rs/src/process.rs b/codex-rs/windows-sandbox-rs/src/process.rs index 74607ff4d5..356bdd6ebb 100644 --- a/codex-rs/windows-sandbox-rs/src/process.rs +++ b/codex-rs/windows-sandbox-rs/src/process.rs @@ -189,6 +189,7 @@ pub fn spawn_process_with_pipes( env_map: &HashMap, stdin_mode: StdinMode, stderr_mode: StderrMode, + use_private_desktop: bool, ) -> Result { let mut in_r: HANDLE = 0; let mut in_w: HANDLE = 0; @@ -222,8 +223,17 @@ pub fn spawn_process_with_pipes( }; let stdio = Some((in_r, out_w, stderr_handle)); - let spawn_result = - unsafe { create_process_as_user(h_token, argv, cwd, env_map, None, stdio, false) }; + let spawn_result = unsafe { + create_process_as_user( + h_token, + argv, + cwd, + env_map, + None, + stdio, + use_private_desktop, + ) + }; let created = match spawn_result { Ok(v) => v, Err(err) => { From 49e7dda2dfd6e67dd5f9dd8bfa22b7c2b1df17ef Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 17 Mar 2026 14:12:12 -0600 Subject: [PATCH 02/13] Add device-code onboarding and ChatGPT token refresh to app-server TUI (#14952) ## Summary - add device-code ChatGPT sign-in to `tui_app_server` onboarding and reuse the existing `chatgptAuthTokens` login path - fall back to browser login when device-code auth is unavailable on the server - treat `ChatgptAuthTokens` as an existing signed-in ChatGPT state during onboarding - add a local ChatGPT auth loader for handing local tokens to the app server and serving refresh requests - handle `account/chatgptAuthTokens/refresh` instead of marking it unsupported, including workspace/account mismatch checks - add focused coverage for onboarding success, existing auth handling, local auth loading, and refresh request behavior ## Testing - `cargo test -p codex-tui-app-server` - `just fix -p codex-tui-app-server` --- .../src/app/app_server_adapter.rs | 206 +++++++++++++ .../src/app/app_server_requests.rs | 24 +- codex-rs/tui_app_server/src/lib.rs | 1 + .../tui_app_server/src/local_chatgpt_auth.rs | 195 ++++++++++++ .../tui_app_server/src/onboarding/auth.rs | 22 +- .../onboarding/auth/headless_chatgpt_login.rs | 280 +++++++++++++++++- 6 files changed, 713 insertions(+), 15 deletions(-) create mode 100644 codex-rs/tui_app_server/src/local_chatgpt_auth.rs diff --git a/codex-rs/tui_app_server/src/app/app_server_adapter.rs b/codex-rs/tui_app_server/src/app/app_server_adapter.rs index 0fff49fd20..6c54bf3ce8 100644 --- a/codex-rs/tui_app_server/src/app/app_server_adapter.rs +++ b/codex-rs/tui_app_server/src/app/app_server_adapter.rs @@ -16,9 +16,12 @@ use crate::app_event::AppEvent; use crate::app_server_session::AppServerSession; use crate::app_server_session::app_server_rate_limit_snapshot_to_core; use crate::app_server_session::status_account_display_from_auth_mode; +use crate::local_chatgpt_auth::load_local_chatgpt_auth; use codex_app_server_client::AppServerEvent; +use codex_app_server_protocol::ChatgptAuthTokensRefreshParams; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::Turn; @@ -90,6 +93,7 @@ impl App { matches!( notification.auth_mode, Some(codex_app_server_protocol::AuthMode::Chatgpt) + | Some(codex_app_server_protocol::AuthMode::ChatgptAuthTokens) ), ); } @@ -150,6 +154,15 @@ impl App { } } AppServerEvent::ServerRequest(request) => { + if let ServerRequest::ChatgptAuthTokensRefresh { request_id, params } = request { + self.handle_chatgpt_auth_tokens_refresh_request( + app_server_client, + request_id, + params, + ) + .await; + return; + } if let Some(unsupported) = self .pending_app_server_requests .note_server_request(&request) @@ -181,6 +194,70 @@ impl App { } } + async fn handle_chatgpt_auth_tokens_refresh_request( + &mut self, + app_server_client: &AppServerSession, + request_id: codex_app_server_protocol::RequestId, + params: ChatgptAuthTokensRefreshParams, + ) { + let config = self.config.clone(); + let result = tokio::task::spawn_blocking(move || { + resolve_chatgpt_auth_tokens_refresh_response( + &config.codex_home, + config.cli_auth_credentials_store_mode, + config.forced_chatgpt_workspace_id.as_deref(), + ¶ms, + ) + }) + .await; + + match result { + Ok(Ok(response)) => { + let response = serde_json::to_value(response).map_err(|err| { + format!("failed to serialize chatgpt auth refresh response: {err}") + }); + match response { + Ok(response) => { + if let Err(err) = app_server_client + .resolve_server_request(request_id, response) + .await + { + tracing::warn!("failed to resolve chatgpt auth refresh request: {err}"); + } + } + Err(err) => { + self.chat_widget.add_error_message(err.clone()); + if let Err(reject_err) = self + .reject_app_server_request(app_server_client, request_id, err) + .await + { + tracing::warn!("{reject_err}"); + } + } + } + } + Ok(Err(err)) => { + self.chat_widget.add_error_message(err.clone()); + if let Err(reject_err) = self + .reject_app_server_request(app_server_client, request_id, err) + .await + { + tracing::warn!("{reject_err}"); + } + } + Err(err) => { + let message = format!("chatgpt auth refresh task failed: {err}"); + self.chat_widget.add_error_message(message.clone()); + if let Err(reject_err) = self + .reject_app_server_request(app_server_client, request_id, message) + .await + { + tracing::warn!("{reject_err}"); + } + } + } + } + async fn reject_app_server_request( &self, app_server_client: &AppServerSession, @@ -201,6 +278,28 @@ impl App { } } +fn resolve_chatgpt_auth_tokens_refresh_response( + codex_home: &std::path::Path, + auth_credentials_store_mode: codex_core::auth::AuthCredentialsStoreMode, + forced_chatgpt_workspace_id: Option<&str>, + params: &ChatgptAuthTokensRefreshParams, +) -> Result { + let auth = load_local_chatgpt_auth( + codex_home, + auth_credentials_store_mode, + forced_chatgpt_workspace_id, + )?; + if let Some(previous_account_id) = params.previous_account_id.as_deref() + && previous_account_id != auth.chatgpt_account_id + { + return Err(format!( + "local ChatGPT auth refresh account mismatch: expected `{previous_account_id}`, got `{}`", + auth.chatgpt_account_id + )); + } + Ok(auth.to_refresh_response()) +} + /// Convert a `Thread` snapshot into a flat sequence of protocol `Event`s /// suitable for replaying into the TUI event store. /// @@ -624,6 +723,113 @@ fn thread_item_to_core(item: &ThreadItem) -> Option { } } +#[cfg(test)] +mod refresh_tests { + use super::*; + + use base64::Engine; + use chrono::Utc; + use codex_app_server_protocol::AuthMode; + use codex_core::auth::AuthCredentialsStoreMode; + use codex_core::auth::AuthDotJson; + use codex_core::auth::save_auth; + use codex_core::token_data::TokenData; + use pretty_assertions::assert_eq; + use serde::Serialize; + use serde_json::json; + use tempfile::TempDir; + + fn fake_jwt(account_id: &str, plan_type: &str) -> String { + #[derive(Serialize)] + struct Header { + alg: &'static str, + typ: &'static str, + } + + let header = Header { + alg: "none", + typ: "JWT", + }; + let payload = json!({ + "email": "user@example.com", + "https://api.openai.com/auth": { + "chatgpt_account_id": account_id, + "chatgpt_plan_type": plan_type, + }, + }); + let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); + let header_b64 = encode(&serde_json::to_vec(&header).expect("serialize header")); + let payload_b64 = encode(&serde_json::to_vec(&payload).expect("serialize payload")); + let signature_b64 = encode(b"sig"); + format!("{header_b64}.{payload_b64}.{signature_b64}") + } + + fn write_chatgpt_auth(codex_home: &std::path::Path) { + let id_token = fake_jwt("workspace-1", "business"); + let access_token = fake_jwt("workspace-1", "business"); + save_auth( + codex_home, + &AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(TokenData { + id_token: codex_core::token_data::parse_chatgpt_jwt_claims(&id_token) + .expect("id token should parse"), + access_token, + refresh_token: "refresh-token".to_string(), + account_id: Some("workspace-1".to_string()), + }), + last_refresh: Some(Utc::now()), + }, + AuthCredentialsStoreMode::File, + ) + .expect("chatgpt auth should save"); + } + + #[test] + fn refresh_request_uses_local_chatgpt_auth() { + let codex_home = TempDir::new().expect("tempdir"); + write_chatgpt_auth(codex_home.path()); + + let response = resolve_chatgpt_auth_tokens_refresh_response( + codex_home.path(), + AuthCredentialsStoreMode::File, + Some("workspace-1"), + &ChatgptAuthTokensRefreshParams { + reason: codex_app_server_protocol::ChatgptAuthTokensRefreshReason::Unauthorized, + previous_account_id: Some("workspace-1".to_string()), + }, + ) + .expect("refresh response should resolve"); + + assert_eq!(response.chatgpt_account_id, "workspace-1"); + assert_eq!(response.chatgpt_plan_type.as_deref(), Some("business")); + assert!(!response.access_token.is_empty()); + } + + #[test] + fn refresh_request_rejects_account_mismatch() { + let codex_home = TempDir::new().expect("tempdir"); + write_chatgpt_auth(codex_home.path()); + + let err = resolve_chatgpt_auth_tokens_refresh_response( + codex_home.path(), + AuthCredentialsStoreMode::File, + Some("workspace-1"), + &ChatgptAuthTokensRefreshParams { + reason: codex_app_server_protocol::ChatgptAuthTokensRefreshReason::Unauthorized, + previous_account_id: Some("workspace-2".to_string()), + }, + ) + .expect_err("mismatched account should fail"); + + assert_eq!( + err, + "local ChatGPT auth refresh account mismatch: expected `workspace-2`, got `workspace-1`" + ); + } +} + fn app_server_web_search_action_to_core( action: codex_app_server_protocol::WebSearchAction, ) -> Option { diff --git a/codex-rs/tui_app_server/src/app/app_server_requests.rs b/codex-rs/tui_app_server/src/app/app_server_requests.rs index 1975f36063..3e65f8dd62 100644 --- a/codex-rs/tui_app_server/src/app/app_server_requests.rs +++ b/codex-rs/tui_app_server/src/app/app_server_requests.rs @@ -99,13 +99,7 @@ impl PendingAppServerRequests { .to_string(), }) } - ServerRequest::ChatgptAuthTokensRefresh { request_id, .. } => { - Some(UnsupportedAppServerRequest { - request_id: request_id.clone(), - message: "ChatGPT auth token refresh is not available in app-server TUI yet." - .to_string(), - }) - } + ServerRequest::ChatgptAuthTokensRefresh { .. } => None, ServerRequest::ApplyPatchApproval { request_id, .. } => { Some(UnsupportedAppServerRequest { request_id: request_id.clone(), @@ -608,6 +602,22 @@ mod tests { ); } + #[test] + fn does_not_mark_chatgpt_auth_refresh_as_unsupported() { + let mut pending = PendingAppServerRequests::default(); + + assert_eq!( + pending.note_server_request(&ServerRequest::ChatgptAuthTokensRefresh { + request_id: AppServerRequestId::Integer(100), + params: codex_app_server_protocol::ChatgptAuthTokensRefreshParams { + reason: codex_app_server_protocol::ChatgptAuthTokensRefreshReason::Unauthorized, + previous_account_id: Some("workspace-1".to_string()), + }, + }), + None + ); + } + #[test] fn rejects_invalid_patch_decisions_for_file_change_requests() { let mut pending = PendingAppServerRequests::default(); diff --git a/codex-rs/tui_app_server/src/lib.rs b/codex-rs/tui_app_server/src/lib.rs index 0546d88487..62a428918d 100644 --- a/codex-rs/tui_app_server/src/lib.rs +++ b/codex-rs/tui_app_server/src/lib.rs @@ -100,6 +100,7 @@ pub mod insert_history; mod key_hint; mod line_truncation; pub mod live_wrap; +mod local_chatgpt_auth; mod markdown; mod markdown_render; mod markdown_stream; diff --git a/codex-rs/tui_app_server/src/local_chatgpt_auth.rs b/codex-rs/tui_app_server/src/local_chatgpt_auth.rs new file mode 100644 index 0000000000..89c7769f0f --- /dev/null +++ b/codex-rs/tui_app_server/src/local_chatgpt_auth.rs @@ -0,0 +1,195 @@ +use std::path::Path; + +use codex_app_server_protocol::AuthMode; +use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse; +use codex_core::auth::AuthCredentialsStoreMode; +use codex_core::auth::load_auth_dot_json; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LocalChatgptAuth { + pub(crate) access_token: String, + pub(crate) chatgpt_account_id: String, + pub(crate) chatgpt_plan_type: Option, +} + +impl LocalChatgptAuth { + pub(crate) fn to_refresh_response(&self) -> ChatgptAuthTokensRefreshResponse { + ChatgptAuthTokensRefreshResponse { + access_token: self.access_token.clone(), + chatgpt_account_id: self.chatgpt_account_id.clone(), + chatgpt_plan_type: self.chatgpt_plan_type.clone(), + } + } +} + +pub(crate) fn load_local_chatgpt_auth( + codex_home: &Path, + auth_credentials_store_mode: AuthCredentialsStoreMode, + forced_chatgpt_workspace_id: Option<&str>, +) -> Result { + let auth = load_auth_dot_json(codex_home, auth_credentials_store_mode) + .map_err(|err| format!("failed to load local auth: {err}"))? + .ok_or_else(|| "no local auth available".to_string())?; + if matches!(auth.auth_mode, Some(AuthMode::ApiKey)) || auth.openai_api_key.is_some() { + return Err("local auth is not a ChatGPT login".to_string()); + } + + let tokens = auth + .tokens + .ok_or_else(|| "local ChatGPT auth is missing token data".to_string())?; + let access_token = tokens.access_token; + let chatgpt_account_id = tokens + .account_id + .or(tokens.id_token.chatgpt_account_id.clone()) + .ok_or_else(|| "local ChatGPT auth is missing chatgpt account id".to_string())?; + if let Some(expected_workspace) = forced_chatgpt_workspace_id + && chatgpt_account_id != expected_workspace + { + return Err(format!( + "local ChatGPT auth must use workspace {expected_workspace}, but found {chatgpt_account_id:?}" + )); + } + + let chatgpt_plan_type = tokens + .id_token + .get_chatgpt_plan_type() + .map(|plan_type| plan_type.to_ascii_lowercase()); + + Ok(LocalChatgptAuth { + access_token, + chatgpt_account_id, + chatgpt_plan_type, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + use base64::Engine; + use chrono::Utc; + use codex_app_server_protocol::AuthMode; + use codex_core::auth::AuthDotJson; + use codex_core::auth::login_with_chatgpt_auth_tokens; + use codex_core::auth::save_auth; + use codex_core::token_data::TokenData; + use pretty_assertions::assert_eq; + use serde::Serialize; + use serde_json::json; + use tempfile::TempDir; + + fn fake_jwt(email: &str, account_id: &str, plan_type: &str) -> String { + #[derive(Serialize)] + struct Header { + alg: &'static str, + typ: &'static str, + } + + let header = Header { + alg: "none", + typ: "JWT", + }; + let payload = json!({ + "email": email, + "https://api.openai.com/auth": { + "chatgpt_account_id": account_id, + "chatgpt_plan_type": plan_type, + }, + }); + let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); + let header_b64 = encode(&serde_json::to_vec(&header).expect("serialize header")); + let payload_b64 = encode(&serde_json::to_vec(&payload).expect("serialize payload")); + let signature_b64 = encode(b"sig"); + format!("{header_b64}.{payload_b64}.{signature_b64}") + } + + fn write_chatgpt_auth(codex_home: &Path) { + let id_token = fake_jwt("user@example.com", "workspace-1", "business"); + let access_token = fake_jwt("user@example.com", "workspace-1", "business"); + let auth = AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(TokenData { + id_token: codex_core::token_data::parse_chatgpt_jwt_claims(&id_token) + .expect("id token should parse"), + access_token, + refresh_token: "refresh-token".to_string(), + account_id: Some("workspace-1".to_string()), + }), + last_refresh: Some(Utc::now()), + }; + save_auth(codex_home, &auth, AuthCredentialsStoreMode::File) + .expect("chatgpt auth should save"); + } + + #[test] + fn loads_local_chatgpt_auth_from_managed_auth() { + let codex_home = TempDir::new().expect("tempdir"); + write_chatgpt_auth(codex_home.path()); + + let auth = load_local_chatgpt_auth( + codex_home.path(), + AuthCredentialsStoreMode::File, + Some("workspace-1"), + ) + .expect("chatgpt auth should load"); + + assert_eq!(auth.chatgpt_account_id, "workspace-1"); + assert_eq!(auth.chatgpt_plan_type.as_deref(), Some("business")); + assert!(!auth.access_token.is_empty()); + } + + #[test] + fn rejects_missing_local_auth() { + let codex_home = TempDir::new().expect("tempdir"); + + let err = load_local_chatgpt_auth(codex_home.path(), AuthCredentialsStoreMode::File, None) + .expect_err("missing auth should fail"); + + assert_eq!(err, "no local auth available"); + } + + #[test] + fn rejects_api_key_auth() { + let codex_home = TempDir::new().expect("tempdir"); + save_auth( + codex_home.path(), + &AuthDotJson { + auth_mode: Some(AuthMode::ApiKey), + openai_api_key: Some("sk-test".to_string()), + tokens: None, + last_refresh: None, + }, + AuthCredentialsStoreMode::File, + ) + .expect("api key auth should save"); + + let err = load_local_chatgpt_auth(codex_home.path(), AuthCredentialsStoreMode::File, None) + .expect_err("api key auth should fail"); + + assert_eq!(err, "local auth is not a ChatGPT login"); + } + + #[test] + fn prefers_managed_auth_over_external_ephemeral_tokens() { + let codex_home = TempDir::new().expect("tempdir"); + write_chatgpt_auth(codex_home.path()); + login_with_chatgpt_auth_tokens( + codex_home.path(), + &fake_jwt("user@example.com", "workspace-2", "enterprise"), + "workspace-2", + Some("enterprise"), + ) + .expect("external auth should save"); + + let auth = load_local_chatgpt_auth( + codex_home.path(), + AuthCredentialsStoreMode::File, + Some("workspace-1"), + ) + .expect("managed auth should win"); + + assert_eq!(auth.chatgpt_account_id, "workspace-1"); + assert_eq!(auth.chatgpt_plan_type.as_deref(), Some("business")); + } +} diff --git a/codex-rs/tui_app_server/src/onboarding/auth.rs b/codex-rs/tui_app_server/src/onboarding/auth.rs index d089f741a7..612fdbe2ac 100644 --- a/codex-rs/tui_app_server/src/onboarding/auth.rs +++ b/codex-rs/tui_app_server/src/onboarding/auth.rs @@ -104,8 +104,6 @@ pub(crate) enum SignInOption { } const API_KEY_DISABLED_MESSAGE: &str = "API key login is disabled."; -const APP_SERVER_TUI_UNSUPPORTED_MESSAGE: &str = "Not available in app-server TUI yet."; - fn onboarding_request_id() -> codex_app_server_protocol::RequestId { codex_app_server_protocol::RequestId::String(Uuid::new_v4().to_string()) } @@ -741,6 +739,7 @@ impl AuthModeWidget { if matches!( self.login_status, LoginStatus::AuthMode(AppServerAuthMode::Chatgpt) + | LoginStatus::AuthMode(AppServerAuthMode::ChatgptAuthTokens) ) { *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; self.request_frame.schedule_frame(); @@ -799,9 +798,8 @@ impl AuthModeWidget { return; } - self.set_error(Some(APP_SERVER_TUI_UNSUPPORTED_MESSAGE.to_string())); - *self.sign_in_state.write().unwrap() = SignInState::PickMode; - self.request_frame.schedule_frame(); + self.set_error(/*message*/ None); + headless_chatgpt_login::start_headless_chatgpt_login(self); } pub(crate) fn on_account_login_completed( @@ -978,6 +976,20 @@ mod tests { assert_eq!(widget.login_status, LoginStatus::NotAuthenticated); } + #[tokio::test] + async fn existing_chatgpt_auth_tokens_login_counts_as_signed_in() { + let (mut widget, _tmp) = widget_forced_chatgpt().await; + widget.login_status = LoginStatus::AuthMode(AppServerAuthMode::ChatgptAuthTokens); + + let handled = widget.handle_existing_chatgpt_login(); + + assert_eq!(handled, true); + assert!(matches!( + &*widget.sign_in_state.read().unwrap(), + SignInState::ChatGptSuccess + )); + } + /// Collects all buffer cell symbols that contain the OSC 8 open sequence /// for the given URL. Returns the concatenated "inner" characters. fn collect_osc8_chars(buf: &Buffer, area: Rect, url: &str) -> String { diff --git a/codex-rs/tui_app_server/src/onboarding/auth/headless_chatgpt_login.rs b/codex-rs/tui_app_server/src/onboarding/auth/headless_chatgpt_login.rs index c967f76213..33afe740b8 100644 --- a/codex-rs/tui_app_server/src/onboarding/auth/headless_chatgpt_login.rs +++ b/codex-rs/tui_app_server/src/onboarding/auth/headless_chatgpt_login.rs @@ -1,6 +1,12 @@ #![allow(dead_code)] +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::LoginAccountParams; +use codex_app_server_protocol::LoginAccountResponse; +use codex_core::auth::CLIENT_ID; use codex_login::ServerOptions; +use codex_login::complete_device_code_login; +use codex_login::request_device_code; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::prelude::Widget; @@ -13,17 +19,106 @@ use std::sync::Arc; use std::sync::RwLock; use tokio::sync::Notify; +use crate::local_chatgpt_auth::LocalChatgptAuth; +use crate::local_chatgpt_auth::load_local_chatgpt_auth; use crate::shimmer::shimmer_spans; use crate::tui::FrameRequester; use super::AuthModeWidget; +use super::ContinueInBrowserState; use super::ContinueWithDeviceCodeState; use super::SignInState; use super::mark_url_hyperlink; +use super::onboarding_request_id; -pub(super) fn start_headless_chatgpt_login(widget: &mut AuthModeWidget, opts: ServerOptions) { - let _ = opts; - let _ = widget; +pub(super) fn start_headless_chatgpt_login(widget: &mut AuthModeWidget) { + let mut opts = ServerOptions::new( + widget.codex_home.clone(), + CLIENT_ID.to_string(), + widget.forced_chatgpt_workspace_id.clone(), + widget.cli_auth_credentials_store_mode, + ); + opts.open_browser = false; + + let sign_in_state = widget.sign_in_state.clone(); + let request_frame = widget.request_frame.clone(); + let error = widget.error.clone(); + let request_handle = widget.app_server_request_handle.clone(); + let codex_home = widget.codex_home.clone(); + let cli_auth_credentials_store_mode = widget.cli_auth_credentials_store_mode; + let forced_chatgpt_workspace_id = widget.forced_chatgpt_workspace_id.clone(); + let cancel = begin_device_code_attempt(&sign_in_state, &request_frame); + + tokio::spawn(async move { + let device_code = match request_device_code(&opts).await { + Ok(device_code) => device_code, + Err(err) => { + if err.kind() == std::io::ErrorKind::NotFound { + fallback_to_browser_login( + request_handle, + sign_in_state, + request_frame, + error, + cancel, + ) + .await; + } else { + set_device_code_error_for_active_attempt( + &sign_in_state, + &request_frame, + &error, + &cancel, + err.to_string(), + ); + } + return; + } + }; + + if !set_device_code_state_for_active_attempt( + &sign_in_state, + &request_frame, + &cancel, + SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState { + device_code: Some(device_code.clone()), + cancel: Some(cancel.clone()), + }), + ) { + return; + } + + tokio::select! { + _ = cancel.notified() => {} + result = complete_device_code_login(opts, device_code) => { + match result { + Ok(()) => { + let local_auth = load_local_chatgpt_auth( + &codex_home, + cli_auth_credentials_store_mode, + forced_chatgpt_workspace_id.as_deref(), + ); + handle_chatgpt_auth_tokens_login_result_for_active_attempt( + request_handle, + sign_in_state, + request_frame, + error, + cancel, + local_auth, + ).await; + } + Err(err) => { + set_device_code_error_for_active_attempt( + &sign_in_state, + &request_frame, + &error, + &cancel, + err.to_string(), + ); + } + } + } + } + }); } pub(super) fn render_device_code_login( @@ -151,6 +246,159 @@ fn set_device_code_success_message_for_active_attempt( true } +fn set_device_code_error_for_active_attempt( + sign_in_state: &Arc>, + request_frame: &FrameRequester, + error: &Arc>>, + cancel: &Arc, + message: String, +) -> bool { + if !set_device_code_state_for_active_attempt( + sign_in_state, + request_frame, + cancel, + SignInState::PickMode, + ) { + return false; + } + *error.write().unwrap() = Some(message); + request_frame.schedule_frame(); + true +} + +async fn fallback_to_browser_login( + request_handle: codex_app_server_client::AppServerRequestHandle, + sign_in_state: Arc>, + request_frame: FrameRequester, + error: Arc>>, + cancel: Arc, +) { + let should_fallback = { + let guard = sign_in_state.read().unwrap(); + device_code_attempt_matches(&guard, &cancel) + }; + if !should_fallback { + return; + } + + match request_handle + .request_typed::(ClientRequest::LoginAccount { + request_id: onboarding_request_id(), + params: LoginAccountParams::Chatgpt, + }) + .await + { + Ok(LoginAccountResponse::Chatgpt { login_id, auth_url }) => { + *error.write().unwrap() = None; + let _updated = set_device_code_state_for_active_attempt( + &sign_in_state, + &request_frame, + &cancel, + SignInState::ChatGptContinueInBrowser(ContinueInBrowserState { + login_id, + auth_url, + }), + ); + } + Ok(other) => { + set_device_code_error_for_active_attempt( + &sign_in_state, + &request_frame, + &error, + &cancel, + format!("Unexpected account/login/start response: {other:?}"), + ); + } + Err(err) => { + set_device_code_error_for_active_attempt( + &sign_in_state, + &request_frame, + &error, + &cancel, + err.to_string(), + ); + } + } +} + +async fn handle_chatgpt_auth_tokens_login_result_for_active_attempt( + request_handle: codex_app_server_client::AppServerRequestHandle, + sign_in_state: Arc>, + request_frame: FrameRequester, + error: Arc>>, + cancel: Arc, + local_auth: Result, +) { + let local_auth = match local_auth { + Ok(local_auth) => local_auth, + Err(err) => { + set_device_code_error_for_active_attempt( + &sign_in_state, + &request_frame, + &error, + &cancel, + err, + ); + return; + } + }; + + let result = request_handle + .request_typed::(ClientRequest::LoginAccount { + request_id: onboarding_request_id(), + params: LoginAccountParams::ChatgptAuthTokens { + access_token: local_auth.access_token, + chatgpt_account_id: local_auth.chatgpt_account_id, + chatgpt_plan_type: local_auth.chatgpt_plan_type, + }, + }) + .await; + apply_chatgpt_auth_tokens_login_response_for_active_attempt( + &sign_in_state, + &request_frame, + &error, + &cancel, + result.map_err(|err| err.to_string()), + ); +} + +fn apply_chatgpt_auth_tokens_login_response_for_active_attempt( + sign_in_state: &Arc>, + request_frame: &FrameRequester, + error: &Arc>>, + cancel: &Arc, + result: Result, +) { + match result { + Ok(LoginAccountResponse::ChatgptAuthTokens {}) => { + *error.write().unwrap() = None; + let _updated = set_device_code_success_message_for_active_attempt( + sign_in_state, + request_frame, + cancel, + ); + } + Ok(other) => { + set_device_code_error_for_active_attempt( + sign_in_state, + request_frame, + error, + cancel, + format!("Unexpected account/login/start response: {other:?}"), + ); + } + Err(err) => { + set_device_code_error_for_active_attempt( + sign_in_state, + request_frame, + error, + cancel, + err, + ); + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -269,4 +517,30 @@ mod tests { SignInState::ChatGptDeviceCode(_) )); } + + #[test] + fn chatgpt_auth_tokens_success_sets_success_message_without_login_id() { + let sign_in_state = device_code_sign_in_state(Arc::new(Notify::new())); + let request_frame = FrameRequester::test_dummy(); + let error = Arc::new(RwLock::new(None)); + let cancel = match &*sign_in_state.read().unwrap() { + SignInState::ChatGptDeviceCode(state) => { + state.cancel.as_ref().expect("cancel handle").clone() + } + _ => panic!("expected device-code state"), + }; + + apply_chatgpt_auth_tokens_login_response_for_active_attempt( + &sign_in_state, + &request_frame, + &error, + &cancel, + Ok(LoginAccountResponse::ChatgptAuthTokens {}), + ); + + assert!(matches!( + &*sign_in_state.read().unwrap(), + SignInState::ChatGptSuccessMessage + )); + } } From 683c37ce755f198f417db27f780965a5972b5b7b Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Tue, 17 Mar 2026 13:19:28 -0700 Subject: [PATCH 03/13] [plugins] Support plugin installation elicitation. (#14896) It now supports: - Connectors that are from installed and enabled plugins that are not installed yet - Plugins that are on the allowlist that are not installed yet. --- codex-rs/core/src/codex.rs | 14 +- codex-rs/core/src/connectors.rs | 55 ++-- codex-rs/core/src/connectors_tests.rs | 28 +- codex-rs/core/src/plugins/discoverable.rs | 72 +++++ .../core/src/plugins/discoverable_tests.rs | 119 ++++++++ codex-rs/core/src/plugins/manager.rs | 15 +- codex-rs/core/src/plugins/manager_tests.rs | 49 +--- codex-rs/core/src/plugins/mod.rs | 5 + codex-rs/core/src/plugins/test_support.rs | 109 ++++++++ codex-rs/core/src/tools/discoverable.rs | 23 ++ .../core/src/tools/handlers/tool_suggest.rs | 160 ++++++----- .../src/tools/handlers/tool_suggest_tests.rs | 253 +++++++++++++----- codex-rs/core/src/tools/spec.rs | 2 +- codex-rs/core/src/tools/spec_tests.rs | 3 +- .../src/bottom_pane/mcp_server_elicitation.rs | 40 ++- codex-rs/tui/src/bottom_pane/mod.rs | 6 +- .../src/bottom_pane/mcp_server_elicitation.rs | 40 ++- .../tui_app_server/src/bottom_pane/mod.rs | 6 +- 18 files changed, 755 insertions(+), 244 deletions(-) create mode 100644 codex-rs/core/src/plugins/discoverable.rs create mode 100644 codex-rs/core/src/plugins/discoverable_tests.rs create mode 100644 codex-rs/core/src/plugins/test_support.rs diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 8ffe1d3bd1..fc762ed63c 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -291,7 +291,6 @@ use crate::tasks::SessionTask; use crate::tasks::SessionTaskContext; use crate::tools::ToolRouter; use crate::tools::context::SharedTurnDiffTracker; -use crate::tools::discoverable::DiscoverableTool; use crate::tools::js_repl::JsReplHandle; use crate::tools::js_repl::resolve_compatible_node; use crate::tools::network_approval::NetworkApprovalService; @@ -6410,11 +6409,14 @@ pub(crate) async fn built_tools( accessible_connectors.as_slice(), ) .await - { - Ok(connectors) if connectors.is_empty() => None, - Ok(connectors) => { - Some(connectors.into_iter().map(DiscoverableTool::from).collect()) - } + .map(|discoverable_tools| { + crate::tools::discoverable::filter_tool_suggest_discoverable_tools_for_client( + discoverable_tools, + turn_context.app_server_client_name.as_deref(), + ) + }) { + Ok(discoverable_tools) if discoverable_tools.is_empty() => None, + Ok(discoverable_tools) => Some(discoverable_tools), Err(err) => { warn!("failed to load discoverable tool suggestions: {err:#}"); None diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 12a02e03a1..3221e34089 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -42,24 +42,14 @@ use crate::mcp_connection_manager::McpConnectionManager; use crate::mcp_connection_manager::codex_apps_tools_cache_key; use crate::plugins::AppConnectorId; use crate::plugins::PluginsManager; +use crate::plugins::list_tool_suggest_discoverable_plugins; use crate::token_data::TokenData; +use crate::tools::discoverable::DiscoverablePluginInfo; +use crate::tools::discoverable::DiscoverableTool; pub use codex_connectors::CONNECTORS_CACHE_TTL; const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30); const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60); -const TOOL_SUGGEST_DISCOVERABLE_CONNECTOR_IDS: &[&str] = &[ - "connector_2128aebfecb84f64a069897515042a44", - "connector_68df038e0ba48191908c8434991bbac2", - "asdk_app_69a1d78e929881919bba0dbda1f6436d", - "connector_4964e3b22e3e427e9b4ae1acf2c1fa34", - "connector_9d7cfa34e6654a5f98d3387af34b2e1c", - "connector_6f1ec045b8fa4ced8738e32c7f74514b", - "connector_947e0d954944416db111db556030eea6", - "connector_5f3c8c41a1e54ad7a76272c89e2554fa", - "connector_686fad9b54914a35b75be6d06a0f6f31", - "connector_76869538009648d5b282a4bb21c3d157", - "connector_37316be7febe4224b3d31465bae4dbd7", -]; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct AppToolPolicy { @@ -116,13 +106,24 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth( config: &Config, auth: Option<&CodexAuth>, accessible_connectors: &[AppInfo], -) -> anyhow::Result> { +) -> anyhow::Result> { let directory_connectors = list_directory_connectors_for_tool_suggest_with_auth(config, auth).await?; - Ok(filter_tool_suggest_discoverable_tools( + let connector_ids = tool_suggest_connector_ids(config); + let discoverable_connectors = filter_tool_suggest_discoverable_connectors( directory_connectors, accessible_connectors, - )) + &connector_ids, + ) + .into_iter() + .map(DiscoverableTool::from); + let discoverable_plugins = list_tool_suggest_discoverable_plugins(config)? + .into_iter() + .map(DiscoverablePluginInfo::from) + .map(DiscoverableTool::from); + Ok(discoverable_connectors + .chain(discoverable_plugins) + .collect()) } pub async fn list_cached_accessible_connectors_from_mcp_tools( @@ -350,24 +351,21 @@ fn write_cached_accessible_connectors( }); } -fn filter_tool_suggest_discoverable_tools( +fn filter_tool_suggest_discoverable_connectors( directory_connectors: Vec, accessible_connectors: &[AppInfo], + discoverable_connector_ids: &HashSet, ) -> Vec { let accessible_connector_ids: HashSet<&str> = accessible_connectors .iter() - .filter(|connector| connector.is_accessible && connector.is_enabled) + .filter(|connector| connector.is_accessible) .map(|connector| connector.id.as_str()) .collect(); - let allowed_connector_ids: HashSet<&str> = TOOL_SUGGEST_DISCOVERABLE_CONNECTOR_IDS - .iter() - .copied() - .collect(); let mut connectors = filter_disallowed_connectors(directory_connectors) .into_iter() .filter(|connector| !accessible_connector_ids.contains(connector.id.as_str())) - .filter(|connector| allowed_connector_ids.contains(connector.id.as_str())) + .filter(|connector| discoverable_connector_ids.contains(connector.id.as_str())) .collect::>(); connectors.sort_by(|left, right| { left.name @@ -377,6 +375,16 @@ fn filter_tool_suggest_discoverable_tools( connectors } +fn tool_suggest_connector_ids(config: &Config) -> HashSet { + PluginsManager::new(config.codex_home.clone()) + .plugins_for_config(config) + .capability_summaries() + .iter() + .flat_map(|plugin| plugin.app_connector_ids.iter()) + .map(|connector_id| connector_id.0.clone()) + .collect() +} + async fn list_directory_connectors_for_tool_suggest_with_auth( config: &Config, auth: Option<&CodexAuth>, @@ -675,6 +683,7 @@ pub(crate) fn codex_app_tool_is_enabled( const DISALLOWED_CONNECTOR_IDS: &[&str] = &[ "asdk_app_6938a94a61d881918ef32cb999ff937c", "connector_2b0a9009c9c64bf9933a3dae3f2b1254", + "connector_3f8d1a79f27c4c7ba1a897ab13bf37dc", "connector_68de829bf7648191acd70a907364c67c", "connector_68e004f14af881919eb50893d3d9f523", "connector_69272cb413a081919685ec3c88d1744e", diff --git a/codex-rs/core/src/connectors_tests.rs b/codex-rs/core/src/connectors_tests.rs index 3b731e5086..f0ec1309cc 100644 --- a/codex-rs/core/src/connectors_tests.rs +++ b/codex-rs/core/src/connectors_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::config::CONFIG_TOML_FILE; use crate::config::ConfigBuilder; use crate::config::types::AppConfig; use crate::config::types::AppToolConfig; @@ -13,13 +14,13 @@ use crate::config_loader::ConfigRequirementsToml; use crate::features::Feature; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp_connection_manager::ToolInfo; -use codex_config::CONFIG_TOML_FILE; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use rmcp::model::JsonObject; use rmcp::model::Tool; use std::collections::BTreeMap; use std::collections::HashMap; +use std::collections::HashSet; use std::sync::Arc; use tempfile::tempdir; @@ -957,6 +958,7 @@ fn filter_disallowed_connectors_filters_openai_prefix() { fn filter_disallowed_connectors_filters_disallowed_connector_ids() { let filtered = filter_disallowed_connectors(vec![ app("asdk_app_6938a94a61d881918ef32cb999ff937c"), + app("connector_3f8d1a79f27c4c7ba1a897ab13bf37dc"), app("delta"), ]); assert_eq!(filtered, vec![app("delta")]); @@ -979,8 +981,8 @@ fn first_party_chat_originator_filters_target_and_openai_prefixed_connectors() { } #[test] -fn filter_tool_suggest_discoverable_tools_keeps_only_allowlisted_uninstalled_apps() { - let filtered = filter_tool_suggest_discoverable_tools( +fn filter_tool_suggest_discoverable_connectors_keeps_only_plugin_backed_uninstalled_apps() { + let filtered = filter_tool_suggest_discoverable_connectors( vec![ named_app( "connector_2128aebfecb84f64a069897515042a44", @@ -996,6 +998,10 @@ fn filter_tool_suggest_discoverable_tools_keeps_only_allowlisted_uninstalled_app "Google Calendar", ) }], + &HashSet::from([ + "connector_2128aebfecb84f64a069897515042a44".to_string(), + "connector_68df038e0ba48191908c8434991bbac2".to_string(), + ]), ); assert_eq!( @@ -1008,8 +1014,8 @@ fn filter_tool_suggest_discoverable_tools_keeps_only_allowlisted_uninstalled_app } #[test] -fn filter_tool_suggest_discoverable_tools_keeps_disabled_accessible_apps() { - let filtered = filter_tool_suggest_discoverable_tools( +fn filter_tool_suggest_discoverable_connectors_excludes_accessible_apps_even_when_disabled() { + let filtered = filter_tool_suggest_discoverable_connectors( vec![ named_app( "connector_2128aebfecb84f64a069897515042a44", @@ -1031,13 +1037,11 @@ fn filter_tool_suggest_discoverable_tools_keeps_disabled_accessible_apps() { ..named_app("connector_68df038e0ba48191908c8434991bbac2", "Gmail") }, ], + &HashSet::from([ + "connector_2128aebfecb84f64a069897515042a44".to_string(), + "connector_68df038e0ba48191908c8434991bbac2".to_string(), + ]), ); - assert_eq!( - filtered, - vec![named_app( - "connector_68df038e0ba48191908c8434991bbac2", - "Gmail" - )] - ); + assert_eq!(filtered, Vec::::new()); } diff --git a/codex-rs/core/src/plugins/discoverable.rs b/codex-rs/core/src/plugins/discoverable.rs new file mode 100644 index 0000000000..ddadd749e6 --- /dev/null +++ b/codex-rs/core/src/plugins/discoverable.rs @@ -0,0 +1,72 @@ +use anyhow::Context; +use tracing::warn; + +use super::OPENAI_CURATED_MARKETPLACE_NAME; +use super::PluginCapabilitySummary; +use super::PluginReadRequest; +use super::PluginsManager; +use crate::config::Config; +use crate::features::Feature; + +const TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST: &[&str] = &[ + "github@openai-curated", + "notion@openai-curated", + "slack@openai-curated", + "gmail@openai-curated", + "google-calendar@openai-curated", + "google-docs@openai-curated", + "google-drive@openai-curated", + "google-sheets@openai-curated", + "google-slides@openai-curated", +]; + +pub(crate) fn list_tool_suggest_discoverable_plugins( + config: &Config, +) -> anyhow::Result> { + if !config.features.enabled(Feature::Plugins) { + return Ok(Vec::new()); + } + + let plugins_manager = PluginsManager::new(config.codex_home.clone()); + let marketplaces = plugins_manager + .list_marketplaces_for_config(config, &[]) + .context("failed to list plugin marketplaces for tool suggestions")?; + let Some(curated_marketplace) = marketplaces + .into_iter() + .find(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME) + else { + return Ok(Vec::new()); + }; + + let mut discoverable_plugins = Vec::::new(); + for plugin in curated_marketplace.plugins { + if plugin.installed + || !TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST.contains(&plugin.id.as_str()) + { + continue; + } + let plugin_id = plugin.id.clone(); + let plugin_name = plugin.name.clone(); + + match plugins_manager.read_plugin_for_config( + config, + &PluginReadRequest { + plugin_name, + marketplace_path: curated_marketplace.path.clone(), + }, + ) { + Ok(plugin) => discoverable_plugins.push(plugin.plugin.into()), + Err(err) => warn!("failed to load curated plugin suggestion {plugin_id}: {err:#}"), + } + } + discoverable_plugins.sort_by(|left, right| { + left.display_name + .cmp(&right.display_name) + .then_with(|| left.config_name.cmp(&right.config_name)) + }); + Ok(discoverable_plugins) +} + +#[cfg(test)] +#[path = "discoverable_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/plugins/discoverable_tests.rs b/codex-rs/core/src/plugins/discoverable_tests.rs new file mode 100644 index 0000000000..f624172ed1 --- /dev/null +++ b/codex-rs/core/src/plugins/discoverable_tests.rs @@ -0,0 +1,119 @@ +use super::*; +use crate::plugins::PluginInstallRequest; +use crate::plugins::test_support::load_plugins_config; +use crate::plugins::test_support::write_curated_plugin_sha; +use crate::plugins::test_support::write_file; +use crate::plugins::test_support::write_openai_curated_marketplace; +use crate::plugins::test_support::write_plugins_feature_config; +use crate::tools::discoverable::DiscoverablePluginInfo; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use tempfile::tempdir; + +#[tokio::test] +async fn list_tool_suggest_discoverable_plugins_returns_uninstalled_curated_plugins() { + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace(&curated_root, &["sample", "slack"]); + write_plugins_feature_config(codex_home.path()); + + let config = load_plugins_config(codex_home.path()).await; + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) + .unwrap() + .into_iter() + .map(DiscoverablePluginInfo::from) + .collect::>(); + + assert_eq!( + discoverable_plugins, + vec![DiscoverablePluginInfo { + id: "slack@openai-curated".to_string(), + name: "slack".to_string(), + description: Some( + "Plugin that includes skills, MCP servers, and app connectors".to_string(), + ), + has_skills: true, + mcp_server_names: vec!["sample-docs".to_string()], + app_connector_ids: vec!["connector_calendar".to_string()], + }] + ); +} + +#[tokio::test] +async fn list_tool_suggest_discoverable_plugins_returns_empty_when_plugins_feature_disabled() { + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace(&curated_root, &["slack"]); + + let config = load_plugins_config(codex_home.path()).await; + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) + .unwrap() + .into_iter() + .map(DiscoverablePluginInfo::from) + .collect::>(); + + assert_eq!(discoverable_plugins, Vec::::new()); +} + +#[tokio::test] +async fn list_tool_suggest_discoverable_plugins_normalizes_description() { + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace(&curated_root, &["slack"]); + write_plugins_feature_config(codex_home.path()); + write_file( + &curated_root.join("plugins/slack/.codex-plugin/plugin.json"), + r#"{ + "name": "slack", + "description": " Plugin\n with extra spacing " +}"#, + ); + + let config = load_plugins_config(codex_home.path()).await; + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) + .unwrap() + .into_iter() + .map(DiscoverablePluginInfo::from) + .collect::>(); + + assert_eq!( + discoverable_plugins, + vec![DiscoverablePluginInfo { + id: "slack@openai-curated".to_string(), + name: "slack".to_string(), + description: Some("Plugin with extra spacing".to_string()), + has_skills: true, + mcp_server_names: vec!["sample-docs".to_string()], + app_connector_ids: vec!["connector_calendar".to_string()], + }] + ); +} + +#[tokio::test] +async fn list_tool_suggest_discoverable_plugins_omits_installed_curated_plugins() { + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace(&curated_root, &["slack"]); + write_curated_plugin_sha(codex_home.path()); + write_plugins_feature_config(codex_home.path()); + + PluginsManager::new(codex_home.path().to_path_buf()) + .install_plugin(PluginInstallRequest { + plugin_name: "slack".to_string(), + marketplace_path: AbsolutePathBuf::try_from( + curated_root.join(".agents/plugins/marketplace.json"), + ) + .expect("marketplace path"), + }) + .await + .expect("plugin should install"); + + let refreshed_config = load_plugins_config(codex_home.path()).await; + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&refreshed_config) + .unwrap() + .into_iter() + .map(DiscoverablePluginInfo::from) + .collect::>(); + + assert_eq!(discoverable_plugins, Vec::::new()); +} diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index 8347dc427d..60c375c9cd 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -68,7 +68,7 @@ use tracing::warn; const DEFAULT_SKILLS_DIR_NAME: &str = "skills"; const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json"; const DEFAULT_APP_CONFIG_FILE: &str = ".app.json"; -const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated"; +pub const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated"; static CURATED_REPO_SYNC_STARTED: AtomicBool = AtomicBool::new(false); const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024; @@ -219,6 +219,19 @@ impl PluginCapabilitySummary { } } +impl From for PluginCapabilitySummary { + fn from(value: PluginDetailSummary) -> Self { + Self { + config_name: value.id, + display_name: value.name, + description: prompt_safe_plugin_description(value.description.as_deref()), + has_skills: !value.skills.is_empty(), + mcp_server_names: value.mcp_server_names, + app_connector_ids: value.apps, + } + } +} + fn prompt_safe_plugin_description(description: Option<&str>) -> Option { let description = description? .split_whitespace() diff --git a/codex-rs/core/src/plugins/manager_tests.rs b/codex-rs/core/src/plugins/manager_tests.rs index 2a327f4e43..926f07cb29 100644 --- a/codex-rs/core/src/plugins/manager_tests.rs +++ b/codex-rs/core/src/plugins/manager_tests.rs @@ -7,6 +7,10 @@ use crate::config_loader::ConfigLayerEntry; use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigRequirements; use crate::config_loader::ConfigRequirementsToml; +use crate::plugins::test_support::TEST_CURATED_PLUGIN_SHA; +use crate::plugins::test_support::write_curated_plugin_sha_with as write_curated_plugin_sha; +use crate::plugins::test_support::write_file; +use crate::plugins::test_support::write_openai_curated_marketplace; use codex_app_server_protocol::ConfigLayerSource; use pretty_assertions::assert_eq; use std::fs; @@ -19,13 +23,6 @@ use wiremock::matchers::header; use wiremock::matchers::method; use wiremock::matchers::path; -const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567"; - -fn write_file(path: &Path, contents: &str) { - fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap(); - fs::write(path, contents).unwrap(); -} - fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) { let plugin_root = root.join(dir_name); fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); @@ -39,44 +36,6 @@ fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) { fs::write(plugin_root.join(".mcp.json"), r#"{"mcpServers":{}}"#).unwrap(); } -fn write_openai_curated_marketplace(root: &Path, plugin_names: &[&str]) { - fs::create_dir_all(root.join(".agents/plugins")).unwrap(); - let plugins = plugin_names - .iter() - .map(|plugin_name| { - format!( - r#"{{ - "name": "{plugin_name}", - "source": {{ - "source": "local", - "path": "./plugins/{plugin_name}" - }} - }}"# - ) - }) - .collect::>() - .join(",\n"); - fs::write( - root.join(".agents/plugins/marketplace.json"), - format!( - r#"{{ - "name": "{OPENAI_CURATED_MARKETPLACE_NAME}", - "plugins": [ -{plugins} - ] -}}"# - ), - ) - .unwrap(); - for plugin_name in plugin_names { - write_plugin(root, &format!("plugins/{plugin_name}"), plugin_name); - } -} - -fn write_curated_plugin_sha(codex_home: &Path, sha: &str) { - write_file(&codex_home.join(".tmp/plugins.sha"), &format!("{sha}\n")); -} - fn plugin_config_toml(enabled: bool, plugins_feature_enabled: bool) -> String { let mut root = toml::map::Map::new(); diff --git a/codex-rs/core/src/plugins/mod.rs b/codex-rs/core/src/plugins/mod.rs index 9f540d7cd0..8b45954abe 100644 --- a/codex-rs/core/src/plugins/mod.rs +++ b/codex-rs/core/src/plugins/mod.rs @@ -1,4 +1,5 @@ mod curated_repo; +mod discoverable; mod injection; mod manager; mod manifest; @@ -6,16 +7,20 @@ mod marketplace; mod remote; mod render; mod store; +#[cfg(test)] +pub(crate) mod test_support; mod toggles; pub(crate) use curated_repo::curated_plugins_repo_path; pub(crate) use curated_repo::read_curated_plugins_sha; pub(crate) use curated_repo::sync_openai_plugins_repo; +pub(crate) use discoverable::list_tool_suggest_discoverable_plugins; pub(crate) use injection::build_plugin_injections; pub use manager::AppConnectorId; pub use manager::ConfiguredMarketplacePluginSummary; pub use manager::ConfiguredMarketplaceSummary; pub use manager::LoadedPlugin; +pub use manager::OPENAI_CURATED_MARKETPLACE_NAME; pub use manager::PluginCapabilitySummary; pub use manager::PluginDetailSummary; pub use manager::PluginInstallError; diff --git a/codex-rs/core/src/plugins/test_support.rs b/codex-rs/core/src/plugins/test_support.rs new file mode 100644 index 0000000000..8624d40810 --- /dev/null +++ b/codex-rs/core/src/plugins/test_support.rs @@ -0,0 +1,109 @@ +use crate::config::CONFIG_TOML_FILE; +use crate::config::ConfigBuilder; +use std::fs; +use std::path::Path; + +use super::OPENAI_CURATED_MARKETPLACE_NAME; + +pub(crate) const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567"; + +pub(crate) fn write_file(path: &Path, contents: &str) { + fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap(); + fs::write(path, contents).unwrap(); +} + +pub(crate) fn write_curated_plugin(root: &Path, plugin_name: &str) { + let plugin_root = root.join("plugins").join(plugin_name); + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + &format!( + r#"{{ + "name": "{plugin_name}", + "description": "Plugin that includes skills, MCP servers, and app connectors" +}}"# + ), + ); + write_file( + &plugin_root.join("skills/SKILL.md"), + "---\nname: sample\ndescription: sample\n---\n", + ); + write_file( + &plugin_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "sample-docs": { + "type": "http", + "url": "https://sample.example/mcp" + } + } +}"#, + ); + write_file( + &plugin_root.join(".app.json"), + r#"{ + "apps": { + "calendar": { + "id": "connector_calendar" + } + } +}"#, + ); +} + +pub(crate) fn write_openai_curated_marketplace(root: &Path, plugin_names: &[&str]) { + let plugins = plugin_names + .iter() + .map(|plugin_name| { + format!( + r#"{{ + "name": "{plugin_name}", + "source": {{ + "source": "local", + "path": "./plugins/{plugin_name}" + }} + }}"# + ) + }) + .collect::>() + .join(",\n"); + write_file( + &root.join(".agents/plugins/marketplace.json"), + &format!( + r#"{{ + "name": "{OPENAI_CURATED_MARKETPLACE_NAME}", + "plugins": [ +{plugins} + ] +}}"# + ), + ); + for plugin_name in plugin_names { + write_curated_plugin(root, plugin_name); + } +} + +pub(crate) fn write_curated_plugin_sha(codex_home: &Path) { + write_curated_plugin_sha_with(codex_home, TEST_CURATED_PLUGIN_SHA); +} + +pub(crate) fn write_curated_plugin_sha_with(codex_home: &Path, sha: &str) { + write_file(&codex_home.join(".tmp/plugins.sha"), &format!("{sha}\n")); +} + +pub(crate) fn write_plugins_feature_config(codex_home: &Path) { + write_file( + &codex_home.join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +"#, + ); +} + +pub(crate) async fn load_plugins_config(codex_home: &Path) -> crate::config::Config { + ConfigBuilder::default() + .codex_home(codex_home.to_path_buf()) + .fallback_cwd(Some(codex_home.to_path_buf())) + .build() + .await + .expect("config should load") +} diff --git a/codex-rs/core/src/tools/discoverable.rs b/codex-rs/core/src/tools/discoverable.rs index 75de51b150..fc1c66847b 100644 --- a/codex-rs/core/src/tools/discoverable.rs +++ b/codex-rs/core/src/tools/discoverable.rs @@ -3,6 +3,8 @@ use codex_app_server_protocol::AppInfo; use serde::Deserialize; use serde::Serialize; +const TUI_APP_SERVER_CLIENT_NAME: &str = "codex-tui"; + #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub(crate) enum DiscoverableToolType { @@ -69,6 +71,13 @@ impl DiscoverableTool { Self::Plugin(plugin) => plugin.description.as_deref(), } } + + pub(crate) fn install_url(&self) -> Option<&str> { + match self { + Self::Connector(connector) => connector.install_url.as_deref(), + Self::Plugin(_) => None, + } + } } impl From for DiscoverableTool { @@ -83,6 +92,20 @@ impl From for DiscoverableTool { } } +pub(crate) fn filter_tool_suggest_discoverable_tools_for_client( + discoverable_tools: Vec, + app_server_client_name: Option<&str>, +) -> Vec { + if app_server_client_name != Some(TUI_APP_SERVER_CLIENT_NAME) { + return discoverable_tools; + } + + discoverable_tools + .into_iter() + .filter(|tool| !matches!(tool, DiscoverableTool::Plugin(_))) + .collect() +} + #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct DiscoverablePluginInfo { pub(crate) id: String, diff --git a/codex-rs/core/src/tools/handlers/tool_suggest.rs b/codex-rs/core/src/tools/handlers/tool_suggest.rs index 311f191bd0..533f12c4f1 100644 --- a/codex-rs/core/src/tools/handlers/tool_suggest.rs +++ b/codex-rs/core/src/tools/handlers/tool_suggest.rs @@ -23,6 +23,7 @@ use crate::tools::context::ToolPayload; use crate::tools::discoverable::DiscoverableTool; use crate::tools::discoverable::DiscoverableToolAction; use crate::tools::discoverable::DiscoverableToolType; +use crate::tools::discoverable::filter_tool_suggest_discoverable_tools_for_client; use crate::tools::handlers::parse_arguments; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; @@ -59,7 +60,8 @@ struct ToolSuggestMeta<'a> { suggest_reason: &'a str, tool_id: &'a str, tool_name: &'a str, - install_url: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + install_url: Option<&'a str>, } #[async_trait] @@ -95,15 +97,16 @@ impl ToolHandler for ToolSuggestHandler { "suggest_reason must not be empty".to_string(), )); } - if args.tool_type == DiscoverableToolType::Plugin { - return Err(FunctionCallError::RespondToModel( - "plugin tool suggestions are not currently available".to_string(), - )); - } if args.action_type != DiscoverableToolAction::Install { return Err(FunctionCallError::RespondToModel( - "connector tool suggestions currently support only action_type=\"install\"" - .to_string(), + "tool suggestions currently support only action_type=\"install\"".to_string(), + )); + } + if args.tool_type == DiscoverableToolType::Plugin + && turn.app_server_client_name.as_deref() == Some("codex-tui") + { + return Err(FunctionCallError::RespondToModel( + "plugin tool suggestions are not available in codex-tui yet".to_string(), )); } @@ -121,11 +124,11 @@ impl ToolHandler for ToolSuggestHandler { &accessible_connectors, ) .await - .map(|connectors| { - connectors - .into_iter() - .map(DiscoverableTool::from) - .collect::>() + .map(|discoverable_tools| { + filter_tool_suggest_discoverable_tools_for_client( + discoverable_tools, + turn.app_server_client_name.as_deref(), + ) }) .map_err(|err| { FunctionCallError::RespondToModel(format!( @@ -133,14 +136,9 @@ impl ToolHandler for ToolSuggestHandler { )) })?; - let connector = discoverable_tools + let tool = discoverable_tools .into_iter() - .find_map(|tool| match tool { - DiscoverableTool::Connector(connector) if connector.id == args.tool_id => { - Some(*connector) - } - DiscoverableTool::Connector(_) | DiscoverableTool::Plugin(_) => None, - }) + .find(|tool| tool.tool_type() == args.tool_type && tool.id() == args.tool_id) .ok_or_else(|| { FunctionCallError::RespondToModel(format!( "tool_id must match one of the discoverable tools exposed by {TOOL_SUGGEST_TOOL_NAME}" @@ -153,7 +151,7 @@ impl ToolHandler for ToolSuggestHandler { turn.sub_id.clone(), &args, suggest_reason, - &connector, + &tool, ); let response = session .request_mcp_server_elicitation(turn.as_ref(), request_id, params) @@ -163,37 +161,12 @@ impl ToolHandler for ToolSuggestHandler { .is_some_and(|response| response.action == ElicitationAction::Accept); let completed = if user_confirmed { - let manager = session.services.mcp_connection_manager.read().await; - match manager.hard_refresh_codex_apps_tools_cache().await { - Ok(mcp_tools) => { - let accessible_connectors = connectors::with_app_enabled_state( - connectors::accessible_connectors_from_mcp_tools(&mcp_tools), - &turn.config, - ); - connectors::refresh_accessible_connectors_cache_from_mcp_tools( - &turn.config, - auth.as_ref(), - &mcp_tools, - ); - verified_connector_suggestion_completed( - args.action_type, - connector.id.as_str(), - &accessible_connectors, - ) - } - Err(err) => { - warn!( - "failed to refresh codex apps tools cache after tool suggestion for {}: {err:#}", - connector.id - ); - false - } - } + verify_tool_suggestion_completed(&session, &turn, &tool, auth.as_ref()).await } else { false }; - if completed { + if completed && let DiscoverableTool::Connector(connector) = &tool { session .merge_connector_selection(HashSet::from([connector.id.clone()])) .await; @@ -204,8 +177,8 @@ impl ToolHandler for ToolSuggestHandler { user_confirmed, tool_type: args.tool_type, action_type: args.action_type, - tool_id: connector.id, - tool_name: connector.name, + tool_id: tool.id().to_string(), + tool_name: tool.name().to_string(), suggest_reason: suggest_reason.to_string(), }) .map_err(|err| { @@ -223,18 +196,11 @@ fn build_tool_suggestion_elicitation_request( turn_id: String, args: &ToolSuggestArgs, suggest_reason: &str, - connector: &AppInfo, + tool: &DiscoverableTool, ) -> McpServerElicitationRequestParams { - let tool_name = connector.name.clone(); - let install_url = connector - .install_url - .clone() - .unwrap_or_else(|| connectors::connector_install_url(&tool_name, &connector.id)); - - let message = format!( - "{tool_name} could help with this request.\n\n{suggest_reason}\n\nOpen ChatGPT to {} it, then confirm here if you finish.", - args.action_type.as_str() - ); + let tool_name = tool.name().to_string(); + let install_url = tool.install_url().map(ToString::to_string); + let message = suggest_reason.to_string(); McpServerElicitationRequestParams { thread_id, @@ -245,9 +211,9 @@ fn build_tool_suggestion_elicitation_request( args.tool_type, args.action_type, suggest_reason, - connector.id.as_str(), + tool.id(), tool_name.as_str(), - install_url.as_str(), + install_url.as_deref(), ))), message, requested_schema: McpElicitationSchema { @@ -266,7 +232,7 @@ fn build_tool_suggestion_meta<'a>( suggest_reason: &'a str, tool_id: &'a str, tool_name: &'a str, - install_url: &'a str, + install_url: Option<&'a str>, ) -> ToolSuggestMeta<'a> { ToolSuggestMeta { codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE, @@ -279,18 +245,74 @@ fn build_tool_suggestion_meta<'a>( } } +async fn verify_tool_suggestion_completed( + session: &crate::codex::Session, + turn: &crate::codex::TurnContext, + tool: &DiscoverableTool, + auth: Option<&crate::CodexAuth>, +) -> bool { + match tool { + DiscoverableTool::Connector(connector) => { + let manager = session.services.mcp_connection_manager.read().await; + match manager.hard_refresh_codex_apps_tools_cache().await { + Ok(mcp_tools) => { + let accessible_connectors = connectors::with_app_enabled_state( + connectors::accessible_connectors_from_mcp_tools(&mcp_tools), + &turn.config, + ); + connectors::refresh_accessible_connectors_cache_from_mcp_tools( + &turn.config, + auth, + &mcp_tools, + ); + verified_connector_suggestion_completed( + connector.id.as_str(), + &accessible_connectors, + ) + } + Err(err) => { + warn!( + "failed to refresh codex apps tools cache after tool suggestion for {}: {err:#}", + connector.id + ); + false + } + } + } + DiscoverableTool::Plugin(plugin) => { + session.reload_user_config_layer().await; + let config = session.get_config().await; + verified_plugin_suggestion_completed( + plugin.id.as_str(), + config.as_ref(), + session.services.plugins_manager.as_ref(), + ) + } + } +} + fn verified_connector_suggestion_completed( - action_type: DiscoverableToolAction, tool_id: &str, accessible_connectors: &[AppInfo], ) -> bool { accessible_connectors .iter() .find(|connector| connector.id == tool_id) - .is_some_and(|connector| match action_type { - DiscoverableToolAction::Install => connector.is_accessible, - DiscoverableToolAction::Enable => connector.is_accessible && connector.is_enabled, - }) + .is_some_and(|connector| connector.is_accessible) +} + +fn verified_plugin_suggestion_completed( + tool_id: &str, + config: &crate::config::Config, + plugins_manager: &crate::plugins::PluginsManager, +) -> bool { + plugins_manager + .list_marketplaces_for_config(config, &[]) + .ok() + .into_iter() + .flatten() + .flat_map(|marketplace| marketplace.plugins.into_iter()) + .any(|plugin| plugin.id == tool_id && plugin.installed) } #[cfg(test)] diff --git a/codex-rs/core/src/tools/handlers/tool_suggest_tests.rs b/codex-rs/core/src/tools/handlers/tool_suggest_tests.rs index a8c4541e91..31aa49bbab 100644 --- a/codex-rs/core/src/tools/handlers/tool_suggest_tests.rs +++ b/codex-rs/core/src/tools/handlers/tool_suggest_tests.rs @@ -1,5 +1,18 @@ use super::*; +use crate::plugins::PluginInstallRequest; +use crate::plugins::PluginsManager; +use crate::plugins::test_support::load_plugins_config; +use crate::plugins::test_support::write_curated_plugin_sha; +use crate::plugins::test_support::write_openai_curated_marketplace; +use crate::plugins::test_support::write_plugins_feature_config; +use crate::tools::discoverable::DiscoverablePluginInfo; +use crate::tools::discoverable::filter_tool_suggest_discoverable_tools_for_client; +use codex_app_server_protocol::AppInfo; +use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; +use serde_json::json; +use std::collections::BTreeMap; +use tempfile::tempdir; #[test] fn build_tool_suggestion_elicitation_request_uses_expected_shape() { @@ -9,7 +22,7 @@ fn build_tool_suggestion_elicitation_request_uses_expected_shape() { tool_id: "connector_2128aebfecb84f64a069897515042a44".to_string(), suggest_reason: "Plan and reference events from your calendar".to_string(), }; - let connector = AppInfo { + let connector = DiscoverableTool::Connector(Box::new(AppInfo { id: "connector_2128aebfecb84f64a069897515042a44".to_string(), name: "Google Calendar".to_string(), description: Some("Plan events and schedules.".to_string()), @@ -26,7 +39,7 @@ fn build_tool_suggestion_elicitation_request_uses_expected_shape() { is_accessible: false, is_enabled: true, plugin_display_names: Vec::new(), - }; + })); let request = build_tool_suggestion_elicitation_request( "thread-1".to_string(), @@ -37,31 +50,86 @@ fn build_tool_suggestion_elicitation_request_uses_expected_shape() { ); assert_eq!( - request, - McpServerElicitationRequestParams { - thread_id: "thread-1".to_string(), - turn_id: Some("turn-1".to_string()), - server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), - request: McpServerElicitationRequest::Form { - meta: Some(json!(ToolSuggestMeta { - codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE, - tool_type: DiscoverableToolType::Connector, - suggest_type: DiscoverableToolAction::Install, - suggest_reason: "Plan and reference events from your calendar", - tool_id: "connector_2128aebfecb84f64a069897515042a44", - tool_name: "Google Calendar", - install_url: "https://chatgpt.com/apps/google-calendar/connector_2128aebfecb84f64a069897515042a44", - })), - message: "Google Calendar could help with this request.\n\nPlan and reference events from your calendar\n\nOpen ChatGPT to install it, then confirm here if you finish.".to_string(), - requested_schema: McpElicitationSchema { - schema_uri: None, - type_: McpElicitationObjectType::Object, - properties: BTreeMap::new(), - required: None, - }, + request, + McpServerElicitationRequestParams { + thread_id: "thread-1".to_string(), + turn_id: Some("turn-1".to_string()), + server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), + request: McpServerElicitationRequest::Form { + meta: Some(json!(ToolSuggestMeta { + codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE, + tool_type: DiscoverableToolType::Connector, + suggest_type: DiscoverableToolAction::Install, + suggest_reason: "Plan and reference events from your calendar", + tool_id: "connector_2128aebfecb84f64a069897515042a44", + tool_name: "Google Calendar", + install_url: Some( + "https://chatgpt.com/apps/google-calendar/connector_2128aebfecb84f64a069897515042a44" + ), + })), + message: "Plan and reference events from your calendar".to_string(), + requested_schema: McpElicitationSchema { + schema_uri: None, + type_: McpElicitationObjectType::Object, + properties: BTreeMap::new(), + required: None, }, - } - ); + }, + } + ); +} + +#[test] +fn build_tool_suggestion_elicitation_request_for_plugin_omits_install_url() { + let args = ToolSuggestArgs { + tool_type: DiscoverableToolType::Plugin, + action_type: DiscoverableToolAction::Install, + tool_id: "sample@openai-curated".to_string(), + suggest_reason: "Use the sample plugin's skills and MCP server".to_string(), + }; + let plugin = DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo { + id: "sample@openai-curated".to_string(), + name: "Sample Plugin".to_string(), + description: Some("Includes skills, MCP servers, and apps.".to_string()), + has_skills: true, + mcp_server_names: vec!["sample-docs".to_string()], + app_connector_ids: vec!["connector_calendar".to_string()], + })); + + let request = build_tool_suggestion_elicitation_request( + "thread-1".to_string(), + "turn-1".to_string(), + &args, + "Use the sample plugin's skills and MCP server", + &plugin, + ); + + assert_eq!( + request, + McpServerElicitationRequestParams { + thread_id: "thread-1".to_string(), + turn_id: Some("turn-1".to_string()), + server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), + request: McpServerElicitationRequest::Form { + meta: Some(json!(ToolSuggestMeta { + codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE, + tool_type: DiscoverableToolType::Plugin, + suggest_type: DiscoverableToolAction::Install, + suggest_reason: "Use the sample plugin's skills and MCP server", + tool_id: "sample@openai-curated", + tool_name: "Sample Plugin", + install_url: None, + })), + message: "Use the sample plugin's skills and MCP server".to_string(), + requested_schema: McpElicitationSchema { + schema_uri: None, + type_: McpElicitationObjectType::Object, + properties: BTreeMap::new(), + required: None, + }, + }, + } + ); } #[test] @@ -72,7 +140,7 @@ fn build_tool_suggestion_meta_uses_expected_shape() { "Find and reference emails from your inbox", "connector_68df038e0ba48191908c8434991bbac2", "Gmail", - "https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2", + Some("https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2"), ); assert_eq!( @@ -84,13 +152,63 @@ fn build_tool_suggestion_meta_uses_expected_shape() { suggest_reason: "Find and reference emails from your inbox", tool_id: "connector_68df038e0ba48191908c8434991bbac2", tool_name: "Gmail", - install_url: "https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2", + install_url: Some( + "https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2" + ), } ); } #[test] -fn verified_connector_suggestion_completed_requires_installed_connector() { +fn filter_tool_suggest_discoverable_tools_for_codex_tui_omits_plugins() { + let discoverable_tools = vec![ + DiscoverableTool::Connector(Box::new(AppInfo { + id: "connector_google_calendar".to_string(), + name: "Google Calendar".to_string(), + description: Some("Plan events and schedules.".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://example.test/google-calendar".to_string()), + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + })), + DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo { + id: "slack@openai-curated".to_string(), + name: "Slack".to_string(), + description: Some("Search Slack messages".to_string()), + has_skills: true, + mcp_server_names: vec!["slack".to_string()], + app_connector_ids: vec!["connector_slack".to_string()], + })), + ]; + + assert_eq!( + filter_tool_suggest_discoverable_tools_for_client(discoverable_tools, Some("codex-tui"),), + vec![DiscoverableTool::Connector(Box::new(AppInfo { + id: "connector_google_calendar".to_string(), + name: "Google Calendar".to_string(), + description: Some("Plan events and schedules.".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://example.test/google-calendar".to_string()), + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }))] + ); +} + +#[test] +fn verified_connector_suggestion_completed_requires_accessible_connector() { let accessible_connectors = vec![AppInfo { id: "calendar".to_string(), name: "Google Calendar".to_string(), @@ -103,65 +221,52 @@ fn verified_connector_suggestion_completed_requires_installed_connector() { labels: None, install_url: None, is_accessible: true, - is_enabled: true, + is_enabled: false, plugin_display_names: Vec::new(), }]; assert!(verified_connector_suggestion_completed( - DiscoverableToolAction::Install, "calendar", &accessible_connectors, )); assert!(!verified_connector_suggestion_completed( - DiscoverableToolAction::Install, "gmail", &accessible_connectors, )); } -#[test] -fn verified_connector_suggestion_completed_requires_enabled_connector_for_enable() { - let accessible_connectors = vec![ - AppInfo { - id: "calendar".to_string(), - name: "Google Calendar".to_string(), - description: None, - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: None, - is_accessible: true, - is_enabled: false, - plugin_display_names: Vec::new(), - }, - AppInfo { - id: "gmail".to_string(), - name: "Gmail".to_string(), - description: None, - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: None, - is_accessible: true, - is_enabled: true, - plugin_display_names: Vec::new(), - }, - ]; +#[tokio::test] +async fn verified_plugin_suggestion_completed_requires_installed_plugin() { + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace(&curated_root, &["sample"]); + write_curated_plugin_sha(codex_home.path()); + write_plugins_feature_config(codex_home.path()); - assert!(!verified_connector_suggestion_completed( - DiscoverableToolAction::Enable, - "calendar", - &accessible_connectors, + let config = load_plugins_config(codex_home.path()).await; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + + assert!(!verified_plugin_suggestion_completed( + "sample@openai-curated", + &config, + &plugins_manager, )); - assert!(verified_connector_suggestion_completed( - DiscoverableToolAction::Enable, - "gmail", - &accessible_connectors, + + plugins_manager + .install_plugin(PluginInstallRequest { + plugin_name: "sample".to_string(), + marketplace_path: AbsolutePathBuf::try_from( + curated_root.join(".agents/plugins/marketplace.json"), + ) + .expect("marketplace path"), + }) + .await + .expect("plugin should install"); + + let refreshed_config = load_plugins_config(codex_home.path()).await; + assert!(verified_plugin_suggestion_completed( + "sample@openai-curated", + &refreshed_config, + &plugins_manager, )); } diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index ab5cff7949..a323a91452 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -1827,7 +1827,7 @@ fn format_discoverable_tools(discoverable_tools: &[DiscoverableTool]) -> String }); let default_action = match tool.tool_type() { DiscoverableToolType::Connector => DiscoverableToolAction::Install, - DiscoverableToolType::Plugin => DiscoverableToolAction::Enable, + DiscoverableToolType::Plugin => DiscoverableToolAction::Install, }; format!( "- {} (id: `{}`, type: {}, action: {}): {}", diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index c3c228703a..b58f2ae538 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -2097,7 +2097,8 @@ fn tool_suggest_description_lists_discoverable_tools() { assert!(description.contains("Sample Plugin")); assert!(description.contains("Plan events and schedules.")); assert!(description.contains("Find and summarize email threads.")); - assert!(description.contains("id: `sample@test`, type: plugin, action: enable")); + assert!(description.contains("id: `sample@test`, type: plugin, action: install")); + assert!(description.contains("`action_type`: `install` or `enable`")); assert!( description.contains("skills; MCP servers: sample-docs; app connectors: connector_sample") ); diff --git a/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs b/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs index 6e0d1acbb1..2b506d504f 100644 --- a/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs +++ b/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs @@ -149,7 +149,7 @@ pub(crate) struct ToolSuggestionRequest { pub(crate) suggest_reason: String, pub(crate) tool_id: String, pub(crate) tool_name: String, - pub(crate) install_url: String, + pub(crate) install_url: Option, } #[derive(Clone, Debug, PartialEq)] @@ -373,8 +373,8 @@ fn parse_tool_suggestion_request(meta: Option<&Value>) -> Option { AppLinkSuggestionType::Install @@ -989,7 +991,7 @@ impl BottomPane { "Enable this app to use it for the current request.".to_string() } }, - url: tool_suggestion.install_url.clone(), + url: install_url, is_installed, is_enabled: false, suggest_reason: Some(tool_suggestion.suggest_reason.clone()), diff --git a/codex-rs/tui_app_server/src/bottom_pane/mcp_server_elicitation.rs b/codex-rs/tui_app_server/src/bottom_pane/mcp_server_elicitation.rs index db03a7f1cc..6e8bb3c697 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/mcp_server_elicitation.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/mcp_server_elicitation.rs @@ -149,7 +149,7 @@ pub(crate) struct ToolSuggestionRequest { pub(crate) suggest_reason: String, pub(crate) tool_id: String, pub(crate) tool_name: String, - pub(crate) install_url: String, + pub(crate) install_url: Option, } #[derive(Clone, Debug, PartialEq)] @@ -373,8 +373,8 @@ fn parse_tool_suggestion_request(meta: Option<&Value>) -> Option { AppLinkSuggestionType::Install @@ -982,7 +984,7 @@ impl BottomPane { "Enable this app to use it for the current request.".to_string() } }, - url: tool_suggestion.install_url.clone(), + url: install_url, is_installed, is_enabled: false, suggest_reason: Some(tool_suggestion.suggest_reason.clone()), From b02388672f7df432fbe34a9128f78e7a1e9d43ea Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 17 Mar 2026 13:21:46 -0700 Subject: [PATCH 04/13] Stabilize Windows cmd-based shell test harnesses (#14958) ## What is flaky The Windows shell-driven integration tests in `codex-rs/core` were intermittently unstable, especially: - `apply_patch_cli_can_use_shell_command_output_as_patch_input` - `websocket_test_codex_shell_chain` - `websocket_v2_test_codex_shell_chain` ## Why it was flaky These tests were exercising real shell-tool flows through whichever shell Codex selected on Windows, and the `apply_patch` test also nested a PowerShell read inside `cmd /c`. There were multiple independent sources of nondeterminism in that setup: - The test harness depended on the model-selected Windows shell instead of pinning the shell it actually meant to exercise. - `cmd.exe /c powershell.exe -Command "..."` is quoting-sensitive; on CI that could leave the read command wrapped as a literal string instead of executing it. - Even after getting the quoting right, PowerShell could emit CLIXML progress records like module-initialization output onto stdout. - The `apply_patch` test was building a patch directly from shell stdout, so any quoting artifact or progress noise corrupted the patch input. So the failures were driven by shell startup and output-shape variance, not by the `apply_patch` or websocket logic themselves. ## How this PR fixes it - Add a test-only `user_shell_override` path so Windows integration tests can pin `cmd.exe` explicitly. - Use that override in the websocket shell-chain tests and in the `apply_patch` harness. - Change the nested Windows file read in `apply_patch_cli_can_use_shell_command_output_as_patch_input` to a UTF-8 PowerShell `-EncodedCommand` script. - Run that nested PowerShell process with `-NonInteractive`, set `$ProgressPreference = 'SilentlyContinue'`, and read the file with `[System.IO.File]::ReadAllText(...)`. ## Why this fix fixes the flakiness The outer harness now runs under a deterministic shell, and the inner PowerShell read no longer depends on fragile `cmd` quoting or on progress output staying quiet by accident. The shell tool returns only the file contents, so patch construction and websocket assertions depend on stable test inputs instead of on runner-specific shell behavior. --------- Co-authored-by: Ahmed Ibrahim <219906144+aibrahim-oai@users.noreply.github.com> Co-authored-by: Codex --- codex-rs/core/src/codex.rs | 10 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 6 +++ codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/test_support.rs | 27 +++++++++++ codex-rs/core/src/thread_manager.rs | 51 ++++++++++++++++++++ codex-rs/core/tests/common/test_codex.rs | 47 ++++++++++++++++-- codex-rs/core/tests/suite/agent_websocket.rs | 4 +- codex-rs/core/tests/suite/apply_patch_cli.rs | 26 +++++++--- 9 files changed, 160 insertions(+), 13 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index fc762ed63c..1b4a23fe1f 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -369,6 +369,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) persist_extended_history: bool, pub(crate) metrics_service_name: Option, pub(crate) inherited_shell_snapshot: Option>, + pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, } @@ -420,6 +421,7 @@ impl Codex { persist_extended_history, metrics_service_name, inherited_shell_snapshot, + user_shell_override, parent_trace: _, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); @@ -574,6 +576,7 @@ impl Codex { dynamic_tools, persist_extended_history, inherited_shell_snapshot, + user_shell_override, }; // Generate a unique ID for the lifetime of this Codex session. @@ -1036,6 +1039,7 @@ pub(crate) struct SessionConfiguration { dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, + user_shell_override: Option, } impl SessionConfiguration { @@ -1616,7 +1620,11 @@ impl Session { ); let use_zsh_fork_shell = config.features.enabled(Feature::ShellZshFork); - let mut default_shell = if use_zsh_fork_shell { + let mut default_shell = if let Some(user_shell_override) = + session_configuration.user_shell_override.clone() + { + user_shell_override + } else if use_zsh_fork_shell { let zsh_path = config.zsh_path.as_ref().ok_or_else(|| { anyhow::anyhow!( "zsh fork feature enabled, but `zsh_path` is not configured; set `zsh_path` in config.toml" diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 7deaad9460..4369b81dff 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -88,6 +88,7 @@ pub(crate) async fn run_codex_thread_interactive( persist_extended_history: false, metrics_service_name: None, inherited_shell_snapshot: None, + user_shell_override: None, parent_trace: None, }) .await?; diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index fab591db7d..0f45e3de66 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -1663,6 +1663,7 @@ async fn set_rate_limits_retains_previous_credits() { dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, + user_shell_override: None, }; let mut state = SessionState::new(session_configuration); @@ -1760,6 +1761,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, + user_shell_override: None, }; let mut state = SessionState::new(session_configuration); @@ -2115,6 +2117,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, + user_shell_override: None, } } @@ -2345,6 +2348,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, + user_shell_override: None, }; let (tx_event, _rx_event) = async_channel::unbounded(); @@ -2439,6 +2443,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, + user_shell_override: None, }; let per_turn_config = Session::build_per_turn_config(&session_configuration); let model_info = ModelsManager::construct_model_info_offline_for_tests( @@ -3230,6 +3235,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, + user_shell_override: None, }; let per_turn_config = Session::build_per_turn_config(&session_configuration); let model_info = ModelsManager::construct_model_info_offline_for_tests( diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 8c96407f50..20f09e759f 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -452,6 +452,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { persist_extended_history: false, metrics_service_name: None, inherited_shell_snapshot: None, + user_shell_override: None, parent_trace: None, }) .await diff --git a/codex-rs/core/src/test_support.rs b/codex-rs/core/src/test_support.rs index 12ba7cda82..c2aad83df4 100644 --- a/codex-rs/core/src/test_support.rs +++ b/codex-rs/core/src/test_support.rs @@ -64,6 +64,33 @@ pub fn thread_manager_with_models_provider_and_home( ThreadManager::with_models_provider_and_home_for_tests(auth, provider, codex_home) } +pub async fn start_thread_with_user_shell_override( + thread_manager: &ThreadManager, + config: Config, + user_shell_override: crate::shell::Shell, +) -> crate::error::Result { + thread_manager + .start_thread_with_user_shell_override_for_tests(config, user_shell_override) + .await +} + +pub async fn resume_thread_from_rollout_with_user_shell_override( + thread_manager: &ThreadManager, + config: Config, + rollout_path: PathBuf, + auth_manager: Arc, + user_shell_override: crate::shell::Shell, +) -> crate::error::Result { + thread_manager + .resume_thread_from_rollout_with_user_shell_override_for_tests( + config, + rollout_path, + auth_manager, + user_shell_override, + ) + .await +} + pub fn models_manager_with_provider( codex_home: PathBuf, auth_manager: Arc, diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 647980a6ed..58b3e30c0d 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -381,6 +381,7 @@ impl ThreadManager { persist_extended_history, metrics_service_name, parent_trace, + /*user_shell_override*/ None, )) .await } @@ -420,6 +421,48 @@ impl ThreadManager { persist_extended_history, /*metrics_service_name*/ None, parent_trace, + /*user_shell_override*/ None, + )) + .await + } + + pub(crate) async fn start_thread_with_user_shell_override_for_tests( + &self, + config: Config, + user_shell_override: crate::shell::Shell, + ) -> CodexResult { + Box::pin(self.state.spawn_thread( + config, + InitialHistory::New, + Arc::clone(&self.state.auth_manager), + self.agent_control(), + Vec::new(), + /*persist_extended_history*/ false, + /*metrics_service_name*/ None, + /*parent_trace*/ None, + /*user_shell_override*/ Some(user_shell_override), + )) + .await + } + + pub(crate) async fn resume_thread_from_rollout_with_user_shell_override_for_tests( + &self, + config: Config, + rollout_path: PathBuf, + auth_manager: Arc, + user_shell_override: crate::shell::Shell, + ) -> CodexResult { + let initial_history = RolloutRecorder::get_rollout_history(&rollout_path).await?; + Box::pin(self.state.spawn_thread( + config, + initial_history, + auth_manager, + self.agent_control(), + Vec::new(), + /*persist_extended_history*/ false, + /*metrics_service_name*/ None, + /*parent_trace*/ None, + /*user_shell_override*/ Some(user_shell_override), )) .await } @@ -505,6 +548,7 @@ impl ThreadManager { persist_extended_history, /*metrics_service_name*/ None, parent_trace, + /*user_shell_override*/ None, )) .await } @@ -590,6 +634,7 @@ impl ThreadManagerState { metrics_service_name, inherited_shell_snapshot, /*parent_trace*/ None, + /*user_shell_override*/ None, )) .await } @@ -614,6 +659,7 @@ impl ThreadManagerState { /*metrics_service_name*/ None, inherited_shell_snapshot, /*parent_trace*/ None, + /*user_shell_override*/ None, )) .await } @@ -638,6 +684,7 @@ impl ThreadManagerState { /*metrics_service_name*/ None, inherited_shell_snapshot, /*parent_trace*/ None, + /*user_shell_override*/ None, )) .await } @@ -654,6 +701,7 @@ impl ThreadManagerState { persist_extended_history: bool, metrics_service_name: Option, parent_trace: Option, + user_shell_override: Option, ) -> CodexResult { Box::pin(self.spawn_thread_with_source( config, @@ -666,6 +714,7 @@ impl ThreadManagerState { metrics_service_name, /*inherited_shell_snapshot*/ None, parent_trace, + user_shell_override, )) .await } @@ -683,6 +732,7 @@ impl ThreadManagerState { metrics_service_name: Option, inherited_shell_snapshot: Option>, parent_trace: Option, + user_shell_override: Option, ) -> CodexResult { let watch_registration = self .file_watcher @@ -704,6 +754,7 @@ impl ThreadManagerState { persist_extended_history, metrics_service_name, inherited_shell_snapshot, + user_shell_override, parent_trace, }) .await?; diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index b7d38adcad..f66855a6a3 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -13,6 +13,8 @@ use codex_core::built_in_model_providers; use codex_core::config::Config; use codex_core::features::Feature; use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig; +use codex_core::shell::Shell; +use codex_core::shell::get_shell_by_model_provided_path; use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::protocol::AskForApproval; @@ -64,6 +66,7 @@ pub struct TestCodexBuilder { auth: CodexAuth, pre_build_hooks: Vec>, home: Option>, + user_shell_override: Option, } impl TestCodexBuilder { @@ -100,6 +103,19 @@ impl TestCodexBuilder { self } + pub fn with_user_shell(mut self, user_shell: Shell) -> Self { + self.user_shell_override = Some(user_shell); + self + } + + pub fn with_windows_cmd_shell(self) -> Self { + if cfg!(windows) { + self.with_user_shell(get_shell_by_model_provided_path(&PathBuf::from("cmd.exe"))) + } else { + self + } + } + pub async fn build(&mut self, server: &wiremock::MockServer) -> anyhow::Result { let home = match self.home.clone() { Some(home) => home, @@ -199,9 +215,23 @@ impl TestCodexBuilder { ) }; let thread_manager = Arc::new(thread_manager); + let user_shell_override = self.user_shell_override.clone(); - let new_conversation = match resume_from { - Some(path) => { + let new_conversation = match (resume_from, user_shell_override) { + (Some(path), Some(user_shell_override)) => { + let auth_manager = codex_core::test_support::auth_manager_from_auth(auth); + Box::pin( + codex_core::test_support::resume_thread_from_rollout_with_user_shell_override( + thread_manager.as_ref(), + config.clone(), + path, + auth_manager, + user_shell_override, + ), + ) + .await? + } + (Some(path), None) => { let auth_manager = codex_core::test_support::auth_manager_from_auth(auth); Box::pin(thread_manager.resume_thread_from_rollout( config.clone(), @@ -211,7 +241,17 @@ impl TestCodexBuilder { )) .await? } - None => Box::pin(thread_manager.start_thread(config.clone())).await?, + (None, Some(user_shell_override)) => { + Box::pin( + codex_core::test_support::start_thread_with_user_shell_override( + thread_manager.as_ref(), + config.clone(), + user_shell_override, + ), + ) + .await? + } + (None, None) => Box::pin(thread_manager.start_thread(config.clone())).await?, }; Ok(TestCodex { @@ -562,6 +602,7 @@ pub fn test_codex() -> TestCodexBuilder { auth: CodexAuth::from_api_key("dummy"), pre_build_hooks: vec![], home: None, + user_shell_override: None, } } diff --git a/codex-rs/core/tests/suite/agent_websocket.rs b/codex-rs/core/tests/suite/agent_websocket.rs index 45752f1826..6b38ca2b45 100644 --- a/codex-rs/core/tests/suite/agent_websocket.rs +++ b/codex-rs/core/tests/suite/agent_websocket.rs @@ -35,7 +35,7 @@ async fn websocket_test_codex_shell_chain() -> Result<()> { ]]) .await; - let mut builder = test_codex(); + let mut builder = test_codex().with_windows_cmd_shell(); let test = builder.build_with_websocket_server(&server).await?; test.submit_turn_with_policy( @@ -183,7 +183,7 @@ async fn websocket_v2_test_codex_shell_chain() -> Result<()> { ]]) .await; - let mut builder = test_codex().with_config(|config| { + let mut builder = test_codex().with_windows_cmd_shell().with_config(|config| { config .features .enable(Feature::ResponsesWebsocketsV2) diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index 28fdd0b83e..f5390a4113 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -1,6 +1,8 @@ #![allow(clippy::expect_used)] use anyhow::Result; +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_test_macros::large_stack_test; use core_test_support::responses::ev_apply_patch_call; use core_test_support::responses::ev_apply_patch_custom_tool_call; @@ -740,7 +742,9 @@ async fn apply_patch_shell_command_heredoc_with_cd_updates_relative_workdir() -> async fn apply_patch_cli_can_use_shell_command_output_as_patch_input() -> Result<()> { skip_if_no_network!(Ok(())); - let harness = apply_patch_harness_with(|builder| builder.with_model("gpt-5.1")).await?; + let harness = + apply_patch_harness_with(|builder| builder.with_model("gpt-5.1").with_windows_cmd_shell()) + .await?; let source_contents = "line1\nnaïve café\nline3\n"; let source_path = harness.path("source.txt"); @@ -786,9 +790,21 @@ async fn apply_patch_cli_can_use_shell_command_output_as_patch_input() -> Result match call_num { 0 => { let command = if cfg!(windows) { - "Get-Content -Encoding utf8 source.txt" + // Encode the nested PowerShell script so `cmd.exe /c` does not leave the + // read command wrapped in quotes, and suppress progress records so the + // shell tool only returns the file contents back to apply_patch. + let script = "$ProgressPreference = 'SilentlyContinue'; [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); [System.IO.File]::ReadAllText('source.txt', [System.Text.UTF8Encoding]::new($false))"; + let encoded = BASE64_STANDARD.encode( + script + .encode_utf16() + .flat_map(u16::to_le_bytes) + .collect::>(), + ); + format!( + "powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand {encoded}" + ) } else { - "cat source.txt" + "cat source.txt".to_string() }; let args = json!({ "command": command, @@ -807,9 +823,7 @@ async fn apply_patch_cli_can_use_shell_command_output_as_patch_input() -> Result let body_json: serde_json::Value = request.body_json().expect("request body should be json"); let read_output = function_call_output_text(&body_json, &self.read_call_id); - eprintln!("read_output: \n{read_output}"); let stdout = stdout_from_shell_output(&read_output); - eprintln!("stdout: \n{stdout}"); let patch_lines = stdout .lines() .map(|line| format!("+{line}")) @@ -819,8 +833,6 @@ async fn apply_patch_cli_can_use_shell_command_output_as_patch_input() -> Result "*** Begin Patch\n*** Add File: target.txt\n{patch_lines}\n*** End Patch" ); - eprintln!("patch: \n{patch}"); - let body = sse(vec![ ev_response_created("resp-2"), ev_apply_patch_custom_tool_call(&self.apply_call_id, &patch), From 23a44ddbe8f45154a6e55280a74d28957dfefe72 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 17 Mar 2026 13:45:44 -0700 Subject: [PATCH 05/13] Stabilize permissions popup selection tests (#14966) ## What is flaky The permissions popup tests in the TUI are flaky, especially on Windows. They assume the popup opens on a specific row and that a fixed number of `Up` or `Down` keypresses will land on a specific preset. They also match popup text too loosely, so a non-selected row can satisfy the assertion. ## Why it was flaky These tests were asserting incidental rendering details rather than the actual selected permission preset. On Windows, the initial selection can differ from non-Windows runs. Some tests also searched the entire popup for text like `Guardian Approvals` or `(current)`, which can match a row that is visible but not selected. Once the popup order or current preset shifted slightly, a test could fail even though the UI behavior was still correct. ## How this PR fixes it This PR adds helpers that identify the selected popup row and selected preset name directly. The tests now assert the current selection by name, navigate to concrete target presets instead of assuming a fixed number of keypresses, and explicitly set the reviewer state in the cases that require `Guardian Approvals` to be current. ## Why this fix fixes the flakiness The assertions now track semantic state, not fragile text placement. Navigation is target-based instead of order-based, so Windows/non-Windows row differences and harmless popup layout changes no longer break the tests. That removes the scheduler- and platform-sensitive assumptions that made the popup suite intermittent. Co-authored-by: Ahmed Ibrahim <219906144+aibrahim-oai@users.noreply.github.com> Co-authored-by: Codex --- codex-rs/tui/src/chatwidget/tests.rs | 132 +++++++++++++++++++++++---- 1 file changed, 113 insertions(+), 19 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index ffc571288a..ecebc44328 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -6604,6 +6604,50 @@ fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String { lines.join("\n") } +fn selected_permissions_popup_line(popup: &str) -> &str { + popup + .lines() + .find(|line| { + line.contains('›') + && (line.contains("Default") + || line.contains("Read Only") + || line.contains("Guardian Approvals") + || line.contains("Full Access")) + }) + .unwrap_or_else(|| { + panic!("expected permissions popup to have a selected preset row: {popup}") + }) +} + +fn selected_permissions_popup_name(popup: &str) -> &'static str { + selected_permissions_popup_line(popup) + .trim_start() + .strip_prefix('›') + .map(str::trim_start) + .and_then(|line| line.split_once(". ").map(|(_, rest)| rest)) + .and_then(|line| { + ["Read Only", "Default", "Guardian Approvals", "Full Access"] + .into_iter() + .find(|label| line.starts_with(label)) + }) + .unwrap_or_else(|| { + panic!("expected permissions popup row to start with a preset label: {popup}") + }) +} + +fn move_permissions_popup_selection_to(chat: &mut ChatWidget, label: &str, direction: KeyCode) { + for _ in 0..4 { + let popup = render_bottom_popup(chat, 120); + if selected_permissions_popup_name(&popup) == label { + return; + } + chat.handle_key_event(KeyEvent::from(direction)); + } + + let popup = render_bottom_popup(chat, 120); + panic!("expected permissions popup to select {label}: {popup}"); +} + #[tokio::test] async fn apps_popup_stays_loading_until_final_snapshot_updates() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -8333,7 +8377,25 @@ async fn permissions_selection_emits_history_cell_when_selection_changes() { chat.config.notices.hide_full_access_warning = Some(true); chat.open_permissions_popup(); + let popup = render_bottom_popup(&chat, 120); + #[cfg(target_os = "windows")] + let expected_initial = "Read Only"; + #[cfg(not(target_os = "windows"))] + let expected_initial = "Default"; + assert!( + selected_permissions_popup_name(&popup) == expected_initial, + "expected permissions popup to open with {expected_initial} selected: {popup}" + ); chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + let popup = render_bottom_popup(&chat, 120); + #[cfg(target_os = "windows")] + let expected_after_one_down = "Default"; + #[cfg(not(target_os = "windows"))] + let expected_after_one_down = "Full Access"; + assert!( + selected_permissions_popup_name(&popup) == expected_after_one_down, + "expected moving down to select {expected_after_one_down} before confirmation: {popup}" + ); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); let cells = drain_insert_history(&mut rx); @@ -8360,9 +8422,21 @@ async fn permissions_selection_history_snapshot_after_mode_switch() { chat.config.notices.hide_full_access_warning = Some(true); chat.open_permissions_popup(); - chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + let popup = render_bottom_popup(&chat, 120); #[cfg(target_os = "windows")] - chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + let expected_initial = "Read Only"; + #[cfg(not(target_os = "windows"))] + let expected_initial = "Default"; + assert!( + selected_permissions_popup_name(&popup) == expected_initial, + "expected permissions popup to open with {expected_initial} selected: {popup}" + ); + move_permissions_popup_selection_to(&mut chat, "Full Access", KeyCode::Down); + let popup = render_bottom_popup(&chat, 120); + assert!( + selected_permissions_popup_name(&popup) == "Full Access", + "expected navigation to land on Full Access before confirmation: {popup}" + ); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); let cells = drain_insert_history(&mut rx); @@ -8395,10 +8469,16 @@ async fn permissions_selection_history_snapshot_full_access_to_default() { chat.open_permissions_popup(); let popup = render_bottom_popup(&chat, 120); - chat.handle_key_event(KeyEvent::from(KeyCode::Up)); - if popup.contains("Guardian Approvals") { - chat.handle_key_event(KeyEvent::from(KeyCode::Up)); - } + assert!( + selected_permissions_popup_name(&popup) == "Full Access", + "expected permissions popup to open with Full Access selected: {popup}" + ); + move_permissions_popup_selection_to(&mut chat, "Default", KeyCode::Up); + let popup = render_bottom_popup(&chat, 120); + assert!( + selected_permissions_popup_name(&popup) == "Default", + "expected navigation to land on Default before confirmation: {popup}" + ); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); let cells = drain_insert_history(&mut rx); @@ -8437,6 +8517,11 @@ async fn permissions_selection_emits_history_cell_when_current_is_selected() { .expect("set sandbox policy"); chat.open_permissions_popup(); + let popup = render_bottom_popup(&chat, 120); + assert!( + selected_permissions_popup_name(&popup) == "Default", + "expected permissions popup to open with Default selected: {popup}" + ); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); let cells = drain_insert_history(&mut rx); @@ -8542,7 +8627,8 @@ async fn permissions_selection_marks_guardian_approvals_current_after_session_co let popup = render_bottom_popup(&chat, 120); assert!( - popup.contains("Guardian Approvals (current)"), + selected_permissions_popup_name(&popup) == "Guardian Approvals" + && selected_permissions_popup_line(&popup).contains("(current)"), "expected Guardian Approvals to be current after SessionConfigured sync: {popup}" ); } @@ -8597,7 +8683,8 @@ async fn permissions_selection_marks_guardian_approvals_current_with_custom_work let popup = render_bottom_popup(&chat, 120); assert!( - popup.contains("Guardian Approvals (current)"), + selected_permissions_popup_name(&popup) == "Guardian Approvals" + && selected_permissions_popup_line(&popup).contains("(current)"), "expected Guardian Approvals to be current even with custom workspace-write details: {popup}" ); } @@ -8622,9 +8709,22 @@ async fn permissions_selection_can_disable_guardian_approvals() { .sandbox_policy .set(SandboxPolicy::new_workspace_write_policy()) .expect("set sandbox policy"); + chat.set_approvals_reviewer(ApprovalsReviewer::GuardianSubagent); chat.open_permissions_popup(); - chat.handle_key_event(KeyEvent::from(KeyCode::Up)); + let popup = render_bottom_popup(&chat, 120); + assert!( + selected_permissions_popup_name(&popup) == "Guardian Approvals" + && selected_permissions_popup_line(&popup).contains("(current)"), + "expected permissions popup to open with Guardian Approvals selected: {popup}" + ); + + move_permissions_popup_selection_to(&mut chat, "Default", KeyCode::Up); + let popup = render_bottom_popup(&chat, 120); + assert!( + selected_permissions_popup_name(&popup) == "Default", + "expected one Up from Guardian Approvals to select Default: {popup}" + ); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); @@ -8668,18 +8768,14 @@ async fn permissions_selection_sends_approvals_reviewer_in_override_turn_context chat.open_permissions_popup(); let popup = render_bottom_popup(&chat, 120); assert!( - popup - .lines() - .any(|line| line.contains("(current)") && line.contains('›')), + selected_permissions_popup_line(&popup).contains("(current)"), "expected permissions popup to open with the current preset selected: {popup}" ); - chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + move_permissions_popup_selection_to(&mut chat, "Guardian Approvals", KeyCode::Down); let popup = render_bottom_popup(&chat, 120); assert!( - popup - .lines() - .any(|line| line.contains("Guardian Approvals") && line.contains('›')), + selected_permissions_popup_name(&popup) == "Guardian Approvals", "expected one Down from Default to select Guardian Approvals: {popup}" ); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); @@ -8720,9 +8816,7 @@ async fn permissions_full_access_history_cell_emitted_only_after_confirmation() chat.config.notices.hide_full_access_warning = None; chat.open_permissions_popup(); - chat.handle_key_event(KeyEvent::from(KeyCode::Down)); - #[cfg(target_os = "windows")] - chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + move_permissions_popup_selection_to(&mut chat, "Full Access", KeyCode::Down); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); let mut open_confirmation_event = None; From 4d9d4b7b0f2b8cfbe4ab18e31a7bd80465a975e4 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 17 Mar 2026 13:52:36 -0700 Subject: [PATCH 06/13] Stabilize approval matrix write-file command (#14968) ## What is flaky The approval-matrix `WriteFile` scenario is flaky. It sometimes fails in CI even though the approval logic is unchanged, because the test delegates the file write and readback to shell parsing instead of deterministic file I/O. ## Why it was flaky The test generated a command shaped like `printf ... > file && cat file`. That means the scenario depended on shell quoting, redirection, newline handling, and encoding behavior in addition to the approval system it was actually trying to validate. If the shell interpreted the payload differently, the test would report an approval failure even though the product logic was fine. That also made failures hard to diagnose, because the test did not log the exact generated command or the parsed result payload. ## How this PR fixes it This PR replaces the shell-redirection path with a deterministic `python3 -c` script that writes the file with `Path.write_text(..., encoding='utf-8')` and then reads it back with the same UTF-8 path. It also logs the generated command and the resulting exit code/stdout for the approval scenario so any future failure is directly attributable. ## Why this fix fixes the flakiness The scenario no longer depends on shell parsing and redirection semantics. The file contents are produced and read through explicit UTF-8 file I/O, so the approval test is measuring approval behavior instead of shell behavior. The added diagnostics mean a future failure will show the exact command/result pair instead of looking like a generic intermittent mismatch. Co-authored-by: Ahmed Ibrahim <219906144+aibrahim-oai@users.noreply.github.com> Co-authored-by: Codex --- codex-rs/core/tests/suite/approvals.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 7a9dba038e..53978c1766 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -122,7 +122,11 @@ impl ActionKind { ActionKind::WriteFile { target, content } => { let (path, _) = target.resolve_for_patch(test); let _ = fs::remove_file(&path); - let command = format!("printf {content:?} > {path:?} && cat {path:?}"); + let path_str = path.display().to_string(); + let script = format!( + "from pathlib import Path; path = Path({path_str:?}); content = {content:?}; path.write_text(content, encoding='utf-8'); print(path.read_text(encoding='utf-8'), end='')", + ); + let command = format!("python3 -c {script:?}"); let event = shell_event(call_id, &command, 5_000, sandbox_permissions)?; Ok((event, Some(command))) } @@ -1611,6 +1615,9 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> { .action .prepare(&test, &server, call_id, scenario.sandbox_permissions) .await?; + if let Some(command) = expected_command.as_deref() { + eprintln!("approval scenario {} command: {command}", scenario.name); + } let _ = mount_sse_once( &server, @@ -1692,6 +1699,10 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> { let output_item = results_mock.single_request().function_call_output(call_id); let result = parse_result(&output_item); + eprintln!( + "approval scenario {} result: exit_code={:?} stdout={:?}", + scenario.name, result.exit_code, result.stdout + ); scenario.expectation.verify(&test, &result)?; Ok(()) From 2cc4ee413f8d86c38a5a46887d2fd5a18d40efbe Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Tue, 17 Mar 2026 14:09:57 -0700 Subject: [PATCH 07/13] temporarily disable private desktop until it works with elevated IPC path (#14986) --- codex-rs/windows-sandbox-rs/src/elevated/command_runner_win.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/windows-sandbox-rs/src/elevated/command_runner_win.rs b/codex-rs/windows-sandbox-rs/src/elevated/command_runner_win.rs index bbc4de6b36..93956fb41d 100644 --- a/codex-rs/windows-sandbox-rs/src/elevated/command_runner_win.rs +++ b/codex-rs/windows-sandbox-rs/src/elevated/command_runner_win.rs @@ -294,7 +294,7 @@ fn spawn_ipc_process( &req.env, stdin_mode, StderrMode::Separate, - req.use_private_desktop, + false, )?; ( pipe_handles.process, From ee756eb80f94fe018c7a07306c0e43e1a42bcfa6 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Tue, 17 Mar 2026 14:22:26 -0700 Subject: [PATCH 08/13] Rename exec_wait tool to wait (#14983) Summary - document that code mode only exposes `exec` and the renamed `wait` tool - update code mode tool spec and descriptions to match the new tool name - rename tests and helper references from `exec_wait` to `wait` Testing - Not run (not requested) --- codex-rs/core/src/features.rs | 2 +- codex-rs/core/src/tools/code_mode/mod.rs | 5 +-- .../src/tools/code_mode/wait_description.md | 10 +++--- codex-rs/core/src/tools/spec.rs | 4 +-- codex-rs/core/src/tools/spec_tests.rs | 6 ++-- codex-rs/core/tests/suite/code_mode.rs | 34 +++++++++---------- 6 files changed, 31 insertions(+), 30 deletions(-) diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index eb78e54d55..d6b3ae101f 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -87,7 +87,7 @@ pub enum Feature { JsRepl, /// Enable a minimal JavaScript mode backed by Node's built-in vm runtime. CodeMode, - /// Restrict model-visible tools to code mode entrypoints (`exec`, `exec_wait`). + /// Restrict model-visible tools to code mode entrypoints (`exec`, `wait`). CodeModeOnly, /// Only expose js_repl tools directly to the model. JsReplToolsOnly, diff --git a/codex-rs/core/src/tools/code_mode/mod.rs b/codex-rs/core/src/tools/code_mode/mod.rs index d8a7488a98..5a0be3ccfe 100644 --- a/codex-rs/core/src/tools/code_mode/mod.rs +++ b/codex-rs/core/src/tools/code_mode/mod.rs @@ -34,10 +34,11 @@ const CODE_MODE_BRIDGE_SOURCE: &str = include_str!("bridge.js"); const CODE_MODE_DESCRIPTION_TEMPLATE: &str = include_str!("description.md"); const CODE_MODE_WAIT_DESCRIPTION_TEMPLATE: &str = include_str!("wait_description.md"); const CODE_MODE_PRAGMA_PREFIX: &str = "// @exec:"; -const CODE_MODE_ONLY_PREFACE: &str = "Use `exec/exec_wait` tool to run all other tools, do not attempt to use any other tools directly"; +const CODE_MODE_ONLY_PREFACE: &str = + "Use `exec/wait` tool to run all other tools, do not attempt to use any other tools directly"; pub(crate) const PUBLIC_TOOL_NAME: &str = "exec"; -pub(crate) const WAIT_TOOL_NAME: &str = "exec_wait"; +pub(crate) const WAIT_TOOL_NAME: &str = "wait"; pub(crate) fn is_code_mode_nested_tool(tool_name: &str) -> bool { tool_name != PUBLIC_TOOL_NAME && tool_name != WAIT_TOOL_NAME diff --git a/codex-rs/core/src/tools/code_mode/wait_description.md b/codex-rs/core/src/tools/code_mode/wait_description.md index 5780b007b0..41b928f514 100644 --- a/codex-rs/core/src/tools/code_mode/wait_description.md +++ b/codex-rs/core/src/tools/code_mode/wait_description.md @@ -1,8 +1,8 @@ -- Use `exec_wait` only after `exec` returns `Script running with cell ID ...`. +- Use `wait` only after `exec` returns `Script running with cell ID ...`. - `cell_id` identifies the running `exec` cell to resume. -- `yield_time_ms` controls how long to wait for more output before yielding again. If omitted, `exec_wait` uses its default wait timeout. +- `yield_time_ms` controls how long to wait for more output before yielding again. If omitted, `wait` uses its default wait timeout. - `max_tokens` limits how much new output this wait call returns. - `terminate: true` stops the running cell instead of waiting for more output. -- `exec_wait` returns only the new output since the last yield, or the final completion or termination result for that cell. -- If the cell is still running, `exec_wait` may yield again with the same `cell_id`. -- If the cell has already finished, `exec_wait` returns the completed result and closes the cell. +- `wait` returns only the new output since the last yield, or the final completion or termination result for that cell. +- If the cell is still running, `wait` may yield again with the same `cell_id`. +- If the cell has already finished, `wait` returns the completed result and closes the cell. diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index a323a91452..fa75c26d50 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -772,7 +772,7 @@ fn create_write_stdin_tool() -> ToolSpec { }) } -fn create_exec_wait_tool() -> ToolSpec { +fn create_wait_tool() -> ToolSpec { let properties = BTreeMap::from([ ( "cell_id".to_string(), @@ -2597,7 +2597,7 @@ pub(crate) fn build_specs_with_discoverable_tools( builder.register_handler(PUBLIC_TOOL_NAME, code_mode_handler); push_tool_spec( &mut builder, - create_exec_wait_tool(), + create_wait_tool(), /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, ); diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index b58f2ae538..37c85e30bb 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -2693,7 +2693,7 @@ fn code_mode_only_restricts_model_tools_to_exec_tools() { "gpt-5.1-codex", &features, Some(WebSearchMode::Live), - &["exec", "exec_wait"], + &["exec", "wait"], ); } @@ -2724,7 +2724,7 @@ fn code_mode_only_exec_description_includes_full_nested_tool_details() { assert!(!description.contains("Enabled nested tools:")); assert!(!description.contains("Nested tool reference:")); assert!(description.starts_with( - "Use `exec/exec_wait` tool to run all other tools, do not attempt to use any other tools directly" + "Use `exec/wait` tool to run all other tools, do not attempt to use any other tools directly" )); assert!(description.contains("### `update_plan` (`update_plan`)")); assert!(description.contains("### `view_image` (`view_image`)")); @@ -2754,7 +2754,7 @@ fn code_mode_exec_description_omits_nested_tool_details_when_not_code_mode_only( }; assert!(!description.starts_with( - "Use `exec/exec_wait` tool to run all other tools, do not attempt to use any other tools directly" + "Use `exec/wait` tool to run all other tools, do not attempt to use any other tools directly" )); assert!(!description.contains("### `update_plan` (`update_plan`)")); assert!(!description.contains("### `view_image` (`view_image`)")); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index e4f1f1a497..6c5995f5c5 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -305,7 +305,7 @@ async fn code_mode_only_restricts_prompt_tools() -> Result<()> { let first_body = resp_mock.single_request().body_json(); assert_eq!( tool_names(&first_body), - vec!["exec".to_string(), "exec_wait".to_string()] + vec!["exec".to_string(), "wait".to_string()] ); Ok(()) @@ -539,7 +539,7 @@ Error:\ boom\n #[cfg_attr(windows, ignore = "no exec_command on Windows")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn code_mode_can_yield_and_resume_with_exec_wait() -> Result<()> { +async fn code_mode_can_yield_and_resume_with_wait() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; @@ -602,7 +602,7 @@ text("phase 3"); ev_response_created("resp-3"), responses::ev_function_call( "call-2", - "exec_wait", + "wait", &serde_json::to_string(&serde_json::json!({ "cell_id": cell_id.clone(), "yield_time_ms": 1_000, @@ -646,7 +646,7 @@ text("phase 3"); ev_response_created("resp-5"), responses::ev_function_call( "call-3", - "exec_wait", + "wait", &serde_json::to_string(&serde_json::json!({ "cell_id": cell_id.clone(), "yield_time_ms": 1_000, @@ -742,7 +742,7 @@ while (true) {} ev_response_created("resp-3"), responses::ev_function_call( "call-2", - "exec_wait", + "wait", &serde_json::to_string(&serde_json::json!({ "cell_id": cell_id.clone(), "terminate": true, @@ -869,7 +869,7 @@ text("session b done"); ev_response_created("resp-5"), responses::ev_function_call( "call-3", - "exec_wait", + "wait", &serde_json::to_string(&serde_json::json!({ "cell_id": session_a_id.clone(), "yield_time_ms": 1_000, @@ -909,7 +909,7 @@ text("session b done"); ev_response_created("resp-7"), responses::ev_function_call( "call-4", - "exec_wait", + "wait", &serde_json::to_string(&serde_json::json!({ "cell_id": session_b_id.clone(), "yield_time_ms": 1_000, @@ -947,7 +947,7 @@ text("session b done"); #[cfg_attr(windows, ignore = "no exec_command on Windows")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn code_mode_exec_wait_can_terminate_and_continue() -> Result<()> { +async fn code_mode_wait_can_terminate_and_continue() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; @@ -999,7 +999,7 @@ text("phase 2"); ev_response_created("resp-3"), responses::ev_function_call( "call-2", - "exec_wait", + "wait", &serde_json::to_string(&serde_json::json!({ "cell_id": cell_id.clone(), "terminate": true, @@ -1073,7 +1073,7 @@ text("after terminate"); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn code_mode_exec_wait_returns_error_for_unknown_session() -> Result<()> { +async fn code_mode_wait_returns_error_for_unknown_session() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; @@ -1088,7 +1088,7 @@ async fn code_mode_exec_wait_returns_error_for_unknown_session() -> Result<()> { ev_response_created("resp-1"), responses::ev_function_call( "call-1", - "exec_wait", + "wait", &serde_json::to_string(&serde_json::json!({ "cell_id": "999999", "yield_time_ms": 1_000, @@ -1134,7 +1134,7 @@ async fn code_mode_exec_wait_returns_error_for_unknown_session() -> Result<()> { #[cfg_attr(windows, ignore = "no exec_command on Windows")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn code_mode_exec_wait_terminate_returns_completed_session_if_it_finished_after_yield_control() +async fn code_mode_wait_terminate_returns_completed_session_if_it_finished_after_yield_control() -> Result<()> { skip_if_no_network!(Ok(())); @@ -1229,7 +1229,7 @@ text("session b done"); ev_response_created("resp-5"), responses::ev_function_call( "call-3", - "exec_wait", + "wait", &serde_json::to_string(&serde_json::json!({ "cell_id": session_b_id.clone(), "yield_time_ms": 1_000, @@ -1279,7 +1279,7 @@ text("session b done"); ev_response_created("resp-7"), responses::ev_function_call( "call-4", - "exec_wait", + "wait", &serde_json::to_string(&serde_json::json!({ "cell_id": session_a_id.clone(), "terminate": true, @@ -1330,7 +1330,7 @@ text("session b done"); #[cfg_attr(windows, ignore = "no exec_command on Windows")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn code_mode_background_keeps_running_on_later_turn_without_exec_wait() -> Result<()> { +async fn code_mode_background_keeps_running_on_later_turn_without_wait() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; @@ -1423,7 +1423,7 @@ text("after yield"); #[cfg_attr(windows, ignore = "no exec_command on Windows")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn code_mode_exec_wait_uses_its_own_max_tokens_budget() -> Result<()> { +async fn code_mode_wait_uses_its_own_max_tokens_budget() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; @@ -1476,7 +1476,7 @@ text("token one token two token three token four token five token six token seve ev_response_created("resp-3"), responses::ev_function_call( "call-2", - "exec_wait", + "wait", &serde_json::to_string(&serde_json::json!({ "cell_id": cell_id.clone(), "yield_time_ms": 1_000, From 0d2ff40a58dde63e5aa8be85b5a5f19f384c354c Mon Sep 17 00:00:00 2001 From: Colin Young Date: Tue, 17 Mar 2026 14:26:27 -0700 Subject: [PATCH 09/13] Add auth env observability (#14905) CXC-410 Emit Env Var Status with `/feedback` report Add more observability on top of #14611 [Unset](https://openai.sentry.io/issues/7340419168/?project=4510195390611458&query=019cfa8d-c1ba-7002-96fa-e35fc340551d&referrer=issue-stream) [Set](https://openai.sentry.io/issues/7340426331/?project=4510195390611458&query=019cfa91-aba1-7823-ab7e-762edfbc0ed4&referrer=issue-stream) image ###### Summary - Adds auth-env telemetry that records whether key auth-related env overrides were present on session start and request paths. - Threads those auth-env fields through `/responses`, websocket, and `/models` telemetry and feedback metadata. - Buckets custom provider `env_key` configuration to a safe `"configured"` value instead of emitting raw config text. - Keeps the slice observability-only: no raw token values or raw URLs are emitted. ###### Rationale (from spec findings) - 401 and auth-path debugging needs a way to distinguish env-driven auth paths from sessions with no auth env override. - Startup and model-refresh failures need the same auth-env diagnostics as normal request failures. - Feedback and Sentry tags need the same auth-env signal as OTel events so reports can be triaged consistently. - Custom provider config is user-controlled text, so the telemetry contract must stay presence-only / bucketed. ###### Scope - Adds a small `AuthEnvTelemetry` bundle for env presence collection and threads it through the main request/session telemetry paths. - Does not add endpoint/base-url/provider-header/geo routing attribution or broader telemetry API redesign. ###### Trade-offs - `provider_env_key_name` is bucketed to `"configured"` instead of preserving the literal configured env var name. - `/models` is included because startup/model-refresh auth failures need the same diagnostics, but broader parity work remains out of scope. - This slice keeps the existing telemetry APIs and layers auth-env fields onto them rather than redesigning the metadata model. ###### Client follow-up - Add the separate endpoint/base-url attribution slice if routing-source diagnosis is still needed. - Add provider-header or residency attribution only if auth-env presence proves insufficient in real reports. - Revisit whether any additional auth-related env inputs need safe bucketing after more 401 triage data. ###### Testing - `cargo test -p codex-core emit_feedback_request_tags -- --nocapture` - `cargo test -p codex-core collect_auth_env_telemetry_buckets_provider_env_key_name -- --nocapture` - `cargo test -p codex-core models_request_telemetry_emits_auth_env_feedback_tags_on_failure -- --nocapture` - `cargo test -p codex-otel otel_export_routing_policy_routes_api_request_auth_observability -- --nocapture` - `cargo test -p codex-otel otel_export_routing_policy_routes_websocket_connect_auth_observability -- --nocapture` - `cargo test -p codex-otel otel_export_routing_policy_routes_websocket_request_transport_observability -- --nocapture` - `cargo test -p codex-core --no-run --message-format short` - `cargo test -p codex-otel --no-run --message-format short` --------- Co-authored-by: Codex --- codex-rs/core/src/auth.rs | 4 + codex-rs/core/src/auth_env_telemetry.rs | 85 ++++++++ codex-rs/core/src/client.rs | 166 +++++++++------- codex-rs/core/src/codex.rs | 8 +- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/models_manager/manager.rs | 57 ++++-- .../core/src/models_manager/manager_tests.rs | 154 +++++++++++++++ codex-rs/core/src/util.rs | 128 ++++++++---- codex-rs/core/src/util_tests.rs | 186 ++++++++++++++---- codex-rs/otel/src/events/session_telemetry.rs | 41 ++++ codex-rs/otel/src/lib.rs | 1 + .../tests/suite/otel_export_routing_policy.rs | 100 +++++++++- 12 files changed, 770 insertions(+), 161 deletions(-) create mode 100644 codex-rs/core/src/auth_env_telemetry.rs diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index bafc3179da..90f0dcfdaf 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -1250,6 +1250,10 @@ impl AuthManager { .is_some_and(CodexAuth::is_external_chatgpt_tokens) } + pub fn codex_api_key_env_enabled(&self) -> bool { + self.enable_codex_api_key_env + } + /// Convenience constructor returning an `Arc` wrapper. pub fn shared( codex_home: PathBuf, diff --git a/codex-rs/core/src/auth_env_telemetry.rs b/codex-rs/core/src/auth_env_telemetry.rs new file mode 100644 index 0000000000..be281e05a1 --- /dev/null +++ b/codex-rs/core/src/auth_env_telemetry.rs @@ -0,0 +1,85 @@ +use codex_otel::AuthEnvTelemetryMetadata; + +use crate::auth::CODEX_API_KEY_ENV_VAR; +use crate::auth::OPENAI_API_KEY_ENV_VAR; +use crate::auth::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; +use crate::model_provider_info::ModelProviderInfo; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct AuthEnvTelemetry { + pub(crate) openai_api_key_env_present: bool, + pub(crate) codex_api_key_env_present: bool, + pub(crate) codex_api_key_env_enabled: bool, + pub(crate) provider_env_key_name: Option, + pub(crate) provider_env_key_present: Option, + pub(crate) refresh_token_url_override_present: bool, +} + +impl AuthEnvTelemetry { + pub(crate) fn to_otel_metadata(&self) -> AuthEnvTelemetryMetadata { + AuthEnvTelemetryMetadata { + openai_api_key_env_present: self.openai_api_key_env_present, + codex_api_key_env_present: self.codex_api_key_env_present, + codex_api_key_env_enabled: self.codex_api_key_env_enabled, + provider_env_key_name: self.provider_env_key_name.clone(), + provider_env_key_present: self.provider_env_key_present, + refresh_token_url_override_present: self.refresh_token_url_override_present, + } + } +} + +pub(crate) fn collect_auth_env_telemetry( + provider: &ModelProviderInfo, + codex_api_key_env_enabled: bool, +) -> AuthEnvTelemetry { + AuthEnvTelemetry { + openai_api_key_env_present: env_var_present(OPENAI_API_KEY_ENV_VAR), + codex_api_key_env_present: env_var_present(CODEX_API_KEY_ENV_VAR), + codex_api_key_env_enabled, + // Custom provider `env_key` is arbitrary config text, so emit only a safe bucket. + provider_env_key_name: provider.env_key.as_ref().map(|_| "configured".to_string()), + provider_env_key_present: provider.env_key.as_deref().map(env_var_present), + refresh_token_url_override_present: env_var_present(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR), + } +} + +fn env_var_present(name: &str) -> bool { + match std::env::var(name) { + Ok(value) => !value.trim().is_empty(), + Err(std::env::VarError::NotUnicode(_)) => true, + Err(std::env::VarError::NotPresent) => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn collect_auth_env_telemetry_buckets_provider_env_key_name() { + let provider = ModelProviderInfo { + name: "Custom".to_string(), + base_url: None, + env_key: Some("sk-should-not-leak".to_string()), + env_key_instructions: None, + experimental_bearer_token: None, + wire_api: crate::model_provider_info::WireApi::Responses, + query_params: None, + http_headers: None, + env_http_headers: None, + request_max_retries: None, + stream_max_retries: None, + stream_idle_timeout_ms: None, + requires_openai_auth: false, + supports_websockets: false, + }; + + let telemetry = collect_auth_env_telemetry(&provider, false); + + assert_eq!( + telemetry.provider_env_key_name, + Some("configured".to_string()) + ); + } +} diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 79fec5dfa1..48e8d90ecf 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -34,6 +34,8 @@ use crate::api_bridge::CoreAuthProvider; use crate::api_bridge::auth_provider_from_auth; use crate::api_bridge::map_api_error; use crate::auth::UnauthorizedRecovery; +use crate::auth_env_telemetry::AuthEnvTelemetry; +use crate::auth_env_telemetry::collect_auth_env_telemetry; use codex_api::CompactClient as ApiCompactClient; use codex_api::CompactionInput as ApiCompactionInput; use codex_api::MemoriesClient as ApiMemoriesClient; @@ -106,7 +108,7 @@ use crate::response_debug_context::telemetry_transport_error_message; use crate::tools::spec::create_tools_json_for_responses_api; use crate::util::FeedbackRequestTags; use crate::util::emit_feedback_auth_recovery_tags; -use crate::util::emit_feedback_request_tags; +use crate::util::emit_feedback_request_tags_with_auth_env; pub const OPENAI_BETA_HEADER: &str = "OpenAI-Beta"; pub const X_CODEX_TURN_STATE_HEADER: &str = "x-codex-turn-state"; @@ -138,6 +140,7 @@ struct ModelClientState { auth_manager: Option>, conversation_id: ThreadId, provider: ModelProviderInfo, + auth_env_telemetry: AuthEnvTelemetry, session_source: SessionSource, model_verbosity: Option, responses_websockets_enabled_by_feature: bool, @@ -267,11 +270,16 @@ impl ModelClient { include_timing_metrics: bool, beta_features_header: Option, ) -> Self { + let codex_api_key_env_enabled = auth_manager + .as_ref() + .is_some_and(|manager| manager.codex_api_key_env_enabled()); + let auth_env_telemetry = collect_auth_env_telemetry(&provider, codex_api_key_env_enabled); Self { state: Arc::new(ModelClientState { auth_manager, conversation_id, provider, + auth_env_telemetry, session_source, model_verbosity, responses_websockets_enabled_by_feature, @@ -362,6 +370,7 @@ impl ModelClient { PendingUnauthorizedRetry::default(), ), RequestRouteTelemetry::for_endpoint(RESPONSES_COMPACT_ENDPOINT), + self.state.auth_env_telemetry.clone(), ); let client = ApiCompactClient::new(transport, client_setup.api_provider, client_setup.api_auth) @@ -430,6 +439,7 @@ impl ModelClient { PendingUnauthorizedRetry::default(), ), RequestRouteTelemetry::for_endpoint(MEMORIES_SUMMARIZE_ENDPOINT), + self.state.auth_env_telemetry.clone(), ); let client = ApiMemoriesClient::new(transport, client_setup.api_provider, client_setup.api_auth) @@ -474,11 +484,13 @@ impl ModelClient { session_telemetry: &SessionTelemetry, auth_context: AuthRequestTelemetryContext, request_route_telemetry: RequestRouteTelemetry, + auth_env_telemetry: AuthEnvTelemetry, ) -> Arc { let telemetry = Arc::new(ApiTelemetry::new( session_telemetry.clone(), auth_context, request_route_telemetry, + auth_env_telemetry, )); let request_telemetry: Arc = telemetry; request_telemetry @@ -561,6 +573,7 @@ impl ModelClient { session_telemetry, auth_context, request_route_telemetry, + self.state.auth_env_telemetry.clone(), ); let websocket_connect_timeout = self.state.provider.websocket_connect_timeout(); let start = Instant::now(); @@ -601,27 +614,30 @@ impl ModelClient { response_debug.auth_error.as_deref(), response_debug.auth_error_code.as_deref(), ); - emit_feedback_request_tags(&FeedbackRequestTags { - endpoint: request_route_telemetry.endpoint, - auth_header_attached: auth_context.auth_header_attached, - auth_header_name: auth_context.auth_header_name, - auth_mode: auth_context.auth_mode, - auth_retry_after_unauthorized: Some(auth_context.retry_after_unauthorized), - auth_recovery_mode: auth_context.recovery_mode, - auth_recovery_phase: auth_context.recovery_phase, - auth_connection_reused: Some(false), - auth_request_id: response_debug.request_id.as_deref(), - auth_cf_ray: response_debug.cf_ray.as_deref(), - auth_error: response_debug.auth_error.as_deref(), - auth_error_code: response_debug.auth_error_code.as_deref(), - auth_recovery_followup_success: auth_context - .retry_after_unauthorized - .then_some(result.is_ok()), - auth_recovery_followup_status: auth_context - .retry_after_unauthorized - .then_some(status) - .flatten(), - }); + emit_feedback_request_tags_with_auth_env( + &FeedbackRequestTags { + endpoint: request_route_telemetry.endpoint, + auth_header_attached: auth_context.auth_header_attached, + auth_header_name: auth_context.auth_header_name, + auth_mode: auth_context.auth_mode, + auth_retry_after_unauthorized: Some(auth_context.retry_after_unauthorized), + auth_recovery_mode: auth_context.recovery_mode, + auth_recovery_phase: auth_context.recovery_phase, + auth_connection_reused: Some(false), + auth_request_id: response_debug.request_id.as_deref(), + auth_cf_ray: response_debug.cf_ray.as_deref(), + auth_error: response_debug.auth_error.as_deref(), + auth_error_code: response_debug.auth_error_code.as_deref(), + auth_recovery_followup_success: auth_context + .retry_after_unauthorized + .then_some(result.is_ok()), + auth_recovery_followup_status: auth_context + .retry_after_unauthorized + .then_some(status) + .flatten(), + }, + &self.state.auth_env_telemetry, + ); result } @@ -1030,6 +1046,7 @@ impl ModelClientSession { session_telemetry, request_auth_context, RequestRouteTelemetry::for_endpoint(RESPONSES_ENDPOINT), + self.client.state.auth_env_telemetry.clone(), ); let compression = self.responses_request_compression(client_setup.auth.as_ref()); let options = self.build_responses_options(turn_metadata_header, compression); @@ -1190,11 +1207,13 @@ impl ModelClientSession { session_telemetry: &SessionTelemetry, auth_context: AuthRequestTelemetryContext, request_route_telemetry: RequestRouteTelemetry, + auth_env_telemetry: AuthEnvTelemetry, ) -> (Arc, Arc) { let telemetry = Arc::new(ApiTelemetry::new( session_telemetry.clone(), auth_context, request_route_telemetry, + auth_env_telemetry, )); let request_telemetry: Arc = telemetry.clone(); let sse_telemetry: Arc = telemetry; @@ -1206,11 +1225,13 @@ impl ModelClientSession { session_telemetry: &SessionTelemetry, auth_context: AuthRequestTelemetryContext, request_route_telemetry: RequestRouteTelemetry, + auth_env_telemetry: AuthEnvTelemetry, ) -> Arc { let telemetry = Arc::new(ApiTelemetry::new( session_telemetry.clone(), auth_context, request_route_telemetry, + auth_env_telemetry, )); let websocket_telemetry: Arc = telemetry; websocket_telemetry @@ -1663,6 +1684,7 @@ struct ApiTelemetry { session_telemetry: SessionTelemetry, auth_context: AuthRequestTelemetryContext, request_route_telemetry: RequestRouteTelemetry, + auth_env_telemetry: AuthEnvTelemetry, } impl ApiTelemetry { @@ -1670,11 +1692,13 @@ impl ApiTelemetry { session_telemetry: SessionTelemetry, auth_context: AuthRequestTelemetryContext, request_route_telemetry: RequestRouteTelemetry, + auth_env_telemetry: AuthEnvTelemetry, ) -> Self { Self { session_telemetry, auth_context, request_route_telemetry, + auth_env_telemetry, } } } @@ -1708,29 +1732,32 @@ impl RequestTelemetry for ApiTelemetry { debug.auth_error.as_deref(), debug.auth_error_code.as_deref(), ); - emit_feedback_request_tags(&FeedbackRequestTags { - endpoint: self.request_route_telemetry.endpoint, - auth_header_attached: self.auth_context.auth_header_attached, - auth_header_name: self.auth_context.auth_header_name, - auth_mode: self.auth_context.auth_mode, - auth_retry_after_unauthorized: Some(self.auth_context.retry_after_unauthorized), - auth_recovery_mode: self.auth_context.recovery_mode, - auth_recovery_phase: self.auth_context.recovery_phase, - auth_connection_reused: None, - auth_request_id: debug.request_id.as_deref(), - auth_cf_ray: debug.cf_ray.as_deref(), - auth_error: debug.auth_error.as_deref(), - auth_error_code: debug.auth_error_code.as_deref(), - auth_recovery_followup_success: self - .auth_context - .retry_after_unauthorized - .then_some(error.is_none()), - auth_recovery_followup_status: self - .auth_context - .retry_after_unauthorized - .then_some(status) - .flatten(), - }); + emit_feedback_request_tags_with_auth_env( + &FeedbackRequestTags { + endpoint: self.request_route_telemetry.endpoint, + auth_header_attached: self.auth_context.auth_header_attached, + auth_header_name: self.auth_context.auth_header_name, + auth_mode: self.auth_context.auth_mode, + auth_retry_after_unauthorized: Some(self.auth_context.retry_after_unauthorized), + auth_recovery_mode: self.auth_context.recovery_mode, + auth_recovery_phase: self.auth_context.recovery_phase, + auth_connection_reused: None, + auth_request_id: debug.request_id.as_deref(), + auth_cf_ray: debug.cf_ray.as_deref(), + auth_error: debug.auth_error.as_deref(), + auth_error_code: debug.auth_error_code.as_deref(), + auth_recovery_followup_success: self + .auth_context + .retry_after_unauthorized + .then_some(error.is_none()), + auth_recovery_followup_status: self + .auth_context + .retry_after_unauthorized + .then_some(status) + .flatten(), + }, + &self.auth_env_telemetry, + ); } } @@ -1759,29 +1786,32 @@ impl WebsocketTelemetry for ApiTelemetry { error_message.as_deref(), connection_reused, ); - emit_feedback_request_tags(&FeedbackRequestTags { - endpoint: self.request_route_telemetry.endpoint, - auth_header_attached: self.auth_context.auth_header_attached, - auth_header_name: self.auth_context.auth_header_name, - auth_mode: self.auth_context.auth_mode, - auth_retry_after_unauthorized: Some(self.auth_context.retry_after_unauthorized), - auth_recovery_mode: self.auth_context.recovery_mode, - auth_recovery_phase: self.auth_context.recovery_phase, - auth_connection_reused: Some(connection_reused), - auth_request_id: debug.request_id.as_deref(), - auth_cf_ray: debug.cf_ray.as_deref(), - auth_error: debug.auth_error.as_deref(), - auth_error_code: debug.auth_error_code.as_deref(), - auth_recovery_followup_success: self - .auth_context - .retry_after_unauthorized - .then_some(error.is_none()), - auth_recovery_followup_status: self - .auth_context - .retry_after_unauthorized - .then_some(status) - .flatten(), - }); + emit_feedback_request_tags_with_auth_env( + &FeedbackRequestTags { + endpoint: self.request_route_telemetry.endpoint, + auth_header_attached: self.auth_context.auth_header_attached, + auth_header_name: self.auth_context.auth_header_name, + auth_mode: self.auth_context.auth_mode, + auth_retry_after_unauthorized: Some(self.auth_context.retry_after_unauthorized), + auth_recovery_mode: self.auth_context.recovery_mode, + auth_recovery_phase: self.auth_context.recovery_phase, + auth_connection_reused: Some(connection_reused), + auth_request_id: debug.request_id.as_deref(), + auth_cf_ray: debug.cf_ray.as_deref(), + auth_error: debug.auth_error.as_deref(), + auth_error_code: debug.auth_error_code.as_deref(), + auth_recovery_followup_success: self + .auth_context + .retry_after_unauthorized + .then_some(error.is_none()), + auth_recovery_followup_status: self + .auth_context + .retry_after_unauthorized + .then_some(status) + .flatten(), + }, + &self.auth_env_telemetry, + ); } fn on_ws_event( diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1b4a23fe1f..9382401bac 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -17,6 +17,7 @@ use crate::analytics_client::AppInvocation; use crate::analytics_client::InvocationType; use crate::analytics_client::build_track_events_context; use crate::apps::render_apps_section; +use crate::auth_env_telemetry::collect_auth_env_telemetry; use crate::commit_attribution::commit_message_trailer_instruction; use crate::compact; use crate::compact::InitialContextInjection; @@ -1565,6 +1566,10 @@ impl Session { let originator = crate::default_client::originator().value; let terminal_type = terminal::user_agent(); let session_model = session_configuration.collaboration_mode.model().to_string(); + let auth_env_telemetry = collect_auth_env_telemetry( + &session_configuration.provider, + auth_manager.codex_api_key_env_enabled(), + ); let mut session_telemetry = SessionTelemetry::new( conversation_id, session_model.as_str(), @@ -1576,7 +1581,8 @@ impl Session { config.otel.log_user_prompt, terminal_type.clone(), session_configuration.session_source.clone(), - ); + ) + .with_auth_env(auth_env_telemetry.to_otel_metadata()); if let Some(service_name) = session_configuration.metrics_service_name.as_deref() { session_telemetry = session_telemetry.with_metrics_service_name(service_name); } diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index e02a346545..51d9fcf8d6 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -11,6 +11,7 @@ mod apply_patch; mod apps; mod arc_monitor; pub mod auth; +mod auth_env_telemetry; mod client; mod client_common; pub mod codex; diff --git a/codex-rs/core/src/models_manager/manager.rs b/codex-rs/core/src/models_manager/manager.rs index f974aa4c1b..29a1a85767 100644 --- a/codex-rs/core/src/models_manager/manager.rs +++ b/codex-rs/core/src/models_manager/manager.rs @@ -4,6 +4,8 @@ use crate::api_bridge::map_api_error; use crate::auth::AuthManager; use crate::auth::AuthMode; use crate::auth::CodexAuth; +use crate::auth_env_telemetry::AuthEnvTelemetry; +use crate::auth_env_telemetry::collect_auth_env_telemetry; use crate::config::Config; use crate::default_client::build_reqwest_client; use crate::error::CodexErr; @@ -15,7 +17,7 @@ use crate::models_manager::model_info; use crate::response_debug_context::extract_response_debug_context; use crate::response_debug_context::telemetry_transport_error_message; use crate::util::FeedbackRequestTags; -use crate::util::emit_feedback_request_tags; +use crate::util::emit_feedback_request_tags_with_auth_env; use codex_api::ModelsClient; use codex_api::RequestTelemetry; use codex_api::ReqwestTransport; @@ -46,6 +48,7 @@ struct ModelsRequestTelemetry { auth_mode: Option, auth_header_attached: bool, auth_header_name: Option<&'static str>, + auth_env: AuthEnvTelemetry, } impl RequestTelemetry for ModelsRequestTelemetry { @@ -74,6 +77,12 @@ impl RequestTelemetry for ModelsRequestTelemetry { endpoint = MODELS_ENDPOINT, auth.header_attached = self.auth_header_attached, auth.header_name = self.auth_header_name, + auth.env_openai_api_key_present = self.auth_env.openai_api_key_env_present, + auth.env_codex_api_key_present = self.auth_env.codex_api_key_env_present, + auth.env_codex_api_key_enabled = self.auth_env.codex_api_key_env_enabled, + auth.env_provider_key_name = self.auth_env.provider_env_key_name.as_deref(), + auth.env_provider_key_present = self.auth_env.provider_env_key_present, + auth.env_refresh_token_url_override_present = self.auth_env.refresh_token_url_override_present, auth.request_id = response_debug.request_id.as_deref(), auth.cf_ray = response_debug.cf_ray.as_deref(), auth.error = response_debug.auth_error.as_deref(), @@ -92,28 +101,37 @@ impl RequestTelemetry for ModelsRequestTelemetry { endpoint = MODELS_ENDPOINT, auth.header_attached = self.auth_header_attached, auth.header_name = self.auth_header_name, + auth.env_openai_api_key_present = self.auth_env.openai_api_key_env_present, + auth.env_codex_api_key_present = self.auth_env.codex_api_key_env_present, + auth.env_codex_api_key_enabled = self.auth_env.codex_api_key_env_enabled, + auth.env_provider_key_name = self.auth_env.provider_env_key_name.as_deref(), + auth.env_provider_key_present = self.auth_env.provider_env_key_present, + auth.env_refresh_token_url_override_present = self.auth_env.refresh_token_url_override_present, auth.request_id = response_debug.request_id.as_deref(), auth.cf_ray = response_debug.cf_ray.as_deref(), auth.error = response_debug.auth_error.as_deref(), auth.error_code = response_debug.auth_error_code.as_deref(), auth.mode = self.auth_mode.as_deref(), ); - emit_feedback_request_tags(&FeedbackRequestTags { - endpoint: MODELS_ENDPOINT, - auth_header_attached: self.auth_header_attached, - auth_header_name: self.auth_header_name, - auth_mode: self.auth_mode.as_deref(), - auth_retry_after_unauthorized: None, - auth_recovery_mode: None, - auth_recovery_phase: None, - auth_connection_reused: None, - auth_request_id: response_debug.request_id.as_deref(), - auth_cf_ray: response_debug.cf_ray.as_deref(), - auth_error: response_debug.auth_error.as_deref(), - auth_error_code: response_debug.auth_error_code.as_deref(), - auth_recovery_followup_success: None, - auth_recovery_followup_status: None, - }); + emit_feedback_request_tags_with_auth_env( + &FeedbackRequestTags { + endpoint: MODELS_ENDPOINT, + auth_header_attached: self.auth_header_attached, + auth_header_name: self.auth_header_name, + auth_mode: self.auth_mode.as_deref(), + auth_retry_after_unauthorized: None, + auth_recovery_mode: None, + auth_recovery_phase: None, + auth_connection_reused: None, + auth_request_id: response_debug.request_id.as_deref(), + auth_cf_ray: response_debug.cf_ray.as_deref(), + auth_error: response_debug.auth_error.as_deref(), + auth_error_code: response_debug.auth_error_code.as_deref(), + auth_recovery_followup_success: None, + auth_recovery_followup_status: None, + }, + &self.auth_env, + ); } } @@ -417,11 +435,16 @@ impl ModelsManager { let auth_mode = auth.as_ref().map(CodexAuth::auth_mode); let api_provider = self.provider.to_api_provider(auth_mode)?; let api_auth = auth_provider_from_auth(auth.clone(), &self.provider)?; + let auth_env = collect_auth_env_telemetry( + &self.provider, + self.auth_manager.codex_api_key_env_enabled(), + ); let transport = ReqwestTransport::new(build_reqwest_client()); let request_telemetry: Arc = Arc::new(ModelsRequestTelemetry { auth_mode: auth_mode.map(|mode| TelemetryAuthMode::from(mode).to_string()), auth_header_attached: api_auth.auth_header_attached(), auth_header_name: api_auth.auth_header_name(), + auth_env, }); let client = ModelsClient::new(transport, api_provider, api_auth) .with_telemetry(Some(request_telemetry)); diff --git a/codex-rs/core/src/models_manager/manager_tests.rs b/codex-rs/core/src/models_manager/manager_tests.rs index da42f2c859..07cf4dc39d 100644 --- a/codex-rs/core/src/models_manager/manager_tests.rs +++ b/codex-rs/core/src/models_manager/manager_tests.rs @@ -3,12 +3,27 @@ use crate::CodexAuth; use crate::auth::AuthCredentialsStoreMode; use crate::config::ConfigBuilder; use crate::model_provider_info::WireApi; +use base64::Engine as _; use chrono::Utc; +use codex_api::TransportError; use codex_protocol::openai_models::ModelsResponse; use core_test_support::responses::mount_models_once; +use http::HeaderMap; +use http::StatusCode; use pretty_assertions::assert_eq; use serde_json::json; +use std::collections::BTreeMap; +use std::sync::Arc; +use std::sync::Mutex; use tempfile::tempdir; +use tracing::Event; +use tracing::Subscriber; +use tracing::field::Visit; +use tracing_subscriber::Layer; +use tracing_subscriber::layer::Context; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::registry::LookupSpan; +use tracing_subscriber::util::SubscriberInitExt; use wiremock::MockServer; fn remote_model(slug: &str, display: &str, priority: i32) -> ModelInfo { @@ -77,6 +92,47 @@ fn provider_for(base_url: String) -> ModelProviderInfo { } } +#[derive(Default)] +struct TagCollectorVisitor { + tags: BTreeMap, +} + +impl Visit for TagCollectorVisitor { + fn record_bool(&mut self, field: &tracing::field::Field, value: bool) { + self.tags + .insert(field.name().to_string(), value.to_string()); + } + + fn record_str(&mut self, field: &tracing::field::Field, value: &str) { + self.tags + .insert(field.name().to_string(), value.to_string()); + } + + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + self.tags + .insert(field.name().to_string(), format!("{value:?}")); + } +} + +#[derive(Clone)] +struct TagCollectorLayer { + tags: Arc>>, +} + +impl Layer for TagCollectorLayer +where + S: Subscriber + for<'a> LookupSpan<'a>, +{ + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + if event.metadata().target() != "feedback_tags" { + return; + } + let mut visitor = TagCollectorVisitor::default(); + event.record(&mut visitor); + self.tags.lock().unwrap().extend(visitor.tags); + } +} + #[tokio::test] async fn get_model_info_tracks_fallback_usage() { let codex_home = tempdir().expect("temp dir"); @@ -530,6 +586,104 @@ async fn refresh_available_models_skips_network_without_chatgpt_auth() { ); } +#[test] +fn models_request_telemetry_emits_auth_env_feedback_tags_on_failure() { + let tags = Arc::new(Mutex::new(BTreeMap::new())); + let _guard = tracing_subscriber::registry() + .with(TagCollectorLayer { tags: tags.clone() }) + .set_default(); + + let telemetry = ModelsRequestTelemetry { + auth_mode: Some(TelemetryAuthMode::Chatgpt.to_string()), + auth_header_attached: true, + auth_header_name: Some("authorization"), + auth_env: crate::auth_env_telemetry::AuthEnvTelemetry { + openai_api_key_env_present: false, + codex_api_key_env_present: false, + codex_api_key_env_enabled: false, + provider_env_key_name: Some("configured".to_string()), + provider_env_key_present: Some(false), + refresh_token_url_override_present: false, + }, + }; + let mut headers = HeaderMap::new(); + headers.insert("x-request-id", "req-models-401".parse().unwrap()); + headers.insert("cf-ray", "ray-models-401".parse().unwrap()); + headers.insert( + "x-openai-authorization-error", + "missing_authorization_header".parse().unwrap(), + ); + headers.insert( + "x-error-json", + base64::engine::general_purpose::STANDARD + .encode(r#"{"error":{"code":"token_expired"}}"#) + .parse() + .unwrap(), + ); + telemetry.on_request( + 1, + Some(StatusCode::UNAUTHORIZED), + Some(&TransportError::Http { + status: StatusCode::UNAUTHORIZED, + url: Some("https://example.test/models".to_string()), + headers: Some(headers), + body: Some("plain text error".to_string()), + }), + Duration::from_millis(17), + ); + + let tags = tags.lock().unwrap().clone(); + assert_eq!( + tags.get("endpoint").map(String::as_str), + Some("\"/models\"") + ); + assert_eq!( + tags.get("auth_mode").map(String::as_str), + Some("\"Chatgpt\"") + ); + assert_eq!( + tags.get("auth_request_id").map(String::as_str), + Some("\"req-models-401\"") + ); + assert_eq!( + tags.get("auth_error").map(String::as_str), + Some("\"missing_authorization_header\"") + ); + assert_eq!( + tags.get("auth_error_code").map(String::as_str), + Some("\"token_expired\"") + ); + assert_eq!( + tags.get("auth_env_openai_api_key_present") + .map(String::as_str), + Some("false") + ); + assert_eq!( + tags.get("auth_env_codex_api_key_present") + .map(String::as_str), + Some("false") + ); + assert_eq!( + tags.get("auth_env_codex_api_key_enabled") + .map(String::as_str), + Some("false") + ); + assert_eq!( + tags.get("auth_env_provider_key_name").map(String::as_str), + Some("\"configured\"") + ); + assert_eq!( + tags.get("auth_env_provider_key_present") + .map(String::as_str), + Some("\"false\"") + ); + assert_eq!( + tags.get("auth_env_refresh_token_url_override_present") + .map(String::as_str), + Some("false") + ); +} + #[test] fn build_available_models_picks_default_after_hiding_hidden_models() { let codex_home = tempdir().expect("temp dir"); diff --git a/codex-rs/core/src/util.rs b/codex-rs/core/src/util.rs index 43c6d85222..1dbd6a84fc 100644 --- a/codex-rs/core/src/util.rs +++ b/codex-rs/core/src/util.rs @@ -7,6 +7,7 @@ use rand::Rng; use tracing::debug; use tracing::error; +use crate::auth_env_telemetry::AuthEnvTelemetry; use crate::parse_command::shlex_join; const INITIAL_DELAY_MS: u64 = 200; @@ -54,6 +55,23 @@ pub(crate) struct FeedbackRequestTags<'a> { pub auth_recovery_followup_status: Option, } +struct FeedbackRequestSnapshot<'a> { + endpoint: &'a str, + auth_header_attached: bool, + auth_header_name: &'a str, + auth_mode: &'a str, + auth_retry_after_unauthorized: String, + auth_recovery_mode: &'a str, + auth_recovery_phase: &'a str, + auth_connection_reused: String, + auth_request_id: &'a str, + auth_cf_ray: &'a str, + auth_error: &'a str, + auth_error_code: &'a str, + auth_recovery_followup_success: String, + auth_recovery_followup_status: String, +} + struct Auth401FeedbackSnapshot<'a> { request_id: &'a str, cf_ray: &'a str, @@ -77,42 +95,84 @@ impl<'a> Auth401FeedbackSnapshot<'a> { } } +impl<'a> FeedbackRequestSnapshot<'a> { + fn from_tags(tags: &'a FeedbackRequestTags<'a>) -> Self { + Self { + endpoint: tags.endpoint, + auth_header_attached: tags.auth_header_attached, + auth_header_name: tags.auth_header_name.unwrap_or(""), + auth_mode: tags.auth_mode.unwrap_or(""), + auth_retry_after_unauthorized: tags + .auth_retry_after_unauthorized + .map_or_else(String::new, |value| value.to_string()), + auth_recovery_mode: tags.auth_recovery_mode.unwrap_or(""), + auth_recovery_phase: tags.auth_recovery_phase.unwrap_or(""), + auth_connection_reused: tags + .auth_connection_reused + .map_or_else(String::new, |value| value.to_string()), + auth_request_id: tags.auth_request_id.unwrap_or(""), + auth_cf_ray: tags.auth_cf_ray.unwrap_or(""), + auth_error: tags.auth_error.unwrap_or(""), + auth_error_code: tags.auth_error_code.unwrap_or(""), + auth_recovery_followup_success: tags + .auth_recovery_followup_success + .map_or_else(String::new, |value| value.to_string()), + auth_recovery_followup_status: tags + .auth_recovery_followup_status + .map_or_else(String::new, |value| value.to_string()), + } + } +} + +#[cfg(test)] pub(crate) fn emit_feedback_request_tags(tags: &FeedbackRequestTags<'_>) { - let auth_header_name = tags.auth_header_name.unwrap_or(""); - let auth_mode = tags.auth_mode.unwrap_or(""); - let auth_retry_after_unauthorized = tags - .auth_retry_after_unauthorized - .map_or_else(String::new, |value| value.to_string()); - let auth_recovery_mode = tags.auth_recovery_mode.unwrap_or(""); - let auth_recovery_phase = tags.auth_recovery_phase.unwrap_or(""); - let auth_connection_reused = tags - .auth_connection_reused - .map_or_else(String::new, |value| value.to_string()); - let auth_request_id = tags.auth_request_id.unwrap_or(""); - let auth_cf_ray = tags.auth_cf_ray.unwrap_or(""); - let auth_error = tags.auth_error.unwrap_or(""); - let auth_error_code = tags.auth_error_code.unwrap_or(""); - let auth_recovery_followup_success = tags - .auth_recovery_followup_success - .map_or_else(String::new, |value| value.to_string()); - let auth_recovery_followup_status = tags - .auth_recovery_followup_status - .map_or_else(String::new, |value| value.to_string()); + let snapshot = FeedbackRequestSnapshot::from_tags(tags); feedback_tags!( - endpoint = tags.endpoint, - auth_header_attached = tags.auth_header_attached, - auth_header_name = auth_header_name, - auth_mode = auth_mode, - auth_retry_after_unauthorized = auth_retry_after_unauthorized, - auth_recovery_mode = auth_recovery_mode, - auth_recovery_phase = auth_recovery_phase, - auth_connection_reused = auth_connection_reused, - auth_request_id = auth_request_id, - auth_cf_ray = auth_cf_ray, - auth_error = auth_error, - auth_error_code = auth_error_code, - auth_recovery_followup_success = auth_recovery_followup_success, - auth_recovery_followup_status = auth_recovery_followup_status + endpoint = snapshot.endpoint, + auth_header_attached = snapshot.auth_header_attached, + auth_header_name = snapshot.auth_header_name, + auth_mode = snapshot.auth_mode, + auth_retry_after_unauthorized = snapshot.auth_retry_after_unauthorized, + auth_recovery_mode = snapshot.auth_recovery_mode, + auth_recovery_phase = snapshot.auth_recovery_phase, + auth_connection_reused = snapshot.auth_connection_reused, + auth_request_id = snapshot.auth_request_id, + auth_cf_ray = snapshot.auth_cf_ray, + auth_error = snapshot.auth_error, + auth_error_code = snapshot.auth_error_code, + auth_recovery_followup_success = snapshot.auth_recovery_followup_success, + auth_recovery_followup_status = snapshot.auth_recovery_followup_status + ); +} + +pub(crate) fn emit_feedback_request_tags_with_auth_env( + tags: &FeedbackRequestTags<'_>, + auth_env: &AuthEnvTelemetry, +) { + let snapshot = FeedbackRequestSnapshot::from_tags(tags); + feedback_tags!( + endpoint = snapshot.endpoint, + auth_header_attached = snapshot.auth_header_attached, + auth_header_name = snapshot.auth_header_name, + auth_mode = snapshot.auth_mode, + auth_retry_after_unauthorized = snapshot.auth_retry_after_unauthorized, + auth_recovery_mode = snapshot.auth_recovery_mode, + auth_recovery_phase = snapshot.auth_recovery_phase, + auth_connection_reused = snapshot.auth_connection_reused, + auth_request_id = snapshot.auth_request_id, + auth_cf_ray = snapshot.auth_cf_ray, + auth_error = snapshot.auth_error, + auth_error_code = snapshot.auth_error_code, + auth_recovery_followup_success = snapshot.auth_recovery_followup_success, + auth_recovery_followup_status = snapshot.auth_recovery_followup_status, + auth_env_openai_api_key_present = auth_env.openai_api_key_env_present, + auth_env_codex_api_key_present = auth_env.codex_api_key_env_present, + auth_env_codex_api_key_enabled = auth_env.codex_api_key_env_enabled, + auth_env_provider_key_name = auth_env.provider_env_key_name.as_deref().unwrap_or(""), + auth_env_provider_key_present = auth_env + .provider_env_key_present + .map_or_else(String::new, |value| value.to_string()), + auth_env_refresh_token_url_override_present = auth_env.refresh_token_url_override_present ); } diff --git a/codex-rs/core/src/util_tests.rs b/codex-rs/core/src/util_tests.rs index 9df8c67e89..0e9979309f 100644 --- a/codex-rs/core/src/util_tests.rs +++ b/codex-rs/core/src/util_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::auth_env_telemetry::AuthEnvTelemetry; use std::collections::BTreeMap; use std::sync::Arc; use std::sync::Mutex; @@ -68,6 +69,7 @@ impl Visit for TagCollectorVisitor { #[derive(Clone)] struct TagCollectorLayer { tags: Arc>>, + event_count: Arc>, } impl Layer for TagCollectorLayer @@ -81,32 +83,49 @@ where let mut visitor = TagCollectorVisitor::default(); event.record(&mut visitor); self.tags.lock().unwrap().extend(visitor.tags); + *self.event_count.lock().unwrap() += 1; } } #[test] fn emit_feedback_request_tags_records_sentry_feedback_fields() { let tags = Arc::new(Mutex::new(BTreeMap::new())); + let event_count = Arc::new(Mutex::new(0)); let _guard = tracing_subscriber::registry() - .with(TagCollectorLayer { tags: tags.clone() }) + .with(TagCollectorLayer { + tags: tags.clone(), + event_count: event_count.clone(), + }) .set_default(); - emit_feedback_request_tags(&FeedbackRequestTags { - endpoint: "/responses", - auth_header_attached: true, - auth_header_name: Some("authorization"), - auth_mode: Some("chatgpt"), - auth_retry_after_unauthorized: Some(false), - auth_recovery_mode: Some("managed"), - auth_recovery_phase: Some("refresh_token"), - auth_connection_reused: Some(true), - auth_request_id: Some("req-123"), - auth_cf_ray: Some("ray-123"), - auth_error: Some("missing_authorization_header"), - auth_error_code: Some("token_expired"), - auth_recovery_followup_success: Some(true), - auth_recovery_followup_status: Some(200), - }); + let auth_env = AuthEnvTelemetry { + openai_api_key_env_present: true, + codex_api_key_env_present: false, + codex_api_key_env_enabled: true, + provider_env_key_name: Some("configured".to_string()), + provider_env_key_present: Some(true), + refresh_token_url_override_present: true, + }; + + emit_feedback_request_tags_with_auth_env( + &FeedbackRequestTags { + endpoint: "/responses", + auth_header_attached: true, + auth_header_name: Some("authorization"), + auth_mode: Some("chatgpt"), + auth_retry_after_unauthorized: Some(false), + auth_recovery_mode: Some("managed"), + auth_recovery_phase: Some("refresh_token"), + auth_connection_reused: Some(true), + auth_request_id: Some("req-123"), + auth_cf_ray: Some("ray-123"), + auth_error: Some("missing_authorization_header"), + auth_error_code: Some("token_expired"), + auth_recovery_followup_success: Some(true), + auth_recovery_followup_status: Some(200), + }, + &auth_env, + ); let tags = tags.lock().unwrap().clone(); assert_eq!( @@ -121,6 +140,35 @@ fn emit_feedback_request_tags_records_sentry_feedback_fields() { tags.get("auth_header_name").map(String::as_str), Some("\"authorization\"") ); + assert_eq!( + tags.get("auth_env_openai_api_key_present") + .map(String::as_str), + Some("true") + ); + assert_eq!( + tags.get("auth_env_codex_api_key_present") + .map(String::as_str), + Some("false") + ); + assert_eq!( + tags.get("auth_env_codex_api_key_enabled") + .map(String::as_str), + Some("true") + ); + assert_eq!( + tags.get("auth_env_provider_key_name").map(String::as_str), + Some("\"configured\"") + ); + assert_eq!( + tags.get("auth_env_provider_key_present") + .map(String::as_str), + Some("\"true\"") + ); + assert_eq!( + tags.get("auth_env_refresh_token_url_override_present") + .map(String::as_str), + Some("true") + ); assert_eq!( tags.get("auth_request_id").map(String::as_str), Some("\"req-123\"") @@ -139,13 +187,18 @@ fn emit_feedback_request_tags_records_sentry_feedback_fields() { .map(String::as_str), Some("\"200\"") ); + assert_eq!(*event_count.lock().unwrap(), 1); } #[test] fn emit_feedback_auth_recovery_tags_preserves_401_specific_fields() { let tags = Arc::new(Mutex::new(BTreeMap::new())); + let event_count = Arc::new(Mutex::new(0)); let _guard = tracing_subscriber::registry() - .with(TagCollectorLayer { tags: tags.clone() }) + .with(TagCollectorLayer { + tags: tags.clone(), + event_count: event_count.clone(), + }) .set_default(); emit_feedback_auth_recovery_tags( @@ -175,13 +228,18 @@ fn emit_feedback_auth_recovery_tags_preserves_401_specific_fields() { tags.get("auth_401_error_code").map(String::as_str), Some("\"token_expired\"") ); + assert_eq!(*event_count.lock().unwrap(), 1); } #[test] fn emit_feedback_auth_recovery_tags_clears_stale_401_fields() { let tags = Arc::new(Mutex::new(BTreeMap::new())); + let event_count = Arc::new(Mutex::new(0)); let _guard = tracing_subscriber::registry() - .with(TagCollectorLayer { tags: tags.clone() }) + .with(TagCollectorLayer { + tags: tags.clone(), + event_count: event_count.clone(), + }) .set_default(); emit_feedback_auth_recovery_tags( @@ -217,13 +275,18 @@ fn emit_feedback_auth_recovery_tags_clears_stale_401_fields() { tags.get("auth_401_error_code").map(String::as_str), Some("\"\"") ); + assert_eq!(*event_count.lock().unwrap(), 2); } #[test] fn emit_feedback_request_tags_preserves_latest_auth_fields_after_unauthorized() { let tags = Arc::new(Mutex::new(BTreeMap::new())); + let event_count = Arc::new(Mutex::new(0)); let _guard = tracing_subscriber::registry() - .with(TagCollectorLayer { tags: tags.clone() }) + .with(TagCollectorLayer { + tags: tags.clone(), + event_count: event_count.clone(), + }) .set_default(); emit_feedback_request_tags(&FeedbackRequestTags { @@ -265,31 +328,48 @@ fn emit_feedback_request_tags_preserves_latest_auth_fields_after_unauthorized() .map(String::as_str), Some("\"false\"") ); + assert_eq!(*event_count.lock().unwrap(), 1); } #[test] -fn emit_feedback_request_tags_clears_stale_latest_auth_fields() { +fn emit_feedback_request_tags_preserves_auth_env_fields_for_legacy_emitters() { let tags = Arc::new(Mutex::new(BTreeMap::new())); + let event_count = Arc::new(Mutex::new(0)); let _guard = tracing_subscriber::registry() - .with(TagCollectorLayer { tags: tags.clone() }) + .with(TagCollectorLayer { + tags: tags.clone(), + event_count: event_count.clone(), + }) .set_default(); - emit_feedback_request_tags(&FeedbackRequestTags { - endpoint: "/responses", - auth_header_attached: true, - auth_header_name: Some("authorization"), - auth_mode: Some("chatgpt"), - auth_retry_after_unauthorized: Some(false), - auth_recovery_mode: Some("managed"), - auth_recovery_phase: Some("refresh_token"), - auth_connection_reused: Some(true), - auth_request_id: Some("req-123"), - auth_cf_ray: Some("ray-123"), - auth_error: Some("missing_authorization_header"), - auth_error_code: Some("token_expired"), - auth_recovery_followup_success: Some(true), - auth_recovery_followup_status: Some(200), - }); + let auth_env = AuthEnvTelemetry { + openai_api_key_env_present: true, + codex_api_key_env_present: true, + codex_api_key_env_enabled: true, + provider_env_key_name: Some("configured".to_string()), + provider_env_key_present: Some(true), + refresh_token_url_override_present: true, + }; + + emit_feedback_request_tags_with_auth_env( + &FeedbackRequestTags { + endpoint: "/responses", + auth_header_attached: true, + auth_header_name: Some("authorization"), + auth_mode: Some("chatgpt"), + auth_retry_after_unauthorized: Some(false), + auth_recovery_mode: Some("managed"), + auth_recovery_phase: Some("refresh_token"), + auth_connection_reused: Some(true), + auth_request_id: Some("req-123"), + auth_cf_ray: Some("ray-123"), + auth_error: Some("missing_authorization_header"), + auth_error_code: Some("token_expired"), + auth_recovery_followup_success: Some(true), + auth_recovery_followup_status: Some(200), + }, + &auth_env, + ); emit_feedback_request_tags(&FeedbackRequestTags { endpoint: "/responses", auth_header_attached: true, @@ -323,6 +403,35 @@ fn emit_feedback_request_tags_clears_stale_latest_auth_fields() { tags.get("auth_error_code").map(String::as_str), Some("\"\"") ); + assert_eq!( + tags.get("auth_env_openai_api_key_present") + .map(String::as_str), + Some("true") + ); + assert_eq!( + tags.get("auth_env_codex_api_key_present") + .map(String::as_str), + Some("true") + ); + assert_eq!( + tags.get("auth_env_codex_api_key_enabled") + .map(String::as_str), + Some("true") + ); + assert_eq!( + tags.get("auth_env_provider_key_name").map(String::as_str), + Some("\"configured\"") + ); + assert_eq!( + tags.get("auth_env_provider_key_present") + .map(String::as_str), + Some("\"true\"") + ); + assert_eq!( + tags.get("auth_env_refresh_token_url_override_present") + .map(String::as_str), + Some("true") + ); assert_eq!( tags.get("auth_recovery_followup_success") .map(String::as_str), @@ -333,6 +442,7 @@ fn emit_feedback_request_tags_clears_stale_latest_auth_fields() { .map(String::as_str), Some("\"\"") ); + assert_eq!(*event_count.lock().unwrap(), 2); } #[test] diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index aac10e2c1b..e2c86a6e63 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -62,10 +62,21 @@ const RESPONSES_API_ENGINE_SERVICE_TTFT_FIELD: &str = "engine_service_ttft_total const RESPONSES_API_ENGINE_IAPI_TBT_FIELD: &str = "engine_iapi_tbt_across_engine_calls_ms"; const RESPONSES_API_ENGINE_SERVICE_TBT_FIELD: &str = "engine_service_tbt_across_engine_calls_ms"; +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AuthEnvTelemetryMetadata { + pub openai_api_key_env_present: bool, + pub codex_api_key_env_present: bool, + pub codex_api_key_env_enabled: bool, + pub provider_env_key_name: Option, + pub provider_env_key_present: Option, + pub refresh_token_url_override_present: bool, +} + #[derive(Debug, Clone)] pub struct SessionTelemetryMetadata { pub(crate) conversation_id: ThreadId, pub(crate) auth_mode: Option, + pub(crate) auth_env: AuthEnvTelemetryMetadata, pub(crate) account_id: Option, pub(crate) account_email: Option, pub(crate) originator: String, @@ -86,6 +97,11 @@ pub struct SessionTelemetry { } impl SessionTelemetry { + pub fn with_auth_env(mut self, auth_env: AuthEnvTelemetryMetadata) -> Self { + self.metadata.auth_env = auth_env; + self + } + pub fn with_model(mut self, model: &str, slug: &str) -> Self { self.metadata.model = model.to_owned(); self.metadata.slug = slug.to_owned(); @@ -255,6 +271,7 @@ impl SessionTelemetry { metadata: SessionTelemetryMetadata { conversation_id, auth_mode: auth_mode.map(|m| m.to_string()), + auth_env: AuthEnvTelemetryMetadata::default(), account_id, account_email, originator: sanitize_metric_tag_value(originator.as_str()), @@ -309,6 +326,12 @@ impl SessionTelemetry { common: { event.name = "codex.conversation_starts", provider_name = %provider_name, + auth.env_openai_api_key_present = self.metadata.auth_env.openai_api_key_env_present, + auth.env_codex_api_key_present = self.metadata.auth_env.codex_api_key_env_present, + auth.env_codex_api_key_enabled = self.metadata.auth_env.codex_api_key_env_enabled, + auth.env_provider_key_name = self.metadata.auth_env.provider_env_key_name.as_deref(), + auth.env_provider_key_present = self.metadata.auth_env.provider_env_key_present, + auth.env_refresh_token_url_override_present = self.metadata.auth_env.refresh_token_url_override_present, reasoning_effort = reasoning_effort.map(|e| e.to_string()), reasoning_summary = %reasoning_summary, context_window = context_window, @@ -407,6 +430,12 @@ impl SessionTelemetry { auth.recovery_mode = recovery_mode, auth.recovery_phase = recovery_phase, endpoint = endpoint, + auth.env_openai_api_key_present = self.metadata.auth_env.openai_api_key_env_present, + auth.env_codex_api_key_present = self.metadata.auth_env.codex_api_key_env_present, + auth.env_codex_api_key_enabled = self.metadata.auth_env.codex_api_key_env_enabled, + auth.env_provider_key_name = self.metadata.auth_env.provider_env_key_name.as_deref(), + auth.env_provider_key_present = self.metadata.auth_env.provider_env_key_present, + auth.env_refresh_token_url_override_present = self.metadata.auth_env.refresh_token_url_override_present, auth.request_id = request_id, auth.cf_ray = cf_ray, auth.error = auth_error, @@ -454,6 +483,12 @@ impl SessionTelemetry { auth.recovery_mode = recovery_mode, auth.recovery_phase = recovery_phase, endpoint = endpoint, + auth.env_openai_api_key_present = self.metadata.auth_env.openai_api_key_env_present, + auth.env_codex_api_key_present = self.metadata.auth_env.codex_api_key_env_present, + auth.env_codex_api_key_enabled = self.metadata.auth_env.codex_api_key_env_enabled, + auth.env_provider_key_name = self.metadata.auth_env.provider_env_key_name.as_deref(), + auth.env_provider_key_present = self.metadata.auth_env.provider_env_key_present, + auth.env_refresh_token_url_override_present = self.metadata.auth_env.refresh_token_url_override_present, auth.connection_reused = connection_reused, auth.request_id = request_id, auth.cf_ray = cf_ray, @@ -489,6 +524,12 @@ impl SessionTelemetry { duration_ms = %duration.as_millis(), success = success_str, error.message = error, + auth.env_openai_api_key_present = self.metadata.auth_env.openai_api_key_env_present, + auth.env_codex_api_key_present = self.metadata.auth_env.codex_api_key_env_present, + auth.env_codex_api_key_enabled = self.metadata.auth_env.codex_api_key_env_enabled, + auth.env_provider_key_name = self.metadata.auth_env.provider_env_key_name.as_deref(), + auth.env_provider_key_present = self.metadata.auth_env.provider_env_key_present, + auth.env_refresh_token_url_override_present = self.metadata.auth_env.refresh_token_url_override_present, auth.connection_reused = connection_reused, }, log: {}, diff --git a/codex-rs/otel/src/lib.rs b/codex-rs/otel/src/lib.rs index cd1bbe5ce7..353130976c 100644 --- a/codex-rs/otel/src/lib.rs +++ b/codex-rs/otel/src/lib.rs @@ -12,6 +12,7 @@ use crate::metrics::Result as MetricsResult; use serde::Serialize; use strum_macros::Display; +pub use crate::events::session_telemetry::AuthEnvTelemetryMetadata; pub use crate::events::session_telemetry::SessionTelemetry; pub use crate::events::session_telemetry::SessionTelemetryMetadata; pub use crate::metrics::runtime_metrics::RuntimeMetricTotals; diff --git a/codex-rs/otel/tests/suite/otel_export_routing_policy.rs b/codex-rs/otel/tests/suite/otel_export_routing_policy.rs index df5d876b4b..b7fce86145 100644 --- a/codex-rs/otel/tests/suite/otel_export_routing_policy.rs +++ b/codex-rs/otel/tests/suite/otel_export_routing_policy.rs @@ -1,3 +1,4 @@ +use codex_otel::AuthEnvTelemetryMetadata; use codex_otel::OtelProvider; use codex_otel::SessionTelemetry; use codex_otel::TelemetryAuthMode; @@ -18,6 +19,9 @@ use tracing_subscriber::filter::filter_fn; use tracing_subscriber::layer::SubscriberExt; use codex_protocol::ThreadId; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::user_input::UserInput; @@ -76,6 +80,17 @@ fn find_span_event_by_name_attr<'a>( .unwrap_or_else(|| panic!("missing span event: {event_name}")) } +fn auth_env_metadata() -> AuthEnvTelemetryMetadata { + AuthEnvTelemetryMetadata { + openai_api_key_env_present: true, + codex_api_key_env_present: false, + codex_api_key_env_enabled: true, + provider_env_key_name: Some("configured".to_string()), + provider_env_key_present: Some(true), + refresh_token_url_override_present: true, + } +} + #[test] fn otel_export_routing_policy_routes_user_prompt_log_and_trace_events() { let log_exporter = InMemoryLogExporter::default(); @@ -482,9 +497,21 @@ fn otel_export_routing_policy_routes_api_request_auth_observability() { true, "tty".to_string(), SessionSource::Cli, - ); + ) + .with_auth_env(auth_env_metadata()); let root_span = tracing::info_span!("root"); let _root_guard = root_span.enter(); + manager.conversation_starts( + "openai", + None, + ReasoningSummary::Auto, + None, + None, + AskForApproval::Never, + SandboxPolicy::DangerFullAccess, + Vec::new(), + None, + ); manager.record_api_request( 1, Some(401), @@ -507,6 +534,20 @@ fn otel_export_routing_policy_routes_api_request_auth_observability() { tracer_provider.force_flush().expect("flush traces"); let logs = log_exporter.get_emitted_logs().expect("log export"); + let conversation_log = find_log_by_event_name(&logs, "codex.conversation_starts"); + let conversation_log_attrs = log_attributes(&conversation_log.record); + assert_eq!( + conversation_log_attrs + .get("auth.env_openai_api_key_present") + .map(String::as_str), + Some("true") + ); + assert_eq!( + conversation_log_attrs + .get("auth.env_provider_key_name") + .map(String::as_str), + Some("configured") + ); let request_log = find_log_by_event_name(&logs, "codex.api_request"); let request_log_attrs = log_attributes(&request_log.record); assert_eq!( @@ -547,8 +588,29 @@ fn otel_export_routing_policy_routes_api_request_auth_observability() { request_log_attrs.get("auth.error").map(String::as_str), Some("missing_authorization_header") ); + assert_eq!( + request_log_attrs + .get("auth.env_codex_api_key_enabled") + .map(String::as_str), + Some("true") + ); + assert_eq!( + request_log_attrs + .get("auth.env_refresh_token_url_override_present") + .map(String::as_str), + Some("true") + ); let spans = span_exporter.get_finished_spans().expect("span export"); + let conversation_trace_event = + find_span_event_by_name_attr(&spans[0].events.events, "codex.conversation_starts"); + let conversation_trace_attrs = span_event_attributes(conversation_trace_event); + assert_eq!( + conversation_trace_attrs + .get("auth.env_provider_key_present") + .map(String::as_str), + Some("true") + ); let request_trace_event = find_span_event_by_name_attr(&spans[0].events.events, "codex.api_request"); let request_trace_attrs = span_event_attributes(request_trace_event); @@ -574,6 +636,12 @@ fn otel_export_routing_policy_routes_api_request_auth_observability() { request_trace_attrs.get("endpoint").map(String::as_str), Some("/responses") ); + assert_eq!( + request_trace_attrs + .get("auth.env_openai_api_key_present") + .map(String::as_str), + Some("true") + ); } #[test] @@ -614,7 +682,8 @@ fn otel_export_routing_policy_routes_websocket_connect_auth_observability() { true, "tty".to_string(), SessionSource::Cli, - ); + ) + .with_auth_env(auth_env_metadata()); let root_span = tracing::info_span!("root"); let _root_guard = root_span.enter(); manager.record_websocket_connect( @@ -667,6 +736,12 @@ fn otel_export_routing_policy_routes_websocket_connect_auth_observability() { .map(String::as_str), Some("false") ); + assert_eq!( + connect_log_attrs + .get("auth.env_provider_key_name") + .map(String::as_str), + Some("configured") + ); let spans = span_exporter.get_finished_spans().expect("span export"); let connect_trace_event = @@ -678,6 +753,12 @@ fn otel_export_routing_policy_routes_websocket_connect_auth_observability() { .map(String::as_str), Some("reload") ); + assert_eq!( + connect_trace_attrs + .get("auth.env_refresh_token_url_override_present") + .map(String::as_str), + Some("true") + ); } #[test] @@ -718,7 +799,8 @@ fn otel_export_routing_policy_routes_websocket_request_transport_observability() true, "tty".to_string(), SessionSource::Cli, - ); + ) + .with_auth_env(auth_env_metadata()); let root_span = tracing::info_span!("root"); let _root_guard = root_span.enter(); manager.record_websocket_request( @@ -744,6 +826,12 @@ fn otel_export_routing_policy_routes_websocket_request_transport_observability() request_log_attrs.get("error.message").map(String::as_str), Some("stream error") ); + assert_eq!( + request_log_attrs + .get("auth.env_openai_api_key_present") + .map(String::as_str), + Some("true") + ); let spans = span_exporter.get_finished_spans().expect("span export"); let request_trace_event = @@ -755,4 +843,10 @@ fn otel_export_routing_policy_routes_websocket_request_transport_observability() .map(String::as_str), Some("true") ); + assert_eq!( + request_trace_attrs + .get("auth.env_provider_key_present") + .map(String::as_str), + Some("true") + ); } From 43ee72a9b9c9c88dccc86e1e50901ac90dadcc37 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 17 Mar 2026 19:11:27 -0300 Subject: [PATCH 10/13] fix(tui): implement /mcp inventory for tui_app_server (#14931) ## Problem The `/mcp` command did not work in the app-server TUI (remote mode). On `main`, `add_mcp_output()` called `McpManager::effective_servers()` in-process, which only sees locally configured servers, and then emitted a generic stub message for the app-server to handle. In remote usage, that left `/mcp` without a real inventory view. ## Solution Implement `/mcp` for the app-server TUI by fetching MCP server inventory directly from the app-server via the paginated `mcpServerStatus/list` RPC and rendering the results into chat history. The command now follows a three-phase lifecycle: 1. Loading: `ChatWidget::add_mcp_output()` inserts a transient `McpInventoryLoadingCell` and emits `AppEvent::FetchMcpInventory`. This gives immediate feedback that the command registered. 2. Fetch: `App::fetch_mcp_inventory()` spawns a background task that calls `fetch_all_mcp_server_statuses()` over an app-server request handle. When the RPC completes, it sends `AppEvent::McpInventoryLoaded { result }`. 3. Resolve: `App::handle_mcp_inventory_result()` clears the loading cell and renders either `new_mcp_tools_output_from_statuses(...)` or an error message. This keeps the main app event loop responsive, so the TUI can repaint before the remote RPC finishes. ## Notes - No `app-server` changes were required. - The rendered inventory includes auth, tools, resources, and resource templates, plus transport details when they are available from local config for display enrichment. - The app-server RPC does not expose authoritative `enabled` or `disabled_reason` state for MCP servers, so the remote `/mcp` view no longer renders a `Status:` row rather than guessing from local config. - RPC failures surface in history as `Failed to load MCP inventory: ...`. ## Tests - `slash_mcp_requests_inventory_via_app_server` - `mcp_inventory_maps_prefix_tool_names_by_server` - `handle_mcp_inventory_result_clears_committed_loading_cell` - `mcp_tools_output_from_statuses_renders_status_only_servers` - `mcp_inventory_loading_snapshot` --- codex-rs/tui_app_server/src/app.rs | 226 ++++++++++++++ codex-rs/tui_app_server/src/app_event.rs | 9 + codex-rs/tui_app_server/src/chatwidget.rs | 38 ++- .../tui_app_server/src/chatwidget/tests.rs | 11 + codex-rs/tui_app_server/src/history_cell.rs | 286 ++++++++++++++++++ ...tests__mcp_inventory_loading_snapshot.snap | 6 + ..._statuses_renders_status_only_servers.snap | 14 + 7 files changed, 581 insertions(+), 9 deletions(-) create mode 100644 codex-rs/tui_app_server/src/snapshots/codex_tui_app_server__history_cell__tests__mcp_inventory_loading_snapshot.snap create mode 100644 codex-rs/tui_app_server/src/snapshots/codex_tui_app_server__history_cell__tests__mcp_tools_output_from_statuses_renders_status_only_servers.snap diff --git a/codex-rs/tui_app_server/src/app.rs b/codex-rs/tui_app_server/src/app.rs index c08e74c337..0f25cd2b06 100644 --- a/codex-rs/tui_app_server/src/app.rs +++ b/codex-rs/tui_app_server/src/app.rs @@ -44,7 +44,13 @@ use crate::tui::TuiEvent; use crate::update_action::UpdateAction; use crate::version::CODEX_CLI_VERSION; use codex_ansi_escape::ansi_escape_line; +use codex_app_server_client::AppServerRequestHandle; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConfigLayerSource; +use codex_app_server_protocol::ListMcpServerStatusParams; +use codex_app_server_protocol::ListMcpServerStatusResponse; +use codex_app_server_protocol::McpServerStatus; +use codex_app_server_protocol::RequestId; use codex_core::config::Config; use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; @@ -75,6 +81,8 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::FinalOutput; use codex_protocol::protocol::ListSkillsResponseEvent; #[cfg(test)] +use codex_protocol::protocol::McpAuthStatus; +#[cfg(test)] use codex_protocol::protocol::Op; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; @@ -111,6 +119,7 @@ use tokio::sync::mpsc::error::TrySendError; use tokio::sync::mpsc::unbounded_channel; use tokio::task::JoinHandle; use toml::Value as TomlValue; +use uuid::Uuid; mod agent_navigation; mod app_server_adapter; mod app_server_requests; @@ -1536,6 +1545,72 @@ impl App { Ok(()) } + /// Spawn a background task that fetches the full MCP server inventory from the + /// app-server via paginated RPCs, then delivers the result back through + /// `AppEvent::McpInventoryLoaded`. + /// + /// The spawned task is fire-and-forget: no `JoinHandle` is stored, so a stale + /// result may arrive after the user has moved on. We currently accept that + /// tradeoff because the effect is limited to stale inventory output in history, + /// while request-token invalidation would add cross-cutting async state for a + /// low-severity path. + fn fetch_mcp_inventory(&mut self, app_server: &AppServerSession) { + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let result = fetch_all_mcp_server_statuses(request_handle) + .await + .map_err(|err| err.to_string()); + app_event_tx.send(AppEvent::McpInventoryLoaded { result }); + }); + } + + /// Process the completed MCP inventory fetch: clear the loading spinner, then + /// render either the full tool/resource listing or an error into chat history. + /// + /// When both the local config and the app-server report zero servers, a special + /// "empty" cell is shown instead of the full table. + fn handle_mcp_inventory_result(&mut self, result: Result, String>) { + let config = self.chat_widget.config_ref().clone(); + self.chat_widget.clear_mcp_inventory_loading(); + self.clear_committed_mcp_inventory_loading(); + + let statuses = match result { + Ok(statuses) => statuses, + Err(err) => { + self.chat_widget + .add_error_message(format!("Failed to load MCP inventory: {err}")); + return; + } + }; + + if config.mcp_servers.get().is_empty() && statuses.is_empty() { + self.chat_widget + .add_to_history(history_cell::empty_mcp_output()); + return; + } + + self.chat_widget + .add_to_history(history_cell::new_mcp_tools_output_from_statuses( + &config, &statuses, + )); + } + + fn clear_committed_mcp_inventory_loading(&mut self) { + let Some(index) = self + .transcript_cells + .iter() + .rposition(|cell| cell.as_any().is::()) + else { + return; + }; + + self.transcript_cells.remove(index); + if let Some(Overlay::Transcript(overlay)) = &mut self.overlay { + overlay.replace_cells(self.transcript_cells.clone()); + } + } + async fn try_submit_active_thread_op_via_app_server( &mut self, app_server: &mut AppServerSession, @@ -3047,6 +3122,12 @@ impl App { AppEvent::RefreshConnectors { force_refetch } => { self.chat_widget.refresh_connectors(force_refetch); } + AppEvent::FetchMcpInventory => { + self.fetch_mcp_inventory(app_server); + } + AppEvent::McpInventoryLoaded { result } => { + self.handle_mcp_inventory_result(result); + } AppEvent::StartFileSearch(query) => { self.file_search.on_user_query(query); } @@ -4469,6 +4550,80 @@ impl App { } } +/// Collect every MCP server status from the app-server by walking the paginated +/// `mcpServerStatus/list` RPC until no `next_cursor` is returned. +/// +/// All pages are eagerly gathered into a single `Vec` so the caller can render +/// the inventory atomically. Each page requests up to 100 entries. +async fn fetch_all_mcp_server_statuses( + request_handle: AppServerRequestHandle, +) -> Result> { + let mut cursor = None; + let mut statuses = Vec::new(); + + loop { + let request_id = RequestId::String(format!("mcp-inventory-{}", Uuid::new_v4())); + let response: ListMcpServerStatusResponse = request_handle + .request_typed(ClientRequest::McpServerStatusList { + request_id, + params: ListMcpServerStatusParams { + cursor: cursor.clone(), + limit: Some(100), + }, + }) + .await + .wrap_err("mcpServerStatus/list failed in app-server TUI")?; + statuses.extend(response.data); + if let Some(next_cursor) = response.next_cursor { + cursor = Some(next_cursor); + } else { + break; + } + } + + Ok(statuses) +} + +/// Convert flat `McpServerStatus` responses into the per-server maps used by the +/// in-process MCP subsystem (tools keyed as `mcp__{server}__{tool}`, plus +/// per-server resource/template/auth maps). Test-only because the app-server TUI +/// renders directly from `McpServerStatus` rather than these maps. +#[cfg(test)] +type McpInventoryMaps = ( + HashMap, + HashMap>, + HashMap>, + HashMap, +); + +#[cfg(test)] +fn mcp_inventory_maps_from_statuses(statuses: Vec) -> McpInventoryMaps { + let mut tools = HashMap::new(); + let mut resources = HashMap::new(); + let mut resource_templates = HashMap::new(); + let mut auth_statuses = HashMap::new(); + + for status in statuses { + let server_name = status.name; + auth_statuses.insert( + server_name.clone(), + match status.auth_status { + codex_app_server_protocol::McpAuthStatus::Unsupported => McpAuthStatus::Unsupported, + codex_app_server_protocol::McpAuthStatus::NotLoggedIn => McpAuthStatus::NotLoggedIn, + codex_app_server_protocol::McpAuthStatus::BearerToken => McpAuthStatus::BearerToken, + codex_app_server_protocol::McpAuthStatus::OAuth => McpAuthStatus::OAuth, + }, + ); + resources.insert(server_name.clone(), status.resources); + resource_templates.insert(server_name.clone(), status.resource_templates); + for (tool_name, tool) in status.tools { + tools.insert(format!("mcp__{server_name}__{tool_name}"), tool); + } + } + + (tools, resources, resource_templates, auth_statuses) +} + #[cfg(test)] mod tests { use super::*; @@ -4500,11 +4655,13 @@ mod tests { use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; + use codex_protocol::mcp::Tool; use codex_protocol::openai_models::ModelAvailabilityNux; use codex_protocol::protocol::AgentMessageDeltaEvent; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; + use codex_protocol::protocol::McpAuthStatus; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionSource; @@ -4545,6 +4702,75 @@ mod tests { Ok(()) } + #[test] + fn mcp_inventory_maps_prefix_tool_names_by_server() { + let statuses = vec![ + McpServerStatus { + name: "docs".to_string(), + tools: HashMap::from([( + "list".to_string(), + Tool { + description: None, + name: "list".to_string(), + title: None, + input_schema: serde_json::json!({"type": "object"}), + output_schema: None, + annotations: None, + icons: None, + meta: None, + }, + )]), + resources: Vec::new(), + resource_templates: Vec::new(), + auth_status: codex_app_server_protocol::McpAuthStatus::Unsupported, + }, + McpServerStatus { + name: "disabled".to_string(), + tools: HashMap::new(), + resources: Vec::new(), + resource_templates: Vec::new(), + auth_status: codex_app_server_protocol::McpAuthStatus::Unsupported, + }, + ]; + + let (tools, resources, resource_templates, auth_statuses) = + mcp_inventory_maps_from_statuses(statuses); + let mut resource_names = resources.keys().cloned().collect::>(); + resource_names.sort(); + let mut template_names = resource_templates.keys().cloned().collect::>(); + template_names.sort(); + + assert_eq!( + tools.keys().cloned().collect::>(), + vec!["mcp__docs__list".to_string()] + ); + assert_eq!(resource_names, vec!["disabled", "docs"]); + assert_eq!(template_names, vec!["disabled", "docs"]); + assert_eq!( + auth_statuses.get("disabled"), + Some(&McpAuthStatus::Unsupported) + ); + } + + #[tokio::test] + async fn handle_mcp_inventory_result_clears_committed_loading_cell() { + let mut app = make_test_app().await; + app.transcript_cells + .push(Arc::new(history_cell::new_mcp_inventory_loading( + /*animations_enabled*/ false, + ))); + + app.handle_mcp_inventory_result(Ok(vec![McpServerStatus { + name: "docs".to_string(), + tools: HashMap::new(), + resources: Vec::new(), + resource_templates: Vec::new(), + auth_status: codex_app_server_protocol::McpAuthStatus::Unsupported, + }])); + + assert_eq!(app.transcript_cells.len(), 0); + } + #[test] fn startup_waiting_gate_is_only_for_fresh_or_exit_session_selection() { assert_eq!( diff --git a/codex-rs/tui_app_server/src/app_event.rs b/codex-rs/tui_app_server/src/app_event.rs index 0582538bd9..8b6513d24b 100644 --- a/codex-rs/tui_app_server/src/app_event.rs +++ b/codex-rs/tui_app_server/src/app_event.rs @@ -10,6 +10,7 @@ use std::path::PathBuf; +use codex_app_server_protocol::McpServerStatus; use codex_chatgpt::connectors::AppInfo; use codex_file_search::FileMatch; use codex_protocol::ThreadId; @@ -165,6 +166,14 @@ pub(crate) enum AppEvent { force_refetch: bool, }, + /// Fetch MCP inventory via app-server RPCs and render it into history. + FetchMcpInventory, + + /// Result of fetching MCP inventory via app-server RPCs. + McpInventoryLoaded { + result: Result, String>, + }, + InsertHistoryCell(Box), /// Apply rollback semantics to local transcript cells. diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index e4101bb11e..f7d7c6ee76 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -67,7 +67,6 @@ use codex_core::find_thread_name_by_id; use codex_core::git_info::current_branch_name; use codex_core::git_info::get_git_repo_root; use codex_core::git_info::local_git_branches; -use codex_core::mcp::McpManager; use codex_core::plugins::PluginsManager; use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME; use codex_core::skills::model::SkillMetadata; @@ -8243,18 +8242,39 @@ impl ChatWidget { PlainHistoryCell::new(vec![line.into()]) } + /// Begin the asynchronous MCP inventory flow: show a loading spinner and + /// request the app-server fetch via `AppEvent::FetchMcpInventory`. + /// + /// The spinner lives in `active_cell` and is cleared by + /// [`clear_mcp_inventory_loading`] once the result arrives. pub(crate) fn add_mcp_output(&mut self) { - let mcp_manager = McpManager::new(Arc::new(PluginsManager::new( - self.config.codex_home.clone(), + self.flush_answer_stream_with_separator(); + self.flush_active_cell(); + self.active_cell = Some(Box::new(history_cell::new_mcp_inventory_loading( + self.config.animations, ))); - if mcp_manager - .effective_servers(&self.config, /*auth*/ None) - .is_empty() + self.bump_active_cell_revision(); + self.request_redraw(); + self.app_event_tx.send(AppEvent::FetchMcpInventory); + } + + /// Remove the MCP loading spinner if it is still the active cell. + /// + /// Uses `Any`-based type checking so that a late-arriving inventory result + /// does not accidentally clear an unrelated cell that was set in the meantime. + pub(crate) fn clear_mcp_inventory_loading(&mut self) { + let Some(active) = self.active_cell.as_ref() else { + return; + }; + if !active + .as_any() + .is::() { - self.add_to_history(history_cell::empty_mcp_output()); - } else { - self.add_app_server_stub_message("MCP tool inventory"); + return; } + self.active_cell = None; + self.bump_active_cell_revision(); + self.request_redraw(); } pub(crate) fn add_connectors_output(&mut self) { diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index 6468e3de47..29e56bb2b8 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -6042,6 +6042,17 @@ async fn slash_memory_drop_reports_stubbed_feature() { ); } +#[tokio::test] +async fn slash_mcp_requests_inventory_via_app_server() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + + chat.dispatch_command(SlashCommand::Mcp); + + assert!(active_blob(&chat).contains("Loading MCP inventory")); + assert_matches!(rx.try_recv(), Ok(AppEvent::FetchMcpInventory)); + assert!(op_rx.try_recv().is_err(), "expected no core op to be sent"); +} + #[tokio::test] async fn slash_memory_update_reports_stubbed_feature() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; diff --git a/codex-rs/tui_app_server/src/history_cell.rs b/codex-rs/tui_app_server/src/history_cell.rs index bba63c77ab..4ff095881b 100644 --- a/codex-rs/tui_app_server/src/history_cell.rs +++ b/codex-rs/tui_app_server/src/history_cell.rs @@ -37,6 +37,7 @@ use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_line; use crate::wrapping::adaptive_wrap_lines; use base64::Engine; +use codex_app_server_protocol::McpServerStatus; use codex_core::config::Config; use codex_core::config::types::McpServerTransportConfig; use codex_core::mcp::McpManager; @@ -1963,6 +1964,179 @@ pub(crate) fn new_mcp_tools_output( PlainHistoryCell { lines } } + +/// Build the `/mcp` history cell from app-server `McpServerStatus` responses. +/// +/// The server list comes directly from the app-server status response, sorted +/// alphabetically. Local config is only used to enrich returned servers with +/// transport details such as command, URL, cwd, and environment display. +/// +/// This mirrors the layout of [`new_mcp_tools_output`] but sources data from +/// the paginated RPC response rather than the in-process `McpManager`. +pub(crate) fn new_mcp_tools_output_from_statuses( + config: &Config, + statuses: &[McpServerStatus], +) -> PlainHistoryCell { + let mut lines: Vec> = vec![ + "/mcp".magenta().into(), + "".into(), + vec!["🔌 ".into(), "MCP Tools".bold()].into(), + "".into(), + ]; + + let mut statuses_by_name = HashMap::new(); + for status in statuses { + statuses_by_name.insert(status.name.as_str(), status); + } + + let mut server_names: Vec = statuses.iter().map(|status| status.name.clone()).collect(); + server_names.sort(); + + let has_any_tools = statuses.iter().any(|status| !status.tools.is_empty()); + if !has_any_tools { + lines.push(" • No MCP tools available.".italic().into()); + lines.push("".into()); + } + + for server in server_names { + let cfg = config.mcp_servers.get().get(server.as_str()); + let status = statuses_by_name.get(server.as_str()).copied(); + let header: Vec> = vec![" • ".into(), server.clone().into()]; + + lines.push(header.into()); + let auth_status = status + .map(|status| match status.auth_status { + codex_app_server_protocol::McpAuthStatus::Unsupported => McpAuthStatus::Unsupported, + codex_app_server_protocol::McpAuthStatus::NotLoggedIn => McpAuthStatus::NotLoggedIn, + codex_app_server_protocol::McpAuthStatus::BearerToken => McpAuthStatus::BearerToken, + codex_app_server_protocol::McpAuthStatus::OAuth => McpAuthStatus::OAuth, + }) + .unwrap_or(McpAuthStatus::Unsupported); + lines.push(vec![" • Auth: ".into(), auth_status.to_string().into()].into()); + + if let Some(cfg) = cfg { + match &cfg.transport { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { + let args_suffix = if args.is_empty() { + String::new() + } else { + format!(" {}", args.join(" ")) + }; + let cmd_display = format!("{command}{args_suffix}"); + lines.push(vec![" • Command: ".into(), cmd_display.into()].into()); + + if let Some(cwd) = cwd.as_ref() { + lines.push( + vec![" • Cwd: ".into(), cwd.display().to_string().into()].into(), + ); + } + + let env_display = format_env_display(env.as_ref(), env_vars.as_slice()); + if env_display != "-" { + lines.push(vec![" • Env: ".into(), env_display.into()].into()); + } + } + McpServerTransportConfig::StreamableHttp { + url, + http_headers, + env_http_headers, + .. + } => { + lines.push(vec![" • URL: ".into(), url.clone().into()].into()); + if let Some(headers) = http_headers.as_ref() + && !headers.is_empty() + { + let mut pairs: Vec<_> = headers.iter().collect(); + pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + let display = pairs + .into_iter() + .map(|(name, _)| format!("{name}=*****")) + .collect::>() + .join(", "); + lines.push(vec![" • HTTP headers: ".into(), display.into()].into()); + } + if let Some(headers) = env_http_headers.as_ref() + && !headers.is_empty() + { + let mut pairs: Vec<_> = headers.iter().collect(); + pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + let display = pairs + .into_iter() + .map(|(name, var)| format!("{name}={var}")) + .collect::>() + .join(", "); + lines.push(vec![" • Env HTTP headers: ".into(), display.into()].into()); + } + } + } + } + + let mut names = status + .map(|status| status.tools.keys().cloned().collect::>()) + .unwrap_or_default(); + names.sort(); + if names.is_empty() { + lines.push(" • Tools: (none)".into()); + } else { + lines.push(vec![" • Tools: ".into(), names.join(", ").into()].into()); + } + + let server_resources = status + .map(|status| status.resources.clone()) + .unwrap_or_default(); + if server_resources.is_empty() { + lines.push(" • Resources: (none)".into()); + } else { + let mut spans: Vec> = vec![" • Resources: ".into()]; + + for (idx, resource) in server_resources.iter().enumerate() { + if idx > 0 { + spans.push(", ".into()); + } + + let label = resource.title.as_ref().unwrap_or(&resource.name); + spans.push(label.clone().into()); + spans.push(" ".into()); + spans.push(format!("({})", resource.uri).dim()); + } + + lines.push(spans.into()); + } + + let server_templates = status + .map(|status| status.resource_templates.clone()) + .unwrap_or_default(); + if server_templates.is_empty() { + lines.push(" • Resource templates: (none)".into()); + } else { + let mut spans: Vec> = vec![" • Resource templates: ".into()]; + + for (idx, template) in server_templates.iter().enumerate() { + if idx > 0 { + spans.push(", ".into()); + } + + let label = template.title.as_ref().unwrap_or(&template.name); + spans.push(label.clone().into()); + spans.push(" ".into()); + spans.push(format!("({})", template.uri_template).dim()); + } + + lines.push(spans.into()); + } + + lines.push(Line::from("")); + } + + PlainHistoryCell { lines } +} + pub(crate) fn new_info_event(message: String, hint: Option) -> PlainHistoryCell { let mut line = vec!["• ".dim(), message.into()]; if let Some(hint) = hint { @@ -1981,6 +2155,54 @@ pub(crate) fn new_error_event(message: String) -> PlainHistoryCell { PlainHistoryCell { lines } } +/// A transient history cell that shows an animated spinner while the MCP +/// inventory RPC is in flight. +/// +/// Inserted as the `active_cell` by `ChatWidget::add_mcp_output()` and removed +/// once the fetch completes. The app removes committed copies from transcript +/// history, while `ChatWidget::clear_mcp_inventory_loading()` only clears the +/// in-flight `active_cell`. +#[derive(Debug)] +pub(crate) struct McpInventoryLoadingCell { + start_time: Instant, + animations_enabled: bool, +} + +impl McpInventoryLoadingCell { + pub(crate) fn new(animations_enabled: bool) -> Self { + Self { + start_time: Instant::now(), + animations_enabled, + } + } +} + +impl HistoryCell for McpInventoryLoadingCell { + fn display_lines(&self, _width: u16) -> Vec> { + vec![ + vec![ + spinner(Some(self.start_time), self.animations_enabled), + " ".into(), + "Loading MCP inventory".bold(), + "…".dim(), + ] + .into(), + ] + } + + fn transcript_animation_tick(&self) -> Option { + if !self.animations_enabled { + return None; + } + Some((self.start_time.elapsed().as_millis() / 50) as u64) + } +} + +/// Convenience constructor for [`McpInventoryLoadingCell`]. +pub(crate) fn new_mcp_inventory_loading(animations_enabled: bool) -> McpInventoryLoadingCell { + McpInventoryLoadingCell::new(animations_enabled) +} + /// Renders a completed (or interrupted) request_user_input exchange in history. #[derive(Debug)] pub(crate) struct RequestUserInputResultCell { @@ -2542,6 +2764,7 @@ mod tests { use codex_core::config::Config; use codex_core::config::ConfigBuilder; use codex_core::config::types::McpServerConfig; + use codex_core::config::types::McpServerDisabledReason; use codex_core::config::types::McpServerTransportConfig; use codex_otel::RuntimeMetricTotals; use codex_otel::RuntimeMetricsSummary; @@ -2961,6 +3184,61 @@ mod tests { insta::assert_snapshot!(rendered); } + #[tokio::test] + async fn mcp_tools_output_from_statuses_renders_status_only_servers() { + let mut config = test_config().await; + let servers = HashMap::from([( + "plugin_docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "docs-server".to_string(), + args: vec!["--stdio".to_string()], + env: None, + env_vars: vec![], + cwd: None, + }, + enabled: false, + required: false, + disabled_reason: Some(McpServerDisabledReason::Unknown), + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + }, + )]); + config + .mcp_servers + .set(servers) + .expect("test mcp servers should accept any configuration"); + + let statuses = vec![McpServerStatus { + name: "plugin_docs".to_string(), + tools: HashMap::from([( + "lookup".to_string(), + Tool { + description: None, + name: "lookup".to_string(), + title: None, + input_schema: serde_json::json!({"type": "object", "properties": {}}), + output_schema: None, + annotations: None, + icons: None, + meta: None, + }, + )]), + resources: Vec::new(), + resource_templates: Vec::new(), + auth_status: codex_app_server_protocol::McpAuthStatus::Unsupported, + }]; + + let cell = new_mcp_tools_output_from_statuses(&config, &statuses); + let rendered = render_lines(&cell.display_lines(120)).join("\n"); + + insta::assert_snapshot!(rendered); + } + #[test] fn empty_agent_message_cell_transcript() { let cell = AgentMessageCell::new(vec![Line::default()], false); @@ -3188,6 +3466,14 @@ mod tests { insta::assert_snapshot!(rendered); } + #[test] + fn mcp_inventory_loading_snapshot() { + let cell = new_mcp_inventory_loading(/*animations_enabled*/ true); + let rendered = render_lines(&cell.display_lines(80)).join("\n"); + + insta::assert_snapshot!(rendered); + } + #[test] fn completed_mcp_tool_call_success_snapshot() { let invocation = McpInvocation { diff --git a/codex-rs/tui_app_server/src/snapshots/codex_tui_app_server__history_cell__tests__mcp_inventory_loading_snapshot.snap b/codex-rs/tui_app_server/src/snapshots/codex_tui_app_server__history_cell__tests__mcp_inventory_loading_snapshot.snap new file mode 100644 index 0000000000..d01c31bd6a --- /dev/null +++ b/codex-rs/tui_app_server/src/snapshots/codex_tui_app_server__history_cell__tests__mcp_inventory_loading_snapshot.snap @@ -0,0 +1,6 @@ +--- +source: tui_app_server/src/history_cell.rs +assertion_line: 3477 +expression: rendered +--- +• Loading MCP inventory… diff --git a/codex-rs/tui_app_server/src/snapshots/codex_tui_app_server__history_cell__tests__mcp_tools_output_from_statuses_renders_status_only_servers.snap b/codex-rs/tui_app_server/src/snapshots/codex_tui_app_server__history_cell__tests__mcp_tools_output_from_statuses_renders_status_only_servers.snap new file mode 100644 index 0000000000..6c95cc4433 --- /dev/null +++ b/codex-rs/tui_app_server/src/snapshots/codex_tui_app_server__history_cell__tests__mcp_tools_output_from_statuses_renders_status_only_servers.snap @@ -0,0 +1,14 @@ +--- +source: tui_app_server/src/history_cell.rs +expression: rendered +--- +/mcp + +🔌 MCP Tools + + • plugin_docs + • Auth: Unsupported + • Command: docs-server --stdio + • Tools: lookup + • Resources: (none) + • Resource templates: (none) From 1a9555eda98cc561b4beec51fd1c577b0b068e2a Mon Sep 17 00:00:00 2001 From: xl-openai Date: Tue, 17 Mar 2026 15:22:36 -0700 Subject: [PATCH 11/13] Cleanup skills/remote/xxx endpoints. (#14977) Remote skills/remote/xxx as they are not in used for now. --- .../schema/json/ClientRequest.json | 102 ----------- .../codex_app_server_protocol.schemas.json | 158 ------------------ .../codex_app_server_protocol.v2.schemas.json | 158 ------------------ .../json/v2/SkillsRemoteReadParams.json | 47 ------ .../json/v2/SkillsRemoteReadResponse.json | 37 ---- .../json/v2/SkillsRemoteWriteParams.json | 13 -- .../json/v2/SkillsRemoteWriteResponse.json | 17 -- .../schema/typescript/ClientRequest.ts | 4 +- .../schema/typescript/v2/HazelnutScope.ts | 5 - .../schema/typescript/v2/ProductSurface.ts | 5 - .../typescript/v2/RemoteSkillSummary.ts | 5 - .../typescript/v2/SkillsRemoteReadParams.ts | 7 - .../typescript/v2/SkillsRemoteReadResponse.ts | 6 - .../typescript/v2/SkillsRemoteWriteParams.ts | 5 - .../v2/SkillsRemoteWriteResponse.ts | 5 - .../schema/typescript/v2/index.ts | 7 - .../src/protocol/common.rs | 8 - .../app-server-protocol/src/protocol/v2.rs | 67 -------- codex-rs/app-server/README.md | 2 - .../app-server/src/codex_message_processor.rs | 109 ------------ codex-rs/core/src/codex.rs | 118 ------------- codex-rs/core/src/rollout/policy.rs | 2 - codex-rs/core/src/skills/remote.rs | 54 ++++-- .../src/event_processor_with_human_output.rs | 4 - codex-rs/mcp-server/src/codex_tool_runner.rs | 2 - codex-rs/protocol/src/protocol.rs | 59 ------- codex-rs/tui/src/chatwidget.rs | 1 - codex-rs/tui_app_server/src/chatwidget.rs | 1 - .../src/codex_app_server/generated/v2_all.py | 107 ++---------- 29 files changed, 48 insertions(+), 1067 deletions(-) delete mode 100644 codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteReadParams.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteReadResponse.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteWriteParams.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteWriteResponse.json delete mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/HazelnutScope.ts delete mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ProductSurface.ts delete mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/RemoteSkillSummary.ts delete mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteReadParams.ts delete mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteReadResponse.ts delete mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteWriteParams.ts delete mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteWriteResponse.ts diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 9c2004f057..5472a70a32 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -937,15 +937,6 @@ ], "type": "object" }, - "HazelnutScope": { - "enum": [ - "example", - "workspace-shared", - "all-shared", - "personal" - ], - "type": "string" - }, "ImageDetail": { "enum": [ "auto", @@ -1328,15 +1319,6 @@ ], "type": "object" }, - "ProductSurface": { - "enum": [ - "chatgpt", - "codex", - "api", - "atlas" - ], - "type": "string" - }, "ReadOnlyAccess": { "oneOf": [ { @@ -2410,42 +2392,6 @@ }, "type": "object" }, - "SkillsRemoteReadParams": { - "properties": { - "enabled": { - "default": false, - "type": "boolean" - }, - "hazelnutScope": { - "allOf": [ - { - "$ref": "#/definitions/HazelnutScope" - } - ], - "default": "example" - }, - "productSurface": { - "allOf": [ - { - "$ref": "#/definitions/ProductSurface" - } - ], - "default": "codex" - } - }, - "type": "object" - }, - "SkillsRemoteWriteParams": { - "properties": { - "hazelnutId": { - "type": "string" - } - }, - "required": [ - "hazelnutId" - ], - "type": "object" - }, "TextElement": { "properties": { "byteRange": { @@ -3802,54 +3748,6 @@ "title": "Plugin/readRequest", "type": "object" }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "skills/remote/list" - ], - "title": "Skills/remote/listRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/SkillsRemoteReadParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "Skills/remote/listRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "skills/remote/export" - ], - "title": "Skills/remote/exportRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/SkillsRemoteWriteParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "Skills/remote/exportRequest", - "type": "object" - }, { "properties": { "id": { 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 e370546dc2..5697ca0983 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 @@ -667,54 +667,6 @@ "title": "Plugin/readRequest", "type": "object" }, - { - "properties": { - "id": { - "$ref": "#/definitions/v2/RequestId" - }, - "method": { - "enum": [ - "skills/remote/list" - ], - "title": "Skills/remote/listRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/v2/SkillsRemoteReadParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "Skills/remote/listRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/v2/RequestId" - }, - "method": { - "enum": [ - "skills/remote/export" - ], - "title": "Skills/remote/exportRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/v2/SkillsRemoteWriteParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "Skills/remote/exportRequest", - "type": "object" - }, { "properties": { "id": { @@ -7904,15 +7856,6 @@ ], "type": "string" }, - "HazelnutScope": { - "enum": [ - "example", - "workspace-shared", - "all-shared", - "personal" - ], - "type": "string" - }, "HookCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -9520,15 +9463,6 @@ "title": "PluginUninstallResponse", "type": "object" }, - "ProductSurface": { - "enum": [ - "chatgpt", - "codex", - "api", - "atlas" - ], - "type": "string" - }, "ProfileV2": { "additionalProperties": true, "properties": { @@ -9986,25 +9920,6 @@ "title": "ReasoningTextDeltaNotification", "type": "object" }, - "RemoteSkillSummary": { - "properties": { - "description": { - "type": "string" - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": [ - "description", - "id", - "name" - ], - "type": "object" - }, "RequestId": { "anyOf": [ { @@ -11381,79 +11296,6 @@ "title": "SkillsListResponse", "type": "object" }, - "SkillsRemoteReadParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "enabled": { - "default": false, - "type": "boolean" - }, - "hazelnutScope": { - "allOf": [ - { - "$ref": "#/definitions/v2/HazelnutScope" - } - ], - "default": "example" - }, - "productSurface": { - "allOf": [ - { - "$ref": "#/definitions/v2/ProductSurface" - } - ], - "default": "codex" - } - }, - "title": "SkillsRemoteReadParams", - "type": "object" - }, - "SkillsRemoteReadResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "data": { - "items": { - "$ref": "#/definitions/v2/RemoteSkillSummary" - }, - "type": "array" - } - }, - "required": [ - "data" - ], - "title": "SkillsRemoteReadResponse", - "type": "object" - }, - "SkillsRemoteWriteParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "hazelnutId": { - "type": "string" - } - }, - "required": [ - "hazelnutId" - ], - "title": "SkillsRemoteWriteParams", - "type": "object" - }, - "SkillsRemoteWriteResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "id": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "required": [ - "id", - "path" - ], - "title": "SkillsRemoteWriteResponse", - "type": "object" - }, "SubAgentSource": { "oneOf": [ { 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 b069d3e5e7..91fa980a62 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 @@ -1194,54 +1194,6 @@ "title": "Plugin/readRequest", "type": "object" }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "skills/remote/list" - ], - "title": "Skills/remote/listRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/SkillsRemoteReadParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "Skills/remote/listRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "skills/remote/export" - ], - "title": "Skills/remote/exportRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/SkillsRemoteWriteParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "Skills/remote/exportRequest", - "type": "object" - }, { "properties": { "id": { @@ -4648,15 +4600,6 @@ ], "type": "string" }, - "HazelnutScope": { - "enum": [ - "example", - "workspace-shared", - "all-shared", - "personal" - ], - "type": "string" - }, "HookCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -6308,15 +6251,6 @@ "title": "PluginUninstallResponse", "type": "object" }, - "ProductSurface": { - "enum": [ - "chatgpt", - "codex", - "api", - "atlas" - ], - "type": "string" - }, "ProfileV2": { "additionalProperties": true, "properties": { @@ -6774,25 +6708,6 @@ "title": "ReasoningTextDeltaNotification", "type": "object" }, - "RemoteSkillSummary": { - "properties": { - "description": { - "type": "string" - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": [ - "description", - "id", - "name" - ], - "type": "object" - }, "RequestId": { "anyOf": [ { @@ -9141,79 +9056,6 @@ "title": "SkillsListResponse", "type": "object" }, - "SkillsRemoteReadParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "enabled": { - "default": false, - "type": "boolean" - }, - "hazelnutScope": { - "allOf": [ - { - "$ref": "#/definitions/HazelnutScope" - } - ], - "default": "example" - }, - "productSurface": { - "allOf": [ - { - "$ref": "#/definitions/ProductSurface" - } - ], - "default": "codex" - } - }, - "title": "SkillsRemoteReadParams", - "type": "object" - }, - "SkillsRemoteReadResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "data": { - "items": { - "$ref": "#/definitions/RemoteSkillSummary" - }, - "type": "array" - } - }, - "required": [ - "data" - ], - "title": "SkillsRemoteReadResponse", - "type": "object" - }, - "SkillsRemoteWriteParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "hazelnutId": { - "type": "string" - } - }, - "required": [ - "hazelnutId" - ], - "title": "SkillsRemoteWriteParams", - "type": "object" - }, - "SkillsRemoteWriteResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "id": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "required": [ - "id", - "path" - ], - "title": "SkillsRemoteWriteResponse", - "type": "object" - }, "SubAgentSource": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteReadParams.json b/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteReadParams.json deleted file mode 100644 index f99e53d894..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteReadParams.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "HazelnutScope": { - "enum": [ - "example", - "workspace-shared", - "all-shared", - "personal" - ], - "type": "string" - }, - "ProductSurface": { - "enum": [ - "chatgpt", - "codex", - "api", - "atlas" - ], - "type": "string" - } - }, - "properties": { - "enabled": { - "default": false, - "type": "boolean" - }, - "hazelnutScope": { - "allOf": [ - { - "$ref": "#/definitions/HazelnutScope" - } - ], - "default": "example" - }, - "productSurface": { - "allOf": [ - { - "$ref": "#/definitions/ProductSurface" - } - ], - "default": "codex" - } - }, - "title": "SkillsRemoteReadParams", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteReadResponse.json deleted file mode 100644 index a8e19c65bb..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteReadResponse.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "RemoteSkillSummary": { - "properties": { - "description": { - "type": "string" - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": [ - "description", - "id", - "name" - ], - "type": "object" - } - }, - "properties": { - "data": { - "items": { - "$ref": "#/definitions/RemoteSkillSummary" - }, - "type": "array" - } - }, - "required": [ - "data" - ], - "title": "SkillsRemoteReadResponse", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteWriteParams.json b/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteWriteParams.json deleted file mode 100644 index f1a70eeeb0..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteWriteParams.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "hazelnutId": { - "type": "string" - } - }, - "required": [ - "hazelnutId" - ], - "title": "SkillsRemoteWriteParams", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteWriteResponse.json b/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteWriteResponse.json deleted file mode 100644 index b732732bdc..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v2/SkillsRemoteWriteResponse.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "id": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "required": [ - "id", - "path" - ], - "title": "SkillsRemoteWriteResponse", - "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 fd523c889f..b854afd66e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -39,8 +39,6 @@ import type { PluginUninstallParams } from "./v2/PluginUninstallParams"; import type { ReviewStartParams } from "./v2/ReviewStartParams"; import type { SkillsConfigWriteParams } from "./v2/SkillsConfigWriteParams"; import type { SkillsListParams } from "./v2/SkillsListParams"; -import type { SkillsRemoteReadParams } from "./v2/SkillsRemoteReadParams"; -import type { SkillsRemoteWriteParams } from "./v2/SkillsRemoteWriteParams"; import type { ThreadArchiveParams } from "./v2/ThreadArchiveParams"; import type { ThreadCompactStartParams } from "./v2/ThreadCompactStartParams"; import type { ThreadForkParams } from "./v2/ThreadForkParams"; @@ -62,4 +60,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": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "skills/remote/list", id: RequestId, params: SkillsRemoteReadParams, } | { "method": "skills/remote/export", id: RequestId, params: SkillsRemoteWriteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "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, }; +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": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "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/v2/HazelnutScope.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HazelnutScope.ts deleted file mode 100644 index e623f1860b..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HazelnutScope.ts +++ /dev/null @@ -1,5 +0,0 @@ -// 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 HazelnutScope = "example" | "workspace-shared" | "all-shared" | "personal"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ProductSurface.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ProductSurface.ts deleted file mode 100644 index 9998c727a8..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ProductSurface.ts +++ /dev/null @@ -1,5 +0,0 @@ -// 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 ProductSurface = "chatgpt" | "codex" | "api" | "atlas"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RemoteSkillSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RemoteSkillSummary.ts deleted file mode 100644 index 7bf57b3b09..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/RemoteSkillSummary.ts +++ /dev/null @@ -1,5 +0,0 @@ -// 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 RemoteSkillSummary = { id: string, name: string, description: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteReadParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteReadParams.ts deleted file mode 100644 index 1257f0d791..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteReadParams.ts +++ /dev/null @@ -1,7 +0,0 @@ -// 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 { HazelnutScope } from "./HazelnutScope"; -import type { ProductSurface } from "./ProductSurface"; - -export type SkillsRemoteReadParams = { hazelnutScope: HazelnutScope, productSurface: ProductSurface, enabled: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteReadResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteReadResponse.ts deleted file mode 100644 index c1c7b1cc70..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteReadResponse.ts +++ /dev/null @@ -1,6 +0,0 @@ -// 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 { RemoteSkillSummary } from "./RemoteSkillSummary"; - -export type SkillsRemoteReadResponse = { data: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteWriteParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteWriteParams.ts deleted file mode 100644 index ea42595bfd..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteWriteParams.ts +++ /dev/null @@ -1,5 +0,0 @@ -// 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 SkillsRemoteWriteParams = { hazelnutId: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteWriteResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteWriteResponse.ts deleted file mode 100644 index 228b723b19..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteWriteResponse.ts +++ /dev/null @@ -1,5 +0,0 @@ -// 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 SkillsRemoteWriteResponse = { id: string, path: string, }; 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 1529bbea88..63514ed9b4 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -119,7 +119,6 @@ export type { GrantedPermissionProfile } from "./GrantedPermissionProfile"; export type { GuardianApprovalReview } from "./GuardianApprovalReview"; export type { GuardianApprovalReviewStatus } from "./GuardianApprovalReviewStatus"; export type { GuardianRiskLevel } from "./GuardianRiskLevel"; -export type { HazelnutScope } from "./HazelnutScope"; export type { HookCompletedNotification } from "./HookCompletedNotification"; export type { HookEventName } from "./HookEventName"; export type { HookExecutionMode } from "./HookExecutionMode"; @@ -211,7 +210,6 @@ export type { PluginSource } from "./PluginSource"; export type { PluginSummary } from "./PluginSummary"; export type { PluginUninstallParams } from "./PluginUninstallParams"; export type { PluginUninstallResponse } from "./PluginUninstallResponse"; -export type { ProductSurface } from "./ProductSurface"; export type { ProfileV2 } from "./ProfileV2"; export type { RateLimitSnapshot } from "./RateLimitSnapshot"; export type { RateLimitWindow } from "./RateLimitWindow"; @@ -221,7 +219,6 @@ export type { ReasoningEffortOption } from "./ReasoningEffortOption"; export type { ReasoningSummaryPartAddedNotification } from "./ReasoningSummaryPartAddedNotification"; export type { ReasoningSummaryTextDeltaNotification } from "./ReasoningSummaryTextDeltaNotification"; export type { ReasoningTextDeltaNotification } from "./ReasoningTextDeltaNotification"; -export type { RemoteSkillSummary } from "./RemoteSkillSummary"; export type { RequestPermissionProfile } from "./RequestPermissionProfile"; export type { ResidencyRequirement } from "./ResidencyRequirement"; export type { ReviewDelivery } from "./ReviewDelivery"; @@ -247,10 +244,6 @@ export type { SkillsListEntry } from "./SkillsListEntry"; export type { SkillsListExtraRootsForCwd } from "./SkillsListExtraRootsForCwd"; export type { SkillsListParams } from "./SkillsListParams"; export type { SkillsListResponse } from "./SkillsListResponse"; -export type { SkillsRemoteReadParams } from "./SkillsRemoteReadParams"; -export type { SkillsRemoteReadResponse } from "./SkillsRemoteReadResponse"; -export type { SkillsRemoteWriteParams } from "./SkillsRemoteWriteParams"; -export type { SkillsRemoteWriteResponse } from "./SkillsRemoteWriteResponse"; export type { TerminalInteractionNotification } from "./TerminalInteractionNotification"; export type { TextElement } from "./TextElement"; export type { TextPosition } from "./TextPosition"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 73139a2e09..46020b2c81 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -300,14 +300,6 @@ client_request_definitions! { params: v2::PluginReadParams, response: v2::PluginReadResponse, }, - SkillsRemoteList => "skills/remote/list" { - params: v2::SkillsRemoteReadParams, - response: v2::SkillsRemoteReadResponse, - }, - SkillsRemoteExport => "skills/remote/export" { - params: v2::SkillsRemoteWriteParams, - response: v2::SkillsRemoteWriteResponse, - }, AppsList => "app/list" { params: v2::AppsListParams, response: v2::AppsListResponse, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index e2316d8e78..c653ed0493 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3107,73 +3107,6 @@ pub struct PluginReadResponse { pub plugin: PluginDetail, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillsRemoteReadParams { - #[serde(default)] - pub hazelnut_scope: HazelnutScope, - #[serde(default)] - pub product_surface: ProductSurface, - #[serde(default)] - pub enabled: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS, Default)] -#[serde(rename_all = "kebab-case")] -#[ts(rename_all = "kebab-case")] -#[ts(export_to = "v2/")] -pub enum HazelnutScope { - #[default] - Example, - WorkspaceShared, - AllShared, - Personal, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS, Default)] -#[serde(rename_all = "camelCase")] -#[ts(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum ProductSurface { - Chatgpt, - #[default] - Codex, - Api, - Atlas, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct RemoteSkillSummary { - pub id: String, - pub name: String, - pub description: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillsRemoteReadResponse { - pub data: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillsRemoteWriteParams { - pub hazelnut_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillsRemoteWriteResponse { - pub id: String, - pub path: PathBuf, -} - #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] #[ts(rename_all = "snake_case")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 67bc310da1..55adb794a3 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -167,8 +167,6 @@ Example with notification opt-out: - `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category. Pass `forceRemoteSync: true` to refresh curated plugin state before listing (**under development; do not call from production clients yet**). - `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/apps/MCP server names (**under development; do not call from production clients yet**). - `skills/changed` — notification emitted when watched local skill files change. -- `skills/remote/list` — list public remote skills (**under development; do not call from production clients yet**). -- `skills/remote/export` — download a remote skill by `hazelnutId` into `skills` under `codex_home` (**under development; do not call from production clients yet**). - `app/list` — list available apps. - `skills/config/write` — write user-level skill config by path. - `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**). diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index eb4e432c5b..e8e98f9ab4 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -64,7 +64,6 @@ use codex_app_server_protocol::GetConversationSummaryParams; use codex_app_server_protocol::GetConversationSummaryResponse; use codex_app_server_protocol::GitDiffToRemoteResponse; use codex_app_server_protocol::GitInfo as ApiGitInfo; -use codex_app_server_protocol::HazelnutScope as ApiHazelnutScope; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::ListMcpServerStatusParams; use codex_app_server_protocol::ListMcpServerStatusResponse; @@ -95,7 +94,6 @@ use codex_app_server_protocol::PluginSource; use codex_app_server_protocol::PluginSummary; use codex_app_server_protocol::PluginUninstallParams; use codex_app_server_protocol::PluginUninstallResponse; -use codex_app_server_protocol::ProductSurface as ApiProductSurface; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ReviewDelivery as ApiReviewDelivery; use codex_app_server_protocol::ReviewStartParams; @@ -109,10 +107,6 @@ use codex_app_server_protocol::SkillsConfigWriteParams; use codex_app_server_protocol::SkillsConfigWriteResponse; use codex_app_server_protocol::SkillsListParams; use codex_app_server_protocol::SkillsListResponse; -use codex_app_server_protocol::SkillsRemoteReadParams; -use codex_app_server_protocol::SkillsRemoteReadResponse; -use codex_app_server_protocol::SkillsRemoteWriteParams; -use codex_app_server_protocol::SkillsRemoteWriteResponse; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadArchiveParams; use codex_app_server_protocol::ThreadArchiveResponse; @@ -236,8 +230,6 @@ use codex_core::read_head_for_summary; use codex_core::read_session_meta_line; use codex_core::rollout_date_parts; use codex_core::sandboxing::SandboxPermissions; -use codex_core::skills::remote::export_remote_skill; -use codex_core::skills::remote::list_remote_skills; use codex_core::state_db::StateDbHandle; use codex_core::state_db::get_state_db; use codex_core::state_db::reconcile_rollout; @@ -267,8 +259,6 @@ use codex_protocol::protocol::McpAuthStatus as CoreMcpAuthStatus; use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::Op; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; -use codex_protocol::protocol::RemoteSkillHazelnutScope; -use codex_protocol::protocol::RemoteSkillProductSurface; use codex_protocol::protocol::ReviewDelivery as CoreReviewDelivery; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; @@ -359,24 +349,6 @@ enum ThreadShutdownResult { TimedOut, } -fn convert_remote_scope(scope: ApiHazelnutScope) -> RemoteSkillHazelnutScope { - match scope { - ApiHazelnutScope::WorkspaceShared => RemoteSkillHazelnutScope::WorkspaceShared, - ApiHazelnutScope::AllShared => RemoteSkillHazelnutScope::AllShared, - ApiHazelnutScope::Personal => RemoteSkillHazelnutScope::Personal, - ApiHazelnutScope::Example => RemoteSkillHazelnutScope::Example, - } -} - -fn convert_remote_product_surface(product_surface: ApiProductSurface) -> RemoteSkillProductSurface { - match product_surface { - ApiProductSurface::Chatgpt => RemoteSkillProductSurface::Chatgpt, - ApiProductSurface::Codex => RemoteSkillProductSurface::Codex, - ApiProductSurface::Api => RemoteSkillProductSurface::Api, - ApiProductSurface::Atlas => RemoteSkillProductSurface::Atlas, - } -} - impl Drop for ActiveLogin { fn drop(&mut self) { self.shutdown_handle.shutdown(); @@ -730,14 +702,6 @@ impl CodexMessageProcessor { self.plugin_read(to_connection_request_id(request_id), params) .await; } - ClientRequest::SkillsRemoteList { request_id, params } => { - self.skills_remote_list(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::SkillsRemoteExport { request_id, params } => { - self.skills_remote_export(to_connection_request_id(request_id), params) - .await; - } ClientRequest::AppsList { request_id, params } => { self.apps_list(to_connection_request_id(request_id), params) .await; @@ -5570,79 +5534,6 @@ impl CodexMessageProcessor { .await; } - async fn skills_remote_list( - &self, - request_id: ConnectionRequestId, - params: SkillsRemoteReadParams, - ) { - let hazelnut_scope = convert_remote_scope(params.hazelnut_scope); - let product_surface = convert_remote_product_surface(params.product_surface); - let enabled = if params.enabled { Some(true) } else { None }; - - let auth = self.auth_manager.auth().await; - match list_remote_skills( - &self.config, - auth.as_ref(), - hazelnut_scope, - product_surface, - enabled, - ) - .await - { - Ok(skills) => { - let data = skills - .into_iter() - .map(|skill| codex_app_server_protocol::RemoteSkillSummary { - id: skill.id, - name: skill.name, - description: skill.description, - }) - .collect(); - self.outgoing - .send_response(request_id, SkillsRemoteReadResponse { data }) - .await; - } - Err(err) => { - self.send_internal_error( - request_id, - format!("failed to list remote skills: {err}"), - ) - .await; - } - } - } - - async fn skills_remote_export( - &self, - request_id: ConnectionRequestId, - params: SkillsRemoteWriteParams, - ) { - let SkillsRemoteWriteParams { hazelnut_id } = params; - let auth = self.auth_manager.auth().await; - let response = export_remote_skill(&self.config, auth.as_ref(), hazelnut_id.as_str()).await; - - match response { - Ok(downloaded) => { - self.outgoing - .send_response( - request_id, - SkillsRemoteWriteResponse { - id: downloaded.id, - path: downloaded.path, - }, - ) - .await; - } - Err(err) => { - self.send_internal_error( - request_id, - format!("failed to download remote skill: {err}"), - ) - .await; - } - } - } - async fn skills_config_write( &self, request_id: ConnectionRequestId, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 9382401bac..d367479187 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4242,27 +4242,6 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv handlers::list_skills(&sess, sub.id.clone(), cwds, force_reload).await; false } - Op::ListRemoteSkills { - hazelnut_scope, - product_surface, - enabled, - } => { - handlers::list_remote_skills( - &sess, - &config, - sub.id.clone(), - hazelnut_scope, - product_surface, - enabled, - ) - .await; - false - } - Op::DownloadRemoteSkill { hazelnut_id } => { - handlers::export_remote_skill(&sess, &config, sub.id.clone(), hazelnut_id) - .await; - false - } Op::Undo => { handlers::undo(&sess, sub.id.clone()).await; false @@ -4384,14 +4363,9 @@ mod handlers { use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ListCustomPromptsResponseEvent; - use codex_protocol::protocol::ListRemoteSkillsResponseEvent; use codex_protocol::protocol::ListSkillsResponseEvent; use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::Op; - use codex_protocol::protocol::RemoteSkillDownloadedEvent; - use codex_protocol::protocol::RemoteSkillHazelnutScope; - use codex_protocol::protocol::RemoteSkillProductSurface; - use codex_protocol::protocol::RemoteSkillSummary; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; @@ -4792,96 +4766,6 @@ mod handlers { sess.send_event_raw(event).await; } - pub async fn list_remote_skills( - sess: &Session, - config: &Arc, - sub_id: String, - hazelnut_scope: RemoteSkillHazelnutScope, - product_surface: RemoteSkillProductSurface, - enabled: Option, - ) { - let auth = sess.services.auth_manager.auth().await; - let response = crate::skills::remote::list_remote_skills( - config, - auth.as_ref(), - hazelnut_scope, - product_surface, - enabled, - ) - .await - .map(|skills| { - skills - .into_iter() - .map(|skill| RemoteSkillSummary { - id: skill.id, - name: skill.name, - description: skill.description, - }) - .collect::>() - }); - - match response { - Ok(skills) => { - let event = Event { - id: sub_id, - msg: EventMsg::ListRemoteSkillsResponse(ListRemoteSkillsResponseEvent { - skills, - }), - }; - sess.send_event_raw(event).await; - } - Err(err) => { - let event = Event { - id: sub_id, - msg: EventMsg::Error(ErrorEvent { - message: format!("failed to list remote skills: {err}"), - codex_error_info: Some(CodexErrorInfo::Other), - }), - }; - sess.send_event_raw(event).await; - } - } - } - - pub async fn export_remote_skill( - sess: &Session, - config: &Arc, - sub_id: String, - hazelnut_id: String, - ) { - let auth = sess.services.auth_manager.auth().await; - match crate::skills::remote::export_remote_skill( - config, - auth.as_ref(), - hazelnut_id.as_str(), - ) - .await - { - Ok(result) => { - let id = result.id; - let event = Event { - id: sub_id, - msg: EventMsg::RemoteSkillDownloaded(RemoteSkillDownloadedEvent { - id: id.clone(), - name: id, - path: result.path, - }), - }; - sess.send_event_raw(event).await; - } - Err(err) => { - let event = Event { - id: sub_id, - msg: EventMsg::Error(ErrorEvent { - message: format!("failed to export remote skill {hazelnut_id}: {err}"), - codex_error_info: Some(CodexErrorInfo::Other), - }), - }; - sess.send_event_raw(event).await; - } - } - } - pub async fn undo(sess: &Arc, sub_id: String) { let turn_context = sess.new_default_turn_with_sub_id(sub_id).await; sess.spawn_task(turn_context, Vec::new(), UndoTask::new()) @@ -6737,8 +6621,6 @@ fn realtime_text_for_event(msg: &EventMsg) -> Option { | EventMsg::McpListToolsResponse(_) | EventMsg::ListCustomPromptsResponse(_) | EventMsg::ListSkillsResponse(_) - | EventMsg::ListRemoteSkillsResponse(_) - | EventMsg::RemoteSkillDownloaded(_) | EventMsg::SkillsUpdateAvailable | EventMsg::PlanUpdate(_) | EventMsg::TurnAborted(_) diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index ca1b01b274..4600431c64 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -164,8 +164,6 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option { | EventMsg::McpStartupComplete(_) | EventMsg::ListCustomPromptsResponse(_) | EventMsg::ListSkillsResponse(_) - | EventMsg::ListRemoteSkillsResponse(_) - | EventMsg::RemoteSkillDownloaded(_) | EventMsg::PlanUpdate(_) | EventMsg::ShutdownComplete | EventMsg::DeprecationNotice(_) diff --git a/codex-rs/core/src/skills/remote.rs b/codex-rs/core/src/skills/remote.rs index 6abd402584..165c450635 100644 --- a/codex-rs/core/src/skills/remote.rs +++ b/codex-rs/core/src/skills/remote.rs @@ -9,17 +9,34 @@ use std::time::Duration; use crate::auth::CodexAuth; use crate::config::Config; use crate::default_client::build_reqwest_client; -use codex_protocol::protocol::RemoteSkillHazelnutScope; -use codex_protocol::protocol::RemoteSkillProductSurface; const REMOTE_SKILLS_API_TIMEOUT: Duration = Duration::from_secs(30); -fn as_query_hazelnut_scope(scope: RemoteSkillHazelnutScope) -> Option<&'static str> { +// Low-level client for the remote skill API. This is intentionally kept around for +// future wiring, but it is not used yet by any active product surface. + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RemoteSkillScope { + WorkspaceShared, + AllShared, + Personal, + Example, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RemoteSkillProductSurface { + Chatgpt, + Codex, + Api, + Atlas, +} + +fn as_query_scope(scope: RemoteSkillScope) -> Option<&'static str> { match scope { - RemoteSkillHazelnutScope::WorkspaceShared => Some("workspace-shared"), - RemoteSkillHazelnutScope::AllShared => Some("all-shared"), - RemoteSkillHazelnutScope::Personal => Some("personal"), - RemoteSkillHazelnutScope::Example => Some("example"), + RemoteSkillScope::WorkspaceShared => Some("workspace-shared"), + RemoteSkillScope::AllShared => Some("all-shared"), + RemoteSkillScope::Personal => Some("personal"), + RemoteSkillScope::Example => Some("example"), } } @@ -34,11 +51,11 @@ fn as_query_product_surface(product_surface: RemoteSkillProductSurface) -> &'sta fn ensure_chatgpt_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth> { let Some(auth) = auth else { - anyhow::bail!("chatgpt authentication required for hazelnut scopes"); + anyhow::bail!("chatgpt authentication required for remote skill scopes"); }; if !auth.is_chatgpt_auth() { anyhow::bail!( - "chatgpt authentication required for hazelnut scopes; api key auth is not supported" + "chatgpt authentication required for remote skill scopes; api key auth is not supported" ); } Ok(auth) @@ -59,7 +76,8 @@ pub struct RemoteSkillDownloadResult { #[derive(Debug, Deserialize)] struct RemoteSkillsResponse { - hazelnuts: Vec, + #[serde(rename = "hazelnuts")] + skills: Vec, } #[derive(Debug, Deserialize)] @@ -72,7 +90,7 @@ struct RemoteSkill { pub async fn list_remote_skills( config: &Config, auth: Option<&CodexAuth>, - hazelnut_scope: RemoteSkillHazelnutScope, + scope: RemoteSkillScope, product_surface: RemoteSkillProductSurface, enabled: Option, ) -> Result> { @@ -82,7 +100,7 @@ pub async fn list_remote_skills( let url = format!("{base_url}/hazelnuts"); let product_surface = as_query_product_surface(product_surface); let mut query_params = vec![("product_surface", product_surface)]; - if let Some(scope) = as_query_hazelnut_scope(hazelnut_scope) { + if let Some(scope) = as_query_scope(scope) { query_params.push(("scope", scope)); } if let Some(enabled) = enabled { @@ -117,7 +135,7 @@ pub async fn list_remote_skills( serde_json::from_str(&body).context("Failed to parse skills response")?; Ok(parsed - .hazelnuts + .skills .into_iter() .map(|skill| RemoteSkillSummary { id: skill.id, @@ -130,13 +148,13 @@ pub async fn list_remote_skills( pub async fn export_remote_skill( config: &Config, auth: Option<&CodexAuth>, - hazelnut_id: &str, + skill_id: &str, ) -> Result { let auth = ensure_chatgpt_auth(auth)?; let client = build_reqwest_client(); let base_url = config.chatgpt_base_url.trim_end_matches('/'); - let url = format!("{base_url}/hazelnuts/{hazelnut_id}/export"); + let url = format!("{base_url}/hazelnuts/{skill_id}/export"); let mut request = client.get(&url).timeout(REMOTE_SKILLS_API_TIMEOUT); let token = auth @@ -163,14 +181,14 @@ pub async fn export_remote_skill( anyhow::bail!("Downloaded remote skill payload is not a zip archive"); } - let output_dir = config.codex_home.join("skills").join(hazelnut_id); + let output_dir = config.codex_home.join("skills").join(skill_id); tokio::fs::create_dir_all(&output_dir) .await .context("Failed to create downloaded skills directory")?; let zip_bytes = body.to_vec(); let output_dir_clone = output_dir.clone(); - let prefix_candidates = vec![hazelnut_id.to_string()]; + let prefix_candidates = vec![skill_id.to_string()]; tokio::task::spawn_blocking(move || { extract_zip_to_dir(zip_bytes, &output_dir_clone, &prefix_candidates) }) @@ -178,7 +196,7 @@ pub async fn export_remote_skill( .context("Zip extraction task failed")??; Ok(RemoteSkillDownloadResult { - id: hazelnut_id.to_string(), + id: skill_id.to_string(), path: output_dir, }) } diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 5dfcb8247c..092e5d9990 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -870,8 +870,6 @@ impl EventProcessor for EventProcessorWithHumanOutput { | EventMsg::McpListToolsResponse(_) | EventMsg::ListCustomPromptsResponse(_) | EventMsg::ListSkillsResponse(_) - | EventMsg::ListRemoteSkillsResponse(_) - | EventMsg::RemoteSkillDownloaded(_) | EventMsg::RawResponseItem(_) | EventMsg::UserMessage(_) | EventMsg::EnteredReviewMode(_) @@ -1032,8 +1030,6 @@ impl EventProcessorWithHumanOutput { | EventMsg::McpListToolsResponse(_) | EventMsg::ListCustomPromptsResponse(_) | EventMsg::ListSkillsResponse(_) - | EventMsg::ListRemoteSkillsResponse(_) - | EventMsg::RemoteSkillDownloaded(_) | EventMsg::RawResponseItem(_) | EventMsg::UserMessage(_) | EventMsg::EnteredReviewMode(_) diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 1585b76623..780a808038 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -339,8 +339,6 @@ async fn run_codex_tool_session_inner( | EventMsg::McpListToolsResponse(_) | EventMsg::ListCustomPromptsResponse(_) | EventMsg::ListSkillsResponse(_) - | EventMsg::ListRemoteSkillsResponse(_) - | EventMsg::RemoteSkillDownloaded(_) | EventMsg::ExecCommandBegin(_) | EventMsg::TerminalInteraction(_) | EventMsg::ExecCommandOutputDelta(_) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index daf3b7d74a..69f71d4b0e 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -452,16 +452,6 @@ pub enum Op { force_reload: bool, }, - /// Request the list of remote skills available via ChatGPT sharing. - ListRemoteSkills { - hazelnut_scope: RemoteSkillHazelnutScope, - product_surface: RemoteSkillProductSurface, - enabled: Option, - }, - - /// Download a remote skill by id into the local skills cache. - DownloadRemoteSkill { hazelnut_id: String }, - /// Request the agent to summarize the current conversation context. /// The agent will use its existing context (either conversation history or previous response id) /// to generate a summary which will be returned as an AgentMessage event. @@ -532,8 +522,6 @@ impl Op { Self::ReloadUserConfig => "reload_user_config", Self::ListCustomPrompts => "list_custom_prompts", Self::ListSkills { .. } => "list_skills", - Self::ListRemoteSkills { .. } => "list_remote_skills", - Self::DownloadRemoteSkill { .. } => "download_remote_skill", Self::Compact => "compact", Self::DropMemories => "drop_memories", Self::UpdateMemories => "update_memories", @@ -1299,12 +1287,6 @@ pub enum EventMsg { /// List of skills available to the agent. ListSkillsResponse(ListSkillsResponseEvent), - /// List of remote skills available to the agent. - ListRemoteSkillsResponse(ListRemoteSkillsResponseEvent), - - /// Remote skill downloaded to local cache. - RemoteSkillDownloaded(RemoteSkillDownloadedEvent), - /// Notification that skill data may have been updated and clients may want to reload. SkillsUpdateAvailable, @@ -2917,47 +2899,6 @@ pub struct ListSkillsResponseEvent { pub skills: Vec, } -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] -pub struct RemoteSkillSummary { - pub id: String, - pub name: String, - pub description: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] -#[serde(rename_all = "kebab-case")] -#[ts(rename_all = "kebab-case")] -pub enum RemoteSkillHazelnutScope { - WorkspaceShared, - AllShared, - Personal, - Example, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] -#[serde(rename_all = "lowercase")] -#[ts(rename_all = "lowercase")] -pub enum RemoteSkillProductSurface { - Chatgpt, - Codex, - Api, - Atlas, -} - -/// Response payload for `Op::ListRemoteSkills`. -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] -pub struct ListRemoteSkillsResponseEvent { - pub skills: Vec, -} - -/// Response payload for `Op::DownloadRemoteSkill`. -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] -pub struct RemoteSkillDownloadedEvent { - pub id: String, - pub name: String, - pub path: PathBuf, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] #[serde(rename_all = "snake_case")] #[ts(rename_all = "snake_case")] diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index dbf318c616..b32fbf8f15 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5354,7 +5354,6 @@ impl ChatWidget { EventMsg::McpListToolsResponse(ev) => self.on_list_mcp_tools(ev), EventMsg::ListCustomPromptsResponse(ev) => self.on_list_custom_prompts(ev), EventMsg::ListSkillsResponse(ev) => self.on_list_skills(ev), - EventMsg::ListRemoteSkillsResponse(_) | EventMsg::RemoteSkillDownloaded(_) => {} EventMsg::SkillsUpdateAvailable => { self.submit_op(Op::ListSkills { cwds: Vec::new(), diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index f7d7c6ee76..922279657c 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -5189,7 +5189,6 @@ impl ChatWidget { ); } EventMsg::ListSkillsResponse(ev) => self.on_list_skills(ev), - EventMsg::ListRemoteSkillsResponse(_) | EventMsg::RemoteSkillDownloaded(_) => {} EventMsg::SkillsUpdateAvailable => { self.submit_op(AppCommand::list_skills( Vec::new(), diff --git a/sdk/python/src/codex_app_server/generated/v2_all.py b/sdk/python/src/codex_app_server/generated/v2_all.py index 2c000cc22f..0ff2c5897d 100644 --- a/sdk/python/src/codex_app_server/generated/v2_all.py +++ b/sdk/python/src/codex_app_server/generated/v2_all.py @@ -1133,13 +1133,6 @@ class GuardianRiskLevel(Enum): high = "high" -class HazelnutScope(Enum): - example = "example" - workspace_shared = "workspace-shared" - all_shared = "all-shared" - personal = "personal" - - class HookEventName(Enum): session_start = "sessionStart" stop = "stop" @@ -1385,6 +1378,13 @@ class LogoutAccountResponse(BaseModel): ) +class MarketplaceInterface(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + display_name: Annotated[str | None, Field(alias="displayName")] = None + + class McpAuthStatus(Enum): unsupported = "unsupported" not_logged_in = "notLoggedIn" @@ -1761,13 +1761,6 @@ class PluginUninstallResponse(BaseModel): ) -class ProductSurface(Enum): - chatgpt = "chatgpt" - codex = "codex" - api = "api" - atlas = "atlas" - - class RateLimitWindow(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -1920,15 +1913,6 @@ class ReasoningTextDeltaNotification(BaseModel): turn_id: Annotated[str, Field(alias="turnId")] -class RemoteSkillSummary(BaseModel): - model_config = ConfigDict( - populate_by_name=True, - ) - description: str - id: str - name: str - - class RequestId(RootModel[str | int]): model_config = ConfigDict( populate_by_name=True, @@ -1988,7 +1972,6 @@ class ReasoningResponseItem(BaseModel): ) content: list[ReasoningItemContent] | None = None encrypted_content: str | None = None - id: str summary: list[ReasoningItemReasoningSummary] type: Annotated[Literal["reasoning"], Field(title="ReasoningResponseItemType")] @@ -2613,41 +2596,6 @@ class SkillsListParams(BaseModel): ] = None -class SkillsRemoteReadParams(BaseModel): - model_config = ConfigDict( - populate_by_name=True, - ) - enabled: bool | None = False - hazelnut_scope: Annotated[HazelnutScope | None, Field(alias="hazelnutScope")] = ( - "example" - ) - product_surface: Annotated[ProductSurface | None, Field(alias="productSurface")] = ( - "codex" - ) - - -class SkillsRemoteReadResponse(BaseModel): - model_config = ConfigDict( - populate_by_name=True, - ) - data: list[RemoteSkillSummary] - - -class SkillsRemoteWriteParams(BaseModel): - model_config = ConfigDict( - populate_by_name=True, - ) - hazelnut_id: Annotated[str, Field(alias="hazelnutId")] - - -class SkillsRemoteWriteResponse(BaseModel): - model_config = ConfigDict( - populate_by_name=True, - ) - id: str - path: str - - class SubAgentSourceValue(Enum): review = "review" compact = "compact" @@ -3064,6 +3012,7 @@ class ThreadRealtimeAudioChunk(BaseModel): populate_by_name=True, ) data: str + item_id: Annotated[str | None, Field(alias="itemId")] = None num_channels: Annotated[int, Field(alias="numChannels", ge=0)] sample_rate: Annotated[int, Field(alias="sampleRate", ge=0)] samples_per_channel: Annotated[ @@ -3812,29 +3761,6 @@ class PluginReadRequest(BaseModel): params: PluginReadParams -class SkillsRemoteListRequest(BaseModel): - model_config = ConfigDict( - populate_by_name=True, - ) - id: RequestId - method: Annotated[ - Literal["skills/remote/list"], Field(title="Skills/remote/listRequestMethod") - ] - params: SkillsRemoteReadParams - - -class SkillsRemoteExportRequest(BaseModel): - model_config = ConfigDict( - populate_by_name=True, - ) - id: RequestId - method: Annotated[ - Literal["skills/remote/export"], - Field(title="Skills/remote/exportRequestMethod"), - ] - params: SkillsRemoteWriteParams - - class AppListRequest(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -4693,6 +4619,7 @@ class PluginMarketplaceEntry(BaseModel): model_config = ConfigDict( populate_by_name=True, ) + interface: MarketplaceInterface | None = None name: str path: AbsolutePathBuf plugins: list[PluginSummary] @@ -5603,14 +5530,6 @@ class FunctionCallOutputBody(RootModel[str | list[FunctionCallOutputContentItem] root: str | list[FunctionCallOutputContentItem] -class FunctionCallOutputPayload(BaseModel): - model_config = ConfigDict( - populate_by_name=True, - ) - body: FunctionCallOutputBody - success: bool | None = None - - class GetAccountRateLimitsResponse(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -5708,7 +5627,7 @@ class FunctionCallOutputResponseItem(BaseModel): populate_by_name=True, ) call_id: str - output: FunctionCallOutputPayload + output: FunctionCallOutputBody type: Annotated[ Literal["function_call_output"], Field(title="FunctionCallOutputResponseItemType"), @@ -5720,7 +5639,7 @@ class CustomToolCallOutputResponseItem(BaseModel): populate_by_name=True, ) call_id: str - output: FunctionCallOutputPayload + output: FunctionCallOutputBody type: Annotated[ Literal["custom_tool_call_output"], Field(title="CustomToolCallOutputResponseItemType"), @@ -6153,8 +6072,6 @@ class ClientRequest( | SkillsListRequest | PluginListRequest | PluginReadRequest - | SkillsRemoteListRequest - | SkillsRemoteExportRequest | AppListRequest | FsReadFileRequest | FsWriteFileRequest @@ -6216,8 +6133,6 @@ class ClientRequest( | SkillsListRequest | PluginListRequest | PluginReadRequest - | SkillsRemoteListRequest - | SkillsRemoteExportRequest | AppListRequest | FsReadFileRequest | FsWriteFileRequest From c6ab4ee537e5b118a20e9e0d3e0c0023cae2d982 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 17 Mar 2026 15:24:37 -0700 Subject: [PATCH 12/13] Gate realtime audio interruption logic to v2 (#14984) - thread the realtime version into conversation start and app-server notifications - keep playback-aware mic gating and playback interruption behavior on v2 only, leaving v1 on the legacy path --- .../schema/json/ServerNotification.json | 13 ++- .../codex_app_server_protocol.schemas.json | 13 ++- .../codex_app_server_protocol.v2.schemas.json | 13 ++- .../v2/ThreadRealtimeStartedNotification.json | 15 +++- .../typescript/RealtimeConversationVersion.ts | 5 ++ .../schema/typescript/index.ts | 1 + .../v2/ThreadRealtimeStartedNotification.ts | 3 +- .../src/protocol/common.rs | 2 + .../app-server-protocol/src/protocol/v2.rs | 2 + .../app-server/src/bespoke_event_handling.rs | 1 + .../tests/suite/v2/realtime_conversation.rs | 2 + codex-rs/core/config.schema.json | 16 ++-- codex-rs/core/src/auth_env_telemetry.rs | 1 + codex-rs/core/src/config/mod.rs | 8 +- codex-rs/core/src/realtime_conversation.rs | 4 +- .../core/tests/suite/realtime_conversation.rs | 2 + codex-rs/protocol/src/protocol.rs | 9 ++ codex-rs/tui/src/chatwidget/realtime.rs | 46 +++++++++- codex-rs/tui/src/lib.rs | 10 ++- codex-rs/tui/src/voice.rs | 83 +++++++++++++++---- .../src/app/app_server_adapter.rs | 1 + 21 files changed, 212 insertions(+), 38 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/RealtimeConversationVersion.ts diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 14908dbb1f..aa66a83097 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1694,6 +1694,13 @@ ], "type": "object" }, + "RealtimeConversationVersion": { + "enum": [ + "v1", + "v2" + ], + "type": "string" + }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -2857,10 +2864,14 @@ }, "threadId": { "type": "string" + }, + "version": { + "$ref": "#/definitions/RealtimeConversationVersion" } }, "required": [ - "threadId" + "threadId", + "version" ], "type": "object" }, 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 5697ca0983..b412b03f9d 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 @@ -9719,6 +9719,13 @@ } ] }, + "RealtimeConversationVersion": { + "enum": [ + "v1", + "v2" + ], + "type": "string" + }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -12767,10 +12774,14 @@ }, "threadId": { "type": "string" + }, + "version": { + "$ref": "#/definitions/v2/RealtimeConversationVersion" } }, "required": [ - "threadId" + "threadId", + "version" ], "title": "ThreadRealtimeStartedNotification", "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 91fa980a62..111a86f0f7 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 @@ -6507,6 +6507,13 @@ } ] }, + "RealtimeConversationVersion": { + "enum": [ + "v1", + "v2" + ], + "type": "string" + }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -10527,10 +10534,14 @@ }, "threadId": { "type": "string" + }, + "version": { + "$ref": "#/definitions/RealtimeConversationVersion" } }, "required": [ - "threadId" + "threadId", + "version" ], "title": "ThreadRealtimeStartedNotification", "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRealtimeStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRealtimeStartedNotification.json index 1584112640..dd94a5cc49 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRealtimeStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRealtimeStartedNotification.json @@ -1,5 +1,14 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "RealtimeConversationVersion": { + "enum": [ + "v1", + "v2" + ], + "type": "string" + } + }, "description": "EXPERIMENTAL - emitted when thread realtime startup is accepted.", "properties": { "sessionId": { @@ -10,10 +19,14 @@ }, "threadId": { "type": "string" + }, + "version": { + "$ref": "#/definitions/RealtimeConversationVersion" } }, "required": [ - "threadId" + "threadId", + "version" ], "title": "ThreadRealtimeStartedNotification", "type": "object" diff --git a/codex-rs/app-server-protocol/schema/typescript/RealtimeConversationVersion.ts b/codex-rs/app-server-protocol/schema/typescript/RealtimeConversationVersion.ts new file mode 100644 index 0000000000..cedc4bbe52 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/RealtimeConversationVersion.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 RealtimeConversationVersion = "v1" | "v2"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 396d07e12b..09e75abed8 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -49,6 +49,7 @@ export type { NetworkPolicyRuleAction } from "./NetworkPolicyRuleAction"; export type { ParsedCommand } from "./ParsedCommand"; export type { Personality } from "./Personality"; export type { PlanType } from "./PlanType"; +export type { RealtimeConversationVersion } from "./RealtimeConversationVersion"; export type { ReasoningEffort } from "./ReasoningEffort"; export type { ReasoningItemContent } from "./ReasoningItemContent"; export type { ReasoningItemReasoningSummary } from "./ReasoningItemReasoningSummary"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeStartedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeStartedNotification.ts index 736ecde1fe..d494100611 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeStartedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeStartedNotification.ts @@ -1,8 +1,9 @@ // 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 { RealtimeConversationVersion } from "../RealtimeConversationVersion"; /** * EXPERIMENTAL - emitted when thread realtime startup is accepted. */ -export type ThreadRealtimeStartedNotification = { threadId: string, sessionId: string | null, }; +export type ThreadRealtimeStartedNotification = { threadId: string, sessionId: string | null, version: RealtimeConversationVersion, }; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 46020b2c81..6a35ad78eb 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -938,6 +938,7 @@ mod tests { use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::parse_command::ParsedCommand; + use codex_protocol::protocol::RealtimeConversationVersion; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use serde_json::json; @@ -1620,6 +1621,7 @@ mod tests { ServerNotification::ThreadRealtimeStarted(v2::ThreadRealtimeStartedNotification { thread_id: "thr_123".to_string(), session_id: Some("sess_456".to_string()), + version: RealtimeConversationVersion::V1, }); let reason = crate::experimental_api::ExperimentalApi::experimental_reason(¬ification); assert_eq!(reason, Some("thread/realtime/started")); diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index c653ed0493..98b80bbe05 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -68,6 +68,7 @@ use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow; use codex_protocol::protocol::ReadOnlyAccess as CoreReadOnlyAccess; use codex_protocol::protocol::RealtimeAudioFrame as CoreRealtimeAudioFrame; +use codex_protocol::protocol::RealtimeConversationVersion; use codex_protocol::protocol::ReviewDecision as CoreReviewDecision; use codex_protocol::protocol::SessionSource as CoreSessionSource; use codex_protocol::protocol::SkillDependencies as CoreSkillDependencies; @@ -3708,6 +3709,7 @@ pub struct ThreadRealtimeStopResponse {} pub struct ThreadRealtimeStartedNotification { pub thread_id: String, pub session_id: Option, + pub version: RealtimeConversationVersion, } /// EXPERIMENTAL - raw non-audio thread realtime item emitted by the backend. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 4f4f995e2c..8a6b48a47d 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -338,6 +338,7 @@ pub(crate) async fn apply_bespoke_event_handling( let notification = ThreadRealtimeStartedNotification { thread_id: conversation_id.to_string(), session_id: event.session_id, + version: event.version, }; outgoing .send_server_notification(ServerNotification::ThreadRealtimeStarted( diff --git a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs index 71b6d6dcf3..1c30ee530d 100644 --- a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs +++ b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs @@ -25,6 +25,7 @@ use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_core::features::FEATURES; use codex_core::features::Feature; +use codex_protocol::protocol::RealtimeConversationVersion; use core_test_support::responses::start_websocket_server; use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; @@ -115,6 +116,7 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { .await?; assert_eq!(started.thread_id, thread_start.thread.id); assert!(started.session_id.is_some()); + assert_eq!(started.version, RealtimeConversationVersion::V2); let startup_context_request = realtime_server.wait_for_request(0, 0).await; assert_eq!( diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 7d3ecdaa01..ea00a7a2a2 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1359,6 +1359,13 @@ }, "type": "object" }, + "RealtimeConversationVersion": { + "enum": [ + "v1", + "v2" + ], + "type": "string" + }, "RealtimeToml": { "additionalProperties": false, "properties": { @@ -1366,7 +1373,7 @@ "$ref": "#/definitions/RealtimeWsMode" }, "version": { - "$ref": "#/definitions/RealtimeWsVersion" + "$ref": "#/definitions/RealtimeConversationVersion" } }, "type": "object" @@ -1378,13 +1385,6 @@ ], "type": "string" }, - "RealtimeWsVersion": { - "enum": [ - "v1", - "v2" - ], - "type": "string" - }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ diff --git a/codex-rs/core/src/auth_env_telemetry.rs b/codex-rs/core/src/auth_env_telemetry.rs index be281e05a1..85cd23fe06 100644 --- a/codex-rs/core/src/auth_env_telemetry.rs +++ b/codex-rs/core/src/auth_env_telemetry.rs @@ -71,6 +71,7 @@ mod tests { request_max_retries: None, stream_max_retries: None, stream_idle_timeout_ms: None, + websocket_connect_timeout_ms: None, requires_openai_auth: false, supports_websockets: false, }; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 7a543161e6..bc436a0cf9 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1535,13 +1535,7 @@ pub enum RealtimeWsMode { Transcription, } -#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum RealtimeWsVersion { - #[default] - V1, - V2, -} +pub use codex_protocol::protocol::RealtimeConversationVersion as RealtimeWsVersion; #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index 938f922f87..2a8a6337a7 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -371,7 +371,8 @@ pub(crate) async fn handle_start( format!("{prompt}\n\n{startup_context}") }; let model = config.experimental_realtime_ws_model.clone(); - let event_parser = match config.realtime.version { + let version = config.realtime.version; + let event_parser = match version { RealtimeWsVersion::V1 => RealtimeEventParser::V1, RealtimeWsVersion::V2 => RealtimeEventParser::RealtimeV2, }; @@ -411,6 +412,7 @@ pub(crate) async fn handle_start( id: sub_id.clone(), msg: EventMsg::RealtimeConversationStarted(RealtimeConversationStartedEvent { session_id: requested_session_id, + version, }), }) .await; diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index 4ab9871214..8d156d17dd 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -13,6 +13,7 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::protocol::RealtimeAudioFrame; use codex_protocol::protocol::RealtimeConversationRealtimeEvent; +use codex_protocol::protocol::RealtimeConversationVersion; use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::SessionSource; use codex_protocol::user_input::UserInput; @@ -159,6 +160,7 @@ async fn conversation_start_audio_text_close_round_trip() -> Result<()> { .await .unwrap_or_else(|err: ErrorEvent| panic!("conversation start failed: {err:?}")); assert!(started.session_id.is_some()); + assert_eq!(started.version, RealtimeConversationVersion::V1); let session_updated = wait_for_event_match(&test.codex, |msg| match msg { EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 69f71d4b0e..f74afb9563 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1428,9 +1428,18 @@ pub struct HookCompletedEvent { pub run: HookRunSummary, } +#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum RealtimeConversationVersion { + #[default] + V1, + V2, +} + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] pub struct RealtimeConversationStartedEvent { pub session_id: Option, + pub version: RealtimeConversationVersion, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/tui/src/chatwidget/realtime.rs b/codex-rs/tui/src/chatwidget/realtime.rs index 37646880e9..892e241836 100644 --- a/codex-rs/tui/src/chatwidget/realtime.rs +++ b/codex-rs/tui/src/chatwidget/realtime.rs @@ -4,6 +4,7 @@ use codex_protocol::protocol::RealtimeAudioFrame; use codex_protocol::protocol::RealtimeConversationClosedEvent; use codex_protocol::protocol::RealtimeConversationRealtimeEvent; use codex_protocol::protocol::RealtimeConversationStartedEvent; +use codex_protocol::protocol::RealtimeConversationVersion; use codex_protocol::protocol::RealtimeEvent; #[cfg(not(target_os = "linux"))] use std::sync::atomic::AtomicUsize; @@ -22,6 +23,7 @@ pub(super) enum RealtimeConversationPhase { #[derive(Default)] pub(super) struct RealtimeConversationUiState { phase: RealtimeConversationPhase, + audio_behavior: RealtimeAudioBehavior, requested_close: bool, session_id: Option, warned_audio_only_submission: bool, @@ -38,6 +40,35 @@ pub(super) struct RealtimeConversationUiState { playback_queued_samples: Arc, } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum RealtimeAudioBehavior { + #[default] + Legacy, + PlaybackAware, +} + +impl RealtimeAudioBehavior { + fn from_version(version: RealtimeConversationVersion) -> Self { + match version { + RealtimeConversationVersion::V1 => Self::Legacy, + RealtimeConversationVersion::V2 => Self::PlaybackAware, + } + } + + #[cfg(not(target_os = "linux"))] + fn input_behavior( + self, + playback_queued_samples: Arc, + ) -> crate::voice::RealtimeInputBehavior { + match self { + Self::Legacy => crate::voice::RealtimeInputBehavior::Ungated, + Self::PlaybackAware => crate::voice::RealtimeInputBehavior::PlaybackAware { + playback_queued_samples, + }, + } + } +} + impl RealtimeConversationUiState { pub(super) fn is_live(&self) -> bool { matches!( @@ -202,6 +233,7 @@ impl ChatWidget { self.realtime_conversation.phase = RealtimeConversationPhase::Starting; self.realtime_conversation.requested_close = false; self.realtime_conversation.session_id = None; + self.realtime_conversation.audio_behavior = RealtimeAudioBehavior::Legacy; self.realtime_conversation.warned_audio_only_submission = false; self.set_footer_hint_override(Some(vec![( "/realtime".to_string(), @@ -241,6 +273,7 @@ impl ChatWidget { self.realtime_conversation.phase = RealtimeConversationPhase::Inactive; self.realtime_conversation.requested_close = false; self.realtime_conversation.session_id = None; + self.realtime_conversation.audio_behavior = RealtimeAudioBehavior::Legacy; self.realtime_conversation.warned_audio_only_submission = false; } @@ -255,6 +288,7 @@ impl ChatWidget { } self.realtime_conversation.phase = RealtimeConversationPhase::Active; self.realtime_conversation.session_id = ev.session_id; + self.realtime_conversation.audio_behavior = RealtimeAudioBehavior::from_version(ev.version); self.realtime_conversation.warned_audio_only_submission = false; self.set_footer_hint_override(Some(vec![( "/realtime".to_string(), @@ -274,7 +308,11 @@ impl ChatWidget { } RealtimeEvent::InputAudioSpeechStarted(_) | RealtimeEvent::ResponseCancelled(_) => { #[cfg(not(target_os = "linux"))] - if let Some(player) = &self.realtime_conversation.audio_player { + if matches!( + self.realtime_conversation.audio_behavior, + RealtimeAudioBehavior::PlaybackAware + ) && let Some(player) = &self.realtime_conversation.audio_player + { // Once the server detects user speech or the current response is cancelled, // any buffered assistant audio is stale and should stop gating mic input. player.clear(); @@ -341,7 +379,11 @@ impl ChatWidget { let capture = match crate::voice::VoiceCapture::start_realtime( &self.config, self.app_event_tx.clone(), - Arc::clone(&self.realtime_conversation.playback_queued_samples), + self.realtime_conversation + .audio_behavior + .input_behavior(Arc::clone( + &self.realtime_conversation.playback_queued_samples, + )), ) { Ok(capture) => capture, Err(err) => { diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index f537c95bbc..6d52e020f1 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -147,6 +147,14 @@ mod voice { pub(crate) struct RealtimeAudioPlayer; + #[derive(Clone)] + pub(crate) enum RealtimeInputBehavior { + Ungated, + PlaybackAware { + playback_queued_samples: Arc, + }, + } + impl VoiceCapture { pub fn start() -> Result { Err("voice input is unavailable in this build".to_string()) @@ -155,7 +163,7 @@ mod voice { pub fn start_realtime( _config: &Config, _tx: AppEventSender, - _playback_queued_samples: Arc, + _input_behavior: RealtimeInputBehavior, ) -> Result { Err("voice input is unavailable in this build".to_string()) } diff --git a/codex-rs/tui/src/voice.rs b/codex-rs/tui/src/voice.rs index ba260b028f..510010c303 100644 --- a/codex-rs/tui/src/voice.rs +++ b/codex-rs/tui/src/voice.rs @@ -44,6 +44,14 @@ const REALTIME_INTERRUPT_INPUT_PEAK_THRESHOLD: u16 = 4_000; // callbacks so trailing syllables are not chopped up between chunks. const REALTIME_INTERRUPT_GRACE_PERIOD: Duration = Duration::from_millis(900); +#[derive(Clone)] +pub(crate) enum RealtimeInputBehavior { + Ungated, + PlaybackAware { + playback_queued_samples: Arc, + }, +} + struct TranscriptionAuthContext { mode: AuthMode, bearer_token: String, @@ -94,7 +102,7 @@ impl VoiceCapture { pub fn start_realtime( config: &Config, tx: AppEventSender, - playback_queued_samples: Arc, + input_behavior: RealtimeInputBehavior, ) -> Result { let (device, config) = select_configured_input_device_and_config(config)?; @@ -110,7 +118,7 @@ impl VoiceCapture { sample_rate, channels, tx, - playback_queued_samples, + input_behavior, last_peak.clone(), )?; stream @@ -354,7 +362,7 @@ fn build_realtime_input_stream( sample_rate: u32, channels: u16, tx: AppEventSender, - playback_queued_samples: Arc, + input_behavior: RealtimeInputBehavior, last_peak: Arc, ) -> Result { match config.sample_format() { @@ -362,7 +370,6 @@ fn build_realtime_input_stream( .build_input_stream( &config.clone().into(), { - let playback_queued_samples = Arc::clone(&playback_queued_samples); let last_peak = Arc::clone(&last_peak); let tx = tx; let mut allow_input_until = None; @@ -370,9 +377,8 @@ fn build_realtime_input_stream( let peak = peak_f32(input); if !should_send_realtime_input( peak, - &playback_queued_samples, + &input_behavior, &mut allow_input_until, - Instant::now(), ) { last_peak.store(0, Ordering::Relaxed); return; @@ -390,7 +396,6 @@ fn build_realtime_input_stream( .build_input_stream( &config.clone().into(), { - let playback_queued_samples = Arc::clone(&playback_queued_samples); let last_peak = Arc::clone(&last_peak); let tx = tx; let mut allow_input_until = None; @@ -398,9 +403,8 @@ fn build_realtime_input_stream( let peak = peak_i16(input); if !should_send_realtime_input( peak, - &playback_queued_samples, + &input_behavior, &mut allow_input_until, - Instant::now(), ) { last_peak.store(0, Ordering::Relaxed); return; @@ -417,7 +421,6 @@ fn build_realtime_input_stream( .build_input_stream( &config.clone().into(), { - let playback_queued_samples = Arc::clone(&playback_queued_samples); let last_peak = Arc::clone(&last_peak); let tx = tx; let mut allow_input_until = None; @@ -426,9 +429,8 @@ fn build_realtime_input_stream( let peak = convert_u16_to_i16_and_peak(input, &mut samples); if !should_send_realtime_input( peak, - &playback_queued_samples, + &input_behavior, &mut allow_input_until, - Instant::now(), ) { last_peak.store(0, Ordering::Relaxed); return; @@ -739,10 +741,21 @@ fn fill_output_u16( /// utterance reaches the server. fn should_send_realtime_input( peak: u16, - playback_queued_samples: &Arc, + input_behavior: &RealtimeInputBehavior, allow_input_until: &mut Option, - now: Instant, ) -> bool { + let playback_queued_samples = match input_behavior { + RealtimeInputBehavior::Ungated => { + *allow_input_until = None; + return true; + } + RealtimeInputBehavior::PlaybackAware { + playback_queued_samples, + } => playback_queued_samples, + }; + + let now = Instant::now(); + if playback_queued_samples.load(Ordering::Relaxed) == 0 { *allow_input_until = None; return true; @@ -1021,11 +1034,18 @@ async fn transcribe_bytes( #[cfg(test)] mod tests { + use super::RealtimeInputBehavior; use super::RecordedAudio; use super::convert_pcm16; use super::encode_wav_normalized; + use super::should_send_realtime_input; use pretty_assertions::assert_eq; use std::io::Cursor; + use std::sync::Arc; + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + use std::time::Duration; + use std::time::Instant; #[test] fn convert_pcm16_downmixes_and_resamples_for_model_input() { @@ -1054,4 +1074,39 @@ mod tests { assert_eq!(spec.sample_rate, 24_000); assert_eq!(samples, vec![8_426, 29_490]); } + + #[test] + fn ungated_realtime_input_ignores_playback_backlog() { + let mut allow_input_until = Some(Instant::now() + Duration::from_secs(1)); + let playback_queued_samples = Arc::new(AtomicUsize::new(1024)); + + assert!(should_send_realtime_input( + 0, + &RealtimeInputBehavior::Ungated, + &mut allow_input_until, + )); + assert_eq!(allow_input_until, None); + assert_eq!(playback_queued_samples.load(Ordering::Relaxed), 1024); + } + + #[test] + fn playback_aware_realtime_input_requires_an_interrupt_peak() { + let mut allow_input_until = None; + let playback_queued_samples = Arc::new(AtomicUsize::new(1024)); + let input_behavior = RealtimeInputBehavior::PlaybackAware { + playback_queued_samples: Arc::clone(&playback_queued_samples), + }; + + assert!(!should_send_realtime_input( + 100, + &input_behavior, + &mut allow_input_until, + )); + assert!(should_send_realtime_input( + 5_000, + &input_behavior, + &mut allow_input_until, + )); + assert!(allow_input_until.is_some()); + } } diff --git a/codex-rs/tui_app_server/src/app/app_server_adapter.rs b/codex-rs/tui_app_server/src/app/app_server_adapter.rs index 6c54bf3ce8..5efac9bc0d 100644 --- a/codex-rs/tui_app_server/src/app/app_server_adapter.rs +++ b/codex-rs/tui_app_server/src/app/app_server_adapter.rs @@ -492,6 +492,7 @@ fn server_notification_thread_events( id: String::new(), msg: EventMsg::RealtimeConversationStarted(RealtimeConversationStartedEvent { session_id: notification.session_id, + version: notification.version, }), }], )), From 9c1e03914013618d7d7bdaffaca8d5667671cb01 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 17 Mar 2026 15:42:41 -0700 Subject: [PATCH 13/13] feat: introduce sandbox-aware filesystem abstraction --- codex-rs/Cargo.lock | 14 + codex-rs/Cargo.toml | 2 + codex-rs/arg0/Cargo.toml | 1 + codex-rs/arg0/src/lib.rs | 11 + codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/bin/config_schema.rs | 10 + codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/sandboxed_fs.rs | 276 ++++++++++++++++++ .../core/src/tools/handlers/view_image.rs | 57 ++-- codex-rs/core/tests/suite/view_image.rs | 110 +++++++ codex-rs/fs-ops/BUILD.bazel | 6 + codex-rs/fs-ops/Cargo.toml | 22 ++ codex-rs/fs-ops/src/lib.rs | 229 +++++++++++++++ codex-rs/protocol/src/models.rs | 24 +- codex-rs/utils/image/src/lib.rs | 9 +- 15 files changed, 753 insertions(+), 20 deletions(-) create mode 100644 codex-rs/core/src/sandboxed_fs.rs create mode 100644 codex-rs/fs-ops/BUILD.bazel create mode 100644 codex-rs/fs-ops/Cargo.toml create mode 100644 codex-rs/fs-ops/src/lib.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 84f911bd44..32d0414ebb 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1557,6 +1557,7 @@ version = "0.0.0" dependencies = [ "anyhow", "codex-apply-patch", + "codex-fs-ops", "codex-linux-sandbox", "codex-shell-escalation", "codex-utils-home-dir", @@ -1843,6 +1844,7 @@ dependencies = [ "codex-connectors", "codex-execpolicy", "codex-file-search", + "codex-fs-ops", "codex-git", "codex-hooks", "codex-keyring-store", @@ -2063,6 +2065,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "codex-fs-ops" +version = "0.0.0" +dependencies = [ + "anyhow", + "base64 0.22.1", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "codex-git" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index b1e0fcf371..b7018679f4 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -11,6 +11,7 @@ members = [ "apply-patch", "arg0", "feedback", + "fs-ops", "codex-backend-openapi-models", "cloud-requirements", "cloud-tasks", @@ -107,6 +108,7 @@ codex-exec = { path = "exec" } codex-execpolicy = { path = "execpolicy" } codex-experimental-api-macros = { path = "codex-experimental-api-macros" } codex-feedback = { path = "feedback" } +codex-fs-ops = { path = "fs-ops" } codex-file-search = { path = "file-search" } codex-git = { path = "utils/git" } codex-hooks = { path = "hooks" } diff --git a/codex-rs/arg0/Cargo.toml b/codex-rs/arg0/Cargo.toml index abe7277d94..10c31eac5e 100644 --- a/codex-rs/arg0/Cargo.toml +++ b/codex-rs/arg0/Cargo.toml @@ -14,6 +14,7 @@ workspace = true [dependencies] anyhow = { workspace = true } codex-apply-patch = { workspace = true } +codex-fs-ops = { workspace = true } codex-linux-sandbox = { workspace = true } codex-shell-escalation = { workspace = true } codex-utils-home-dir = { workspace = true } diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index d8dbaaa30d..aee33e9649 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -4,6 +4,7 @@ use std::path::Path; use std::path::PathBuf; use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1; +use codex_fs_ops::CODEX_CORE_FS_OPS_ARG1; use codex_utils_home_dir::find_codex_home; #[cfg(unix)] use std::os::unix::fs::symlink; @@ -105,6 +106,16 @@ pub fn arg0_dispatch() -> Option { }; std::process::exit(exit_code); } + if argv1 == CODEX_CORE_FS_OPS_ARG1 { + let exit_code = match codex_fs_ops::run_from_args(args) { + Ok(()) => 0, + Err(err) => { + eprintln!("Error: failed to run fs helper: {err}"); + 1 + } + }; + std::process::exit(exit_code); + } // This modifies the environment, which is not thread-safe, so do this // before creating any threads/the Tokio runtime. diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index ef6b8a0132..ee297aa53b 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -37,6 +37,7 @@ codex-config = { workspace = true } codex-shell-command = { workspace = true } codex-skills = { workspace = true } codex-execpolicy = { workspace = true } +codex-fs-ops = { workspace = true } codex-file-search = { workspace = true } codex-git = { workspace = true } codex-hooks = { workspace = true } diff --git a/codex-rs/core/src/bin/config_schema.rs b/codex-rs/core/src/bin/config_schema.rs index 8d33df42e1..b94b4122d6 100644 --- a/codex-rs/core/src/bin/config_schema.rs +++ b/codex-rs/core/src/bin/config_schema.rs @@ -1,5 +1,6 @@ use anyhow::Result; use clap::Parser; +use codex_fs_ops::CODEX_CORE_FS_OPS_ARG1; use std::path::PathBuf; /// Generate the JSON Schema for `config.toml` and write it to `config.schema.json`. @@ -11,6 +12,15 @@ struct Args { } fn main() -> Result<()> { + let mut args = std::env::args_os(); + let _program_name = args.next(); + if matches!( + args.next().as_deref(), + Some(flag) if flag == std::ffi::OsStr::new(CODEX_CORE_FS_OPS_ARG1) + ) { + return codex_fs_ops::run_from_args(args); + } + let args = Args::parse(); let out_path = args .out diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 51d9fcf8d6..423770de31 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -113,6 +113,7 @@ pub mod default_client; pub mod project_doc; mod rollout; pub(crate) mod safety; +mod sandboxed_fs; pub mod seatbelt; pub mod shell; pub mod shell_snapshot; diff --git a/codex-rs/core/src/sandboxed_fs.rs b/codex-rs/core/src/sandboxed_fs.rs new file mode 100644 index 0000000000..76f7123bcc --- /dev/null +++ b/codex-rs/core/src/sandboxed_fs.rs @@ -0,0 +1,276 @@ +use crate::codex::Session; +use crate::codex::TurnContext; +use crate::exec::ExecExpiration; +use crate::sandboxing::CommandSpec; +use crate::sandboxing::SandboxPermissions; +use crate::sandboxing::execute_env; +use crate::sandboxing::merge_permission_profiles; +use crate::tools::sandboxing::SandboxAttempt; +use crate::tools::sandboxing::SandboxablePreference; +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use codex_fs_ops::CODEX_CORE_FS_OPS_ARG1; +use codex_fs_ops::FsError; +use codex_fs_ops::FsErrorKind; +use codex_fs_ops::FsPayload; +use codex_fs_ops::FsResponse; +use codex_protocol::models::PermissionProfile; +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +const SANDBOXED_FS_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Debug, thiserror::Error)] +pub(crate) enum SandboxedFsError { + #[error("failed to determine codex executable: {message}")] + ResolveExe { message: String }, + #[error("sandboxed fs helper timed out while accessing `{path}`")] + TimedOut { path: PathBuf }, + #[error("sandboxed fs helper exited with code {exit_code} while accessing `{path}`: {message}")] + ProcessFailed { + path: PathBuf, + exit_code: i32, + message: String, + }, + #[error("sandboxed fs helper returned invalid output for `{path}`: {message}")] + InvalidResponse { path: PathBuf, message: String }, + #[error("sandboxed fs helper could not access `{path}`: {error}")] + Operation { path: PathBuf, error: FsError }, +} + +impl SandboxedFsError { + pub(crate) fn operation_error_kind(&self) -> Option<&FsErrorKind> { + match self { + Self::Operation { error, .. } => Some(&error.kind), + _ => None, + } + } + + pub(crate) fn operation_error_message(&self) -> Option<&str> { + match self { + Self::Operation { error, .. } => Some(error.message.as_str()), + _ => None, + } + } + + #[allow(dead_code)] + pub(crate) fn to_io_error(&self) -> std::io::Error { + match self { + Self::Operation { error, .. } => error.to_io_error(), + Self::TimedOut { .. } => { + std::io::Error::new(std::io::ErrorKind::TimedOut, self.to_string()) + } + Self::ResolveExe { .. } | Self::ProcessFailed { .. } | Self::InvalidResponse { .. } => { + std::io::Error::other(self.to_string()) + } + } + } +} + +pub(crate) async fn read_bytes( + session: &Arc, + turn: &Arc, + path: &Path, +) -> Result, SandboxedFsError> { + let path_buf = path.to_path_buf(); + let payload = run_request(session, turn, "read_bytes", &path_buf).await?; + let FsPayload::Bytes { base64 } = payload else { + return Err(SandboxedFsError::InvalidResponse { + path: path_buf, + message: "expected bytes payload".to_string(), + }); + }; + BASE64_STANDARD + .decode(base64) + .map_err(|error| SandboxedFsError::InvalidResponse { + path: path.to_path_buf(), + message: format!("failed to decode base64 payload: {error}"), + }) +} + +#[allow(dead_code)] +pub(crate) async fn read_text( + session: &Arc, + turn: &Arc, + path: &Path, +) -> Result { + let path_buf = path.to_path_buf(); + let payload = run_request(session, turn, "read_text", &path_buf).await?; + let FsPayload::Text { text } = payload else { + return Err(SandboxedFsError::InvalidResponse { + path: path_buf, + message: "expected text payload".to_string(), + }); + }; + Ok(text) +} + +async fn run_request( + session: &Arc, + turn: &Arc, + operation: &str, + path: &Path, +) -> Result { + let exe = resolve_codex_exe(turn)?; + let additional_permissions = effective_granted_permissions(session).await; + let sandbox_manager = crate::sandboxing::SandboxManager::new(); + let attempt = SandboxAttempt { + sandbox: sandbox_manager.select_initial( + &turn.file_system_sandbox_policy, + turn.network_sandbox_policy, + SandboxablePreference::Auto, + turn.windows_sandbox_level, + /*has_managed_network_requirements*/ false, + ), + policy: &turn.sandbox_policy, + file_system_policy: &turn.file_system_sandbox_policy, + network_policy: turn.network_sandbox_policy, + enforce_managed_network: false, + manager: &sandbox_manager, + sandbox_cwd: &turn.cwd, + codex_linux_sandbox_exe: turn.codex_linux_sandbox_exe.as_ref(), + use_legacy_landlock: turn.features.use_legacy_landlock(), + windows_sandbox_level: turn.windows_sandbox_level, + windows_sandbox_private_desktop: turn.config.permissions.windows_sandbox_private_desktop, + }; + let exec_request = attempt + .env_for( + CommandSpec { + program: exe.to_string_lossy().to_string(), + args: vec![ + CODEX_CORE_FS_OPS_ARG1.to_string(), + operation.to_string(), + path.to_string_lossy().to_string(), + ], + cwd: turn.cwd.clone(), + env: HashMap::new(), + expiration: ExecExpiration::Timeout(SANDBOXED_FS_TIMEOUT), + sandbox_permissions: SandboxPermissions::UseDefault, + additional_permissions, + justification: None, + }, + None, + ) + .map_err(|error| SandboxedFsError::ProcessFailed { + path: path.to_path_buf(), + exit_code: -1, + message: error.to_string(), + })?; + let output = + execute_env(exec_request, None) + .await + .map_err(|error| SandboxedFsError::ProcessFailed { + path: path.to_path_buf(), + exit_code: -1, + message: error.to_string(), + })?; + + if output.timed_out { + return Err(SandboxedFsError::TimedOut { + path: path.to_path_buf(), + }); + } + if output.exit_code != 0 { + let stderr = output.stderr.text.trim(); + let stdout = output.stdout.text.trim(); + let message = if !stderr.is_empty() { + stderr.to_string() + } else if !stdout.is_empty() { + stdout.to_string() + } else { + "no error details emitted".to_string() + }; + return Err(SandboxedFsError::ProcessFailed { + path: path.to_path_buf(), + exit_code: output.exit_code, + message, + }); + } + + let response: FsResponse = + serde_json::from_str(output.stdout.text.trim()).map_err(|error| { + SandboxedFsError::InvalidResponse { + path: path.to_path_buf(), + message: format!("failed to parse helper response: {error}"), + } + })?; + + match response { + FsResponse::Success { payload } => Ok(payload), + FsResponse::Error { error } => Err(SandboxedFsError::Operation { + path: path.to_path_buf(), + error, + }), + } +} + +async fn effective_granted_permissions(session: &Session) -> Option { + let granted_session_permissions = session.granted_session_permissions().await; + let granted_turn_permissions = session.granted_turn_permissions().await; + merge_permission_profiles( + granted_session_permissions.as_ref(), + granted_turn_permissions.as_ref(), + ) +} + +fn resolve_codex_exe(turn: &TurnContext) -> Result { + #[cfg(target_os = "windows")] + { + let current_exe = + std::env::current_exe().map_err(|error| SandboxedFsError::ResolveExe { + message: error.to_string(), + })?; + if is_codex_launcher(¤t_exe) { + return Ok(current_exe); + } + if let Some(helper) = sibling_fs_helper_launcher(¤t_exe) + && helper.is_file() + { + return Ok(helper); + } + Ok(codex_windows_sandbox::resolve_current_exe_for_launch( + &turn.config.codex_home, + "codex.exe", + )) + } + #[cfg(not(target_os = "windows"))] + { + let _ = turn; + let current_exe = + std::env::current_exe().map_err(|error| SandboxedFsError::ResolveExe { + message: error.to_string(), + })?; + if is_codex_launcher(¤t_exe) { + return Ok(current_exe); + } + if let Some(helper) = sibling_fs_helper_launcher(¤t_exe) + && helper.is_file() + { + return Ok(helper); + } + Ok(current_exe) + } +} + +fn sibling_fs_helper_launcher(current_exe: &Path) -> Option { + let bin_dir = current_exe.parent()?.parent()?; + #[cfg(target_os = "windows")] + let helper_name = "codex-write-config-schema.exe"; + #[cfg(not(target_os = "windows"))] + let helper_name = "codex-write-config-schema"; + Some(bin_dir.join(helper_name)) +} + +fn is_codex_launcher(path: &Path) -> bool { + path.file_name() + .and_then(std::ffi::OsStr::to_str) + .is_some_and(|name| { + matches!( + name, + "codex" | "codex.exe" | "codex-exec" | "codex-exec.exe" + ) + }) +} diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index 5757aeb4e1..b6613b8aad 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -2,16 +2,16 @@ use async_trait::async_trait; use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::ImageDetail; -use codex_protocol::models::local_image_content_items_with_label_number; +use codex_protocol::models::local_image_content_items_from_bytes_with_label_number; use codex_protocol::openai_models::InputModality; use codex_utils_image::PromptImageMode; use serde::Deserialize; -use tokio::fs; use crate::function_tool::FunctionCallError; use crate::original_image_detail::can_request_original_image_detail; use crate::protocol::EventMsg; use crate::protocol::ViewImageToolCallEvent; +use crate::sandboxed_fs; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; @@ -88,20 +88,6 @@ impl ToolHandler for ViewImageHandler { }; let abs_path = turn.resolve_path(Some(args.path)); - - let metadata = fs::metadata(&abs_path).await.map_err(|error| { - FunctionCallError::RespondToModel(format!( - "unable to locate image at `{}`: {error}", - abs_path.display() - )) - })?; - - if !metadata.is_file() { - return Err(FunctionCallError::RespondToModel(format!( - "image path `{}` is not a file", - abs_path.display() - ))); - } let event_path = abs_path.clone(); let can_request_original_detail = @@ -114,9 +100,17 @@ impl ToolHandler for ViewImageHandler { PromptImageMode::ResizeToFit }; let image_detail = use_original_detail.then_some(ImageDetail::Original); + let image_bytes = sandboxed_fs::read_bytes(&session, &turn, &abs_path) + .await + .map_err(|error| { + FunctionCallError::RespondToModel(render_view_image_read_error(&abs_path, &error)) + })?; - let content = local_image_content_items_with_label_number( - &abs_path, /*label_number*/ None, image_mode, + let content = local_image_content_items_from_bytes_with_label_number( + &abs_path, + image_bytes, + /*label_number*/ None, + image_mode, ) .into_iter() .map(|item| match item { @@ -142,3 +136,30 @@ impl ToolHandler for ViewImageHandler { Ok(FunctionToolOutput::from_content(content, Some(true))) } } + +fn render_view_image_read_error( + path: &std::path::Path, + error: &sandboxed_fs::SandboxedFsError, +) -> String { + let operation_message = error + .operation_error_message() + .map(str::to_owned) + .unwrap_or_else(|| error.to_string()); + match error.operation_error_kind() { + Some(codex_fs_ops::FsErrorKind::IsADirectory) => { + format!("image path `{}` is not a file", path.display()) + } + Some(codex_fs_ops::FsErrorKind::NotFound) => { + format!( + "unable to locate image at `{}`: {operation_message}", + path.display() + ) + } + Some(_) | None => { + format!( + "unable to read image at `{}`: {operation_message}", + path.display() + ) + } + } +} diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index a3e341f192..1ce0d12dcc 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -3,6 +3,7 @@ use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_core::CodexAuth; +use codex_core::config::Constrained; use codex_core::features::Feature; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::openai_models::ConfigShellToolType; @@ -13,9 +14,15 @@ use codex_protocol::openai_models::ModelsResponse; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::openai_models::TruncationPolicyConfig; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; +use codex_protocol::protocol::ReadOnlyAccess; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; use core_test_support::responses; @@ -1244,6 +1251,109 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn view_image_tool_respects_filesystem_sandbox() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let sandbox_policy_for_config = SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::Restricted { + include_platform_defaults: true, + readable_roots: Vec::new(), + }, + network_access: false, + }; + let mut builder = test_codex().with_config({ + let sandbox_policy_for_config = sandbox_policy_for_config.clone(); + move |config| { + config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config); + config.permissions.file_system_sandbox_policy = + FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Minimal, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: FileSystemAccessMode::Read, + }, + ]); + } + }); + let TestCodex { + codex, + config, + cwd, + session_configured, + .. + } = builder.build(&server).await?; + + let outside_dir = tempfile::tempdir()?; + let abs_path = outside_dir.path().join("blocked.png"); + let image = ImageBuffer::from_pixel(256, 128, Rgba([10u8, 20, 30, 255])); + image.save(&abs_path)?; + + let call_id = "view-image-sandbox-denied"; + let arguments = serde_json::json!({ "path": abs_path }).to_string(); + + let first_response = sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "view_image", &arguments), + ev_completed("resp-1"), + ]); + responses::mount_sse_once(&server, first_response).await; + + let second_response = sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]); + let mock = responses::mount_sse_once(&server, second_response).await; + + let session_model = session_configured.model.clone(); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "please attach the outside image".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: config.permissions.sandbox_policy.get().clone(), + model: session_model, + effort: None, + summary: None, + service_tier: None, + collaboration_mode: None, + personality: None, + }) + .await?; + + wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + + let request = mock.single_request(); + assert!( + request.inputs_of_type("input_image").is_empty(), + "sandbox-denied image should not produce an input_image message" + ); + let output_text = request + .function_call_output_content_and_success(call_id) + .and_then(|(content, _)| content) + .expect("output text present"); + let expected_prefix = format!("unable to read image at `{}`:", abs_path.display()); + assert!( + output_text.starts_with(&expected_prefix), + "expected sandbox denial prefix `{expected_prefix}` but got `{output_text}`" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/fs-ops/BUILD.bazel b/codex-rs/fs-ops/BUILD.bazel new file mode 100644 index 0000000000..87fe4d5517 --- /dev/null +++ b/codex-rs/fs-ops/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "fs-ops", + crate_name = "codex_fs_ops", +) diff --git a/codex-rs/fs-ops/Cargo.toml b/codex-rs/fs-ops/Cargo.toml new file mode 100644 index 0000000000..2b7495f0d2 --- /dev/null +++ b/codex-rs/fs-ops/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "codex-fs-ops" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_fs_ops" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +base64 = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/fs-ops/src/lib.rs b/codex-rs/fs-ops/src/lib.rs new file mode 100644 index 0000000000..32d5c2cf6f --- /dev/null +++ b/codex-rs/fs-ops/src/lib.rs @@ -0,0 +1,229 @@ +use anyhow::Context; +use anyhow::Result; +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use serde::Deserialize; +use serde::Serialize; +use std::ffi::OsString; +use std::io::ErrorKind; +use std::io::Write; +use std::path::PathBuf; + +/// Special argv[1] flag used when the Codex executable self-invokes to run the +/// internal sandbox-backed filesystem helper path. +pub const CODEX_CORE_FS_OPS_ARG1: &str = "--codex-run-as-fs-ops"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FsCommand { + ReadBytes { path: PathBuf }, + ReadText { path: PathBuf }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FsErrorKind { + NotFound, + PermissionDenied, + IsADirectory, + InvalidData, + Other, +} + +impl From for FsErrorKind { + fn from(value: ErrorKind) -> Self { + match value { + ErrorKind::NotFound => Self::NotFound, + ErrorKind::PermissionDenied => Self::PermissionDenied, + ErrorKind::IsADirectory => Self::IsADirectory, + ErrorKind::InvalidData => Self::InvalidData, + _ => Self::Other, + } + } +} + +impl FsErrorKind { + pub fn to_io_error_kind(&self) -> ErrorKind { + match self { + Self::NotFound => ErrorKind::NotFound, + Self::PermissionDenied => ErrorKind::PermissionDenied, + Self::IsADirectory => ErrorKind::IsADirectory, + Self::InvalidData => ErrorKind::InvalidData, + Self::Other => ErrorKind::Other, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FsError { + pub kind: FsErrorKind, + pub message: String, + pub raw_os_error: Option, +} + +impl std::fmt::Display for FsError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl FsError { + pub fn to_io_error(&self) -> std::io::Error { + if let Some(raw_os_error) = self.raw_os_error { + std::io::Error::from_raw_os_error(raw_os_error) + } else { + std::io::Error::new(self.kind.to_io_error_kind(), self.message.clone()) + } + } +} + +impl From for FsError { + fn from(error: std::io::Error) -> Self { + Self { + kind: error.kind().into(), + message: error.to_string(), + raw_os_error: error.raw_os_error(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum FsPayload { + Bytes { base64: String }, + Text { text: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum FsResponse { + Success { payload: FsPayload }, + Error { error: FsError }, +} + +pub fn parse_command_from_args( + mut args: impl Iterator, +) -> Result { + let Some(operation) = args.next() else { + return Err("missing operation".to_string()); + }; + let Some(operation) = operation.to_str() else { + return Err("operation must be valid UTF-8".to_string()); + }; + let Some(path) = args.next() else { + return Err(format!("missing path for operation `{operation}`")); + }; + if args.next().is_some() { + return Err(format!( + "unexpected extra arguments for operation `{operation}`" + )); + } + + let path = PathBuf::from(path); + match operation { + "read_bytes" => Ok(FsCommand::ReadBytes { path }), + "read_text" => Ok(FsCommand::ReadText { path }), + _ => Err(format!( + "unsupported filesystem operation `{operation}`; expected one of `read_bytes`, `read_text`" + )), + } +} + +pub fn execute(command: FsCommand) -> FsResponse { + match command { + FsCommand::ReadBytes { path } => match std::fs::read(&path) { + Ok(bytes) => FsResponse::Success { + payload: FsPayload::Bytes { + base64: BASE64_STANDARD.encode(bytes), + }, + }, + Err(error) => FsResponse::Error { + error: error.into(), + }, + }, + FsCommand::ReadText { path } => match std::fs::read_to_string(&path) { + Ok(text) => FsResponse::Success { + payload: FsPayload::Text { text }, + }, + Err(error) => FsResponse::Error { + error: error.into(), + }, + }, + } +} + +pub fn write_response(stdout: &mut impl Write, response: &FsResponse) -> Result<()> { + serde_json::to_writer(&mut *stdout, response).context("failed to serialize fs response")?; + writeln!(stdout).context("failed to terminate fs response with newline")?; + Ok(()) +} + +pub fn run_from_args(args: impl Iterator) -> Result<()> { + let command = parse_command_from_args(args).map_err(anyhow::Error::msg)?; + let response = execute(command); + let mut stdout = std::io::stdout().lock(); + write_response(&mut stdout, &response) +} + +#[cfg(test)] +mod tests { + use super::FsCommand; + use super::FsErrorKind; + use super::FsPayload; + use super::FsResponse; + use super::execute; + use super::parse_command_from_args; + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + #[test] + fn parse_read_bytes_command() { + let command = parse_command_from_args( + ["read_bytes", "/tmp/example.png"] + .into_iter() + .map(Into::into), + ) + .expect("command should parse"); + + assert_eq!( + command, + FsCommand::ReadBytes { + path: "/tmp/example.png".into(), + } + ); + } + + #[test] + fn read_text_returns_text_payload() { + let tempdir = tempdir().expect("tempdir"); + let path = tempdir.path().join("note.txt"); + std::fs::write(&path, "hello").expect("write test file"); + + let response = execute(FsCommand::ReadText { path }); + + assert_eq!( + response, + FsResponse::Success { + payload: FsPayload::Text { + text: "hello".to_string(), + }, + } + ); + } + + #[test] + fn read_bytes_reports_directory_error() { + let tempdir = tempdir().expect("tempdir"); + + let response = execute(FsCommand::ReadBytes { + path: tempdir.path().to_path_buf(), + }); + + let FsResponse::Error { error } = response else { + panic!("expected error response"); + }; + #[cfg(target_os = "windows")] + assert_eq!(error.kind, FsErrorKind::PermissionDenied); + #[cfg(not(target_os = "windows"))] + assert_eq!(error.kind, FsErrorKind::IsADirectory); + } +} diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 1e5a7445e3..f60f431132 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -3,6 +3,7 @@ use std::path::Path; use codex_utils_image::PromptImageMode; use codex_utils_image::load_for_prompt; +use codex_utils_image::load_for_prompt_from_bytes; use serde::Deserialize; use serde::Deserializer; use serde::Serialize; @@ -947,7 +948,28 @@ pub fn local_image_content_items_with_label_number( label_number: Option, mode: PromptImageMode, ) -> Vec { - match load_for_prompt(path, mode) { + local_image_content_items_for_load_result(path, label_number, load_for_prompt(path, mode)) +} + +pub fn local_image_content_items_from_bytes_with_label_number( + path: &std::path::Path, + bytes: Vec, + label_number: Option, + mode: PromptImageMode, +) -> Vec { + local_image_content_items_for_load_result( + path, + label_number, + load_for_prompt_from_bytes(path, bytes, mode), + ) +} + +fn local_image_content_items_for_load_result( + path: &std::path::Path, + label_number: Option, + image_result: Result, +) -> Vec { + match image_result { Ok(image) => { let mut items = Vec::with_capacity(3); if let Some(label_number) = label_number { diff --git a/codex-rs/utils/image/src/lib.rs b/codex-rs/utils/image/src/lib.rs index d0aba2f608..89547fa3a7 100644 --- a/codex-rs/utils/image/src/lib.rs +++ b/codex-rs/utils/image/src/lib.rs @@ -62,9 +62,16 @@ pub fn load_for_prompt( mode: PromptImageMode, ) -> Result { let path_buf = path.to_path_buf(); - let file_bytes = read_file_bytes(path, &path_buf)?; + load_for_prompt_from_bytes(path, file_bytes, mode) +} +pub fn load_for_prompt_from_bytes( + path: &Path, + file_bytes: Vec, + mode: PromptImageMode, +) -> Result { + let path_buf = path.to_path_buf(); let key = ImageCacheKey { digest: sha1_digest(&file_bytes), mode,