diff --git a/codex-rs/.config/nextest.toml b/codex-rs/.config/nextest.toml index f432af88ea..3f68bbbe96 100644 --- a/codex-rs/.config/nextest.toml +++ b/codex-rs/.config/nextest.toml @@ -2,6 +2,12 @@ # Do not increase, fix your test instead slow-timeout = { period = "15s", terminate-after = 2 } +[test-groups.app_server_protocol_codegen] +max-threads = 1 + +[test-groups.app_server_integration] +max-threads = 1 + [[profile.default.overrides]] # Do not add new tests here @@ -11,3 +17,13 @@ slow-timeout = { period = "1m", terminate-after = 4 } [[profile.default.overrides]] filter = 'test(approval_matrix_covers_all_modes)' slow-timeout = { period = "30s", terminate-after = 2 } + +[[profile.default.overrides]] +filter = 'package(codex-app-server-protocol) & (test(typescript_schema_fixtures_match_generated) | test(json_schema_fixtures_match_generated) | test(generate_ts_with_experimental_api_retains_experimental_entries) | test(generated_ts_optional_nullable_fields_only_in_params) | test(generate_json_filters_experimental_fields_and_methods))' +test-group = 'app_server_protocol_codegen' + +[[profile.default.overrides]] +# These integration tests spawn a fresh app-server subprocess per case. +# Keep the library unit tests parallel. +filter = 'package(codex-app-server) & kind(test)' +test-group = 'app_server_integration' diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 7fc34083f2..2fed4efe17 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -827,6 +827,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ "axum-core", + "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", @@ -845,8 +846,10 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1", "sync_wrapper", "tokio", + "tokio-tungstenite", "tower", "tower-layer", "tower-service", @@ -1441,6 +1444,7 @@ dependencies = [ "futures", "owo-colors", "pretty_assertions", + "reqwest", "rmcp", "serde", "serde_json", @@ -1458,6 +1462,24 @@ dependencies = [ "wiremock", ] +[[package]] +name = "codex-app-server-client" +version = "0.0.0" +dependencies = [ + "codex-app-server", + "codex-app-server-protocol", + "codex-arg0", + "codex-core", + "codex-feedback", + "codex-protocol", + "pretty_assertions", + "serde", + "serde_json", + "tokio", + "toml 0.9.11+spec-1.1.0", + "tracing", +] + [[package]] name = "codex-app-server-protocol" version = "0.0.0" @@ -1899,10 +1921,13 @@ dependencies = [ "anyhow", "assert_cmd", "clap", + "codex-app-server-client", + "codex-app-server-protocol", "codex-apply-patch", "codex-arg0", "codex-cloud-requirements", "codex-core", + "codex-feedback", "codex-otel", "codex-protocol", "codex-utils-absolute-path", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index cbf7bb6abf..681487c099 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -4,6 +4,7 @@ members = [ "ansi-escape", "async-utils", "app-server", + "app-server-client", "app-server-protocol", "app-server-test-client", "debug-client", @@ -86,6 +87,7 @@ codex-api = { path = "codex-api" } codex-artifacts = { path = "artifacts" } codex-package-manager = { path = "package-manager" } codex-app-server = { path = "app-server" } +codex-app-server-client = { path = "app-server-client" } codex-app-server-protocol = { path = "app-server-protocol" } codex-app-server-test-client = { path = "app-server-test-client" } codex-apply-patch = { path = "apply-patch" } diff --git a/codex-rs/app-server-client/BUILD.bazel b/codex-rs/app-server-client/BUILD.bazel new file mode 100644 index 0000000000..953de7421e --- /dev/null +++ b/codex-rs/app-server-client/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "app-server-client", + crate_name = "codex_app_server_client", +) diff --git a/codex-rs/app-server-client/Cargo.toml b/codex-rs/app-server-client/Cargo.toml new file mode 100644 index 0000000000..addde4e529 --- /dev/null +++ b/codex-rs/app-server-client/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "codex-app-server-client" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_app_server_client" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +codex-app-server = { workspace = true } +codex-app-server-protocol = { workspace = true } +codex-arg0 = { workspace = true } +codex-core = { workspace = true } +codex-feedback = { workspace = true } +codex-protocol = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["sync", "time", "rt"] } +toml = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/codex-rs/app-server-client/README.md b/codex-rs/app-server-client/README.md new file mode 100644 index 0000000000..8944ab4ba1 --- /dev/null +++ b/codex-rs/app-server-client/README.md @@ -0,0 +1,67 @@ +# codex-app-server-client + +Shared in-process app-server client used by conversational CLI surfaces: + +- `codex-exec` +- `codex-tui` + +## Purpose + +This crate centralizes startup and lifecycle management for an in-process +`codex-app-server` runtime, so CLI clients do not need to duplicate: + +- app-server bootstrap and initialize handshake +- in-memory request/event transport wiring +- lifecycle orchestration around caller-provided startup identity +- graceful shutdown behavior + +## Startup identity + +Callers pass both the app-server `SessionSource` and the initialize +`client_info.name` explicitly when starting the facade. + +That keeps thread metadata (for example in `thread/list` and `thread/read`) +aligned with the originating runtime without baking TUI/exec-specific policy +into the shared client layer. + +## Transport model + +The in-process path uses typed channels: + +- client -> server: `ClientRequest` / `ClientNotification` +- server -> client: `InProcessServerEvent` + - `ServerRequest` + - `ServerNotification` + - `LegacyNotification` + +JSON serialization is still used at external transport boundaries +(stdio/websocket), but the in-process hot path is typed. + +Typed requests still receive app-server responses through the JSON-RPC +result envelope internally. That is intentional: the in-process path is +meant to preserve app-server semantics while removing the process +boundary, not to introduce a second response contract. + +## Bootstrap behavior + +The client facade starts an already-initialized in-process runtime, but +thread bootstrap still follows normal app-server flow: + +- caller sends `thread/start` or `thread/resume` +- app-server returns the immediate typed response +- richer session metadata may arrive later as a `SessionConfigured` + legacy event + +Surfaces such as TUI and exec may therefore need a short bootstrap +phase where they reconcile startup response data with later events. + +## Backpressure and shutdown + +- Queues are bounded and use `DEFAULT_IN_PROCESS_CHANNEL_CAPACITY` by default. +- Full queues return explicit overload behavior instead of unbounded growth. +- `shutdown()` performs a bounded graceful shutdown and then aborts if timeout + is exceeded. + +If the client falls behind on event consumption, the worker emits +`InProcessServerEvent::Lagged` and may reject pending server requests so +approval flows do not hang indefinitely behind a saturated queue. diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs new file mode 100644 index 0000000000..80a328384f --- /dev/null +++ b/codex-rs/app-server-client/src/lib.rs @@ -0,0 +1,801 @@ +//! Shared in-process app-server client facade for CLI surfaces. +//! +//! This crate wraps [`codex_app_server::in_process`] behind a single async API +//! used by surfaces like TUI and exec. It centralizes: +//! +//! - Runtime startup and initialize-capabilities handshake. +//! - Typed caller-provided startup identity (`SessionSource` + client name). +//! - Typed and raw request/notification dispatch. +//! - Server request resolution and rejection. +//! - Event consumption with backpressure signaling ([`InProcessServerEvent::Lagged`]). +//! - Bounded graceful shutdown with abort fallback. +//! +//! The facade interposes a worker task between the caller and the underlying +//! [`InProcessClientHandle`](codex_app_server::in_process::InProcessClientHandle), +//! bridging async `mpsc` channels on both sides. Queues are bounded so overload +//! surfaces as channel-full errors rather than unbounded memory growth. + +use std::error::Error; +use std::fmt; +use std::io::Error as IoError; +use std::io::ErrorKind; +use std::io::Result as IoResult; +use std::sync::Arc; +use std::time::Duration; + +pub use codex_app_server::in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY; +pub use codex_app_server::in_process::InProcessServerEvent; +use codex_app_server::in_process::InProcessStartArgs; +use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientNotification; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ConfigWarningNotification; +use codex_app_server_protocol::InitializeCapabilities; +use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::Result as JsonRpcResult; +use codex_arg0::Arg0DispatchPaths; +use codex_core::config::Config; +use codex_core::config_loader::CloudRequirementsLoader; +use codex_core::config_loader::LoaderOverrides; +use codex_feedback::CodexFeedback; +use codex_protocol::protocol::SessionSource; +use serde::de::DeserializeOwned; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio::time::timeout; +use toml::Value as TomlValue; +use tracing::warn; + +const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); + +/// Raw app-server request result for typed in-process requests. +/// +/// Even on the in-process path, successful responses still travel back through +/// the same JSON-RPC result envelope used by socket/stdio transports because +/// `MessageProcessor` continues to produce that shape internally. +pub type RequestResult = std::result::Result; + +fn event_requires_delivery(event: &InProcessServerEvent) -> bool { + // These terminal events drive surface shutdown/completion state. Dropping + // them under backpressure can leave exec/TUI waiting forever even though + // the underlying turn has already ended. + match event { + InProcessServerEvent::ServerNotification( + codex_app_server_protocol::ServerNotification::TurnCompleted(_), + ) => true, + InProcessServerEvent::LegacyNotification(notification) => matches!( + notification + .method + .strip_prefix("codex/event/") + .unwrap_or(¬ification.method), + "task_complete" | "turn_aborted" | "shutdown_complete" + ), + _ => false, + } +} + +/// Layered error for [`InProcessAppServerClient::request_typed`]. +/// +/// This keeps transport failures, server-side JSON-RPC failures, and response +/// decode failures distinct so callers can decide whether to retry, surface a +/// server error, or treat the response as an internal request/response mismatch. +#[derive(Debug)] +pub enum TypedRequestError { + Transport { + method: String, + source: IoError, + }, + Server { + method: String, + source: JSONRPCErrorError, + }, + Deserialize { + method: String, + source: serde_json::Error, + }, +} + +impl fmt::Display for TypedRequestError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Transport { method, source } => { + write!(f, "{method} transport error: {source}") + } + Self::Server { method, source } => { + write!(f, "{method} failed: {}", source.message) + } + Self::Deserialize { method, source } => { + write!(f, "{method} response decode error: {source}") + } + } + } +} + +impl Error for TypedRequestError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Transport { source, .. } => Some(source), + Self::Server { .. } => None, + Self::Deserialize { source, .. } => Some(source), + } + } +} + +#[derive(Clone)] +pub struct InProcessClientStartArgs { + /// Resolved argv0 dispatch paths used by command execution internals. + pub arg0_paths: Arg0DispatchPaths, + /// Shared config used to initialize app-server runtime. + pub config: Arc, + /// CLI config overrides that are already parsed into TOML values. + pub cli_overrides: Vec<(String, TomlValue)>, + /// Loader override knobs used by config API paths. + pub loader_overrides: LoaderOverrides, + /// Preloaded cloud requirements provider. + pub cloud_requirements: CloudRequirementsLoader, + /// Feedback sink used by app-server/core telemetry and logs. + pub feedback: CodexFeedback, + /// Startup warnings emitted after initialize succeeds. + pub config_warnings: Vec, + /// Session source recorded in app-server thread metadata. + pub session_source: SessionSource, + /// Whether auth loading should honor the `CODEX_API_KEY` environment variable. + pub enable_codex_api_key_env: bool, + /// Client name reported during initialize. + pub client_name: String, + /// Client version reported during initialize. + pub client_version: String, + /// Whether experimental APIs are requested at initialize time. + pub experimental_api: bool, + /// Notification methods this client opts out of receiving. + pub opt_out_notification_methods: Vec, + /// Queue capacity for command/event channels (clamped to at least 1). + pub channel_capacity: usize, +} + +impl InProcessClientStartArgs { + /// Builds initialize params from caller-provided metadata. + pub fn initialize_params(&self) -> InitializeParams { + let capabilities = InitializeCapabilities { + experimental_api: self.experimental_api, + opt_out_notification_methods: if self.opt_out_notification_methods.is_empty() { + None + } else { + Some(self.opt_out_notification_methods.clone()) + }, + }; + + InitializeParams { + client_info: ClientInfo { + name: self.client_name.clone(), + title: None, + version: self.client_version.clone(), + }, + capabilities: Some(capabilities), + } + } + + fn into_runtime_start_args(self) -> InProcessStartArgs { + let initialize = self.initialize_params(); + InProcessStartArgs { + arg0_paths: self.arg0_paths, + config: self.config, + cli_overrides: self.cli_overrides, + loader_overrides: self.loader_overrides, + cloud_requirements: self.cloud_requirements, + feedback: self.feedback, + config_warnings: self.config_warnings, + session_source: self.session_source, + enable_codex_api_key_env: self.enable_codex_api_key_env, + initialize, + channel_capacity: self.channel_capacity, + } + } +} + +/// Internal command sent from public facade methods to the worker task. +/// +/// Each variant carries a oneshot sender so the caller can `await` the +/// result without holding a mutable reference to the client. +enum ClientCommand { + Request { + request: Box, + response_tx: oneshot::Sender>, + }, + Notify { + notification: ClientNotification, + response_tx: oneshot::Sender>, + }, + ResolveServerRequest { + request_id: RequestId, + result: JsonRpcResult, + response_tx: oneshot::Sender>, + }, + RejectServerRequest { + request_id: RequestId, + error: JSONRPCErrorError, + response_tx: oneshot::Sender>, + }, + Shutdown { + response_tx: oneshot::Sender>, + }, +} + +/// Async facade over the in-process app-server runtime. +/// +/// This type owns a worker task that bridges between: +/// - caller-facing async `mpsc` channels used by TUI/exec +/// - [`codex_app_server::in_process::InProcessClientHandle`], which speaks to +/// the embedded `MessageProcessor` +/// +/// The facade intentionally preserves the server's request/notification/event +/// model instead of exposing direct core runtime handles. That keeps in-process +/// callers aligned with app-server behavior while still avoiding a process +/// boundary. +pub struct InProcessAppServerClient { + command_tx: mpsc::Sender, + event_rx: mpsc::Receiver, + worker_handle: tokio::task::JoinHandle<()>, +} + +impl InProcessAppServerClient { + /// Starts the in-process runtime and facade worker task. + /// + /// The returned client is ready for requests and event consumption. If the + /// internal event queue is saturated later, server requests are rejected + /// with overload error instead of being silently dropped. + pub async fn start(args: InProcessClientStartArgs) -> IoResult { + let channel_capacity = args.channel_capacity.max(1); + let mut handle = + codex_app_server::in_process::start(args.into_runtime_start_args()).await?; + let request_sender = handle.sender(); + let (command_tx, mut command_rx) = mpsc::channel::(channel_capacity); + let (event_tx, event_rx) = mpsc::channel::(channel_capacity); + + let worker_handle = tokio::spawn(async move { + let mut event_stream_enabled = true; + let mut skipped_events = 0usize; + loop { + tokio::select! { + command = command_rx.recv() => { + match command { + Some(ClientCommand::Request { request, response_tx }) => { + let request_sender = request_sender.clone(); + // Request waits happen on a detached task so + // this loop can keep draining runtime events + // while the request is blocked on client input. + tokio::spawn(async move { + let result = request_sender.request(*request).await; + let _ = response_tx.send(result); + }); + } + Some(ClientCommand::Notify { + notification, + response_tx, + }) => { + let result = request_sender.notify(notification); + let _ = response_tx.send(result); + } + Some(ClientCommand::ResolveServerRequest { + request_id, + result, + response_tx, + }) => { + let send_result = + request_sender.respond_to_server_request(request_id, result); + let _ = response_tx.send(send_result); + } + Some(ClientCommand::RejectServerRequest { + request_id, + error, + response_tx, + }) => { + let send_result = request_sender.fail_server_request(request_id, error); + let _ = response_tx.send(send_result); + } + Some(ClientCommand::Shutdown { response_tx }) => { + let shutdown_result = handle.shutdown().await; + let _ = response_tx.send(shutdown_result); + break; + } + None => { + let _ = handle.shutdown().await; + break; + } + } + } + event = handle.next_event(), if event_stream_enabled => { + let Some(event) = event else { + break; + }; + + if skipped_events > 0 { + if event_requires_delivery(&event) { + // Surface lag before the terminal event, but + // do not let the lag marker itself cause us to + // drop the completion/abort notification that + // the caller is blocked on. + if event_tx + .send(InProcessServerEvent::Lagged { + skipped: skipped_events, + }) + .await + .is_err() + { + event_stream_enabled = false; + continue; + } + skipped_events = 0; + } else { + match event_tx.try_send(InProcessServerEvent::Lagged { + skipped: skipped_events, + }) { + Ok(()) => { + skipped_events = 0; + } + Err(mpsc::error::TrySendError::Full(_)) => { + skipped_events = skipped_events.saturating_add(1); + warn!( + "dropping in-process app-server event because consumer queue is full" + ); + if let InProcessServerEvent::ServerRequest(request) = event { + let _ = request_sender.fail_server_request( + request.id().clone(), + JSONRPCErrorError { + code: -32001, + message: "in-process app-server event queue is full".to_string(), + data: None, + }, + ); + } + continue; + } + Err(mpsc::error::TrySendError::Closed(_)) => { + event_stream_enabled = false; + continue; + } + } + } + } + + if event_requires_delivery(&event) { + // Block until the consumer catches up for + // terminal notifications; this preserves the + // completion signal even when the queue is + // otherwise saturated. + if event_tx.send(event).await.is_err() { + event_stream_enabled = false; + } + continue; + } + + match event_tx.try_send(event) { + Ok(()) => {} + Err(mpsc::error::TrySendError::Full(event)) => { + skipped_events = skipped_events.saturating_add(1); + warn!("dropping in-process app-server event because consumer queue is full"); + if let InProcessServerEvent::ServerRequest(request) = event { + let _ = request_sender.fail_server_request( + request.id().clone(), + JSONRPCErrorError { + code: -32001, + message: "in-process app-server event queue is full".to_string(), + data: None, + }, + ); + } + } + Err(mpsc::error::TrySendError::Closed(_)) => { + event_stream_enabled = false; + } + } + } + } + } + }); + + Ok(Self { + command_tx, + event_rx, + worker_handle, + }) + } + + /// Sends a typed client request and returns raw JSON-RPC result. + /// + /// Callers that expect a concrete response type should usually prefer + /// [`request_typed`](Self::request_typed). + pub async fn request(&self, request: ClientRequest) -> IoResult { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(ClientCommand::Request { + request: Box::new(request), + response_tx, + }) + .await + .map_err(|_| { + IoError::new( + ErrorKind::BrokenPipe, + "in-process app-server worker channel is closed", + ) + })?; + response_rx.await.map_err(|_| { + IoError::new( + ErrorKind::BrokenPipe, + "in-process app-server request channel is closed", + ) + })? + } + + /// Sends a typed client request and decodes the successful response body. + /// + /// This still deserializes from a JSON value produced by app-server's + /// JSON-RPC result envelope. Because the caller chooses `T`, `Deserialize` + /// failures indicate an internal request/response mismatch at the call site + /// (or an in-process bug), not transport skew from an external client. + pub async fn request_typed(&self, request: ClientRequest) -> Result + where + T: DeserializeOwned, + { + let method = request_method_name(&request); + let response = + self.request(request) + .await + .map_err(|source| TypedRequestError::Transport { + method: method.clone(), + source, + })?; + let result = response.map_err(|source| TypedRequestError::Server { + method: method.clone(), + source, + })?; + serde_json::from_value(result) + .map_err(|source| TypedRequestError::Deserialize { method, source }) + } + + /// Sends a typed client notification. + pub async fn notify(&self, notification: ClientNotification) -> IoResult<()> { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(ClientCommand::Notify { + notification, + response_tx, + }) + .await + .map_err(|_| { + IoError::new( + ErrorKind::BrokenPipe, + "in-process app-server worker channel is closed", + ) + })?; + response_rx.await.map_err(|_| { + IoError::new( + ErrorKind::BrokenPipe, + "in-process app-server notify channel is closed", + ) + })? + } + + /// Resolves a pending server request. + /// + /// This should only be called with request IDs obtained from the current + /// client's event stream. + pub async fn resolve_server_request( + &self, + request_id: RequestId, + result: JsonRpcResult, + ) -> IoResult<()> { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(ClientCommand::ResolveServerRequest { + request_id, + result, + response_tx, + }) + .await + .map_err(|_| { + IoError::new( + ErrorKind::BrokenPipe, + "in-process app-server worker channel is closed", + ) + })?; + response_rx.await.map_err(|_| { + IoError::new( + ErrorKind::BrokenPipe, + "in-process app-server resolve channel is closed", + ) + })? + } + + /// Rejects a pending server request with JSON-RPC error payload. + pub async fn reject_server_request( + &self, + request_id: RequestId, + error: JSONRPCErrorError, + ) -> IoResult<()> { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(ClientCommand::RejectServerRequest { + request_id, + error, + response_tx, + }) + .await + .map_err(|_| { + IoError::new( + ErrorKind::BrokenPipe, + "in-process app-server worker channel is closed", + ) + })?; + response_rx.await.map_err(|_| { + IoError::new( + ErrorKind::BrokenPipe, + "in-process app-server reject channel is closed", + ) + })? + } + + /// Returns the next in-process event, or `None` when worker exits. + /// + /// Callers are expected to drain this stream promptly. If they fall behind, + /// the worker emits [`InProcessServerEvent::Lagged`] markers and may reject + /// pending server requests rather than letting approval flows hang. + pub async fn next_event(&mut self) -> Option { + self.event_rx.recv().await + } + + /// Shuts down worker and in-process runtime with bounded wait. + /// + /// If graceful shutdown exceeds timeout, the worker task is aborted to + /// avoid leaking background tasks in embedding callers. + pub async fn shutdown(self) -> IoResult<()> { + let Self { + command_tx, + event_rx, + worker_handle, + } = self; + let mut worker_handle = worker_handle; + // Drop the caller-facing receiver before asking the worker to shut + // down. That unblocks any pending must-deliver `event_tx.send(..)` + // so the worker can reach `handle.shutdown()` instead of timing out + // and getting aborted with the runtime still attached. + drop(event_rx); + let (response_tx, response_rx) = oneshot::channel(); + if command_tx + .send(ClientCommand::Shutdown { response_tx }) + .await + .is_ok() + && let Ok(command_result) = timeout(SHUTDOWN_TIMEOUT, response_rx).await + { + command_result.map_err(|_| { + IoError::new( + ErrorKind::BrokenPipe, + "in-process app-server shutdown channel is closed", + ) + })??; + } + + if let Err(_elapsed) = timeout(SHUTDOWN_TIMEOUT, &mut worker_handle).await { + worker_handle.abort(); + let _ = worker_handle.await; + } + Ok(()) + } +} + +/// Extracts the JSON-RPC method name for diagnostics without extending the +/// protocol crate with in-process-only helpers. +fn request_method_name(request: &ClientRequest) -> String { + serde_json::to_value(request) + .ok() + .and_then(|value| { + value + .get("method") + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned) + }) + .unwrap_or_else(|| "".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_app_server_protocol::ConfigRequirementsReadResponse; + use codex_app_server_protocol::SessionSource as ApiSessionSource; + use codex_app_server_protocol::ThreadStartParams; + use codex_app_server_protocol::ThreadStartResponse; + use codex_core::config::ConfigBuilder; + use pretty_assertions::assert_eq; + use tokio::time::Duration; + use tokio::time::timeout; + + async fn build_test_config() -> Config { + match ConfigBuilder::default().build().await { + Ok(config) => config, + Err(_) => Config::load_default_with_cli_overrides(Vec::new()) + .expect("default config should load"), + } + } + + async fn start_test_client_with_capacity( + session_source: SessionSource, + channel_capacity: usize, + ) -> InProcessAppServerClient { + InProcessAppServerClient::start(InProcessClientStartArgs { + arg0_paths: Arg0DispatchPaths::default(), + config: Arc::new(build_test_config().await), + cli_overrides: Vec::new(), + loader_overrides: LoaderOverrides::default(), + cloud_requirements: CloudRequirementsLoader::default(), + feedback: CodexFeedback::new(), + config_warnings: Vec::new(), + session_source, + enable_codex_api_key_env: false, + client_name: "codex-app-server-client-test".to_string(), + client_version: "0.0.0-test".to_string(), + experimental_api: true, + opt_out_notification_methods: Vec::new(), + channel_capacity, + }) + .await + .expect("in-process app-server client should start") + } + + async fn start_test_client(session_source: SessionSource) -> InProcessAppServerClient { + start_test_client_with_capacity(session_source, DEFAULT_IN_PROCESS_CHANNEL_CAPACITY).await + } + + #[tokio::test] + async fn typed_request_roundtrip_works() { + let client = start_test_client(SessionSource::Exec).await; + let _response: ConfigRequirementsReadResponse = client + .request_typed(ClientRequest::ConfigRequirementsRead { + request_id: RequestId::Integer(1), + params: None, + }) + .await + .expect("typed request should succeed"); + client.shutdown().await.expect("shutdown should complete"); + } + + #[tokio::test] + async fn typed_request_reports_json_rpc_errors() { + let client = start_test_client(SessionSource::Exec).await; + let err = client + .request_typed::(ClientRequest::ThreadRead { + request_id: RequestId::Integer(99), + params: codex_app_server_protocol::ThreadReadParams { + thread_id: "missing-thread".to_string(), + include_turns: false, + }, + }) + .await + .expect_err("missing thread should return a JSON-RPC error"); + assert!( + err.to_string().starts_with("thread/read failed:"), + "expected method-qualified JSON-RPC failure message" + ); + client.shutdown().await.expect("shutdown should complete"); + } + + #[tokio::test] + async fn caller_provided_session_source_is_applied() { + for (session_source, expected_source) in [ + (SessionSource::Exec, ApiSessionSource::Exec), + (SessionSource::Cli, ApiSessionSource::Cli), + ] { + let client = start_test_client(session_source).await; + let parsed: ThreadStartResponse = client + .request_typed(ClientRequest::ThreadStart { + request_id: RequestId::Integer(2), + params: ThreadStartParams { + ephemeral: Some(true), + ..ThreadStartParams::default() + }, + }) + .await + .expect("thread/start should succeed"); + assert_eq!(parsed.thread.source, expected_source); + client.shutdown().await.expect("shutdown should complete"); + } + } + + #[tokio::test] + async fn tiny_channel_capacity_still_supports_request_roundtrip() { + let client = start_test_client_with_capacity(SessionSource::Exec, 1).await; + let _response: ConfigRequirementsReadResponse = client + .request_typed(ClientRequest::ConfigRequirementsRead { + request_id: RequestId::Integer(1), + params: None, + }) + .await + .expect("typed request should succeed"); + client.shutdown().await.expect("shutdown should complete"); + } + + #[test] + fn typed_request_error_exposes_sources() { + let transport = TypedRequestError::Transport { + method: "config/read".to_string(), + source: IoError::new(ErrorKind::BrokenPipe, "closed"), + }; + assert_eq!(std::error::Error::source(&transport).is_some(), true); + + let server = TypedRequestError::Server { + method: "thread/read".to_string(), + source: JSONRPCErrorError { + code: -32603, + data: None, + message: "internal".to_string(), + }, + }; + assert_eq!(std::error::Error::source(&server).is_some(), false); + + let deserialize = TypedRequestError::Deserialize { + method: "thread/start".to_string(), + source: serde_json::from_str::("\"nope\"") + .expect_err("invalid integer should return deserialize error"), + }; + assert_eq!(std::error::Error::source(&deserialize).is_some(), true); + } + + #[tokio::test] + async fn next_event_surfaces_lagged_markers() { + let (command_tx, _command_rx) = mpsc::channel(1); + let (event_tx, event_rx) = mpsc::channel(1); + let worker_handle = tokio::spawn(async {}); + event_tx + .send(InProcessServerEvent::Lagged { skipped: 3 }) + .await + .expect("lagged marker should enqueue"); + drop(event_tx); + + let mut client = InProcessAppServerClient { + command_tx, + event_rx, + worker_handle, + }; + + let event = timeout(Duration::from_secs(2), client.next_event()) + .await + .expect("lagged marker should arrive before timeout"); + assert!(matches!( + event, + Some(InProcessServerEvent::Lagged { skipped: 3 }) + )); + + client.shutdown().await.expect("shutdown should complete"); + } + + #[test] + fn event_requires_delivery_marks_terminal_events() { + assert!(event_requires_delivery( + &InProcessServerEvent::ServerNotification( + codex_app_server_protocol::ServerNotification::TurnCompleted( + codex_app_server_protocol::TurnCompletedNotification { + thread_id: "thread".to_string(), + turn: codex_app_server_protocol::Turn { + id: "turn".to_string(), + items: Vec::new(), + status: codex_app_server_protocol::TurnStatus::Completed, + error: None, + }, + } + ) + ) + )); + assert!(event_requires_delivery( + &InProcessServerEvent::LegacyNotification( + codex_app_server_protocol::JSONRPCNotification { + method: "codex/event/turn_aborted".to_string(), + params: None, + } + ) + )); + assert!(!event_requires_delivery(&InProcessServerEvent::Lagged { + skipped: 1 + })); + } +} diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index a790874de0..03be878ce8 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -57,6 +57,9 @@ "mcp_elicitations": { "type": "boolean" }, + "request_permissions": { + "type": "boolean" + }, "rules": { "type": "boolean" }, @@ -66,6 +69,7 @@ }, "required": [ "mcp_elicitations", + "request_permissions", "rules", "sandbox_approval" ], @@ -350,6 +354,10 @@ "string", "null" ] + }, + "reloadUserConfig": { + "description": "When true, hot-reload the updated user config into all loaded threads after writing.", + "type": "boolean" } }, "required": [ @@ -1127,6 +1135,17 @@ }, "type": "object" }, + "PluginUninstallParams": { + "properties": { + "pluginId": { + "type": "string" + } + }, + "required": [ + "pluginId" + ], + "type": "object" + }, "ProductSurface": { "enum": [ "chatgpt", @@ -3567,6 +3586,30 @@ "title": "Plugin/installRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/uninstall" + ], + "title": "Plugin/uninstallRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginUninstallParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/uninstallRequest", + "type": "object" + }, { "properties": { "id": { @@ -4217,4 +4260,4 @@ } ], "title": "ClientRequest" -} +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json index fad972da4e..befa086b3b 100644 --- a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json @@ -286,6 +286,17 @@ } ] }, + "CommandExecutionRequestApprovalSkillMetadata": { + "properties": { + "pathToSkillsMd": { + "type": "string" + } + }, + "required": [ + "pathToSkillsMd" + ], + "type": "object" + }, "MacOsAutomationPermission": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/EventMsg.json b/codex-rs/app-server-protocol/schema/json/EventMsg.json index 70ddde458e..ea67aec658 100644 --- a/codex-rs/app-server-protocol/schema/json/EventMsg.json +++ b/codex-rs/app-server-protocol/schema/json/EventMsg.json @@ -1866,6 +1866,17 @@ "null" ] }, + "skill_metadata": { + "anyOf": [ + { + "$ref": "#/definitions/ExecApprovalRequestSkillMetadata" + }, + { + "type": "null" + } + ], + "description": "Optional skill metadata when the approval was triggered by a skill script." + }, "turn_id": { "default": "", "description": "Turn ID that this command belongs to. Uses `#[serde(default)]` for backwards compatibility.", @@ -1889,6 +1900,42 @@ "title": "ExecApprovalRequestEventMsg", "type": "object" }, + { + "properties": { + "call_id": { + "description": "Responses API call id for the associated tool call, if available.", + "type": "string" + }, + "permissions": { + "$ref": "#/definitions/PermissionProfile" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "turn_id": { + "default": "", + "description": "Turn ID that this request belongs to. Uses `#[serde(default)]` for backwards compatibility.", + "type": "string" + }, + "type": { + "enum": [ + "request_permissions" + ], + "title": "RequestPermissionsEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "permissions", + "type" + ], + "title": "RequestPermissionsEventMsg", + "type": "object" + }, { "properties": { "call_id": { @@ -3442,6 +3489,17 @@ } ] }, + "ExecApprovalRequestSkillMetadata": { + "properties": { + "path_to_skills_md": { + "type": "string" + } + }, + "required": [ + "path_to_skills_md" + ], + "type": "object" + }, "ExecCommandSource": { "enum": [ "agent", @@ -4625,6 +4683,10 @@ "description": "Reject MCP elicitation prompts.", "type": "boolean" }, + "request_permissions": { + "description": "Reject approval prompts related to built-in permission requests.", + "type": "boolean" + }, "rules": { "description": "Reject prompts triggered by execpolicy `prompt` rules.", "type": "boolean" @@ -4636,6 +4698,7 @@ }, "required": [ "mcp_elicitations", + "request_permissions", "rules", "sandbox_approval" ], @@ -7678,6 +7741,17 @@ "null" ] }, + "skill_metadata": { + "anyOf": [ + { + "$ref": "#/definitions/ExecApprovalRequestSkillMetadata" + }, + { + "type": "null" + } + ], + "description": "Optional skill metadata when the approval was triggered by a skill script." + }, "turn_id": { "default": "", "description": "Turn ID that this command belongs to. Uses `#[serde(default)]` for backwards compatibility.", @@ -7701,6 +7775,42 @@ "title": "ExecApprovalRequestEventMsg", "type": "object" }, + { + "properties": { + "call_id": { + "description": "Responses API call id for the associated tool call, if available.", + "type": "string" + }, + "permissions": { + "$ref": "#/definitions/PermissionProfile" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "turn_id": { + "default": "", + "description": "Turn ID that this request belongs to. Uses `#[serde(default)]` for backwards compatibility.", + "type": "string" + }, + "type": { + "enum": [ + "request_permissions" + ], + "title": "RequestPermissionsEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "permissions", + "type" + ], + "title": "RequestPermissionsEventMsg", + "type": "object" + }, { "properties": { "call_id": { diff --git a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json new file mode 100644 index 0000000000..f642c81cf0 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json @@ -0,0 +1,164 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "AdditionalFileSystemPermissions": { + "properties": { + "read": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + }, + "write": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + } + }, + "type": "object" + }, + "AdditionalMacOsPermissions": { + "properties": { + "accessibility": { + "type": "boolean" + }, + "automations": { + "$ref": "#/definitions/MacOsAutomationPermission" + }, + "calendar": { + "type": "boolean" + }, + "preferences": { + "$ref": "#/definitions/MacOsPreferencesPermission" + } + }, + "required": [ + "accessibility", + "automations", + "calendar", + "preferences" + ], + "type": "object" + }, + "AdditionalNetworkPermissions": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, + "AdditionalPermissionProfile": { + "properties": { + "fileSystem": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalFileSystemPermissions" + }, + { + "type": "null" + } + ] + }, + "macos": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalMacOsPermissions" + }, + { + "type": "null" + } + ] + }, + "network": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalNetworkPermissions" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "MacOsAutomationPermission": { + "oneOf": [ + { + "enum": [ + "none", + "all" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "bundle_ids": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "bundle_ids" + ], + "title": "BundleIdsMacOsAutomationPermission", + "type": "object" + } + ] + }, + "MacOsPreferencesPermission": { + "enum": [ + "none", + "read_only", + "read_write" + ], + "type": "string" + } + }, + "properties": { + "itemId": { + "type": "string" + }, + "permissions": { + "$ref": "#/definitions/AdditionalPermissionProfile" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "itemId", + "permissions", + "threadId", + "turnId" + ], + "title": "PermissionsRequestApprovalParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json new file mode 100644 index 0000000000..2637ed5dda --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json @@ -0,0 +1,175 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "AdditionalFileSystemPermissions": { + "properties": { + "read": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + }, + "write": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + } + }, + "type": "object" + }, + "AdditionalNetworkPermissions": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, + "GrantedMacOsPermissions": { + "properties": { + "accessibility": { + "type": [ + "boolean", + "null" + ] + }, + "automations": { + "anyOf": [ + { + "$ref": "#/definitions/MacOsAutomationPermission" + }, + { + "type": "null" + } + ] + }, + "calendar": { + "type": [ + "boolean", + "null" + ] + }, + "preferences": { + "anyOf": [ + { + "$ref": "#/definitions/MacOsPreferencesPermission" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "GrantedPermissionProfile": { + "properties": { + "fileSystem": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalFileSystemPermissions" + }, + { + "type": "null" + } + ] + }, + "macos": { + "anyOf": [ + { + "$ref": "#/definitions/GrantedMacOsPermissions" + }, + { + "type": "null" + } + ] + }, + "network": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalNetworkPermissions" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "MacOsAutomationPermission": { + "oneOf": [ + { + "enum": [ + "none", + "all" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "bundle_ids": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "bundle_ids" + ], + "title": "BundleIdsMacOsAutomationPermission", + "type": "object" + } + ] + }, + "MacOsPreferencesPermission": { + "enum": [ + "none", + "read_only", + "read_write" + ], + "type": "string" + }, + "PermissionGrantScope": { + "enum": [ + "turn", + "session" + ], + "type": "string" + } + }, + "properties": { + "permissions": { + "$ref": "#/definitions/GrantedPermissionProfile" + }, + "scope": { + "allOf": [ + { + "$ref": "#/definitions/PermissionGrantScope" + } + ], + "default": "turn" + } + }, + "required": [ + "permissions" + ], + "title": "PermissionsRequestApprovalResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index ae0c4ee506..310b50171f 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -440,6 +440,17 @@ ], "type": "object" }, + "CommandExecutionRequestApprovalSkillMetadata": { + "properties": { + "pathToSkillsMd": { + "type": "string" + } + }, + "required": [ + "pathToSkillsMd" + ], + "type": "object" + }, "DynamicToolCallParams": { "properties": { "arguments": true, @@ -1412,6 +1423,35 @@ } ] }, + "PermissionsRequestApprovalParams": { + "properties": { + "itemId": { + "type": "string" + }, + "permissions": { + "$ref": "#/definitions/AdditionalPermissionProfile" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "itemId", + "permissions", + "threadId", + "turnId" + ], + "type": "object" + }, "RequestId": { "anyOf": [ { @@ -1609,6 +1649,31 @@ "title": "McpServer/elicitation/requestRequest", "type": "object" }, + { + "description": "Request approval for additional permissions from the user.", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "item/permissions/requestApproval" + ], + "title": "Item/permissions/requestApprovalRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PermissionsRequestApprovalParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Item/permissions/requestApprovalRequest", + "type": "object" + }, { "description": "Execute a dynamic tool call on the client.", "properties": { 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 c26cea19a4..1b80de6ff7 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 @@ -860,6 +860,30 @@ "title": "Plugin/installRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "plugin/uninstall" + ], + "title": "Plugin/uninstallRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/PluginUninstallParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/uninstallRequest", + "type": "object" + }, { "properties": { "id": { @@ -1762,6 +1786,17 @@ "title": "CommandExecutionRequestApprovalResponse", "type": "object" }, + "CommandExecutionRequestApprovalSkillMetadata": { + "properties": { + "pathToSkillsMd": { + "type": "string" + } + }, + "required": [ + "pathToSkillsMd" + ], + "type": "object" + }, "CustomPrompt": { "properties": { "argument_hint": { @@ -3182,6 +3217,17 @@ "null" ] }, + "skill_metadata": { + "anyOf": [ + { + "$ref": "#/definitions/ExecApprovalRequestSkillMetadata" + }, + { + "type": "null" + } + ], + "description": "Optional skill metadata when the approval was triggered by a skill script." + }, "turn_id": { "default": "", "description": "Turn ID that this command belongs to. Uses `#[serde(default)]` for backwards compatibility.", @@ -3205,6 +3251,42 @@ "title": "ExecApprovalRequestEventMsg", "type": "object" }, + { + "properties": { + "call_id": { + "description": "Responses API call id for the associated tool call, if available.", + "type": "string" + }, + "permissions": { + "$ref": "#/definitions/PermissionProfile" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "turn_id": { + "default": "", + "description": "Turn ID that this request belongs to. Uses `#[serde(default)]` for backwards compatibility.", + "type": "string" + }, + "type": { + "enum": [ + "request_permissions" + ], + "title": "RequestPermissionsEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "permissions", + "type" + ], + "title": "RequestPermissionsEventMsg", + "type": "object" + }, { "properties": { "call_id": { @@ -4759,6 +4841,17 @@ ], "title": "EventMsg" }, + "ExecApprovalRequestSkillMetadata": { + "properties": { + "path_to_skills_md": { + "type": "string" + } + }, + "required": [ + "path_to_skills_md" + ], + "type": "object" + }, "ExecCommandApprovalParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -5134,6 +5227,78 @@ "title": "FuzzyFileSearchSessionUpdatedNotification", "type": "object" }, + "GrantedMacOsPermissions": { + "properties": { + "accessibility": { + "type": [ + "boolean", + "null" + ] + }, + "automations": { + "anyOf": [ + { + "$ref": "#/definitions/MacOsAutomationPermission" + }, + { + "type": "null" + } + ] + }, + "calendar": { + "type": [ + "boolean", + "null" + ] + }, + "preferences": { + "anyOf": [ + { + "$ref": "#/definitions/MacOsPreferencesPermission" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "GrantedPermissionProfile": { + "properties": { + "fileSystem": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalFileSystemPermissions" + }, + { + "type": "null" + } + ] + }, + "macos": { + "anyOf": [ + { + "$ref": "#/definitions/GrantedMacOsPermissions" + }, + { + "type": "null" + } + ] + }, + "network": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalNetworkPermissions" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, "HistoryEntry": { "properties": { "conversation_id": { @@ -6297,6 +6462,13 @@ } ] }, + "PermissionGrantScope": { + "enum": [ + "turn", + "session" + ], + "type": "string" + }, "PermissionProfile": { "properties": { "file_system": { @@ -6332,6 +6504,58 @@ }, "type": "object" }, + "PermissionsRequestApprovalParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "itemId": { + "type": "string" + }, + "permissions": { + "$ref": "#/definitions/AdditionalPermissionProfile" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "itemId", + "permissions", + "threadId", + "turnId" + ], + "title": "PermissionsRequestApprovalParams", + "type": "object" + }, + "PermissionsRequestApprovalResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "permissions": { + "$ref": "#/definitions/GrantedPermissionProfile" + }, + "scope": { + "allOf": [ + { + "$ref": "#/definitions/PermissionGrantScope" + } + ], + "default": "turn" + } + }, + "required": [ + "permissions" + ], + "title": "PermissionsRequestApprovalResponse", + "type": "object" + }, "PlanItemArg": { "additionalProperties": false, "properties": { @@ -6528,6 +6752,10 @@ "description": "Reject MCP elicitation prompts.", "type": "boolean" }, + "request_permissions": { + "description": "Reject approval prompts related to built-in permission requests.", + "type": "boolean" + }, "rules": { "description": "Reject prompts triggered by execpolicy `prompt` rules.", "type": "boolean" @@ -6539,6 +6767,7 @@ }, "required": [ "mcp_elicitations", + "request_permissions", "rules", "sandbox_approval" ], @@ -7806,6 +8035,31 @@ "title": "McpServer/elicitation/requestRequest", "type": "object" }, + { + "description": "Request approval for additional permissions from the user.", + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "item/permissions/requestApproval" + ], + "title": "Item/permissions/requestApprovalRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PermissionsRequestApprovalParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Item/permissions/requestApprovalRequest", + "type": "object" + }, { "description": "Execute a dynamic tool call on the client.", "properties": { @@ -8983,6 +9237,9 @@ "mcp_elicitations": { "type": "boolean" }, + "request_permissions": { + "type": "boolean" + }, "rules": { "type": "boolean" }, @@ -8992,6 +9249,7 @@ }, "required": [ "mcp_elicitations", + "request_permissions", "rules", "sandbox_approval" ], @@ -9943,6 +10201,10 @@ "string", "null" ] + }, + "reloadUserConfig": { + "description": "When true, hot-reload the updated user config into all loaded threads after writing.", + "type": "boolean" } }, "required": [ @@ -12351,6 +12613,24 @@ ], "type": "object" }, + "PluginUninstallParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "pluginId": { + "type": "string" + } + }, + "required": [ + "pluginId" + ], + "title": "PluginUninstallParams", + "type": "object" + }, + "PluginUninstallResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PluginUninstallResponse", + "type": "object" + }, "ProductSurface": { "enum": [ "chatgpt", @@ -17071,4 +17351,4 @@ }, "title": "CodexAppServerProtocol", "type": "object" -} +} \ No newline at end of file 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 bbf05bfed0..c608fb8e15 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 @@ -731,6 +731,9 @@ "mcp_elicitations": { "type": "boolean" }, + "request_permissions": { + "type": "boolean" + }, "rules": { "type": "boolean" }, @@ -740,6 +743,7 @@ }, "required": [ "mcp_elicitations", + "request_permissions", "rules", "sandbox_approval" ], @@ -1383,6 +1387,30 @@ "title": "Plugin/installRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/uninstall" + ], + "title": "Plugin/uninstallRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginUninstallParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/uninstallRequest", + "type": "object" + }, { "properties": { "id": { @@ -2962,6 +2990,10 @@ "string", "null" ] + }, + "reloadUserConfig": { + "description": "When true, hot-reload the updated user config into all loaded threads after writing.", + "type": "boolean" } }, "required": [ @@ -4995,6 +5027,17 @@ "null" ] }, + "skill_metadata": { + "anyOf": [ + { + "$ref": "#/definitions/ExecApprovalRequestSkillMetadata" + }, + { + "type": "null" + } + ], + "description": "Optional skill metadata when the approval was triggered by a skill script." + }, "turn_id": { "default": "", "description": "Turn ID that this command belongs to. Uses `#[serde(default)]` for backwards compatibility.", @@ -5018,6 +5061,42 @@ "title": "ExecApprovalRequestEventMsg", "type": "object" }, + { + "properties": { + "call_id": { + "description": "Responses API call id for the associated tool call, if available.", + "type": "string" + }, + "permissions": { + "$ref": "#/definitions/PermissionProfile" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "turn_id": { + "default": "", + "description": "Turn ID that this request belongs to. Uses `#[serde(default)]` for backwards compatibility.", + "type": "string" + }, + "type": { + "enum": [ + "request_permissions" + ], + "title": "RequestPermissionsEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "permissions", + "type" + ], + "title": "RequestPermissionsEventMsg", + "type": "object" + }, { "properties": { "call_id": { @@ -6572,6 +6651,17 @@ ], "title": "EventMsg" }, + "ExecApprovalRequestSkillMetadata": { + "properties": { + "path_to_skills_md": { + "type": "string" + } + }, + "required": [ + "path_to_skills_md" + ], + "type": "object" + }, "ExecCommandSource": { "enum": [ "agent", @@ -8987,6 +9077,24 @@ ], "type": "object" }, + "PluginUninstallParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "pluginId": { + "type": "string" + } + }, + "required": [ + "pluginId" + ], + "title": "PluginUninstallParams", + "type": "object" + }, + "PluginUninstallResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PluginUninstallResponse", + "type": "object" + }, "ProductSurface": { "enum": [ "chatgpt", @@ -15326,4 +15434,4 @@ }, "title": "CodexAppServerProtocolV2", "type": "object" -} +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigBatchWriteParams.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigBatchWriteParams.json index 37b23b2405..03cf2edb7a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigBatchWriteParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigBatchWriteParams.json @@ -45,6 +45,10 @@ "string", "null" ] + }, + "reloadUserConfig": { + "description": "When true, hot-reload the updated user config into all loaded threads after writing.", + "type": "boolean" } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index 90828da0ba..2a2f0be780 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -148,6 +148,9 @@ "mcp_elicitations": { "type": "boolean" }, + "request_permissions": { + "type": "boolean" + }, "rules": { "type": "boolean" }, @@ -157,6 +160,7 @@ }, "required": [ "mcp_elicitations", + "request_permissions", "rules", "sandbox_approval" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index 55bc3c62c9..0d7b245288 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -20,6 +20,9 @@ "mcp_elicitations": { "type": "boolean" }, + "request_permissions": { + "type": "boolean" + }, "rules": { "type": "boolean" }, @@ -29,6 +32,7 @@ }, "required": [ "mcp_elicitations", + "request_permissions", "rules", "sandbox_approval" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginUninstallParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginUninstallParams.json new file mode 100644 index 0000000000..5b7e0a592f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginUninstallParams.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "pluginId": { + "type": "string" + } + }, + "required": [ + "pluginId" + ], + "title": "PluginUninstallParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginUninstallResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginUninstallResponse.json new file mode 100644 index 0000000000..5c0e37bd9d --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginUninstallResponse.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PluginUninstallResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json index d4ef3effdb..92f3a16004 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json @@ -20,6 +20,9 @@ "mcp_elicitations": { "type": "boolean" }, + "request_permissions": { + "type": "boolean" + }, "rules": { "type": "boolean" }, @@ -29,6 +32,7 @@ }, "required": [ "mcp_elicitations", + "request_permissions", "rules", "sandbox_approval" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index fec4e20e44..c671a77384 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -24,6 +24,9 @@ "mcp_elicitations": { "type": "boolean" }, + "request_permissions": { + "type": "boolean" + }, "rules": { "type": "boolean" }, @@ -33,6 +36,7 @@ }, "required": [ "mcp_elicitations", + "request_permissions", "rules", "sandbox_approval" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index 75c8a39675..aec9a6d491 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -20,6 +20,9 @@ "mcp_elicitations": { "type": "boolean" }, + "request_permissions": { + "type": "boolean" + }, "rules": { "type": "boolean" }, @@ -29,6 +32,7 @@ }, "required": [ "mcp_elicitations", + "request_permissions", "rules", "sandbox_approval" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index e8addc1101..42a931c6ed 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -24,6 +24,9 @@ "mcp_elicitations": { "type": "boolean" }, + "request_permissions": { + "type": "boolean" + }, "rules": { "type": "boolean" }, @@ -33,6 +36,7 @@ }, "required": [ "mcp_elicitations", + "request_permissions", "rules", "sandbox_approval" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json index 0cdc8d4598..659fb9f973 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -20,6 +20,9 @@ "mcp_elicitations": { "type": "boolean" }, + "request_permissions": { + "type": "boolean" + }, "rules": { "type": "boolean" }, @@ -29,6 +32,7 @@ }, "required": [ "mcp_elicitations", + "request_permissions", "rules", "sandbox_approval" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 52d9e29515..966ed815d5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -24,6 +24,9 @@ "mcp_elicitations": { "type": "boolean" }, + "request_permissions": { + "type": "boolean" + }, "rules": { "type": "boolean" }, @@ -33,6 +36,7 @@ }, "required": [ "mcp_elicitations", + "request_permissions", "rules", "sandbox_approval" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index b1d5febacc..7a6184e3cf 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -24,6 +24,9 @@ "mcp_elicitations": { "type": "boolean" }, + "request_permissions": { + "type": "boolean" + }, "rules": { "type": "boolean" }, @@ -33,6 +36,7 @@ }, "required": [ "mcp_elicitations", + "request_permissions", "rules", "sandbox_approval" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/WindowsSandboxSetupStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/WindowsSandboxSetupStartParams.json index 6e2e8baebf..ed93913cb3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/WindowsSandboxSetupStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/WindowsSandboxSetupStartParams.json @@ -33,4 +33,4 @@ ], "title": "WindowsSandboxSetupStartParams", "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 715a51d57e..5fa8f27b08 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -27,6 +27,7 @@ import type { McpServerOauthLoginParams } from "./v2/McpServerOauthLoginParams"; import type { ModelListParams } from "./v2/ModelListParams"; import type { PluginInstallParams } from "./v2/PluginInstallParams"; import type { PluginListParams } from "./v2/PluginListParams"; +import type { PluginUninstallParams } from "./v2/PluginUninstallParams"; import type { ReviewStartParams } from "./v2/ReviewStartParams"; import type { SkillsConfigWriteParams } from "./v2/SkillsConfigWriteParams"; import type { SkillsListParams } from "./v2/SkillsListParams"; @@ -53,4 +54,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "skills/remote/list", id: RequestId, params: SkillsRemoteReadParams, } | { "method": "skills/remote/export", id: RequestId, params: SkillsRemoteWriteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "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": "skills/remote/list", id: RequestId, params: SkillsRemoteReadParams, } | { "method": "skills/remote/export", id: RequestId, params: SkillsRemoteWriteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "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/EventMsg.ts b/codex-rs/app-server-protocol/schema/typescript/EventMsg.ts index 658a97ef14..a51bca719b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/EventMsg.ts +++ b/codex-rs/app-server-protocol/schema/typescript/EventMsg.ts @@ -56,6 +56,7 @@ import type { RealtimeConversationStartedEvent } from "./RealtimeConversationSta import type { ReasoningContentDeltaEvent } from "./ReasoningContentDeltaEvent"; import type { ReasoningRawContentDeltaEvent } from "./ReasoningRawContentDeltaEvent"; import type { RemoteSkillDownloadedEvent } from "./RemoteSkillDownloadedEvent"; +import type { RequestPermissionsEvent } from "./RequestPermissionsEvent"; import type { RequestUserInputEvent } from "./RequestUserInputEvent"; import type { ReviewRequest } from "./ReviewRequest"; import type { SessionConfiguredEvent } from "./SessionConfiguredEvent"; @@ -81,4 +82,4 @@ import type { WebSearchEndEvent } from "./WebSearchEndEvent"; * Response event from the agent * NOTE: Make sure none of these values have optional types, as it will mess up the extension code-gen. */ -export type EventMsg = { "type": "error" } & ErrorEvent | { "type": "warning" } & WarningEvent | { "type": "realtime_conversation_started" } & RealtimeConversationStartedEvent | { "type": "realtime_conversation_realtime" } & RealtimeConversationRealtimeEvent | { "type": "realtime_conversation_closed" } & RealtimeConversationClosedEvent | { "type": "model_reroute" } & ModelRerouteEvent | { "type": "context_compacted" } & ContextCompactedEvent | { "type": "thread_rolled_back" } & ThreadRolledBackEvent | { "type": "task_started" } & TurnStartedEvent | { "type": "task_complete" } & TurnCompleteEvent | { "type": "token_count" } & TokenCountEvent | { "type": "agent_message" } & AgentMessageEvent | { "type": "user_message" } & UserMessageEvent | { "type": "agent_message_delta" } & AgentMessageDeltaEvent | { "type": "agent_reasoning" } & AgentReasoningEvent | { "type": "agent_reasoning_delta" } & AgentReasoningDeltaEvent | { "type": "agent_reasoning_raw_content" } & AgentReasoningRawContentEvent | { "type": "agent_reasoning_raw_content_delta" } & AgentReasoningRawContentDeltaEvent | { "type": "agent_reasoning_section_break" } & AgentReasoningSectionBreakEvent | { "type": "session_configured" } & SessionConfiguredEvent | { "type": "thread_name_updated" } & ThreadNameUpdatedEvent | { "type": "mcp_startup_update" } & McpStartupUpdateEvent | { "type": "mcp_startup_complete" } & McpStartupCompleteEvent | { "type": "mcp_tool_call_begin" } & McpToolCallBeginEvent | { "type": "mcp_tool_call_end" } & McpToolCallEndEvent | { "type": "web_search_begin" } & WebSearchBeginEvent | { "type": "web_search_end" } & WebSearchEndEvent | { "type": "image_generation_begin" } & ImageGenerationBeginEvent | { "type": "image_generation_end" } & ImageGenerationEndEvent | { "type": "exec_command_begin" } & ExecCommandBeginEvent | { "type": "exec_command_output_delta" } & ExecCommandOutputDeltaEvent | { "type": "terminal_interaction" } & TerminalInteractionEvent | { "type": "exec_command_end" } & ExecCommandEndEvent | { "type": "view_image_tool_call" } & ViewImageToolCallEvent | { "type": "exec_approval_request" } & ExecApprovalRequestEvent | { "type": "request_user_input" } & RequestUserInputEvent | { "type": "dynamic_tool_call_request" } & DynamicToolCallRequest | { "type": "dynamic_tool_call_response" } & DynamicToolCallResponseEvent | { "type": "elicitation_request" } & ElicitationRequestEvent | { "type": "apply_patch_approval_request" } & ApplyPatchApprovalRequestEvent | { "type": "deprecation_notice" } & DeprecationNoticeEvent | { "type": "background_event" } & BackgroundEventEvent | { "type": "undo_started" } & UndoStartedEvent | { "type": "undo_completed" } & UndoCompletedEvent | { "type": "stream_error" } & StreamErrorEvent | { "type": "patch_apply_begin" } & PatchApplyBeginEvent | { "type": "patch_apply_end" } & PatchApplyEndEvent | { "type": "turn_diff" } & TurnDiffEvent | { "type": "get_history_entry_response" } & GetHistoryEntryResponseEvent | { "type": "mcp_list_tools_response" } & McpListToolsResponseEvent | { "type": "list_custom_prompts_response" } & ListCustomPromptsResponseEvent | { "type": "list_skills_response" } & ListSkillsResponseEvent | { "type": "list_remote_skills_response" } & ListRemoteSkillsResponseEvent | { "type": "remote_skill_downloaded" } & RemoteSkillDownloadedEvent | { "type": "skills_update_available" } | { "type": "plan_update" } & UpdatePlanArgs | { "type": "turn_aborted" } & TurnAbortedEvent | { "type": "shutdown_complete" } | { "type": "entered_review_mode" } & ReviewRequest | { "type": "exited_review_mode" } & ExitedReviewModeEvent | { "type": "raw_response_item" } & RawResponseItemEvent | { "type": "item_started" } & ItemStartedEvent | { "type": "item_completed" } & ItemCompletedEvent | { "type": "agent_message_content_delta" } & AgentMessageContentDeltaEvent | { "type": "plan_delta" } & PlanDeltaEvent | { "type": "reasoning_content_delta" } & ReasoningContentDeltaEvent | { "type": "reasoning_raw_content_delta" } & ReasoningRawContentDeltaEvent | { "type": "collab_agent_spawn_begin" } & CollabAgentSpawnBeginEvent | { "type": "collab_agent_spawn_end" } & CollabAgentSpawnEndEvent | { "type": "collab_agent_interaction_begin" } & CollabAgentInteractionBeginEvent | { "type": "collab_agent_interaction_end" } & CollabAgentInteractionEndEvent | { "type": "collab_waiting_begin" } & CollabWaitingBeginEvent | { "type": "collab_waiting_end" } & CollabWaitingEndEvent | { "type": "collab_close_begin" } & CollabCloseBeginEvent | { "type": "collab_close_end" } & CollabCloseEndEvent | { "type": "collab_resume_begin" } & CollabResumeBeginEvent | { "type": "collab_resume_end" } & CollabResumeEndEvent; +export type EventMsg = { "type": "error" } & ErrorEvent | { "type": "warning" } & WarningEvent | { "type": "realtime_conversation_started" } & RealtimeConversationStartedEvent | { "type": "realtime_conversation_realtime" } & RealtimeConversationRealtimeEvent | { "type": "realtime_conversation_closed" } & RealtimeConversationClosedEvent | { "type": "model_reroute" } & ModelRerouteEvent | { "type": "context_compacted" } & ContextCompactedEvent | { "type": "thread_rolled_back" } & ThreadRolledBackEvent | { "type": "task_started" } & TurnStartedEvent | { "type": "task_complete" } & TurnCompleteEvent | { "type": "token_count" } & TokenCountEvent | { "type": "agent_message" } & AgentMessageEvent | { "type": "user_message" } & UserMessageEvent | { "type": "agent_message_delta" } & AgentMessageDeltaEvent | { "type": "agent_reasoning" } & AgentReasoningEvent | { "type": "agent_reasoning_delta" } & AgentReasoningDeltaEvent | { "type": "agent_reasoning_raw_content" } & AgentReasoningRawContentEvent | { "type": "agent_reasoning_raw_content_delta" } & AgentReasoningRawContentDeltaEvent | { "type": "agent_reasoning_section_break" } & AgentReasoningSectionBreakEvent | { "type": "session_configured" } & SessionConfiguredEvent | { "type": "thread_name_updated" } & ThreadNameUpdatedEvent | { "type": "mcp_startup_update" } & McpStartupUpdateEvent | { "type": "mcp_startup_complete" } & McpStartupCompleteEvent | { "type": "mcp_tool_call_begin" } & McpToolCallBeginEvent | { "type": "mcp_tool_call_end" } & McpToolCallEndEvent | { "type": "web_search_begin" } & WebSearchBeginEvent | { "type": "web_search_end" } & WebSearchEndEvent | { "type": "image_generation_begin" } & ImageGenerationBeginEvent | { "type": "image_generation_end" } & ImageGenerationEndEvent | { "type": "exec_command_begin" } & ExecCommandBeginEvent | { "type": "exec_command_output_delta" } & ExecCommandOutputDeltaEvent | { "type": "terminal_interaction" } & TerminalInteractionEvent | { "type": "exec_command_end" } & ExecCommandEndEvent | { "type": "view_image_tool_call" } & ViewImageToolCallEvent | { "type": "exec_approval_request" } & ExecApprovalRequestEvent | { "type": "request_permissions" } & RequestPermissionsEvent | { "type": "request_user_input" } & RequestUserInputEvent | { "type": "dynamic_tool_call_request" } & DynamicToolCallRequest | { "type": "dynamic_tool_call_response" } & DynamicToolCallResponseEvent | { "type": "elicitation_request" } & ElicitationRequestEvent | { "type": "apply_patch_approval_request" } & ApplyPatchApprovalRequestEvent | { "type": "deprecation_notice" } & DeprecationNoticeEvent | { "type": "background_event" } & BackgroundEventEvent | { "type": "undo_started" } & UndoStartedEvent | { "type": "undo_completed" } & UndoCompletedEvent | { "type": "stream_error" } & StreamErrorEvent | { "type": "patch_apply_begin" } & PatchApplyBeginEvent | { "type": "patch_apply_end" } & PatchApplyEndEvent | { "type": "turn_diff" } & TurnDiffEvent | { "type": "get_history_entry_response" } & GetHistoryEntryResponseEvent | { "type": "mcp_list_tools_response" } & McpListToolsResponseEvent | { "type": "list_custom_prompts_response" } & ListCustomPromptsResponseEvent | { "type": "list_skills_response" } & ListSkillsResponseEvent | { "type": "list_remote_skills_response" } & ListRemoteSkillsResponseEvent | { "type": "remote_skill_downloaded" } & RemoteSkillDownloadedEvent | { "type": "skills_update_available" } | { "type": "plan_update" } & UpdatePlanArgs | { "type": "turn_aborted" } & TurnAbortedEvent | { "type": "shutdown_complete" } | { "type": "entered_review_mode" } & ReviewRequest | { "type": "exited_review_mode" } & ExitedReviewModeEvent | { "type": "raw_response_item" } & RawResponseItemEvent | { "type": "item_started" } & ItemStartedEvent | { "type": "item_completed" } & ItemCompletedEvent | { "type": "agent_message_content_delta" } & AgentMessageContentDeltaEvent | { "type": "plan_delta" } & PlanDeltaEvent | { "type": "reasoning_content_delta" } & ReasoningContentDeltaEvent | { "type": "reasoning_raw_content_delta" } & ReasoningRawContentDeltaEvent | { "type": "collab_agent_spawn_begin" } & CollabAgentSpawnBeginEvent | { "type": "collab_agent_spawn_end" } & CollabAgentSpawnEndEvent | { "type": "collab_agent_interaction_begin" } & CollabAgentInteractionBeginEvent | { "type": "collab_agent_interaction_end" } & CollabAgentInteractionEndEvent | { "type": "collab_waiting_begin" } & CollabWaitingBeginEvent | { "type": "collab_waiting_end" } & CollabWaitingEndEvent | { "type": "collab_close_begin" } & CollabCloseBeginEvent | { "type": "collab_close_end" } & CollabCloseEndEvent | { "type": "collab_resume_begin" } & CollabResumeBeginEvent | { "type": "collab_resume_end" } & CollabResumeEndEvent; diff --git a/codex-rs/app-server-protocol/schema/typescript/ExecApprovalRequestEvent.ts b/codex-rs/app-server-protocol/schema/typescript/ExecApprovalRequestEvent.ts index cc13400900..5f305f521d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ExecApprovalRequestEvent.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ExecApprovalRequestEvent.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ExecApprovalRequestSkillMetadata } from "./ExecApprovalRequestSkillMetadata"; import type { ExecPolicyAmendment } from "./ExecPolicyAmendment"; import type { NetworkApprovalContext } from "./NetworkApprovalContext"; import type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment"; @@ -53,6 +54,10 @@ proposed_network_policy_amendments?: Array, * Optional additional filesystem permissions requested for this command. */ additional_permissions?: PermissionProfile, +/** + * Optional skill metadata when the approval was triggered by a skill script. + */ +skill_metadata?: ExecApprovalRequestSkillMetadata, /** * Ordered list of decisions the client may present for this prompt. * diff --git a/codex-rs/app-server-protocol/schema/typescript/ExecApprovalRequestSkillMetadata.ts b/codex-rs/app-server-protocol/schema/typescript/ExecApprovalRequestSkillMetadata.ts new file mode 100644 index 0000000000..1121e214eb --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/ExecApprovalRequestSkillMetadata.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 ExecApprovalRequestSkillMetadata = { path_to_skills_md: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/RejectConfig.ts b/codex-rs/app-server-protocol/schema/typescript/RejectConfig.ts index 320f1096b0..19b26481c7 100644 --- a/codex-rs/app-server-protocol/schema/typescript/RejectConfig.ts +++ b/codex-rs/app-server-protocol/schema/typescript/RejectConfig.ts @@ -11,6 +11,10 @@ sandbox_approval: boolean, * Reject prompts triggered by execpolicy `prompt` rules. */ rules: boolean, +/** + * Reject approval prompts related to built-in permission requests. + */ +request_permissions: boolean, /** * Reject MCP elicitation prompts. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/RequestPermissionsEvent.ts b/codex-rs/app-server-protocol/schema/typescript/RequestPermissionsEvent.ts new file mode 100644 index 0000000000..33a109f46e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/RequestPermissionsEvent.ts @@ -0,0 +1,15 @@ +// 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 { PermissionProfile } from "./PermissionProfile"; + +export type RequestPermissionsEvent = { +/** + * Responses API call id for the associated tool call, if available. + */ +call_id: string, +/** + * Turn ID that this request belongs to. + * Uses `#[serde(default)]` for backwards compatibility. + */ +turn_id: string, reason: string | null, permissions: PermissionProfile, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ServerRequest.ts index 107c5eebc0..13d04b0be7 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerRequest.ts @@ -9,9 +9,10 @@ import type { CommandExecutionRequestApprovalParams } from "./v2/CommandExecutio import type { DynamicToolCallParams } from "./v2/DynamicToolCallParams"; import type { FileChangeRequestApprovalParams } from "./v2/FileChangeRequestApprovalParams"; import type { McpServerElicitationRequestParams } from "./v2/McpServerElicitationRequestParams"; +import type { PermissionsRequestApprovalParams } from "./v2/PermissionsRequestApprovalParams"; import type { ToolRequestUserInputParams } from "./v2/ToolRequestUserInputParams"; /** * Request initiated from the server and sent to the client. */ -export type ServerRequest = { "method": "item/commandExecution/requestApproval", id: RequestId, params: CommandExecutionRequestApprovalParams, } | { "method": "item/fileChange/requestApproval", id: RequestId, params: FileChangeRequestApprovalParams, } | { "method": "item/tool/requestUserInput", id: RequestId, params: ToolRequestUserInputParams, } | { "method": "mcpServer/elicitation/request", id: RequestId, params: McpServerElicitationRequestParams, } | { "method": "item/tool/call", id: RequestId, params: DynamicToolCallParams, } | { "method": "account/chatgptAuthTokens/refresh", id: RequestId, params: ChatgptAuthTokensRefreshParams, } | { "method": "applyPatchApproval", id: RequestId, params: ApplyPatchApprovalParams, } | { "method": "execCommandApproval", id: RequestId, params: ExecCommandApprovalParams, }; +export type ServerRequest = { "method": "item/commandExecution/requestApproval", id: RequestId, params: CommandExecutionRequestApprovalParams, } | { "method": "item/fileChange/requestApproval", id: RequestId, params: FileChangeRequestApprovalParams, } | { "method": "item/tool/requestUserInput", id: RequestId, params: ToolRequestUserInputParams, } | { "method": "mcpServer/elicitation/request", id: RequestId, params: McpServerElicitationRequestParams, } | { "method": "item/permissions/requestApproval", id: RequestId, params: PermissionsRequestApprovalParams, } | { "method": "item/tool/call", id: RequestId, params: DynamicToolCallParams, } | { "method": "account/chatgptAuthTokens/refresh", id: RequestId, params: ChatgptAuthTokensRefreshParams, } | { "method": "applyPatchApproval", id: RequestId, params: ApplyPatchApprovalParams, } | { "method": "execCommandApproval", id: RequestId, params: ExecCommandApprovalParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index af0db3367e..54dea84bc4 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -53,6 +53,7 @@ export type { ElicitationRequestEvent } from "./ElicitationRequestEvent"; export type { ErrorEvent } from "./ErrorEvent"; export type { EventMsg } from "./EventMsg"; export type { ExecApprovalRequestEvent } from "./ExecApprovalRequestEvent"; +export type { ExecApprovalRequestSkillMetadata } from "./ExecApprovalRequestSkillMetadata"; export type { ExecCommandApprovalParams } from "./ExecCommandApprovalParams"; export type { ExecCommandApprovalResponse } from "./ExecCommandApprovalResponse"; export type { ExecCommandBeginEvent } from "./ExecCommandBeginEvent"; @@ -154,6 +155,7 @@ export type { RejectConfig } from "./RejectConfig"; export type { RemoteSkillDownloadedEvent } from "./RemoteSkillDownloadedEvent"; export type { RemoteSkillSummary } from "./RemoteSkillSummary"; export type { RequestId } from "./RequestId"; +export type { RequestPermissionsEvent } from "./RequestPermissionsEvent"; export type { RequestUserInputEvent } from "./RequestUserInputEvent"; export type { RequestUserInputQuestion } from "./RequestUserInputQuestion"; export type { RequestUserInputQuestionOption } from "./RequestUserInputQuestionOption"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AskForApproval.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AskForApproval.ts index b5f1bacdfa..46f5fa8c35 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AskForApproval.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AskForApproval.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type AskForApproval = "untrusted" | "on-failure" | "on-request" | { "reject": { sandbox_approval: boolean, rules: boolean, mcp_elicitations: boolean, } } | "never"; +export type AskForApproval = "untrusted" | "on-failure" | "on-request" | { "reject": { sandbox_approval: boolean, rules: boolean, request_permissions: boolean, mcp_elicitations: boolean, } } | "never"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts index 8fb6375e69..623fb971c1 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts @@ -4,6 +4,7 @@ import type { AdditionalPermissionProfile } from "./AdditionalPermissionProfile"; import type { CommandAction } from "./CommandAction"; import type { CommandExecutionApprovalDecision } from "./CommandExecutionApprovalDecision"; +import type { CommandExecutionRequestApprovalSkillMetadata } from "./CommandExecutionRequestApprovalSkillMetadata"; import type { ExecPolicyAmendment } from "./ExecPolicyAmendment"; import type { NetworkApprovalContext } from "./NetworkApprovalContext"; import type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment"; @@ -43,6 +44,10 @@ commandActions?: Array | null, * Optional additional permissions requested for this command. */ additionalPermissions?: AdditionalPermissionProfile | null, +/** + * Optional skill metadata when the approval was triggered by a skill script. + */ +skillMetadata?: CommandExecutionRequestApprovalSkillMetadata | null, /** * Optional proposed execpolicy amendment to allow similar commands without prompting. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalSkillMetadata.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalSkillMetadata.ts new file mode 100644 index 0000000000..dcfd7c2916 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalSkillMetadata.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 CommandExecutionRequestApprovalSkillMetadata = { pathToSkillsMd: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigBatchWriteParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigBatchWriteParams.ts index 77df84e3b1..6103039ef6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigBatchWriteParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigBatchWriteParams.ts @@ -7,4 +7,8 @@ export type ConfigBatchWriteParams = { edits: Array, /** * Path to the config file to write; defaults to the user's `config.toml` when omitted. */ -filePath?: string | null, expectedVersion?: string | null, }; +filePath?: string | null, expectedVersion?: string | null, +/** + * When true, hot-reload the updated user config into all loaded threads after writing. + */ +reloadUserConfig?: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/GrantedMacOsPermissions.ts b/codex-rs/app-server-protocol/schema/typescript/v2/GrantedMacOsPermissions.ts new file mode 100644 index 0000000000..b95a2940f1 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/GrantedMacOsPermissions.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { MacOsAutomationPermission } from "../MacOsAutomationPermission"; +import type { MacOsPreferencesPermission } from "../MacOsPreferencesPermission"; + +export type GrantedMacOsPermissions = { preferences?: MacOsPreferencesPermission, automations?: MacOsAutomationPermission, accessibility?: boolean, calendar?: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/GrantedPermissionProfile.ts b/codex-rs/app-server-protocol/schema/typescript/v2/GrantedPermissionProfile.ts new file mode 100644 index 0000000000..84a9aa3778 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/GrantedPermissionProfile.ts @@ -0,0 +1,8 @@ +// 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 { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPermissions"; +import type { AdditionalNetworkPermissions } from "./AdditionalNetworkPermissions"; +import type { GrantedMacOsPermissions } from "./GrantedMacOsPermissions"; + +export type GrantedPermissionProfile = { network?: AdditionalNetworkPermissions, fileSystem?: AdditionalFileSystemPermissions, macos?: GrantedMacOsPermissions, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionGrantScope.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionGrantScope.ts new file mode 100644 index 0000000000..8ca127ebcb --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionGrantScope.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 PermissionGrantScope = "turn" | "session"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalParams.ts new file mode 100644 index 0000000000..cde277f1a4 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalParams.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AdditionalPermissionProfile } from "./AdditionalPermissionProfile"; + +export type PermissionsRequestApprovalParams = { threadId: string, turnId: string, itemId: string, reason: string | null, permissions: AdditionalPermissionProfile, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalResponse.ts new file mode 100644 index 0000000000..6561417b40 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalResponse.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { GrantedPermissionProfile } from "./GrantedPermissionProfile"; +import type { PermissionGrantScope } from "./PermissionGrantScope"; + +export type PermissionsRequestApprovalResponse = { permissions: GrantedPermissionProfile, scope: PermissionGrantScope, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginUninstallParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginUninstallParams.ts new file mode 100644 index 0000000000..e7f52c0eb3 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginUninstallParams.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 PluginUninstallParams = { pluginId: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginUninstallResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginUninstallResponse.ts new file mode 100644 index 0000000000..5d02c2f716 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginUninstallResponse.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 PluginUninstallResponse = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index ac81e35a5a..50689c33c0 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -53,6 +53,7 @@ export type { CommandExecutionApprovalDecision } from "./CommandExecutionApprova export type { CommandExecutionOutputDeltaNotification } from "./CommandExecutionOutputDeltaNotification"; export type { CommandExecutionRequestApprovalParams } from "./CommandExecutionRequestApprovalParams"; export type { CommandExecutionRequestApprovalResponse } from "./CommandExecutionRequestApprovalResponse"; +export type { CommandExecutionRequestApprovalSkillMetadata } from "./CommandExecutionRequestApprovalSkillMetadata"; export type { CommandExecutionStatus } from "./CommandExecutionStatus"; export type { Config } from "./Config"; export type { ConfigBatchWriteParams } from "./ConfigBatchWriteParams"; @@ -98,6 +99,8 @@ export type { GetAccountParams } from "./GetAccountParams"; export type { GetAccountRateLimitsResponse } from "./GetAccountRateLimitsResponse"; export type { GetAccountResponse } from "./GetAccountResponse"; export type { GitInfo } from "./GitInfo"; +export type { GrantedMacOsPermissions } from "./GrantedMacOsPermissions"; +export type { GrantedPermissionProfile } from "./GrantedPermissionProfile"; export type { HazelnutScope } from "./HazelnutScope"; export type { ItemCompletedNotification } from "./ItemCompletedNotification"; export type { ItemStartedNotification } from "./ItemStartedNotification"; @@ -158,6 +161,9 @@ export type { NetworkRequirements } from "./NetworkRequirements"; export type { OverriddenMetadata } from "./OverriddenMetadata"; export type { PatchApplyStatus } from "./PatchApplyStatus"; export type { PatchChangeKind } from "./PatchChangeKind"; +export type { PermissionGrantScope } from "./PermissionGrantScope"; +export type { PermissionsRequestApprovalParams } from "./PermissionsRequestApprovalParams"; +export type { PermissionsRequestApprovalResponse } from "./PermissionsRequestApprovalResponse"; export type { PlanDeltaNotification } from "./PlanDeltaNotification"; export type { PluginInstallParams } from "./PluginInstallParams"; export type { PluginInstallResponse } from "./PluginInstallResponse"; @@ -167,6 +173,8 @@ export type { PluginListResponse } from "./PluginListResponse"; export type { PluginMarketplaceEntry } from "./PluginMarketplaceEntry"; 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"; diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index ba151046a1..10fca2a198 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -23,6 +23,7 @@ use schemars::schema_for; use serde::Serialize; use serde_json::Map; use serde_json::Value; +use std::collections::BTreeMap; use std::collections::HashMap; use std::collections::HashSet; use std::ffi::OsStr; @@ -32,9 +33,10 @@ use std::io::Write; use std::path::Path; use std::path::PathBuf; use std::process::Command; +use std::thread; use ts_rs::TS; -const HEADER: &str = "// GENERATED CODE! DO NOT MODIFY BY HAND!\n\n"; +pub(crate) const GENERATED_TS_HEADER: &str = "// GENERATED CODE! DO NOT MODIFY BY HAND!\n\n"; const IGNORED_DEFINITIONS: &[&str] = &["Option<()>"]; const JSON_V1_ALLOWLIST: &[&str] = &["InitializeParams", "InitializeResponse"]; const SPECIAL_DEFINITIONS: &[&str] = &[ @@ -137,9 +139,29 @@ pub fn generate_ts_with_options( } if options.ensure_headers { - for file in &ts_files { - prepend_header_if_missing(file)?; - } + let worker_count = thread::available_parallelism() + .map_or(1, usize::from) + .min(ts_files.len().max(1)); + let chunk_size = ts_files.len().div_ceil(worker_count); + thread::scope(|scope| -> Result<()> { + let mut workers = Vec::new(); + for chunk in ts_files.chunks(chunk_size.max(1)) { + workers.push(scope.spawn(move || -> Result<()> { + for file in chunk { + prepend_header_if_missing(file)?; + } + Ok(()) + })); + } + + for worker in workers { + worker + .join() + .map_err(|_| anyhow!("TypeScript header worker panicked"))??; + } + + Ok(()) + })?; } // Optionally run Prettier on all generated TS files. @@ -231,6 +253,41 @@ fn filter_experimental_ts(out_dir: &Path) -> Result<()> { Ok(()) } +pub(crate) fn filter_experimental_ts_tree(tree: &mut BTreeMap) -> Result<()> { + let registered_fields = experimental_fields(); + let experimental_method_types = experimental_method_types(); + if let Some(content) = tree.get_mut(Path::new("ClientRequest.ts")) { + let filtered = + filter_client_request_ts_contents(std::mem::take(content), EXPERIMENTAL_CLIENT_METHODS); + *content = filtered; + } + + let mut fields_by_type_name: HashMap> = HashMap::new(); + for field in registered_fields { + fields_by_type_name + .entry(field.type_name.to_string()) + .or_default() + .insert(field.field_name.to_string()); + } + + for (path, content) in tree.iter_mut() { + let Some(type_name) = path.file_stem().and_then(|stem| stem.to_str()) else { + continue; + }; + let Some(experimental_field_names) = fields_by_type_name.get(type_name) else { + continue; + }; + let filtered = filter_experimental_type_fields_ts_contents( + std::mem::take(content), + experimental_field_names, + ); + *content = filtered; + } + + remove_generated_type_entries(tree, &experimental_method_types, "ts"); + Ok(()) +} + /// Removes union arms from `ClientRequest.ts` for methods marked experimental. fn filter_client_request_ts(out_dir: &Path, experimental_methods: &[&str]) -> Result<()> { let path = out_dir.join("ClientRequest.ts"); @@ -239,9 +296,15 @@ fn filter_client_request_ts(out_dir: &Path, experimental_methods: &[&str]) -> Re } let mut content = fs::read_to_string(&path).with_context(|| format!("Failed to read {}", path.display()))?; + content = filter_client_request_ts_contents(content, experimental_methods); + fs::write(&path, content).with_context(|| format!("Failed to write {}", path.display()))?; + Ok(()) +} + +fn filter_client_request_ts_contents(mut content: String, experimental_methods: &[&str]) -> String { let Some((prefix, body, suffix)) = split_type_alias(&content) else { - return Ok(()); + return content; }; let experimental_methods: HashSet<&str> = experimental_methods .iter() @@ -259,12 +322,9 @@ fn filter_client_request_ts(out_dir: &Path, experimental_methods: &[&str]) -> Re let new_body = filtered_arms.join(" | "); content = format!("{prefix}{new_body}{suffix}"); let import_usage_scope = split_type_alias(&content) - .map(|(_, body, _)| body) + .map(|(_, filtered_body, _)| filtered_body) .unwrap_or_else(|| new_body.clone()); - content = prune_unused_type_imports(content, &import_usage_scope); - - fs::write(&path, content).with_context(|| format!("Failed to write {}", path.display()))?; - Ok(()) + prune_unused_type_imports(content, &import_usage_scope) } /// Removes experimental properties from generated TypeScript type files. @@ -302,8 +362,17 @@ fn filter_experimental_fields_in_ts_file( ) -> Result<()> { let mut content = fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?; + content = filter_experimental_type_fields_ts_contents(content, experimental_field_names); + fs::write(path, content).with_context(|| format!("Failed to write {}", path.display()))?; + Ok(()) +} + +fn filter_experimental_type_fields_ts_contents( + mut content: String, + experimental_field_names: &HashSet, +) -> String { let Some((open_brace, close_brace)) = type_body_brace_span(&content) else { - return Ok(()); + return content; }; let inner = &content[open_brace + 1..close_brace]; let fields = split_top_level_multi(inner, &[',', ';']); @@ -322,9 +391,7 @@ fn filter_experimental_fields_in_ts_file( let import_usage_scope = split_type_alias(&content) .map(|(_, body, _)| body) .unwrap_or_else(|| new_inner.clone()); - content = prune_unused_type_imports(content, &import_usage_scope); - fs::write(path, content).with_context(|| format!("Failed to write {}", path.display()))?; - Ok(()) + prune_unused_type_imports(content, &import_usage_scope) } fn filter_experimental_schema(bundle: &mut Value) -> Result<()> { @@ -526,6 +593,23 @@ fn remove_generated_type_files( Ok(()) } +fn remove_generated_type_entries( + tree: &mut BTreeMap, + type_names: &HashSet, + extension: &str, +) { + for type_name in type_names { + for subdir in ["", "v1", "v2"] { + let path = if subdir.is_empty() { + PathBuf::from(format!("{type_name}.{extension}")) + } else { + PathBuf::from(subdir).join(format!("{type_name}.{extension}")) + }; + tree.remove(&path); + } + } +} + fn remove_experimental_method_type_definitions(bundle: &mut Value) { let type_names = experimental_method_types(); let Some(definitions) = bundle.get_mut("definitions").and_then(Value::as_object_mut) else { @@ -1807,13 +1891,13 @@ fn prepend_header_if_missing(path: &Path) -> Result<()> { .with_context(|| format!("Failed to read {}", path.display()))?; } - if content.starts_with(HEADER) { + if content.starts_with(GENERATED_TS_HEADER) { return Ok(()); } let mut f = fs::File::create(path) .with_context(|| format!("Failed to open {} for writing", path.display()))?; - f.write_all(HEADER.as_bytes()) + f.write_all(GENERATED_TS_HEADER.as_bytes()) .with_context(|| format!("Failed to write header to {}", path.display()))?; f.write_all(content.as_bytes()) .with_context(|| format!("Failed to write content to {}", path.display()))?; @@ -1858,35 +1942,15 @@ fn ts_files_in_recursive(dir: &Path) -> Result> { /// Generate an index.ts file that re-exports all generated types. /// This allows consumers to import all types from a single file. fn generate_index_ts(out_dir: &Path) -> Result { - let mut entries: Vec = Vec::new(); - let mut stems: Vec = ts_files_in(out_dir)? - .into_iter() - .filter_map(|p| { - let stem = p.file_stem()?.to_string_lossy().into_owned(); - if stem == "index" { None } else { Some(stem) } - }) - .collect(); - stems.sort(); - stems.dedup(); - - for name in stems { - entries.push(format!("export type {{ {name} }} from \"./{name}\";\n")); - } - - // If this is the root out_dir and a ./v2 folder exists with TS files, - // expose it as a namespace to avoid symbol collisions at the root. - let v2_dir = out_dir.join("v2"); - let has_v2_ts = ts_files_in(&v2_dir).map(|v| !v.is_empty()).unwrap_or(false); - if has_v2_ts { - entries.push("export * as v2 from \"./v2\";\n".to_string()); - } - - let mut content = - String::with_capacity(HEADER.len() + entries.iter().map(String::len).sum::()); - content.push_str(HEADER); - for line in &entries { - content.push_str(line); - } + let content = generated_index_ts_with_header(index_ts_entries( + &ts_files_in(out_dir)? + .iter() + .map(PathBuf::as_path) + .collect::>(), + ts_files_in(&out_dir.join("v2")) + .map(|v| !v.is_empty()) + .unwrap_or(false), + )); let index_path = out_dir.join("index.ts"); let mut f = fs::File::create(&index_path) @@ -1896,244 +1960,278 @@ fn generate_index_ts(out_dir: &Path) -> Result { Ok(index_path) } +pub(crate) fn generate_index_ts_tree(tree: &mut BTreeMap) { + let root_entries = tree + .keys() + .filter(|path| path.components().count() == 1) + .map(PathBuf::as_path) + .collect::>(); + let has_v2_ts = tree.keys().any(|path| { + path.parent() + .is_some_and(|parent| parent == Path::new("v2")) + && path.extension() == Some(OsStr::new("ts")) + && path.file_stem().is_some_and(|stem| stem != "index") + }); + tree.insert( + PathBuf::from("index.ts"), + index_ts_entries(&root_entries, has_v2_ts), + ); + + let v2_entries = tree + .keys() + .filter(|path| { + path.parent() + .is_some_and(|parent| parent == Path::new("v2")) + }) + .map(PathBuf::as_path) + .collect::>(); + if !v2_entries.is_empty() { + tree.insert( + PathBuf::from("v2").join("index.ts"), + index_ts_entries(&v2_entries, false), + ); + } +} + +fn generated_index_ts_with_header(content: String) -> String { + let mut with_header = String::with_capacity(GENERATED_TS_HEADER.len() + content.len()); + with_header.push_str(GENERATED_TS_HEADER); + with_header.push_str(&content); + with_header +} + +fn index_ts_entries(paths: &[&Path], has_v2_ts: bool) -> String { + let mut stems: Vec = paths + .iter() + .filter(|path| path.extension() == Some(OsStr::new("ts"))) + .filter_map(|path| { + let stem = path.file_stem()?.to_string_lossy().into_owned(); + if stem == "index" { None } else { Some(stem) } + }) + .collect(); + stems.sort(); + stems.dedup(); + + let mut entries = String::new(); + for name in stems { + entries.push_str(&format!("export type {{ {name} }} from \"./{name}\";\n")); + } + if has_v2_ts { + entries.push_str("export * as v2 from \"./v2\";\n"); + } + entries +} + #[cfg(test)] mod tests { use super::*; use crate::protocol::v2; + use crate::schema_fixtures::read_schema_fixture_subtree; + use anyhow::Context; use anyhow::Result; use pretty_assertions::assert_eq; use std::collections::BTreeSet; - use std::fs; + use std::path::Path; use std::path::PathBuf; use uuid::Uuid; #[test] fn generated_ts_optional_nullable_fields_only_in_params() -> Result<()> { // Assert that "?: T | null" only appears in generated *Params types. - let output_dir = std::env::temp_dir().join(format!("codex_ts_types_{}", Uuid::now_v7())); - fs::create_dir(&output_dir)?; + let fixture_tree = read_schema_fixture_subtree(&schema_root()?, "typescript")?; - struct TempDirGuard(PathBuf); - - impl Drop for TempDirGuard { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); - } - } - - let _guard = TempDirGuard(output_dir.clone()); - - // Avoid doing more work than necessary to keep the test from timing out. - let options = GenerateTsOptions { - generate_indices: false, - ensure_headers: false, - run_prettier: false, - experimental_api: false, - }; - generate_ts_with_options(&output_dir, None, options)?; - - let client_request_ts = fs::read_to_string(output_dir.join("ClientRequest.ts"))?; + let client_request_ts = std::str::from_utf8( + fixture_tree + .get(Path::new("ClientRequest.ts")) + .ok_or_else(|| anyhow::anyhow!("missing ClientRequest.ts fixture"))?, + )?; assert_eq!(client_request_ts.contains("mock/experimentalMethod"), false); assert_eq!( client_request_ts.contains("MockExperimentalMethodParams"), false ); - assert_eq!(output_dir.join("EventMsg.ts").exists(), true); - let thread_start_ts = - fs::read_to_string(output_dir.join("v2").join("ThreadStartParams.ts"))?; + assert_eq!(fixture_tree.contains_key(Path::new("EventMsg.ts")), true); + let thread_start_ts = std::str::from_utf8( + fixture_tree + .get(Path::new("v2/ThreadStartParams.ts")) + .ok_or_else(|| anyhow::anyhow!("missing v2/ThreadStartParams.ts fixture"))?, + )?; assert_eq!(thread_start_ts.contains("mockExperimentalField"), false); assert_eq!( - output_dir - .join("v2") - .join("MockExperimentalMethodParams.ts") - .exists(), + fixture_tree.contains_key(Path::new("v2/MockExperimentalMethodParams.ts")), false ); assert_eq!( - output_dir - .join("v2") - .join("MockExperimentalMethodResponse.ts") - .exists(), + fixture_tree.contains_key(Path::new("v2/MockExperimentalMethodResponse.ts")), false ); let mut undefined_offenders = Vec::new(); let mut optional_nullable_offenders = BTreeSet::new(); - let mut stack = vec![output_dir]; - while let Some(dir) = stack.pop() { - for entry in fs::read_dir(&dir)? { - let entry = entry?; - let path = entry.path(); - if path.is_dir() { - stack.push(path); + for (path, contents) in &fixture_tree { + if !matches!(path.extension().and_then(|ext| ext.to_str()), Some("ts")) { + continue; + } + + // Only allow "?: T | null" in objects representing JSON-RPC requests, + // which we assume are called "*Params". + let allow_optional_nullable = path + .file_stem() + .and_then(|stem| stem.to_str()) + .is_some_and(|stem| { + stem.ends_with("Params") + || stem == "InitializeCapabilities" + || matches!( + stem, + "CollabAgentRef" + | "CollabAgentStatusEntry" + | "CollabAgentSpawnEndEvent" + | "CollabAgentInteractionEndEvent" + | "CollabCloseEndEvent" + | "CollabResumeBeginEvent" + | "CollabResumeEndEvent" + ) + }); + + let contents = std::str::from_utf8(contents)?; + if contents.contains("| undefined") { + undefined_offenders.push(path.clone()); + } + + const SKIP_PREFIXES: &[&str] = &[ + "const ", + "let ", + "var ", + "export const ", + "export let ", + "export var ", + ]; + + let mut search_start = 0; + while let Some(idx) = contents[search_start..].find("| null") { + let abs_idx = search_start + idx; + // Find the property-colon for this field by scanning forward + // from the start of the segment and ignoring nested braces, + // brackets, and parens. This avoids colons inside nested + // type literals like `{ [k in string]?: string }`. + + let line_start_idx = contents[..abs_idx].rfind('\n').map(|i| i + 1).unwrap_or(0); + + let mut segment_start_idx = line_start_idx; + if let Some(rel_idx) = contents[line_start_idx..abs_idx].rfind(',') { + segment_start_idx = segment_start_idx.max(line_start_idx + rel_idx + 1); + } + if let Some(rel_idx) = contents[line_start_idx..abs_idx].rfind('{') { + segment_start_idx = segment_start_idx.max(line_start_idx + rel_idx + 1); + } + if let Some(rel_idx) = contents[line_start_idx..abs_idx].rfind('}') { + segment_start_idx = segment_start_idx.max(line_start_idx + rel_idx + 1); + } + + // Scan forward for the colon that separates the field name from its type. + let mut level_brace = 0_i32; + let mut level_brack = 0_i32; + let mut level_paren = 0_i32; + let mut in_single = false; + let mut in_double = false; + let mut escape = false; + let mut prop_colon_idx = None; + for (i, ch) in contents[segment_start_idx..abs_idx].char_indices() { + let idx_abs = segment_start_idx + i; + if escape { + escape = false; + continue; + } + match ch { + '\\' => { + if in_single || in_double { + escape = true; + } + } + '\'' => { + if !in_double { + in_single = !in_single; + } + } + '"' => { + if !in_single { + in_double = !in_double; + } + } + '{' if !in_single && !in_double => level_brace += 1, + '}' if !in_single && !in_double => level_brace -= 1, + '[' if !in_single && !in_double => level_brack += 1, + ']' if !in_single && !in_double => level_brack -= 1, + '(' if !in_single && !in_double => level_paren += 1, + ')' if !in_single && !in_double => level_paren -= 1, + ':' if !in_single + && !in_double + && level_brace == 0 + && level_brack == 0 + && level_paren == 0 => + { + prop_colon_idx = Some(idx_abs); + break; + } + _ => {} + } + } + + let Some(colon_idx) = prop_colon_idx else { + search_start = abs_idx + 5; + continue; + }; + + let mut field_prefix = contents[segment_start_idx..colon_idx].trim(); + if field_prefix.is_empty() { + search_start = abs_idx + 5; continue; } - if matches!(path.extension().and_then(|ext| ext.to_str()), Some("ts")) { - // Only allow "?: T | null" in objects representing JSON-RPC requests, - // which we assume are called "*Params". - let allow_optional_nullable = path - .file_stem() - .and_then(|stem| stem.to_str()) - .is_some_and(|stem| { - stem.ends_with("Params") - || stem == "InitializeCapabilities" - || matches!( - stem, - "CollabAgentRef" - | "CollabAgentStatusEntry" - | "CollabAgentSpawnEndEvent" - | "CollabAgentInteractionEndEvent" - | "CollabCloseEndEvent" - | "CollabResumeBeginEvent" - | "CollabResumeEndEvent" - ) - }); - - let contents = fs::read_to_string(&path)?; - if contents.contains("| undefined") { - undefined_offenders.push(path.clone()); - } - - const SKIP_PREFIXES: &[&str] = &[ - "const ", - "let ", - "var ", - "export const ", - "export let ", - "export var ", - ]; - - let mut search_start = 0; - while let Some(idx) = contents[search_start..].find("| null") { - let abs_idx = search_start + idx; - // Find the property-colon for this field by scanning forward - // from the start of the segment and ignoring nested braces, - // brackets, and parens. This avoids colons inside nested - // type literals like `{ [k in string]?: string }`. - - let line_start_idx = - contents[..abs_idx].rfind('\n').map(|i| i + 1).unwrap_or(0); - - let mut segment_start_idx = line_start_idx; - if let Some(rel_idx) = contents[line_start_idx..abs_idx].rfind(',') { - segment_start_idx = segment_start_idx.max(line_start_idx + rel_idx + 1); - } - if let Some(rel_idx) = contents[line_start_idx..abs_idx].rfind('{') { - segment_start_idx = segment_start_idx.max(line_start_idx + rel_idx + 1); - } - if let Some(rel_idx) = contents[line_start_idx..abs_idx].rfind('}') { - segment_start_idx = segment_start_idx.max(line_start_idx + rel_idx + 1); - } - - // Scan forward for the colon that separates the field name from its type. - let mut level_brace = 0_i32; - let mut level_brack = 0_i32; - let mut level_paren = 0_i32; - let mut in_single = false; - let mut in_double = false; - let mut escape = false; - let mut prop_colon_idx = None; - for (i, ch) in contents[segment_start_idx..abs_idx].char_indices() { - let idx_abs = segment_start_idx + i; - if escape { - escape = false; - continue; - } - match ch { - '\\' => { - // Only treat as escape when inside a string. - if in_single || in_double { - escape = true; - } - } - '\'' => { - if !in_double { - in_single = !in_single; - } - } - '"' => { - if !in_single { - in_double = !in_double; - } - } - '{' if !in_single && !in_double => level_brace += 1, - '}' if !in_single && !in_double => level_brace -= 1, - '[' if !in_single && !in_double => level_brack += 1, - ']' if !in_single && !in_double => level_brack -= 1, - '(' if !in_single && !in_double => level_paren += 1, - ')' if !in_single && !in_double => level_paren -= 1, - ':' if !in_single - && !in_double - && level_brace == 0 - && level_brack == 0 - && level_paren == 0 => - { - prop_colon_idx = Some(idx_abs); - break; - } - _ => {} - } - } - - let Some(colon_idx) = prop_colon_idx else { - search_start = abs_idx + 5; - continue; - }; - - let mut field_prefix = contents[segment_start_idx..colon_idx].trim(); - if field_prefix.is_empty() { - search_start = abs_idx + 5; - continue; - } - - if let Some(comment_idx) = field_prefix.rfind("*/") { - field_prefix = field_prefix[comment_idx + 2..].trim_start(); - } - - if field_prefix.is_empty() { - search_start = abs_idx + 5; - continue; - } - - if SKIP_PREFIXES - .iter() - .any(|prefix| field_prefix.starts_with(prefix)) - { - search_start = abs_idx + 5; - continue; - } - - if field_prefix.contains('(') { - search_start = abs_idx + 5; - continue; - } - - // If the last non-whitespace before ':' is '?', then this is an - // optional field with a nullable type (i.e., "?: T | null"). - // These are only allowed in *Params types. - if field_prefix.chars().rev().find(|c| !c.is_whitespace()) == Some('?') - && !allow_optional_nullable - { - let line_number = - contents[..abs_idx].chars().filter(|c| *c == '\n').count() + 1; - let offending_line_end = contents[line_start_idx..] - .find('\n') - .map(|i| line_start_idx + i) - .unwrap_or(contents.len()); - let offending_snippet = - contents[line_start_idx..offending_line_end].trim(); - - optional_nullable_offenders.insert(format!( - "{}:{}: {offending_snippet}", - path.display(), - line_number - )); - } - - search_start = abs_idx + 5; - } + if let Some(comment_idx) = field_prefix.rfind("*/") { + field_prefix = field_prefix[comment_idx + 2..].trim_start(); } + + if field_prefix.is_empty() { + search_start = abs_idx + 5; + continue; + } + + if SKIP_PREFIXES + .iter() + .any(|prefix| field_prefix.starts_with(prefix)) + { + search_start = abs_idx + 5; + continue; + } + + if field_prefix.contains('(') { + search_start = abs_idx + 5; + continue; + } + + // If the last non-whitespace before ':' is '?', then this is an + // optional field with a nullable type (i.e., "?: T | null"). + // These are only allowed in *Params types. + if field_prefix.chars().rev().find(|c| !c.is_whitespace()) == Some('?') + && !allow_optional_nullable + { + let line_number = + contents[..abs_idx].chars().filter(|c| *c == '\n').count() + 1; + let offending_line_end = contents[line_start_idx..] + .find('\n') + .map(|i| line_start_idx + i) + .unwrap_or(contents.len()); + let offending_snippet = contents[line_start_idx..offending_line_end].trim(); + + optional_nullable_offenders.insert(format!( + "{}:{}: {offending_snippet}", + path.display(), + line_number + )); + } + + search_start = abs_idx + 5; } } @@ -2153,59 +2251,48 @@ mod tests { Ok(()) } + fn schema_root() -> Result { + let typescript_index = codex_utils_cargo_bin::find_resource!("schema/typescript/index.ts") + .context("resolve TypeScript schema index.ts")?; + let schema_root = typescript_index + .parent() + .and_then(|parent| parent.parent()) + .context("derive schema root from schema/typescript/index.ts")? + .to_path_buf(); + Ok(schema_root) + } + #[test] fn generate_ts_with_experimental_api_retains_experimental_entries() -> Result<()> { - let output_dir = - std::env::temp_dir().join(format!("codex_ts_types_experimental_{}", Uuid::now_v7())); - fs::create_dir(&output_dir)?; - - struct TempDirGuard(PathBuf); - - impl Drop for TempDirGuard { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); - } - } - - let _guard = TempDirGuard(output_dir.clone()); - - let options = GenerateTsOptions { - generate_indices: false, - ensure_headers: false, - run_prettier: false, - experimental_api: true, - }; - generate_ts_with_options(&output_dir, None, options)?; - - let client_request_ts = fs::read_to_string(output_dir.join("ClientRequest.ts"))?; + let client_request_ts = ClientRequest::export_to_string()?; assert_eq!(client_request_ts.contains("mock/experimentalMethod"), true); assert_eq!( - output_dir - .join("v2") - .join("MockExperimentalMethodParams.ts") - .exists(), + client_request_ts.contains("MockExperimentalMethodParams"), true ); assert_eq!( - output_dir - .join("v2") - .join("MockExperimentalMethodResponse.ts") - .exists(), + v2::MockExperimentalMethodParams::export_to_string()? + .contains("MockExperimentalMethodParams"), + true + ); + assert_eq!( + v2::MockExperimentalMethodResponse::export_to_string()? + .contains("MockExperimentalMethodResponse"), true ); - let thread_start_ts = - fs::read_to_string(output_dir.join("v2").join("ThreadStartParams.ts"))?; + let thread_start_ts = v2::ThreadStartParams::export_to_string()?; assert_eq!(thread_start_ts.contains("mockExperimentalField"), true); - let command_execution_request_approval_ts = fs::read_to_string( - output_dir - .join("v2") - .join("CommandExecutionRequestApprovalParams.ts"), - )?; + let command_execution_request_approval_ts = + v2::CommandExecutionRequestApprovalParams::export_to_string()?; assert_eq!( command_execution_request_approval_ts.contains("additionalPermissions"), true ); + assert_eq!( + command_execution_request_approval_ts.contains("skillMetadata"), + true + ); Ok(()) } @@ -2318,48 +2405,6 @@ mod tests { Ok(()) } - #[test] - fn build_schema_bundle_rejects_conflicting_duplicate_definitions() { - let err = build_schema_bundle(vec![ - GeneratedSchema { - namespace: Some("v2".to_string()), - logical_name: "First".to_string(), - in_v1_dir: false, - value: serde_json::json!({ - "title": "First", - "type": "object", - "definitions": { - "Shared": { - "title": "SharedString", - "type": "string" - } - } - }), - }, - GeneratedSchema { - namespace: Some("v2".to_string()), - logical_name: "Second".to_string(), - in_v1_dir: false, - value: serde_json::json!({ - "title": "Second", - "type": "object", - "definitions": { - "Shared": { - "title": "SharedInteger", - "type": "integer" - } - } - }), - }, - ]) - .expect_err("conflicting schema definitions should be rejected"); - - assert_eq!( - err.to_string(), - "schema definition collision in namespace `v2`: Shared (existing title: SharedString, new title: SharedInteger); use #[schemars(rename = \"...\")] to rename one of the conflicting schema definitions" - ); - } - #[test] fn build_flat_v2_schema_keeps_shared_root_schemas_and_dependencies() -> Result<()> { let bundle = serde_json::json!({ @@ -2668,6 +2713,10 @@ export type Config = { stableField: Keep, unstableField: string | null } & ({ [k command_execution_request_approval_json.contains("additionalPermissions"), false ); + assert_eq!( + command_execution_request_approval_json.contains("skillMetadata"), + false + ); let client_request_json = fs::read_to_string(output_dir.join("ClientRequest.json"))?; assert_eq!( @@ -2679,6 +2728,7 @@ export type Config = { stableField: Keep, unstableField: string | null } & ({ [k fs::read_to_string(output_dir.join("codex_app_server_protocol.schemas.json"))?; assert_eq!(bundle_json.contains("mockExperimentalField"), false); assert_eq!(bundle_json.contains("additionalPermissions"), false); + assert_eq!(bundle_json.contains("skillMetadata"), false); assert_eq!(bundle_json.contains("MockExperimentalMethodParams"), false); assert_eq!( bundle_json.contains("MockExperimentalMethodResponse"), @@ -2688,6 +2738,7 @@ export type Config = { stableField: Keep, unstableField: string | null } & ({ [k fs::read_to_string(output_dir.join("codex_app_server_protocol.v2.schemas.json"))?; assert_eq!(flat_v2_bundle_json.contains("mockExperimentalField"), false); assert_eq!(flat_v2_bundle_json.contains("additionalPermissions"), false); + assert_eq!(flat_v2_bundle_json.contains("skillMetadata"), false); assert_eq!( flat_v2_bundle_json.contains("MockExperimentalMethodParams"), false diff --git a/codex-rs/app-server-protocol/src/lib.rs b/codex-rs/app-server-protocol/src/lib.rs index 54a933bb74..26cf97011c 100644 --- a/codex-rs/app-server-protocol/src/lib.rs +++ b/codex-rs/app-server-protocol/src/lib.rs @@ -17,6 +17,9 @@ pub use protocol::thread_history::*; pub use protocol::v1::*; pub use protocol::v2::*; pub use schema_fixtures::SchemaFixtureOptions; +#[doc(hidden)] +pub use schema_fixtures::generate_typescript_schema_fixture_subtree_for_tests; +pub use schema_fixtures::read_schema_fixture_subtree; pub use schema_fixtures::read_schema_fixture_tree; pub use schema_fixtures::write_schema_fixtures; pub use schema_fixtures::write_schema_fixtures_with_options; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 54c0425256..63db91b1bd 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -110,6 +110,26 @@ macro_rules! client_request_definitions { )* } + impl ClientRequest { + pub fn id(&self) -> &RequestId { + match self { + $(Self::$variant { request_id, .. } => request_id,)* + } + } + + pub fn method(&self) -> String { + serde_json::to_value(self) + .ok() + .and_then(|value| { + value + .get("method") + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + }) + .unwrap_or_else(|| "".to_string()) + } + } + impl crate::experimental_api::ExperimentalApi for ClientRequest { fn experimental_reason(&self) -> Option<&'static str> { match self { @@ -151,6 +171,12 @@ macro_rules! client_request_definitions { Ok(()) } + pub(crate) fn visit_client_response_types(v: &mut impl ::ts_rs::TypeVisitor) { + $( + v.visit::<$response>(); + )* + } + #[allow(clippy::vec_init_then_push)] pub fn export_client_response_schemas( out_dir: &::std::path::Path, @@ -272,6 +298,10 @@ client_request_definitions! { params: v2::PluginInstallParams, response: v2::PluginInstallResponse, }, + PluginUninstall => "plugin/uninstall" { + params: v2::PluginUninstallParams, + response: v2::PluginUninstallResponse, + }, TurnStart => "turn/start" { params: v2::TurnStartParams, inspect_params: true, @@ -525,6 +555,12 @@ macro_rules! server_request_definitions { Ok(()) } + pub(crate) fn visit_server_response_types(v: &mut impl ::ts_rs::TypeVisitor) { + $( + v.visit::<$response>(); + )* + } + #[allow(clippy::vec_init_then_push)] pub fn export_server_response_schemas( out_dir: &Path, @@ -675,6 +711,12 @@ server_request_definitions! { response: v2::McpServerElicitationRequestResponse, }, + /// Request approval for additional permissions from the user. + PermissionsRequestApproval => "item/permissions/requestApproval" { + params: v2::PermissionsRequestApprovalParams, + response: v2::PermissionsRequestApprovalResponse, + }, + /// Execute a dynamic tool call on the client. DynamicToolCall => "item/tool/call" { params: v2::DynamicToolCallParams, @@ -1136,6 +1178,8 @@ mod tests { request_id: RequestId::Integer(1), params: None, }; + assert_eq!(request.id(), &RequestId::Integer(1)); + assert_eq!(request.method(), "account/rateLimits/read"); assert_eq!( json!({ "method": "account/rateLimits/read", @@ -1540,6 +1584,7 @@ mod tests { }), macos: None, }), + skill_metadata: None, proposed_execpolicy_amendment: None, proposed_network_policy_amendments: None, available_decisions: None, @@ -1550,4 +1595,31 @@ mod tests { Some("item/commandExecution/requestApproval.additionalPermissions") ); } + + #[test] + fn command_execution_request_approval_skill_metadata_is_marked_experimental() { + let params = v2::CommandExecutionRequestApprovalParams { + thread_id: "thr_123".to_string(), + turn_id: "turn_123".to_string(), + item_id: "call_123".to_string(), + approval_id: None, + reason: None, + network_approval_context: None, + command: Some("cat file".to_string()), + cwd: None, + command_actions: None, + additional_permissions: None, + skill_metadata: Some(v2::CommandExecutionRequestApprovalSkillMetadata { + path_to_skills_md: PathBuf::from("/tmp/SKILLS.md"), + }), + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + available_decisions: None, + }; + let reason = crate::experimental_api::ExperimentalApi::experimental_reason(¶ms); + assert_eq!( + reason, + Some("item/commandExecution/requestApproval.skillMetadata") + ); + } } diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 938e6dc26f..f28c86e53b 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -7,6 +7,7 @@ use crate::protocol::common::AuthMode; use codex_experimental_api_macros::ExperimentalApi; use codex_protocol::account::PlanType; use codex_protocol::approvals::ElicitationRequest as CoreElicitationRequest; +use codex_protocol::approvals::ExecApprovalRequestSkillMetadata as CoreExecApprovalRequestSkillMetadata; use codex_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment; use codex_protocol::approvals::NetworkApprovalContext as CoreNetworkApprovalContext; use codex_protocol::approvals::NetworkApprovalProtocol as CoreNetworkApprovalProtocol; @@ -67,6 +68,7 @@ use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; +use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; use codex_protocol::user_input::ByteRange as CoreByteRange; use codex_protocol::user_input::TextElement as CoreTextElement; use codex_protocol::user_input::UserInput as CoreUserInput; @@ -82,12 +84,18 @@ use ts_rs::TS; // tends to use either snake_case or kebab-case. macro_rules! v2_enum_from_core { ( - pub enum $Name:ident from $Src:path { $( $Variant:ident ),+ $(,)? } + $(#[$enum_meta:meta])* + pub enum $Name:ident from $Src:path { + $( $(#[$variant_meta:meta])* $Variant:ident ),+ $(,)? + } ) => { #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] + $(#[$enum_meta])* #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] - pub enum $Name { $( $Variant ),+ } + pub enum $Name { + $( $(#[$variant_meta])* $Variant ),+ + } impl $Name { pub fn to_core(self) -> $Src { @@ -185,6 +193,7 @@ pub enum AskForApproval { Reject { sandbox_approval: bool, rules: bool, + request_permissions: bool, mcp_elicitations: bool, }, Never, @@ -199,10 +208,12 @@ impl AskForApproval { AskForApproval::Reject { sandbox_approval, rules, + request_permissions, mcp_elicitations, } => CoreAskForApproval::Reject(CoreRejectConfig { sandbox_approval, rules, + request_permissions, mcp_elicitations, }), AskForApproval::Never => CoreAskForApproval::Never, @@ -219,6 +230,7 @@ impl From for AskForApproval { CoreAskForApproval::Reject(reject_config) => AskForApproval::Reject { sandbox_approval: reject_config.sandbox_approval, rules: reject_config.rules, + request_permissions: reject_config.request_permissions, mcp_elicitations: reject_config.mcp_elicitations, }, CoreAskForApproval::Never => AskForApproval::Never, @@ -734,6 +746,9 @@ pub struct ConfigBatchWriteParams { pub file_path: Option, #[ts(optional = nullable)] pub expected_version: Option, + /// When true, hot-reload the updated user config into all loaded threads after writing. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub reload_user_config: bool, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -833,6 +848,15 @@ impl From for AdditionalFileSystemPermissions { } } +impl From for CoreFileSystemPermissions { + fn from(value: AdditionalFileSystemPermissions) -> Self { + Self { + read: value.read, + write: value.write, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -854,6 +878,17 @@ impl From for AdditionalMacOsPermissions { } } +impl From for CoreMacOsSeatbeltProfileExtensions { + fn from(value: AdditionalMacOsPermissions) -> Self { + Self { + macos_preferences: value.preferences, + macos_automation: value.automations, + macos_accessibility: value.accessibility, + macos_calendar: value.calendar, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -869,6 +904,14 @@ impl From for AdditionalNetworkPermissions { } } +impl From for CoreNetworkPermissions { + fn from(value: AdditionalNetworkPermissions) -> Self { + Self { + enabled: value.enabled, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -888,6 +931,86 @@ impl From for AdditionalPermissionProfile { } } +impl From for CorePermissionProfile { + fn from(value: AdditionalPermissionProfile) -> Self { + Self { + network: value.network.map(CoreNetworkPermissions::from), + file_system: value.file_system.map(CoreFileSystemPermissions::from), + macos: value.macos.map(CoreMacOsSeatbeltProfileExtensions::from), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GrantedMacOsPermissions { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub preferences: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub automations: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub accessibility: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub calendar: Option, +} + +impl From for CoreMacOsSeatbeltProfileExtensions { + fn from(value: GrantedMacOsPermissions) -> Self { + Self { + macos_preferences: value + .preferences + .unwrap_or(CoreMacOsPreferencesPermission::None), + macos_automation: value + .automations + .unwrap_or(CoreMacOsAutomationPermission::None), + macos_accessibility: value.accessibility.unwrap_or(false), + macos_calendar: value.calendar.unwrap_or(false), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GrantedPermissionProfile { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub network: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub file_system: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub macos: Option, +} + +impl From for CorePermissionProfile { + fn from(value: GrantedPermissionProfile) -> Self { + let macos = value.macos.and_then(|macos| { + if macos.preferences.is_none() + && macos.automations.is_none() + && macos.accessibility.is_none() + && macos.calendar.is_none() + { + None + } else { + Some(CoreMacOsSeatbeltProfileExtensions::from(macos)) + } + }); + + Self { + network: value.network.map(CoreNetworkPermissions::from), + file_system: value.file_system.map(CoreFileSystemPermissions::from), + macos, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -2809,6 +2932,18 @@ pub struct PluginInstallResponse { pub apps_needing_auth: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginUninstallParams { + pub plugin_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginUninstallResponse {} + impl From for SkillMetadata { fn from(value: CoreSkillMetadata) -> Self { Self { @@ -2824,6 +2959,14 @@ impl From for SkillMetadata { } } +impl From for CommandExecutionRequestApprovalSkillMetadata { + fn from(value: CoreExecApprovalRequestSkillMetadata) -> Self { + Self { + path_to_skills_md: value.path_to_skills_md, + } + } +} + impl From for SkillInterface { fn from(value: CoreSkillInterface) -> Self { Self { @@ -4297,6 +4440,11 @@ pub struct CommandExecutionRequestApprovalParams { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional = nullable)] pub additional_permissions: Option, + /// Optional skill metadata when the approval was triggered by a skill script. + #[experimental("item/commandExecution/requestApproval.skillMetadata")] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub skill_metadata: Option, /// Optional proposed execpolicy amendment to allow similar commands without prompting. #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional = nullable)] @@ -4318,9 +4466,17 @@ impl CommandExecutionRequestApprovalParams { // We need a generic outbound compatibility design for stripping or // otherwise handling experimental server->client payloads. self.additional_permissions = None; + self.skill_metadata = None; } } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecutionRequestApprovalSkillMetadata { + pub path_to_skills_md: PathBuf, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -4827,6 +4983,35 @@ pub struct DynamicToolCallParams { pub arguments: JsonValue, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PermissionsRequestApprovalParams { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub reason: Option, + pub permissions: AdditionalPermissionProfile, +} + +v2_enum_from_core!( + #[derive(Default)] + pub enum PermissionGrantScope from CorePermissionGrantScope { + #[default] + Turn, + Session + } +); + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PermissionsRequestApprovalResponse { + pub permissions: GrantedPermissionProfile, + #[serde(default)] + pub scope: PermissionGrantScope, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -5095,6 +5280,7 @@ mod tests { }, "macos": null }, + "skillMetadata": null, "proposedExecpolicyAmendment": null, "proposedNetworkPolicyAmendments": null, "availableDecisions": null @@ -5130,6 +5316,7 @@ mod tests { "calendar": false } }, + "skillMetadata": null, "proposedExecpolicyAmendment": null, "proposedNetworkPolicyAmendments": null, "availableDecisions": null @@ -5147,6 +5334,169 @@ mod tests { ); } + #[test] + fn command_execution_request_approval_accepts_skill_metadata() { + let params = serde_json::from_value::(json!({ + "threadId": "thr_123", + "turnId": "turn_123", + "itemId": "call_123", + "command": "cat file", + "cwd": "/tmp", + "commandActions": null, + "reason": null, + "networkApprovalContext": null, + "additionalPermissions": null, + "skillMetadata": { + "pathToSkillsMd": "/tmp/SKILLS.md" + }, + "proposedExecpolicyAmendment": null, + "proposedNetworkPolicyAmendments": null, + "availableDecisions": null + })) + .expect("skill metadata should deserialize"); + + assert_eq!( + params.skill_metadata, + Some(CommandExecutionRequestApprovalSkillMetadata { + path_to_skills_md: PathBuf::from("/tmp/SKILLS.md"), + }) + ); + } + + #[test] + fn permissions_request_approval_response_accepts_partial_macos_grants() { + let cases = vec![ + (json!({}), Some(GrantedMacOsPermissions::default()), None), + ( + json!({ + "preferences": "read_only", + }), + Some(GrantedMacOsPermissions { + preferences: Some(CoreMacOsPreferencesPermission::ReadOnly), + ..Default::default() + }), + Some(CoreMacOsSeatbeltProfileExtensions { + macos_preferences: CoreMacOsPreferencesPermission::ReadOnly, + macos_automation: CoreMacOsAutomationPermission::None, + macos_accessibility: false, + macos_calendar: false, + }), + ), + ( + json!({ + "automations": { + "bundle_ids": ["com.apple.Notes"], + }, + }), + Some(GrantedMacOsPermissions { + automations: Some(CoreMacOsAutomationPermission::BundleIds(vec![ + "com.apple.Notes".to_string(), + ])), + ..Default::default() + }), + Some(CoreMacOsSeatbeltProfileExtensions { + macos_preferences: CoreMacOsPreferencesPermission::None, + macos_automation: CoreMacOsAutomationPermission::BundleIds(vec![ + "com.apple.Notes".to_string(), + ]), + macos_accessibility: false, + macos_calendar: false, + }), + ), + ( + json!({ + "accessibility": true, + }), + Some(GrantedMacOsPermissions { + accessibility: Some(true), + ..Default::default() + }), + Some(CoreMacOsSeatbeltProfileExtensions { + macos_preferences: CoreMacOsPreferencesPermission::None, + macos_automation: CoreMacOsAutomationPermission::None, + macos_accessibility: true, + macos_calendar: false, + }), + ), + ( + json!({ + "calendar": true, + }), + Some(GrantedMacOsPermissions { + calendar: Some(true), + ..Default::default() + }), + Some(CoreMacOsSeatbeltProfileExtensions { + macos_preferences: CoreMacOsPreferencesPermission::None, + macos_automation: CoreMacOsAutomationPermission::None, + macos_accessibility: false, + macos_calendar: true, + }), + ), + ]; + + for (macos_json, expected_granted_macos, expected_core_macos) in cases { + let response = serde_json::from_value::(json!({ + "permissions": { + "macos": macos_json, + }, + })) + .expect("partial macos permissions response should deserialize"); + + assert_eq!( + response.permissions, + GrantedPermissionProfile { + macos: expected_granted_macos, + ..Default::default() + } + ); + + assert_eq!( + CorePermissionProfile::from(response.permissions), + CorePermissionProfile { + macos: expected_core_macos, + ..Default::default() + } + ); + } + } + + #[test] + fn permissions_request_approval_response_omits_ungranted_macos_keys_when_serialized() { + let response = PermissionsRequestApprovalResponse { + permissions: GrantedPermissionProfile { + macos: Some(GrantedMacOsPermissions { + accessibility: Some(true), + ..Default::default() + }), + ..Default::default() + }, + scope: PermissionGrantScope::Turn, + }; + + assert_eq!( + serde_json::to_value(response).expect("response should serialize"), + json!({ + "permissions": { + "macos": { + "accessibility": true, + }, + }, + "scope": "turn", + }) + ); + } + + #[test] + fn permissions_request_approval_response_defaults_scope_to_turn() { + let response = serde_json::from_value::(json!({ + "permissions": {}, + })) + .expect("response should deserialize"); + + assert_eq!(response.scope, PermissionGrantScope::Turn); + } + #[test] fn command_exec_params_default_optional_streaming_flags() { let params = serde_json::from_value::(json!({ @@ -5486,6 +5836,30 @@ mod tests { assert_eq!(back_to_v2, v2_policy); } + #[test] + fn ask_for_approval_reject_round_trips_request_permissions_flag() { + let v2_policy = AskForApproval::Reject { + sandbox_approval: true, + rules: false, + request_permissions: true, + mcp_elicitations: false, + }; + + let core_policy = v2_policy.to_core(); + assert_eq!( + core_policy, + CoreAskForApproval::Reject(CoreRejectConfig { + sandbox_approval: true, + rules: false, + request_permissions: true, + mcp_elicitations: false, + }) + ); + + let back_to_v2 = AskForApproval::from(core_policy); + assert_eq!(back_to_v2, v2_policy); + } + #[test] fn mcp_server_elicitation_response_round_trips_rmcp_result() { let rmcp_result = rmcp::model::CreateElicitationResult { diff --git a/codex-rs/app-server-protocol/src/schema_fixtures.rs b/codex-rs/app-server-protocol/src/schema_fixtures.rs index 5412da8632..2af015b684 100644 --- a/codex-rs/app-server-protocol/src/schema_fixtures.rs +++ b/codex-rs/app-server-protocol/src/schema_fixtures.rs @@ -1,11 +1,25 @@ +use crate::ClientNotification; +use crate::ClientRequest; +use crate::ServerNotification; +use crate::ServerRequest; +use crate::export::GENERATED_TS_HEADER; +use crate::export::filter_experimental_ts_tree; +use crate::export::generate_index_ts_tree; +use crate::protocol::common::visit_client_response_types; +use crate::protocol::common::visit_server_response_types; use anyhow::Context; use anyhow::Result; +use codex_protocol::protocol::EventMsg; use serde_json::Map; use serde_json::Value; +use std::any::TypeId; use std::cmp::Ordering; use std::collections::BTreeMap; +use std::collections::HashSet; use std::path::Path; use std::path::PathBuf; +use ts_rs::TS; +use ts_rs::TypeVisitor; #[derive(Clone, Copy, Debug, Default)] pub struct SchemaFixtureOptions { @@ -27,6 +41,42 @@ pub fn read_schema_fixture_tree(schema_root: &Path) -> Result Result>> { + let subtree_root = schema_root.join(label); + collect_files_recursive(&subtree_root) + .with_context(|| format!("read schema fixture subtree {}", subtree_root.display())) +} + +#[doc(hidden)] +pub fn generate_typescript_schema_fixture_subtree_for_tests() -> Result>> +{ + let mut files = BTreeMap::new(); + let mut seen = HashSet::new(); + + collect_typescript_fixture_file::(&mut files, &mut seen)?; + visit_typescript_fixture_dependencies(&mut files, &mut seen, |visitor| { + visit_client_response_types(visitor); + })?; + collect_typescript_fixture_file::(&mut files, &mut seen)?; + collect_typescript_fixture_file::(&mut files, &mut seen)?; + visit_typescript_fixture_dependencies(&mut files, &mut seen, |visitor| { + visit_server_response_types(visitor); + })?; + collect_typescript_fixture_file::(&mut files, &mut seen)?; + collect_typescript_fixture_file::(&mut files, &mut seen)?; + + filter_experimental_ts_tree(&mut files)?; + generate_index_ts_tree(&mut files); + + Ok(files + .into_iter() + .map(|(path, content)| (path, content.into_bytes())) + .collect()) +} + /// Regenerates `schema/typescript/` and `schema/json/`. /// /// This is intended to be used by tooling (e.g., `just write-app-server-schema`). @@ -86,6 +136,12 @@ fn read_file_bytes(path: &Path) -> Result> { let text = String::from_utf8(bytes) .with_context(|| format!("expected UTF-8 TypeScript in {}", path.display()))?; let text = text.replace("\r\n", "\n").replace('\r', "\n"); + // Fixture comparisons care about schema content, not whether the generator + // re-prepended the standard banner to every TypeScript file. + let text = text + .strip_prefix(GENERATED_TS_HEADER) + .unwrap_or(&text) + .to_string(); return Ok(text.into_bytes()); } Ok(bytes) @@ -209,6 +265,73 @@ fn collect_files_recursive(root: &Path) -> Result>> { Ok(files) } +fn collect_typescript_fixture_file( + files: &mut BTreeMap, + seen: &mut HashSet, +) -> Result<()> { + let Some(output_path) = T::output_path() else { + return Ok(()); + }; + if !seen.insert(TypeId::of::()) { + return Ok(()); + } + + let contents = T::export_to_string().context("export TypeScript fixture content")?; + let output_path = normalize_relative_fixture_path(&output_path); + files.insert( + output_path, + contents.replace("\r\n", "\n").replace('\r', "\n"), + ); + + let mut visitor = TypeScriptFixtureCollector { + files, + seen, + error: None, + }; + T::visit_dependencies(&mut visitor); + if let Some(error) = visitor.error { + return Err(error); + } + + Ok(()) +} + +fn normalize_relative_fixture_path(path: &Path) -> PathBuf { + path.components().collect() +} + +fn visit_typescript_fixture_dependencies( + files: &mut BTreeMap, + seen: &mut HashSet, + visit: impl FnOnce(&mut TypeScriptFixtureCollector<'_>), +) -> Result<()> { + let mut visitor = TypeScriptFixtureCollector { + files, + seen, + error: None, + }; + visit(&mut visitor); + if let Some(error) = visitor.error { + return Err(error); + } + Ok(()) +} + +struct TypeScriptFixtureCollector<'a> { + files: &'a mut BTreeMap, + seen: &'a mut HashSet, + error: Option, +} + +impl TypeVisitor for TypeScriptFixtureCollector<'_> { + fn visit(&mut self) { + if self.error.is_some() { + return; + } + self.error = collect_typescript_fixture_file::(self.files, self.seen).err(); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/app-server-protocol/tests/schema_fixtures.rs b/codex-rs/app-server-protocol/tests/schema_fixtures.rs index 12379f7809..fbe1fd4408 100644 --- a/codex-rs/app-server-protocol/tests/schema_fixtures.rs +++ b/codex-rs/app-server-protocol/tests/schema_fixtures.rs @@ -1,19 +1,60 @@ use anyhow::Context; use anyhow::Result; -use codex_app_server_protocol::read_schema_fixture_tree; -use codex_app_server_protocol::write_schema_fixtures; +use codex_app_server_protocol::generate_json_with_experimental; +use codex_app_server_protocol::generate_typescript_schema_fixture_subtree_for_tests; +use codex_app_server_protocol::read_schema_fixture_subtree; use similar::TextDiff; +use std::collections::BTreeMap; use std::path::Path; +use std::path::PathBuf; #[test] -fn schema_fixtures_match_generated() -> Result<()> { +fn typescript_schema_fixtures_match_generated() -> Result<()> { let schema_root = schema_root()?; - let fixture_tree = read_tree(&schema_root)?; + let fixture_tree = read_tree(&schema_root, "typescript")?; + let generated_tree = generate_typescript_schema_fixture_subtree_for_tests() + .context("generate in-memory typescript schema fixtures")?; + + assert_schema_trees_match("typescript", &fixture_tree, &generated_tree)?; + + Ok(()) +} + +#[test] +fn json_schema_fixtures_match_generated() -> Result<()> { + assert_schema_fixtures_match_generated("json", |output_dir| { + generate_json_with_experimental(output_dir, false) + }) +} + +fn assert_schema_fixtures_match_generated( + label: &'static str, + generate: impl FnOnce(&Path) -> Result<()>, +) -> Result<()> { + let schema_root = schema_root()?; + let fixture_tree = read_tree(&schema_root, label)?; let temp_dir = tempfile::tempdir().context("create temp dir")?; - write_schema_fixtures(temp_dir.path(), None).context("generate schema fixtures")?; - let generated_tree = read_tree(temp_dir.path())?; + let generated_root = temp_dir.path().join(label); + generate(&generated_root).with_context(|| { + format!( + "generate {label} schema fixtures into {}", + generated_root.display() + ) + })?; + let generated_tree = read_tree(temp_dir.path(), label)?; + + assert_schema_trees_match(label, &fixture_tree, &generated_tree)?; + + Ok(()) +} + +fn assert_schema_trees_match( + label: &str, + fixture_tree: &BTreeMap>, + generated_tree: &BTreeMap>, +) -> Result<()> { let fixture_paths = fixture_tree .keys() .map(|p| p.display().to_string()) @@ -32,13 +73,13 @@ fn schema_fixtures_match_generated() -> Result<()> { .to_string(); panic!( - "Vendored app-server schema fixture file set doesn't match freshly generated output. \ + "Vendored {label} app-server schema fixture file set doesn't match freshly generated output. \ Run `just write-app-server-schema` to overwrite with your changes.\n\n{diff}" ); } // If the file sets match, diff contents for each file for a nicer error. - for (path, expected) in &fixture_tree { + for (path, expected) in fixture_tree { let actual = generated_tree .get(path) .ok_or_else(|| anyhow::anyhow!("missing generated file: {}", path.display()))?; @@ -54,7 +95,7 @@ Run `just write-app-server-schema` to overwrite with your changes.\n\n{diff}" .header("fixture", "generated") .to_string(); panic!( - "Vendored app-server schema fixture {} differs from generated output. \ + "Vendored {label} app-server schema fixture {} differs from generated output. \ Run `just write-app-server-schema` to overwrite with your changes.\n\n{diff}", path.display() ); @@ -63,7 +104,7 @@ Run `just write-app-server-schema` to overwrite with your changes.\n\n{diff}", Ok(()) } -fn schema_root() -> Result { +fn schema_root() -> Result { // In Bazel runfiles (especially manifest-only mode), resolving directories is not // reliable. Resolve a known file, then walk up to the schema root. let typescript_index = codex_utils_cargo_bin::find_resource!("schema/typescript/index.ts") @@ -92,6 +133,11 @@ fn schema_root() -> Result { Ok(schema_root) } -fn read_tree(root: &Path) -> Result>> { - read_schema_fixture_tree(root).context("read schema fixture tree") +fn read_tree(root: &Path, label: &str) -> Result>> { + read_schema_fixture_subtree(root, label).with_context(|| { + format!( + "read {label} schema fixture subtree from {}", + root.display() + ) + }) } diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index 51931d6a80..c5a917b64c 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -1507,6 +1507,7 @@ impl CodexClient { cwd, command_actions, additional_permissions, + skill_metadata, proposed_execpolicy_amendment, proposed_network_policy_amendments, available_decisions, @@ -1541,6 +1542,9 @@ impl CodexClient { if let Some(additional_permissions) = additional_permissions.as_ref() { println!("< additional permissions: {additional_permissions:?}"); } + if let Some(skill_metadata) = skill_metadata.as_ref() { + println!("< skill metadata: {skill_metadata:?}"); + } if let Some(execpolicy_amendment) = proposed_execpolicy_amendment.as_ref() { println!("< proposed execpolicy amendment: {execpolicy_amendment:?}"); } diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index b2126f1947..088a9fb7db 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -19,6 +19,12 @@ workspace = true anyhow = { workspace = true } async-trait = { workspace = true } base64 = { workspace = true } +axum = { workspace = true, default-features = false, features = [ + "http1", + "json", + "tokio", + "ws", +] } codex-arg0 = { workspace = true } codex-cloud-requirements = { workspace = true } codex-core = { workspace = true } @@ -61,6 +67,7 @@ uuid = { workspace = true, features = ["serde", "v7"] } [dev-dependencies] app_test_support = { workspace = true } +base64 = { workspace = true } axum = { workspace = true, default-features = false, features = [ "http1", "json", @@ -69,6 +76,7 @@ axum = { workspace = true, default-features = false, features = [ core_test_support = { workspace = true } codex-utils-cargo-bin = { workspace = true } pretty_assertions = { workspace = true } +reqwest = { workspace = true, features = ["rustls-tls"] } rmcp = { workspace = true, default-features = false, features = [ "elicitation", "server", diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 2d517d2710..64de7c3f52 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -26,6 +26,11 @@ Supported transports: - stdio (`--listen stdio://`, default): newline-delimited JSON (JSONL) - websocket (`--listen ws://IP:PORT`): one JSON-RPC message per websocket text frame (**experimental / unsupported**) +When running with `--listen ws://IP:PORT`, the same listener also serves basic HTTP health probes: + +- `GET /readyz` returns `200 OK` once the listener is accepting new connections. +- `GET /healthz` currently always returns `200 OK`. + Websocket transport is currently experimental and unsupported. Do not rely on it for production workloads. Tracing/log output: @@ -159,6 +164,7 @@ Example with notification opt-out: - `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 and return any apps that still need auth (**under development; do not call from production clients yet**). +- `plugin/uninstall` — uninstall a plugin by id by removing its cached files and clearing its user-level config entry (**under development; do not call from production clients yet**). - `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes. - `tool/requestUserInput` — prompt the user with 1–3 short questions for a tool call and return their answers (experimental). - `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server. @@ -169,7 +175,7 @@ Example with notification opt-out: - `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home). - `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home). - `config/value/write` — write a single config key/value to the user's config.toml on disk. -- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk. +- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads. - `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), pinned feature values (`featureRequirements`), `enforceResidency`, and `network` constraints. ### Example: Start or resume a thread @@ -850,8 +856,8 @@ Order of messages: Order of messages: 1. `item/started` — emits a `fileChange` item with `changes` (diff chunk summaries) and `status: "inProgress"`. Show the proposed edits and paths to the user. -2. `item/fileChange/requestApproval` (request) — includes `itemId`, `threadId`, `turnId`, and an optional `reason`. -3. Client response — `{ "decision": "accept" }` or `{ "decision": "decline" }`. +2. `item/fileChange/requestApproval` (request) — includes `itemId`, `threadId`, `turnId`, an optional `reason`, and may include unstable `grantRoot` when the agent is asking for session-scoped write access under a specific root. +3. Client response — `{ "decision": "accept" }`, `{ "decision": "acceptForSession" }`, `{ "decision": "decline" }`, or `{ "decision": "cancel" }`. 4. `serverRequest/resolved` — `{ threadId, requestId }` confirms the pending request has been resolved or cleared, including lifecycle cleanup on turn start/complete/interrupt. 5. `item/completed` — returns the same `fileChange` item with `status` updated to `completed`, `failed`, or `declined` after the patch attempt. Rely on this to show success/failure and finalize the diff state in your UI. @@ -875,6 +881,55 @@ Order of messages: `turnId` is best-effort. When the elicitation is correlated with an active turn, the request includes that turn id; otherwise it is `null`. +### Permission requests + +The built-in `request_permissions` tool sends an `item/permissions/requestApproval` JSON-RPC request to the client with the requested permission profile. Today that commonly means additional filesystem access, but the payload is intentionally general so future requests can include non-filesystem permissions too. This request is part of the v2 protocol surface. + +```json +{ + "method": "item/permissions/requestApproval", + "id": 61, + "params": { + "threadId": "thr_123", + "turnId": "turn_123", + "itemId": "call_123", + "reason": "Select a workspace root", + "permissions": { + "fileSystem": { + "write": [ + "/Users/me/project", + "/Users/me/shared" + ] + } + } + } +} +``` + +The client responds with `result.permissions`, which should be the granted subset of the requested permission profile. It may also set `result.scope` to `"session"` to make the grant persist for later turns in the same session; omitted or `"turn"` keeps the existing turn-scoped behavior: + +```json +{ + "id": 61, + "result": { + "scope": "session", + "permissions": { + "fileSystem": { + "write": [ + "/Users/me/project" + ] + } + } + } +} +``` + +Only the granted subset matters on the wire. Any permissions omitted from `result.permissions` are treated as denied, including omitted nested keys inside `result.permissions.macos`, so a sparse response like `{ "permissions": { "macos": { "accessibility": true } } }` grants only accessibility. Any permissions not present in the original request are ignored by the server. + +Within the same turn, granted permissions are sticky: later shell-like tool calls can automatically reuse the granted subset without reissuing a separate permission request. + +If the session approval policy uses `Reject` with `request_permissions: true`, the server does not send `item/permissions/requestApproval` to the client. Instead, the tool is auto-denied and resolves with an empty granted-permissions payload. + ### Dynamic tool calls (experimental) `dynamicTools` on `thread/start` and the corresponding `item/tool/call` request/response flow are experimental APIs. To enable them, set `initialize.params.capabilities.experimentalApi = true`. diff --git a/codex-rs/app-server/src/app_server_tracing.rs b/codex-rs/app-server/src/app_server_tracing.rs index 779cef9671..fd32be87c1 100644 --- a/codex-rs/app-server/src/app_server_tracing.rs +++ b/codex-rs/app-server/src/app_server_tracing.rs @@ -1,6 +1,16 @@ +//! Tracing helpers shared by socket and in-process app-server entry points. +//! +//! The in-process path intentionally reuses the same span shape as JSON-RPC +//! transports so request telemetry stays comparable across stdio, websocket, +//! and embedded callers. [`typed_request_span`] is the in-process counterpart +//! of [`request_span`] and stamps `rpc.transport` as `"in-process"` while +//! deriving client identity from the typed [`ClientRequest`] rather than +//! from a parsed JSON envelope. + use crate::message_processor::ConnectionSessionState; use crate::outgoing_message::ConnectionId; use crate::transport::AppServerTransport; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::JSONRPCRequest; use codex_otel::set_parent_from_context; @@ -65,6 +75,51 @@ pub(crate) fn request_span( span } +/// Builds tracing span metadata for typed in-process requests. +/// +/// This mirrors `request_span` semantics while stamping transport as +/// `in-process` and deriving client info either from initialize params or +/// from existing connection session state. +pub(crate) fn typed_request_span( + request: &ClientRequest, + connection_id: ConnectionId, + session: &ConnectionSessionState, +) -> Span { + let method = request.method(); + let span = info_span!( + "app_server.request", + otel.kind = "server", + otel.name = method, + rpc.system = "jsonrpc", + rpc.method = method, + rpc.transport = "in-process", + rpc.request_id = ?request.id(), + app_server.connection_id = ?connection_id, + app_server.api_version = "v2", + app_server.client_name = field::Empty, + app_server.client_version = field::Empty, + ); + + if let Some((client_name, client_version)) = initialize_client_info_from_typed_request(request) + { + span.record("app_server.client_name", client_name); + span.record("app_server.client_version", client_version); + } else { + if let Some(client_name) = session.app_server_client_name.as_deref() { + span.record("app_server.client_name", client_name); + } + if let Some(client_version) = session.client_version.as_deref() { + span.record("app_server.client_version", client_version); + } + } + + if let Some(context) = traceparent_context_from_env() { + set_parent_from_context(&span, context); + } + + span +} + fn transport_name(transport: AppServerTransport) -> &'static str { match transport { AppServerTransport::Stdio => "stdio", @@ -99,3 +154,13 @@ fn initialize_client_info(request: &JSONRPCRequest) -> Option let params = request.params.clone()?; serde_json::from_value(params).ok() } + +fn initialize_client_info_from_typed_request(request: &ClientRequest) -> Option<(&str, &str)> { + match request { + ClientRequest::Initialize { params, .. } => Some(( + params.client_info.name.as_str(), + params.client_info.version.as_str(), + )), + _ => None, + } +} diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 499ac9ffc7..5e34baef42 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -26,6 +26,7 @@ use codex_app_server_protocol::CommandExecutionApprovalDecision; use codex_app_server_protocol::CommandExecutionOutputDeltaNotification; use codex_app_server_protocol::CommandExecutionRequestApprovalParams; use codex_app_server_protocol::CommandExecutionRequestApprovalResponse; +use codex_app_server_protocol::CommandExecutionRequestApprovalSkillMetadata; use codex_app_server_protocol::CommandExecutionStatus; use codex_app_server_protocol::ContextCompactedNotification; use codex_app_server_protocol::DeprecationNoticeNotification; @@ -41,6 +42,7 @@ use codex_app_server_protocol::FileChangeOutputDeltaNotification; use codex_app_server_protocol::FileChangeRequestApprovalParams; use codex_app_server_protocol::FileChangeRequestApprovalResponse; use codex_app_server_protocol::FileUpdateChange; +use codex_app_server_protocol::GrantedPermissionProfile as V2GrantedPermissionProfile; use codex_app_server_protocol::InterruptConversationResponse; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; @@ -56,6 +58,8 @@ use codex_app_server_protocol::NetworkApprovalContext as V2NetworkApprovalContex use codex_app_server_protocol::NetworkPolicyAmendment as V2NetworkPolicyAmendment; use codex_app_server_protocol::NetworkPolicyRuleAction as V2NetworkPolicyRuleAction; use codex_app_server_protocol::PatchApplyStatus; +use codex_app_server_protocol::PermissionsRequestApprovalParams; +use codex_app_server_protocol::PermissionsRequestApprovalResponse; use codex_app_server_protocol::PlanDeltaNotification; use codex_app_server_protocol::RawResponseItemCompletedNotification; use codex_app_server_protocol::ReasoningSummaryPartAddedNotification; @@ -96,9 +100,11 @@ use codex_core::ThreadManager; use codex_core::find_thread_name_by_id; use codex_core::review_format::format_review_findings_block; use codex_core::review_prompts; +use codex_core::sandboxing::intersect_permission_profiles; use codex_protocol::ThreadId; use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynamicToolCallOutputContentItem; use codex_protocol::dynamic_tools::DynamicToolResponse as CoreDynamicToolResponse; +use codex_protocol::models::PermissionProfile as CorePermissionProfile; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; @@ -114,6 +120,8 @@ use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewOutputEvent; use codex_protocol::protocol::TokenCountEvent; use codex_protocol::protocol::TurnDiffEvent; +use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; +use codex_protocol::request_permissions::RequestPermissionsResponse as CoreRequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputAnswer as CoreRequestUserInputAnswer; use codex_protocol::request_user_input::RequestUserInputResponse as CoreRequestUserInputResponse; use codex_shell_command::parse_command::shlex_join; @@ -437,6 +445,7 @@ pub(crate) async fn apply_bespoke_event_handling( proposed_execpolicy_amendment, proposed_network_policy_amendments, additional_permissions, + skill_metadata, parsed_cmd, .. } = ev; @@ -508,6 +517,8 @@ pub(crate) async fn apply_bespoke_event_handling( }); let additional_permissions = additional_permissions.map(V2AdditionalPermissionProfile::from); + let skill_metadata = + skill_metadata.map(CommandExecutionRequestApprovalSkillMetadata::from); let params = CommandExecutionRequestApprovalParams { thread_id: conversation_id.to_string(), @@ -520,6 +531,7 @@ pub(crate) async fn apply_bespoke_event_handling( cwd, command_actions, additional_permissions, + skill_metadata, proposed_execpolicy_amendment: proposed_execpolicy_amendment_v2, proposed_network_policy_amendments: proposed_network_policy_amendments_v2, available_decisions: Some(available_decisions), @@ -672,6 +684,54 @@ pub(crate) async fn apply_bespoke_event_handling( }); } } + EventMsg::RequestPermissions(request) => { + if matches!(api_version, ApiVersion::V2) { + let permission_guard = thread_watch_manager + .note_permission_requested(&conversation_id.to_string()) + .await; + let requested_permissions = request.permissions.clone(); + let params = PermissionsRequestApprovalParams { + thread_id: conversation_id.to_string(), + turn_id: request.turn_id.clone(), + item_id: request.call_id.clone(), + reason: request.reason, + permissions: request.permissions.into(), + }; + let (pending_request_id, rx) = outgoing + .send_request(ServerRequestPayload::PermissionsRequestApproval(params)) + .await; + tokio::spawn(async move { + on_request_permissions_response( + request.call_id, + requested_permissions, + pending_request_id, + rx, + conversation, + thread_state, + permission_guard, + ) + .await; + }); + } else { + error!( + "request_permissions is only supported on api v2 (call_id: {})", + request.call_id + ); + let empty = CoreRequestPermissionsResponse { + permissions: Default::default(), + scope: CorePermissionGrantScope::Turn, + }; + if let Err(err) = conversation + .submit(Op::RequestPermissionsResponse { + id: request.call_id, + response: empty, + }) + .await + { + error!("failed to submit RequestPermissionsResponse: {err}"); + } + } + } EventMsg::DynamicToolCallRequest(request) => { if matches!(api_version, ApiVersion::V2) { let call_id = request.call_id; @@ -2121,6 +2181,75 @@ fn mcp_server_elicitation_response_from_client_result( } } +async fn on_request_permissions_response( + call_id: String, + requested_permissions: CorePermissionProfile, + pending_request_id: RequestId, + receiver: oneshot::Receiver, + conversation: Arc, + thread_state: Arc>, + request_permissions_guard: ThreadWatchActiveGuard, +) { + let response = receiver.await; + resolve_server_request_on_thread_listener(&thread_state, pending_request_id).await; + drop(request_permissions_guard); + let Some(response) = + request_permissions_response_from_client_result(requested_permissions, response) + else { + return; + }; + + if let Err(err) = conversation + .submit(Op::RequestPermissionsResponse { + id: call_id, + response, + }) + .await + { + error!("failed to submit RequestPermissionsResponse: {err}"); + } +} + +fn request_permissions_response_from_client_result( + requested_permissions: CorePermissionProfile, + response: std::result::Result, +) -> Option { + let value = match response { + Ok(Ok(value)) => value, + Ok(Err(err)) if is_turn_transition_server_request_error(&err) => return None, + Ok(Err(err)) => { + error!("request failed with client error: {err:?}"); + return Some(CoreRequestPermissionsResponse { + permissions: Default::default(), + scope: CorePermissionGrantScope::Turn, + }); + } + Err(err) => { + error!("request failed: {err:?}"); + return Some(CoreRequestPermissionsResponse { + permissions: Default::default(), + scope: CorePermissionGrantScope::Turn, + }); + } + }; + + let response = serde_json::from_value::(value) + .unwrap_or_else(|err| { + error!("failed to deserialize PermissionsRequestApprovalResponse: {err}"); + PermissionsRequestApprovalResponse { + permissions: V2GrantedPermissionProfile::default(), + scope: codex_app_server_protocol::PermissionGrantScope::Turn, + } + }); + Some(CoreRequestPermissionsResponse { + permissions: intersect_permission_profiles( + requested_permissions, + response.permissions.into(), + ), + scope: response.scope.to_core(), + }) +} + const REVIEW_FALLBACK_MESSAGE: &str = "Reviewer failed to output a response."; fn render_review_output_text(output: &ReviewOutputEvent) -> String { @@ -2469,6 +2598,9 @@ mod tests { use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::TurnPlanStepStatus; use codex_protocol::mcp::CallToolResult; + use codex_protocol::models::MacOsAutomationPermission; + use codex_protocol::models::MacOsPreferencesPermission; + use codex_protocol::models::MacOsSeatbeltProfileExtensions; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::protocol::CollabResumeBeginEvent; @@ -2531,6 +2663,141 @@ mod tests { ); } + #[test] + fn request_permissions_turn_transition_error_is_ignored() { + let error = JSONRPCErrorError { + code: -1, + message: "client request resolved because the turn state was changed".to_string(), + data: Some(serde_json::json!({ "reason": "turnTransition" })), + }; + + let response = request_permissions_response_from_client_result( + CorePermissionProfile::default(), + Ok(Err(error)), + ); + + assert_eq!(response, None); + } + + #[test] + fn request_permissions_response_accepts_partial_macos_grants() { + let requested_permissions = CorePermissionProfile { + macos: Some(MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::ReadWrite, + macos_automation: MacOsAutomationPermission::BundleIds(vec![ + "com.apple.Notes".to_string(), + "com.apple.Reminders".to_string(), + ]), + macos_accessibility: true, + macos_calendar: true, + }), + ..Default::default() + }; + let cases = vec![ + (serde_json::json!({}), CorePermissionProfile::default()), + ( + serde_json::json!({ + "preferences": "read_only", + }), + CorePermissionProfile { + macos: Some(MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::ReadOnly, + macos_automation: MacOsAutomationPermission::None, + macos_accessibility: false, + macos_calendar: false, + }), + ..Default::default() + }, + ), + ( + serde_json::json!({ + "automations": { + "bundle_ids": ["com.apple.Notes"], + }, + }), + CorePermissionProfile { + macos: Some(MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::None, + macos_automation: MacOsAutomationPermission::BundleIds(vec![ + "com.apple.Notes".to_string(), + ]), + macos_accessibility: false, + macos_calendar: false, + }), + ..Default::default() + }, + ), + ( + serde_json::json!({ + "accessibility": true, + }), + CorePermissionProfile { + macos: Some(MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::None, + macos_automation: MacOsAutomationPermission::None, + macos_accessibility: true, + macos_calendar: false, + }), + ..Default::default() + }, + ), + ( + serde_json::json!({ + "calendar": true, + }), + CorePermissionProfile { + macos: Some(MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::None, + macos_automation: MacOsAutomationPermission::None, + macos_accessibility: false, + macos_calendar: true, + }), + ..Default::default() + }, + ), + ]; + + for (granted_macos, expected_permissions) in cases { + let response = request_permissions_response_from_client_result( + requested_permissions.clone(), + Ok(Ok(serde_json::json!({ + "permissions": { + "macos": granted_macos, + }, + }))), + ) + .expect("response should be accepted"); + + assert_eq!( + response, + CoreRequestPermissionsResponse { + permissions: expected_permissions, + scope: CorePermissionGrantScope::Turn, + } + ); + } + } + + #[test] + fn request_permissions_response_preserves_session_scope() { + let response = request_permissions_response_from_client_result( + CorePermissionProfile::default(), + Ok(Ok(serde_json::json!({ + "scope": "session", + "permissions": {}, + }))), + ) + .expect("response should be accepted"); + + assert_eq!( + response, + CoreRequestPermissionsResponse { + permissions: CorePermissionProfile::default(), + scope: CorePermissionGrantScope::Session, + } + ); + } + #[test] fn collab_resume_begin_maps_to_item_started_resume_agent() { let event = CollabResumeBeginEvent { diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index b2d5c0a75b..fa9ec85776 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -90,6 +90,8 @@ use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginMarketplaceEntry; 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; @@ -217,6 +219,7 @@ use codex_core::plugins::MarketplaceError; use codex_core::plugins::MarketplacePluginSourceSummary; use codex_core::plugins::PluginInstallError as CorePluginInstallError; use codex_core::plugins::PluginInstallRequest; +use codex_core::plugins::PluginUninstallError as CorePluginUninstallError; use codex_core::plugins::load_plugin_apps; use codex_core::read_head_for_summary; use codex_core::read_session_meta_line; @@ -711,6 +714,10 @@ impl CodexMessageProcessor { self.plugin_install(to_connection_request_id(request_id), params) .await; } + ClientRequest::PluginUninstall { request_id, params } => { + self.plugin_uninstall(to_connection_request_id(request_id), params) + .await; + } ClientRequest::TurnStart { request_id, params } => { self.turn_start( to_connection_request_id(request_id), @@ -1642,9 +1649,10 @@ impl CodexMessageProcessor { None => ExecExpiration::DefaultTimeout, } }; + let sandbox_cwd = self.config.cwd.clone(); let exec_params = ExecParams { command, - cwd, + cwd: cwd.clone(), expiration, env, network: started_network_proxy @@ -1665,7 +1673,7 @@ impl CodexMessageProcessor { Some(policy) => match self.config.permissions.sandbox_policy.can_set(&policy) { Ok(()) => { let file_system_sandbox_policy = - codex_protocol::permissions::FileSystemSandboxPolicy::from(&policy); + codex_protocol::permissions::FileSystemSandboxPolicy::from_legacy_sandbox_policy(&policy, &sandbox_cwd); let network_sandbox_policy = codex_protocol::permissions::NetworkSandboxPolicy::from(&policy); (policy, file_system_sandbox_policy, network_sandbox_policy) @@ -1690,7 +1698,6 @@ impl CodexMessageProcessor { let codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone(); let outgoing = self.outgoing.clone(); let request_for_task = request.clone(); - let sandbox_cwd = self.config.cwd.clone(); let started_network_proxy_for_task = started_network_proxy; let use_linux_sandbox_bwrap = self.config.features.enabled(Feature::UseLinuxSandboxBwrap); let size = match size.map(crate::command_exec::terminal_size_from_protocol) { @@ -5516,6 +5523,57 @@ impl CodexMessageProcessor { } } + async fn plugin_uninstall( + &self, + request_id: ConnectionRequestId, + params: PluginUninstallParams, + ) { + let plugins_manager = self.thread_manager.plugins_manager(); + + match plugins_manager.uninstall_plugin(params.plugin_id).await { + Ok(()) => { + self.clear_plugin_related_caches(); + self.outgoing + .send_response(request_id, PluginUninstallResponse {}) + .await; + } + Err(err) => { + if err.is_invalid_request() { + self.send_invalid_request_error(request_id, err.to_string()) + .await; + return; + } + + match err { + CorePluginUninstallError::Config(err) => { + self.send_internal_error( + request_id, + format!("failed to clear plugin config: {err}"), + ) + .await; + } + CorePluginUninstallError::Join(err) => { + self.send_internal_error( + request_id, + format!("failed to uninstall plugin: {err}"), + ) + .await; + } + CorePluginUninstallError::Store(err) => { + self.send_internal_error( + request_id, + format!("failed to uninstall plugin: {err}"), + ) + .await; + } + CorePluginUninstallError::InvalidPluginId(_) => { + unreachable!("invalid plugin ids are handled above"); + } + } + } + } + } + async fn turn_start( &self, request_id: ConnectionRequestId, diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index cf4b7da626..7e1427181b 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -1,5 +1,6 @@ use crate::error_code::INTERNAL_ERROR_CODE; use crate::error_code::INVALID_REQUEST_ERROR_CODE; +use async_trait::async_trait; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigReadParams; use codex_app_server_protocol::ConfigReadResponse; @@ -11,6 +12,7 @@ use codex_app_server_protocol::ConfigWriteResponse; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::NetworkRequirements; use codex_app_server_protocol::SandboxMode; +use codex_core::ThreadManager; use codex_core::config::ConfigService; use codex_core::config::ConfigServiceError; use codex_core::config_loader::CloudRequirementsLoader; @@ -19,11 +21,33 @@ use codex_core::config_loader::LoaderOverrides; use codex_core::config_loader::ResidencyRequirement as CoreResidencyRequirement; use codex_core::config_loader::SandboxModeRequirement as CoreSandboxModeRequirement; use codex_protocol::config_types::WebSearchMode; +use codex_protocol::protocol::Op; use serde_json::json; use std::path::PathBuf; use std::sync::Arc; use std::sync::RwLock; use toml::Value as TomlValue; +use tracing::warn; + +#[async_trait] +pub(crate) trait UserConfigReloader: Send + Sync { + async fn reload_user_config(&self); +} + +#[async_trait] +impl UserConfigReloader for ThreadManager { + async fn reload_user_config(&self) { + let thread_ids = self.list_thread_ids().await; + for thread_id in thread_ids { + let Ok(thread) = self.get_thread(thread_id).await else { + continue; + }; + if let Err(err) = thread.submit(Op::ReloadUserConfig).await { + warn!("failed to request user config reload: {err}"); + } + } + } +} #[derive(Clone)] pub(crate) struct ConfigApi { @@ -31,6 +55,7 @@ pub(crate) struct ConfigApi { cli_overrides: Vec<(String, TomlValue)>, loader_overrides: LoaderOverrides, cloud_requirements: Arc>, + user_config_reloader: Arc, } impl ConfigApi { @@ -39,12 +64,14 @@ impl ConfigApi { cli_overrides: Vec<(String, TomlValue)>, loader_overrides: LoaderOverrides, cloud_requirements: Arc>, + user_config_reloader: Arc, ) -> Self { Self { codex_home, cli_overrides, loader_overrides, cloud_requirements, + user_config_reloader, } } @@ -96,10 +123,16 @@ impl ConfigApi { &self, params: ConfigBatchWriteParams, ) -> Result { - self.config_service() + let reload_user_config = params.reload_user_config; + let response = self + .config_service() .batch_write(params) .await - .map_err(map_error) + .map_err(map_error)?; + if reload_user_config { + self.user_config_reloader.reload_user_config().await; + } + Ok(response) } } @@ -199,6 +232,22 @@ mod tests { use codex_core::config_loader::NetworkRequirementsToml as CoreNetworkRequirementsToml; use codex_protocol::protocol::AskForApproval as CoreAskForApproval; use pretty_assertions::assert_eq; + use serde_json::json; + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + use tempfile::TempDir; + + #[derive(Default)] + struct RecordingUserConfigReloader { + call_count: AtomicUsize, + } + + #[async_trait] + impl UserConfigReloader for RecordingUserConfigReloader { + async fn reload_user_config(&self) { + self.call_count.fetch_add(1, Ordering::Relaxed); + } + } #[test] fn map_requirements_toml_to_api_converts_core_enums() { @@ -303,4 +352,51 @@ mod tests { Some(vec![WebSearchMode::Disabled]) ); } + + #[tokio::test] + async fn batch_write_reloads_user_config_when_requested() { + let codex_home = TempDir::new().expect("create temp dir"); + let user_config_path = codex_home.path().join("config.toml"); + std::fs::write(&user_config_path, "").expect("write config"); + let reloader = Arc::new(RecordingUserConfigReloader::default()); + let config_api = ConfigApi::new( + codex_home.path().to_path_buf(), + Vec::new(), + LoaderOverrides::default(), + Arc::new(RwLock::new(CloudRequirementsLoader::default())), + reloader.clone(), + ); + + let response = config_api + .batch_write(ConfigBatchWriteParams { + edits: vec![codex_app_server_protocol::ConfigEdit { + key_path: "model".to_string(), + value: json!("gpt-5"), + merge_strategy: codex_app_server_protocol::MergeStrategy::Replace, + }], + file_path: Some(user_config_path.display().to_string()), + expected_version: None, + reload_user_config: true, + }) + .await + .expect("batch write should succeed"); + + assert_eq!( + response, + ConfigWriteResponse { + status: codex_app_server_protocol::WriteStatus::Ok, + version: response.version.clone(), + file_path: codex_utils_absolute_path::AbsolutePathBuf::try_from( + user_config_path.clone() + ) + .expect("absolute config path"), + overridden_metadata: None, + } + ); + assert_eq!( + std::fs::read_to_string(user_config_path).unwrap(), + "model = \"gpt-5\"\n" + ); + assert_eq!(reloader.call_count.load(Ordering::Relaxed), 1); + } } diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs new file mode 100644 index 0000000000..4e3572ee5d --- /dev/null +++ b/codex-rs/app-server/src/in_process.rs @@ -0,0 +1,884 @@ +//! In-process app-server runtime host for local embedders. +//! +//! This module runs the existing [`MessageProcessor`] and outbound routing logic +//! on Tokio tasks, but replaces socket/stdio transports with bounded in-memory +//! channels. The intent is to preserve app-server semantics while avoiding a +//! process boundary for CLI surfaces that run in the same process. +//! +//! # Lifecycle +//! +//! 1. Construct runtime state with [`InProcessStartArgs`]. +//! 2. Call [`start`], which performs the `initialize` / `initialized` handshake +//! internally and returns a ready-to-use [`InProcessClientHandle`]. +//! 3. Send requests via [`InProcessClientHandle::request`], notifications via +//! [`InProcessClientHandle::notify`], and consume events via +//! [`InProcessClientHandle::next_event`]. +//! 4. Terminate with [`InProcessClientHandle::shutdown`]. +//! +//! # Transport model +//! +//! The runtime is transport-local but not protocol-free. Incoming requests are +//! typed [`ClientRequest`] values, yet responses still come back through the +//! same JSON-RPC result envelope that `MessageProcessor` uses for stdio and +//! websocket transports. This keeps in-process behavior aligned with +//! app-server rather than creating a second execution contract. +//! +//! # Backpressure +//! +//! Command submission uses `try_send` and can return `WouldBlock`, while event +//! fanout may drop notifications under saturation. Server requests are never +//! silently abandoned: if they cannot be queued they are failed back into +//! `MessageProcessor` with overload or internal errors so approval flows do +//! not hang indefinitely. +//! +//! # Relationship to `codex-app-server-client` +//! +//! This module provides the low-level runtime handle ([`InProcessClientHandle`]). +//! Higher-level callers (TUI, exec) should go through `codex-app-server-client`, +//! which wraps this module behind a worker task with async request/response +//! helpers, surface-specific startup policy, and bounded shutdown. + +use std::collections::HashMap; +use std::collections::HashSet; +use std::collections::hash_map::Entry; +use std::io::Error as IoError; +use std::io::ErrorKind; +use std::io::Result as IoResult; +use std::sync::Arc; +use std::sync::RwLock; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use crate::error_code::INTERNAL_ERROR_CODE; +use crate::error_code::INVALID_REQUEST_ERROR_CODE; +use crate::error_code::OVERLOADED_ERROR_CODE; +use crate::message_processor::ConnectionSessionState; +use crate::message_processor::MessageProcessor; +use crate::message_processor::MessageProcessorArgs; +use crate::outgoing_message::ConnectionId; +use crate::outgoing_message::OutgoingEnvelope; +use crate::outgoing_message::OutgoingMessage; +use crate::outgoing_message::OutgoingMessageSender; +use crate::transport::CHANNEL_CAPACITY; +use crate::transport::OutboundConnectionState; +use crate::transport::route_outgoing_envelope; +use codex_app_server_protocol::ClientNotification; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ConfigWarningNotification; +use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::Result; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; +use codex_arg0::Arg0DispatchPaths; +use codex_core::config::Config; +use codex_core::config_loader::CloudRequirementsLoader; +use codex_core::config_loader::LoaderOverrides; +use codex_feedback::CodexFeedback; +use codex_protocol::protocol::SessionSource; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio::time::timeout; +use toml::Value as TomlValue; +use tracing::warn; + +const IN_PROCESS_CONNECTION_ID: ConnectionId = ConnectionId(0); +const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); +/// Default bounded channel capacity for in-process runtime queues. +pub const DEFAULT_IN_PROCESS_CHANNEL_CAPACITY: usize = CHANNEL_CAPACITY; + +type PendingClientRequestResponse = std::result::Result; + +fn server_notification_requires_delivery(notification: &ServerNotification) -> bool { + matches!(notification, ServerNotification::TurnCompleted(_)) +} + +fn legacy_notification_requires_delivery(notification: &JSONRPCNotification) -> bool { + matches!( + notification + .method + .strip_prefix("codex/event/") + .unwrap_or(¬ification.method), + "task_complete" | "turn_aborted" | "shutdown_complete" + ) +} + +/// Input needed to start an in-process app-server runtime. +/// +/// These fields mirror the pieces of ambient process state that stdio and +/// websocket transports normally assemble before `MessageProcessor` starts. +#[derive(Clone)] +pub struct InProcessStartArgs { + /// Resolved argv0 dispatch paths used by command execution internals. + pub arg0_paths: Arg0DispatchPaths, + /// Shared base config used to initialize core components. + pub config: Arc, + /// CLI config overrides that are already parsed into TOML values. + pub cli_overrides: Vec<(String, TomlValue)>, + /// Loader override knobs used by config API paths. + pub loader_overrides: LoaderOverrides, + /// Preloaded cloud requirements provider. + pub cloud_requirements: CloudRequirementsLoader, + /// Feedback sink used by app-server/core telemetry and logs. + pub feedback: CodexFeedback, + /// Startup warnings emitted after initialize succeeds. + pub config_warnings: Vec, + /// Session source stamped into thread/session metadata. + pub session_source: SessionSource, + /// Whether auth loading should honor the `CODEX_API_KEY` environment variable. + pub enable_codex_api_key_env: bool, + /// Initialize params used for initial handshake. + pub initialize: InitializeParams, + /// Capacity used for all runtime queues (clamped to at least 1). + pub channel_capacity: usize, +} + +/// Event emitted from the app-server to the in-process client. +/// +/// The stream carries three event families because CLI surfaces are mid-migration +/// from the legacy `codex_protocol::Event` model to the typed app-server +/// notification model. Once all surfaces consume only [`ServerNotification`], +/// [`LegacyNotification`](Self::LegacyNotification) can be removed. +/// +/// [`Lagged`](Self::Lagged) is a transport health marker, not an application +/// event — it signals that the consumer fell behind and some events were dropped. +#[derive(Debug, Clone)] +pub enum InProcessServerEvent { + /// Server request that requires client response/rejection. + ServerRequest(ServerRequest), + /// App-server notification directed to the embedded client. + ServerNotification(ServerNotification), + /// Legacy JSON-RPC notification from core event bridge. + LegacyNotification(JSONRPCNotification), + /// Indicates one or more events were dropped due to backpressure. + Lagged { skipped: usize }, +} + +/// Internal message sent from [`InProcessClientHandle`] methods to the runtime task. +/// +/// Requests carry a oneshot sender for the response; notifications and server-request +/// replies are fire-and-forget from the caller's perspective (transport errors are +/// caught by `try_send` on the outer channel). +enum InProcessClientMessage { + Request { + request: Box, + response_tx: oneshot::Sender, + }, + Notification { + notification: ClientNotification, + }, + ServerRequestResponse { + request_id: RequestId, + result: Result, + }, + ServerRequestError { + request_id: RequestId, + error: JSONRPCErrorError, + }, + Shutdown { + done_tx: oneshot::Sender<()>, + }, +} + +enum ProcessorCommand { + Request(Box), + Notification(ClientNotification), +} + +#[derive(Clone)] +pub struct InProcessClientSender { + client_tx: mpsc::Sender, +} + +impl InProcessClientSender { + pub async fn request(&self, request: ClientRequest) -> IoResult { + let (response_tx, response_rx) = oneshot::channel(); + self.try_send_client_message(InProcessClientMessage::Request { + request: Box::new(request), + response_tx, + })?; + response_rx.await.map_err(|err| { + IoError::new( + ErrorKind::BrokenPipe, + format!("in-process request response channel closed: {err}"), + ) + }) + } + + pub fn notify(&self, notification: ClientNotification) -> IoResult<()> { + self.try_send_client_message(InProcessClientMessage::Notification { notification }) + } + + pub fn respond_to_server_request(&self, request_id: RequestId, result: Result) -> IoResult<()> { + self.try_send_client_message(InProcessClientMessage::ServerRequestResponse { + request_id, + result, + }) + } + + pub fn fail_server_request( + &self, + request_id: RequestId, + error: JSONRPCErrorError, + ) -> IoResult<()> { + self.try_send_client_message(InProcessClientMessage::ServerRequestError { + request_id, + error, + }) + } + + fn try_send_client_message(&self, message: InProcessClientMessage) -> IoResult<()> { + match self.client_tx.try_send(message) { + Ok(()) => Ok(()), + Err(mpsc::error::TrySendError::Full(_)) => Err(IoError::new( + ErrorKind::WouldBlock, + "in-process app-server client queue is full", + )), + Err(mpsc::error::TrySendError::Closed(_)) => Err(IoError::new( + ErrorKind::BrokenPipe, + "in-process app-server runtime is closed", + )), + } + } +} + +/// Handle used by an in-process client to call app-server and consume events. +/// +/// This is the low-level runtime handle. Higher-level callers should usually go +/// through `codex-app-server-client`, which adds worker-task buffering, +/// request/response helpers, and surface-specific startup policy. +pub struct InProcessClientHandle { + client: InProcessClientSender, + event_rx: mpsc::Receiver, + runtime_handle: tokio::task::JoinHandle<()>, +} + +impl InProcessClientHandle { + /// Sends a typed client request into the in-process runtime. + /// + /// The returned value is a transport-level `IoResult` containing either a + /// JSON-RPC success payload or JSON-RPC error payload. Callers must keep + /// request IDs unique among concurrent requests; reusing an in-flight ID + /// produces an `INVALID_REQUEST` response and can make request routing + /// ambiguous in the caller. + pub async fn request(&self, request: ClientRequest) -> IoResult { + self.client.request(request).await + } + + /// Sends a typed client notification into the in-process runtime. + /// + /// Notifications do not have an application-level response. Transport + /// errors indicate queue saturation or closed runtime. + pub fn notify(&self, notification: ClientNotification) -> IoResult<()> { + self.client.notify(notification) + } + + /// Resolves a pending [`ServerRequest`](InProcessServerEvent::ServerRequest). + /// + /// This should be used only with request IDs received from the current + /// runtime event stream; sending arbitrary IDs has no effect on app-server + /// state and can mask a stuck approval flow in the caller. + pub fn respond_to_server_request(&self, request_id: RequestId, result: Result) -> IoResult<()> { + self.client.respond_to_server_request(request_id, result) + } + + /// Rejects a pending [`ServerRequest`](InProcessServerEvent::ServerRequest). + /// + /// Use this when the embedder cannot satisfy a server request; leaving + /// requests unanswered can stall turn progress. + pub fn fail_server_request( + &self, + request_id: RequestId, + error: JSONRPCErrorError, + ) -> IoResult<()> { + self.client.fail_server_request(request_id, error) + } + + /// Receives the next server event from the in-process runtime. + /// + /// Returns `None` when the runtime task exits and no more events are + /// available. + pub async fn next_event(&mut self) -> Option { + self.event_rx.recv().await + } + + /// Requests runtime shutdown and waits for worker termination. + /// + /// Shutdown is bounded by internal timeouts and may abort background tasks + /// if graceful drain does not complete in time. + pub async fn shutdown(self) -> IoResult<()> { + let mut runtime_handle = self.runtime_handle; + let (done_tx, done_rx) = oneshot::channel(); + + if self + .client + .client_tx + .send(InProcessClientMessage::Shutdown { done_tx }) + .await + .is_ok() + { + let _ = timeout(SHUTDOWN_TIMEOUT, done_rx).await; + } + + if let Err(_elapsed) = timeout(SHUTDOWN_TIMEOUT, &mut runtime_handle).await { + runtime_handle.abort(); + let _ = runtime_handle.await; + } + Ok(()) + } + + pub fn sender(&self) -> InProcessClientSender { + self.client.clone() + } +} + +/// Starts an in-process app-server runtime and performs initialize handshake. +/// +/// This function sends `initialize` followed by `initialized` before returning +/// the handle, so callers receive a ready-to-use runtime. If initialize fails, +/// the runtime is shut down and an `InvalidData` error is returned. +pub async fn start(args: InProcessStartArgs) -> IoResult { + let initialize = args.initialize.clone(); + let client = start_uninitialized(args); + + let initialize_response = client + .request(ClientRequest::Initialize { + request_id: RequestId::Integer(0), + params: initialize, + }) + .await?; + if let Err(error) = initialize_response { + let _ = client.shutdown().await; + return Err(IoError::new( + ErrorKind::InvalidData, + format!("in-process initialize failed: {}", error.message), + )); + } + client.notify(ClientNotification::Initialized)?; + + Ok(client) +} + +fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { + let channel_capacity = args.channel_capacity.max(1); + let (client_tx, mut client_rx) = mpsc::channel::(channel_capacity); + let (event_tx, event_rx) = mpsc::channel::(channel_capacity); + + let runtime_handle = tokio::spawn(async move { + let (outgoing_tx, mut outgoing_rx) = mpsc::channel::(channel_capacity); + let outgoing_message_sender = Arc::new(OutgoingMessageSender::new(outgoing_tx)); + + let (writer_tx, mut writer_rx) = mpsc::channel::(channel_capacity); + let outbound_initialized = Arc::new(AtomicBool::new(false)); + let outbound_experimental_api_enabled = Arc::new(AtomicBool::new(false)); + let outbound_opted_out_notification_methods = Arc::new(RwLock::new(HashSet::new())); + + let mut outbound_connections = HashMap::::new(); + outbound_connections.insert( + IN_PROCESS_CONNECTION_ID, + OutboundConnectionState::new( + writer_tx, + Arc::clone(&outbound_initialized), + Arc::clone(&outbound_experimental_api_enabled), + Arc::clone(&outbound_opted_out_notification_methods), + None, + ), + ); + let mut outbound_handle = tokio::spawn(async move { + while let Some(envelope) = outgoing_rx.recv().await { + route_outgoing_envelope(&mut outbound_connections, envelope).await; + } + }); + + let processor_outgoing = Arc::clone(&outgoing_message_sender); + let (processor_tx, mut processor_rx) = mpsc::channel::(channel_capacity); + let mut processor_handle = tokio::spawn(async move { + let mut processor = MessageProcessor::new(MessageProcessorArgs { + outgoing: Arc::clone(&processor_outgoing), + arg0_paths: args.arg0_paths, + config: args.config, + cli_overrides: args.cli_overrides, + loader_overrides: args.loader_overrides, + cloud_requirements: args.cloud_requirements, + feedback: args.feedback, + log_db: None, + config_warnings: args.config_warnings, + session_source: args.session_source, + enable_codex_api_key_env: args.enable_codex_api_key_env, + }); + let mut thread_created_rx = processor.thread_created_receiver(); + let mut session = ConnectionSessionState::default(); + let mut listen_for_threads = true; + + loop { + tokio::select! { + command = processor_rx.recv() => { + match command { + Some(ProcessorCommand::Request(request)) => { + let was_initialized = session.initialized; + processor + .process_client_request( + IN_PROCESS_CONNECTION_ID, + *request, + &mut session, + &outbound_initialized, + ) + .await; + if let Ok(mut opted_out_notification_methods) = + outbound_opted_out_notification_methods.write() + { + *opted_out_notification_methods = + session.opted_out_notification_methods.clone(); + } else { + warn!("failed to update outbound opted-out notifications"); + } + outbound_experimental_api_enabled.store( + session.experimental_api_enabled, + Ordering::Release, + ); + if !was_initialized && session.initialized { + processor.send_initialize_notifications().await; + } + } + Some(ProcessorCommand::Notification(notification)) => { + processor.process_client_notification(notification).await; + } + None => { + break; + } + } + } + created = thread_created_rx.recv(), if listen_for_threads => { + match created { + Ok(thread_id) => { + let connection_ids = if session.initialized { + vec![IN_PROCESS_CONNECTION_ID] + } else { + Vec::::new() + }; + processor + .try_attach_thread_listener(thread_id, connection_ids) + .await; + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + warn!("thread_created receiver lagged; skipping resync"); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + listen_for_threads = false; + } + } + } + } + } + + processor.connection_closed(IN_PROCESS_CONNECTION_ID).await; + }); + let mut pending_request_responses = + HashMap::>::new(); + let mut shutdown_ack = None; + + loop { + tokio::select! { + message = client_rx.recv() => { + match message { + Some(InProcessClientMessage::Request { request, response_tx }) => { + let request = *request; + let request_id = request.id().clone(); + match pending_request_responses.entry(request_id.clone()) { + Entry::Vacant(entry) => { + entry.insert(response_tx); + } + Entry::Occupied(_) => { + let _ = response_tx.send(Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("duplicate request id: {request_id:?}"), + data: None, + })); + continue; + } + } + + match processor_tx.try_send(ProcessorCommand::Request(Box::new(request))) { + Ok(()) => {} + Err(mpsc::error::TrySendError::Full(_)) => { + if let Some(response_tx) = + pending_request_responses.remove(&request_id) + { + let _ = response_tx.send(Err(JSONRPCErrorError { + code: OVERLOADED_ERROR_CODE, + message: "in-process app-server request queue is full" + .to_string(), + data: None, + })); + } + } + Err(mpsc::error::TrySendError::Closed(_)) => { + if let Some(response_tx) = + pending_request_responses.remove(&request_id) + { + let _ = response_tx.send(Err(JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: + "in-process app-server request processor is closed" + .to_string(), + data: None, + })); + } + break; + } + } + } + Some(InProcessClientMessage::Notification { notification }) => { + match processor_tx.try_send(ProcessorCommand::Notification(notification)) { + Ok(()) => {} + Err(mpsc::error::TrySendError::Full(_)) => { + warn!("dropping in-process client notification (queue full)"); + } + Err(mpsc::error::TrySendError::Closed(_)) => { + break; + } + } + } + Some(InProcessClientMessage::ServerRequestResponse { request_id, result }) => { + outgoing_message_sender + .notify_client_response(request_id, result) + .await; + } + Some(InProcessClientMessage::ServerRequestError { request_id, error }) => { + outgoing_message_sender + .notify_client_error(request_id, error) + .await; + } + Some(InProcessClientMessage::Shutdown { done_tx }) => { + shutdown_ack = Some(done_tx); + break; + } + None => { + break; + } + } + } + outgoing_message = writer_rx.recv() => { + let Some(outgoing_message) = outgoing_message else { + break; + }; + match outgoing_message { + OutgoingMessage::Response(response) => { + if let Some(response_tx) = pending_request_responses.remove(&response.id) { + let _ = response_tx.send(Ok(response.result)); + } else { + warn!( + request_id = ?response.id, + "dropping unmatched in-process response" + ); + } + } + OutgoingMessage::Error(error) => { + if let Some(response_tx) = pending_request_responses.remove(&error.id) { + let _ = response_tx.send(Err(error.error)); + } else { + warn!( + request_id = ?error.id, + "dropping unmatched in-process error response" + ); + } + } + OutgoingMessage::Request(request) => { + // Send directly to avoid cloning; on failure the + // original value is returned inside the error. + if let Err(send_error) = event_tx + .try_send(InProcessServerEvent::ServerRequest(request)) + { + let (code, message, inner) = match send_error { + mpsc::error::TrySendError::Full(inner) => ( + OVERLOADED_ERROR_CODE, + "in-process server request queue is full", + inner, + ), + mpsc::error::TrySendError::Closed(inner) => ( + INTERNAL_ERROR_CODE, + "in-process server request consumer is closed", + inner, + ), + }; + let request_id = match inner { + InProcessServerEvent::ServerRequest(req) => req.id().clone(), + _ => unreachable!("we just sent a ServerRequest variant"), + }; + outgoing_message_sender + .notify_client_error( + request_id, + JSONRPCErrorError { + code, + message: message.to_string(), + data: None, + }, + ) + .await; + } + } + OutgoingMessage::AppServerNotification(notification) => { + if server_notification_requires_delivery(¬ification) { + if event_tx + .send(InProcessServerEvent::ServerNotification(notification)) + .await + .is_err() + { + break; + } + } else if let Err(send_error) = + event_tx.try_send(InProcessServerEvent::ServerNotification(notification)) + { + match send_error { + mpsc::error::TrySendError::Full(_) => { + warn!("dropping in-process server notification (queue full)"); + } + mpsc::error::TrySendError::Closed(_) => { + break; + } + } + } + } + OutgoingMessage::Notification(notification) => { + let notification = JSONRPCNotification { + method: notification.method, + params: notification.params, + }; + if legacy_notification_requires_delivery(¬ification) { + if event_tx + .send(InProcessServerEvent::LegacyNotification(notification)) + .await + .is_err() + { + break; + } + } else if let Err(send_error) = + event_tx.try_send(InProcessServerEvent::LegacyNotification(notification)) + { + match send_error { + mpsc::error::TrySendError::Full(_) => { + warn!("dropping in-process legacy notification (queue full)"); + } + mpsc::error::TrySendError::Closed(_) => { + break; + } + } + } + } + } + } + } + } + + drop(writer_rx); + drop(processor_tx); + outgoing_message_sender + .cancel_all_requests(Some(JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: "in-process app-server runtime is shutting down".to_string(), + data: None, + })) + .await; + // Drop the runtime's last sender before awaiting the router task so + // `outgoing_rx.recv()` can observe channel closure and exit cleanly. + drop(outgoing_message_sender); + for (_, response_tx) in pending_request_responses { + let _ = response_tx.send(Err(JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: "in-process app-server runtime is shutting down".to_string(), + data: None, + })); + } + + if let Err(_elapsed) = timeout(SHUTDOWN_TIMEOUT, &mut processor_handle).await { + processor_handle.abort(); + let _ = processor_handle.await; + } + if let Err(_elapsed) = timeout(SHUTDOWN_TIMEOUT, &mut outbound_handle).await { + outbound_handle.abort(); + let _ = outbound_handle.await; + } + + if let Some(done_tx) = shutdown_ack { + let _ = done_tx.send(()); + } + }); + + InProcessClientHandle { + client: InProcessClientSender { client_tx }, + event_rx, + runtime_handle, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_app_server_protocol::ClientInfo; + use codex_app_server_protocol::ConfigRequirementsReadResponse; + use codex_app_server_protocol::SessionSource as ApiSessionSource; + use codex_app_server_protocol::ThreadStartParams; + use codex_app_server_protocol::ThreadStartResponse; + use codex_app_server_protocol::Turn; + use codex_app_server_protocol::TurnCompletedNotification; + use codex_app_server_protocol::TurnStatus; + use codex_core::config::ConfigBuilder; + use pretty_assertions::assert_eq; + + async fn build_test_config() -> Config { + match ConfigBuilder::default().build().await { + Ok(config) => config, + Err(_) => Config::load_default_with_cli_overrides(Vec::new()) + .expect("default config should load"), + } + } + + async fn start_test_client_with_capacity( + session_source: SessionSource, + channel_capacity: usize, + ) -> InProcessClientHandle { + let args = InProcessStartArgs { + arg0_paths: Arg0DispatchPaths::default(), + config: Arc::new(build_test_config().await), + cli_overrides: Vec::new(), + loader_overrides: LoaderOverrides::default(), + cloud_requirements: CloudRequirementsLoader::default(), + feedback: CodexFeedback::new(), + config_warnings: Vec::new(), + session_source, + enable_codex_api_key_env: false, + initialize: InitializeParams { + client_info: ClientInfo { + name: "codex-in-process-test".to_string(), + title: None, + version: "0.0.0".to_string(), + }, + capabilities: None, + }, + channel_capacity, + }; + start(args).await.expect("in-process runtime should start") + } + + async fn start_test_client(session_source: SessionSource) -> InProcessClientHandle { + start_test_client_with_capacity(session_source, DEFAULT_IN_PROCESS_CHANNEL_CAPACITY).await + } + + #[tokio::test] + async fn in_process_start_initializes_and_handles_typed_v2_request() { + let client = start_test_client(SessionSource::Cli).await; + let response = client + .request(ClientRequest::ConfigRequirementsRead { + request_id: RequestId::Integer(1), + params: None, + }) + .await + .expect("request transport should work") + .expect("request should succeed"); + assert!(response.is_object()); + + let _parsed: ConfigRequirementsReadResponse = + serde_json::from_value(response).expect("response should match v2 schema"); + client + .shutdown() + .await + .expect("in-process runtime should shutdown cleanly"); + } + + #[tokio::test] + async fn in_process_start_uses_requested_session_source_for_thread_start() { + for (requested_source, expected_source) in [ + (SessionSource::Cli, ApiSessionSource::Cli), + (SessionSource::Exec, ApiSessionSource::Exec), + ] { + let client = start_test_client(requested_source).await; + let response = client + .request(ClientRequest::ThreadStart { + request_id: RequestId::Integer(2), + params: ThreadStartParams { + ephemeral: Some(true), + ..ThreadStartParams::default() + }, + }) + .await + .expect("request transport should work") + .expect("thread/start should succeed"); + let parsed: ThreadStartResponse = + serde_json::from_value(response).expect("thread/start response should parse"); + assert_eq!(parsed.thread.source, expected_source); + client + .shutdown() + .await + .expect("in-process runtime should shutdown cleanly"); + } + } + + #[tokio::test] + async fn in_process_start_clamps_zero_channel_capacity() { + let client = start_test_client_with_capacity(SessionSource::Cli, 0).await; + let response = loop { + match client + .request(ClientRequest::ConfigRequirementsRead { + request_id: RequestId::Integer(4), + params: None, + }) + .await + { + Ok(response) => break response.expect("request should succeed"), + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + tokio::task::yield_now().await; + } + Err(err) => panic!("request transport should work: {err}"), + } + }; + let _parsed: ConfigRequirementsReadResponse = + serde_json::from_value(response).expect("response should match v2 schema"); + client + .shutdown() + .await + .expect("in-process runtime should shutdown cleanly"); + } + + #[test] + fn guaranteed_delivery_helpers_cover_terminal_notifications() { + assert!(server_notification_requires_delivery( + &ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: TurnStatus::Completed, + error: None, + }, + }) + )); + + assert!(legacy_notification_requires_delivery( + &JSONRPCNotification { + method: "codex/event/task_complete".to_string(), + params: None, + } + )); + assert!(legacy_notification_requires_delivery( + &JSONRPCNotification { + method: "codex/event/turn_aborted".to_string(), + params: None, + } + )); + assert!(legacy_notification_requires_delivery( + &JSONRPCNotification { + method: "codex/event/shutdown_complete".to_string(), + params: None, + } + )); + assert!(!legacy_notification_requires_delivery( + &JSONRPCNotification { + method: "codex/event/item_started".to_string(), + params: None, + } + )); + } +} diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 34796c575f..8ad14b8e1c 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -39,6 +39,7 @@ use codex_core::check_execpolicy_for_warnings; use codex_core::config_loader::ConfigLoadError; use codex_core::config_loader::TextRange as CoreTextRange; use codex_feedback::CodexFeedback; +use codex_protocol::protocol::SessionSource; use codex_state::log_db; use tokio::sync::mpsc; use tokio::task::JoinHandle; @@ -65,6 +66,7 @@ mod error_code; mod external_agent_config_api; mod filters; mod fuzzy_file_search; +pub mod in_process; mod message_processor; mod models; mod outgoing_message; @@ -597,6 +599,8 @@ pub async fn run_main_with_transport( feedback: feedback.clone(), log_db, config_warnings, + session_source: SessionSource::VSCode, + enable_codex_api_key_env: false, }); let mut thread_created_rx = processor.thread_created_receiver(); let mut running_turn_count_rx = processor.subscribe_running_assistant_turn_count(); @@ -711,7 +715,6 @@ pub async fn run_main_with_transport( request, transport, &mut connection_state.session, - &connection_state.outbound_initialized, ) .await; if let Ok(mut opted_out_notification_methods) = connection_state @@ -734,7 +737,15 @@ pub async fn run_main_with_transport( std::sync::atomic::Ordering::Release, ); if !was_initialized && connection_state.session.initialized { - processor.send_initialize_notifications().await; + processor + .send_initialize_notifications_to_connection( + connection_id, + ) + .await; + processor.connection_initialized(connection_id).await; + connection_state + .outbound_initialized + .store(true, std::sync::atomic::Ordering::Release); } } JSONRPCMessage::Response(response) => { diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 51049e624a..7b6501a217 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -18,6 +18,7 @@ use codex_app_server_protocol::ChatgptAuthTokensRefreshParams; use codex_app_server_protocol::ChatgptAuthTokensRefreshReason; use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientNotification; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigReadParams; @@ -157,6 +158,8 @@ pub(crate) struct MessageProcessorArgs { pub(crate) feedback: CodexFeedback, pub(crate) log_db: Option, pub(crate) config_warnings: Vec, + pub(crate) session_source: SessionSource, + pub(crate) enable_codex_api_key_env: bool, } impl MessageProcessor { @@ -173,10 +176,12 @@ impl MessageProcessor { feedback, log_db, config_warnings, + session_source, + enable_codex_api_key_env, } = args; let auth_manager = AuthManager::shared( config.codex_home.clone(), - false, + enable_codex_api_key_env, config.cli_auth_credentials_store_mode, ); auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone()); @@ -186,7 +191,7 @@ impl MessageProcessor { let thread_manager = Arc::new(ThreadManager::new( config.codex_home.clone(), auth_manager.clone(), - SessionSource::VSCode, + session_source, config.model_catalog.clone(), CollaborationModesConfig { default_mode_request_user_input: config @@ -194,13 +199,14 @@ impl MessageProcessor { .enabled(codex_core::features::Feature::DefaultModeRequestUserInput), }, )); + // TODO(xl): Move into PluginManager once this no longer depends on config feature gating. thread_manager .plugins_manager() .maybe_start_curated_repo_sync_for_config(&config); let cloud_requirements = Arc::new(RwLock::new(cloud_requirements)); let codex_message_processor = CodexMessageProcessor::new(CodexMessageProcessorArgs { auth_manager, - thread_manager, + thread_manager: Arc::clone(&thread_manager), outgoing: outgoing.clone(), arg0_paths, config: Arc::clone(&config), @@ -214,6 +220,7 @@ impl MessageProcessor { cli_overrides, loader_overrides, cloud_requirements, + thread_manager, ); let external_agent_config_api = ExternalAgentConfigApi::new(config.codex_home.clone()); @@ -233,7 +240,6 @@ impl MessageProcessor { request: JSONRPCRequest, transport: AppServerTransport, session: &mut ConnectionSessionState, - outbound_initialized: &AtomicBool, ) { let request_span = crate::app_server_tracing::request_span(&request, transport, connection_id, session); @@ -274,187 +280,51 @@ impl MessageProcessor { } }; - match codex_request { - // Handle Initialize internally so CodexMessageProcessor does not have to concern - // itself with the `initialized` bool. - ClientRequest::Initialize { request_id, params } => { - let request_id = ConnectionRequestId { - connection_id, - request_id, - }; - if session.initialized { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "Already initialized".to_string(), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } else { - // TODO(maxj): Revisit capability scoping for `experimental_api_enabled`. - // Current behavior is per-connection. Reviewer feedback notes this can - // create odd cross-client behavior (for example dynamic tool calls on a - // shared thread when another connected client did not opt into - // experimental API). Proposed direction is instance-global first-write-wins - // with initialize-time mismatch rejection. - let (experimental_api_enabled, opt_out_notification_methods) = - match params.capabilities { - Some(capabilities) => ( - capabilities.experimental_api, - capabilities - .opt_out_notification_methods - .unwrap_or_default(), - ), - None => (false, Vec::new()), - }; - session.experimental_api_enabled = experimental_api_enabled; - session.opted_out_notification_methods = - opt_out_notification_methods.into_iter().collect(); - let ClientInfo { - name, - title: _title, - version, - } = params.client_info; - session.app_server_client_name = Some(name.clone()); - session.client_version = Some(version.clone()); - if let Err(error) = set_default_originator(name.clone()) { - match error { - SetOriginatorError::InvalidHeaderValue => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!( - "Invalid clientInfo.name: '{name}'. Must be a valid HTTP header value." - ), - data: None, - }; - self.outgoing.send_error(request_id.clone(), error).await; - return; - } - SetOriginatorError::AlreadyInitialized => { - // No-op. This is expected to happen if the originator is already set via env var. - // TODO(owen): Once we remove support for CODEX_INTERNAL_ORIGINATOR_OVERRIDE, - // this will be an unexpected state and we can return a JSON-RPC error indicating - // internal server error. - } - } - } - set_default_client_residency_requirement(self.config.enforce_residency.value()); - let user_agent_suffix = format!("{name}; {version}"); - if let Ok(mut suffix) = USER_AGENT_SUFFIX.lock() { - *suffix = Some(user_agent_suffix); - } + // Websocket callers finalize outbound readiness in lib.rs after mirroring + // session state into outbound state and sending initialize notifications to + // this specific connection. Passing `None` avoids marking the connection + // ready too early from inside the shared request handler. + self.handle_client_request(connection_id, request_id, codex_request, session, None) + .await; + } + .instrument(request_span) + .await; + } - let user_agent = get_codex_user_agent(); - let response = InitializeResponse { user_agent }; - self.outgoing.send_response(request_id, response).await; - - session.initialized = true; - outbound_initialized.store(true, Ordering::Release); - self.codex_message_processor - .connection_initialized(connection_id) - .await; - return; - } - } - _ => { - if !session.initialized { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "Not initialized".to_string(), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - } - } - if let Some(reason) = codex_request.experimental_reason() - && !session.experimental_api_enabled - { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: experimental_required_message(reason), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - - match codex_request { - ClientRequest::ConfigRead { request_id, params } => { - self.handle_config_read( - ConnectionRequestId { - connection_id, - request_id, - }, - params, - ) - .await; - } - ClientRequest::ExternalAgentConfigDetect { request_id, params } => { - self.handle_external_agent_config_detect( - ConnectionRequestId { - connection_id, - request_id, - }, - params, - ) - .await; - } - ClientRequest::ExternalAgentConfigImport { request_id, params } => { - self.handle_external_agent_config_import( - ConnectionRequestId { - connection_id, - request_id, - }, - params, - ) - .await; - } - ClientRequest::ConfigValueWrite { request_id, params } => { - self.handle_config_value_write( - ConnectionRequestId { - connection_id, - request_id, - }, - params, - ) - .await; - } - ClientRequest::ConfigBatchWrite { request_id, params } => { - self.handle_config_batch_write( - ConnectionRequestId { - connection_id, - request_id, - }, - params, - ) - .await; - } - ClientRequest::ConfigRequirementsRead { - request_id, - params: _, - } => { - self.handle_config_requirements_read(ConnectionRequestId { - connection_id, - request_id, - }) - .await; - } - other => { - // Box the delegated future so this wrapper's async state machine does not - // inline the full `CodexMessageProcessor::process_request` future, which - // can otherwise push worker-thread stack usage over the edge. - self.codex_message_processor - .process_request( - connection_id, - other, - session.app_server_client_name.clone(), - ) - .boxed() - .await; - } - } + /// Handles a typed request path used by in-process embedders. + /// + /// This bypasses JSON request deserialization but keeps identical request + /// semantics by delegating to `handle_client_request`. + pub(crate) async fn process_client_request( + &mut self, + connection_id: ConnectionId, + request: ClientRequest, + session: &mut ConnectionSessionState, + outbound_initialized: &AtomicBool, + ) { + let request_span = + crate::app_server_tracing::typed_request_span(&request, connection_id, session); + async { + let request_id = ConnectionRequestId { + connection_id, + request_id: request.id().clone(), + }; + tracing::trace!( + ?connection_id, + request_id = ?request_id.request_id, + "app-server typed request" + ); + // In-process clients do not have the websocket transport loop that performs + // post-initialize bookkeeping, so they still finalize outbound readiness in + // the shared request handler. + self.handle_client_request( + connection_id, + request_id, + request, + session, + Some(outbound_initialized), + ) + .await; } .instrument(request_span) .await; @@ -466,10 +336,37 @@ impl MessageProcessor { tracing::info!("<- notification: {:?}", notification); } + /// Handles typed notifications from in-process clients. + pub(crate) async fn process_client_notification(&self, notification: ClientNotification) { + // Currently, we do not expect to receive any typed notifications from + // in-process clients, so we just log them. + tracing::info!("<- typed notification: {:?}", notification); + } + pub(crate) fn thread_created_receiver(&self) -> broadcast::Receiver { self.codex_message_processor.thread_created_receiver() } + pub(crate) async fn send_initialize_notifications_to_connection( + &self, + connection_id: ConnectionId, + ) { + for notification in self.config_warnings.iter().cloned() { + self.outgoing + .send_server_notification_to_connections( + &[connection_id], + ServerNotification::ConfigWarning(notification), + ) + .await; + } + } + + pub(crate) async fn connection_initialized(&self, connection_id: ConnectionId) { + self.codex_message_processor + .connection_initialized(connection_id) + .await; + } + pub(crate) async fn send_initialize_notifications(&self) { for notification in self.config_warnings.iter().cloned() { self.outgoing @@ -512,6 +409,201 @@ impl MessageProcessor { self.outgoing.notify_client_error(err.id, err.error).await; } + async fn handle_client_request( + &mut self, + connection_id: ConnectionId, + request_id: ConnectionRequestId, + codex_request: ClientRequest, + session: &mut ConnectionSessionState, + // `Some(...)` means the caller wants initialize to immediately mark the + // connection outbound-ready. Websocket JSON-RPC calls pass `None` so + // lib.rs can deliver connection-scoped initialize notifications first. + outbound_initialized: Option<&AtomicBool>, + ) { + match codex_request { + // Handle Initialize internally so CodexMessageProcessor does not have to concern + // itself with the `initialized` bool. + ClientRequest::Initialize { request_id, params } => { + let request_id = ConnectionRequestId { + connection_id, + request_id, + }; + if session.initialized { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "Already initialized".to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + + // TODO(maxj): Revisit capability scoping for `experimental_api_enabled`. + // Current behavior is per-connection. Reviewer feedback notes this can + // create odd cross-client behavior (for example dynamic tool calls on a + // shared thread when another connected client did not opt into + // experimental API). Proposed direction is instance-global first-write-wins + // with initialize-time mismatch rejection. + let (experimental_api_enabled, opt_out_notification_methods) = + match params.capabilities { + Some(capabilities) => ( + capabilities.experimental_api, + capabilities + .opt_out_notification_methods + .unwrap_or_default(), + ), + None => (false, Vec::new()), + }; + session.experimental_api_enabled = experimental_api_enabled; + session.opted_out_notification_methods = + opt_out_notification_methods.into_iter().collect(); + let ClientInfo { + name, + title: _title, + version, + } = params.client_info; + session.app_server_client_name = Some(name.clone()); + session.client_version = Some(version.clone()); + if let Err(error) = set_default_originator(name.clone()) { + match error { + SetOriginatorError::InvalidHeaderValue => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!( + "Invalid clientInfo.name: '{name}'. Must be a valid HTTP header value." + ), + data: None, + }; + self.outgoing.send_error(request_id.clone(), error).await; + return; + } + SetOriginatorError::AlreadyInitialized => { + // No-op. This is expected to happen if the originator is already set via env var. + // TODO(owen): Once we remove support for CODEX_INTERNAL_ORIGINATOR_OVERRIDE, + // this will be an unexpected state and we can return a JSON-RPC error indicating + // internal server error. + } + } + } + set_default_client_residency_requirement(self.config.enforce_residency.value()); + let user_agent_suffix = format!("{name}; {version}"); + if let Ok(mut suffix) = USER_AGENT_SUFFIX.lock() { + *suffix = Some(user_agent_suffix); + } + + let user_agent = get_codex_user_agent(); + let response = InitializeResponse { user_agent }; + self.outgoing.send_response(request_id, response).await; + + session.initialized = true; + if let Some(outbound_initialized) = outbound_initialized { + // In-process clients can complete readiness immediately here. The + // websocket path defers this until lib.rs finishes transport-layer + // initialize handling for the specific connection. + outbound_initialized.store(true, Ordering::Release); + self.codex_message_processor + .connection_initialized(connection_id) + .await; + } + return; + } + _ => { + if !session.initialized { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "Not initialized".to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + } + } + if let Some(reason) = codex_request.experimental_reason() + && !session.experimental_api_enabled + { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: experimental_required_message(reason), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + + match codex_request { + ClientRequest::ConfigRead { request_id, params } => { + self.handle_config_read( + ConnectionRequestId { + connection_id, + request_id, + }, + params, + ) + .await; + } + ClientRequest::ExternalAgentConfigDetect { request_id, params } => { + self.handle_external_agent_config_detect( + ConnectionRequestId { + connection_id, + request_id, + }, + params, + ) + .await; + } + ClientRequest::ExternalAgentConfigImport { request_id, params } => { + self.handle_external_agent_config_import( + ConnectionRequestId { + connection_id, + request_id, + }, + params, + ) + .await; + } + ClientRequest::ConfigValueWrite { request_id, params } => { + self.handle_config_value_write( + ConnectionRequestId { + connection_id, + request_id, + }, + params, + ) + .await; + } + ClientRequest::ConfigBatchWrite { request_id, params } => { + self.handle_config_batch_write( + ConnectionRequestId { + connection_id, + request_id, + }, + params, + ) + .await; + } + ClientRequest::ConfigRequirementsRead { + request_id, + params: _, + } => { + self.handle_config_requirements_read(ConnectionRequestId { + connection_id, + request_id, + }) + .await; + } + other => { + // Box the delegated future so this wrapper's async state machine does not + // inline the full `CodexMessageProcessor::process_request` future, which + // can otherwise push worker-thread stack usage over the edge. + self.codex_message_processor + .process_request(connection_id, other, session.app_server_client_name.clone()) + .boxed() + .await; + } + } + } + async fn handle_config_read(&self, request_id: ConnectionRequestId, params: ConfigReadParams) { match self.config_api.read(params).await { Ok(response) => self.outgoing.send_response(request_id, response).await, diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index bc38bd36c1..d615e51518 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -273,6 +273,25 @@ impl OutgoingMessageSender { self.take_request_callback(id).await.is_some() } + pub(crate) async fn cancel_all_requests(&self, error: Option) { + let entries = { + let mut request_id_to_callback = self.request_id_to_callback.lock().await; + request_id_to_callback + .drain() + .map(|(_, entry)| entry) + .collect::>() + }; + + if let Some(error) = error { + for entry in entries { + if let Err(err) = entry.callback.send(Err(error.clone())) { + let request_id = entry.request.id(); + warn!("could not notify callback for {request_id:?} due to: {err:?}"); + } + } + } + } + async fn take_request_callback( &self, id: &RequestId, diff --git a/codex-rs/app-server/src/transport.rs b/codex-rs/app-server/src/transport.rs index 019d00e45e..f537d8ba55 100644 --- a/codex-rs/app-server/src/transport.rs +++ b/codex-rs/app-server/src/transport.rs @@ -4,6 +4,16 @@ use crate::outgoing_message::ConnectionId; use crate::outgoing_message::OutgoingEnvelope; use crate::outgoing_message::OutgoingError; use crate::outgoing_message::OutgoingMessage; +use axum::Router; +use axum::extract::ConnectInfo; +use axum::extract::State; +use axum::extract::ws::Message as WebSocketMessage; +use axum::extract::ws::WebSocket; +use axum::extract::ws::WebSocketUpgrade; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::routing::any; +use axum::routing::get; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::ServerRequest; @@ -28,12 +38,8 @@ use tokio::io::AsyncWriteExt; use tokio::io::BufReader; use tokio::io::{self}; use tokio::net::TcpListener; -use tokio::net::TcpStream; use tokio::sync::mpsc; use tokio::task::JoinHandle; -use tokio_tungstenite::accept_async_with_config; -use tokio_tungstenite::tungstenite::Message as WebSocketMessage; -use tokio_tungstenite::tungstenite::protocol::WebSocketConfig; use tokio_util::sync::CancellationToken; use tracing::debug; use tracing::error; @@ -55,9 +61,15 @@ fn print_websocket_startup_banner(addr: SocketAddr) { let title = colorize("codex app-server (WebSockets)", Style::new().bold().cyan()); let listening_label = colorize("listening on:", Style::new().dimmed()); let listen_url = colorize(&format!("ws://{addr}"), Style::new().green()); + let ready_label = colorize("readyz:", Style::new().dimmed()); + let ready_url = colorize(&format!("http://{addr}/readyz"), Style::new().green()); + let health_label = colorize("healthz:", Style::new().dimmed()); + let health_url = colorize(&format!("http://{addr}/healthz"), Style::new().green()); let note_label = colorize("note:", Style::new().dimmed()); eprintln!("{title}"); eprintln!(" {listening_label} {listen_url}"); + eprintln!(" {ready_label} {ready_url}"); + eprintln!(" {health_label} {health_url}"); if addr.ip().is_loopback() { eprintln!( " {note_label} binds localhost only (use SSH port-forwarding for remote access)" @@ -69,6 +81,28 @@ fn print_websocket_startup_banner(addr: SocketAddr) { } } +#[derive(Clone)] +struct WebSocketListenerState { + transport_event_tx: mpsc::Sender, + connection_counter: Arc, +} + +async fn health_check_handler() -> StatusCode { + StatusCode::OK +} + +async fn websocket_upgrade_handler( + websocket: WebSocketUpgrade, + ConnectInfo(peer_addr): ConnectInfo, + State(state): State, +) -> impl IntoResponse { + let connection_id = ConnectionId(state.connection_counter.fetch_add(1, Ordering::Relaxed)); + info!(%peer_addr, "websocket client connected"); + websocket.on_upgrade(move |stream| async move { + run_websocket_connection(connection_id, stream, state.transport_event_tx).await; + }) +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum AppServerTransport { Stdio, @@ -279,54 +313,34 @@ pub(crate) async fn start_websocket_acceptor( print_websocket_startup_banner(local_addr); info!("app-server websocket listening on ws://{local_addr}"); - let connection_counter = Arc::new(AtomicU64::new(1)); + let router = Router::new() + .route("/readyz", get(health_check_handler)) + .route("/healthz", get(health_check_handler)) + .fallback(any(websocket_upgrade_handler)) + .with_state(WebSocketListenerState { + transport_event_tx, + connection_counter: Arc::new(AtomicU64::new(1)), + }); + let server = axum::serve( + listener, + router.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(async move { + shutdown_token.cancelled().await; + }); Ok(tokio::spawn(async move { - loop { - tokio::select! { - _ = shutdown_token.cancelled() => { - info!("websocket acceptor shutting down"); - break; - } - accept_result = listener.accept() => { - match accept_result { - Ok((stream, peer_addr)) => { - info!(%peer_addr, "websocket client connected"); - let connection_id = - ConnectionId(connection_counter.fetch_add(1, Ordering::Relaxed)); - let transport_event_tx_for_connection = transport_event_tx.clone(); - tokio::spawn(async move { - run_websocket_connection( - connection_id, - stream, - transport_event_tx_for_connection, - ) - .await; - }); - } - Err(err) => { - error!("failed to accept websocket connection: {err}"); - } - } - } - } + if let Err(err) = server.await { + error!("websocket acceptor failed: {err}"); } + info!("websocket acceptor shutting down"); })) } async fn run_websocket_connection( connection_id: ConnectionId, - stream: TcpStream, + websocket_stream: WebSocket, transport_event_tx: mpsc::Sender, ) { - let websocket_stream = - match accept_async_with_config(stream, Some(WebSocketConfig::default())).await { - Ok(stream) => stream, - Err(err) => { - warn!("failed to complete websocket handshake: {err}"); - return; - } - }; - let (writer_tx, writer_rx) = mpsc::channel::(CHANNEL_CAPACITY); let writer_tx_for_reader = writer_tx.clone(); let disconnect_token = CancellationToken::new(); @@ -377,10 +391,7 @@ async fn run_websocket_connection( } async fn run_websocket_outbound_loop( - mut websocket_writer: futures::stream::SplitSink< - tokio_tungstenite::WebSocketStream, - WebSocketMessage, - >, + mut websocket_writer: futures::stream::SplitSink, mut writer_rx: mpsc::Receiver, mut writer_control_rx: mpsc::Receiver, disconnect_token: CancellationToken, @@ -414,9 +425,7 @@ async fn run_websocket_outbound_loop( } async fn run_websocket_inbound_loop( - mut websocket_reader: futures::stream::SplitStream< - tokio_tungstenite::WebSocketStream, - >, + mut websocket_reader: futures::stream::SplitStream, transport_event_tx: mpsc::Sender, writer_tx_for_reader: mpsc::Sender, writer_control_tx: mpsc::Sender, @@ -435,7 +444,7 @@ async fn run_websocket_inbound_loop( &transport_event_tx, &writer_tx_for_reader, connection_id, - &text, + text.as_ref(), ) .await { @@ -457,7 +466,6 @@ async fn run_websocket_inbound_loop( Some(Ok(WebSocketMessage::Binary(_))) => { warn!("dropping unsupported binary websocket message"); } - Some(Ok(WebSocketMessage::Frame(_))) => {} Some(Err(err)) => { warn!("websocket receive error: {err}"); break; @@ -671,6 +679,7 @@ pub(crate) async fn route_outgoing_envelope( mod tests { use super::*; use crate::error_code::OVERLOADED_ERROR_CODE; + use codex_app_server_protocol::CommandExecutionRequestApprovalSkillMetadata; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use serde_json::json; @@ -991,6 +1000,9 @@ mod tests { macos: None, }, ), + skill_metadata: Some(CommandExecutionRequestApprovalSkillMetadata { + path_to_skills_md: PathBuf::from("/tmp/SKILLS.md"), + }), proposed_execpolicy_amendment: None, proposed_network_policy_amendments: None, available_decisions: None, @@ -1006,6 +1018,7 @@ mod tests { .expect("request should be delivered to the connection"); let json = serde_json::to_value(message).expect("request should serialize"); assert_eq!(json["params"].get("additionalPermissions"), None); + assert_eq!(json["params"].get("skillMetadata"), None); } #[tokio::test] @@ -1053,6 +1066,9 @@ mod tests { macos: None, }, ), + skill_metadata: Some(CommandExecutionRequestApprovalSkillMetadata { + path_to_skills_md: PathBuf::from("/tmp/SKILLS.md"), + }), proposed_execpolicy_amendment: None, proposed_network_policy_amendments: None, available_decisions: None, @@ -1079,6 +1095,12 @@ mod tests { "macos": null, }) ); + assert_eq!( + json["params"]["skillMetadata"], + json!({ + "pathToSkillsMd": "/tmp/SKILLS.md", + }) + ); } #[tokio::test] diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 4a2a99db23..74d1b47d40 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -29,6 +29,7 @@ pub use models_cache::write_models_cache_with_models; pub use responses::create_apply_patch_sse_response; pub use responses::create_exec_command_sse_response; pub use responses::create_final_assistant_message_sse_response; +pub use responses::create_request_permissions_sse_response; pub use responses::create_request_user_input_sse_response; pub use responses::create_shell_command_sse_response; pub use rollout::create_fake_rollout; diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index a52338b2a4..2398cd8fc1 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -41,6 +41,7 @@ use codex_app_server_protocol::MockExperimentalMethodParams; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::PluginInstallParams; use codex_app_server_protocol::PluginListParams; +use codex_app_server_protocol::PluginUninstallParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ReviewStartParams; use codex_app_server_protocol::ServerRequest; @@ -454,6 +455,15 @@ impl McpProcess { self.send_request("plugin/install", params).await } + /// Send a `plugin/uninstall` JSON-RPC request. + pub async fn send_plugin_uninstall_request( + &mut self, + params: PluginUninstallParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("plugin/uninstall", params).await + } + /// Send a `plugin/list` JSON-RPC request. pub async fn send_plugin_list_request( &mut self, diff --git a/codex-rs/app-server/tests/common/responses.rs b/codex-rs/app-server/tests/common/responses.rs index e15319e02f..586d1446c0 100644 --- a/codex-rs/app-server/tests/common/responses.rs +++ b/codex-rs/app-server/tests/common/responses.rs @@ -83,3 +83,23 @@ pub fn create_request_user_input_sse_response(call_id: &str) -> anyhow::Result anyhow::Result { + let tool_call_arguments = serde_json::to_string(&json!({ + "reason": "Select a workspace root", + "permissions": { + "file_system": { + "write": [ + ".", + "../shared" + ] + } + } + }))?; + + Ok(responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call(call_id, "request_permissions", &tool_call_arguments), + responses::ev_completed("resp-1"), + ])) +} diff --git a/codex-rs/app-server/tests/suite/auth.rs b/codex-rs/app-server/tests/suite/auth.rs index 2bbdeff400..68d0bcd95d 100644 --- a/codex-rs/app-server/tests/suite/auth.rs +++ b/codex-rs/app-server/tests/suite/auth.rs @@ -33,6 +33,9 @@ sandbox_mode = "danger-full-access" model_provider = "mock_provider" +[features] +shell_snapshot = false + [model_providers.mock_provider] name = "Mock provider for test" base_url = "http://127.0.0.1:0/v1" @@ -53,6 +56,9 @@ fn create_config_toml(codex_home: &Path) -> std::io::Result<()> { model = "mock-model" approval_policy = "never" sandbox_mode = "danger-full-access" + +[features] +shell_snapshot = false "#, ) } @@ -65,6 +71,9 @@ model = "mock-model" approval_policy = "never" sandbox_mode = "danger-full-access" forced_login_method = "{forced_method}" + +[features] +shell_snapshot = false "# ); std::fs::write(config_toml, contents) diff --git a/codex-rs/app-server/tests/suite/fuzzy_file_search.rs b/codex-rs/app-server/tests/suite/fuzzy_file_search.rs index 81b965319c..7341a5a5f7 100644 --- a/codex-rs/app-server/tests/suite/fuzzy_file_search.rs +++ b/codex-rs/app-server/tests/suite/fuzzy_file_search.rs @@ -7,6 +7,7 @@ use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use pretty_assertions::assert_eq; use serde_json::json; +use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; @@ -23,7 +24,23 @@ enum FileExpectation { NonEmpty, } +fn create_config_toml(codex_home: &Path) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "danger-full-access" + +[features] +shell_snapshot = false +"#, + ) +} + async fn initialized_mcp(codex_home: &TempDir) -> Result { + create_config_toml(codex_home.path())?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; Ok(mcp) @@ -164,6 +181,7 @@ async fn assert_no_session_updates_for( async fn test_fuzzy_file_search_sorts_and_includes_indices() -> Result<()> { // Prepare a temporary Codex home and a separate root with test files. let codex_home = TempDir::new()?; + create_config_toml(codex_home.path())?; let root = TempDir::new()?; // Create files designed to have deterministic ordering for query "abe". @@ -235,6 +253,7 @@ async fn test_fuzzy_file_search_sorts_and_includes_indices() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_fuzzy_file_search_accepts_cancellation_token() -> Result<()> { let codex_home = TempDir::new()?; + create_config_toml(codex_home.path())?; let root = TempDir::new()?; std::fs::write(root.path().join("alpha.txt"), "contents")?; @@ -434,7 +453,7 @@ async fn test_fuzzy_file_search_session_update_after_stop_fails() -> Result<()> async fn test_fuzzy_file_search_session_stops_sending_updates_after_stop() -> Result<()> { let codex_home = TempDir::new()?; let root = TempDir::new()?; - for i in 0..2_000 { + for i in 0..512 { let file_path = root.path().join(format!("file-{i:04}.txt")); std::fs::write(file_path, "contents")?; } diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index 48ede78f40..514a6542a0 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -83,6 +83,9 @@ sandbox_mode = "danger-full-access" model_provider = "mock_provider" +[features] +shell_snapshot = false + [model_providers.mock_provider] name = "Mock provider for test" base_url = "{base_url}" diff --git a/codex-rs/app-server/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index 638a020a8c..9bd654349c 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -501,16 +501,6 @@ async fn list_apps_waits_for_accessible_data_before_emitting_directory_updates() }) .await?; - let maybe_update = timeout( - Duration::from_millis(150), - read_app_list_updated_notification(&mut mcp), - ) - .await; - assert!( - maybe_update.is_err(), - "unexpected directory-only app/list update before accessible apps loaded" - ); - let expected = vec![ AppInfo { id: "beta".to_string(), @@ -544,8 +534,17 @@ async fn list_apps_waits_for_accessible_data_before_emitting_directory_updates() }, ]; - let update = read_app_list_updated_notification(&mut mcp).await?; - assert_eq!(update.data, expected); + loop { + let update = read_app_list_updated_notification(&mut mcp).await?; + if update.data == expected { + break; + } + + assert!( + !update.data.is_empty() && update.data.iter().all(|connector| connector.is_accessible), + "unexpected directory-only app/list update before accessible apps loaded" + ); + } let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index 1b73589a6b..97f1e8dfd0 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -605,6 +605,7 @@ async fn config_batch_write_applies_multiple_edits() -> Result<()> { }, ], expected_version: None, + reload_user_config: false, }) .await?; let batch_resp: JSONRPCResponse = timeout( diff --git a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs index 349e92df3c..478281522a 100644 --- a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs +++ b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs @@ -12,6 +12,7 @@ use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use futures::SinkExt; use futures::StreamExt; +use reqwest::StatusCode; use serde_json::json; use std::net::SocketAddr; use std::path::Path; @@ -79,6 +80,34 @@ async fn websocket_transport_routes_per_connection_handshake_and_responses() -> Ok(()) } +#[tokio::test] +async fn websocket_transport_serves_health_endpoints_on_same_listener() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + + let bind_addr = reserve_local_addr()?; + let mut process = spawn_websocket_server(codex_home.path(), bind_addr).await?; + let client = reqwest::Client::new(); + + let readyz = http_get(&client, bind_addr, "/readyz").await?; + assert_eq!(readyz.status(), StatusCode::OK); + + let healthz = http_get(&client, bind_addr, "/healthz").await?; + assert_eq!(healthz.status(), StatusCode::OK); + + let mut ws = connect_websocket(bind_addr).await?; + send_initialize_request(&mut ws, 1, "ws_health_client").await?; + let init = read_response_for_id(&mut ws, 1).await?; + assert_eq!(init.id, RequestId::Integer(1)); + + process + .kill() + .await + .context("failed to stop websocket app-server process")?; + Ok(()) +} + pub(super) async fn spawn_websocket_server( codex_home: &Path, bind_addr: SocketAddr, @@ -133,6 +162,30 @@ pub(super) async fn connect_websocket(bind_addr: SocketAddr) -> Result } } +async fn http_get( + client: &reqwest::Client, + bind_addr: SocketAddr, + path: &str, +) -> Result { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + match client + .get(format!("http://{bind_addr}{path}")) + .send() + .await + .with_context(|| format!("failed to GET http://{bind_addr}{path}")) + { + Ok(response) => return Ok(response), + Err(err) => { + if Instant::now() >= deadline { + bail!("failed to GET http://{bind_addr}{path}: {err}"); + } + sleep(Duration::from_millis(50)).await; + } + } + } +} + pub(super) async fn send_initialize_request( stream: &mut WsClient, id: i64, diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 327b6a04d8..20e9758796 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -19,8 +19,10 @@ mod output_schema; mod plan_item; mod plugin_install; mod plugin_list; +mod plugin_uninstall; mod rate_limits; mod realtime_conversation; +mod request_permissions; mod request_user_input; mod review; mod safety_check_downgrade; diff --git a/codex-rs/app-server/tests/suite/v2/plan_item.rs b/codex-rs/app-server/tests/suite/v2/plan_item.rs index 881bbb54c8..58471f434f 100644 --- a/codex-rs/app-server/tests/suite/v2/plan_item.rs +++ b/codex-rs/app-server/tests/suite/v2/plan_item.rs @@ -1,7 +1,8 @@ use anyhow::Result; use anyhow::anyhow; +use anyhow::bail; use app_test_support::McpProcess; -use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::to_response; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; @@ -28,11 +29,13 @@ use pretty_assertions::assert_eq; use std::collections::BTreeMap; use std::path::Path; use tempfile::TempDir; +use tokio::time::sleep; use tokio::time::timeout; +use wiremock::MockServer; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); -#[tokio::test] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn plan_mode_uses_proposed_plan_block_for_plan_item() -> Result<()> { skip_if_no_network!(Ok(())); @@ -45,7 +48,7 @@ async fn plan_mode_uses_proposed_plan_block_for_plan_item() -> Result<()> { responses::ev_assistant_message("msg-1", &full_message), responses::ev_completed("resp-1"), ])]; - let server = create_mock_responses_server_sequence(responses).await; + let server = create_mock_responses_server_sequence_unchecked(responses).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; @@ -56,6 +59,7 @@ async fn plan_mode_uses_proposed_plan_block_for_plan_item() -> Result<()> { let turn = start_plan_mode_turn(&mut mcp).await?; let (_, completed_items, plan_deltas, turn_completed) = collect_turn_notifications(&mut mcp).await?; + wait_for_responses_request_count(&server, 1).await?; assert_eq!(turn_completed.turn.id, turn.id); assert_eq!(turn_completed.turn.status, TurnStatus::Completed); @@ -93,7 +97,7 @@ async fn plan_mode_uses_proposed_plan_block_for_plan_item() -> Result<()> { Ok(()) } -#[tokio::test] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn plan_mode_without_proposed_plan_does_not_emit_plan_item() -> Result<()> { skip_if_no_network!(Ok(())); @@ -102,7 +106,7 @@ async fn plan_mode_without_proposed_plan_does_not_emit_plan_item() -> Result<()> responses::ev_assistant_message("msg-1", "Done"), responses::ev_completed("resp-1"), ])]; - let server = create_mock_responses_server_sequence(responses).await; + let server = create_mock_responses_server_sequence_unchecked(responses).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; @@ -112,6 +116,7 @@ async fn plan_mode_without_proposed_plan_does_not_emit_plan_item() -> Result<()> let _turn = start_plan_mode_turn(&mut mcp).await?; let (_, completed_items, plan_deltas, _) = collect_turn_notifications(&mut mcp).await?; + wait_for_responses_request_count(&server, 1).await?; let has_plan_item = completed_items .iter() @@ -214,6 +219,36 @@ async fn collect_turn_notifications( } } +async fn wait_for_responses_request_count( + server: &MockServer, + expected_count: usize, +) -> Result<()> { + timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let Some(requests) = server.received_requests().await else { + bail!("wiremock did not record requests"); + }; + let responses_request_count = requests + .iter() + .filter(|request| { + request.method == "POST" && request.url.path().ends_with("/responses") + }) + .count(); + if responses_request_count == expected_count { + return Ok::<(), anyhow::Error>(()); + } + if responses_request_count > expected_count { + bail!( + "expected exactly {expected_count} /responses requests, got {responses_request_count}" + ); + } + sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await??; + Ok(()) +} + fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { let features = BTreeMap::from([(Feature::CollaborationModes, true)]); let feature_entries = features diff --git a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs new file mode 100644 index 0000000000..fb8cf582aa --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs @@ -0,0 +1,84 @@ +use std::time::Duration; + +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::PluginUninstallParams; +use codex_app_server_protocol::PluginUninstallResponse; +use codex_app_server_protocol::RequestId; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); + +#[tokio::test] +async fn plugin_uninstall_removes_plugin_cache_and_config_entry() -> Result<()> { + let codex_home = TempDir::new()?; + write_installed_plugin(&codex_home, "debug", "sample-plugin")?; + std::fs::write( + codex_home.path().join("config.toml"), + r#"[features] +plugins = true + +[plugins."sample-plugin@debug"] +enabled = true +"#, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let params = PluginUninstallParams { + plugin_id: "sample-plugin@debug".to_string(), + }; + + let request_id = mcp.send_plugin_uninstall_request(params.clone()).await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginUninstallResponse = to_response(response)?; + assert_eq!(response, PluginUninstallResponse {}); + + assert!( + !codex_home + .path() + .join("plugins/cache/debug/sample-plugin") + .exists() + ); + let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + assert!(!config.contains(r#"[plugins."sample-plugin@debug"]"#)); + + let request_id = mcp.send_plugin_uninstall_request(params).await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginUninstallResponse = to_response(response)?; + assert_eq!(response, PluginUninstallResponse {}); + + Ok(()) +} + +fn write_installed_plugin( + codex_home: &TempDir, + marketplace_name: &str, + plugin_name: &str, +) -> Result<()> { + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join(marketplace_name) + .join(plugin_name) + .join("local/.codex-plugin"); + std::fs::create_dir_all(&plugin_root)?; + std::fs::write( + plugin_root.join("plugin.json"), + format!(r#"{{"name":"{plugin_name}"}}"#), + )?; + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/request_permissions.rs b/codex-rs/app-server/tests/suite/v2/request_permissions.rs new file mode 100644 index 0000000000..561a0fc741 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/request_permissions.rs @@ -0,0 +1,157 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_final_assistant_message_sse_response; +use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_request_permissions_sse_response; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::PermissionGrantScope; +use codex_app_server_protocol::PermissionsRequestApprovalResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ServerRequestResolvedNotification; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput as V2UserInput; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn request_permissions_round_trip() -> Result<()> { + let codex_home = tempfile::TempDir::new()?; + let responses = vec![ + create_request_permissions_sse_response("call1")?, + create_final_assistant_message_sse_response("done")?, + ]; + let server = create_mock_responses_server_sequence(responses).await; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?; + + let turn_start_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "pick a directory".to_string(), + text_elements: Vec::new(), + }], + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let turn_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)), + ) + .await??; + let TurnStartResponse { turn, .. } = to_response(turn_start_resp)?; + + let server_req = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::PermissionsRequestApproval { request_id, params } = server_req else { + panic!("expected PermissionsRequestApproval request, got: {server_req:?}"); + }; + + assert_eq!(params.thread_id, thread.id); + assert_eq!(params.turn_id, turn.id); + assert_eq!(params.item_id, "call1"); + assert_eq!(params.reason, Some("Select a workspace root".to_string())); + let requested_writes = params + .permissions + .file_system + .and_then(|file_system| file_system.write) + .expect("request should include write permissions"); + assert_eq!(requested_writes.len(), 2); + let resolved_request_id = request_id.clone(); + + mcp.send_response( + request_id, + serde_json::to_value(PermissionsRequestApprovalResponse { + permissions: codex_app_server_protocol::GrantedPermissionProfile { + network: None, + file_system: Some(codex_app_server_protocol::AdditionalFileSystemPermissions { + read: None, + write: Some(vec![requested_writes[0].clone()]), + }), + macos: None, + }, + scope: PermissionGrantScope::Turn, + })?, + ) + .await?; + + let mut saw_resolved = false; + loop { + let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??; + let JSONRPCMessage::Notification(notification) = message else { + continue; + }; + match notification.method.as_str() { + "serverRequest/resolved" => { + let resolved: ServerRequestResolvedNotification = serde_json::from_value( + notification + .params + .clone() + .expect("serverRequest/resolved params"), + )?; + assert_eq!(resolved.thread_id, thread.id); + assert_eq!(resolved.request_id, resolved_request_id); + saw_resolved = true; + } + "turn/completed" => { + assert!(saw_resolved, "serverRequest/resolved should arrive first"); + break; + } + _ => {} + } + } + + Ok(()) +} + +fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "untrusted" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 + +[features] +request_permissions_tool = true +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index e5ed8f57cd..7a3c04f572 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -4,7 +4,6 @@ use app_test_support::create_apply_patch_sse_response; use app_test_support::create_fake_rollout_with_text_elements; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_repeating_assistant; -use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::rollout_path; @@ -60,6 +59,36 @@ use uuid::Uuid; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); const CODEX_5_2_INSTRUCTIONS_TEMPLATE_DEFAULT: &str = "You are Codex, a coding agent based on GPT-5. You and the user share the same workspace and collaborate to achieve the user's goals."; +async fn wait_for_responses_request_count( + server: &wiremock::MockServer, + expected_count: usize, +) -> Result<()> { + timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let Some(requests) = server.received_requests().await else { + anyhow::bail!("wiremock did not record requests"); + }; + let responses_request_count = requests + .iter() + .filter(|request| { + request.method == "POST" && request.url.path().ends_with("/responses") + }) + .count(); + if responses_request_count == expected_count { + return Ok::<(), anyhow::Error>(()); + } + if responses_request_count > expected_count { + anyhow::bail!( + "expected exactly {expected_count} /responses requests, got {responses_request_count}" + ); + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await??; + Ok(()) +} + #[tokio::test] async fn thread_resume_rejects_unmaterialized_thread() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -982,6 +1011,7 @@ async fn thread_resume_replays_pending_command_execution_request_approval() -> R primary.read_stream_until_notification_message("turn/completed"), ) .await??; + wait_for_responses_request_count(&server, 3).await?; Ok(()) } @@ -1004,7 +1034,7 @@ async fn thread_resume_replays_pending_file_change_request_approval() -> Result< create_apply_patch_sse_response(patch, "patch-call")?, create_final_assistant_message_sse_response("done")?, ]; - let server = create_mock_responses_server_sequence(responses).await; + let server = create_mock_responses_server_sequence_unchecked(responses).await; create_config_toml(&codex_home, &server.uri())?; let mut primary = McpProcess::new(&codex_home).await?; @@ -1147,6 +1177,7 @@ async fn thread_resume_replays_pending_file_change_request_approval() -> Result< primary.read_stream_until_notification_message("turn/completed"), ) .await??; + wait_for_responses_request_count(&server, 3).await?; Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs index 214cbbd73a..8fd1bb6e50 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs @@ -54,6 +54,7 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> { std::fs::create_dir(&codex_home)?; let workspace = tmp.path().join("workspace"); std::fs::create_dir(&workspace)?; + let release_marker = workspace.join("interrupt-release"); let Some(zsh_path) = find_test_zsh_path()? else { eprintln!("skipping zsh fork test: no zsh executable found"); @@ -61,8 +62,15 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> { }; eprintln!("using zsh path for zsh-fork test: {}", zsh_path.display()); + // Keep the shell command in flight until we interrupt it. A fast command + // like `echo hi` can finish before the interrupt arrives on faster runners, + // which turns this into a test for post-command follow-up behavior instead + // of interrupting an active zsh-fork command. + let release_marker_escaped = release_marker.to_string_lossy().replace('\'', r#"'\''"#); + let wait_for_interrupt = + format!("while [ ! -f '{release_marker_escaped}' ]; do sleep 0.01; done"); let response = create_shell_command_sse_response( - vec!["echo".to_string(), "hi".to_string()], + vec!["/bin/sh".to_string(), "-c".to_string(), wait_for_interrupt], None, Some(5000), "call-zsh-fork", @@ -155,7 +163,9 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> { assert_eq!(id, "call-zsh-fork"); assert_eq!(status, CommandExecutionStatus::InProgress); assert!(command.starts_with(&zsh_path.display().to_string())); - assert!(command.contains(" -lc 'echo hi'")); + assert!(command.contains("/bin/sh -c")); + assert!(command.contains("sleep 0.01")); + assert!(command.contains(&release_marker.display().to_string())); assert_eq!(cwd, workspace); mcp.interrupt_turn_and_wait_for_aborted(thread.id, turn.id, DEFAULT_READ_TIMEOUT) @@ -663,22 +673,50 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() }; assert_eq!(id, "call-zsh-fork-subcommand-decline"); assert_eq!(status, CommandExecutionStatus::Declined); - assert!( - aggregated_output.is_none() - || aggregated_output == Some("exec command rejected by user".to_string()) - ); + if let Some(output) = aggregated_output.as_deref() { + assert!( + output == "exec command rejected by user" + || output.contains("sandbox denied exec error"), + "unexpected aggregated output: {output}" + ); + } - mcp.interrupt_turn_and_wait_for_aborted( - thread.id.clone(), - turn.id.clone(), + match timeout( DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), ) - .await?; + .await + { + Ok(Ok(completed_notif)) => { + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.thread_id, thread.id); + assert_eq!(completed.turn.id, turn.id); + assert!(matches!( + completed.turn.status, + TurnStatus::Interrupted | TurnStatus::Completed + )); + } + Ok(Err(error)) => return Err(error), + Err(_) => { + mcp.interrupt_turn_and_wait_for_aborted( + thread.id.clone(), + turn.id.clone(), + DEFAULT_READ_TIMEOUT, + ) + .await?; + } + } } Ok(Err(error)) => return Err(error), Err(_) => { // Some zsh builds abort the turn immediately after the rejected - // subcommand without emitting a parent `item/completed`. + // subcommand without emitting a parent `item/completed`, and Linux + // sandbox failures can also complete the turn before the parent + // completion item is observed. let completed_notif = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_notification_message("turn/completed"), @@ -691,7 +729,10 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() )?; assert_eq!(completed.thread_id, thread.id); assert_eq!(completed.turn.id, turn.id); - assert_eq!(completed.turn.status, TurnStatus::Interrupted); + assert!(matches!( + completed.turn.status, + TurnStatus::Interrupted | TurnStatus::Completed + )); } } diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 40bc276f01..c5a0e637ea 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -10,6 +10,7 @@ use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::protocol::CreditsSnapshot; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow; +use reqwest::StatusCode; use reqwest::header::AUTHORIZATION; use reqwest::header::CONTENT_TYPE; use reqwest::header::HeaderMap; @@ -17,6 +18,65 @@ use reqwest::header::HeaderName; use reqwest::header::HeaderValue; use reqwest::header::USER_AGENT; use serde::de::DeserializeOwned; +use std::fmt; + +#[derive(Debug)] +pub enum RequestError { + UnexpectedStatus { + method: String, + url: String, + status: StatusCode, + content_type: String, + body: String, + }, + Other(anyhow::Error), +} + +impl RequestError { + pub fn status(&self) -> Option { + match self { + Self::UnexpectedStatus { status, .. } => Some(*status), + Self::Other(_) => None, + } + } + + pub fn is_unauthorized(&self) -> bool { + self.status() == Some(StatusCode::UNAUTHORIZED) + } +} + +impl fmt::Display for RequestError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnexpectedStatus { + method, + url, + status, + content_type, + body, + } => write!( + f, + "{method} {url} failed: {status}; content-type={content_type}; body={body}" + ), + Self::Other(err) => write!(f, "{err}"), + } + } +} + +impl std::error::Error for RequestError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::UnexpectedStatus { .. } => None, + Self::Other(err) => Some(err.as_ref()), + } + } +} + +impl From for RequestError { + fn from(err: anyhow::Error) -> Self { + Self::Other(err) + } +} #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PathStyle { @@ -148,6 +208,33 @@ impl Client { Ok((body, ct)) } + async fn exec_request_detailed( + &self, + req: reqwest::RequestBuilder, + method: &str, + url: &str, + ) -> std::result::Result<(String, String), RequestError> { + let res = req.send().await.map_err(anyhow::Error::from)?; + let status = res.status(); + let content_type = res + .headers() + .get(CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let body = res.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(RequestError::UnexpectedStatus { + method: method.to_string(), + url: url.to_string(), + status, + content_type, + body, + }); + } + Ok((body, content_type)) + } + fn decode_json(&self, url: &str, ct: &str, body: &str) -> Result { match serde_json::from_str::(body) { Ok(v) => Ok(v), @@ -256,14 +343,17 @@ impl Client { /// /// `GET /api/codex/config/requirements` (Codex API style) or /// `GET /wham/config/requirements` (ChatGPT backend-api style). - pub async fn get_config_requirements_file(&self) -> Result { + pub async fn get_config_requirements_file( + &self, + ) -> std::result::Result { let url = match self.path_style { PathStyle::CodexApi => format!("{}/api/codex/config/requirements", self.base_url), PathStyle::ChatGptApi => format!("{}/wham/config/requirements", self.base_url), }; let req = self.http.get(&url).headers(self.headers()); - let (body, ct) = self.exec_request(req, "GET", &url).await?; + let (body, ct) = self.exec_request_detailed(req, "GET", &url).await?; self.decode_json::(&url, &ct, &body) + .map_err(RequestError::from) } /// Create a new task (user turn) by POSTing to the appropriate backend path diff --git a/codex-rs/backend-client/src/lib.rs b/codex-rs/backend-client/src/lib.rs index de827e9a97..a3b2f37608 100644 --- a/codex-rs/backend-client/src/lib.rs +++ b/codex-rs/backend-client/src/lib.rs @@ -2,6 +2,7 @@ mod client; pub mod types; pub use client::Client; +pub use client::RequestError; pub use types::CodeTaskDetailsResponse; pub use types::CodeTaskDetailsResponseExt; pub use types::ConfigFileResponse; diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs index 9e4a8b002d..b71a1af51c 100644 --- a/codex-rs/cloud-requirements/src/lib.rs +++ b/codex-rs/cloud-requirements/src/lib.rs @@ -17,6 +17,7 @@ use chrono::Utc; use codex_backend_client::Client as BackendClient; use codex_core::AuthManager; use codex_core::auth::CodexAuth; +use codex_core::auth::RefreshTokenError; use codex_core::config_loader::CloudRequirementsLoadError; use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::ConfigRequirementsToml; @@ -44,6 +45,7 @@ const CLOUD_REQUIREMENTS_MAX_ATTEMPTS: usize = 5; const CLOUD_REQUIREMENTS_CACHE_FILENAME: &str = "cloud-requirements-cache.json"; const CLOUD_REQUIREMENTS_CACHE_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60); const CLOUD_REQUIREMENTS_CACHE_TTL: Duration = Duration::from_secs(30 * 60); +const CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE: &str = "failed to load your workspace-managed config"; const CLOUD_REQUIREMENTS_CACHE_WRITE_HMAC_KEY: &[u8] = b"codex-cloud-requirements-cache-v3-064f8542-75b4-494c-a294-97d3ce597271"; const CLOUD_REQUIREMENTS_CACHE_READ_HMAC_KEYS: &[&[u8]] = @@ -62,6 +64,12 @@ enum FetchCloudRequirementsStatus { Request, } +#[derive(Clone, Debug, Eq, PartialEq)] +enum FetchCloudRequirementsError { + Retryable(FetchCloudRequirementsStatus), + Unauthorized(CloudRequirementsLoadError), +} + #[derive(Clone, Debug, Eq, Error, PartialEq)] enum CacheLoadStatus { #[error("Skipping cloud requirements cache read because auth identity is incomplete.")] @@ -141,6 +149,16 @@ fn verify_cache_signature(payload_bytes: &[u8], signature: &str) -> bool { .any(|key| verify_cache_signature_with_key(payload_bytes, &signature_bytes, key)) } +fn auth_identity(auth: &CodexAuth) -> (Option, Option) { + let token_data = auth.get_token_data().ok(); + let chatgpt_user_id = token_data + .as_ref() + .and_then(|token_data| token_data.id_token.chatgpt_user_id.as_deref()) + .map(str::to_owned); + let account_id = auth.get_account_id(); + (chatgpt_user_id, account_id) +} + fn cache_payload_bytes(payload: &CloudRequirementsCacheSignedPayload) -> Option> { serde_json::to_vec(&payload).ok() } @@ -153,7 +171,7 @@ trait RequirementsFetcher: Send + Sync { async fn fetch_requirements( &self, auth: &CodexAuth, - ) -> Result, FetchCloudRequirementsStatus>; + ) -> Result, FetchCloudRequirementsError>; } struct BackendRequirementsFetcher { @@ -171,7 +189,7 @@ impl RequirementsFetcher for BackendRequirementsFetcher { async fn fetch_requirements( &self, auth: &CodexAuth, - ) -> Result, FetchCloudRequirementsStatus> { + ) -> Result, FetchCloudRequirementsError> { let client = BackendClient::from_auth(self.base_url.clone(), auth) .inspect_err(|err| { tracing::warn!( @@ -179,13 +197,25 @@ impl RequirementsFetcher for BackendRequirementsFetcher { "Failed to construct backend client for cloud requirements" ); }) - .map_err(|_| FetchCloudRequirementsStatus::BackendClientInit)?; + .map_err(|_| { + FetchCloudRequirementsError::Retryable( + FetchCloudRequirementsStatus::BackendClientInit, + ) + })?; let response = client .get_config_requirements_file() .await .inspect_err(|err| tracing::warn!(error = %err, "Failed to fetch cloud requirements")) - .map_err(|_| FetchCloudRequirementsStatus::Request)?; + .map_err(|err| { + if err.is_unauthorized() { + FetchCloudRequirementsError::Unauthorized(CloudRequirementsLoadError::new( + err.to_string(), + )) + } else { + FetchCloudRequirementsError::Retryable(FetchCloudRequirementsStatus::Request) + } + })?; let Some(contents) = response.contents else { tracing::info!( @@ -281,14 +311,12 @@ impl CloudRequirementsService { { return Ok(None); } - let token_data = auth.get_token_data().ok(); - let chatgpt_user_id = token_data - .as_ref() - .and_then(|token_data| token_data.id_token.chatgpt_user_id.as_deref()); - let account_id = auth.get_account_id(); - let account_id = account_id.as_deref(); + let (chatgpt_user_id, account_id) = auth_identity(&auth); - match self.load_cache(chatgpt_user_id, account_id).await { + match self + .load_cache(chatgpt_user_id.as_deref(), account_id.as_deref()) + .await + { Ok(signed_payload) => { tracing::info!( path = %self.cache_path.display(), @@ -301,28 +329,20 @@ impl CloudRequirementsService { } } - self.fetch_with_retries(&auth, chatgpt_user_id, account_id) - .await - .ok_or_else(|| { - let message = "failed to load your workspace-managed config"; - tracing::error!( - path = %self.cache_path.display(), - "{message}" - ); - CloudRequirementsLoadError::new(message) - }) + self.fetch_with_retries(auth).await } async fn fetch_with_retries( &self, - auth: &CodexAuth, - chatgpt_user_id: Option<&str>, - account_id: Option<&str>, - ) -> Option> { - for attempt in 1..=CLOUD_REQUIREMENTS_MAX_ATTEMPTS { - let contents = match self.fetcher.fetch_requirements(auth).await { + mut auth: CodexAuth, + ) -> Result, CloudRequirementsLoadError> { + let mut attempt = 1; + let mut auth_recovery = self.auth_manager.unauthorized_recovery(); + + while attempt <= CLOUD_REQUIREMENTS_MAX_ATTEMPTS { + let contents = match self.fetcher.fetch_requirements(&auth).await { Ok(contents) => contents, - Err(status) => { + Err(FetchCloudRequirementsError::Retryable(status)) => { if attempt < CLOUD_REQUIREMENTS_MAX_ATTEMPTS { tracing::warn!( status = ?status, @@ -332,8 +352,60 @@ impl CloudRequirementsService { ); sleep(backoff(attempt as u64)).await; } + attempt += 1; continue; } + Err(FetchCloudRequirementsError::Unauthorized(err)) => { + if auth_recovery.has_next() { + tracing::warn!( + attempt, + max_attempts = CLOUD_REQUIREMENTS_MAX_ATTEMPTS, + "Cloud requirements request was unauthorized; attempting auth recovery" + ); + match auth_recovery.next().await { + Ok(()) => { + let Some(refreshed_auth) = self.auth_manager.auth().await else { + tracing::error!( + "Auth recovery succeeded but no auth is available for cloud requirements" + ); + return Err(CloudRequirementsLoadError::new( + CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE, + )); + }; + auth = refreshed_auth; + continue; + } + Err(RefreshTokenError::Permanent(failed)) => { + tracing::warn!( + error = %failed, + "Failed to recover from unauthorized cloud requirements request" + ); + return Err(CloudRequirementsLoadError::new(failed.message)); + } + Err(RefreshTokenError::Transient(recovery_err)) => { + if attempt < CLOUD_REQUIREMENTS_MAX_ATTEMPTS { + tracing::warn!( + error = %recovery_err, + attempt, + max_attempts = CLOUD_REQUIREMENTS_MAX_ATTEMPTS, + "Failed to recover from unauthorized cloud requirements request; retrying" + ); + sleep(backoff(attempt as u64)).await; + } + attempt += 1; + continue; + } + } + } + + tracing::warn!( + error = %err, + "Cloud requirements request was unauthorized and no auth recovery is available" + ); + return Err(CloudRequirementsLoadError::new( + CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE, + )); + } }; let requirements = match contents.as_deref() { @@ -341,27 +413,29 @@ impl CloudRequirementsService { Ok(requirements) => requirements, Err(err) => { tracing::error!(error = %err, "Failed to parse cloud requirements"); - return None; + return Err(CloudRequirementsLoadError::new( + CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE, + )); } }, None => None, }; - if let Err(err) = self - .save_cache( - chatgpt_user_id.map(str::to_owned), - account_id.map(str::to_owned), - contents, - ) - .await - { + let (chatgpt_user_id, account_id) = auth_identity(&auth); + if let Err(err) = self.save_cache(chatgpt_user_id, account_id, contents).await { tracing::warn!(error = %err, "Failed to write cloud requirements cache"); } - return Some(requirements); + return Ok(requirements); } - None + tracing::error!( + path = %self.cache_path.display(), + "{CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE}" + ); + Err(CloudRequirementsLoadError::new( + CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE, + )) } async fn refresh_cache_in_background(&self) { @@ -392,20 +466,10 @@ impl CloudRequirementsService { return false; } - let token_data = auth.get_token_data().ok(); - let chatgpt_user_id = token_data - .as_ref() - .and_then(|token_data| token_data.id_token.chatgpt_user_id.as_deref()); - let account_id = auth.get_account_id(); - let account_id = account_id.as_deref(); - - if self - .fetch_with_retries(&auth, chatgpt_user_id, account_id) - .await - .is_none() - { + if let Err(err) = self.fetch_with_retries(auth).await { tracing::error!( path = %self.cache_path.display(), + error = %err, "Failed to refresh cloud requirements cache from remote" ); if let Some(metrics) = codex_otel::metrics::global() { @@ -594,6 +658,7 @@ mod tests { use std::path::Path; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; + use tempfile::TempDir; use tempfile::tempdir; fn write_auth_json(codex_home: &Path, value: serde_json::Value) -> std::io::Result<()> { @@ -622,6 +687,49 @@ mod tests { account_id: Option<&str>, ) -> Arc { let tmp = tempdir().expect("tempdir"); + write_auth_json( + tmp.path(), + chatgpt_auth_json( + plan_type, + chatgpt_user_id, + account_id, + "test-access-token", + "test-refresh-token", + ), + ) + .expect("write auth"); + Arc::new(AuthManager::new( + tmp.path().to_path_buf(), + false, + AuthCredentialsStoreMode::File, + )) + } + + fn chatgpt_auth_json( + plan_type: &str, + chatgpt_user_id: Option<&str>, + account_id: Option<&str>, + access_token: &str, + refresh_token: &str, + ) -> serde_json::Value { + chatgpt_auth_json_with_mode( + plan_type, + chatgpt_user_id, + account_id, + access_token, + refresh_token, + None, + ) + } + + fn chatgpt_auth_json_with_mode( + plan_type: &str, + chatgpt_user_id: Option<&str>, + account_id: Option<&str>, + access_token: &str, + refresh_token: &str, + auth_mode: Option<&str>, + ) -> serde_json::Value { let header = json!({ "alg": "none", "typ": "JWT" }); let auth_payload = json!({ "chatgpt_plan_type": plan_type, @@ -637,22 +745,54 @@ mod tests { let signature_b64 = URL_SAFE_NO_PAD.encode(b"sig"); let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); - let auth_json = json!({ + let mut auth_json = json!({ "OPENAI_API_KEY": null, "tokens": { "id_token": fake_jwt, - "access_token": "test-access-token", - "refresh_token": "test-refresh-token", + "access_token": access_token, + "refresh_token": refresh_token, "account_id": account_id, }, "last_refresh": "2025-01-01T00:00:00Z", }); - write_auth_json(tmp.path(), auth_json).expect("write auth"); - Arc::new(AuthManager::new( - tmp.path().to_path_buf(), - false, - AuthCredentialsStoreMode::File, - )) + if let Some(auth_mode) = auth_mode { + auth_json["auth_mode"] = serde_json::Value::String(auth_mode.to_string()); + } + auth_json + } + + struct ManagedAuthContext { + _home: TempDir, + manager: Arc, + } + + fn managed_auth_context( + plan_type: &str, + chatgpt_user_id: Option<&str>, + account_id: Option<&str>, + access_token: &str, + refresh_token: &str, + ) -> ManagedAuthContext { + let home = tempdir().expect("tempdir"); + write_auth_json( + home.path(), + chatgpt_auth_json( + plan_type, + chatgpt_user_id, + account_id, + access_token, + refresh_token, + ), + ) + .expect("write auth"); + ManagedAuthContext { + manager: Arc::new(AuthManager::new( + home.path().to_path_buf(), + false, + AuthCredentialsStoreMode::File, + )), + _home: home, + } } fn auth_manager_with_plan(plan_type: &str) -> Arc { @@ -663,6 +803,10 @@ mod tests { contents.and_then(|contents| parse_cloud_requirements(contents).ok().flatten()) } + fn request_error() -> FetchCloudRequirementsError { + FetchCloudRequirementsError::Retryable(FetchCloudRequirementsStatus::Request) + } + struct StaticFetcher { contents: Option, } @@ -672,7 +816,7 @@ mod tests { async fn fetch_requirements( &self, _auth: &CodexAuth, - ) -> Result, FetchCloudRequirementsStatus> { + ) -> Result, FetchCloudRequirementsError> { Ok(self.contents.clone()) } } @@ -684,7 +828,7 @@ mod tests { async fn fetch_requirements( &self, _auth: &CodexAuth, - ) -> Result, FetchCloudRequirementsStatus> { + ) -> Result, FetchCloudRequirementsError> { pending::<()>().await; Ok(None) } @@ -692,12 +836,12 @@ mod tests { struct SequenceFetcher { responses: - tokio::sync::Mutex, FetchCloudRequirementsStatus>>>, + tokio::sync::Mutex, FetchCloudRequirementsError>>>, request_count: AtomicUsize, } impl SequenceFetcher { - fn new(responses: Vec, FetchCloudRequirementsStatus>>) -> Self { + fn new(responses: Vec, FetchCloudRequirementsError>>) -> Self { Self { responses: tokio::sync::Mutex::new(VecDeque::from(responses)), request_count: AtomicUsize::new(0), @@ -710,13 +854,57 @@ mod tests { async fn fetch_requirements( &self, _auth: &CodexAuth, - ) -> Result, FetchCloudRequirementsStatus> { + ) -> Result, FetchCloudRequirementsError> { self.request_count.fetch_add(1, Ordering::SeqCst); let mut responses = self.responses.lock().await; responses.pop_front().unwrap_or(Ok(None)) } } + struct TokenFetcher { + expected_token: String, + contents: String, + request_count: AtomicUsize, + } + + #[async_trait::async_trait] + impl RequirementsFetcher for TokenFetcher { + async fn fetch_requirements( + &self, + auth: &CodexAuth, + ) -> Result, FetchCloudRequirementsError> { + self.request_count.fetch_add(1, Ordering::SeqCst); + if matches!( + auth.get_token().as_deref(), + Ok(token) if token == self.expected_token.as_str() + ) { + Ok(Some(self.contents.clone())) + } else { + Err(FetchCloudRequirementsError::Unauthorized( + CloudRequirementsLoadError::new("GET /config/requirements failed: 401"), + )) + } + } + } + + struct UnauthorizedFetcher { + message: String, + request_count: AtomicUsize, + } + + #[async_trait::async_trait] + impl RequirementsFetcher for UnauthorizedFetcher { + async fn fetch_requirements( + &self, + _auth: &CodexAuth, + ) -> Result, FetchCloudRequirementsError> { + self.request_count.fetch_add(1, Ordering::SeqCst); + Err(FetchCloudRequirementsError::Unauthorized( + CloudRequirementsLoadError::new(self.message.clone()), + )) + } + } + #[tokio::test] async fn fetch_cloud_requirements_skips_non_chatgpt_auth() { let auth_manager = auth_manager_with_api_key(); @@ -837,7 +1025,7 @@ mod tests { #[tokio::test(start_paused = true)] async fn fetch_cloud_requirements_retries_until_success() { let fetcher = Arc::new(SequenceFetcher::new(vec![ - Err(FetchCloudRequirementsStatus::Request), + Err(request_error()), Ok(Some("allowed_approval_policies = [\"never\"]".to_string())), ])); let codex_home = tempdir().expect("tempdir"); @@ -868,6 +1056,206 @@ mod tests { assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2); } + #[tokio::test] + async fn fetch_cloud_requirements_recovers_after_unauthorized_reload() { + let auth = managed_auth_context( + "business", + Some("user-12345"), + Some("account-12345"), + "stale-access-token", + "test-refresh-token", + ); + write_auth_json( + auth._home.path(), + chatgpt_auth_json( + "business", + Some("user-12345"), + Some("account-12345"), + "fresh-access-token", + "test-refresh-token", + ), + ) + .expect("write refreshed auth"); + + let fetcher = Arc::new(TokenFetcher { + expected_token: "fresh-access-token".to_string(), + contents: "allowed_approval_policies = [\"never\"]".to_string(), + request_count: AtomicUsize::new(0), + }); + let codex_home = tempdir().expect("tempdir"); + let service = CloudRequirementsService::new( + Arc::clone(&auth.manager), + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + + assert_eq!( + service.fetch().await, + Ok(Some(ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_sandbox_modes: None, + allowed_web_search_modes: None, + feature_requirements: None, + mcp_servers: None, + rules: None, + enforce_residency: None, + network: None, + })) + ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn fetch_cloud_requirements_recovers_after_unauthorized_reload_updates_cache_identity() { + let auth = managed_auth_context( + "business", + Some("user-12345"), + Some("account-12345"), + "stale-access-token", + "test-refresh-token", + ); + write_auth_json( + auth._home.path(), + chatgpt_auth_json( + "business", + Some("user-99999"), + Some("account-12345"), + "fresh-access-token", + "test-refresh-token", + ), + ) + .expect("write refreshed auth"); + + let fetcher = Arc::new(TokenFetcher { + expected_token: "fresh-access-token".to_string(), + contents: "allowed_approval_policies = [\"never\"]".to_string(), + request_count: AtomicUsize::new(0), + }); + let codex_home = tempdir().expect("tempdir"); + let service = CloudRequirementsService::new( + Arc::clone(&auth.manager), + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + + assert_eq!( + service.fetch().await, + Ok(Some(ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_sandbox_modes: None, + allowed_web_search_modes: None, + feature_requirements: None, + mcp_servers: None, + rules: None, + enforce_residency: None, + network: None, + })) + ); + + let path = codex_home.path().join(CLOUD_REQUIREMENTS_CACHE_FILENAME); + let cache_file: CloudRequirementsCacheFile = + serde_json::from_str(&std::fs::read_to_string(path).expect("read cache")) + .expect("parse cache"); + assert_eq!( + cache_file.signed_payload.chatgpt_user_id, + Some("user-99999".to_string()) + ); + assert_eq!( + cache_file.signed_payload.account_id, + Some("account-12345".to_string()) + ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn fetch_cloud_requirements_surfaces_auth_recovery_message() { + let auth = managed_auth_context( + "enterprise", + Some("user-12345"), + Some("account-12345"), + "stale-access-token", + "test-refresh-token", + ); + write_auth_json( + auth._home.path(), + chatgpt_auth_json( + "enterprise", + Some("user-12345"), + Some("account-99999"), + "fresh-access-token", + "test-refresh-token", + ), + ) + .expect("write mismatched auth"); + + let fetcher = Arc::new(UnauthorizedFetcher { + message: "GET /config/requirements failed: 401".to_string(), + request_count: AtomicUsize::new(0), + }); + let codex_home = tempdir().expect("tempdir"); + let service = CloudRequirementsService::new( + Arc::clone(&auth.manager), + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + + let err = service + .fetch() + .await + .expect_err("cloud requirements should surface auth recovery errors"); + assert_eq!( + err.to_string(), + "Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again." + ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn fetch_cloud_requirements_unauthorized_without_recovery_uses_generic_message() { + let auth_home = tempdir().expect("tempdir"); + write_auth_json( + auth_home.path(), + chatgpt_auth_json_with_mode( + "enterprise", + Some("user-12345"), + Some("account-12345"), + "test-access-token", + "test-refresh-token", + Some("chatgptAuthTokens"), + ), + ) + .expect("write auth"); + let auth_manager = Arc::new(AuthManager::new( + auth_home.path().to_path_buf(), + false, + AuthCredentialsStoreMode::File, + )); + + let fetcher = Arc::new(UnauthorizedFetcher { + message: + "GET https://chatgpt.com/backend-api/wham/config/requirements failed: 401; content-type=text/html; body=nope" + .to_string(), + request_count: AtomicUsize::new(0), + }); + let codex_home = tempdir().expect("tempdir"); + let service = CloudRequirementsService::new( + auth_manager, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + + let err = service + .fetch() + .await + .expect_err("cloud requirements should fail closed"); + assert_eq!(err.to_string(), CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + } + #[tokio::test] async fn fetch_cloud_requirements_parse_error_does_not_retry() { let fetcher = Arc::new(SequenceFetcher::new(vec![ @@ -899,9 +1287,7 @@ mod tests { ); let _ = prime_service.fetch().await; - let fetcher = Arc::new(SequenceFetcher::new(vec![Err( - FetchCloudRequirementsStatus::Request, - )])); + let fetcher = Arc::new(SequenceFetcher::new(vec![Err(request_error())])); let service = CloudRequirementsService::new( auth_manager_with_plan("business"), fetcher.clone(), @@ -1209,10 +1595,7 @@ mod tests { #[tokio::test] async fn fetch_cloud_requirements_none_is_success_without_retry() { - let fetcher = Arc::new(SequenceFetcher::new(vec![ - Ok(None), - Err(FetchCloudRequirementsStatus::Request), - ])); + let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(None), Err(request_error())])); let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( auth_manager_with_plan("enterprise"), @@ -1228,9 +1611,7 @@ mod tests { #[tokio::test(start_paused = true)] async fn fetch_cloud_requirements_stops_after_max_retries() { let fetcher = Arc::new(SequenceFetcher::new(vec![ - Err( - FetchCloudRequirementsStatus::Request - ); + Err(request_error()); CLOUD_REQUIREMENTS_MAX_ATTEMPTS ])); let codex_home = tempdir().expect("tempdir"); diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 1f01173c56..a1ca57d7a2 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -410,6 +410,9 @@ "request_permissions": { "type": "boolean" }, + "request_permissions_tool": { + "type": "boolean" + }, "request_rule": { "type": "boolean" }, @@ -1313,6 +1316,10 @@ "description": "Reject MCP elicitation prompts.", "type": "boolean" }, + "request_permissions": { + "description": "Reject approval prompts related to built-in permission requests.", + "type": "boolean" + }, "rules": { "description": "Reject prompts triggered by execpolicy `prompt` rules.", "type": "boolean" @@ -1324,6 +1331,7 @@ }, "required": [ "mcp_elicitations", + "request_permissions", "rules", "sandbox_approval" ], @@ -1882,6 +1890,9 @@ "request_permissions": { "type": "boolean" }, + "request_permissions_tool": { + "type": "boolean" + }, "request_rule": { "type": "boolean" }, diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 1fb3b7b47c..1351669195 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -155,12 +155,11 @@ fn strip_total_output_header(output: &str) -> Option<(&str, u32)> { pub(crate) mod tools { use crate::tools::spec::JsonSchema; use codex_protocol::config_types::WebSearchContextSize; - use codex_protocol::config_types::WebSearchFilters; - use codex_protocol::config_types::WebSearchUserLocation; + use codex_protocol::config_types::WebSearchFilters as ConfigWebSearchFilters; + use codex_protocol::config_types::WebSearchUserLocation as ConfigWebSearchUserLocation; use codex_protocol::config_types::WebSearchUserLocationType; use serde::Deserialize; use serde::Serialize; - use serde::Serializer; /// When serialized as JSON, this produces a valid "Tool" in the OpenAI /// Responses API. @@ -181,16 +180,10 @@ pub(crate) mod tools { WebSearch { #[serde(skip_serializing_if = "Option::is_none")] external_web_access: Option, - #[serde( - skip_serializing_if = "Option::is_none", - serialize_with = "serialize_web_search_filters" - )] - filters: Option, - #[serde( - skip_serializing_if = "Option::is_none", - serialize_with = "serialize_web_search_user_location" - )] - user_location: Option, + #[serde(skip_serializing_if = "Option::is_none")] + filters: Option, + #[serde(skip_serializing_if = "Option::is_none")] + user_location: Option, #[serde(skip_serializing_if = "Option::is_none")] search_context_size: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -212,63 +205,43 @@ pub(crate) mod tools { } } - fn serialize_web_search_filters( - filters: &Option, - serializer: S, - ) -> Result - where - S: Serializer, - { - match filters { - Some(filters) => { - #[derive(Serialize)] - struct SerializableWebSearchFilters<'a> { - #[serde(skip_serializing_if = "Option::is_none")] - allowed_domains: Option<&'a Vec>, - } + #[derive(Debug, Clone, Serialize, PartialEq)] + pub(crate) struct ResponsesApiWebSearchFilters { + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) allowed_domains: Option>, + } - SerializableWebSearchFilters { - allowed_domains: filters.allowed_domains.as_ref(), - } - .serialize(serializer) + impl From for ResponsesApiWebSearchFilters { + fn from(filters: ConfigWebSearchFilters) -> Self { + Self { + allowed_domains: filters.allowed_domains, } - None => serializer.serialize_none(), } } - fn serialize_web_search_user_location( - user_location: &Option, - serializer: S, - ) -> Result - where - S: Serializer, - { - match user_location { - Some(user_location) => { - #[derive(Serialize)] - struct SerializableWebSearchUserLocation<'a> { - #[serde(rename = "type")] - r#type: WebSearchUserLocationType, - #[serde(skip_serializing_if = "Option::is_none")] - country: Option<&'a String>, - #[serde(skip_serializing_if = "Option::is_none")] - region: Option<&'a String>, - #[serde(skip_serializing_if = "Option::is_none")] - city: Option<&'a String>, - #[serde(skip_serializing_if = "Option::is_none")] - timezone: Option<&'a String>, - } + #[derive(Debug, Clone, Serialize, PartialEq)] + pub(crate) struct ResponsesApiWebSearchUserLocation { + #[serde(rename = "type")] + pub(crate) r#type: WebSearchUserLocationType, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) country: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) region: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) city: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) timezone: Option, + } - SerializableWebSearchUserLocation { - r#type: user_location.r#type, - country: user_location.country.as_ref(), - region: user_location.region.as_ref(), - city: user_location.city.as_ref(), - timezone: user_location.timezone.as_ref(), - } - .serialize(serializer) + impl From for ResponsesApiWebSearchUserLocation { + fn from(user_location: ConfigWebSearchUserLocation) -> Self { + Self { + r#type: user_location.r#type, + country: user_location.country, + region: user_location.region, + city: user_location.city, + timezone: user_location.timezone, } - None => serializer.serialize_none(), } } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index a920aeff36..ac7586e2ba 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -71,6 +71,7 @@ use codex_otel::current_span_w3c_trace_context; use codex_otel::set_parent_from_w3c_trace_context; use codex_protocol::ThreadId; use codex_protocol::approvals::ElicitationRequestEvent; +use codex_protocol::approvals::ExecApprovalRequestSkillMetadata; use codex_protocol::approvals::ExecPolicyAmendment; use codex_protocol::approvals::NetworkPolicyAmendment; use codex_protocol::approvals::NetworkPolicyRuleAction; @@ -102,6 +103,10 @@ use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; use codex_protocol::protocol::TurnStartedEvent; +use codex_protocol::request_permissions::PermissionGrantScope; +use codex_protocol::request_permissions::RequestPermissionsArgs; +use codex_protocol::request_permissions::RequestPermissionsEvent; +use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputArgs; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_rmcp_client::ElicitationResponse; @@ -301,6 +306,7 @@ use crate::windows_sandbox::WindowsSandboxLevelExt; use codex_async_utils::OrCancelExt; use codex_otel::SessionTelemetry; use codex_otel::TelemetryAuthMode; +use codex_otel::metrics::names::THREAD_STARTED_METRIC; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; @@ -945,6 +951,11 @@ impl SessionConfiguration { pub(crate) fn apply(&self, updates: &SessionSettingsUpdate) -> ConstraintResult { let mut next_configuration = self.clone(); + let file_system_policy_matches_legacy = self.file_system_sandbox_policy + == FileSystemSandboxPolicy::from_legacy_sandbox_policy( + self.sandbox_policy.get(), + &self.cwd, + ); if let Some(collaboration_mode) = updates.collaboration_mode.clone() { next_configuration.collaboration_mode = collaboration_mode; } @@ -960,18 +971,29 @@ impl SessionConfiguration { if let Some(approval_policy) = updates.approval_policy { next_configuration.approval_policy.set(approval_policy)?; } + let mut sandbox_policy_changed = false; if let Some(sandbox_policy) = updates.sandbox_policy.clone() { next_configuration.sandbox_policy.set(sandbox_policy)?; - next_configuration.file_system_sandbox_policy = - FileSystemSandboxPolicy::from(next_configuration.sandbox_policy.get()); next_configuration.network_sandbox_policy = NetworkSandboxPolicy::from(next_configuration.sandbox_policy.get()); + sandbox_policy_changed = true; } if let Some(windows_sandbox_level) = updates.windows_sandbox_level { next_configuration.windows_sandbox_level = windows_sandbox_level; } + let mut cwd_changed = false; if let Some(cwd) = updates.cwd.clone() { next_configuration.cwd = cwd; + cwd_changed = true; + } + if sandbox_policy_changed || (cwd_changed && file_system_policy_matches_legacy) { + // Preserve richer split policies across cwd-only updates; only + // rederive when the session is already using the legacy bridge. + next_configuration.file_system_sandbox_policy = + FileSystemSandboxPolicy::from_legacy_sandbox_policy( + next_configuration.sandbox_policy.get(), + &next_configuration.cwd, + ); } if let Some(app_server_client_name) = updates.app_server_client_name.clone() { next_configuration.app_server_client_name = Some(app_server_client_name); @@ -1408,7 +1430,7 @@ impl Session { }; config.features.emit_metrics(&session_telemetry); session_telemetry.counter( - "codex.thread.started", + THREAD_STARTED_METRIC, 1, &[( "is_git", @@ -2721,6 +2743,7 @@ impl Session { network_approval_context: Option, proposed_execpolicy_amendment: Option, additional_permissions: Option, + skill_metadata: Option, available_decisions: Option>, ) -> ReviewDecision { // command-level approvals use `call_id`. @@ -2774,6 +2797,7 @@ impl Session { proposed_execpolicy_amendment, proposed_network_policy_amendments, additional_permissions, + skill_metadata, available_decisions: Some(available_decisions), parsed_cmd, }); @@ -2817,6 +2841,58 @@ impl Session { rx_approve } + pub async fn request_permissions( + &self, + turn_context: &TurnContext, + call_id: String, + args: RequestPermissionsArgs, + ) -> Option { + match turn_context.approval_policy.value() { + AskForApproval::Never => { + return Some(RequestPermissionsResponse { + permissions: PermissionProfile::default(), + scope: PermissionGrantScope::Turn, + }); + } + AskForApproval::Reject(reject_config) + if reject_config.rejects_request_permissions() => + { + return Some(RequestPermissionsResponse { + permissions: PermissionProfile::default(), + scope: PermissionGrantScope::Turn, + }); + } + AskForApproval::OnFailure + | AskForApproval::OnRequest + | AskForApproval::UnlessTrusted + | AskForApproval::Reject(_) => {} + } + + let (tx_response, rx_response) = oneshot::channel(); + let prev_entry = { + let mut active = self.active_turn.lock().await; + match active.as_mut() { + Some(at) => { + let mut ts = at.turn_state.lock().await; + ts.insert_pending_request_permissions(call_id.clone(), tx_response) + } + None => None, + } + }; + if prev_entry.is_some() { + warn!("Overwriting existing pending request_permissions for call_id: {call_id}"); + } + + let event = EventMsg::RequestPermissions(RequestPermissionsEvent { + call_id, + turn_id: turn_context.sub_id.clone(), + reason: args.reason, + permissions: args.permissions, + }); + self.send_event(turn_context, event).await; + rx_response.await.ok() + } + pub async fn request_user_input( &self, turn_context: &TurnContext, @@ -2953,6 +3029,59 @@ impl Session { } } + pub async fn notify_request_permissions_response( + &self, + call_id: &str, + response: RequestPermissionsResponse, + ) { + let mut granted_for_session = None; + let entry = { + let mut active = self.active_turn.lock().await; + match active.as_mut() { + Some(at) => { + let mut ts = at.turn_state.lock().await; + let entry = ts.remove_pending_request_permissions(call_id); + if entry.is_some() && !response.permissions.is_empty() { + match response.scope { + PermissionGrantScope::Turn => { + ts.record_granted_permissions(response.permissions.clone()); + } + PermissionGrantScope::Session => { + granted_for_session = Some(response.permissions.clone()); + } + } + } + entry + } + None => None, + } + }; + if let Some(permissions) = granted_for_session { + let mut state = self.state.lock().await; + state.record_granted_permissions(permissions); + } + match entry { + Some(tx_response) => { + tx_response.send(response).ok(); + } + None => { + warn!("No pending request_permissions found for call_id: {call_id}"); + } + } + } + + pub(crate) async fn granted_turn_permissions(&self) -> Option { + let active = self.active_turn.lock().await; + let active = active.as_ref()?; + let ts = active.turn_state.lock().await; + ts.granted_permissions() + } + + pub(crate) async fn granted_session_permissions(&self) -> Option { + let state = self.state.lock().await; + state.granted_permissions() + } + pub async fn notify_dynamic_tool_response(&self, call_id: &str, response: DynamicToolResponse) { let entry = { let mut active = self.active_turn.lock().await; @@ -3192,7 +3321,6 @@ impl Session { DeveloperInstructions::from_policy( turn_context.sandbox_policy.get(), turn_context.approval_policy.value(), - turn_context.features.enabled(Feature::GuardianApproval), self.services.exec_policy.current().as_ref(), &turn_context.cwd, turn_context.features.enabled(Feature::RequestPermissions), @@ -3921,6 +4049,10 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv handlers::request_user_input_response(&sess, id, response).await; false } + Op::RequestPermissionsResponse { id, response } => { + handlers::request_permissions_response(&sess, id, response).await; + false + } Op::DynamicToolResponse { id, response } => { handlers::dynamic_tool_response(&sess, id, response).await; false @@ -4103,6 +4235,7 @@ mod handlers { use codex_protocol::protocol::ThreadRolledBackEvent; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::WarningEvent; + use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use crate::context_manager::is_user_turn_boundary; @@ -4345,6 +4478,15 @@ mod handlers { sess.notify_user_input_response(&id, response).await; } + pub async fn request_permissions_response( + sess: &Arc, + id: String, + response: RequestPermissionsResponse, + ) { + sess.notify_request_permissions_response(&id, response) + .await; + } + pub async fn dynamic_tool_response( sess: &Arc, id: String, @@ -6221,6 +6363,7 @@ fn realtime_text_for_event(msg: &EventMsg) -> Option { | EventMsg::ImageGenerationBegin(_) | EventMsg::ImageGenerationEnd(_) | EventMsg::ExecApprovalRequest(_) + | EventMsg::RequestPermissions(_) | EventMsg::RequestUserInput(_) | EventMsg::DynamicToolCallRequest(_) | EventMsg::DynamicToolCallResponse(_) diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 8e41ad4a7e..f353febd04 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -13,6 +13,10 @@ use codex_protocol::protocol::RequestUserInputEvent; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::Submission; +use codex_protocol::request_permissions::PermissionGrantScope; +use codex_protocol::request_permissions::RequestPermissionsArgs; +use codex_protocol::request_permissions::RequestPermissionsEvent; +use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputArgs; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -251,6 +255,19 @@ async fn forward_events( ) .await; } + Event { + msg: EventMsg::RequestPermissions(event), + .. + } => { + handle_request_permissions( + &codex, + &parent_session, + &parent_ctx, + event, + &cancel_token, + ) + .await; + } Event { id, msg: EventMsg::RequestUserInput(event), @@ -332,6 +349,7 @@ async fn handle_exec_approval( network_approval_context, proposed_execpolicy_amendment, additional_permissions, + skill_metadata, available_decisions, .. } = event; @@ -346,6 +364,7 @@ async fn handle_exec_approval( network_approval_context, proposed_execpolicy_amendment, additional_permissions, + skill_metadata, available_decisions, ); let decision = await_approval_with_cancel( @@ -423,6 +442,30 @@ async fn handle_request_user_input( let _ = codex.submit(Op::UserInputAnswer { id, response }).await; } +async fn handle_request_permissions( + codex: &Codex, + parent_session: &Session, + parent_ctx: &TurnContext, + event: RequestPermissionsEvent, + cancel_token: &CancellationToken, +) { + let call_id = event.call_id; + let args = RequestPermissionsArgs { + reason: event.reason, + permissions: event.permissions, + }; + let response_fut = parent_session.request_permissions(parent_ctx, call_id.clone(), args); + let response = + await_request_permissions_with_cancel(response_fut, parent_session, &call_id, cancel_token) + .await; + let _ = codex + .submit(Op::RequestPermissionsResponse { + id: call_id, + response, + }) + .await; +} + async fn await_user_input_with_cancel( fut: F, parent_session: &Session, @@ -449,6 +492,34 @@ where } } +async fn await_request_permissions_with_cancel( + fut: F, + parent_session: &Session, + call_id: &str, + cancel_token: &CancellationToken, +) -> RequestPermissionsResponse +where + F: core::future::Future>, +{ + tokio::select! { + biased; + _ = cancel_token.cancelled() => { + let empty = RequestPermissionsResponse { + permissions: Default::default(), + scope: PermissionGrantScope::Turn, + }; + parent_session + .notify_request_permissions_response(call_id, empty.clone()) + .await; + empty + } + response = fut => response.unwrap_or_else(|| RequestPermissionsResponse { + permissions: Default::default(), + scope: PermissionGrantScope::Turn, + }), + } +} + /// Await an approval decision, aborting on cancellation. async fn await_approval_with_cancel( fut: F, @@ -477,11 +548,16 @@ where mod tests { use super::*; use async_channel::bounded; + use codex_protocol::models::NetworkPermissions; + use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AgentStatus; + use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::RawResponseItemEvent; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnAbortedEvent; + use codex_protocol::request_permissions::RequestPermissionsEvent; + use codex_protocol::request_permissions::RequestPermissionsResponse; use pretty_assertions::assert_eq; use tokio::sync::watch; @@ -600,4 +676,91 @@ mod tests { .expect("forward_ops did not exit") .expect("forward_ops join error"); } + + #[tokio::test] + async fn handle_request_permissions_uses_tool_call_id_for_round_trip() { + let (parent_session, parent_ctx, rx_events) = + crate::codex::make_session_and_context_with_rx().await; + *parent_session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); + + let (tx_sub, rx_sub) = bounded(SUBMISSION_CHANNEL_CAPACITY); + let (_tx_events, rx_events_child) = bounded(SUBMISSION_CHANNEL_CAPACITY); + let (_agent_status_tx, agent_status) = watch::channel(AgentStatus::PendingInit); + let codex = Arc::new(Codex { + tx_sub, + rx_event: rx_events_child, + agent_status, + session: Arc::clone(&parent_session), + }); + + let call_id = "tool-call-1".to_string(); + let expected_response = RequestPermissionsResponse { + permissions: PermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(true), + }), + ..PermissionProfile::default() + }, + scope: PermissionGrantScope::Turn, + }; + let cancel_token = CancellationToken::new(); + let request_call_id = call_id.clone(); + + let handle = tokio::spawn({ + let codex = Arc::clone(&codex); + let parent_session = Arc::clone(&parent_session); + let parent_ctx = Arc::clone(&parent_ctx); + let cancel_token = cancel_token.clone(); + async move { + handle_request_permissions( + codex.as_ref(), + parent_session.as_ref(), + parent_ctx.as_ref(), + RequestPermissionsEvent { + call_id: request_call_id, + turn_id: "child-turn-1".to_string(), + reason: Some("need access".to_string()), + permissions: PermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(true), + }), + ..PermissionProfile::default() + }, + }, + &cancel_token, + ) + .await; + } + }); + + let request_event = timeout(Duration::from_secs(1), rx_events.recv()) + .await + .expect("request_permissions event timed out") + .expect("request_permissions event missing"); + let EventMsg::RequestPermissions(request) = request_event.msg else { + panic!("expected RequestPermissions event"); + }; + assert_eq!(request.call_id, call_id.clone()); + + parent_session + .notify_request_permissions_response(&call_id, expected_response.clone()) + .await; + + timeout(Duration::from_secs(1), handle) + .await + .expect("handle_request_permissions hung") + .expect("handle_request_permissions join error"); + + let submission = timeout(Duration::from_secs(1), rx_sub.recv()) + .await + .expect("request_permissions response timed out") + .expect("request_permissions response missing"); + assert_eq!( + submission.op, + Op::RequestPermissionsResponse { + id: call_id, + response: expected_response, + } + ); + } } diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 6e7115619c..e457f873bd 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -17,6 +17,14 @@ use crate::tools::format_exec_output_str; use codex_protocol::ThreadId; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::FunctionCallOutputPayload; +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::ReadOnlyAccess; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::request_permissions::PermissionGrantScope; use tracing::Span; use crate::protocol::CompactedItem; @@ -1885,6 +1893,100 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati } } +#[tokio::test] +async fn session_configuration_apply_preserves_split_file_system_policy_on_cwd_only_update() { + let mut session_configuration = make_session_configuration_for_tests().await; + let workspace = tempfile::tempdir().expect("create temp dir"); + let project_root = workspace.path().join("project"); + let original_cwd = project_root.join("subdir"); + let docs_dir = original_cwd.join("docs"); + std::fs::create_dir_all(&docs_dir).expect("create docs dir"); + let docs_dir = + codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs_dir).expect("docs"); + + session_configuration.cwd = original_cwd; + session_configuration.sandbox_policy = + codex_config::Constrained::allow_any(SandboxPolicy::WorkspaceWrite { + writable_roots: Vec::new(), + read_only_access: ReadOnlyAccess::Restricted { + include_platform_defaults: true, + readable_roots: vec![docs_dir.clone()], + }, + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }); + session_configuration.file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: docs_dir }, + access: FileSystemAccessMode::Read, + }, + ]); + + let updated = session_configuration + .apply(&SessionSettingsUpdate { + cwd: Some(project_root), + ..Default::default() + }) + .expect("cwd-only update should succeed"); + + assert_eq!( + updated.file_system_sandbox_policy, + session_configuration.file_system_sandbox_policy + ); +} + +#[tokio::test] +async fn session_configuration_apply_rederives_legacy_file_system_policy_on_cwd_update() { + let mut session_configuration = make_session_configuration_for_tests().await; + let workspace = tempfile::tempdir().expect("create temp dir"); + let project_root = workspace.path().join("project"); + let original_cwd = project_root.join("subdir"); + let docs_dir = original_cwd.join("docs"); + std::fs::create_dir_all(&docs_dir).expect("create docs dir"); + let docs_dir = + codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs_dir).expect("docs"); + + session_configuration.cwd = original_cwd; + session_configuration.sandbox_policy = + codex_config::Constrained::allow_any(SandboxPolicy::WorkspaceWrite { + writable_roots: Vec::new(), + read_only_access: ReadOnlyAccess::Restricted { + include_platform_defaults: true, + readable_roots: vec![docs_dir], + }, + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }); + session_configuration.file_system_sandbox_policy = + FileSystemSandboxPolicy::from_legacy_sandbox_policy( + session_configuration.sandbox_policy.get(), + &session_configuration.cwd, + ); + + let updated = session_configuration + .apply(&SessionSettingsUpdate { + cwd: Some(project_root.clone()), + ..Default::default() + }) + .expect("cwd-only update should succeed"); + + assert_eq!( + updated.file_system_sandbox_policy, + FileSystemSandboxPolicy::from_legacy_sandbox_policy( + updated.sandbox_policy.get(), + &project_root, + ) + ); +} + #[tokio::test] async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { let codex_home = tempfile::tempdir().expect("create temp dir"); @@ -2141,6 +2243,153 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { (session, turn_context) } +#[tokio::test] +async fn notify_request_permissions_response_ignores_unmatched_call_id() { + let (session, _turn_context) = make_session_and_context().await; + *session.active_turn.lock().await = Some(ActiveTurn::default()); + + session + .notify_request_permissions_response( + "missing", + codex_protocol::request_permissions::RequestPermissionsResponse { + permissions: codex_protocol::models::PermissionProfile { + network: Some(codex_protocol::models::NetworkPermissions { + enabled: Some(true), + }), + ..Default::default() + }, + scope: PermissionGrantScope::Turn, + }, + ) + .await; + + assert_eq!(session.granted_turn_permissions().await, None); +} + +#[tokio::test] +async fn request_permissions_emits_event_when_reject_policy_allows_requests() { + let (session, mut turn_context, rx) = make_session_and_context_with_rx().await; + *session.active_turn.lock().await = Some(ActiveTurn::default()); + Arc::get_mut(&mut turn_context) + .expect("single turn context ref") + .approval_policy + .set(crate::protocol::AskForApproval::Reject( + crate::protocol::RejectConfig { + sandbox_approval: true, + rules: true, + request_permissions: false, + mcp_elicitations: true, + }, + )) + .expect("test setup should allow updating approval policy"); + + let session = Arc::new(session); + let turn_context = Arc::new(turn_context); + let call_id = "call-1".to_string(); + let expected_response = codex_protocol::request_permissions::RequestPermissionsResponse { + permissions: codex_protocol::models::PermissionProfile { + network: Some(codex_protocol::models::NetworkPermissions { + enabled: Some(true), + }), + ..Default::default() + }, + scope: PermissionGrantScope::Turn, + }; + + let handle = tokio::spawn({ + let session = Arc::clone(&session); + let turn_context = Arc::clone(&turn_context); + let call_id = call_id.clone(); + async move { + session + .request_permissions( + turn_context.as_ref(), + call_id, + codex_protocol::request_permissions::RequestPermissionsArgs { + reason: Some("need network".to_string()), + permissions: codex_protocol::models::PermissionProfile { + network: Some(codex_protocol::models::NetworkPermissions { + enabled: Some(true), + }), + ..Default::default() + }, + }, + ) + .await + } + }); + + let request_event = tokio::time::timeout(StdDuration::from_secs(1), rx.recv()) + .await + .expect("request_permissions event timed out") + .expect("request_permissions event missing"); + let EventMsg::RequestPermissions(request) = request_event.msg else { + panic!("expected request_permissions event"); + }; + assert_eq!(request.call_id, call_id); + + session + .notify_request_permissions_response(&request.call_id, expected_response.clone()) + .await; + + let response = tokio::time::timeout(StdDuration::from_secs(1), handle) + .await + .expect("request_permissions future timed out") + .expect("request_permissions join error"); + + assert_eq!(response, Some(expected_response)); +} + +#[tokio::test] +async fn request_permissions_returns_empty_grant_when_reject_policy_blocks_requests() { + let (session, mut turn_context, rx) = make_session_and_context_with_rx().await; + *session.active_turn.lock().await = Some(ActiveTurn::default()); + Arc::get_mut(&mut turn_context) + .expect("single turn context ref") + .approval_policy + .set(crate::protocol::AskForApproval::Reject( + crate::protocol::RejectConfig { + sandbox_approval: false, + rules: false, + request_permissions: true, + mcp_elicitations: false, + }, + )) + .expect("test setup should allow updating approval policy"); + + let response = session + .request_permissions( + &turn_context, + "call-1".to_string(), + codex_protocol::request_permissions::RequestPermissionsArgs { + reason: Some("need network".to_string()), + permissions: codex_protocol::models::PermissionProfile { + network: Some(codex_protocol::models::NetworkPermissions { + enabled: Some(true), + }), + ..Default::default() + }, + }, + ) + .await; + + assert_eq!( + response, + Some( + codex_protocol::request_permissions::RequestPermissionsResponse { + permissions: codex_protocol::models::PermissionProfile::default(), + scope: PermissionGrantScope::Turn, + } + ) + ); + assert!( + tokio::time::timeout(StdDuration::from_millis(50), rx.recv()) + .await + .is_err(), + "unexpected request_permissions event emitted", + ); +} + #[tokio::test] async fn submit_with_id_captures_current_span_trace_context() { let (session, _turn_context) = make_session_and_context().await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 41ad976b81..cc3b3d0ad7 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -16,6 +16,8 @@ use codex_execpolicy::RuleMatch; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::NetworkPermissions; use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::codex_linux_sandbox_exe_or_skip; use core_test_support::responses::ev_assistant_message; @@ -70,15 +72,17 @@ async fn guardian_allows_shell_additional_permissions_requests_past_policy_valid .features .enable(Feature::RequestPermissions) .expect("test setup should allow enabling request permissions"); + turn_context_raw + .sandbox_policy + .set(SandboxPolicy::DangerFullAccess) + .expect("test setup should allow updating sandbox policy"); // This test is about request-permissions validation, not managed sandbox // policy enforcement. Widen the derived sandbox policies directly so the // command runs without depending on a platform sandbox binary. turn_context_raw.file_system_sandbox_policy = - codex_protocol::permissions::FileSystemSandboxPolicy::from( - &SandboxPolicy::DangerFullAccess, - ); + FileSystemSandboxPolicy::from(turn_context_raw.sandbox_policy.get()); turn_context_raw.network_sandbox_policy = - codex_protocol::permissions::NetworkSandboxPolicy::from(&SandboxPolicy::DangerFullAccess); + NetworkSandboxPolicy::from(turn_context_raw.sandbox_policy.get()); let mut config = (*turn_context_raw.config).clone(); config.model_provider.base_url = Some(format!("{}/v1", server.uri())); let config = Arc::new(config); @@ -92,11 +96,14 @@ async fn guardian_allows_shell_additional_permissions_requests_past_policy_valid turn_context_raw.provider = config.model_provider.clone(); let session = Arc::new(session); let turn_context = Arc::new(turn_context_raw); + let expiration_ms: u64 = if cfg!(windows) { 2_500 } else { 1_000 }; let params = ExecParams { command: if cfg!(windows) { vec![ "cmd.exe".to_string(), + "/Q".to_string(), + "/D".to_string(), "/C".to_string(), "echo hi".to_string(), ] @@ -108,7 +115,7 @@ async fn guardian_allows_shell_additional_permissions_requests_past_policy_valid ] }, cwd: turn_context.cwd.clone(), - expiration: 1000.into(), + expiration: expiration_ms.into(), env: HashMap::new(), network: None, sandbox_permissions: SandboxPermissions::WithAdditionalPermissions, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index ebf4862bcc..9fdd756724 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -574,6 +574,156 @@ fn permissions_profiles_reject_nested_entries_for_non_project_roots() -> std::io Ok(()) } +fn load_workspace_permission_profile(profile: PermissionProfileToml) -> std::io::Result { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; + + Config::load_from_base_config_with_overrides( + ConfigToml { + default_permissions: Some("workspace".to_string()), + permissions: Some(PermissionsToml { + entries: BTreeMap::from([("workspace".to_string(), profile)]), + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.path().to_path_buf(), + ) +} + +#[test] +fn permissions_profiles_allow_unknown_special_paths() -> std::io::Result<()> { + let config = load_workspace_permission_profile(PermissionProfileToml { + filesystem: Some(FilesystemPermissionsToml { + entries: BTreeMap::from([( + ":future_special_path".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Read), + )]), + }), + network: None, + })?; + + assert_eq!( + config.permissions.file_system_sandbox_policy, + FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::unknown(":future_special_path", None), + }, + access: FileSystemAccessMode::Read, + }]), + ); + assert_eq!( + config.permissions.sandbox_policy.get(), + &SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::Restricted { + include_platform_defaults: false, + readable_roots: Vec::new(), + }, + network_access: false, + } + ); + assert!( + config.startup_warnings.iter().any(|warning| warning.contains( + "Configured filesystem path `:future_special_path` is not recognized by this version of Codex and will be ignored." + )), + "{:?}", + config.startup_warnings + ); + Ok(()) +} + +#[test] +fn permissions_profiles_allow_unknown_special_paths_with_nested_entries() -> std::io::Result<()> { + let config = load_workspace_permission_profile(PermissionProfileToml { + filesystem: Some(FilesystemPermissionsToml { + entries: BTreeMap::from([( + ":future_special_path".to_string(), + FilesystemPermissionToml::Scoped(BTreeMap::from([( + "docs".to_string(), + FileSystemAccessMode::Read, + )])), + )]), + }), + network: None, + })?; + + assert_eq!( + config.permissions.file_system_sandbox_policy, + FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::unknown(":future_special_path", Some("docs".into())), + }, + access: FileSystemAccessMode::Read, + }]), + ); + assert!( + config.startup_warnings.iter().any(|warning| warning.contains( + "Configured filesystem path `:future_special_path` with nested entry `docs` is not recognized by this version of Codex and will be ignored." + )), + "{:?}", + config.startup_warnings + ); + Ok(()) +} + +#[test] +fn permissions_profiles_allow_missing_filesystem_with_warning() -> std::io::Result<()> { + let config = load_workspace_permission_profile(PermissionProfileToml { + filesystem: None, + network: None, + })?; + + assert_eq!( + config.permissions.file_system_sandbox_policy, + FileSystemSandboxPolicy::restricted(Vec::new()) + ); + assert_eq!( + config.permissions.sandbox_policy.get(), + &SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::Restricted { + include_platform_defaults: false, + readable_roots: Vec::new(), + }, + network_access: false, + } + ); + assert!( + config.startup_warnings.iter().any(|warning| warning.contains( + "Permissions profile `workspace` does not define any recognized filesystem entries for this version of Codex." + )), + "{:?}", + config.startup_warnings + ); + Ok(()) +} + +#[test] +fn permissions_profiles_allow_empty_filesystem_with_warning() -> std::io::Result<()> { + let config = load_workspace_permission_profile(PermissionProfileToml { + filesystem: Some(FilesystemPermissionsToml { + entries: BTreeMap::new(), + }), + network: None, + })?; + + assert_eq!( + config.permissions.file_system_sandbox_policy, + FileSystemSandboxPolicy::restricted(Vec::new()) + ); + assert!( + config.startup_warnings.iter().any(|warning| warning.contains( + "Permissions profile `workspace` does not define any recognized filesystem entries for this version of Codex." + )), + "{:?}", + config.startup_warnings + ); + Ok(()) +} + #[test] fn permissions_profiles_reject_project_root_parent_traversal() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index f7ef78b26d..0c9a3a282e 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1971,7 +1971,11 @@ impl Config { let configured_network_proxy_config = network_proxy_config_from_profile_network(profile.network.as_ref()); let (mut file_system_sandbox_policy, network_sandbox_policy) = - compile_permission_profile(permissions, default_permissions)?; + compile_permission_profile( + permissions, + default_permissions, + &mut startup_warnings, + )?; let mut sandbox_policy = file_system_sandbox_policy .to_legacy_sandbox_policy(network_sandbox_policy, &resolved_cwd)?; if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) { @@ -2004,7 +2008,8 @@ impl Config { } } } - let file_system_sandbox_policy = FileSystemSandboxPolicy::from(&sandbox_policy); + let file_system_sandbox_policy = + FileSystemSandboxPolicy::from_legacy_sandbox_policy(&sandbox_policy, &resolved_cwd); let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy); ( configured_network_proxy_config, @@ -2330,7 +2335,10 @@ impl Config { if effective_sandbox_policy == original_sandbox_policy { file_system_sandbox_policy } else { - FileSystemSandboxPolicy::from(&effective_sandbox_policy) + FileSystemSandboxPolicy::from_legacy_sandbox_policy( + &effective_sandbox_policy, + &resolved_cwd, + ) }; let effective_network_sandbox_policy = if effective_sandbox_policy == original_sandbox_policy { diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index b96039cec3..b931c0f415 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::collections::BTreeMap; use std::io; use std::path::Component; @@ -158,30 +159,27 @@ pub(crate) fn resolve_permission_profile<'a>( pub(crate) fn compile_permission_profile( permissions: &PermissionsToml, profile_name: &str, + startup_warnings: &mut Vec, ) -> io::Result<(FileSystemSandboxPolicy, NetworkSandboxPolicy)> { let profile = resolve_permission_profile(permissions, profile_name)?; - let filesystem = profile.filesystem.as_ref().ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidInput, - format!( - "permissions profile `{profile_name}` must define a `[permissions.{profile_name}.filesystem]` table" - ), - ) - })?; - - if filesystem.is_empty() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!( - "permissions profile `{profile_name}` must define at least one filesystem entry" - ), - )); - } - let mut entries = Vec::new(); - for (path, permission) in &filesystem.entries { - compile_filesystem_permission(path, permission, &mut entries)?; + if let Some(filesystem) = profile.filesystem.as_ref() { + if filesystem.is_empty() { + push_warning( + startup_warnings, + missing_filesystem_entries_warning(profile_name), + ); + } else { + for (path, permission) in &filesystem.entries { + compile_filesystem_permission(path, permission, &mut entries, startup_warnings)?; + } + } + } else { + push_warning( + startup_warnings, + missing_filesystem_entries_warning(profile_name), + ); } let network_sandbox_policy = compile_network_sandbox_policy(profile.network.as_ref()); @@ -207,16 +205,17 @@ fn compile_filesystem_permission( path: &str, permission: &FilesystemPermissionToml, entries: &mut Vec, + startup_warnings: &mut Vec, ) -> io::Result<()> { match permission { FilesystemPermissionToml::Access(access) => entries.push(FileSystemSandboxEntry { - path: compile_filesystem_path(path)?, + path: compile_filesystem_path(path, startup_warnings)?, access: *access, }), FilesystemPermissionToml::Scoped(scoped_entries) => { for (subpath, access) in scoped_entries { entries.push(FileSystemSandboxEntry { - path: compile_scoped_filesystem_path(path, subpath)?, + path: compile_scoped_filesystem_path(path, subpath, startup_warnings)?, access: *access, }); } @@ -225,8 +224,12 @@ fn compile_filesystem_permission( Ok(()) } -fn compile_filesystem_path(path: &str) -> io::Result { - if let Some(special) = parse_special_path(path)? { +fn compile_filesystem_path( + path: &str, + startup_warnings: &mut Vec, +) -> io::Result { + if let Some(special) = parse_special_path(path) { + maybe_push_unknown_special_path_warning(&special, startup_warnings); return Ok(FileSystemPath::Special { value: special }); } @@ -234,21 +237,33 @@ fn compile_filesystem_path(path: &str) -> io::Result { Ok(FileSystemPath::Path { path }) } -fn compile_scoped_filesystem_path(path: &str, subpath: &str) -> io::Result { +fn compile_scoped_filesystem_path( + path: &str, + subpath: &str, + startup_warnings: &mut Vec, +) -> io::Result { if subpath == "." { - return compile_filesystem_path(path); + return compile_filesystem_path(path, startup_warnings); } - if let Some(special) = parse_special_path(path)? { - if !matches!(special, FileSystemSpecialPath::ProjectRoots { .. }) { - return Err(io::Error::new( + if let Some(special) = parse_special_path(path) { + let subpath = parse_relative_subpath(subpath)?; + let special = match special { + FileSystemSpecialPath::ProjectRoots { .. } => Ok(FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(Some(subpath)), + }), + FileSystemSpecialPath::Unknown { path, .. } => Ok(FileSystemPath::Special { + value: FileSystemSpecialPath::unknown(path, Some(subpath)), + }), + _ => Err(io::Error::new( io::ErrorKind::InvalidInput, format!("filesystem path `{path}` does not support nested entries"), - )); + )), + }?; + if let FileSystemPath::Special { value } = &special { + maybe_push_unknown_special_path_warning(value, startup_warnings); } - return Ok(FileSystemPath::Special { - value: FileSystemSpecialPath::project_roots(Some(parse_relative_subpath(subpath)?)), - }); + return Ok(special); } let subpath = parse_relative_subpath(subpath)?; @@ -257,33 +272,90 @@ fn compile_scoped_filesystem_path(path: &str, subpath: &str) -> io::Result io::Result> { - let special = match path { +// WARNING: keep this parser forward-compatible. +// Adding a new `:special_path` must not make older Codex versions reject the +// config. Unknown values intentionally round-trip through +// `FileSystemSpecialPath::Unknown` so they can be surfaced as warnings and +// ignored, rather than aborting config load. +fn parse_special_path(path: &str) -> Option { + match path { ":root" => Some(FileSystemSpecialPath::Root), ":minimal" => Some(FileSystemSpecialPath::Minimal), ":project_roots" => Some(FileSystemSpecialPath::project_roots(None)), ":tmpdir" => Some(FileSystemSpecialPath::Tmpdir), - _ if path.starts_with(':') => { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!("unknown filesystem special path `{path}`"), - )); - } + _ if path.starts_with(':') => Some(FileSystemSpecialPath::unknown(path, None)), _ => None, - }; - - Ok(special) + } } fn parse_absolute_path(path: &str) -> io::Result { - let path_ref = Path::new(path); - if !path_ref.is_absolute() && path != "~" && !path.starts_with("~/") { + parse_absolute_path_for_platform(path, cfg!(windows)) +} + +fn parse_absolute_path_for_platform(path: &str, is_windows: bool) -> io::Result { + let path_ref = normalize_absolute_path_for_platform(path, is_windows); + if !is_absolute_path_for_platform(path, path_ref.as_ref(), is_windows) + && path != "~" + && !path.starts_with("~/") + { return Err(io::Error::new( io::ErrorKind::InvalidInput, format!("filesystem path `{path}` must be absolute, use `~/...`, or start with `:`"), )); } - AbsolutePathBuf::from_absolute_path(path_ref) + AbsolutePathBuf::from_absolute_path(path_ref.as_ref()) +} + +fn is_absolute_path_for_platform(path: &str, normalized_path: &Path, is_windows: bool) -> bool { + if is_windows { + is_windows_absolute_path(path) + || is_windows_absolute_path(&normalized_path.to_string_lossy()) + } else { + normalized_path.is_absolute() + } +} + +fn normalize_absolute_path_for_platform(path: &str, is_windows: bool) -> Cow<'_, Path> { + if !is_windows { + return Cow::Borrowed(Path::new(path)); + } + + match normalize_windows_device_path(path) { + Some(normalized) => Cow::Owned(PathBuf::from(normalized)), + None => Cow::Borrowed(Path::new(path)), + } +} + +fn normalize_windows_device_path(path: &str) -> Option { + if let Some(unc) = path.strip_prefix(r"\\?\UNC\") { + return Some(format!(r"\\{unc}")); + } + if let Some(unc) = path.strip_prefix(r"\\.\UNC\") { + return Some(format!(r"\\{unc}")); + } + if let Some(path) = path.strip_prefix(r"\\?\") + && is_windows_drive_absolute_path(path) + { + return Some(path.to_string()); + } + if let Some(path) = path.strip_prefix(r"\\.\") + && is_windows_drive_absolute_path(path) + { + return Some(path.to_string()); + } + None +} + +fn is_windows_absolute_path(path: &str) -> bool { + is_windows_drive_absolute_path(path) || path.starts_with(r"\\") +} + +fn is_windows_drive_absolute_path(path: &str) -> bool { + let bytes = path.as_bytes(); + bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && matches!(bytes[2], b'\\' | b'/') } fn parse_relative_subpath(subpath: &str) -> io::Result { @@ -304,3 +376,48 @@ fn parse_relative_subpath(subpath: &str) -> io::Result { ), )) } + +fn push_warning(startup_warnings: &mut Vec, message: String) { + tracing::warn!("{message}"); + startup_warnings.push(message); +} + +fn missing_filesystem_entries_warning(profile_name: &str) -> String { + format!( + "Permissions profile `{profile_name}` does not define any recognized filesystem entries for this version of Codex. Filesystem access will remain restricted. Upgrade Codex if this profile expects filesystem permissions." + ) +} + +fn maybe_push_unknown_special_path_warning( + special: &FileSystemSpecialPath, + startup_warnings: &mut Vec, +) { + let FileSystemSpecialPath::Unknown { path, subpath } = special else { + return; + }; + push_warning( + startup_warnings, + match subpath.as_deref() { + Some(subpath) => format!( + "Configured filesystem path `{path}` with nested entry `{}` is not recognized by this version of Codex and will be ignored. Upgrade Codex if this path is required.", + subpath.display() + ), + None => format!( + "Configured filesystem path `{path}` is not recognized by this version of Codex and will be ignored. Upgrade Codex if this path is required." + ), + }, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn normalize_absolute_path_for_platform_simplifies_windows_verbatim_paths() { + let parsed = + normalize_absolute_path_for_platform(r"\\?\D:\c\x\worktrees\2508\swift-base", true); + assert_eq!(parsed, PathBuf::from(r"D:\c\x\worktrees\2508\swift-base")); + } +} diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index b678c2e6cb..7ef6a34108 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -421,9 +421,23 @@ fn for_prompt_rewrites_image_generation_calls_when_images_are_supported() { ResponseItem::Message { id: None, role: "user".to_string(), - content: vec![ContentItem::InputImage { - image_url: "data:image/png;base64,Zm9v".to_string(), - }], + content: vec![ + ContentItem::InputText { + text: "Image Generation Call".to_string(), + }, + ContentItem::InputText { + text: "Image ID: ig_123".to_string(), + }, + ContentItem::InputText { + text: "Prompt: lobster".to_string(), + }, + ContentItem::InputImage { + image_url: "data:image/png;base64,Zm9v".to_string(), + }, + ContentItem::InputText { + text: "Saved to: CWD".to_string(), + }, + ], end_turn: None, phase: None, }, @@ -475,10 +489,24 @@ fn for_prompt_rewrites_image_generation_calls_when_images_are_unsupported() { ResponseItem::Message { id: None, role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "image content omitted because you do not support image input" - .to_string(), - }], + content: vec![ + ContentItem::InputText { + text: "Image Generation Call".to_string(), + }, + ContentItem::InputText { + text: "Image ID: ig_123".to_string(), + }, + ContentItem::InputText { + text: "Prompt: lobster".to_string(), + }, + ContentItem::InputText { + text: "image content omitted because you do not support image input" + .to_string(), + }, + ContentItem::InputText { + text: "Saved to: CWD".to_string(), + }, + ], end_turn: None, phase: None, }, diff --git a/codex-rs/core/src/context_manager/normalize.rs b/codex-rs/core/src/context_manager/normalize.rs index 465edae41b..95d36f2f58 100644 --- a/codex-rs/core/src/context_manager/normalize.rs +++ b/codex-rs/core/src/context_manager/normalize.rs @@ -215,17 +215,37 @@ pub(crate) fn rewrite_image_generation_calls_for_stateless_input(items: &mut Vec *items = original_items .into_iter() .map(|item| match item { - ResponseItem::ImageGenerationCall { result, .. } => { + ResponseItem::ImageGenerationCall { + id, + revised_prompt, + result, + .. + } => { let image_url = if result.starts_with("data:") { result } else { format!("data:image/png;base64,{result}") }; + let revised_prompt = revised_prompt.unwrap_or_default(); ResponseItem::Message { id: None, role: "user".to_string(), - content: vec![ContentItem::InputImage { image_url }], + content: vec![ + ContentItem::InputText { + text: "Image Generation Call".to_string(), + }, + ContentItem::InputText { + text: format!("Image ID: {id}"), + }, + ContentItem::InputText { + text: format!("Prompt: {revised_prompt}"), + }, + ContentItem::InputImage { image_url }, + ContentItem::InputText { + text: "Saved to: CWD".to_string(), + }, + ], end_turn: None, phase: None, } diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index efbb03174f..63deb5c808 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -43,7 +43,6 @@ fn build_permissions_update_item( Some(DeveloperInstructions::from_policy( next.sandbox_policy.get(), next.approval_policy.value(), - next.features.enabled(Feature::GuardianApproval), exec_policy, &next.cwd, next.features.enabled(Feature::RequestPermissions), diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 2a05432eca..60edee6514 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -1569,6 +1569,7 @@ prefix_rule(pattern=["git"], decision="prompt") AskForApproval::Reject(RejectConfig { sandbox_approval: false, rules: false, + request_permissions: false, mcp_elicitations: false, }), &SandboxPolicy::new_read_only_policy(), @@ -1590,6 +1591,7 @@ prefix_rule(pattern=["git"], decision="prompt") approval_policy: AskForApproval::Reject(RejectConfig { sandbox_approval: true, rules: false, + request_permissions: false, mcp_elicitations: false, }), sandbox_policy: &SandboxPolicy::new_read_only_policy(), @@ -1626,6 +1628,7 @@ prefix_rule(pattern=["git"], decision="prompt") approval_policy: AskForApproval::Reject(RejectConfig { sandbox_approval: true, rules: false, + request_permissions: false, mcp_elicitations: false, }), sandbox_policy: &SandboxPolicy::new_read_only_policy(), @@ -1660,6 +1663,7 @@ prefix_rule(pattern=["git"], decision="prompt") approval_policy: AskForApproval::Reject(RejectConfig { sandbox_approval: false, rules: true, + request_permissions: false, mcp_elicitations: false, }), sandbox_policy: &SandboxPolicy::new_read_only_policy(), diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 4d4a5855ed..529203e1c6 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -93,6 +93,8 @@ pub enum Feature { ApplyPatchFreeform, /// Allow requesting additional filesystem permissions while staying sandboxed. RequestPermissions, + /// Expose the built-in request_permissions tool. + RequestPermissionsTool, /// Allow the model to request web searches that fetch live content. WebSearchRequest, /// Allow the model to request web searches that fetch cached content. @@ -147,7 +149,7 @@ pub enum Feature { Steer, /// Allow request_user_input in Default collaboration mode. DefaultModeRequestUserInput, - /// Enable guardian subagent approvals. + /// Enable automatic review for approval prompts. GuardianApproval, /// Enable collaboration modes (Plan, Default). /// Kept for config backward compatibility; behavior is always collaboration-modes-enabled. @@ -581,6 +583,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::RequestPermissionsTool, + key: "request_permissions_tool", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::UseLinuxSandboxBwrap, key: "use_linux_sandbox_bwrap", @@ -702,8 +710,8 @@ pub const FEATURES: &[FeatureSpec] = &[ id: Feature::GuardianApproval, key: "guardian_approval", stage: Stage::Experimental { - name: "Guardian approvals", - menu_description: "Let a guardian subagent review `on-request` approval prompts instead of showing them to you, including sandbox escapes and blocked network access.", + name: "Automatic approval review", + menu_description: "Dispatch `on-request` approval prompts (for e.g. sandbox escapes or blocked network access) to a carefully-prompted security reviewer subagent rather than blocking the agent on your input.", announcement: "", }, default_enabled: false, @@ -909,17 +917,35 @@ mod tests { let stage = spec.stage; assert!(matches!(stage, Stage::Experimental { .. })); - assert_eq!(stage.experimental_menu_name(), Some("Guardian approvals")); + assert_eq!( + stage.experimental_menu_name(), + Some("Automatic approval review") + ); assert_eq!( stage.experimental_menu_description().map(str::to_owned), Some( - "Let a guardian subagent review `on-request` approval prompts instead of showing them to you, including sandbox escapes and blocked network access.".to_string() + "Dispatch `on-request` approval prompts (for e.g. sandbox escapes or blocked network access) to a carefully-prompted security reviewer subagent rather than blocking the agent on your input.".to_string() ) ); assert_eq!(stage.experimental_announcement(), None); assert_eq!(Feature::GuardianApproval.default_enabled(), false); } + #[test] + fn request_permissions_is_under_development() { + assert_eq!(Feature::RequestPermissions.stage(), Stage::UnderDevelopment); + assert_eq!(Feature::RequestPermissions.default_enabled(), false); + } + + #[test] + fn request_permissions_tool_is_under_development() { + assert_eq!( + Feature::RequestPermissionsTool.stage(), + Stage::UnderDevelopment + ); + assert_eq!(Feature::RequestPermissionsTool.default_enabled(), false); + } + #[test] fn image_generation_is_under_development() { assert_eq!(Feature::ImageGeneration.stage(), Stage::UnderDevelopment); diff --git a/codex-rs/core/src/guardian.rs b/codex-rs/core/src/guardian.rs index 385b8190f5..a2c36b4825 100644 --- a/codex-rs/core/src/guardian.rs +++ b/codex-rs/core/src/guardian.rs @@ -12,15 +12,19 @@ //! 4. Approve only low- and medium-risk actions (`risk_score < 80`). use std::collections::HashMap; +use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; +use codex_protocol::approvals::NetworkApprovalProtocol; +use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::WarningEvent; use codex_protocol::user_input::UserInput; +use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; use serde::Serialize; use serde_json::Value; @@ -66,7 +70,7 @@ const GUARDIAN_RECENT_ENTRY_LIMIT: usize = 40; const GUARDIAN_TRUNCATION_TAG: &str = "guardian_truncated"; pub(crate) const GUARDIAN_REJECTION_MESSAGE: &str = concat!( - "Guardian rejected this action due to unacceptable risk. ", + "This action was rejected due to unacceptable risk. ", "The agent must not attempt to achieve the same outcome via workaround, ", "indirect execution, or policy circumvention. ", "Proceed only with a materially safer alternative, or stop and request user input.", @@ -89,12 +93,6 @@ pub(crate) fn is_guardian_subagent_source( ) } -/// Canonical description of the action the guardian is being asked to review. -#[derive(Debug, Clone, PartialEq)] -pub(crate) struct GuardianReviewRequest { - pub(crate) action: Value, -} - /// Coarse risk label paired with the numeric `risk_score`. #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] @@ -120,6 +118,66 @@ pub(crate) struct GuardianAssessment { evidence: Vec, } +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum GuardianApprovalRequest { + Shell { + command: Vec, + cwd: PathBuf, + sandbox_permissions: crate::sandboxing::SandboxPermissions, + additional_permissions: Option, + justification: Option, + }, + ExecCommand { + command: Vec, + cwd: PathBuf, + sandbox_permissions: crate::sandboxing::SandboxPermissions, + additional_permissions: Option, + justification: Option, + tty: bool, + }, + #[cfg(unix)] + Execve { + tool_name: String, + program: String, + argv: Vec, + cwd: PathBuf, + additional_permissions: Option, + }, + ApplyPatch { + cwd: PathBuf, + files: Vec, + change_count: usize, + patch: String, + }, + NetworkAccess { + target: String, + host: String, + protocol: NetworkApprovalProtocol, + port: u16, + }, + McpToolCall { + server: String, + tool_name: String, + arguments: Option, + connector_id: Option, + connector_name: Option, + connector_description: Option, + tool_title: Option, + tool_description: Option, + annotations: Option, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct GuardianMcpAnnotations { + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) destructive_hint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) open_world_hint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) read_only_hint: Option, +} + /// Transcript entry retained for guardian review after filtering. #[derive(Debug, PartialEq, Eq)] struct GuardianTranscriptEntry { @@ -164,14 +222,11 @@ impl GuardianTranscriptEntryKind { async fn run_guardian_review( session: Arc, turn: Arc, - request: GuardianReviewRequest, + request: GuardianApprovalRequest, retry_reason: Option, ) -> ReviewDecision { session - .notify_background_event( - turn.as_ref(), - "Guardian assessing approval request...".to_string(), - ) + .notify_background_event(turn.as_ref(), "Reviewing approval request...".to_string()) .await; let prompt_items = build_guardian_prompt_items(session.as_ref(), retry_reason, request).await; @@ -199,14 +254,15 @@ async fn run_guardian_review( Some(Err(err)) => GuardianAssessment { risk_level: GuardianRiskLevel::High, risk_score: 100, - rationale: format!("Guardian review failed: {err}"), + rationale: format!("Automatic approval review failed: {err}"), evidence: vec![], }, None => GuardianAssessment { risk_level: GuardianRiskLevel::High, risk_score: 100, - rationale: "Guardian review timed out while evaluating the requested approval." - .to_string(), + rationale: + "Automatic approval review timed out while evaluating the requested approval." + .to_string(), evidence: vec![], }, }; @@ -216,8 +272,7 @@ async fn run_guardian_review( // Emit a concise warning so the parent turn has an auditable summary of the // guardian decision without needing the full subagent transcript. let warning = format!( - "Guardian {verdict} approval request ({}/100, {}): {}", - assessment.risk_score, + "Automatic approval review {verdict} (risk: {}): {}", assessment.risk_level.as_str(), assessment.rationale ); @@ -239,7 +294,7 @@ async fn run_guardian_review( pub(crate) async fn review_approval_request( session: &Arc, turn: &Arc, - request: GuardianReviewRequest, + request: GuardianApprovalRequest, retry_reason: Option, ) -> ReviewDecision { run_guardian_review(Arc::clone(session), Arc::clone(turn), request, retry_reason).await @@ -256,11 +311,11 @@ pub(crate) async fn review_approval_request( async fn build_guardian_prompt_items( session: &Session, retry_reason: Option, - request: GuardianReviewRequest, + request: GuardianApprovalRequest, ) -> Vec { let history = session.clone_history().await; let transcript_entries = collect_guardian_transcript_entries(history.raw_items()); - let planned_action_json = format_guardian_action_pretty(&request.action); + let planned_action_json = format_guardian_action_pretty(&request); let (transcript_entries, omission_note) = render_guardian_transcript_entries(transcript_entries.as_slice()); @@ -400,6 +455,13 @@ fn render_guardian_transcript_entries( fn collect_guardian_transcript_entries(items: &[ResponseItem]) -> Vec { let mut entries = Vec::new(); let mut tool_names_by_call_id = HashMap::new(); + let non_empty_entry = |kind, text: String| { + (!text.trim().is_empty()).then_some(GuardianTranscriptEntry { kind, text }) + }; + let content_entry = + |kind, content| content_items_to_text(content).and_then(|text| non_empty_entry(kind, text)); + let serialized_entry = + |kind, serialized: Option| serialized.and_then(|text| non_empty_entry(kind, text)); for item in items { let entry = match item { @@ -407,25 +469,16 @@ fn collect_guardian_transcript_entries(items: &[ResponseItem]) -> Vec { - content_items_to_text(content).map(|text| GuardianTranscriptEntry { - kind: GuardianTranscriptEntryKind::Assistant, - text, - }) + content_entry(GuardianTranscriptEntryKind::Assistant, content) } - ResponseItem::LocalShellCall { action, .. } => serde_json::to_string(action) - .ok() - .filter(|text| !text.trim().is_empty()) - .map(|text| GuardianTranscriptEntry { - kind: GuardianTranscriptEntryKind::Tool("tool shell call".to_string()), - text, - }), + ResponseItem::LocalShellCall { action, .. } => serialized_entry( + GuardianTranscriptEntryKind::Tool("tool shell call".to_string()), + serde_json::to_string(action).ok(), + ), ResponseItem::FunctionCall { call_id, name, @@ -450,28 +503,26 @@ fn collect_guardian_transcript_entries(items: &[ResponseItem]) -> Vec action - .as_ref() - .and_then(|action| serde_json::to_string(action).ok()) - .filter(|text| !text.trim().is_empty()) - .map(|text| GuardianTranscriptEntry { - kind: GuardianTranscriptEntryKind::Tool("tool web_search call".to_string()), - text, - }), + ResponseItem::WebSearchCall { action, .. } => action.as_ref().and_then(|action| { + serialized_entry( + GuardianTranscriptEntryKind::Tool("tool web_search call".to_string()), + serde_json::to_string(action).ok(), + ) + }), ResponseItem::FunctionCallOutput { call_id, output } - | ResponseItem::CustomToolCallOutput { call_id, output } => output - .body - .to_text() - .filter(|text| !text.trim().is_empty()) - .map(|text| GuardianTranscriptEntry { - kind: GuardianTranscriptEntryKind::Tool( - tool_names_by_call_id.get(call_id).map_or_else( - || "tool result".to_string(), - |name| format!("tool {name} result"), + | ResponseItem::CustomToolCallOutput { call_id, output } => { + output.body.to_text().and_then(|text| { + non_empty_entry( + GuardianTranscriptEntryKind::Tool( + tool_names_by_call_id.get(call_id).map_or_else( + || "tool result".to_string(), + |name| format!("tool {name} result"), + ), ), - ), - text, - }), + text, + ) + }) + } _ => None, }; @@ -506,6 +557,13 @@ async fn run_guardian_subagent( .models_manager .list_models(crate::models_manager::manager::RefreshStrategy::Offline) .await; + let preferred_reasoning_effort = |supports_low: bool, fallback| { + if supports_low { + Some(codex_protocol::openai_models::ReasoningEffort::Low) + } else { + fallback + } + }; // Prefer `GUARDIAN_PREFERRED_MODEL` when the active provider exposes it, // but fall back to the parent turn's active model so guardian does not // become a blanket deny on providers or test environments that do not @@ -514,28 +572,23 @@ async fn run_guardian_subagent( .iter() .find(|preset| preset.model == GUARDIAN_PREFERRED_MODEL); let (guardian_model, guardian_reasoning_effort) = if let Some(preset) = preferred_model { - let reasoning_effort = if preset - .supported_reasoning_efforts - .iter() - .any(|effort| effort.effort == codex_protocol::openai_models::ReasoningEffort::Low) - { - Some(codex_protocol::openai_models::ReasoningEffort::Low) - } else { - Some(preset.default_reasoning_effort) - }; + let reasoning_effort = preferred_reasoning_effort( + preset + .supported_reasoning_efforts + .iter() + .any(|effort| effort.effort == codex_protocol::openai_models::ReasoningEffort::Low), + Some(preset.default_reasoning_effort), + ); (GUARDIAN_PREFERRED_MODEL.to_string(), reasoning_effort) } else { - let reasoning_effort = if turn - .model_info - .supported_reasoning_levels - .iter() - .any(|preset| preset.effort == codex_protocol::openai_models::ReasoningEffort::Low) - { - Some(codex_protocol::openai_models::ReasoningEffort::Low) - } else { + let reasoning_effort = preferred_reasoning_effort( + turn.model_info + .supported_reasoning_levels + .iter() + .any(|preset| preset.effort == codex_protocol::openai_models::ReasoningEffort::Low), turn.reasoning_effort - .or(turn.model_info.default_reasoning_level) - }; + .or(turn.model_info.default_reasoning_level), + ); (turn.model_info.slug.clone(), reasoning_effort) }; let guardian_config = build_guardian_subagent_config( @@ -678,9 +731,149 @@ fn truncate_guardian_action_value(value: Value) -> Value { } } -fn format_guardian_action_pretty(action: &Value) -> String { - serde_json::to_string_pretty(&truncate_guardian_action_value(action.clone())) - .unwrap_or_else(|_| "null".to_string()) +fn format_guardian_action_pretty(action: &GuardianApprovalRequest) -> String { + let mut value = match action { + GuardianApprovalRequest::Shell { + command, + cwd, + sandbox_permissions, + additional_permissions, + justification, + } => { + let mut action = serde_json::json!({ + "tool": "shell", + "command": command, + "cwd": cwd, + "sandbox_permissions": sandbox_permissions, + "additional_permissions": additional_permissions, + "justification": justification, + }); + if let Some(action) = action.as_object_mut() { + if additional_permissions.is_none() { + action.remove("additional_permissions"); + } + if justification.is_none() { + action.remove("justification"); + } + } + action + } + GuardianApprovalRequest::ExecCommand { + command, + cwd, + sandbox_permissions, + additional_permissions, + justification, + tty, + } => { + let mut action = serde_json::json!({ + "tool": "exec_command", + "command": command, + "cwd": cwd, + "sandbox_permissions": sandbox_permissions, + "additional_permissions": additional_permissions, + "justification": justification, + "tty": tty, + }); + if let Some(action) = action.as_object_mut() { + if additional_permissions.is_none() { + action.remove("additional_permissions"); + } + if justification.is_none() { + action.remove("justification"); + } + } + action + } + #[cfg(unix)] + GuardianApprovalRequest::Execve { + tool_name, + program, + argv, + cwd, + additional_permissions, + } => { + let mut action = serde_json::json!({ + "tool": tool_name, + "program": program, + "argv": argv, + "cwd": cwd, + "additional_permissions": additional_permissions, + }); + if let Some(action) = action.as_object_mut() + && additional_permissions.is_none() + { + action.remove("additional_permissions"); + } + action + } + GuardianApprovalRequest::ApplyPatch { + cwd, + files, + change_count, + patch, + } => serde_json::json!({ + "tool": "apply_patch", + "cwd": cwd, + "files": files, + "change_count": change_count, + "patch": patch, + }), + GuardianApprovalRequest::NetworkAccess { + target, + host, + protocol, + port, + } => serde_json::json!({ + "tool": "network_access", + "target": target, + "host": host, + "protocol": protocol, + "port": port, + }), + GuardianApprovalRequest::McpToolCall { + server, + tool_name, + arguments, + connector_id, + connector_name, + connector_description, + tool_title, + tool_description, + annotations, + } => { + let mut action = serde_json::json!({ + "tool": "mcp_tool_call", + "server": server, + "tool_name": tool_name, + "arguments": arguments, + "connector_id": connector_id, + "connector_name": connector_name, + "connector_description": connector_description, + "tool_title": tool_title, + "tool_description": tool_description, + "annotations": annotations, + }); + if let Some(action) = action.as_object_mut() { + for key in [ + ("arguments", arguments.is_none()), + ("connector_id", connector_id.is_none()), + ("connector_name", connector_name.is_none()), + ("connector_description", connector_description.is_none()), + ("tool_title", tool_title.is_none()), + ("tool_description", tool_description.is_none()), + ("annotations", annotations.is_none()), + ] { + if key.1 { + action.remove(key.0); + } + } + } + action + } + }; + value = truncate_guardian_action_value(value); + serde_json::to_string_pretty(&value).unwrap_or_else(|_| "null".to_string()) } fn guardian_truncate_text(content: &str, token_cap: usize) -> String { diff --git a/codex-rs/core/src/guardian_tests.rs b/codex-rs/core/src/guardian_tests.rs index e6deaf256b..dd342845f3 100644 --- a/codex-rs/core/src/guardian_tests.rs +++ b/codex-rs/core/src/guardian_tests.rs @@ -6,6 +6,7 @@ use crate::config_loader::FeatureRequirementsToml; use crate::config_loader::NetworkConstraints; use crate::config_loader::RequirementSource; use crate::config_loader::Sourced; +use crate::test_support; use codex_network_proxy::NetworkProxyConfig; use codex_protocol::models::ContentItem; use core_test_support::context_snapshot; @@ -22,6 +23,8 @@ use insta::assert_snapshot; use pretty_assertions::assert_eq; use std::collections::BTreeMap; use std::path::PathBuf; +use std::sync::Arc; +use tokio_util::sync::CancellationToken; #[test] fn build_guardian_transcript_keeps_original_numbering() { @@ -154,21 +157,18 @@ fn guardian_truncate_text_keeps_prefix_suffix_and_xml_marker() { #[test] fn format_guardian_action_pretty_truncates_large_string_fields() { - let action = serde_json::json!({ - "tool": "apply_patch", - "cwd": PathBuf::from("/tmp"), - "files": Vec::::new(), - "change_count": 1usize, - "patch": "line\n".repeat(10_000), - }); + let patch = "line\n".repeat(10_000); + let action = GuardianApprovalRequest::ApplyPatch { + cwd: PathBuf::from("/tmp"), + files: Vec::new(), + change_count: 1usize, + patch: patch.clone(), + }; let rendered = format_guardian_action_pretty(&action); - let original_patch = action["patch"] - .as_str() - .expect("test patch should serialize as a string"); assert!(rendered.contains("\"tool\": \"apply_patch\"")); - assert!(rendered.len() < original_patch.len()); + assert!(rendered.len() < patch.len()); } #[test] @@ -253,7 +253,7 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot() let mut config = (*turn.config).clone(); config.model_provider.base_url = Some(format!("{}/v1", server.uri())); let config = Arc::new(config); - let models_manager = Arc::new(crate::test_support::models_manager_with_provider( + let models_manager = Arc::new(test_support::models_manager_with_provider( config.codex_home.clone(), Arc::clone(&session.services.auth_manager), config.model_provider.clone(), @@ -307,19 +307,19 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot() let prompt = build_guardian_prompt_items( session.as_ref(), Some("Sandbox denied outbound git push to github.com.".to_string()), - GuardianReviewRequest { - action: serde_json::json!({ - "tool": "shell", - "command": [ - "git", - "push", - "origin", - "guardian-approval-mvp" - ], - "cwd": "/repo/codex-rs/core", - "sandbox_permissions": crate::sandboxing::SandboxPermissions::UseDefault, - "justification": "Need to push the reviewed docs fix to the repo remote.", - }), + GuardianApprovalRequest::Shell { + command: vec![ + "git".to_string(), + "push".to_string(), + "origin".to_string(), + "guardian-approval-mvp".to_string(), + ], + cwd: PathBuf::from("/repo/codex-rs/core"), + sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, + additional_permissions: None, + justification: Some( + "Need to push the reviewed docs fix to the repo remote.".to_string(), + ), }, ) .await; diff --git a/codex-rs/core/src/landlock.rs b/codex-rs/core/src/landlock.rs index d022adbc78..e90daf37f2 100644 --- a/codex-rs/core/src/landlock.rs +++ b/codex-rs/core/src/landlock.rs @@ -33,7 +33,8 @@ pub async fn spawn_command_under_linux_sandbox

( where P: AsRef, { - let file_system_sandbox_policy = FileSystemSandboxPolicy::from(sandbox_policy); + let file_system_sandbox_policy = + FileSystemSandboxPolicy::from_legacy_sandbox_policy(sandbox_policy, sandbox_policy_cwd); let network_sandbox_policy = NetworkSandboxPolicy::from(sandbox_policy); let args = create_linux_sandbox_command_args_for_policies( command, diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index e339d87221..d133441c39 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -1739,6 +1739,7 @@ mod tests { RejectConfig { sandbox_approval: false, rules: false, + request_permissions: false, mcp_elicitations: false, } ))); @@ -1751,6 +1752,7 @@ mod tests { RejectConfig { sandbox_approval: false, rules: false, + request_permissions: false, mcp_elicitations: true, } ))); diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index be0ca35af1..29d1de5b6d 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -18,7 +18,8 @@ use crate::config::edit::ConfigEditsBuilder; use crate::config::types::AppToolApproval; use crate::connectors; use crate::features::Feature; -use crate::guardian::GuardianReviewRequest; +use crate::guardian::GuardianApprovalRequest; +use crate::guardian::GuardianMcpAnnotations; use crate::guardian::review_approval_request; use crate::guardian::routes_approval_to_guardian; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; @@ -574,88 +575,23 @@ fn persistent_mcp_tool_approval_key( fn build_guardian_mcp_tool_review_request( invocation: &McpInvocation, metadata: Option<&McpToolApprovalMetadata>, -) -> GuardianReviewRequest { - let mut action = serde_json::Map::from_iter([ - ( - "tool".to_string(), - serde_json::Value::String("mcp_tool_call".to_string()), - ), - ( - "server".to_string(), - serde_json::Value::String(invocation.server.clone()), - ), - ( - "tool_name".to_string(), - serde_json::Value::String(invocation.tool.clone()), - ), - ]); - - if let Some(arguments) = invocation.arguments.clone() { - action.insert("arguments".to_string(), arguments); - } - - if let Some(metadata) = metadata { - if let Some(connector_id) = metadata.connector_id.as_ref() { - action.insert( - "connector_id".to_string(), - serde_json::Value::String(connector_id.clone()), - ); - } - if let Some(connector_name) = metadata.connector_name.as_ref() { - action.insert( - "connector_name".to_string(), - serde_json::Value::String(connector_name.clone()), - ); - } - if let Some(connector_description) = metadata.connector_description.as_ref() { - action.insert( - "connector_description".to_string(), - serde_json::Value::String(connector_description.clone()), - ); - } - if let Some(tool_title) = metadata.tool_title.as_ref() { - action.insert( - "tool_title".to_string(), - serde_json::Value::String(tool_title.clone()), - ); - } - if let Some(tool_description) = metadata.tool_description.as_ref() { - action.insert( - "tool_description".to_string(), - serde_json::Value::String(tool_description.clone()), - ); - } - if let Some(annotations) = metadata.annotations.as_ref() { - let mut annotation_map = serde_json::Map::new(); - if let Some(destructive_hint) = annotations.destructive_hint { - annotation_map.insert( - "destructive_hint".to_string(), - serde_json::Value::Bool(destructive_hint), - ); - } - if let Some(open_world_hint) = annotations.open_world_hint { - annotation_map.insert( - "open_world_hint".to_string(), - serde_json::Value::Bool(open_world_hint), - ); - } - if let Some(read_only_hint) = annotations.read_only_hint { - annotation_map.insert( - "read_only_hint".to_string(), - serde_json::Value::Bool(read_only_hint), - ); - } - if !annotation_map.is_empty() { - action.insert( - "annotations".to_string(), - serde_json::Value::Object(annotation_map), - ); - } - } - } - - GuardianReviewRequest { - action: serde_json::Value::Object(action), +) -> GuardianApprovalRequest { + GuardianApprovalRequest::McpToolCall { + server: invocation.server.clone(), + tool_name: invocation.tool.clone(), + arguments: invocation.arguments.clone(), + connector_id: metadata.and_then(|metadata| metadata.connector_id.clone()), + connector_name: metadata.and_then(|metadata| metadata.connector_name.clone()), + connector_description: metadata.and_then(|metadata| metadata.connector_description.clone()), + tool_title: metadata.and_then(|metadata| metadata.tool_title.clone()), + tool_description: metadata.and_then(|metadata| metadata.tool_description.clone()), + annotations: metadata + .and_then(|metadata| metadata.annotations.as_ref()) + .map(|annotations| GuardianMcpAnnotations { + destructive_hint: annotations.destructive_hint, + open_world_hint: annotations.open_world_hint, + read_only_hint: annotations.read_only_hint, + }), } } @@ -1599,20 +1535,18 @@ mod tests { assert_eq!( request, - GuardianReviewRequest { - action: serde_json::json!({ - "tool": "mcp_tool_call", - "server": CODEX_APPS_MCP_SERVER_NAME, - "tool_name": "browser_navigate", - "arguments": { - "url": "https://example.com", - }, - "connector_id": "playwright", - "connector_name": "Playwright", - "connector_description": "Browser automation", - "tool_title": "Navigate", - "tool_description": "Open a page", - }), + GuardianApprovalRequest::McpToolCall { + server: CODEX_APPS_MCP_SERVER_NAME.to_string(), + tool_name: "browser_navigate".to_string(), + arguments: Some(serde_json::json!({ + "url": "https://example.com", + })), + connector_id: Some("playwright".to_string()), + connector_name: Some("Playwright".to_string()), + connector_description: Some("Browser automation".to_string()), + tool_title: Some("Navigate".to_string()), + tool_description: Some("Open a page".to_string()), + annotations: None, } ); } @@ -1637,16 +1571,19 @@ mod tests { assert_eq!( request, - GuardianReviewRequest { - action: serde_json::json!({ - "tool": "mcp_tool_call", - "server": "custom_server", - "tool_name": "dangerous_tool", - "annotations": { - "destructive_hint": true, - "open_world_hint": true, - "read_only_hint": false, - }, + GuardianApprovalRequest::McpToolCall { + server: "custom_server".to_string(), + tool_name: "dangerous_tool".to_string(), + arguments: None, + connector_id: None, + connector_name: None, + connector_description: None, + tool_title: None, + tool_description: None, + annotations: Some(GuardianMcpAnnotations { + destructive_hint: Some(true), + open_world_hint: Some(true), + read_only_hint: Some(false), }), } ); diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index 9e425bcfee..153e4ee4d1 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -10,6 +10,7 @@ use super::plugin_manifest_name; use super::plugin_manifest_paths; use super::store::DEFAULT_PLUGIN_VERSION; use super::store::PluginId; +use super::store::PluginIdError; use super::store::PluginInstallResult; use super::store::PluginStore; use super::store::PluginStoreError; @@ -18,6 +19,8 @@ use crate::config::Config; use crate::config::ConfigService; use crate::config::ConfigServiceError; use crate::config::ConfigToml; +use crate::config::edit::ConfigEdit; +use crate::config::edit::ConfigEditsBuilder; use crate::config::profile::ConfigProfile; use crate::config::types::McpServerConfig; use crate::config::types::PluginConfig; @@ -290,6 +293,24 @@ impl PluginsManager { Ok(result) } + pub async fn uninstall_plugin(&self, plugin_id: String) -> Result<(), PluginUninstallError> { + let plugin_id = PluginId::parse(&plugin_id)?; + let store = self.store.clone(); + let plugin_id_for_store = plugin_id.clone(); + tokio::task::spawn_blocking(move || store.uninstall(&plugin_id_for_store)) + .await + .map_err(PluginUninstallError::join)??; + + ConfigEditsBuilder::new(&self.codex_home) + .with_edits([ConfigEdit::ClearPath { + segments: vec!["plugins".to_string(), plugin_id.as_key()], + }]) + .apply() + .await?; + + Ok(()) + } + pub fn list_marketplaces_for_config( &self, config: &Config, @@ -428,6 +449,31 @@ impl PluginInstallError { } } +#[derive(Debug, thiserror::Error)] +pub enum PluginUninstallError { + #[error("{0}")] + InvalidPluginId(#[from] PluginIdError), + + #[error("{0}")] + Store(#[from] PluginStoreError), + + #[error("{0}")] + Config(#[from] anyhow::Error), + + #[error("failed to join plugin uninstall task: {0}")] + Join(#[from] tokio::task::JoinError), +} + +impl PluginUninstallError { + fn join(source: tokio::task::JoinError) -> Self { + Self::Join(source) + } + + pub fn is_invalid_request(&self) -> bool { + matches!(self, Self::InvalidPluginId(_)) + } +} + fn plugins_feature_enabled_from_stack(config_layer_stack: &ConfigLayerStack) -> bool { // Plugins are intentionally opt-in from the persisted user config only. Project config // layers should not be able to enable plugin loading for a checkout. @@ -1553,6 +1599,43 @@ mod tests { assert!(config.contains("enabled = true")); } + #[tokio::test] + async fn uninstall_plugin_removes_cache_and_config_entry() { + let tmp = tempfile::tempdir().unwrap(); + write_plugin( + &tmp.path().join("plugins/cache/debug"), + "sample-plugin/local", + "sample-plugin", + ); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."sample-plugin@debug"] +enabled = true +"#, + ); + + let manager = PluginsManager::new(tmp.path().to_path_buf()); + manager + .uninstall_plugin("sample-plugin@debug".to_string()) + .await + .unwrap(); + manager + .uninstall_plugin("sample-plugin@debug".to_string()) + .await + .unwrap(); + + assert!( + !tmp.path() + .join("plugins/cache/debug/sample-plugin") + .exists() + ); + let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); + assert!(!config.contains(r#"[plugins."sample-plugin@debug"]"#)); + } + #[tokio::test] async fn list_marketplaces_includes_enabled_state() { let tmp = tempfile::tempdir().unwrap(); diff --git a/codex-rs/core/src/plugins/mod.rs b/codex-rs/core/src/plugins/mod.rs index 179647854c..8a34ba9add 100644 --- a/codex-rs/core/src/plugins/mod.rs +++ b/codex-rs/core/src/plugins/mod.rs @@ -17,6 +17,7 @@ pub use manager::PluginCapabilitySummary; pub use manager::PluginInstallError; pub use manager::PluginInstallRequest; pub use manager::PluginLoadOutcome; +pub use manager::PluginUninstallError; pub use manager::PluginsManager; pub use manager::load_plugin_apps; pub(crate) use manager::plugin_namespace_for_skill_path; diff --git a/codex-rs/core/src/plugins/store.rs b/codex-rs/core/src/plugins/store.rs index 60da25ecb7..0611df9a07 100644 --- a/codex-rs/core/src/plugins/store.rs +++ b/codex-rs/core/src/plugins/store.rs @@ -135,6 +135,15 @@ impl PluginStore { installed_path, }) } + + pub fn uninstall(&self, plugin_id: &PluginId) -> Result<(), PluginStoreError> { + let plugin_path = self + .root + .as_path() + .join(&plugin_id.marketplace_name) + .join(&plugin_id.plugin_name); + remove_existing_target(&plugin_path) + } } #[derive(Debug, thiserror::Error)] diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index 92b6076812..aff6759da9 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -144,6 +144,7 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option { | EventMsg::TerminalInteraction(_) | EventMsg::ExecCommandOutputDelta(_) | EventMsg::ExecApprovalRequest(_) + | EventMsg::RequestPermissions(_) | EventMsg::RequestUserInput(_) | EventMsg::ElicitationRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) diff --git a/codex-rs/core/src/safety.rs b/codex-rs/core/src/safety.rs index d2e74beed8..7f39a7f6e6 100644 --- a/codex-rs/core/src/safety.rs +++ b/codex-rs/core/src/safety.rs @@ -204,6 +204,7 @@ mod tests { use codex_protocol::protocol::FileSystemSpecialPath; use codex_protocol::protocol::RejectConfig; use codex_utils_absolute_path::AbsolutePathBuf; + use pretty_assertions::assert_eq; use tempfile::TempDir; #[test] @@ -316,6 +317,7 @@ mod tests { AskForApproval::Reject(RejectConfig { sandbox_approval: false, rules: false, + request_permissions: false, mcp_elicitations: false, }), &policy_workspace_only, @@ -348,6 +350,7 @@ mod tests { AskForApproval::Reject(RejectConfig { sandbox_approval: true, rules: false, + request_permissions: false, mcp_elicitations: false, }), &policy_workspace_only, @@ -403,4 +406,47 @@ mod tests { SafetyCheck::AskUser, ); } + + #[test] + fn explicit_read_only_subpaths_prevent_auto_approval_for_external_sandbox() { + let tmp = TempDir::new().unwrap(); + let cwd = tmp.path().to_path_buf(); + let blocked_path = cwd.join("docs").join("blocked.txt"); + let docs_absolute = AbsolutePathBuf::resolve_path_against_base("docs", &cwd).unwrap(); + let action = ApplyPatchAction::new_add_for_test(&blocked_path, "".to_string()); + let sandbox_policy = SandboxPolicy::ExternalSandbox { + network_access: codex_protocol::protocol::NetworkAccess::Restricted, + }; + let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: docs_absolute, + }, + access: FileSystemAccessMode::Read, + }, + ]); + + assert!(!is_write_patch_constrained_to_writable_paths( + &action, + &file_system_sandbox_policy, + &cwd, + )); + assert_eq!( + assess_patch_safety( + &action, + AskForApproval::OnRequest, + &sandbox_policy, + &file_system_sandbox_policy, + &cwd, + WindowsSandboxLevel::Disabled, + ), + SafetyCheck::AskUser, + ); + } } diff --git a/codex-rs/core/src/sandboxing/macos_permissions.rs b/codex-rs/core/src/sandboxing/macos_permissions.rs index 3dfe8d6c95..c3b3840d4d 100644 --- a/codex-rs/core/src/sandboxing/macos_permissions.rs +++ b/codex-rs/core/src/sandboxing/macos_permissions.rs @@ -31,6 +31,28 @@ pub(crate) fn merge_macos_seatbelt_profile_extensions( } } +pub(crate) fn intersect_macos_seatbelt_profile_extensions( + requested: Option, + granted: Option, +) -> Option { + match (requested, granted) { + (Some(requested), Some(granted)) => { + let macos_automation = intersect_macos_automation_permission( + &requested.macos_automation, + &granted.macos_automation, + ); + + Some(MacOsSeatbeltProfileExtensions { + macos_preferences: requested.macos_preferences.min(granted.macos_preferences), + macos_automation, + macos_accessibility: requested.macos_accessibility && granted.macos_accessibility, + macos_calendar: requested.macos_calendar && granted.macos_calendar, + }) + } + _ => None, + } +} + /// Unions two preferences permissions by keeping the more permissive one. /// /// The larger rank wins: `None < ReadOnly < ReadWrite`. When both sides have @@ -75,8 +97,40 @@ fn union_macos_automation_permission( } } +fn intersect_macos_automation_permission( + requested: &MacOsAutomationPermission, + granted: &MacOsAutomationPermission, +) -> MacOsAutomationPermission { + match (requested, granted) { + (_, MacOsAutomationPermission::None) | (MacOsAutomationPermission::None, _) => { + MacOsAutomationPermission::None + } + (MacOsAutomationPermission::All, granted) => granted.clone(), + (MacOsAutomationPermission::BundleIds(requested), MacOsAutomationPermission::All) => { + MacOsAutomationPermission::BundleIds(requested.clone()) + } + ( + MacOsAutomationPermission::BundleIds(requested), + MacOsAutomationPermission::BundleIds(granted), + ) => { + let bundle_ids = requested + .iter() + .filter(|bundle_id| granted.contains(bundle_id)) + .cloned() + .collect::>(); + if bundle_ids.is_empty() { + MacOsAutomationPermission::None + } else { + MacOsAutomationPermission::BundleIds(bundle_ids) + } + } + } +} + #[cfg(all(test, target_os = "macos"))] mod tests { + use super::intersect_macos_automation_permission; + use super::intersect_macos_seatbelt_profile_extensions; use super::merge_macos_seatbelt_profile_extensions; use super::union_macos_automation_permission; use super::union_macos_preferences_permission; @@ -141,4 +195,38 @@ mod tests { assert_eq!(merged, MacOsAutomationPermission::All); } + + #[test] + fn intersect_macos_automation_permission_keeps_common_bundle_ids() { + let requested = MacOsAutomationPermission::BundleIds(vec![ + "com.apple.Notes".to_string(), + "com.apple.Calendar".to_string(), + ]); + let granted = MacOsAutomationPermission::BundleIds(vec!["com.apple.Notes".to_string()]); + + let intersected = intersect_macos_automation_permission(&requested, &granted); + + assert_eq!( + intersected, + MacOsAutomationPermission::BundleIds(vec!["com.apple.Notes".to_string()]) + ); + } + + #[test] + fn intersect_macos_seatbelt_profile_extensions_preserves_default_grant() { + let requested = MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::ReadWrite, + macos_automation: MacOsAutomationPermission::BundleIds(vec![ + "com.apple.Notes".to_string(), + ]), + macos_accessibility: true, + macos_calendar: true, + }; + let granted = MacOsSeatbeltProfileExtensions::default(); + + let intersected = + intersect_macos_seatbelt_profile_extensions(Some(requested), Some(granted)); + + assert_eq!(intersected, Some(MacOsSeatbeltProfileExtensions::default())); + } } diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index c43322ca78..2fb0b45f8c 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -28,6 +28,7 @@ use codex_network_proxy::NetworkProxy; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::MacOsSeatbeltProfileExtensions; +use codex_protocol::models::NetworkPermissions; use codex_protocol::models::PermissionProfile; pub use codex_protocol::models::SandboxPermissions; use codex_protocol::permissions::FileSystemAccessMode; @@ -40,6 +41,7 @@ use codex_protocol::protocol::NetworkAccess; use codex_protocol::protocol::ReadOnlyAccess; use codex_utils_absolute_path::AbsolutePathBuf; use dunce::canonicalize; +use macos_permissions::intersect_macos_seatbelt_profile_extensions; use macos_permissions::merge_macos_seatbelt_profile_extensions; use std::collections::HashMap; use std::collections::HashSet; @@ -146,22 +148,137 @@ impl EffectiveSandboxPermissions { pub(crate) fn normalize_additional_permissions( additional_permissions: PermissionProfile, ) -> Result { + let network = additional_permissions + .network + .filter(|network| !network.is_empty()); + let file_system = additional_permissions + .file_system + .map(|file_system| { + let read = file_system + .read + .map(|paths| normalize_permission_paths(paths, "file_system.read")); + let write = file_system + .write + .map(|paths| normalize_permission_paths(paths, "file_system.write")); + FileSystemPermissions { read, write } + }) + .filter(|file_system| !file_system.is_empty()); + let macos = additional_permissions.macos; + Ok(PermissionProfile { - network: additional_permissions.network, - file_system: additional_permissions - .file_system - .map(|file_system| FileSystemPermissions { - read: file_system - .read - .map(|paths| normalize_permission_paths(paths, "file_system.read")), - write: file_system - .write - .map(|paths| normalize_permission_paths(paths, "file_system.write")), - }), - macos: additional_permissions.macos, + network, + file_system, + macos, }) } +pub(crate) fn merge_permission_profiles( + base: Option<&PermissionProfile>, + permissions: Option<&PermissionProfile>, +) -> Option { + let Some(permissions) = permissions else { + return base.cloned(); + }; + + match base { + Some(base) => { + let network = match (base.network.as_ref(), permissions.network.as_ref()) { + ( + Some(NetworkPermissions { + enabled: Some(true), + }), + _, + ) + | ( + _, + Some(NetworkPermissions { + enabled: Some(true), + }), + ) => Some(NetworkPermissions { + enabled: Some(true), + }), + _ => None, + }; + let file_system = match (base.file_system.as_ref(), permissions.file_system.as_ref()) { + (Some(base), Some(permissions)) => Some(FileSystemPermissions { + read: merge_permission_paths(base.read.as_ref(), permissions.read.as_ref()), + write: merge_permission_paths(base.write.as_ref(), permissions.write.as_ref()), + }) + .filter(|file_system| !file_system.is_empty()), + (Some(base), None) => Some(base.clone()), + (None, Some(permissions)) => Some(permissions.clone()), + (None, None) => None, + }; + let macos = merge_macos_seatbelt_profile_extensions( + base.macos.as_ref(), + permissions.macos.as_ref(), + ); + + Some(PermissionProfile { + network, + file_system, + macos, + }) + .filter(|permissions| !permissions.is_empty()) + } + None => Some(permissions.clone()).filter(|permissions| !permissions.is_empty()), + } +} + +pub fn intersect_permission_profiles( + requested: PermissionProfile, + granted: PermissionProfile, +) -> PermissionProfile { + let file_system = requested + .file_system + .map(|requested_file_system| { + let granted_file_system = granted.file_system.unwrap_or_default(); + let read = requested_file_system + .read + .map(|requested_read| { + let granted_read = granted_file_system.read.unwrap_or_default(); + requested_read + .into_iter() + .filter(|path| granted_read.contains(path)) + .collect() + }) + .filter(|paths: &Vec<_>| !paths.is_empty()); + let write = requested_file_system + .write + .map(|requested_write| { + let granted_write = granted_file_system.write.unwrap_or_default(); + requested_write + .into_iter() + .filter(|path| granted_write.contains(path)) + .collect() + }) + .filter(|paths: &Vec<_>| !paths.is_empty()); + FileSystemPermissions { read, write } + }) + .filter(|file_system| !file_system.is_empty()); + let network = match (requested.network, granted.network) { + ( + Some(NetworkPermissions { + enabled: Some(true), + }), + Some(NetworkPermissions { + enabled: Some(true), + }), + ) => Some(NetworkPermissions { + enabled: Some(true), + }), + _ => None, + }; + + let macos = intersect_macos_seatbelt_profile_extensions(requested.macos, granted.macos); + + PermissionProfile { + network, + file_system, + macos, + } +} + fn normalize_permission_paths( paths: Vec, _permission_kind: &str, @@ -182,6 +299,29 @@ fn normalize_permission_paths( out } +fn merge_permission_paths( + base: Option<&Vec>, + permissions: Option<&Vec>, +) -> Option> { + match (base, permissions) { + (Some(base), Some(permissions)) => { + let mut merged = Vec::with_capacity(base.len() + permissions.len()); + let mut seen = HashSet::with_capacity(base.len() + permissions.len()); + + for path in base.iter().chain(permissions.iter()) { + if seen.insert(path.clone()) { + merged.push(path.clone()); + } + } + + Some(merged).filter(|paths| !paths.is_empty()) + } + (Some(base), None) => Some(base.clone()), + (None, Some(permissions)) => Some(permissions.clone()), + (None, None) => None, + } +} + fn dedup_absolute_paths(paths: Vec) -> Vec { let mut out = Vec::with_capacity(paths.len()); let mut seen = HashSet::new(); @@ -581,6 +721,8 @@ mod tests { #[cfg(target_os = "macos")] use super::EffectiveSandboxPermissions; use super::SandboxManager; + #[cfg(target_os = "macos")] + use super::intersect_permission_profiles; use super::merge_file_system_policy_with_additional_permissions; use super::normalize_additional_permissions; use super::sandbox_policy_with_additional_permissions; @@ -791,6 +933,77 @@ mod tests { ); } + #[test] + fn normalize_additional_permissions_drops_empty_nested_profiles() { + let permissions = normalize_additional_permissions(PermissionProfile { + network: Some(NetworkPermissions { enabled: None }), + file_system: Some(FileSystemPermissions { + read: None, + write: None, + }), + macos: None, + }) + .expect("permissions"); + + assert_eq!(permissions, PermissionProfile::default()); + } + + #[cfg(target_os = "macos")] + #[test] + fn normalize_additional_permissions_preserves_default_macos_preferences_permission() { + let permissions = normalize_additional_permissions(PermissionProfile { + macos: Some(MacOsSeatbeltProfileExtensions::default()), + ..Default::default() + }) + .expect("permissions"); + + assert_eq!( + permissions, + PermissionProfile { + macos: Some(MacOsSeatbeltProfileExtensions::default()), + ..Default::default() + } + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn intersect_permission_profiles_preserves_default_macos_grants() { + let requested = PermissionProfile { + file_system: Some(FileSystemPermissions { + read: Some(Vec::from(["/tmp/requested" + .try_into() + .expect("absolute path")])), + write: None, + }), + macos: Some(MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::ReadWrite, + macos_automation: MacOsAutomationPermission::BundleIds(vec![ + "com.apple.Notes".to_string(), + ]), + macos_accessibility: true, + macos_calendar: true, + }), + ..Default::default() + }; + let granted = PermissionProfile { + file_system: Some(FileSystemPermissions { + read: Some(Vec::new()), + write: None, + }), + macos: Some(MacOsSeatbeltProfileExtensions::default()), + ..Default::default() + }; + + assert_eq!( + intersect_permission_profiles(requested, granted), + PermissionProfile { + macos: Some(MacOsSeatbeltProfileExtensions::default()), + ..Default::default() + } + ); + } + #[cfg(target_os = "macos")] #[test] fn normalize_additional_permissions_preserves_macos_permissions() { diff --git a/codex-rs/core/src/seatbelt.rs b/codex-rs/core/src/seatbelt.rs index 8550bf8d7a..dede3d0553 100644 --- a/codex-rs/core/src/seatbelt.rs +++ b/codex-rs/core/src/seatbelt.rs @@ -331,10 +331,9 @@ pub(crate) fn create_seatbelt_command_args( enforce_managed_network: bool, network: Option<&NetworkProxy>, ) -> Vec { - create_seatbelt_command_args_for_policies_with_extensions( + create_seatbelt_command_args_with_extensions( command, - &FileSystemSandboxPolicy::from(sandbox_policy), - NetworkSandboxPolicy::from(sandbox_policy), + sandbox_policy, sandbox_policy_cwd, enforce_managed_network, network, @@ -410,7 +409,7 @@ pub(crate) fn create_seatbelt_command_args_with_extensions( ) -> Vec { create_seatbelt_command_args_for_policies_with_extensions( command, - &FileSystemSandboxPolicy::from(sandbox_policy), + &FileSystemSandboxPolicy::from_legacy_sandbox_policy(sandbox_policy, sandbox_policy_cwd), NetworkSandboxPolicy::from(sandbox_policy), sandbox_policy_cwd, enforce_managed_network, @@ -596,6 +595,7 @@ mod tests { use super::normalize_path_for_sandbox; use super::unix_socket_dir_params; use super::unix_socket_policy; + use crate::protocol::ReadOnlyAccess; use crate::protocol::SandboxPolicy; use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; use crate::seatbelt_permissions::MacOsAutomationPermission; @@ -865,6 +865,40 @@ sys.exit(0 if allowed else 13) assert!(!policy.contains("(allow user-preference-write)")); } + #[test] + fn seatbelt_legacy_workspace_write_nested_readable_root_stays_writable() { + let tmp = TempDir::new().expect("tempdir"); + let cwd = tmp.path().join("workspace"); + fs::create_dir_all(cwd.join("docs")).expect("create docs"); + let docs = AbsolutePathBuf::from_absolute_path(cwd.join("docs")).expect("absolute docs"); + let args = create_seatbelt_command_args( + vec!["/bin/true".to_string()], + &SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: ReadOnlyAccess::Restricted { + include_platform_defaults: true, + readable_roots: vec![docs.clone()], + }, + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }, + cwd.as_path(), + false, + None, + ); + + let docs_param = format!("-DWRITABLE_ROOT_0_RO_0={}", docs.as_path().display()); + assert!( + !seatbelt_policy_arg(&args).contains("WRITABLE_ROOT_0_RO_0"), + "legacy workspace-write readable roots under cwd should not become seatbelt carveouts:\n{args:#?}" + ); + assert!( + !args.iter().any(|arg| arg == &docs_param), + "unexpected seatbelt carveout parameter for redundant legacy readable root: {args:#?}" + ); + } + #[test] fn seatbelt_args_default_extension_profile_keeps_preferences_read_access() { let cwd = std::env::temp_dir(); diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index 5d285cbfa8..c10a332245 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -740,7 +740,13 @@ mod tests { let dir = tempdir()?; let home = dir.path(); - fs::write(home.join(".bashrc"), "read -r ignored\n").await?; + let read_status_path = home.join("stdin-read-status"); + let read_status_display = read_status_path.display(); + // Persist the startup `read` exit status so the test can assert whether + // bash saw EOF on stdin after the snapshot process exits. + let bashrc = + format!("read -t 1 -r ignored\nprintf '%s' \"$?\" > \"{read_status_display}\"\n"); + fs::write(home.join(".bashrc"), bashrc).await?; let shell = Shell { shell_type: ShellType::Bash, @@ -753,10 +759,17 @@ mod tests { "HOME=\"{home_display}\"; export HOME; {}", bash_snapshot_script() ); - let output = - run_script_with_timeout(&shell, &script, Duration::from_millis(500), true, home) - .await - .context("run snapshot command")?; + let output = run_script_with_timeout(&shell, &script, Duration::from_secs(2), true, home) + .await + .context("run snapshot command")?; + let read_status = fs::read_to_string(&read_status_path) + .await + .context("read stdin probe status")?; + + assert_eq!( + read_status, "1", + "expected shell startup read to see EOF on stdin; status={read_status:?}" + ); assert!( output.contains("# Snapshot file"), diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 576db7cf3c..cc4e128045 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -1,5 +1,6 @@ //! Session-wide mutable state. +use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; use std::collections::HashMap; use std::collections::HashSet; @@ -32,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) startup_regular_task: Option>>, pub(crate) active_mcp_tool_selection: Option>, pub(crate) active_connector_selection: HashSet, + granted_permissions: Option, } impl SessionState { @@ -49,6 +51,7 @@ impl SessionState { startup_regular_task: None, active_mcp_tool_selection: None, active_connector_selection: HashSet::new(), + granted_permissions: None, } } @@ -218,6 +221,17 @@ impl SessionState { self.active_mcp_tool_selection = None; } + pub(crate) fn record_granted_permissions(&mut self, permissions: PermissionProfile) { + self.granted_permissions = crate::sandboxing::merge_permission_profiles( + self.granted_permissions.as_ref(), + Some(&permissions), + ); + } + + pub(crate) fn granted_permissions(&self) -> Option { + self.granted_permissions.clone() + } + // Adds connector IDs to the active set and returns the merged selection. pub(crate) fn merge_connector_selection(&mut self, connector_ids: I) -> HashSet where diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index 4bbcf21c92..e2e141d387 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -10,6 +10,7 @@ use tokio_util::task::AbortOnDropHandle; use codex_protocol::dynamic_tools::DynamicToolResponse; use codex_protocol::models::ResponseInputItem; +use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_rmcp_client::ElicitationResponse; use rmcp::model::RequestId; @@ -18,7 +19,9 @@ use tokio::sync::oneshot; use crate::codex::TurnContext; use crate::protocol::ReviewDecision; use crate::protocol::TokenUsage; +use crate::sandboxing::merge_permission_profiles; use crate::tasks::SessionTask; +use codex_protocol::models::PermissionProfile; /// Metadata about the currently running turn. pub(crate) struct ActiveTurn { @@ -73,10 +76,12 @@ impl ActiveTurn { #[derive(Default)] pub(crate) struct TurnState { pending_approvals: HashMap>, + pending_request_permissions: HashMap>, pending_user_input: HashMap>, pending_elicitations: HashMap<(String, RequestId), oneshot::Sender>, pending_dynamic_tools: HashMap>, pending_input: Vec, + granted_permissions: Option, pub(crate) tool_calls: u64, pub(crate) token_usage_at_turn_start: TokenUsage, } @@ -99,12 +104,28 @@ impl TurnState { pub(crate) fn clear_pending(&mut self) { self.pending_approvals.clear(); + self.pending_request_permissions.clear(); self.pending_user_input.clear(); self.pending_elicitations.clear(); self.pending_dynamic_tools.clear(); self.pending_input.clear(); } + pub(crate) fn insert_pending_request_permissions( + &mut self, + key: String, + tx: oneshot::Sender, + ) -> Option> { + self.pending_request_permissions.insert(key, tx) + } + + pub(crate) fn remove_pending_request_permissions( + &mut self, + key: &str, + ) -> Option> { + self.pending_request_permissions.remove(key) + } + pub(crate) fn insert_pending_user_input( &mut self, key: String, @@ -171,6 +192,15 @@ impl TurnState { pub(crate) fn has_pending_input(&self) -> bool { !self.pending_input.is_empty() } + + pub(crate) fn record_granted_permissions(&mut self, permissions: PermissionProfile) { + self.granted_permissions = + merge_permission_profiles(self.granted_permissions.as_ref(), Some(&permissions)); + } + + pub(crate) fn granted_permissions(&self) -> Option { + self.granted_permissions.clone() + } } impl ActiveTurn { diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index a07e52f730..f59d2fa0d8 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -33,6 +33,9 @@ use crate::protocol::TurnCompleteEvent; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; +use codex_otel::metrics::names::TURN_E2E_DURATION_METRIC; +use codex_otel::metrics::names::TURN_TOKEN_USAGE_METRIC; +use codex_otel::metrics::names::TURN_TOOL_CALL_METRIC; use codex_protocol::items::TurnItem; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseInputItem; @@ -145,7 +148,7 @@ impl Session { let timer = turn_context .session_telemetry - .start_timer("codex.turn.e2e_duration_ms", &[]) + .start_timer(TURN_E2E_DURATION_METRIC, &[]) .ok(); let done_clone = Arc::clone(&done); @@ -201,8 +204,13 @@ impl Session { } pub async fn abort_all_tasks(self: &Arc, reason: TurnAbortReason) { - for task in self.take_all_running_tasks().await { - self.handle_task_abort(task, reason.clone()).await; + if let Some(mut active_turn) = self.take_active_turn().await { + for task in active_turn.drain_tasks() { + self.handle_task_abort(task, reason.clone()).await; + } + // Let interrupted tasks observe cancellation before dropping pending approvals, or an + // in-flight approval wait can surface as a model-visible rejection before TurnAborted. + active_turn.clear_pending().await; } if reason == TurnAbortReason::Interrupted { self.close_unified_exec_processes().await; @@ -273,7 +281,7 @@ impl Session { }, ); self.services.session_telemetry.histogram( - "codex.turn.tool.call", + TURN_TOOL_CALL_METRIC, i64::try_from(turn_tool_calls).unwrap_or(i64::MAX), &[tmp_mem], ); @@ -296,27 +304,27 @@ impl Session { .max(0), }; self.services.session_telemetry.histogram( - "codex.turn.token_usage", + TURN_TOKEN_USAGE_METRIC, turn_token_usage.total_tokens, &[("token_type", "total"), tmp_mem], ); self.services.session_telemetry.histogram( - "codex.turn.token_usage", + TURN_TOKEN_USAGE_METRIC, turn_token_usage.input_tokens, &[("token_type", "input"), tmp_mem], ); self.services.session_telemetry.histogram( - "codex.turn.token_usage", + TURN_TOKEN_USAGE_METRIC, turn_token_usage.cached_input(), &[("token_type", "cached_input"), tmp_mem], ); self.services.session_telemetry.histogram( - "codex.turn.token_usage", + TURN_TOKEN_USAGE_METRIC, turn_token_usage.output_tokens, &[("token_type", "output"), tmp_mem], ); self.services.session_telemetry.histogram( - "codex.turn.token_usage", + TURN_TOKEN_USAGE_METRIC, turn_token_usage.reasoning_output_tokens, &[("token_type", "reasoning_output"), tmp_mem], ); @@ -342,16 +350,9 @@ impl Session { *active = Some(turn); } - async fn take_all_running_tasks(&self) -> Vec { + async fn take_active_turn(&self) -> Option { let mut active = self.active_turn.lock().await; - match active.take() { - Some(mut at) => { - at.clear_pending().await; - - at.drain_tasks() - } - None => Vec::new(), - } + active.take() } pub(crate) async fn close_unified_exec_processes(&self) { diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index 810c5cb1d6..f5b79084ac 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -18,6 +18,7 @@ use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; use crate::tools::events::ToolEmitter; use crate::tools::events::ToolEventCtx; +use crate::tools::handlers::apply_granted_turn_permissions; use crate::tools::handlers::parse_arguments; use crate::tools::orchestrator::ToolOrchestrator; use crate::tools::registry::ToolHandler; @@ -30,7 +31,10 @@ use crate::tools::spec::JsonSchema; use async_trait::async_trait; use codex_apply_patch::ApplyPatchAction; use codex_apply_patch::ApplyPatchFileChange; +use codex_protocol::models::FileSystemPermissions; +use codex_protocol::models::PermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; +use std::collections::BTreeSet; use std::sync::Arc; pub struct ApplyPatchHandler; @@ -61,6 +65,31 @@ fn to_abs_path(cwd: &Path, path: &Path) -> Option { AbsolutePathBuf::resolve_path_against_base(path, cwd).ok() } +fn write_permissions_for_paths(file_paths: &[AbsolutePathBuf]) -> Option { + let write_paths = file_paths + .iter() + .map(|path| { + path.parent() + .unwrap_or_else(|| path.clone()) + .into_path_buf() + }) + .collect::>() + .into_iter() + .map(AbsolutePathBuf::from_absolute_path) + .collect::, _>>() + .ok()?; + + let permissions = (!write_paths.is_empty()).then_some(PermissionProfile { + file_system: Some(FileSystemPermissions { + read: Some(vec![]), + write: Some(write_paths), + }), + ..Default::default() + })?; + + crate::sandboxing::normalize_additional_permissions(permissions).ok() +} + #[async_trait] impl ToolHandler for ApplyPatchHandler { fn kind(&self) -> ToolKind { @@ -119,6 +148,12 @@ impl ToolHandler for ApplyPatchHandler { InternalApplyPatchInvocation::DelegateToExec(apply) => { let changes = convert_apply_patch_to_protocol(&apply.action); let file_paths = file_paths_for_action(&apply.action); + let effective_additional_permissions = apply_granted_turn_permissions( + session.as_ref(), + crate::sandboxing::SandboxPermissions::UseDefault, + write_permissions_for_paths(&file_paths), + ) + .await; let emitter = ToolEmitter::apply_patch(changes.clone(), apply.auto_approved); let event_ctx = ToolEventCtx::new( @@ -134,6 +169,12 @@ impl ToolHandler for ApplyPatchHandler { file_paths, changes, exec_approval_requirement: apply.exec_approval_requirement, + sandbox_permissions: effective_additional_permissions + .sandbox_permissions, + additional_permissions: effective_additional_permissions + .additional_permissions, + permissions_preapproved: effective_additional_permissions + .permissions_preapproved, timeout_ms: None, codex_exe: turn.codex_linux_sandbox_exe.clone(), }; @@ -222,6 +263,12 @@ pub(crate) async fn intercept_apply_patch( InternalApplyPatchInvocation::DelegateToExec(apply) => { let changes = convert_apply_patch_to_protocol(&apply.action); let approval_keys = file_paths_for_action(&apply.action); + let effective_additional_permissions = apply_granted_turn_permissions( + session.as_ref(), + crate::sandboxing::SandboxPermissions::UseDefault, + write_permissions_for_paths(&approval_keys), + ) + .await; let emitter = ToolEmitter::apply_patch(changes.clone(), apply.auto_approved); let event_ctx = ToolEventCtx::new( session.as_ref(), @@ -236,6 +283,11 @@ pub(crate) async fn intercept_apply_patch( file_paths: approval_keys, changes, exec_approval_requirement: apply.exec_approval_requirement, + sandbox_permissions: effective_additional_permissions.sandbox_permissions, + additional_permissions: effective_additional_permissions + .additional_permissions, + permissions_preapproved: effective_additional_permissions + .permissions_preapproved, timeout_ms, codex_exe: turn.codex_linux_sandbox_exe.clone(), }; diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 086701d9c2..c1452e3ffb 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -10,6 +10,7 @@ mod mcp_resource; pub(crate) mod multi_agents; mod plan; mod read_file; +mod request_permissions; mod request_user_input; mod search_tool_bm25; mod shell; @@ -24,8 +25,10 @@ use serde_json::Value; use std::path::Path; use std::path::PathBuf; +use crate::codex::Session; use crate::function_tool::FunctionCallError; use crate::sandboxing::SandboxPermissions; +use crate::sandboxing::merge_permission_profiles; use crate::sandboxing::normalize_additional_permissions; pub use apply_patch::ApplyPatchHandler; pub use artifacts::ArtifactsHandler; @@ -41,6 +44,8 @@ pub use mcp_resource::McpResourceHandler; pub use multi_agents::MultiAgentHandler; pub use plan::PlanHandler; pub use read_file::ReadFileHandler; +pub use request_permissions::RequestPermissionsHandler; +pub(crate) use request_permissions::request_permissions_tool_description; pub use request_user_input::RequestUserInputHandler; pub(crate) use request_user_input::request_user_input_tool_description; pub(crate) use search_tool_bm25::DEFAULT_LIMIT as SEARCH_TOOL_BM25_DEFAULT_LIMIT; @@ -90,11 +95,12 @@ fn resolve_workdir_base_path( /// Validates feature/policy constraints for `with_additional_permissions` and /// normalizes any path-based permissions. Errors if the request is invalid. -pub(super) fn normalize_and_validate_additional_permissions( +pub(crate) fn normalize_and_validate_additional_permissions( request_permission_enabled: bool, approval_policy: AskForApproval, sandbox_permissions: SandboxPermissions, additional_permissions: Option, + permissions_preapproved: bool, _cwd: &Path, ) -> Result, String> { let uses_additional_permissions = matches!( @@ -112,7 +118,7 @@ pub(super) fn normalize_and_validate_additional_permissions( } if uses_additional_permissions { - if !matches!(approval_policy, AskForApproval::OnRequest) { + if !permissions_preapproved && !matches!(approval_policy, AskForApproval::OnRequest) { return Err(format!( "approval policy is {approval_policy:?}; reject command — you cannot request additional permissions unless the approval policy is OnRequest" )); @@ -146,3 +152,56 @@ pub(super) fn normalize_and_validate_additional_permissions( Ok(None) } } + +pub(super) struct EffectiveAdditionalPermissions { + pub sandbox_permissions: SandboxPermissions, + pub additional_permissions: Option, + pub permissions_preapproved: bool, +} + +pub(super) async fn apply_granted_turn_permissions( + session: &Session, + sandbox_permissions: SandboxPermissions, + additional_permissions: Option, +) -> EffectiveAdditionalPermissions { + if matches!(sandbox_permissions, SandboxPermissions::RequireEscalated) { + return EffectiveAdditionalPermissions { + sandbox_permissions, + additional_permissions, + permissions_preapproved: false, + }; + } + + let granted_session_permissions = session.granted_session_permissions().await; + let granted_turn_permissions = session.granted_turn_permissions().await; + let granted_permissions = merge_permission_profiles( + granted_session_permissions.as_ref(), + granted_turn_permissions.as_ref(), + ); + let effective_permissions = merge_permission_profiles( + additional_permissions.as_ref(), + granted_permissions.as_ref(), + ); + let permissions_preapproved = match (effective_permissions.as_ref(), granted_permissions) { + (Some(effective_permissions), Some(granted_permissions)) => { + crate::sandboxing::intersect_permission_profiles( + effective_permissions.clone(), + granted_permissions, + ) == *effective_permissions + } + _ => false, + }; + + let sandbox_permissions = + if effective_permissions.is_some() && !sandbox_permissions.uses_additional_permissions() { + SandboxPermissions::WithAdditionalPermissions + } else { + sandbox_permissions + }; + + EffectiveAdditionalPermissions { + sandbox_permissions, + additional_permissions: effective_permissions, + permissions_preapproved, + } +} diff --git a/codex-rs/core/src/tools/handlers/request_permissions.rs b/codex-rs/core/src/tools/handlers/request_permissions.rs new file mode 100644 index 0000000000..9e4235abf6 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/request_permissions.rs @@ -0,0 +1,75 @@ +use async_trait::async_trait; +use codex_protocol::models::FunctionCallOutputBody; +use codex_protocol::request_permissions::RequestPermissionsArgs; + +use crate::function_tool::FunctionCallError; +use crate::sandboxing::normalize_additional_permissions; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolOutput; +use crate::tools::context::ToolPayload; +use crate::tools::handlers::parse_arguments_with_base_path; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; + +pub(crate) fn request_permissions_tool_description() -> String { + "Request additional permissions from the user and wait for the client to grant a subset of the requested permission profile. Granted permissions apply automatically to later shell-like commands in the current turn, or for the rest of the session if the client approves them at session scope." + .to_string() +} + +pub struct RequestPermissionsHandler; + +#[async_trait] +impl ToolHandler for RequestPermissionsHandler { + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + call_id, + payload, + .. + } = invocation; + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "request_permissions handler received unsupported payload".to_string(), + )); + } + }; + + let mut args: RequestPermissionsArgs = + parse_arguments_with_base_path(&arguments, turn.cwd.as_path())?; + args.permissions = normalize_additional_permissions(args.permissions) + .map_err(FunctionCallError::RespondToModel)?; + if args.permissions.is_empty() { + return Err(FunctionCallError::RespondToModel( + "request_permissions requires at least one permission".to_string(), + )); + } + + let response = session + .request_permissions(turn.as_ref(), call_id, args) + .await + .ok_or_else(|| { + FunctionCallError::RespondToModel( + "request_permissions was cancelled before receiving a response".to_string(), + ) + })?; + + let content = serde_json::to_string(&response).map_err(|err| { + FunctionCallError::Fatal(format!( + "failed to serialize request_permissions response: {err}" + )) + })?; + + Ok(ToolOutput::Function { + body: FunctionCallOutputBody::Text(content), + success: Some(true), + }) + } +} diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index cea0b9dd33..0c50d02262 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -20,6 +20,7 @@ use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; use crate::tools::events::ToolEmitter; use crate::tools::events::ToolEventCtx; +use crate::tools::handlers::apply_granted_turn_permissions; use crate::tools::handlers::apply_patch::intercept_apply_patch; use crate::tools::handlers::normalize_and_validate_additional_permissions; use crate::tools::handlers::parse_arguments_with_base_path; @@ -332,17 +333,29 @@ impl ShellHandler { } let request_permission_enabled = session.features().enabled(Feature::RequestPermissions); + let effective_additional_permissions = apply_granted_turn_permissions( + session.as_ref(), + exec_params.sandbox_permissions, + additional_permissions, + ) + .await; let normalized_additional_permissions = normalize_and_validate_additional_permissions( request_permission_enabled, turn.approval_policy.value(), - exec_params.sandbox_permissions, - additional_permissions, + effective_additional_permissions.sandbox_permissions, + effective_additional_permissions.additional_permissions, + effective_additional_permissions.permissions_preapproved, &exec_params.cwd, ) .map_err(FunctionCallError::RespondToModel)?; // Approval policy guard for explicit escalation in non-OnRequest modes. - if exec_params.sandbox_permissions.requests_sandbox_override() + // Sticky turn permissions have already been approved, so they should + // continue through the normal exec approval flow for the command. + if effective_additional_permissions + .sandbox_permissions + .requests_sandbox_override() + && !effective_additional_permissions.permissions_preapproved && !matches!( turn.approval_policy.value(), codex_protocol::protocol::AskForApproval::OnRequest @@ -387,7 +400,11 @@ impl ShellHandler { command: &exec_params.command, approval_policy: turn.approval_policy.value(), sandbox_policy: turn.sandbox_policy.get(), - sandbox_permissions: exec_params.sandbox_permissions, + sandbox_permissions: if effective_additional_permissions.permissions_preapproved { + codex_protocol::models::SandboxPermissions::UseDefault + } else { + effective_additional_permissions.sandbox_permissions + }, prefix_rule, }) .await; @@ -399,7 +416,7 @@ impl ShellHandler { env: exec_params.env.clone(), explicit_env_overrides, network: exec_params.network.clone(), - sandbox_permissions: exec_params.sandbox_permissions, + sandbox_permissions: effective_additional_permissions.sandbox_permissions, additional_permissions: normalized_additional_permissions, justification: exec_params.justification.clone(), exec_approval_requirement, diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 915c1734c5..c2fc536b35 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -10,6 +10,7 @@ use crate::skills::maybe_emit_implicit_skill_invocation; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; +use crate::tools::handlers::apply_granted_turn_permissions; use crate::tools::handlers::apply_patch::intercept_apply_patch; use crate::tools::handlers::normalize_and_validate_additional_permissions; use crate::tools::handlers::parse_arguments; @@ -170,8 +171,19 @@ impl ToolHandler for UnifiedExecHandler { let request_permission_enabled = session.features().enabled(Feature::RequestPermissions); + let effective_additional_permissions = apply_granted_turn_permissions( + context.session.as_ref(), + sandbox_permissions, + additional_permissions, + ) + .await; - if sandbox_permissions.requests_sandbox_override() + // Sticky turn permissions have already been approved, so they should + // continue through the normal exec approval flow for the command. + if effective_additional_permissions + .sandbox_permissions + .requests_sandbox_override() + && !effective_additional_permissions.permissions_preapproved && !matches!( context.turn.approval_policy.value(), codex_protocol::protocol::AskForApproval::OnRequest @@ -192,8 +204,9 @@ impl ToolHandler for UnifiedExecHandler { match normalize_and_validate_additional_permissions( request_permission_enabled, context.turn.approval_policy.value(), - sandbox_permissions, - additional_permissions, + effective_additional_permissions.sandbox_permissions, + effective_additional_permissions.additional_permissions, + effective_additional_permissions.permissions_preapproved, &cwd, ) { Ok(normalized) => normalized, @@ -229,8 +242,11 @@ impl ToolHandler for UnifiedExecHandler { workdir, network: context.turn.network.clone(), tty, - sandbox_permissions, + sandbox_permissions: effective_additional_permissions + .sandbox_permissions, additional_permissions: normalized_additional_permissions, + additional_permissions_preapproved: effective_additional_permissions + .permissions_preapproved, justification, prefix_rule, }, diff --git a/codex-rs/core/src/tools/network_approval.rs b/codex-rs/core/src/tools/network_approval.rs index 24502cde8d..78f1bb0f44 100644 --- a/codex-rs/core/src/tools/network_approval.rs +++ b/codex-rs/core/src/tools/network_approval.rs @@ -1,6 +1,6 @@ use crate::codex::Session; use crate::guardian::GUARDIAN_REJECTION_MESSAGE; -use crate::guardian::GuardianReviewRequest; +use crate::guardian::GuardianApprovalRequest; use crate::guardian::review_approval_request; use crate::guardian::routes_approval_to_guardian; use crate::network_policy_decision::denied_network_policy_message; @@ -21,7 +21,6 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::WarningEvent; use indexmap::IndexMap; -use serde_json::json; use std::collections::HashMap; use std::collections::HashSet; use std::sync::Arc; @@ -344,14 +343,11 @@ impl NetworkApprovalService { review_approval_request( &session, &turn_context, - GuardianReviewRequest { - action: json!({ - "tool": "network_access", - "target": target, - "host": request.host, - "protocol": key.protocol, - "port": key.port, - }), + GuardianApprovalRequest::NetworkAccess { + target, + host: request.host, + protocol, + port: key.port, }, Some(policy_denial_message.clone()), ) @@ -371,6 +367,7 @@ impl NetworkApprovalService { Some(network_approval_context.clone()), None, None, + None, available_decisions, ) .await diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index e745dd370e..fd0168bf4d 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -5,7 +5,7 @@ //! `codex --codex-run-as-apply-patch`, and runs under the current //! `SandboxAttempt` with a minimal environment. use crate::exec::ExecToolCallOutput; -use crate::guardian::GuardianReviewRequest; +use crate::guardian::GuardianApprovalRequest; use crate::guardian::review_approval_request; use crate::guardian::routes_approval_to_guardian; use crate::sandboxing::CommandSpec; @@ -23,12 +23,12 @@ use crate::tools::sandboxing::ToolRuntime; use crate::tools::sandboxing::with_cached_approval; use codex_apply_patch::ApplyPatchAction; use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1; +use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::FileChange; use codex_protocol::protocol::ReviewDecision; use codex_utils_absolute_path::AbsolutePathBuf; use futures::future::BoxFuture; -use serde_json::json; use std::collections::HashMap; use std::path::PathBuf; @@ -38,6 +38,9 @@ pub struct ApplyPatchRequest { pub file_paths: Vec, pub changes: std::collections::HashMap, pub exec_approval_requirement: ExecApprovalRequirement, + pub sandbox_permissions: SandboxPermissions, + pub additional_permissions: Option, + pub permissions_preapproved: bool, pub timeout_ms: Option, pub codex_exe: Option, } @@ -50,15 +53,12 @@ impl ApplyPatchRuntime { Self } - fn build_guardian_review_request(req: &ApplyPatchRequest) -> GuardianReviewRequest { - GuardianReviewRequest { - action: json!({ - "tool": "apply_patch", - "cwd": req.action.cwd, - "files": req.file_paths, - "change_count": req.changes.len(), - "patch": req.action.patch, - }), + fn build_guardian_review_request(req: &ApplyPatchRequest) -> GuardianApprovalRequest { + GuardianApprovalRequest::ApplyPatch { + cwd: req.action.cwd.clone(), + files: req.file_paths.clone(), + change_count: req.changes.len(), + patch: req.action.patch.clone(), } } @@ -91,8 +91,8 @@ impl ApplyPatchRuntime { expiration: req.timeout_ms.into(), // Run apply_patch with a minimal environment for determinism and to avoid leaks. env: HashMap::new(), - sandbox_permissions: SandboxPermissions::UseDefault, - additional_permissions: None, + sandbox_permissions: req.sandbox_permissions, + additional_permissions: req.additional_permissions.clone(), justification: None, }) } @@ -135,8 +135,11 @@ impl Approvable for ApplyPatchRuntime { let changes = req.changes.clone(); Box::pin(async move { if routes_approval_to_guardian(turn) { - let request = ApplyPatchRuntime::build_guardian_review_request(req); - return review_approval_request(session, turn, request, retry_reason).await; + let action = ApplyPatchRuntime::build_guardian_review_request(req); + return review_approval_request(session, turn, action, retry_reason).await; + } + if req.permissions_preapproved && retry_reason.is_none() { + return ReviewDecision::Approved; } if let Some(reason) = retry_reason { let rx_approve = session @@ -215,6 +218,7 @@ mod tests { !runtime.wants_no_sandbox_approval(AskForApproval::Reject(RejectConfig { sandbox_approval: true, rules: false, + request_permissions: false, mcp_elicitations: false, })) ); @@ -222,6 +226,7 @@ mod tests { runtime.wants_no_sandbox_approval(AskForApproval::Reject(RejectConfig { sandbox_approval: false, rules: false, + request_permissions: false, mcp_elicitations: false, })) ); @@ -248,6 +253,9 @@ mod tests { reason: None, proposed_execpolicy_amendment: None, }, + sandbox_permissions: SandboxPermissions::UseDefault, + additional_permissions: None, + permissions_preapproved: false, timeout_ms: None, codex_exe: None, }; @@ -256,14 +264,11 @@ mod tests { assert_eq!( guardian_request, - GuardianReviewRequest { - action: json!({ - "tool": "apply_patch", - "cwd": expected_cwd, - "files": request.file_paths, - "change_count": 1usize, - "patch": expected_patch, - }), + GuardianApprovalRequest::ApplyPatch { + cwd: expected_cwd, + files: request.file_paths, + change_count: 1usize, + patch: expected_patch, } ); } diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index c5a883438c..dea0aa202a 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -11,7 +11,7 @@ pub(crate) mod zsh_fork_backend; use crate::command_canonicalization::canonicalize_command_for_approval; use crate::exec::ExecToolCallOutput; use crate::features::Feature; -use crate::guardian::GuardianReviewRequest; +use crate::guardian::GuardianApprovalRequest; use crate::guardian::review_approval_request; use crate::guardian::routes_approval_to_guardian; use crate::powershell::prefix_powershell_script_with_utf8; @@ -38,7 +38,6 @@ use codex_network_proxy::NetworkProxy; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::ReviewDecision; use futures::future::BoxFuture; -use serde_json::json; use std::collections::HashMap; use std::path::PathBuf; @@ -145,33 +144,26 @@ impl Approvable for ShellRuntime { let keys = self.approval_keys(req); let command = req.command.clone(); let cwd = req.cwd.clone(); - let reason = ctx - .retry_reason - .clone() - .or_else(|| req.justification.clone()); + let retry_reason = ctx.retry_reason.clone(); + let reason = retry_reason.clone().or_else(|| req.justification.clone()); let session = ctx.session; let turn = ctx.turn; let call_id = ctx.call_id.to_string(); Box::pin(async move { if routes_approval_to_guardian(turn) { - let mut action = json!({ - "tool": "shell", - "command": command, - "cwd": cwd, - "sandbox_permissions": req.sandbox_permissions, - "additional_permissions": req.additional_permissions, - "justification": reason, - }); - if let Some(action) = action.as_object_mut() { - if req.additional_permissions.is_none() { - action.remove("additional_permissions"); - } - if reason.is_none() { - action.remove("justification"); - } - } - let request = GuardianReviewRequest { action }; - return review_approval_request(session, turn, request, None).await; + return review_approval_request( + session, + turn, + GuardianApprovalRequest::Shell { + command, + cwd, + sandbox_permissions: req.sandbox_permissions, + additional_permissions: req.additional_permissions.clone(), + justification: req.justification.clone(), + }, + retry_reason, + ) + .await; } with_cached_approval(&session.services, "shell", keys, move || async move { let available_decisions = None; @@ -188,6 +180,7 @@ impl Approvable for ShellRuntime { .proposed_execpolicy_amendment() .cloned(), req.additional_permissions.clone(), + None, available_decisions, ) .await diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index b5a906eb07..04732b00c7 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -7,7 +7,7 @@ use crate::exec::SandboxType; use crate::exec::is_likely_sandbox_denied; use crate::exec_policy::prompt_is_rejected_by_policy; use crate::features::Feature; -use crate::guardian::GuardianReviewRequest; +use crate::guardian::GuardianApprovalRequest; use crate::guardian::review_approval_request; use crate::guardian::routes_approval_to_guardian; use crate::sandboxing::ExecRequest; @@ -31,6 +31,7 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::ExecApprovalRequestSkillMetadata; use codex_protocol::protocol::NetworkPolicyRuleAction; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SandboxPolicy; @@ -49,7 +50,6 @@ use codex_shell_escalation::PreparedExec; use codex_shell_escalation::ShellCommandExecutor; use codex_shell_escalation::Stopwatch; use codex_utils_absolute_path::AbsolutePathBuf; -use serde_json::json; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; @@ -385,16 +385,19 @@ impl CoreShellActionProvider { Ok(stopwatch .pause_for(async move { if routes_approval_to_guardian(&turn) { - let request = GuardianReviewRequest { - action: json!({ - "tool": tool_name, - "program": program, - "argv": argv, - "cwd": workdir, - "additional_permissions": additional_permissions, - }), - }; - return review_approval_request(&session, &turn, request, None).await; + return review_approval_request( + &session, + &turn, + GuardianApprovalRequest::Execve { + tool_name: tool_name.to_string(), + program: program.to_string_lossy().into_owned(), + argv: argv.to_vec(), + cwd: workdir, + additional_permissions, + }, + None, + ) + .await; } let available_decisions = vec![ Some(ReviewDecision::Approved), @@ -410,6 +413,14 @@ impl CoreShellActionProvider { .into_iter() .flatten() .collect(); + let skill_metadata = match decision_source { + DecisionSource::SkillScript { skill } => { + Some(ExecApprovalRequestSkillMetadata { + path_to_skills_md: skill.path_to_skills_md.clone(), + }) + } + DecisionSource::PrefixRule | DecisionSource::UnmatchedCommandFallback => None, + }; session .request_command_approval( &turn, @@ -421,6 +432,7 @@ impl CoreShellActionProvider { None, None, additional_permissions, + skill_metadata, Some(available_decisions), ) .await diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 2a775e26c0..48052a5371 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -9,7 +9,7 @@ use crate::error::CodexErr; use crate::error::SandboxErr; use crate::exec::ExecExpiration; use crate::features::Feature; -use crate::guardian::GuardianReviewRequest; +use crate::guardian::GuardianApprovalRequest; use crate::guardian::review_approval_request; use crate::guardian::routes_approval_to_guardian; use crate::powershell::prefix_powershell_script_with_utf8; @@ -41,7 +41,6 @@ use codex_network_proxy::NetworkProxy; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::ReviewDecision; use futures::future::BoxFuture; -use serde_json::json; use std::collections::HashMap; use std::path::PathBuf; @@ -113,31 +112,24 @@ impl Approvable for UnifiedExecRuntime<'_> { let call_id = ctx.call_id.to_string(); let command = req.command.clone(); let cwd = req.cwd.clone(); - let reason = ctx - .retry_reason - .clone() - .or_else(|| req.justification.clone()); + let retry_reason = ctx.retry_reason.clone(); + let reason = retry_reason.clone().or_else(|| req.justification.clone()); Box::pin(async move { if routes_approval_to_guardian(turn) { - let mut action = json!({ - "tool": "exec_command", - "command": command, - "cwd": cwd, - "sandbox_permissions": req.sandbox_permissions, - "additional_permissions": req.additional_permissions, - "justification": reason, - "tty": req.tty, - }); - if let Some(action) = action.as_object_mut() { - if req.additional_permissions.is_none() { - action.remove("additional_permissions"); - } - if reason.is_none() { - action.remove("justification"); - } - } - let request = GuardianReviewRequest { action }; - return review_approval_request(session, turn, request, None).await; + return review_approval_request( + session, + turn, + GuardianApprovalRequest::ExecCommand { + command, + cwd, + sandbox_permissions: req.sandbox_permissions, + additional_permissions: req.additional_permissions.clone(), + justification: req.justification.clone(), + tty: req.tty, + }, + retry_reason, + ) + .await; } with_cached_approval(&session.services, "unified_exec", keys, || async move { let available_decisions = None; @@ -154,6 +146,7 @@ impl Approvable for UnifiedExecRuntime<'_> { .proposed_execpolicy_amendment() .cloned(), req.additional_permissions.clone(), + None, available_decisions, ) .await diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index c7883d19bf..935b162b26 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -398,6 +398,7 @@ mod tests { let policy = AskForApproval::Reject(RejectConfig { sandbox_approval: true, rules: false, + request_permissions: false, mcp_elicitations: false, }); @@ -417,6 +418,7 @@ mod tests { let policy = AskForApproval::Reject(RejectConfig { sandbox_approval: false, rules: true, + request_permissions: false, mcp_elicitations: true, }); diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 730130850d..f3c01cecd0 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -16,6 +16,7 @@ use crate::tools::handlers::apply_patch::create_apply_patch_json_tool; use crate::tools::handlers::multi_agents::DEFAULT_WAIT_TIMEOUT_MS; use crate::tools::handlers::multi_agents::MAX_WAIT_TIMEOUT_MS; use crate::tools::handlers::multi_agents::MIN_WAIT_TIMEOUT_MS; +use crate::tools::handlers::request_permissions_tool_description; use crate::tools::handlers::request_user_input_tool_description; use crate::tools::registry::ToolRegistryBuilder; use codex_protocol::config_types::WebSearchConfig; @@ -66,6 +67,7 @@ pub(crate) struct ToolsConfig { pub agent_roles: BTreeMap, pub search_tool: bool, pub request_permission_enabled: bool, + pub request_permissions_tool_enabled: bool, pub js_repl_enabled: bool, pub js_repl_tools_only: bool, pub collab_tools: bool, @@ -107,6 +109,7 @@ impl ToolsConfig { features.enabled(Feature::ImageGeneration) && supports_image_generation(model_info); let include_agent_jobs = include_collab_tools; let request_permission_enabled = features.enabled(Feature::RequestPermissions); + let request_permissions_tool_enabled = features.enabled(Feature::RequestPermissionsTool); let shell_command_backend = if features.enabled(Feature::ShellTool) && features.enabled(Feature::ShellZshFork) { ShellCommandBackendConfig::ZshFork @@ -167,6 +170,7 @@ impl ToolsConfig { agent_roles: BTreeMap::new(), search_tool: include_search_tool, request_permission_enabled, + request_permissions_tool_enabled, js_repl_enabled: include_js_repl, js_repl_tools_only: include_js_repl_tools_only, collab_tools: include_collab_tools, @@ -255,6 +259,94 @@ impl From for AdditionalProperties { } } +fn create_network_permissions_schema() -> JsonSchema { + JsonSchema::Object { + properties: BTreeMap::from([( + "enabled".to_string(), + JsonSchema::Boolean { + description: Some("Set to true to request network access.".to_string()), + }, + )]), + required: None, + additional_properties: Some(false.into()), + } +} + +fn create_file_system_permissions_schema() -> JsonSchema { + JsonSchema::Object { + properties: BTreeMap::from([ + ( + "read".to_string(), + JsonSchema::Array { + items: Box::new(JsonSchema::String { description: None }), + description: Some("Absolute paths to grant read access to.".to_string()), + }, + ), + ( + "write".to_string(), + JsonSchema::Array { + items: Box::new(JsonSchema::String { description: None }), + description: Some("Absolute paths to grant write access to.".to_string()), + }, + ), + ]), + required: None, + additional_properties: Some(false.into()), + } +} + +fn create_macos_permissions_schema() -> JsonSchema { + JsonSchema::Object { + properties: BTreeMap::from([ + ( + "preferences".to_string(), + JsonSchema::String { + description: Some( + "macOS preferences access. Supported values: `none`, `read_only`, or `read_write`." + .to_string(), + ), + }, + ), + ( + "automations".to_string(), + JsonSchema::Array { + items: Box::new(JsonSchema::String { description: None }), + description: Some("macOS automation access as app bundle identifiers.".to_string()), + }, + ), + ( + "accessibility".to_string(), + JsonSchema::Boolean { + description: Some("Whether to request macOS accessibility access.".to_string()), + }, + ), + ( + "calendar".to_string(), + JsonSchema::Boolean { + description: Some("Whether to request macOS calendar access.".to_string()), + }, + ), + ]), + required: None, + additional_properties: Some(false.into()), + } +} + +fn create_permissions_schema() -> JsonSchema { + JsonSchema::Object { + properties: BTreeMap::from([ + ("network".to_string(), create_network_permissions_schema()), + ( + "file_system".to_string(), + create_file_system_permissions_schema(), + ), + ("macos".to_string(), create_macos_permissions_schema()), + ]), + required: None, + additional_properties: Some(false.into()), + } +} + fn create_approval_parameters(request_permission_enabled: bool) -> BTreeMap { let mut properties = BTreeMap::from([ ( @@ -299,103 +391,7 @@ fn create_approval_parameters(request_permission_enabled: bool) -> BTreeMap ToolSpec { + let mut properties = BTreeMap::new(); + properties.insert( + "reason".to_string(), + JsonSchema::String { + description: Some( + "Optional short explanation for why additional permissions are needed.".to_string(), + ), + }, + ); + properties.insert("permissions".to_string(), create_permissions_schema()); + + ToolSpec::Function(ResponsesApiTool { + name: "request_permissions".to_string(), + description: request_permissions_tool_description(), + strict: false, + parameters: JsonSchema::Object { + properties, + required: Some(vec!["permissions".to_string()]), + additional_properties: Some(false.into()), + }, + }) +} + fn create_close_agent_tool() -> ToolSpec { let mut properties = BTreeMap::new(); properties.insert( @@ -1892,6 +1912,7 @@ pub(crate) fn build_specs_with_available_models( use crate::tools::handlers::MultiAgentHandler; use crate::tools::handlers::PlanHandler; use crate::tools::handlers::ReadFileHandler; + use crate::tools::handlers::RequestPermissionsHandler; use crate::tools::handlers::RequestUserInputHandler; use crate::tools::handlers::SearchToolBm25Handler; use crate::tools::handlers::ShellCommandHandler; @@ -1912,6 +1933,7 @@ pub(crate) fn build_specs_with_available_models( let mcp_handler = Arc::new(McpHandler); let mcp_resource_handler = Arc::new(McpResourceHandler); let shell_command_handler = Arc::new(ShellCommandHandler::from(config.shell_command_backend)); + let request_permissions_handler = Arc::new(RequestPermissionsHandler); let request_user_input_handler = Arc::new(RequestUserInputHandler { default_mode_request_user_input: config.default_mode_request_user_input, }); @@ -1985,6 +2007,11 @@ pub(crate) fn build_specs_with_available_models( builder.register_handler("request_user_input", request_user_input_handler); } + if config.request_permissions_tool_enabled { + builder.push_spec(create_request_permissions_tool()); + builder.register_handler("request_permissions", request_permissions_handler); + } + if config.search_tool && let Some(app_tools) = app_tools { @@ -2063,11 +2090,11 @@ pub(crate) fn build_specs_with_available_models( filters: config .web_search_config .as_ref() - .and_then(|cfg| cfg.filters.clone()), + .and_then(|cfg| cfg.filters.clone().map(Into::into)), user_location: config .web_search_config .as_ref() - .and_then(|cfg| cfg.user_location.clone()), + .and_then(|cfg| cfg.user_location.clone().map(Into::into)), search_context_size: config .web_search_config .as_ref() @@ -2370,6 +2397,11 @@ mod tests { expected.insert(tool_name(&spec).to_string(), spec); } + if config.request_permission_enabled { + let spec = create_request_permissions_tool(); + expected.insert(tool_name(&spec).to_string(), spec); + } + // Exact name set match — this is the only test allowed to fail when tools change. let actual_names: HashSet<_> = actual.keys().cloned().collect(); let expected_names: HashSet<_> = expected.keys().cloned().collect(); @@ -2498,6 +2530,55 @@ mod tests { ); } + #[test] + fn request_permissions_requires_feature_flag() { + let config = test_config(); + let model_info = + ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let features = Features::with_defaults(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + assert_lacks_tool_name(&tools, "request_permissions"); + + let mut features = Features::with_defaults(); + features.enable(Feature::RequestPermissionsTool); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + let request_permissions_tool = find_tool(&tools, "request_permissions"); + assert_eq!( + request_permissions_tool.spec, + create_request_permissions_tool() + ); + } + + #[test] + fn request_permissions_tool_is_independent_from_additional_permissions() { + let config = test_config(); + let model_info = + ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::RequestPermissions); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + + assert_lacks_tool_name(&tools, "request_permissions"); + } + #[test] fn spawn_agent_tool_schema_includes_model_overrides() { let config = test_config(); @@ -2824,8 +2905,12 @@ mod tests { tool.spec, ToolSpec::WebSearch { external_web_access: Some(true), - filters: web_search_config.filters, - user_location: web_search_config.user_location, + filters: web_search_config + .filters + .map(crate::client_common::tools::ResponsesApiWebSearchFilters::from), + user_location: web_search_config + .user_location + .map(crate::client_common::tools::ResponsesApiWebSearchUserLocation::from), search_context_size: web_search_config.search_context_size, search_content_types: None, } @@ -3667,6 +3752,67 @@ Examples of valid command strings: assert!(additional_properties.contains_key("macos")); } + #[test] + fn request_permissions_tool_includes_full_permission_schema() { + let tool = super::create_request_permissions_tool(); + let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = tool else { + panic!("expected function tool"); + }; + let JsonSchema::Object { properties, .. } = parameters else { + panic!("expected object parameters"); + }; + let Some(JsonSchema::Object { + properties: permission_properties, + additional_properties, + .. + }) = properties.get("permissions") + else { + panic!("expected permissions object"); + }; + + assert_eq!(additional_properties, &Some(false.into())); + assert!(permission_properties.contains_key("network")); + assert!(permission_properties.contains_key("file_system")); + assert!(permission_properties.contains_key("macos")); + + let Some(JsonSchema::Object { + properties: network_properties, + additional_properties, + .. + }) = permission_properties.get("network") + else { + panic!("expected network object"); + }; + assert_eq!(additional_properties, &Some(false.into())); + assert!(network_properties.contains_key("enabled")); + + let Some(JsonSchema::Object { + properties: file_system_properties, + additional_properties, + .. + }) = permission_properties.get("file_system") + else { + panic!("expected file_system object"); + }; + assert_eq!(additional_properties, &Some(false.into())); + assert!(file_system_properties.contains_key("read")); + assert!(file_system_properties.contains_key("write")); + + let Some(JsonSchema::Object { + properties: macos_properties, + additional_properties, + .. + }) = permission_properties.get("macos") + else { + panic!("expected macos object"); + }; + assert_eq!(additional_properties, &Some(false.into())); + assert!(macos_properties.contains_key("preferences")); + assert!(macos_properties.contains_key("automations")); + assert!(macos_properties.contains_key("accessibility")); + assert!(macos_properties.contains_key("calendar")); + } + #[test] fn test_shell_command_tool() { let tool = super::create_shell_command_tool(true, false); diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index 765373c35e..6d72319bd9 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -1,6 +1,8 @@ use std::time::Duration; use std::time::Instant; +use codex_otel::metrics::names::TURN_TTFM_DURATION_METRIC; +use codex_otel::metrics::names::TURN_TTFT_DURATION_METRIC; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; use tokio::sync::Mutex; @@ -9,9 +11,6 @@ use crate::ResponseEvent; use crate::codex::TurnContext; use crate::stream_events_utils::raw_assistant_output_text_from_item; -const TURN_TTFT_DURATION_METRIC: &str = "codex.turn.ttft.duration_ms"; -const TURN_TTFM_DURATION_METRIC: &str = "codex.turn.ttfm.duration_ms"; - pub(crate) async fn record_turn_ttft_metric(turn_context: &TurnContext, event: &ResponseEvent) { let Some(duration) = turn_context .turn_timing_state diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 2e46d6a943..b7d349e17c 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -95,6 +95,7 @@ pub(crate) struct ExecCommandRequest { pub tty: bool, pub sandbox_permissions: SandboxPermissions, pub additional_permissions: Option, + pub additional_permissions_preapproved: bool, pub justification: Option, pub prefix_rule: Option>, } @@ -240,6 +241,7 @@ mod tests { tty: true, sandbox_permissions: SandboxPermissions::UseDefault, additional_permissions: None, + additional_permissions_preapproved: false, justification: None, prefix_rule: None, }, diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 770379afa4..24214ae66c 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -583,7 +583,11 @@ impl UnifiedExecProcessManager { command: &request.command, approval_policy: context.turn.approval_policy.value(), sandbox_policy: context.turn.sandbox_policy.get(), - sandbox_permissions: request.sandbox_permissions, + sandbox_permissions: if request.additional_permissions_preapproved { + crate::sandboxing::SandboxPermissions::UseDefault + } else { + request.sandbox_permissions + }, prefix_rule: request.prefix_rule.clone(), }) .await; diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index e9867caa76..61dfbe03eb 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -104,6 +104,8 @@ mod remote_models; mod request_compression; #[cfg(not(target_os = "windows"))] mod request_permissions; +#[cfg(not(target_os = "windows"))] +mod request_permissions_tool; mod request_user_input; mod resume; mod resume_warning; diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index 2463b98de7..c96988d18b 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -14,6 +14,7 @@ use core_test_support::responses::ev_local_shell_call; use core_test_support::responses::ev_message_item_added; use core_test_support::responses::ev_output_text_delta; use core_test_support::responses::ev_reasoning_item; +use core_test_support::responses::ev_reasoning_item_added; use core_test_support::responses::ev_reasoning_summary_text_delta; use core_test_support::responses::ev_reasoning_text_delta; use core_test_support::responses::ev_response_created; @@ -629,20 +630,35 @@ async fn record_responses_sets_span_fields_for_response_events() { let sse_body = sse(vec![ ev_response_created("resp-1"), - ev_function_call("call-1", "fn", "{\"value\":1}"), - ev_custom_tool_call("custom-1", "custom_tool", "{\"key\":\"value\"}"), + serde_json::json!({ + "type": "response.output_item.added", + "item": { + "type": "function_call", + "call_id": "call-1", + "name": "fn", + "arguments": "{\"value\":1}" + } + }), ev_message_item_added("msg-added", "hi there"), + ev_reasoning_item_added("reasoning-1", &["summary"]), ev_output_text_delta("delta"), ev_reasoning_summary_text_delta("summary-delta"), ev_reasoning_text_delta("raw-delta"), ev_function_call("call-1", "fn", "{\"key\":\"value\"}"), - ev_custom_tool_call("custom-1", "custom_tool", "{\"key\":\"value\"}"), ev_assistant_message("msg-1", "agent"), ev_reasoning_item("reasoning-1", &["summary"], &[]), ev_completed("resp-1"), ]); mount_response_once(&server, sse_response(sse_body)).await; + mount_response_once( + &server, + sse_response(sse(vec![ + ev_assistant_message("msg-2", "follow-up complete"), + ev_completed("resp-2"), + ])), + ) + .await; let TestCodex { codex, .. } = test_codex() .with_config(|config| { diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index f8df52a3a0..8dfe2b5e6b 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -490,7 +490,6 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { let expected = DeveloperInstructions::from_policy( &sandbox_policy, AskForApproval::OnRequest, - false, &Policy::empty(), test.config.cwd.as_path(), false, diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index 3a9aa20455..6313616e35 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -29,6 +29,7 @@ use core_test_support::wait_for_event_match; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; +use serial_test::serial; use std::ffi::OsString; use std::fs; use std::time::Duration; @@ -37,7 +38,6 @@ use tokio::sync::oneshot; const STARTUP_CONTEXT_HEADER: &str = "Startup context from Codex."; const MEMORY_PROMPT_PHRASE: &str = "You have access to a memory folder with guidance from prior runs."; - fn websocket_request_text( request: &core_test_support::responses::WebSocketRequest, ) -> Option { @@ -54,6 +54,33 @@ fn websocket_request_instructions( .map(str::to_owned) } +async fn wait_for_matching_websocket_request( + server: &core_test_support::responses::WebSocketTestServer, + description: &str, + predicate: F, +) -> core_test_support::responses::WebSocketRequest +where + F: Fn(&core_test_support::responses::WebSocketRequest) -> bool, +{ + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + loop { + if let Some(request) = server + .connections() + .iter() + .flat_map(|connection| connection.iter()) + .find(|request| predicate(request)) + .cloned() + { + return request; + } + + assert!( + tokio::time::Instant::now() < deadline, + "timed out waiting for {description}" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } +} async fn seed_recent_thread( test: &TestCodex, title: &str, @@ -230,6 +257,7 @@ async fn conversation_start_audio_text_close_round_trip() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial(openai_api_key_env)] async fn conversation_start_uses_openai_env_key_fallback_with_chatgpt_auth() -> Result<()> { skip_if_no_network!(Ok(())); @@ -632,21 +660,23 @@ async fn conversation_uses_experimental_realtime_ws_backend_prompt_override() -> async fn conversation_uses_experimental_realtime_ws_startup_context_override() -> Result<()> { skip_if_no_network!(Ok(())); - let server = start_websocket_server(vec![ - vec![], - vec![vec![json!({ - "type": "session.updated", - "session": { "id": "sess_custom_context", "instructions": "prompt from config" } - })]], - ]) + let startup_server = start_websocket_server(vec![vec![]]).await; + let realtime_server = start_websocket_server(vec![vec![vec![json!({ + "type": "session.updated", + "session": { "id": "sess_custom_context", "instructions": "prompt from config" } + })]]]) .await; - let mut builder = test_codex().with_config(|config| { - config.experimental_realtime_ws_backend_prompt = Some("prompt from config".to_string()); - config.experimental_realtime_ws_startup_context = - Some("custom startup context".to_string()); + let mut builder = test_codex().with_config({ + let realtime_base_url = realtime_server.uri().to_string(); + move |config| { + config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.experimental_realtime_ws_backend_prompt = Some("prompt from config".to_string()); + config.experimental_realtime_ws_startup_context = + Some("custom startup context".to_string()); + } }); - let test = builder.build_with_websocket_server(&server).await?; + let test = builder.build_with_websocket_server(&startup_server).await?; seed_recent_thread( &test, "Recent work: cleaned up startup flows and reviewed websocket routing.", @@ -656,7 +686,11 @@ async fn conversation_uses_experimental_realtime_ws_startup_context_override() - .await?; fs::create_dir_all(test.workspace_path("docs"))?; fs::write(test.workspace_path("README.md"), "workspace marker")?; - assert!(server.wait_for_handshakes(1, Duration::from_secs(2)).await); + assert!( + startup_server + .wait_for_handshakes(1, Duration::from_secs(2)) + .await + ); test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { @@ -665,17 +699,12 @@ async fn conversation_uses_experimental_realtime_ws_startup_context_override() - })) .await?; - wait_for_event_match(&test.codex, |msg| match msg { - EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { - payload: RealtimeEvent::SessionUpdated { session_id, .. }, - }) if session_id == "sess_custom_context" => Some(Ok(())), - EventMsg::Error(err) => Some(Err(err.clone())), - _ => None, - }) - .await - .unwrap_or_else(|err: ErrorEvent| panic!("conversation start failed: {err:?}")); - - let startup_context_request = server.wait_for_request(1, 0).await; + let startup_context_request = wait_for_matching_websocket_request( + &realtime_server, + "startup context request with instructions", + |request| websocket_request_instructions(request).is_some(), + ) + .await; let instructions = websocket_request_instructions(&startup_context_request) .expect("custom startup context request should contain instructions"); @@ -683,7 +712,8 @@ async fn conversation_uses_experimental_realtime_ws_startup_context_override() - assert!(!instructions.contains(STARTUP_CONTEXT_HEADER)); assert!(!instructions.contains("## Machine / Workspace Map")); - server.shutdown().await; + startup_server.shutdown().await; + realtime_server.shutdown().await; Ok(()) } @@ -691,20 +721,22 @@ async fn conversation_uses_experimental_realtime_ws_startup_context_override() - async fn conversation_disables_realtime_startup_context_with_empty_override() -> Result<()> { skip_if_no_network!(Ok(())); - let server = start_websocket_server(vec![ - vec![], - vec![vec![json!({ - "type": "session.updated", - "session": { "id": "sess_no_context", "instructions": "prompt from config" } - })]], - ]) + let startup_server = start_websocket_server(vec![vec![]]).await; + let realtime_server = start_websocket_server(vec![vec![vec![json!({ + "type": "session.updated", + "session": { "id": "sess_no_context", "instructions": "prompt from config" } + })]]]) .await; - let mut builder = test_codex().with_config(|config| { - config.experimental_realtime_ws_backend_prompt = Some("prompt from config".to_string()); - config.experimental_realtime_ws_startup_context = Some(String::new()); + let mut builder = test_codex().with_config({ + let realtime_base_url = realtime_server.uri().to_string(); + move |config| { + config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.experimental_realtime_ws_backend_prompt = Some("prompt from config".to_string()); + config.experimental_realtime_ws_startup_context = Some(String::new()); + } }); - let test = builder.build_with_websocket_server(&server).await?; + let test = builder.build_with_websocket_server(&startup_server).await?; seed_recent_thread( &test, "Recent work: cleaned up startup flows and reviewed websocket routing.", @@ -714,7 +746,11 @@ async fn conversation_disables_realtime_startup_context_with_empty_override() -> .await?; fs::create_dir_all(test.workspace_path("docs"))?; fs::write(test.workspace_path("README.md"), "workspace marker")?; - assert!(server.wait_for_handshakes(1, Duration::from_secs(2)).await); + assert!( + startup_server + .wait_for_handshakes(1, Duration::from_secs(2)) + .await + ); test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { @@ -723,17 +759,12 @@ async fn conversation_disables_realtime_startup_context_with_empty_override() -> })) .await?; - wait_for_event_match(&test.codex, |msg| match msg { - EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { - payload: RealtimeEvent::SessionUpdated { session_id, .. }, - }) if session_id == "sess_no_context" => Some(Ok(())), - EventMsg::Error(err) => Some(Err(err.clone())), - _ => None, - }) - .await - .unwrap_or_else(|err: ErrorEvent| panic!("conversation start failed: {err:?}")); - - let startup_context_request = server.wait_for_request(1, 0).await; + let startup_context_request = wait_for_matching_websocket_request( + &realtime_server, + "startup context disable request with instructions", + |request| websocket_request_instructions(request).is_some(), + ) + .await; let instructions = websocket_request_instructions(&startup_context_request) .expect("startup context disable request should contain instructions"); @@ -741,7 +772,8 @@ async fn conversation_disables_realtime_startup_context_with_empty_override() -> assert!(!instructions.contains(STARTUP_CONTEXT_HEADER)); assert!(!instructions.contains("## Machine / Workspace Map")); - server.shutdown().await; + startup_server.shutdown().await; + realtime_server.shutdown().await; Ok(()) } @@ -749,17 +781,20 @@ async fn conversation_disables_realtime_startup_context_with_empty_override() -> async fn conversation_start_injects_startup_context_from_thread_history() -> Result<()> { skip_if_no_network!(Ok(())); - let server = start_websocket_server(vec![ - vec![], - vec![vec![json!({ - "type": "session.updated", - "session": { "id": "sess_context", "instructions": "backend prompt" } - })]], - ]) + let startup_server = start_websocket_server(vec![vec![]]).await; + let realtime_server = start_websocket_server(vec![vec![vec![json!({ + "type": "session.updated", + "session": { "id": "sess_context", "instructions": "backend prompt" } + })]]]) .await; - let mut builder = test_codex(); - let test = builder.build_with_websocket_server(&server).await?; + let mut builder = test_codex().with_config({ + let realtime_base_url = realtime_server.uri().to_string(); + move |config| { + config.experimental_realtime_ws_base_url = Some(realtime_base_url); + } + }); + let test = builder.build_with_websocket_server(&startup_server).await?; seed_recent_thread( &test, "Recent work: cleaned up startup flows and reviewed websocket routing.", @@ -777,17 +812,12 @@ async fn conversation_start_injects_startup_context_from_thread_history() -> Res })) .await?; - wait_for_event_match(&test.codex, |msg| match msg { - EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { - payload: RealtimeEvent::SessionUpdated { session_id, .. }, - }) if session_id == "sess_context" => Some(Ok(())), - EventMsg::Error(err) => Some(Err(err.clone())), - _ => None, - }) - .await - .unwrap_or_else(|err: ErrorEvent| panic!("conversation start failed: {err:?}")); - - let startup_context_request = server.wait_for_request(1, 0).await; + let startup_context_request = wait_for_matching_websocket_request( + &realtime_server, + "startup context request with instructions", + |request| websocket_request_instructions(request).is_some(), + ) + .await; let startup_context = websocket_request_instructions(&startup_context_request) .expect("startup context request should contain instructions"); @@ -802,7 +832,8 @@ async fn conversation_start_injects_startup_context_from_thread_history() -> Res assert!(startup_context.contains("README.md")); assert!(!startup_context.contains(MEMORY_PROMPT_PHRASE)); - server.shutdown().await; + startup_server.shutdown().await; + realtime_server.shutdown().await; Ok(()) } @@ -810,17 +841,20 @@ async fn conversation_start_injects_startup_context_from_thread_history() -> Res async fn conversation_startup_context_falls_back_to_workspace_map() -> Result<()> { skip_if_no_network!(Ok(())); - let server = start_websocket_server(vec![ - vec![], - vec![vec![json!({ - "type": "session.updated", - "session": { "id": "sess_workspace", "instructions": "backend prompt" } - })]], - ]) + let startup_server = start_websocket_server(vec![vec![]]).await; + let realtime_server = start_websocket_server(vec![vec![vec![json!({ + "type": "session.updated", + "session": { "id": "sess_workspace", "instructions": "backend prompt" } + })]]]) .await; - let mut builder = test_codex(); - let test = builder.build_with_websocket_server(&server).await?; + let mut builder = test_codex().with_config({ + let realtime_base_url = realtime_server.uri().to_string(); + move |config| { + config.experimental_realtime_ws_base_url = Some(realtime_base_url); + } + }); + let test = builder.build_with_websocket_server(&startup_server).await?; fs::create_dir_all(test.workspace_path("codex-rs/core"))?; fs::write(test.workspace_path("notes.txt"), "workspace marker")?; @@ -831,17 +865,12 @@ async fn conversation_startup_context_falls_back_to_workspace_map() -> Result<() })) .await?; - wait_for_event_match(&test.codex, |msg| match msg { - EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { - payload: RealtimeEvent::SessionUpdated { session_id, .. }, - }) if session_id == "sess_workspace" => Some(Ok(())), - EventMsg::Error(err) => Some(Err(err.clone())), - _ => None, - }) - .await - .unwrap_or_else(|err: ErrorEvent| panic!("conversation start failed: {err:?}")); - - let startup_context_request = server.wait_for_request(1, 0).await; + let startup_context_request = wait_for_matching_websocket_request( + &realtime_server, + "workspace-map startup context request with instructions", + |request| websocket_request_instructions(request).is_some(), + ) + .await; let startup_context = websocket_request_instructions(&startup_context_request) .expect("startup context request should contain instructions"); @@ -850,7 +879,8 @@ async fn conversation_startup_context_falls_back_to_workspace_map() -> Result<() assert!(startup_context.contains("notes.txt")); assert!(startup_context.contains("codex-rs/")); - server.shutdown().await; + startup_server.shutdown().await; + realtime_server.shutdown().await; Ok(()) } @@ -858,21 +888,24 @@ async fn conversation_startup_context_falls_back_to_workspace_map() -> Result<() async fn conversation_startup_context_is_truncated_and_sent_once_per_start() -> Result<()> { skip_if_no_network!(Ok(())); - let server = start_websocket_server(vec![ + let startup_server = start_websocket_server(vec![vec![]]).await; + let realtime_server = start_websocket_server(vec![vec![ + vec![json!({ + "type": "session.updated", + "session": { "id": "sess_truncated", "instructions": "backend prompt" } + })], vec![], - vec![ - vec![json!({ - "type": "session.updated", - "session": { "id": "sess_truncated", "instructions": "backend prompt" } - })], - vec![], - ], - ]) + ]]) .await; let oversized_summary = "recent work ".repeat(3_500); - let mut builder = test_codex(); - let test = builder.build_with_websocket_server(&server).await?; + let mut builder = test_codex().with_config({ + let realtime_base_url = realtime_server.uri().to_string(); + move |config| { + config.experimental_realtime_ws_base_url = Some(realtime_base_url); + } + }); + let test = builder.build_with_websocket_server(&startup_server).await?; seed_recent_thread(&test, &oversized_summary, "summary", "oversized").await?; fs::write(test.workspace_path("marker.txt"), "marker")?; @@ -883,17 +916,12 @@ async fn conversation_startup_context_is_truncated_and_sent_once_per_start() -> })) .await?; - wait_for_event_match(&test.codex, |msg| match msg { - EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { - payload: RealtimeEvent::SessionUpdated { session_id, .. }, - }) if session_id == "sess_truncated" => Some(Ok(())), - EventMsg::Error(err) => Some(Err(err.clone())), - _ => None, - }) - .await - .unwrap_or_else(|err: ErrorEvent| panic!("conversation start failed: {err:?}")); - - let startup_context_request = server.wait_for_request(1, 0).await; + let startup_context_request = wait_for_matching_websocket_request( + &realtime_server, + "truncated startup context request with instructions", + |request| websocket_request_instructions(request).is_some(), + ) + .await; let startup_context = websocket_request_instructions(&startup_context_request) .expect("startup context request should contain instructions"); assert!(startup_context.contains(STARTUP_CONTEXT_HEADER)); @@ -905,13 +933,19 @@ async fn conversation_startup_context_is_truncated_and_sent_once_per_start() -> })) .await?; - let explicit_text_request = server.wait_for_request(1, 1).await; + let explicit_text_request = wait_for_matching_websocket_request( + &realtime_server, + "explicit realtime text request", + |request| websocket_request_text(request).as_deref() == Some("hello"), + ) + .await; assert_eq!( websocket_request_text(&explicit_text_request), Some("hello".to_string()) ); - server.shutdown().await; + startup_server.shutdown().await; + realtime_server.shutdown().await; Ok(()) } diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 7080592cd2..bbe4d12a79 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -12,6 +12,8 @@ use codex_protocol::protocol::ExecApprovalRequestEvent; use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::request_permissions::PermissionGrantScope; +use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::responses::ev_assistant_message; @@ -19,10 +21,10 @@ use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; +use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; -#[cfg(target_os = "macos")] use core_test_support::skip_if_sandbox; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; @@ -97,7 +99,65 @@ fn shell_event_with_request_permissions( Ok(ev_function_call(call_id, "shell_command", &args_str)) } -#[cfg(target_os = "macos")] +fn request_permissions_tool_event( + call_id: &str, + reason: &str, + permissions: &PermissionProfile, +) -> Result { + let args = json!({ + "reason": reason, + "permissions": permissions, + }); + let args_str = serde_json::to_string(&args)?; + Ok(ev_function_call(call_id, "request_permissions", &args_str)) +} + +fn shell_command_event(call_id: &str, command: &str) -> Result { + let args = json!({ + "command": command, + "timeout_ms": 1_000_u64, + }); + let args_str = serde_json::to_string(&args)?; + Ok(ev_function_call(call_id, "shell_command", &args_str)) +} + +fn exec_command_event(call_id: &str, command: &str) -> Result { + let args = json!({ + "cmd": command, + "yield_time_ms": 1_000_u64, + }); + let args_str = serde_json::to_string(&args)?; + Ok(ev_function_call(call_id, "exec_command", &args_str)) +} + +fn exec_command_event_with_request_permissions( + call_id: &str, + command: &str, + additional_permissions: &PermissionProfile, +) -> Result { + let args = json!({ + "cmd": command, + "yield_time_ms": 1_000_u64, + "sandbox_permissions": SandboxPermissions::WithAdditionalPermissions, + "additional_permissions": additional_permissions, + }); + let args_str = serde_json::to_string(&args)?; + Ok(ev_function_call(call_id, "exec_command", &args_str)) +} + +fn exec_command_event_with_missing_additional_permissions( + call_id: &str, + command: &str, +) -> Result { + let args = json!({ + "cmd": command, + "yield_time_ms": 1_000_u64, + "sandbox_permissions": SandboxPermissions::WithAdditionalPermissions, + }); + let args_str = serde_json::to_string(&args)?; + Ok(ev_function_call(call_id, "exec_command", &args_str)) +} + fn shell_event_with_raw_request_permissions( call_id: &str, command: &str, @@ -177,6 +237,46 @@ async fn expect_exec_approval( } } +async fn wait_for_exec_approval_or_completion( + test: &TestCodex, +) -> Option { + let event = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) + ) + }) + .await; + + match event { + EventMsg::ExecApprovalRequest(approval) => Some(approval), + EventMsg::TurnComplete(_) => None, + other => panic!("unexpected event: {other:?}"), + } +} + +async fn expect_request_permissions_event( + test: &TestCodex, + expected_call_id: &str, +) -> PermissionProfile { + let event = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::RequestPermissions(_) | EventMsg::TurnComplete(_) + ) + }) + .await; + + match event { + EventMsg::RequestPermissions(request) => { + assert_eq!(request.call_id, expected_call_id); + request.permissions + } + EventMsg::TurnComplete(_) => panic!("expected request_permissions before completion"), + other => panic!("unexpected event: {other:?}"), + } +} + fn workspace_write_excluding_tmp() -> SandboxPolicy { SandboxPolicy::WorkspaceWrite { writable_roots: vec![], @@ -187,8 +287,27 @@ fn workspace_write_excluding_tmp() -> SandboxPolicy { } } +fn requested_directory_write_permissions(path: &Path) -> PermissionProfile { + PermissionProfile { + file_system: Some(FileSystemPermissions { + read: Some(vec![]), + write: Some(vec![absolute_path(path)]), + }), + ..Default::default() + } +} + +fn normalized_directory_write_permissions(path: &Path) -> Result { + Ok(PermissionProfile { + file_system: Some(FileSystemPermissions { + read: Some(vec![]), + write: Some(vec![AbsolutePathBuf::try_from(path.canonicalize()?)?]), + }), + ..Default::default() + }) +} + #[tokio::test(flavor = "current_thread")] -#[cfg(target_os = "macos")] async fn with_additional_permissions_requires_approval_under_on_request() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); @@ -205,6 +324,10 @@ async fn with_additional_permissions_requires_approval_under_on_request() -> Res .features .enable(Feature::RequestPermissions) .expect("test config should allow feature update"); + config + .features + .enable(Feature::RequestPermissionsTool) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; @@ -273,7 +396,6 @@ async fn with_additional_permissions_requires_approval_under_on_request() -> Res } #[tokio::test(flavor = "current_thread")] -#[cfg(target_os = "macos")] async fn relative_additional_permissions_resolve_against_tool_workdir() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); @@ -290,6 +412,10 @@ async fn relative_additional_permissions_resolve_against_tool_workdir() -> Resul .features .enable(Feature::RequestPermissions) .expect("test config should allow feature update"); + config + .features + .enable(Feature::RequestPermissionsTool) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; @@ -387,6 +513,10 @@ async fn read_only_with_additional_permissions_does_not_widen_to_unrequested_cwd .features .enable(Feature::RequestPermissions) .expect("test config should allow feature update"); + config + .features + .enable(Feature::RequestPermissionsTool) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; @@ -483,6 +613,10 @@ async fn read_only_with_additional_permissions_does_not_widen_to_unrequested_tmp .features .enable(Feature::RequestPermissions) .expect("test config should allow feature update"); + config + .features + .enable(Feature::RequestPermissionsTool) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; @@ -562,7 +696,6 @@ async fn read_only_with_additional_permissions_does_not_widen_to_unrequested_tmp } #[tokio::test(flavor = "current_thread")] -#[cfg(target_os = "macos")] async fn workspace_write_with_additional_permissions_can_write_outside_cwd() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); @@ -579,6 +712,10 @@ async fn workspace_write_with_additional_permissions_can_write_outside_cwd() -> .features .enable(Feature::RequestPermissions) .expect("test config should allow feature update"); + config + .features + .enable(Feature::RequestPermissionsTool) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; @@ -665,7 +802,6 @@ async fn workspace_write_with_additional_permissions_can_write_outside_cwd() -> } #[tokio::test(flavor = "current_thread")] -#[cfg(unix)] async fn with_additional_permissions_denied_approval_blocks_execution() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -680,6 +816,10 @@ async fn with_additional_permissions_denied_approval_blocks_execution() -> Resul .features .enable(Feature::RequestPermissions) .expect("test config should allow feature update"); + config + .features + .enable(Feature::RequestPermissionsTool) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; @@ -763,3 +903,759 @@ async fn with_additional_permissions_denied_approval_blocks_execution() -> Resul let _ = fs::remove_file(outside_write); Ok(()) } + +#[tokio::test(flavor = "current_thread")] +async fn request_permissions_grants_apply_to_later_exec_command_calls() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + let approval_policy = AskForApproval::OnRequest; + let sandbox_policy = workspace_write_excluding_tmp(); + let sandbox_policy_for_config = sandbox_policy.clone(); + + let mut builder = test_codex().with_config(move |config| { + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config); + config + .features + .enable(Feature::RequestPermissions) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::RequestPermissionsTool) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + let outside_dir = tempfile::tempdir()?; + let outside_write = outside_dir.path().join("sticky-write.txt"); + let command = format!( + "printf {:?} > {:?} && cat {:?}", + "sticky-grant-ok", outside_write, outside_write + ); + let requested_permissions = PermissionProfile { + file_system: Some(FileSystemPermissions { + read: Some(vec![]), + write: Some(vec![absolute_path(outside_dir.path())]), + }), + ..Default::default() + }; + let normalized_requested_permissions = PermissionProfile { + file_system: Some(FileSystemPermissions { + read: Some(vec![]), + write: Some(vec![AbsolutePathBuf::try_from( + outside_dir.path().canonicalize()?, + )?]), + }), + ..Default::default() + }; + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-sticky-1"), + request_permissions_tool_event( + "permissions-call", + "Allow writing outside the workspace", + &requested_permissions, + )?, + ev_completed("resp-sticky-1"), + ]), + sse(vec![ + ev_response_created("resp-sticky-2"), + exec_command_event("exec-call", &command)?, + ev_completed("resp-sticky-2"), + ]), + sse(vec![ + ev_response_created("resp-sticky-3"), + ev_assistant_message("msg-sticky-1", "done"), + ev_completed("resp-sticky-3"), + ]), + ], + ) + .await; + + submit_turn( + &test, + "write outside the workspace", + approval_policy, + sandbox_policy, + ) + .await?; + + let granted_permissions = expect_request_permissions_event(&test, "permissions-call").await; + assert_eq!( + granted_permissions, + normalized_requested_permissions.clone() + ); + test.codex + .submit(Op::RequestPermissionsResponse { + id: "permissions-call".to_string(), + response: RequestPermissionsResponse { + permissions: normalized_requested_permissions.clone(), + scope: PermissionGrantScope::Turn, + }, + }) + .await?; + + if let Some(approval) = wait_for_exec_approval_or_completion(&test).await { + assert_eq!( + approval.additional_permissions, + Some(normalized_requested_permissions.clone()) + ); + test.codex + .submit(Op::ExecApproval { + id: approval.effective_approval_id(), + turn_id: None, + decision: ReviewDecision::Approved, + }) + .await?; + wait_for_completion(&test).await; + } + + let exec_output = responses + .function_call_output_text("exec-call") + .map(|output| json!({ "output": output })) + .unwrap_or_else(|| panic!("expected exec-call output")); + let result = parse_result(&exec_output); + assert_eq!(result.exit_code, Some(0)); + assert_eq!(result.stdout.trim(), "sticky-grant-ok"); + assert_eq!(fs::read_to_string(&outside_write)?, "sticky-grant-ok"); + + Ok(()) +} + +#[tokio::test(flavor = "current_thread")] +async fn request_permissions_preapprove_explicit_exec_permissions_outside_on_request() -> Result<()> +{ + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + let approval_policy = AskForApproval::OnRequest; + let sandbox_policy = workspace_write_excluding_tmp(); + let sandbox_policy_for_config = sandbox_policy.clone(); + + let mut builder = test_codex().with_config(move |config| { + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config); + config + .features + .enable(Feature::RequestPermissions) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::RequestPermissionsTool) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + let outside_dir = tempfile::tempdir()?; + let outside_write = outside_dir.path().join("sticky-explicit-write.txt"); + let command = format!( + "printf {:?} > {:?} && cat {:?}", + "sticky-explicit-grant-ok", outside_write, outside_write + ); + let requested_permissions = requested_directory_write_permissions(outside_dir.path()); + let normalized_requested_permissions = + normalized_directory_write_permissions(outside_dir.path())?; + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-sticky-explicit-1"), + request_permissions_tool_event( + "permissions-call", + "Allow writing outside the workspace", + &requested_permissions, + )?, + ev_completed("resp-sticky-explicit-1"), + ]), + sse(vec![ + ev_response_created("resp-sticky-explicit-2"), + exec_command_event_with_request_permissions( + "exec-call", + &command, + &requested_permissions, + )?, + ev_completed("resp-sticky-explicit-2"), + ]), + sse(vec![ + ev_response_created("resp-sticky-explicit-3"), + ev_assistant_message("msg-sticky-explicit-1", "done"), + ev_completed("resp-sticky-explicit-3"), + ]), + ], + ) + .await; + + submit_turn( + &test, + "write outside the workspace", + approval_policy, + sandbox_policy, + ) + .await?; + + let granted_permissions = expect_request_permissions_event(&test, "permissions-call").await; + assert_eq!( + granted_permissions, + normalized_requested_permissions.clone() + ); + test.codex + .submit(Op::RequestPermissionsResponse { + id: "permissions-call".to_string(), + response: RequestPermissionsResponse { + permissions: normalized_requested_permissions, + scope: PermissionGrantScope::Turn, + }, + }) + .await?; + + if let Some(approval) = wait_for_exec_approval_or_completion(&test).await { + test.codex + .submit(Op::ExecApproval { + id: approval.effective_approval_id(), + turn_id: None, + decision: ReviewDecision::Approved, + }) + .await?; + wait_for_completion(&test).await; + } + + let exec_output = responses + .function_call_output_text("exec-call") + .map(|output| json!({ "output": output })) + .unwrap_or_else(|| panic!("expected exec-call output")); + let result = parse_result(&exec_output); + assert!( + result.exit_code.is_none_or(|exit_code| exit_code == 0), + "expected success output, got exit_code={:?}, stdout={:?}", + result.exit_code, + result.stdout + ); + assert_eq!(result.stdout.trim(), "sticky-explicit-grant-ok"); + assert_eq!( + fs::read_to_string(&outside_write)?, + "sticky-explicit-grant-ok" + ); + + Ok(()) +} + +#[tokio::test(flavor = "current_thread")] +async fn request_permissions_grants_apply_to_later_shell_command_calls() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + let approval_policy = AskForApproval::OnRequest; + let sandbox_policy = workspace_write_excluding_tmp(); + let sandbox_policy_for_config = sandbox_policy.clone(); + + let mut builder = test_codex().with_config(move |config| { + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config); + config + .features + .enable(Feature::RequestPermissions) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::RequestPermissionsTool) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + let outside_dir = tempfile::tempdir()?; + let outside_write = outside_dir.path().join("sticky-shell-write.txt"); + let command = format!( + "printf {:?} > {:?} && cat {:?}", + "sticky-shell-grant-ok", outside_write, outside_write + ); + let requested_permissions = requested_directory_write_permissions(outside_dir.path()); + let normalized_requested_permissions = + normalized_directory_write_permissions(outside_dir.path())?; + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-sticky-shell-1"), + request_permissions_tool_event( + "permissions-call", + "Allow writing outside the workspace", + &requested_permissions, + )?, + ev_completed("resp-sticky-shell-1"), + ]), + sse(vec![ + ev_response_created("resp-sticky-shell-2"), + shell_command_event("shell-call", &command)?, + ev_completed("resp-sticky-shell-2"), + ]), + sse(vec![ + ev_response_created("resp-sticky-shell-3"), + ev_assistant_message("msg-sticky-shell-1", "done"), + ev_completed("resp-sticky-shell-3"), + ]), + ], + ) + .await; + + submit_turn( + &test, + "write outside the workspace", + approval_policy, + sandbox_policy, + ) + .await?; + + let granted_permissions = expect_request_permissions_event(&test, "permissions-call").await; + assert_eq!( + granted_permissions, + normalized_requested_permissions.clone() + ); + test.codex + .submit(Op::RequestPermissionsResponse { + id: "permissions-call".to_string(), + response: RequestPermissionsResponse { + permissions: normalized_requested_permissions.clone(), + scope: PermissionGrantScope::Turn, + }, + }) + .await?; + + if let Some(approval) = wait_for_exec_approval_or_completion(&test).await { + test.codex + .submit(Op::ExecApproval { + id: approval.effective_approval_id(), + turn_id: None, + decision: ReviewDecision::Approved, + }) + .await?; + wait_for_completion(&test).await; + } + + let shell_output = responses + .function_call_output_text("shell-call") + .map(|output| json!({ "output": output })) + .unwrap_or_else(|| panic!("expected shell-call output")); + let result = parse_result(&shell_output); + assert!( + result.exit_code.is_none_or(|exit_code| exit_code == 0), + "expected success output, got exit_code={:?}, stdout={:?}", + result.exit_code, + result.stdout + ); + assert_eq!(result.stdout.trim(), "sticky-shell-grant-ok"); + assert_eq!(fs::read_to_string(&outside_write)?, "sticky-shell-grant-ok"); + + Ok(()) +} + +#[tokio::test(flavor = "current_thread")] +async fn partial_request_permissions_grants_do_not_preapprove_new_permissions() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + let approval_policy = AskForApproval::OnRequest; + let sandbox_policy = workspace_write_excluding_tmp(); + let sandbox_policy_for_config = sandbox_policy.clone(); + + let mut builder = test_codex().with_config(move |config| { + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config); + config + .features + .enable(Feature::RequestPermissions) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::RequestPermissionsTool) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + let first_dir = tempfile::tempdir()?; + let second_dir = tempfile::tempdir()?; + let second_write = second_dir.path().join("partial-grant-write.txt"); + let command = format!( + "printf {:?} > {:?} && cat {:?}", + "partial-grant-ok", second_write, second_write + ); + + let requested_permissions = PermissionProfile { + file_system: Some(FileSystemPermissions { + read: Some(vec![]), + write: Some(vec![ + absolute_path(first_dir.path()), + absolute_path(second_dir.path()), + ]), + }), + ..Default::default() + }; + let normalized_requested_permissions = PermissionProfile { + file_system: Some(FileSystemPermissions { + read: Some(vec![]), + write: Some(vec![ + AbsolutePathBuf::try_from(first_dir.path().canonicalize()?)?, + AbsolutePathBuf::try_from(second_dir.path().canonicalize()?)?, + ]), + }), + ..Default::default() + }; + let granted_permissions = normalized_directory_write_permissions(first_dir.path())?; + let second_dir_permissions = requested_directory_write_permissions(second_dir.path()); + let merged_permissions = PermissionProfile { + file_system: Some(FileSystemPermissions { + read: Some(vec![]), + write: Some(vec![ + AbsolutePathBuf::try_from(first_dir.path().canonicalize()?)?, + AbsolutePathBuf::try_from(second_dir.path().canonicalize()?)?, + ]), + }), + ..Default::default() + }; + + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-partial-1"), + request_permissions_tool_event( + "permissions-call", + "Allow writing outside the workspace", + &requested_permissions, + )?, + ev_completed("resp-partial-1"), + ]), + sse(vec![ + ev_response_created("resp-partial-2"), + exec_command_event_with_request_permissions( + "exec-call", + &command, + &second_dir_permissions, + )?, + ev_completed("resp-partial-2"), + ]), + sse(vec![ + ev_response_created("resp-partial-3"), + ev_assistant_message("msg-partial-1", "done"), + ev_completed("resp-partial-3"), + ]), + ], + ) + .await; + + submit_turn( + &test, + "write outside the workspace", + approval_policy, + sandbox_policy, + ) + .await?; + + let initial_request = expect_request_permissions_event(&test, "permissions-call").await; + assert_eq!(initial_request, normalized_requested_permissions); + test.codex + .submit(Op::RequestPermissionsResponse { + id: "permissions-call".to_string(), + response: RequestPermissionsResponse { + permissions: granted_permissions.clone(), + scope: PermissionGrantScope::Turn, + }, + }) + .await?; + + let approval = expect_exec_approval(&test, &command).await; + let approval_permissions = approval + .additional_permissions + .clone() + .unwrap_or_else(|| panic!("expected merged additional permissions")); + assert_eq!(approval_permissions.network, None); + assert_eq!(approval_permissions.macos, None); + + let approval_file_system = approval_permissions + .file_system + .unwrap_or_else(|| panic!("expected filesystem permissions")); + assert!(approval_file_system.read.as_ref().is_none_or(Vec::is_empty)); + + let mut approval_writes = approval_file_system.write.unwrap_or_default(); + approval_writes.sort_by_key(|path| path.display().to_string()); + + let mut expected_writes = merged_permissions + .file_system + .unwrap_or_else(|| panic!("expected merged filesystem permissions")) + .write + .unwrap_or_default(); + expected_writes.sort_by_key(|path| path.display().to_string()); + + assert_eq!(approval_writes, expected_writes); + test.codex + .submit(Op::ExecApproval { + id: approval.effective_approval_id(), + turn_id: None, + decision: ReviewDecision::Approved, + }) + .await?; + wait_for_completion(&test).await; + + let exec_output = responses + .function_call_output_text("exec-call") + .map(|output| json!({ "output": output })) + .unwrap_or_else(|| panic!("expected exec-call output")); + let result = parse_result(&exec_output); + assert_eq!(result.exit_code, Some(0)); + assert_eq!(result.stdout.trim(), "partial-grant-ok"); + assert_eq!(fs::read_to_string(&second_write)?, "partial-grant-ok"); + + Ok(()) +} + +#[tokio::test(flavor = "current_thread")] +async fn request_permissions_grants_do_not_carry_across_turns() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + let approval_policy = AskForApproval::OnRequest; + let sandbox_policy = workspace_write_excluding_tmp(); + let sandbox_policy_for_config = sandbox_policy.clone(); + + let mut builder = test_codex().with_config(move |config| { + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config); + config + .features + .enable(Feature::RequestPermissions) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::RequestPermissionsTool) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + let outside_dir = tempfile::tempdir()?; + let requested_permissions = requested_directory_write_permissions(outside_dir.path()); + let normalized_requested_permissions = + normalized_directory_write_permissions(outside_dir.path())?; + + let _first_turn = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-turn-1"), + request_permissions_tool_event( + "permissions-call", + "Allow writing outside the workspace", + &requested_permissions, + )?, + ev_completed("resp-turn-1"), + ]), + sse(vec![ + ev_response_created("resp-turn-2"), + ev_assistant_message("msg-turn-1", "done"), + ev_completed("resp-turn-2"), + ]), + ], + ) + .await; + + submit_turn( + &test, + "request permissions for later use", + approval_policy, + sandbox_policy.clone(), + ) + .await?; + + let granted_permissions = expect_request_permissions_event(&test, "permissions-call").await; + assert_eq!( + granted_permissions, + normalized_requested_permissions.clone() + ); + test.codex + .submit(Op::RequestPermissionsResponse { + id: "permissions-call".to_string(), + response: RequestPermissionsResponse { + permissions: normalized_requested_permissions, + scope: PermissionGrantScope::Turn, + }, + }) + .await?; + wait_for_completion(&test).await; + + let second_turn = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-turn-3"), + exec_command_event_with_missing_additional_permissions( + "exec-call", + "printf 'should not run'", + )?, + ev_completed("resp-turn-3"), + ]), + sse(vec![ + ev_response_created("resp-turn-4"), + ev_assistant_message("msg-turn-2", "done"), + ev_completed("resp-turn-4"), + ]), + ], + ) + .await; + + submit_turn( + &test, + "try to reuse permissions in a later turn", + approval_policy, + sandbox_policy, + ) + .await?; + wait_for_completion(&test).await; + + let output = second_turn + .function_call_output_text("exec-call") + .unwrap_or_else(|| panic!("expected exec-call output")); + assert!(output.contains("missing `additional_permissions`")); + + Ok(()) +} + +#[tokio::test(flavor = "current_thread")] +#[cfg(target_os = "macos")] +async fn request_permissions_session_grants_carry_across_turns() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + let approval_policy = AskForApproval::OnRequest; + let sandbox_policy = workspace_write_excluding_tmp(); + let sandbox_policy_for_config = sandbox_policy.clone(); + + let mut builder = test_codex().with_config(move |config| { + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config); + config + .features + .enable(Feature::RequestPermissions) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::RequestPermissionsTool) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + let outside_dir = tempfile::tempdir()?; + let outside_write = outside_dir.path().join("session-sticky-write.txt"); + let requested_permissions = requested_directory_write_permissions(outside_dir.path()); + let normalized_requested_permissions = + normalized_directory_write_permissions(outside_dir.path())?; + let command = format!( + "printf {:?} > {:?} && cat {:?}", + "session-sticky-ok", outside_write, outside_write + ); + + let _first_turn = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-session-turn-1"), + request_permissions_tool_event( + "permissions-call", + "Allow writing outside the workspace", + &requested_permissions, + )?, + ev_completed("resp-session-turn-1"), + ]), + sse(vec![ + ev_response_created("resp-session-turn-2"), + ev_assistant_message("msg-session-turn-1", "done"), + ev_completed("resp-session-turn-2"), + ]), + ], + ) + .await; + + submit_turn( + &test, + "request session permissions for later use", + approval_policy, + sandbox_policy.clone(), + ) + .await?; + + let granted_permissions = expect_request_permissions_event(&test, "permissions-call").await; + assert_eq!( + granted_permissions, + normalized_requested_permissions.clone() + ); + test.codex + .submit(Op::RequestPermissionsResponse { + id: "permissions-call".to_string(), + response: RequestPermissionsResponse { + permissions: normalized_requested_permissions, + scope: PermissionGrantScope::Session, + }, + }) + .await?; + wait_for_completion(&test).await; + + let second_turn = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-session-turn-3"), + exec_command_event("exec-call", &command)?, + ev_completed("resp-session-turn-3"), + ]), + sse(vec![ + ev_response_created("resp-session-turn-4"), + ev_assistant_message("msg-session-turn-2", "done"), + ev_completed("resp-session-turn-4"), + ]), + ], + ) + .await; + + submit_turn( + &test, + "reuse session permissions in a later turn", + approval_policy, + sandbox_policy, + ) + .await?; + + let completion_event = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) + ) + }) + .await; + if let EventMsg::ExecApprovalRequest(approval) = completion_event { + test.codex + .submit(Op::ExecApproval { + id: approval.effective_approval_id(), + turn_id: None, + decision: ReviewDecision::Approved, + }) + .await?; + wait_for_completion(&test).await; + } + + let exec_output = second_turn + .function_call_output_text("exec-call") + .map(|output| json!({ "output": output })) + .unwrap_or_else(|| panic!("expected exec-call output")); + let result = parse_result(&exec_output); + assert_eq!(result.exit_code, Some(0)); + assert_eq!(result.stdout.trim(), "session-sticky-ok"); + assert_eq!(fs::read_to_string(&outside_write)?, "session-sticky-ok"); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs new file mode 100644 index 0000000000..8f99a9a0f4 --- /dev/null +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -0,0 +1,420 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] +#![cfg(target_os = "macos")] + +use anyhow::Result; +use codex_core::config::Constrained; +use codex_core::features::Feature; +use codex_protocol::models::FileSystemPermissions; +use codex_protocol::models::PermissionProfile; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::ReviewDecision; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::request_permissions::PermissionGrantScope; +use codex_protocol::request_permissions::RequestPermissionsResponse; +use codex_protocol::user_input::UserInput; +use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::responses::ev_apply_patch_function_call; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::skip_if_sandbox; +use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; +use regex_lite::Regex; +use serde_json::Value; +use serde_json::json; +use std::fs; +use std::path::Path; + +fn absolute_path(path: &Path) -> AbsolutePathBuf { + AbsolutePathBuf::try_from(path).expect("absolute path") +} + +fn request_permissions_tool_event( + call_id: &str, + reason: &str, + permissions: &PermissionProfile, +) -> Result { + let args = json!({ + "reason": reason, + "permissions": permissions, + }); + let args_str = serde_json::to_string(&args)?; + Ok(ev_function_call(call_id, "request_permissions", &args_str)) +} + +fn exec_command_event(call_id: &str, command: &str) -> Result { + let args = json!({ + "cmd": command, + "yield_time_ms": 1_000_u64, + }); + let args_str = serde_json::to_string(&args)?; + Ok(ev_function_call(call_id, "exec_command", &args_str)) +} + +fn build_add_file_patch(patch_path: &Path, content: &str) -> String { + format!( + "*** Begin Patch\n*** Add File: {}\n+{}\n*** End Patch\n", + patch_path.display(), + content + ) +} + +fn workspace_write_excluding_tmp() -> SandboxPolicy { + SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: Default::default(), + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + } +} + +fn requested_directory_write_permissions(path: &Path) -> PermissionProfile { + PermissionProfile { + file_system: Some(FileSystemPermissions { + read: Some(vec![]), + write: Some(vec![absolute_path(path)]), + }), + ..Default::default() + } +} + +fn normalized_directory_write_permissions(path: &Path) -> Result { + Ok(PermissionProfile { + file_system: Some(FileSystemPermissions { + read: Some(vec![]), + write: Some(vec![AbsolutePathBuf::try_from(path.canonicalize()?)?]), + }), + ..Default::default() + }) +} + +fn parse_result(item: &Value) -> (Option, String) { + let output_str = item + .get("output") + .and_then(Value::as_str) + .expect("shell output payload"); + match serde_json::from_str::(output_str) { + Ok(parsed) => { + let exit_code = parsed["metadata"]["exit_code"].as_i64(); + let stdout = parsed["output"].as_str().unwrap_or_default().to_string(); + (exit_code, stdout) + } + Err(_) => { + let structured = Regex::new(r"(?s)^Exit code:\s*(-?\d+).*?Output:\n(.*)$").unwrap(); + let regex = + Regex::new(r"(?s)^.*?Process exited with code (\d+)\n.*?Output:\n(.*)$").unwrap(); + if let Some(captures) = structured.captures(output_str) { + let exit_code = captures.get(1).unwrap().as_str().parse::().unwrap(); + let output = captures.get(2).unwrap().as_str(); + (Some(exit_code), output.to_string()) + } else if let Some(captures) = regex.captures(output_str) { + let exit_code = captures.get(1).unwrap().as_str().parse::().unwrap(); + let output = captures.get(2).unwrap().as_str(); + (Some(exit_code), output.to_string()) + } else { + (None, output_str.to_string()) + } + } + } +} + +async fn submit_turn( + test: &TestCodex, + prompt: &str, + approval_policy: AskForApproval, + sandbox_policy: SandboxPolicy, +) -> Result<()> { + let session_model = test.session_configured.model.clone(); + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: prompt.into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.cwd.path().to_path_buf(), + approval_policy, + sandbox_policy, + model: session_model, + effort: None, + summary: None, + service_tier: None, + collaboration_mode: None, + personality: None, + }) + .await?; + Ok(()) +} + +async fn expect_request_permissions_event( + test: &TestCodex, + expected_call_id: &str, +) -> PermissionProfile { + let event = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::RequestPermissions(_) | EventMsg::TurnComplete(_) + ) + }) + .await; + + match event { + EventMsg::RequestPermissions(request) => { + assert_eq!(request.call_id, expected_call_id); + request.permissions + } + EventMsg::TurnComplete(_) => panic!("expected request_permissions before completion"), + other => panic!("unexpected event: {other:?}"), + } +} + +#[tokio::test(flavor = "current_thread")] +#[cfg(target_os = "macos")] +async fn approved_folder_write_request_permissions_unblocks_later_exec_without_sandbox_args() +-> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + let approval_policy = AskForApproval::OnRequest; + let sandbox_policy = workspace_write_excluding_tmp(); + let sandbox_policy_for_config = sandbox_policy.clone(); + + let mut builder = test_codex().with_config(move |config| { + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config); + config + .features + .enable(Feature::RequestPermissions) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::RequestPermissionsTool) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + let requested_dir = tempfile::tempdir()?; + let requested_file = requested_dir.path().join("allowed-write.txt"); + let command = format!( + "printf {:?} > {:?} && cat {:?}", + "folder-grant-ok", requested_file, requested_file + ); + let requested_permissions = requested_directory_write_permissions(requested_dir.path()); + let normalized_requested_permissions = + normalized_directory_write_permissions(requested_dir.path())?; + + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-request-permissions-1"), + request_permissions_tool_event( + "permissions-call", + "Allow writing outside the workspace", + &requested_permissions, + )?, + ev_completed("resp-request-permissions-1"), + ]), + sse(vec![ + ev_response_created("resp-request-permissions-2"), + exec_command_event("exec-call", &command)?, + ev_completed("resp-request-permissions-2"), + ]), + sse(vec![ + ev_response_created("resp-request-permissions-3"), + ev_assistant_message("msg-request-permissions-1", "done"), + ev_completed("resp-request-permissions-3"), + ]), + ], + ) + .await; + + submit_turn( + &test, + "write outside the workspace", + approval_policy, + sandbox_policy, + ) + .await?; + + let granted_permissions = expect_request_permissions_event(&test, "permissions-call").await; + assert_eq!( + granted_permissions, + normalized_requested_permissions.clone() + ); + test.codex + .submit(Op::RequestPermissionsResponse { + id: "permissions-call".to_string(), + response: RequestPermissionsResponse { + permissions: normalized_requested_permissions, + scope: PermissionGrantScope::Turn, + }, + }) + .await?; + + let completion_event = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) + ) + }) + .await; + if let EventMsg::ExecApprovalRequest(approval) = completion_event { + test.codex + .submit(Op::ExecApproval { + id: approval.effective_approval_id(), + turn_id: None, + decision: ReviewDecision::Approved, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + } + + let exec_output = responses + .function_call_output_text("exec-call") + .map(|output| json!({ "output": output })) + .unwrap_or_else(|| panic!("expected exec-call output")); + let (exit_code, stdout) = parse_result(&exec_output); + assert!(exit_code.is_none() || exit_code == Some(0)); + assert!(stdout.contains("folder-grant-ok")); + assert!( + requested_file.exists(), + "touch command should create the file" + ); + assert_eq!(fs::read_to_string(&requested_file)?, "folder-grant-ok"); + + Ok(()) +} + +#[tokio::test(flavor = "current_thread")] +#[cfg(target_os = "macos")] +async fn approved_folder_write_request_permissions_unblocks_later_apply_patch_without_prompt() +-> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + let approval_policy = AskForApproval::OnRequest; + let sandbox_policy = workspace_write_excluding_tmp(); + let sandbox_policy_for_config = sandbox_policy.clone(); + + let mut builder = test_codex().with_config(move |config| { + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config); + config + .features + .enable(Feature::RequestPermissions) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::RequestPermissionsTool) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + let requested_dir = tempfile::tempdir()?; + let requested_file = requested_dir.path().join("allowed-patch.txt"); + let requested_permissions = requested_directory_write_permissions(requested_dir.path()); + let normalized_requested_permissions = + normalized_directory_write_permissions(requested_dir.path())?; + let patch = build_add_file_patch(&requested_file, "patched-via-request-permissions"); + + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-request-permissions-patch-1"), + request_permissions_tool_event( + "permissions-call", + "Allow patching outside the workspace", + &requested_permissions, + )?, + ev_completed("resp-request-permissions-patch-1"), + ]), + sse(vec![ + ev_response_created("resp-request-permissions-patch-2"), + ev_apply_patch_function_call("apply-patch-call", &patch), + ev_completed("resp-request-permissions-patch-2"), + ]), + sse(vec![ + ev_response_created("resp-request-permissions-patch-3"), + ev_assistant_message("msg-request-permissions-patch-1", "done"), + ev_completed("resp-request-permissions-patch-3"), + ]), + ], + ) + .await; + + submit_turn( + &test, + "patch outside the workspace", + approval_policy, + sandbox_policy, + ) + .await?; + + let granted_permissions = expect_request_permissions_event(&test, "permissions-call").await; + assert_eq!( + granted_permissions, + normalized_requested_permissions.clone() + ); + test.codex + .submit(Op::RequestPermissionsResponse { + id: "permissions-call".to_string(), + response: RequestPermissionsResponse { + permissions: normalized_requested_permissions, + scope: PermissionGrantScope::Turn, + }, + }) + .await?; + + let event = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::ApplyPatchApprovalRequest(_) | EventMsg::TurnComplete(_) + ) + }) + .await; + match event { + EventMsg::TurnComplete(_) => {} + EventMsg::ApplyPatchApprovalRequest(approval) => { + panic!( + "unexpected apply_patch approval request after granted permissions: {:?}", + approval.call_id + ) + } + other => panic!("unexpected event: {other:?}"), + } + + let patch_output = responses + .function_call_output_text("apply-patch-call") + .map(|output| json!({ "output": output })) + .unwrap_or_else(|| panic!("expected apply-patch-call output")); + let (exit_code, stdout) = parse_result(&patch_output); + assert!(exit_code.is_none() || exit_code == Some(0)); + assert!( + stdout.contains("Success."), + "unexpected patch output: {stdout}" + ); + assert_eq!( + fs::read_to_string(&requested_file)?, + "patched-via-request-permissions\n" + ); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/resume.rs b/codex-rs/core/tests/suite/resume.rs index 98a570a1d9..b5889c9aad 100644 --- a/codex-rs/core/tests/suite/resume.rs +++ b/codex-rs/core/tests/suite/resume.rs @@ -13,10 +13,49 @@ use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; +use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; +use std::path::PathBuf; use std::sync::Arc; +use std::time::Duration; +use tempfile::TempDir; +use wiremock::MockServer; + +async fn resume_until_initial_messages( + builder: &mut TestCodexBuilder, + server: &MockServer, + home: Arc, + rollout_path: PathBuf, + predicate: impl Fn(&[EventMsg]) -> bool, +) -> Result { + let deadline = tokio::time::Instant::now() + Duration::from_secs(2); + let poll_interval = Duration::from_millis(10); + let mut last_initial_messages = "".to_string(); + + loop { + let resumed = builder + .resume(server, Arc::clone(&home), rollout_path.clone()) + .await?; + if let Some(initial_messages) = resumed.session_configured.initial_messages.as_ref() { + if predicate(initial_messages) { + return Ok(resumed); + } + last_initial_messages = format!("{initial_messages:#?}"); + } + + if tokio::time::Instant::now() >= deadline { + panic!( + "timed out waiting for rollout resume messages to stabilize: {last_initial_messages}" + ); + } + + drop(resumed); + tokio::time::sleep(poll_interval).await; + } +} #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn resume_includes_initial_messages_from_rollout_events() -> Result<()> { @@ -57,7 +96,26 @@ async fn resume_includes_initial_messages_from_rollout_events() -> Result<()> { wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; - let resumed = builder.resume(&server, home, rollout_path).await?; + let resumed = resume_until_initial_messages( + &mut builder, + &server, + home, + rollout_path, + |initial_messages| { + matches!( + initial_messages, + [ + EventMsg::TurnStarted(_), + EventMsg::UserMessage(_), + EventMsg::TokenCount(_), + EventMsg::AgentMessage(_), + EventMsg::TokenCount(_), + EventMsg::TurnComplete(_), + ] + ) + }, + ) + .await?; let initial_messages = resumed .session_configured .initial_messages @@ -123,7 +181,28 @@ async fn resume_includes_initial_messages_from_reasoning_events() -> Result<()> wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; - let resumed = builder.resume(&server, home, rollout_path).await?; + let resumed = resume_until_initial_messages( + &mut builder, + &server, + home, + rollout_path, + |initial_messages| { + matches!( + initial_messages, + [ + EventMsg::TurnStarted(_), + EventMsg::UserMessage(_), + EventMsg::TokenCount(_), + EventMsg::AgentReasoning(_), + EventMsg::AgentReasoningRawContent(_), + EventMsg::AgentMessage(_), + EventMsg::TokenCount(_), + EventMsg::TurnComplete(_), + ] + ) + }, + ) + .await?; let initial_messages = resumed .session_configured .initial_messages diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 4139a582a8..3c6948354a 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -4,6 +4,7 @@ use std::ffi::OsString; use std::fs; use std::net::TcpListener; use std::path::Path; +use std::sync::Arc; use std::time::Duration; use std::time::SystemTime; use std::time::UNIX_EPOCH; @@ -36,11 +37,13 @@ use core_test_support::skip_if_no_network; use core_test_support::stdio_server_bin; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; +use core_test_support::wait_for_event_with_timeout; +use reqwest::Client; +use reqwest::StatusCode; use serde_json::Value; use serde_json::json; use serial_test::serial; use tempfile::tempdir; -use tokio::net::TcpStream; use tokio::process::Child; use tokio::process::Command; use tokio::time::Instant; @@ -263,7 +266,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { let tools_ready_deadline = Instant::now() + Duration::from_secs(30); loop { fixture.codex.submit(Op::ListMcpTools).await?; - let list_event = core_test_support::wait_for_event_with_timeout( + let list_event = wait_for_event_with_timeout( &fixture.codex, |ev| matches!(ev, EventMsg::McpListToolsResponse(_)), Duration::from_secs(10), @@ -853,8 +856,8 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { /// This test writes to a fallback credentials file in CODEX_HOME. /// Ideally, we wouldn't need to serialize the test but it's much more cumbersome to wire CODEX_HOME through the code. -#[serial(codex_home)] #[test] +#[serial(codex_home)] fn streamable_http_with_oauth_round_trip() -> anyhow::Result<()> { const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024; @@ -936,8 +939,8 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { wait_for_streamable_http_server(&mut http_server_child, &bind_addr, Duration::from_secs(5)) .await?; - let temp_home = tempdir()?; - let _guard = EnvVarGuard::set("CODEX_HOME", temp_home.path().as_os_str()); + let temp_home = Arc::new(tempdir()?); + let _codex_home_guard = EnvVarGuard::set("CODEX_HOME", temp_home.path().as_os_str()); write_fallback_oauth_tokens( temp_home.path(), server_name, @@ -948,10 +951,10 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { )?; let fixture = test_codex() + .with_home(temp_home.clone()) .with_config(move |config| { - // This test seeds OAuth tokens in CODEX_HOME/.credentials.json and - // validates file-backed OAuth loading. Force file mode so Linux - // keyring backend quirks do not affect this test. + // Keep OAuth credentials isolated to this test home because Bazel + // runs the full core suite in one process. config.mcp_oauth_credentials_store_mode = serde_json::from_value(json!("file")) .expect("`file` should deserialize as OAuthCredentialsStoreMode"); let mut servers = config.mcp_servers.get().clone(); @@ -984,6 +987,31 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { .await?; let session_model = fixture.session_configured.model.clone(); + let tools_ready_deadline = Instant::now() + Duration::from_secs(30); + loop { + fixture.codex.submit(Op::ListMcpTools).await?; + let list_event = wait_for_event_with_timeout( + &fixture.codex, + |ev| matches!(ev, EventMsg::McpListToolsResponse(_)), + Duration::from_secs(10), + ) + .await; + let EventMsg::McpListToolsResponse(tool_list) = list_event else { + unreachable!("event guard guarantees McpListToolsResponse"); + }; + if tool_list.tools.contains_key(&tool_name) { + break; + } + + let available_tools: Vec<&str> = tool_list.tools.keys().map(String::as_str).collect(); + if Instant::now() >= tools_ready_deadline { + panic!( + "timed out waiting for MCP tool {tool_name} to become available; discovered tools: {available_tools:?}" + ); + } + sleep(Duration::from_millis(200)).await; + } + fixture .codex .submit(Op::UserTurn { @@ -1078,7 +1106,8 @@ async fn wait_for_streamable_http_server( timeout: Duration, ) -> anyhow::Result<()> { let deadline = Instant::now() + timeout; - + let metadata_url = format!("http://{address}/.well-known/oauth-authorization-server/mcp"); + let client = Client::builder().no_proxy().build()?; loop { if let Some(status) = server_child.try_wait()? { return Err(anyhow::anyhow!( @@ -1090,22 +1119,30 @@ async fn wait_for_streamable_http_server( if remaining.is_zero() { return Err(anyhow::anyhow!( - "timed out waiting for streamable HTTP server at {address}: deadline reached" + "timed out waiting for streamable HTTP server metadata at {metadata_url}: deadline reached" )); } - match tokio::time::timeout(remaining, TcpStream::connect(address)).await { - Ok(Ok(_)) => return Ok(()), + match tokio::time::timeout(remaining, client.get(&metadata_url).send()).await { + Ok(Ok(response)) if response.status() == StatusCode::OK => return Ok(()), + Ok(Ok(response)) => { + if Instant::now() >= deadline { + return Err(anyhow::anyhow!( + "timed out waiting for streamable HTTP server metadata at {metadata_url}: HTTP {}", + response.status() + )); + } + } Ok(Err(error)) => { if Instant::now() >= deadline { return Err(anyhow::anyhow!( - "timed out waiting for streamable HTTP server at {address}: {error}" + "timed out waiting for streamable HTTP server metadata at {metadata_url}: {error}" )); } } Err(_) => { return Err(anyhow::anyhow!( - "timed out waiting for streamable HTTP server at {address}: connect call timed out" + "timed out waiting for streamable HTTP server metadata at {metadata_url}: request timed out" )); } } diff --git a/codex-rs/core/tests/suite/shell_command.rs b/codex-rs/core/tests/suite/shell_command.rs index fe34630598..cbb10e7680 100644 --- a/codex-rs/core/tests/suite/shell_command.rs +++ b/codex-rs/core/tests/suite/shell_command.rs @@ -262,15 +262,16 @@ async fn unicode_output(login: bool) -> anyhow::Result<()> { }) .await?; + // We use a child process on windows instead of a direct builtin like 'echo' to ensure that Powershell + // config is actually being set correctly. let call_id = "unicode_output"; - mount_shell_responses_with_timeout( - &harness, - call_id, - "git -c alias.say='!printf \"%s\" \"naïve_café\"' say", - Some(login), - MEDIUM_TIMEOUT, - ) - .await; + let command = if cfg!(windows) { + "cmd /c echo naïve_café" + } else { + "echo \"naïve_café\"" + }; + mount_shell_responses_with_timeout(&harness, call_id, command, Some(login), MEDIUM_TIMEOUT) + .await; harness.submit("run the command without login").await?; let output = harness.function_call_stdout(call_id).await; diff --git a/codex-rs/core/tests/suite/shell_serialization.rs b/codex-rs/core/tests/suite/shell_serialization.rs index 3e5942da6a..ccbff4a4b6 100644 --- a/codex-rs/core/tests/suite/shell_serialization.rs +++ b/codex-rs/core/tests/suite/shell_serialization.rs @@ -349,7 +349,7 @@ async fn shell_output_for_freeform_tool_records_duration( let test = builder.build(&server).await?; let call_id = "shell-structured"; - let responses = shell_responses(call_id, vec!["/bin/sh", "-c", "sleep 1"], output_type)?; + let responses = shell_responses(call_id, vec!["/bin/sh", "-c", "sleep 0.2"], output_type)?; let mock = mount_sse_sequence(&server, responses).await; test.submit_turn_with_policy( @@ -381,7 +381,7 @@ $"#; .and_then(|value| value.as_str().parse::().ok()) .expect("expected structured shell output to contain wall time seconds"); assert!( - wall_time_seconds > 0.5, + wall_time_seconds > 0.1, "expected wall time to be greater than zero seconds, got {wall_time_seconds}" ); @@ -740,6 +740,7 @@ async fn shell_command_output_is_freeform() -> Result<()> { let call_id = "shell-command"; let args = json!({ "command": "echo shell command", + "login": false, "timeout_ms": 1_000, }); let responses = vec![ @@ -791,6 +792,7 @@ async fn shell_command_output_is_not_truncated_under_10k_bytes() -> Result<()> { let call_id = "shell-command"; let args = json!({ "command": "perl -e 'print \"1\" x 10000'", + "login": false, "timeout_ms": 1000, }); let responses = vec![ @@ -841,6 +843,7 @@ async fn shell_command_output_is_not_truncated_over_10k_bytes() -> Result<()> { let call_id = "shell-command"; let args = json!({ "command": "perl -e 'print \"1\" x 10001'", + "login": false, "timeout_ms": 1000, }); let responses = vec![ diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index 27341ec24c..0c896aaed9 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -7,6 +7,7 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecApprovalRequestEvent; +use codex_protocol::protocol::ExecApprovalRequestSkillMetadata; use codex_protocol::protocol::Op; use codex_protocol::protocol::RejectConfig; use codex_protocol::protocol::ReviewDecision; @@ -242,6 +243,14 @@ permissions: ..Default::default() }) ); + assert_eq!( + approval.skill_metadata, + Some(ExecApprovalRequestSkillMetadata { + path_to_skills_md: test + .codex_home_path() + .join("skills/mbolin-test-skill/agents/openai.yaml"), + }) + ); test.codex .submit(Op::ExecApproval { @@ -279,6 +288,7 @@ async fn shell_zsh_fork_skill_script_reject_policy_with_sandbox_approval_false_s let approval_policy = AskForApproval::Reject(RejectConfig { sandbox_approval: false, rules: true, + request_permissions: false, mcp_elicitations: false, }); let server = start_mock_server().await; @@ -371,6 +381,7 @@ async fn shell_zsh_fork_skill_script_reject_policy_with_sandbox_approval_true_sk let approval_policy = AskForApproval::Reject(RejectConfig { sandbox_approval: true, rules: false, + request_permissions: false, mcp_elicitations: false, }); let server = start_mock_server().await; diff --git a/codex-rs/exec/Cargo.toml b/codex-rs/exec/Cargo.toml index 1eb4f7e277..3785235606 100644 --- a/codex-rs/exec/Cargo.toml +++ b/codex-rs/exec/Cargo.toml @@ -19,8 +19,11 @@ workspace = true anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } codex-arg0 = { workspace = true } +codex-app-server-client = { workspace = true } +codex-app-server-protocol = { workspace = true } codex-cloud-requirements = { workspace = true } codex-core = { workspace = true } +codex-feedback = { workspace = true } codex-otel = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } 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 ae58f3c9fd..f40dce1fd2 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -880,6 +880,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { | EventMsg::UndoStarted(_) | EventMsg::ThreadRolledBack(_) | EventMsg::RequestUserInput(_) + | EventMsg::RequestPermissions(_) | EventMsg::CollabResumeBegin(_) | EventMsg::CollabResumeEnd(_) | EventMsg::RealtimeConversationStarted(_) @@ -961,6 +962,7 @@ impl EventProcessorWithHumanOutput { | EventMsg::UndoStarted(_) | EventMsg::ThreadRolledBack(_) | EventMsg::RequestUserInput(_) + | EventMsg::RequestPermissions(_) | EventMsg::DynamicToolCallRequest(_) | EventMsg::DynamicToolCallResponse(_) ) diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 55978021f3..d0a6ac2c3e 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -13,13 +13,38 @@ pub mod exec_events; pub use cli::Cli; pub use cli::Command; pub use cli::ReviewArgs; +use codex_app_server_client::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY; +use codex_app_server_client::InProcessAppServerClient; +use codex_app_server_client::InProcessClientStartArgs; +use codex_app_server_client::InProcessServerEvent; +use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ConfigWarningNotification; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::McpServerElicitationAction; +use codex_app_server_protocol::McpServerElicitationRequestResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ReviewStartParams; +use codex_app_server_protocol::ReviewStartResponse; +use codex_app_server_protocol::ReviewTarget as ApiReviewTarget; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ThreadResumeParams; +use codex_app_server_protocol::ThreadResumeResponse; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::ThreadUnsubscribeParams; +use codex_app_server_protocol::ThreadUnsubscribeResponse; +use codex_app_server_protocol::TurnInterruptParams; +use codex_app_server_protocol::TurnInterruptResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; use codex_arg0::Arg0DispatchPaths; use codex_cloud_requirements::cloud_requirements_loader; use codex_core::AuthManager; use codex_core::LMSTUDIO_OSS_PROVIDER_ID; -use codex_core::NewThread; use codex_core::OLLAMA_OSS_PROVIDER_ID; -use codex_core::ThreadManager; use codex_core::auth::enforce_login_restrictions; use codex_core::check_execpolicy_for_warnings; use codex_core::config::Config; @@ -29,23 +54,22 @@ use codex_core::config::find_codex_home; use codex_core::config::load_config_as_toml_with_cli_overrides; use codex_core::config::resolve_oss_provider; use codex_core::config_loader::ConfigLoadError; +use codex_core::config_loader::LoaderOverrides; use codex_core::config_loader::format_config_error_with_source; use codex_core::format_exec_policy_error_with_source; use codex_core::git_info::get_git_repo_root; -use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig; -use codex_core::models_manager::manager::RefreshStrategy; +use codex_feedback::CodexFeedback; use codex_otel::set_parent_from_context; use codex_otel::traceparent_context_from_env; -use codex_protocol::approvals::ElicitationAction; +use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::config_types::SandboxMode; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget; +use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionSource; -use codex_protocol::protocol::SubAgentSource; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_oss::ensure_oss_provider_ready; @@ -54,14 +78,13 @@ use event_processor_with_human_output::EventProcessorWithHumanOutput; use event_processor_with_jsonl_output::EventProcessorWithJsonOutput; use serde_json::Value; use std::collections::HashSet; +use std::collections::VecDeque; use std::io::IsTerminal; use std::io::Read; use std::path::PathBuf; -use std::sync::Arc; use supports_color::Stream; -use tokio::sync::Mutex; +use tokio::sync::mpsc; use tracing::Instrument; -use tracing::debug; use tracing::error; use tracing::field; use tracing::info; @@ -91,15 +114,24 @@ enum InitialOperation { }, } -#[derive(Clone)] -struct ThreadEventEnvelope { - thread_id: codex_protocol::ThreadId, - thread: Arc, - event: Event, - suppress_output: bool, +struct RequestIdSequencer { + next: i64, +} + +impl RequestIdSequencer { + fn new() -> Self { + Self { next: 1 } + } + + fn next(&mut self) -> RequestId { + let id = self.next; + self.next += 1; + RequestId::Integer(id) + } } struct ExecRunArgs { + in_process_start_args: InProcessClientStartArgs, command: Option, config: Config, cursor_ansi: bool, @@ -265,6 +297,9 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result // TODO(gt): Make cloud requirements failures blocking once we can fail-closed. let cloud_requirements = cloud_requirements_loader(cloud_auth_manager, chatgpt_base_url, codex_home.clone()); + let run_cli_overrides = cli_kv_overrides.clone(); + let run_loader_overrides = LoaderOverrides::default(); + let run_cloud_requirements = cloud_requirements.clone(); let model_provider = if oss { let resolved = resolve_oss_provider( @@ -382,7 +417,34 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result if let Some(context) = traceparent_context_from_env() { set_parent_from_context(&exec_span, context); } + let config_warnings: Vec = config + .startup_warnings + .iter() + .map(|warning| ConfigWarningNotification { + summary: warning.clone(), + details: None, + path: None, + range: None, + }) + .collect(); + let in_process_start_args = InProcessClientStartArgs { + arg0_paths, + config: std::sync::Arc::new(config.clone()), + cli_overrides: run_cli_overrides, + loader_overrides: run_loader_overrides, + cloud_requirements: run_cloud_requirements, + feedback: CodexFeedback::new(), + config_warnings, + session_source: SessionSource::Exec, + enable_codex_api_key_env: true, + client_name: "codex-exec".to_string(), + client_version: env!("CARGO_PKG_VERSION").to_string(), + experimental_api: true, + opt_out_notification_methods: Vec::new(), + channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, + }; run_exec_session(ExecRunArgs { + in_process_start_args, command, config, cursor_ansi, @@ -404,6 +466,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let ExecRunArgs { + in_process_start_args, command, config, cursor_ansi, @@ -469,49 +532,73 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { std::process::exit(1); } - let auth_manager = AuthManager::shared( - config.codex_home.clone(), - true, - config.cli_auth_credentials_store_mode, - ); - let thread_manager = Arc::new(ThreadManager::new( - config.codex_home.clone(), - auth_manager.clone(), - SessionSource::Exec, - config.model_catalog.clone(), - CollaborationModesConfig { - default_mode_request_user_input: config - .features - .enabled(codex_core::features::Feature::DefaultModeRequestUserInput), - }, - )); - let default_model = thread_manager - .get_models_manager() - .get_default_model(&config.model, RefreshStrategy::OnlineIfUncached) - .await; + let mut request_ids = RequestIdSequencer::new(); + let mut client = InProcessAppServerClient::start(in_process_start_args) + .await + .map_err(|err| { + anyhow::anyhow!("failed to initialize in-process app-server client: {err}") + })?; // Handle resume subcommand by resolving a rollout path and using explicit resume API. - let NewThread { - thread_id: primary_thread_id, - thread, - session_configured, - } = if let Some(ExecCommand::Resume(args)) = command.as_ref() { - let resume_path = resolve_resume_path(&config, args).await?; + let (primary_thread_id, fallback_session_configured) = + if let Some(ExecCommand::Resume(args)) = command.as_ref() { + let resume_path = resolve_resume_path(&config, args).await?; - if let Some(path) = resume_path { - thread_manager - .resume_thread_from_rollout(config.clone(), path, auth_manager.clone()) - .await? + if let Some(path) = resume_path { + let response: ThreadResumeResponse = send_request_with_response( + &client, + ClientRequest::ThreadResume { + request_id: request_ids.next(), + params: thread_resume_params_from_config(&config, Some(path)), + }, + "thread/resume", + ) + .await + .map_err(anyhow::Error::msg)?; + let session_configured = session_configured_from_thread_resume_response(&response) + .map_err(anyhow::Error::msg)?; + (session_configured.session_id, session_configured) + } else { + let response: ThreadStartResponse = send_request_with_response( + &client, + ClientRequest::ThreadStart { + request_id: request_ids.next(), + params: thread_start_params_from_config(&config), + }, + "thread/start", + ) + .await + .map_err(anyhow::Error::msg)?; + let session_configured = session_configured_from_thread_start_response(&response) + .map_err(anyhow::Error::msg)?; + (session_configured.session_id, session_configured) + } } else { - thread_manager.start_thread(config.clone()).await? - } - } else { - thread_manager.start_thread(config.clone()).await? - }; + let response: ThreadStartResponse = send_request_with_response( + &client, + ClientRequest::ThreadStart { + request_id: request_ids.next(), + params: thread_start_params_from_config(&config), + }, + "thread/start", + ) + .await + .map_err(anyhow::Error::msg)?; + let session_configured = session_configured_from_thread_start_response(&response) + .map_err(anyhow::Error::msg)?; + (session_configured.session_id, session_configured) + }; + let primary_thread_id_for_span = primary_thread_id.to_string(); + let mut buffered_events = VecDeque::new(); + // Use the start/resume response as the authoritative bootstrap payload. + // Waiting for a later streamed `SessionConfigured` event adds up to 10s of + // avoidable startup latency on the in-process path. + let session_configured = fallback_session_configured; + exec_span.record("thread.id", primary_thread_id_for_span.as_str()); - let (initial_operation, prompt_summary) = match (command, prompt, images) { + let (initial_operation, prompt_summary) = match (command.as_ref(), prompt, images) { (Some(ExecCommand::Review(review_cli)), _, _) => { let review_request = build_review_request(review_cli)?; let summary = codex_core::review_prompts::user_facing_hint(&review_request.target); @@ -532,7 +619,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let prompt_text = resolve_prompt(prompt_arg); let mut items: Vec = imgs .into_iter() - .chain(args.images.into_iter()) + .chain(args.images.iter().cloned()) .map(|path| UserInput::LocalImage { path }) .collect(); items.push(UserInput::Text { @@ -577,84 +664,62 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { info!("Codex initialized with event: {session_configured:?}"); - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); - let attached_threads = Arc::new(Mutex::new(HashSet::from([primary_thread_id]))); - spawn_thread_listener(primary_thread_id, thread.clone(), tx.clone(), false); - - { - let thread = thread.clone(); - tokio::spawn(async move { - if tokio::signal::ctrl_c().await.is_ok() { - tracing::debug!("Keyboard interrupt"); - // Immediately notify Codex to abort any in-flight task. - thread.submit(Op::Interrupt).await.ok(); - } - }); - } - - { - let thread_manager = Arc::clone(&thread_manager); - let attached_threads = Arc::clone(&attached_threads); - let tx = tx.clone(); - let mut thread_created_rx = thread_manager.subscribe_thread_created(); - tokio::spawn(async move { - loop { - match thread_created_rx.recv().await { - Ok(thread_id) => { - if attached_threads.lock().await.contains(&thread_id) { - continue; - } - match thread_manager.get_thread(thread_id).await { - Ok(thread) => { - attached_threads.lock().await.insert(thread_id); - let suppress_output = - is_agent_job_subagent(&thread.config_snapshot().await); - spawn_thread_listener( - thread_id, - thread, - tx.clone(), - suppress_output, - ); - } - Err(err) => { - warn!("failed to attach listener for thread {thread_id}: {err}") - } - } - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { - warn!("thread_created receiver lagged; skipping resync"); - } - Err(tokio::sync::broadcast::error::RecvError::Closed) => break, - } - } - }); - } + let (interrupt_tx, mut interrupt_rx) = mpsc::unbounded_channel::<()>(); + tokio::spawn(async move { + if tokio::signal::ctrl_c().await.is_ok() { + tracing::debug!("Keyboard interrupt"); + let _ = interrupt_tx.send(()); + } + }); let task_id = match initial_operation { InitialOperation::UserTurn { items, output_schema, } => { - let task_id = thread - .submit(Op::UserTurn { - items, - cwd: default_cwd, - approval_policy: default_approval_policy, - sandbox_policy: default_sandbox_policy.clone(), - model: default_model, - effort: default_effort, - summary: None, - service_tier: None, - final_output_json_schema: output_schema, - collaboration_mode: None, - personality: None, - }) - .await?; + let response: TurnStartResponse = send_request_with_response( + &client, + ClientRequest::TurnStart { + request_id: request_ids.next(), + params: TurnStartParams { + thread_id: primary_thread_id_for_span.clone(), + input: items.into_iter().map(Into::into).collect(), + cwd: Some(default_cwd), + approval_policy: Some(default_approval_policy.into()), + sandbox_policy: Some(default_sandbox_policy.clone().into()), + model: None, + service_tier: None, + effort: default_effort, + summary: None, + personality: None, + output_schema, + collaboration_mode: None, + }, + }, + "turn/start", + ) + .await + .map_err(anyhow::Error::msg)?; + let task_id = response.turn.id; info!("Sent prompt with event ID: {task_id}"); task_id } InitialOperation::Review { review_request } => { - let task_id = thread.submit(Op::Review { review_request }).await?; + let response: ReviewStartResponse = send_request_with_response( + &client, + ClientRequest::ReviewStart { + request_id: request_ids.next(), + params: ReviewStartParams { + thread_id: primary_thread_id_for_span.clone(), + target: review_target_to_api(review_request.target), + delivery: None, + }, + }, + "review/start", + ) + .await + .map_err(anyhow::Error::msg)?; + let task_id = response.turn.id; info!("Sent review request with event ID: {task_id}"); task_id } @@ -665,70 +730,158 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { // Track whether a fatal error was reported by the server so we can // exit with a non-zero status for automation-friendly signaling. let mut error_seen = false; - let mut shutdown_requested = false; - while let Some(envelope) = rx.recv().await { - let ThreadEventEnvelope { - thread_id, - thread, - event, - suppress_output, - } = envelope; - if suppress_output && should_suppress_agent_job_event(&event.msg) { - continue; - } - if matches!(event.msg, EventMsg::Error(_)) { - error_seen = true; - } - if shutdown_requested - && !matches!(&event.msg, EventMsg::ShutdownComplete | EventMsg::Error(_)) - { - continue; - } - if let EventMsg::ElicitationRequest(ev) = &event.msg { - // Automatically cancel elicitation requests in exec mode. - thread - .submit(Op::ResolveElicitation { - server_name: ev.server_name.clone(), - request_id: ev.id.clone(), - decision: ElicitationAction::Cancel, - content: None, - meta: None, - }) - .await?; - } - if let EventMsg::McpStartupUpdate(update) = &event.msg - && required_mcp_servers.contains(&update.server) - && let codex_protocol::protocol::McpStartupStatus::Failed { error } = &update.status - { - error_seen = true; - eprintln!( - "Required MCP server '{}' failed to initialize: {error}", - update.server - ); - if !shutdown_requested { - thread.submit(Op::Shutdown).await?; - shutdown_requested = true; + let mut interrupt_channel_open = true; + let primary_thread_id_for_requests = primary_thread_id.to_string(); + loop { + let server_event = if let Some(event) = buffered_events.pop_front() { + Some(event) + } else { + tokio::select! { + maybe_interrupt = interrupt_rx.recv(), if interrupt_channel_open => { + if maybe_interrupt.is_none() { + interrupt_channel_open = false; + continue; + } + if let Err(err) = send_request_with_response::( + &client, + ClientRequest::TurnInterrupt { + request_id: request_ids.next(), + params: TurnInterruptParams { + thread_id: primary_thread_id_for_requests.clone(), + turn_id: task_id.clone(), + }, + }, + "turn/interrupt", + ) + .await + { + warn!("turn/interrupt failed: {err}"); + } + continue; + } + maybe_event = client.next_event() => maybe_event, } - } - if thread_id != primary_thread_id && matches!(&event.msg, EventMsg::TurnComplete(_)) { - continue; - } - let shutdown = event_processor.process_event(event); - if thread_id != primary_thread_id && matches!(shutdown, CodexStatus::InitiateShutdown) { - continue; - } - match shutdown { - CodexStatus::Running => continue, - CodexStatus::InitiateShutdown => { - if !shutdown_requested { - thread.submit(Op::Shutdown).await?; - shutdown_requested = true; + }; + + let Some(server_event) = server_event else { + break; + }; + + match server_event { + InProcessServerEvent::ServerRequest(request) => { + handle_server_request( + &client, + request, + &config, + &primary_thread_id_for_requests, + &mut error_seen, + ) + .await; + } + InProcessServerEvent::ServerNotification(notification) => { + if let ServerNotification::Error(payload) = ¬ification + && payload.thread_id == primary_thread_id_for_requests + && payload.turn_id == task_id + && !payload.will_retry + { + error_seen = true; } } - CodexStatus::Shutdown if thread_id == primary_thread_id => break, - CodexStatus::Shutdown => continue, + InProcessServerEvent::LegacyNotification(notification) => { + let decoded = match decode_legacy_notification(notification) { + Ok(event) => event, + Err(err) => { + warn!("{err}"); + continue; + } + }; + if decoded.conversation_id.as_deref() + != Some(primary_thread_id_for_requests.as_str()) + && decoded.conversation_id.is_some() + { + continue; + } + let event = decoded.event; + if matches!(event.msg, EventMsg::SessionConfigured(_)) { + continue; + } + if matches!(event.msg, EventMsg::Error(_)) { + // The legacy bridge still carries fatal turn failures for + // exec. Preserve the non-zero exit behavior until this + // path is fully replaced by typed server notifications. + error_seen = true; + } + match &event.msg { + EventMsg::TurnComplete(payload) => { + if payload.turn_id != task_id { + continue; + } + } + EventMsg::TurnAborted(payload) => { + if payload.turn_id.as_deref() != Some(task_id.as_str()) { + continue; + } + } + EventMsg::McpStartupUpdate(update) => { + if required_mcp_servers.contains(&update.server) + && let codex_protocol::protocol::McpStartupStatus::Failed { error } = + &update.status + { + error_seen = true; + eprintln!( + "Required MCP server '{}' failed to initialize: {error}", + update.server + ); + if let Err(err) = request_shutdown( + &client, + &mut request_ids, + &primary_thread_id_for_requests, + ) + .await + { + warn!("thread/unsubscribe failed during shutdown: {err}"); + } + break; + } + } + _ => {} + } + + match event_processor.process_event(event) { + CodexStatus::Running => {} + CodexStatus::InitiateShutdown => { + if let Err(err) = request_shutdown( + &client, + &mut request_ids, + &primary_thread_id_for_requests, + ) + .await + { + warn!("thread/unsubscribe failed during shutdown: {err}"); + } + break; + } + CodexStatus::Shutdown => { + // `ShutdownComplete` does not identify which attached + // thread emitted it, so subagent shutdowns must not end + // the primary exec loop early. + } + } + } + InProcessServerEvent::Lagged { skipped } => { + let message = lagged_event_warning_message(skipped); + warn!("{message}"); + let _ = event_processor.process_event(Event { + id: String::new(), + msg: EventMsg::Warning(codex_protocol::protocol::WarningEvent { message }), + }); + } } } + + if let Err(err) = client.shutdown().await { + warn!("in-process app-server shutdown failed: {err}"); + } event_processor.print_final_output(); if error_seen { std::process::exit(1); @@ -737,68 +890,487 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { Ok(()) } -fn spawn_thread_listener( - thread_id: codex_protocol::ThreadId, - thread: Arc, - tx: tokio::sync::mpsc::UnboundedSender, - suppress_output: bool, -) { - tokio::spawn(async move { - loop { - match thread.next_event().await { - Ok(event) => { - debug!("Received event: {event:?}"); - - let is_shutdown_complete = matches!(event.msg, EventMsg::ShutdownComplete); - if let Err(err) = tx.send(ThreadEventEnvelope { - thread_id, - thread: Arc::clone(&thread), - event, - suppress_output, - }) { - error!("Error sending event: {err:?}"); - break; - } - if is_shutdown_complete { - info!( - "Received shutdown event for thread {thread_id}, exiting event loop." - ); - break; - } - } - Err(err) => { - error!("Error receiving event: {err:?}"); - break; - } - } +fn sandbox_mode_from_policy( + sandbox_policy: &codex_protocol::protocol::SandboxPolicy, +) -> Option { + match sandbox_policy { + codex_protocol::protocol::SandboxPolicy::DangerFullAccess => { + Some(codex_app_server_protocol::SandboxMode::DangerFullAccess) } - }); -} - -fn is_agent_job_subagent(config: &codex_core::ThreadConfigSnapshot) -> bool { - match &config.session_source { - SessionSource::SubAgent(SubAgentSource::Other(source)) => source.starts_with("agent_job:"), - _ => false, + codex_protocol::protocol::SandboxPolicy::ReadOnly { .. } => { + Some(codex_app_server_protocol::SandboxMode::ReadOnly) + } + codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { .. } => { + Some(codex_app_server_protocol::SandboxMode::WorkspaceWrite) + } + codex_protocol::protocol::SandboxPolicy::ExternalSandbox { .. } => None, } } -fn should_suppress_agent_job_event(msg: &EventMsg) -> bool { - !matches!( - msg, - EventMsg::ExecApprovalRequest(_) - | EventMsg::ApplyPatchApprovalRequest(_) - | EventMsg::RequestUserInput(_) - | EventMsg::DynamicToolCallRequest(_) - | EventMsg::DynamicToolCallResponse(_) - | EventMsg::ElicitationRequest(_) - | EventMsg::Error(_) - | EventMsg::Warning(_) - | EventMsg::DeprecationNotice(_) - | EventMsg::StreamError(_) - | EventMsg::ShutdownComplete +fn thread_start_params_from_config(config: &Config) -> ThreadStartParams { + ThreadStartParams { + model: config.model.clone(), + model_provider: Some(config.model_provider_id.clone()), + cwd: Some(config.cwd.to_string_lossy().to_string()), + approval_policy: Some(config.permissions.approval_policy.value().into()), + sandbox: sandbox_mode_from_policy(config.permissions.sandbox_policy.get()), + ephemeral: Some(config.ephemeral), + ..ThreadStartParams::default() + } +} + +fn thread_resume_params_from_config(config: &Config, path: Option) -> ThreadResumeParams { + ThreadResumeParams { + thread_id: "resume".to_string(), + path, + model: config.model.clone(), + model_provider: Some(config.model_provider_id.clone()), + cwd: Some(config.cwd.to_string_lossy().to_string()), + approval_policy: Some(config.permissions.approval_policy.value().into()), + sandbox: sandbox_mode_from_policy(config.permissions.sandbox_policy.get()), + ..ThreadResumeParams::default() + } +} + +async fn send_request_with_response( + client: &InProcessAppServerClient, + request: ClientRequest, + method: &str, +) -> Result +where + T: serde::de::DeserializeOwned, +{ + client.request_typed(request).await.map_err(|err| { + if method.is_empty() { + err.to_string() + } else { + format!("{method}: {err}") + } + }) +} + +fn session_configured_from_thread_start_response( + response: &ThreadStartResponse, +) -> Result { + session_configured_from_thread_response( + &response.thread.id, + response.thread.name.clone(), + response.thread.path.clone(), + response.model.clone(), + response.model_provider.clone(), + response.service_tier, + response.approval_policy.to_core(), + response.sandbox.to_core(), + response.cwd.clone(), + response.reasoning_effort, ) } +fn session_configured_from_thread_resume_response( + response: &ThreadResumeResponse, +) -> Result { + session_configured_from_thread_response( + &response.thread.id, + response.thread.name.clone(), + response.thread.path.clone(), + response.model.clone(), + response.model_provider.clone(), + response.service_tier, + response.approval_policy.to_core(), + response.sandbox.to_core(), + response.cwd.clone(), + response.reasoning_effort, + ) +} + +#[expect( + clippy::too_many_arguments, + reason = "session mapping keeps explicit fields" +)] +/// Synthesizes startup session metadata from `thread/start` or `thread/resume`. +/// +/// This is a compatibility bridge for the current in-process architecture. +/// Some session fields are not available synchronously from the start/resume +/// response, so callers must treat the result as a best-effort fallback until +/// a later `SessionConfigured` event proves otherwise. +/// TODO(architecture): stop synthesizing a partial `SessionConfiguredEvent` +/// here. Either return the authoritative session-configured payload from +/// `thread/start`/`thread/resume`, or introduce a smaller bootstrap type for +/// exec so this path cannot accidentally depend on placeholder fields. +fn session_configured_from_thread_response( + thread_id: &str, + thread_name: Option, + rollout_path: Option, + model: String, + model_provider_id: String, + service_tier: Option, + approval_policy: AskForApproval, + sandbox_policy: codex_protocol::protocol::SandboxPolicy, + cwd: PathBuf, + reasoning_effort: Option, +) -> Result { + let session_id = codex_protocol::ThreadId::from_string(thread_id) + .map_err(|err| format!("thread id `{thread_id}` is invalid: {err}"))?; + + Ok(SessionConfiguredEvent { + session_id, + forked_from_id: None, + thread_name, + model, + model_provider_id, + service_tier, + approval_policy, + sandbox_policy, + cwd, + reasoning_effort, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path, + }) +} + +fn review_target_to_api(target: ReviewTarget) -> ApiReviewTarget { + match target { + ReviewTarget::UncommittedChanges => ApiReviewTarget::UncommittedChanges, + ReviewTarget::BaseBranch { branch } => ApiReviewTarget::BaseBranch { branch }, + ReviewTarget::Commit { sha, title } => ApiReviewTarget::Commit { sha, title }, + ReviewTarget::Custom { instructions } => ApiReviewTarget::Custom { instructions }, + } +} + +fn normalize_legacy_notification_method(method: &str) -> &str { + method.strip_prefix("codex/event/").unwrap_or(method) +} + +fn lagged_event_warning_message(skipped: usize) -> String { + format!("in-process app-server event stream lagged; dropped {skipped} events") +} + +struct DecodedLegacyNotification { + conversation_id: Option, + event: Event, +} + +fn decode_legacy_notification( + notification: JSONRPCNotification, +) -> Result { + let value = notification + .params + .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new())); + let method = notification.method; + let normalized_method = normalize_legacy_notification_method(&method).to_string(); + let serde_json::Value::Object(mut object) = value else { + return Err(format!( + "legacy notification `{method}` params were not an object" + )); + }; + let conversation_id = object + .get("conversationId") + .and_then(serde_json::Value::as_str) + .map(str::to_owned); + let mut event_payload = if let Some(serde_json::Value::Object(msg_payload)) = object.get("msg") + { + serde_json::Value::Object(msg_payload.clone()) + } else { + object.remove("conversationId"); + serde_json::Value::Object(object) + }; + let serde_json::Value::Object(ref mut object) = event_payload else { + return Err(format!( + "legacy notification `{method}` event payload was not an object" + )); + }; + object.insert( + "type".to_string(), + serde_json::Value::String(normalized_method), + ); + + let msg: EventMsg = serde_json::from_value(event_payload) + .map_err(|err| format!("failed to decode event: {err}"))?; + Ok(DecodedLegacyNotification { + conversation_id, + event: Event { + id: String::new(), + msg, + }, + }) +} + +fn canceled_mcp_server_elicitation_response() -> Result { + serde_json::to_value(McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Cancel, + content: None, + meta: None, + }) + .map_err(|err| format!("failed to encode mcp elicitation response: {err}")) +} + +async fn request_shutdown( + client: &InProcessAppServerClient, + request_ids: &mut RequestIdSequencer, + thread_id: &str, +) -> Result<(), String> { + let request = ClientRequest::ThreadUnsubscribe { + request_id: request_ids.next(), + params: ThreadUnsubscribeParams { + thread_id: thread_id.to_string(), + }, + }; + send_request_with_response::(client, request, "thread/unsubscribe") + .await + .map(|_| ()) +} + +async fn resolve_server_request( + client: &InProcessAppServerClient, + request_id: RequestId, + value: serde_json::Value, + method: &str, +) -> Result<(), String> { + client + .resolve_server_request(request_id, value) + .await + .map_err(|err| format!("failed to resolve `{method}` server request: {err}")) +} + +async fn reject_server_request( + client: &InProcessAppServerClient, + request_id: RequestId, + method: &str, + reason: String, +) -> Result<(), String> { + client + .reject_server_request( + request_id, + JSONRPCErrorError { + code: -32000, + message: reason, + data: None, + }, + ) + .await + .map_err(|err| format!("failed to reject `{method}` server request: {err}")) +} + +fn server_request_method_name(request: &ServerRequest) -> String { + serde_json::to_value(request) + .ok() + .and_then(|value| { + value + .get("method") + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + }) + .unwrap_or_else(|| "unknown".to_string()) +} + +async fn handle_server_request( + client: &InProcessAppServerClient, + request: ServerRequest, + config: &Config, + _thread_id: &str, + error_seen: &mut bool, +) { + let method = server_request_method_name(&request); + let handle_result = match request { + ServerRequest::McpServerElicitationRequest { request_id, .. } => { + // Exec auto-cancels elicitation instead of surfacing it + // interactively. Preserve that behavior for attached subagent + // threads too so we do not turn a cancel into a decline/error. + match canceled_mcp_server_elicitation_response() { + Ok(value) => { + resolve_server_request( + client, + request_id, + value, + "mcpServer/elicitation/request", + ) + .await + } + Err(err) => Err(err), + } + } + ServerRequest::ChatgptAuthTokensRefresh { request_id, params } => { + let refresh_result = tokio::task::spawn_blocking({ + let config = config.clone(); + move || local_external_chatgpt_tokens(&config) + }) + .await; + + match refresh_result { + Err(err) => { + reject_server_request( + client, + request_id, + &method, + format!("local chatgpt auth refresh task failed in exec: {err}"), + ) + .await + } + Ok(Err(reason)) => reject_server_request(client, request_id, &method, reason).await, + Ok(Ok(response)) => { + if let Some(previous_account_id) = params.previous_account_id.as_deref() + && previous_account_id != response.chatgpt_account_id + { + warn!( + "local auth refresh account mismatch: expected `{previous_account_id}`, got `{}`", + response.chatgpt_account_id + ); + } + match serde_json::to_value(response) { + Ok(value) => { + resolve_server_request( + client, + request_id, + value, + "account/chatgptAuthTokens/refresh", + ) + .await + } + Err(err) => Err(format!( + "failed to serialize chatgpt auth refresh response: {err}" + )), + } + } + } + } + ServerRequest::CommandExecutionRequestApproval { request_id, params } => { + reject_server_request( + client, + request_id, + &method, + format!( + "command execution approval is not supported in exec mode for thread `{}`", + params.thread_id + ), + ) + .await + } + ServerRequest::FileChangeRequestApproval { request_id, params } => { + reject_server_request( + client, + request_id, + &method, + format!( + "file change approval is not supported in exec mode for thread `{}`", + params.thread_id + ), + ) + .await + } + ServerRequest::ToolRequestUserInput { request_id, params } => { + reject_server_request( + client, + request_id, + &method, + format!( + "request_user_input is not supported in exec mode for thread `{}`", + params.thread_id + ), + ) + .await + } + ServerRequest::DynamicToolCall { request_id, params } => { + reject_server_request( + client, + request_id, + &method, + format!( + "dynamic tool calls are not supported in exec mode for thread `{}`", + params.thread_id + ), + ) + .await + } + ServerRequest::ApplyPatchApproval { request_id, params } => { + reject_server_request( + client, + request_id, + &method, + format!( + "apply_patch approval is not supported in exec mode for thread `{}`", + params.conversation_id + ), + ) + .await + } + ServerRequest::ExecCommandApproval { request_id, params } => { + reject_server_request( + client, + request_id, + &method, + format!( + "exec command approval is not supported in exec mode for thread `{}`", + params.conversation_id + ), + ) + .await + } + ServerRequest::PermissionsRequestApproval { request_id, params } => { + reject_server_request( + client, + request_id, + &method, + format!( + "permissions approval is not supported in exec mode for thread `{}`", + params.thread_id + ), + ) + .await + } + }; + + if let Err(err) = handle_result { + *error_seen = true; + warn!("{err}"); + } +} + +fn local_external_chatgpt_tokens( + config: &Config, +) -> Result { + let auth_manager = AuthManager::shared( + config.codex_home.clone(), + false, + config.cli_auth_credentials_store_mode, + ); + auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone()); + auth_manager.reload(); + + let auth = auth_manager + .auth_cached() + .ok_or_else(|| "no cached auth available for local token refresh".to_string())?; + if !auth.is_external_chatgpt_tokens() { + return Err("external ChatGPT token auth is not active".to_string()); + } + + let access_token = auth + .get_token() + .map_err(|err| format!("failed to read external access token: {err}"))?; + let chatgpt_account_id = auth + .get_account_id() + .ok_or_else(|| "external token auth is missing chatgpt account id".to_string())?; + let chatgpt_plan_type = auth.account_plan_type().map(|plan_type| match plan_type { + AccountPlanType::Free => "free".to_string(), + AccountPlanType::Go => "go".to_string(), + AccountPlanType::Plus => "plus".to_string(), + AccountPlanType::Pro => "pro".to_string(), + AccountPlanType::Team => "team".to_string(), + AccountPlanType::Business => "business".to_string(), + AccountPlanType::Enterprise => "enterprise".to_string(), + AccountPlanType::Edu => "edu".to_string(), + AccountPlanType::Unknown => "unknown".to_string(), + }); + + Ok(ChatgptAuthTokensRefreshResponse { + access_token, + chatgpt_account_id, + chatgpt_plan_type, + }) +} + async fn resolve_resume_path( config: &Config, args: &crate::cli::ResumeArgs, @@ -980,17 +1552,17 @@ fn resolve_prompt(prompt_arg: Option) -> String { } } -fn build_review_request(args: ReviewArgs) -> anyhow::Result { +fn build_review_request(args: &ReviewArgs) -> anyhow::Result { let target = if args.uncommitted { ReviewTarget::UncommittedChanges - } else if let Some(branch) = args.base { + } else if let Some(branch) = args.base.clone() { ReviewTarget::BaseBranch { branch } - } else if let Some(sha) = args.commit { + } else if let Some(sha) = args.commit.clone() { ReviewTarget::Commit { sha, - title: args.commit_title, + title: args.commit_title.clone(), } - } else if let Some(prompt_arg) = args.prompt { + } else if let Some(prompt_arg) = args.prompt.clone() { let prompt = resolve_prompt(Some(prompt_arg)).trim().to_string(); if prompt.is_empty() { anyhow::bail!("Review prompt cannot be empty"); @@ -1053,14 +1625,14 @@ mod tests { #[test] fn builds_uncommitted_review_request() { - let request = build_review_request(ReviewArgs { + let args = ReviewArgs { uncommitted: true, base: None, commit: None, commit_title: None, prompt: None, - }) - .expect("builds uncommitted review request"); + }; + let request = build_review_request(&args).expect("builds uncommitted review request"); let expected = ReviewRequest { target: ReviewTarget::UncommittedChanges, @@ -1072,14 +1644,14 @@ mod tests { #[test] fn builds_commit_review_request_with_title() { - let request = build_review_request(ReviewArgs { + let args = ReviewArgs { uncommitted: false, base: None, commit: Some("123456789".to_string()), commit_title: Some("Add review command".to_string()), prompt: None, - }) - .expect("builds commit review request"); + }; + let request = build_review_request(&args).expect("builds commit review request"); let expected = ReviewRequest { target: ReviewTarget::Commit { @@ -1094,14 +1666,14 @@ mod tests { #[test] fn builds_custom_review_request_trims_prompt() { - let request = build_review_request(ReviewArgs { + let args = ReviewArgs { uncommitted: false, base: None, commit: None, commit_title: None, prompt: Some(" custom review instructions ".to_string()), - }) - .expect("builds custom review request"); + }; + let request = build_review_request(&args).expect("builds custom review request"); let expected = ReviewRequest { target: ReviewTarget::Custom { @@ -1187,4 +1759,52 @@ mod tests { assert_eq!(err, PromptDecodeError::InvalidUtf8 { valid_up_to: 0 }); } + + #[test] + fn lagged_event_warning_message_is_explicit() { + assert_eq!( + lagged_event_warning_message(7), + "in-process app-server event stream lagged; dropped 7 events".to_string() + ); + } + + #[test] + fn decode_legacy_notification_preserves_conversation_id() { + let decoded = decode_legacy_notification(JSONRPCNotification { + method: "codex/event/error".to_string(), + params: Some(serde_json::json!({ + "conversationId": "thread-123", + "msg": { + "message": "boom" + } + })), + }) + .expect("legacy notification should decode"); + + assert_eq!(decoded.conversation_id.as_deref(), Some("thread-123")); + assert!(matches!( + decoded.event.msg, + EventMsg::Error(codex_protocol::protocol::ErrorEvent { + message, + codex_error_info: None, + }) if message == "boom" + )); + } + + #[test] + fn canceled_mcp_server_elicitation_response_uses_cancel_action() { + let value = canceled_mcp_server_elicitation_response() + .expect("mcp elicitation cancel response should serialize"); + let response: McpServerElicitationRequestResponse = + serde_json::from_value(value).expect("cancel response should deserialize"); + + assert_eq!( + response, + McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Cancel, + content: None, + meta: None, + } + ); + } } diff --git a/codex-rs/linux-sandbox/src/linux_run_main.rs b/codex-rs/linux-sandbox/src/linux_run_main.rs index 2f1b1b6b3c..e6304d2d60 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main.rs @@ -233,7 +233,10 @@ fn resolve_sandbox_policies( } } (Some(sandbox_policy), None) => EffectiveSandboxPolicies { - file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy), + file_system_sandbox_policy: FileSystemSandboxPolicy::from_legacy_sandbox_policy( + &sandbox_policy, + sandbox_policy_cwd, + ), network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy), sandbox_policy, }, diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index d0cfaf909a..03547a7e36 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -228,6 +228,7 @@ async fn run_codex_tool_session_inner( parsed_cmd, network_approval_context: _, additional_permissions: _, + skill_metadata: _, available_decisions: _, } = ev; handle_exec_approval_request( @@ -366,6 +367,7 @@ async fn run_codex_tool_session_inner( | EventMsg::UndoCompleted(_) | EventMsg::ExitedReviewMode(_) | EventMsg::RequestUserInput(_) + | EventMsg::RequestPermissions(_) | EventMsg::DynamicToolCallRequest(_) | EventMsg::DynamicToolCallResponse(_) | EventMsg::ContextCompacted(_) diff --git a/codex-rs/mcp-server/tests/suite/codex_tool.rs b/codex-rs/mcp-server/tests/suite/codex_tool.rs index 041fcc0217..c6df62dbf1 100644 --- a/codex-rs/mcp-server/tests/suite/codex_tool.rs +++ b/codex-rs/mcp-server/tests/suite/codex_tool.rs @@ -54,23 +54,26 @@ async fn test_shell_command_approval_triggers_elicitation() { async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> { // Use a simple, untrusted command that creates a file so we can // observe a side-effect. - // - // Cross‑platform approach: run a tiny Python snippet to touch the file - // using `python3 -c ...` on all platforms. let workdir_for_shell_function_call = TempDir::new()?; let created_filename = "created_by_shell_tool.txt"; let created_file = workdir_for_shell_function_call .path() .join(created_filename); - let shell_command = vec![ - "python3".to_string(), - "-c".to_string(), - format!("import pathlib; pathlib.Path('{created_filename}').touch()"), - ]; - let expected_shell_command = format_with_current_shell(&format!( - "python3 -c \"import pathlib; pathlib.Path('{created_filename}').touch()\"" - )); + let shell_command = if cfg!(windows) { + vec![ + "New-Item".to_string(), + "-ItemType".to_string(), + "File".to_string(), + "-Path".to_string(), + created_filename.to_string(), + "-Force".to_string(), + ] + } else { + vec!["touch".to_string(), created_filename.to_string()] + }; + let expected_shell_command = + format_with_current_shell(&shlex::try_join(shell_command.iter().map(String::as_str))?); let McpHandle { process: mut mcp_process, diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index 50d7614a0f..9bb0b82fd3 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -24,9 +24,8 @@ use crate::metrics::names::WEBSOCKET_EVENT_DURATION_METRIC; use crate::metrics::names::WEBSOCKET_REQUEST_COUNT_METRIC; use crate::metrics::names::WEBSOCKET_REQUEST_DURATION_METRIC; use crate::metrics::runtime_metrics::RuntimeMetricsSummary; +use crate::metrics::tags::SessionMetricTagValues; use crate::metrics::timer::Timer; -use crate::metrics::validation::validate_tag_key; -use crate::metrics::validation::validate_tag_value; use crate::provider::OtelProvider; use crate::sanitize_metric_tag_value; use codex_api::ApiError; @@ -228,40 +227,15 @@ impl SessionTelemetry { if !self.metrics_use_metadata_tags { return Ok(Vec::new()); } - let mut tags = Vec::with_capacity(7); - Self::push_metadata_tag(&mut tags, "auth_mode", self.metadata.auth_mode.as_deref())?; - Self::push_metadata_tag( - &mut tags, - "session_source", - Some(self.metadata.session_source.as_str()), - )?; - Self::push_metadata_tag( - &mut tags, - "originator", - Some(self.metadata.originator.as_str()), - )?; - Self::push_metadata_tag( - &mut tags, - "service_name", - self.metadata.service_name.as_deref(), - )?; - Self::push_metadata_tag(&mut tags, "model", Some(self.metadata.model.as_str()))?; - Self::push_metadata_tag(&mut tags, "app.version", Some(self.metadata.app_version))?; - Ok(tags) - } - - fn push_metadata_tag<'a>( - tags: &mut Vec<(&'a str, &'a str)>, - key: &'static str, - value: Option<&'a str>, - ) -> MetricsResult<()> { - let Some(value) = value else { - return Ok(()); - }; - validate_tag_key(key)?; - validate_tag_value(value)?; - tags.push((key, value)); - Ok(()) + SessionMetricTagValues { + auth_mode: self.metadata.auth_mode.as_deref(), + session_source: self.metadata.session_source.as_str(), + originator: self.metadata.originator.as_str(), + service_name: self.metadata.service_name.as_deref(), + model: self.metadata.model.as_str(), + app_version: self.metadata.app_version, + } + .into_tags() } #[allow(clippy::too_many_arguments)] diff --git a/codex-rs/otel/src/metrics/mod.rs b/codex-rs/otel/src/metrics/mod.rs index 06b06e8d26..1d1195e5fe 100644 --- a/codex-rs/otel/src/metrics/mod.rs +++ b/codex-rs/otel/src/metrics/mod.rs @@ -1,8 +1,9 @@ mod client; mod config; mod error; -pub(crate) mod names; +pub mod names; pub(crate) mod runtime_metrics; +pub mod tags; pub(crate) mod timer; pub(crate) mod validation; diff --git a/codex-rs/otel/src/metrics/names.rs b/codex-rs/otel/src/metrics/names.rs index 932f94759b..1d0ff86376 100644 --- a/codex-rs/otel/src/metrics/names.rs +++ b/codex-rs/otel/src/metrics/names.rs @@ -1,24 +1,27 @@ -pub(crate) const TOOL_CALL_COUNT_METRIC: &str = "codex.tool.call"; -pub(crate) const TOOL_CALL_DURATION_METRIC: &str = "codex.tool.call.duration_ms"; -pub(crate) const API_CALL_COUNT_METRIC: &str = "codex.api_request"; -pub(crate) const API_CALL_DURATION_METRIC: &str = "codex.api_request.duration_ms"; -pub(crate) const SSE_EVENT_COUNT_METRIC: &str = "codex.sse_event"; -pub(crate) const SSE_EVENT_DURATION_METRIC: &str = "codex.sse_event.duration_ms"; -pub(crate) const WEBSOCKET_REQUEST_COUNT_METRIC: &str = "codex.websocket.request"; -pub(crate) const WEBSOCKET_REQUEST_DURATION_METRIC: &str = "codex.websocket.request.duration_ms"; -pub(crate) const WEBSOCKET_EVENT_COUNT_METRIC: &str = "codex.websocket.event"; -pub(crate) const WEBSOCKET_EVENT_DURATION_METRIC: &str = "codex.websocket.event.duration_ms"; -pub(crate) const RESPONSES_API_OVERHEAD_DURATION_METRIC: &str = - "codex.responses_api_overhead.duration_ms"; -pub(crate) const RESPONSES_API_INFERENCE_TIME_DURATION_METRIC: &str = +pub const TOOL_CALL_COUNT_METRIC: &str = "codex.tool.call"; +pub const TOOL_CALL_DURATION_METRIC: &str = "codex.tool.call.duration_ms"; +pub const API_CALL_COUNT_METRIC: &str = "codex.api_request"; +pub const API_CALL_DURATION_METRIC: &str = "codex.api_request.duration_ms"; +pub const SSE_EVENT_COUNT_METRIC: &str = "codex.sse_event"; +pub const SSE_EVENT_DURATION_METRIC: &str = "codex.sse_event.duration_ms"; +pub const WEBSOCKET_REQUEST_COUNT_METRIC: &str = "codex.websocket.request"; +pub const WEBSOCKET_REQUEST_DURATION_METRIC: &str = "codex.websocket.request.duration_ms"; +pub const WEBSOCKET_EVENT_COUNT_METRIC: &str = "codex.websocket.event"; +pub const WEBSOCKET_EVENT_DURATION_METRIC: &str = "codex.websocket.event.duration_ms"; +pub const RESPONSES_API_OVERHEAD_DURATION_METRIC: &str = "codex.responses_api_overhead.duration_ms"; +pub const RESPONSES_API_INFERENCE_TIME_DURATION_METRIC: &str = "codex.responses_api_inference_time.duration_ms"; -pub(crate) const RESPONSES_API_ENGINE_IAPI_TTFT_DURATION_METRIC: &str = +pub const RESPONSES_API_ENGINE_IAPI_TTFT_DURATION_METRIC: &str = "codex.responses_api_engine_iapi_ttft.duration_ms"; -pub(crate) const RESPONSES_API_ENGINE_SERVICE_TTFT_DURATION_METRIC: &str = +pub const RESPONSES_API_ENGINE_SERVICE_TTFT_DURATION_METRIC: &str = "codex.responses_api_engine_service_ttft.duration_ms"; -pub(crate) const RESPONSES_API_ENGINE_IAPI_TBT_DURATION_METRIC: &str = +pub const RESPONSES_API_ENGINE_IAPI_TBT_DURATION_METRIC: &str = "codex.responses_api_engine_iapi_tbt.duration_ms"; -pub(crate) const RESPONSES_API_ENGINE_SERVICE_TBT_DURATION_METRIC: &str = +pub const RESPONSES_API_ENGINE_SERVICE_TBT_DURATION_METRIC: &str = "codex.responses_api_engine_service_tbt.duration_ms"; -pub(crate) const TURN_TTFT_DURATION_METRIC: &str = "codex.turn.ttft.duration_ms"; -pub(crate) const TURN_TTFM_DURATION_METRIC: &str = "codex.turn.ttfm.duration_ms"; +pub const TURN_E2E_DURATION_METRIC: &str = "codex.turn.e2e_duration_ms"; +pub const TURN_TTFT_DURATION_METRIC: &str = "codex.turn.ttft.duration_ms"; +pub const TURN_TTFM_DURATION_METRIC: &str = "codex.turn.ttfm.duration_ms"; +pub const TURN_TOOL_CALL_METRIC: &str = "codex.turn.tool.call"; +pub const TURN_TOKEN_USAGE_METRIC: &str = "codex.turn.token_usage"; +pub const THREAD_STARTED_METRIC: &str = "codex.thread.started"; diff --git a/codex-rs/otel/src/metrics/tags.rs b/codex-rs/otel/src/metrics/tags.rs new file mode 100644 index 0000000000..5c166e307e --- /dev/null +++ b/codex-rs/otel/src/metrics/tags.rs @@ -0,0 +1,108 @@ +use crate::metrics::Result; +use crate::metrics::validation::validate_tag_key; +use crate::metrics::validation::validate_tag_value; + +pub const APP_VERSION_TAG: &str = "app.version"; +pub const AUTH_MODE_TAG: &str = "auth_mode"; +pub const MODEL_TAG: &str = "model"; +pub const ORIGINATOR_TAG: &str = "originator"; +pub const SERVICE_NAME_TAG: &str = "service_name"; +pub const SESSION_SOURCE_TAG: &str = "session_source"; + +pub struct SessionMetricTagValues<'a> { + pub auth_mode: Option<&'a str>, + pub session_source: &'a str, + pub originator: &'a str, + pub service_name: Option<&'a str>, + pub model: &'a str, + pub app_version: &'a str, +} + +impl<'a> SessionMetricTagValues<'a> { + pub fn into_tags(self) -> Result> { + let mut tags = Vec::with_capacity(6); + Self::push_optional_tag(&mut tags, AUTH_MODE_TAG, self.auth_mode)?; + Self::push_optional_tag(&mut tags, SESSION_SOURCE_TAG, Some(self.session_source))?; + Self::push_optional_tag(&mut tags, ORIGINATOR_TAG, Some(self.originator))?; + Self::push_optional_tag(&mut tags, SERVICE_NAME_TAG, self.service_name)?; + Self::push_optional_tag(&mut tags, MODEL_TAG, Some(self.model))?; + Self::push_optional_tag(&mut tags, APP_VERSION_TAG, Some(self.app_version))?; + Ok(tags) + } + + fn push_optional_tag( + tags: &mut Vec<(&'static str, &'a str)>, + key: &'static str, + value: Option<&'a str>, + ) -> Result<()> { + let Some(value) = value else { + return Ok(()); + }; + validate_tag_key(key)?; + validate_tag_value(value)?; + tags.push((key, value)); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::APP_VERSION_TAG; + use super::AUTH_MODE_TAG; + use super::MODEL_TAG; + use super::ORIGINATOR_TAG; + use super::SERVICE_NAME_TAG; + use super::SESSION_SOURCE_TAG; + use super::SessionMetricTagValues; + use pretty_assertions::assert_eq; + + #[test] + fn session_metric_tags_include_expected_tags_in_order() { + let tags = SessionMetricTagValues { + auth_mode: Some("api_key"), + session_source: "cli", + originator: "codex_cli", + service_name: Some("desktop_app"), + model: "gpt-5.1", + app_version: "1.2.3", + } + .into_tags() + .expect("tags"); + + assert_eq!( + tags, + vec![ + (AUTH_MODE_TAG, "api_key"), + (SESSION_SOURCE_TAG, "cli"), + (ORIGINATOR_TAG, "codex_cli"), + (SERVICE_NAME_TAG, "desktop_app"), + (MODEL_TAG, "gpt-5.1"), + (APP_VERSION_TAG, "1.2.3"), + ] + ); + } + + #[test] + fn session_metric_tags_skip_missing_optional_tags() { + let tags = SessionMetricTagValues { + auth_mode: None, + session_source: "exec", + originator: "codex_exec", + service_name: None, + model: "gpt-5.1", + app_version: "1.2.3", + } + .into_tags() + .expect("tags"); + + assert_eq!( + tags, + vec![ + (SESSION_SOURCE_TAG, "exec"), + (ORIGINATOR_TAG, "codex_exec"), + (MODEL_TAG, "gpt-5.1"), + (APP_VERSION_TAG, "1.2.3"), + ] + ); + } +} diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index 4116777afb..74aadb7570 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -90,6 +90,13 @@ pub struct NetworkPolicyAmendment { pub action: NetworkPolicyRuleAction, } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub struct ExecApprovalRequestSkillMetadata { + pub path_to_skills_md: PathBuf, +} + #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ExecApprovalRequestEvent { /// Identifier for the associated command execution item. @@ -128,6 +135,10 @@ pub struct ExecApprovalRequestEvent { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub additional_permissions: Option, + /// Optional skill metadata when the approval was triggered by a skill script. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub skill_metadata: Option, /// Ordered list of decisions the client may present for this prompt. /// /// When absent, clients should derive the legacy default set from the diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs index afe78d37d1..d6adf2c585 100644 --- a/codex-rs/protocol/src/lib.rs +++ b/codex-rs/protocol/src/lib.rs @@ -15,5 +15,6 @@ pub mod parse_command; pub mod permissions; pub mod plan_tool; pub mod protocol; +pub mod request_permissions; pub mod request_user_input; pub mod user_input; diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 151570f789..f8948a772e 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -170,9 +170,13 @@ impl TryFrom for MacOsAutomationPermission { #[derive(Debug, Clone, PartialEq, Eq, Default, Hash, Serialize, Deserialize, JsonSchema, TS)] #[serde(default)] pub struct MacOsSeatbeltProfileExtensions { + #[serde(alias = "preferences")] pub macos_preferences: MacOsPreferencesPermission, + #[serde(alias = "automations")] pub macos_automation: MacOsAutomationPermission, + #[serde(alias = "accessibility")] pub macos_accessibility: bool, + #[serde(alias = "calendar")] pub macos_calendar: bool, } @@ -404,8 +408,6 @@ const APPROVAL_POLICY_ON_REQUEST_RULE: &str = include_str!("prompts/permissions/approval_policy/on_request_rule.md"); const APPROVAL_POLICY_ON_REQUEST_RULE_REQUEST_PERMISSION: &str = include_str!("prompts/permissions/approval_policy/on_request_rule_request_permission.md"); -const GUARDIAN_APPROVAL_FEATURE: &str = - include_str!("prompts/permissions/approval_policy/guardian.md"); const SANDBOX_MODE_DANGER_FULL_ACCESS: &str = include_str!("prompts/permissions/sandbox_mode/danger_full_access.md"); @@ -423,7 +425,6 @@ impl DeveloperInstructions { pub fn from( approval_policy: AskForApproval, - guardian_approval_enabled: bool, exec_policy: &Policy, request_permission_enabled: bool, ) -> DeveloperInstructions { @@ -447,24 +448,19 @@ impl DeveloperInstructions { AskForApproval::Never => APPROVAL_POLICY_NEVER.to_string(), AskForApproval::UnlessTrusted => APPROVAL_POLICY_UNLESS_TRUSTED.to_string(), AskForApproval::OnFailure => APPROVAL_POLICY_ON_FAILURE.to_string(), - AskForApproval::OnRequest => { - let mut instructions = on_request_instructions(); - if guardian_approval_enabled { - instructions.push_str("\n\n"); - instructions.push_str(GUARDIAN_APPROVAL_FEATURE); - } - instructions - } + AskForApproval::OnRequest => on_request_instructions(), AskForApproval::Reject(reject_config) => { let on_request_instructions = on_request_instructions(); let sandbox_approval = reject_config.sandbox_approval; let rules = reject_config.rules; + let request_permissions = reject_config.request_permissions; let mcp_elicitations = reject_config.mcp_elicitations; format!( "{on_request_instructions}\n\n\ Approval policy is `reject`.\n\ - `sandbox_approval`: {sandbox_approval}\n\ - `rules`: {rules}\n\ + - `request_permissions`: {request_permissions}\n\ - `mcp_elicitations`: {mcp_elicitations}\n\ When a category is `true`, requests in that category are auto-rejected instead of prompting the user." ) @@ -517,7 +513,6 @@ impl DeveloperInstructions { pub fn from_policy( sandbox_policy: &SandboxPolicy, approval_policy: AskForApproval, - guardian_approval_enabled: bool, exec_policy: &Policy, cwd: &Path, request_permission_enabled: bool, @@ -542,7 +537,6 @@ impl DeveloperInstructions { sandbox_mode, network_access, approval_policy, - guardian_approval_enabled, exec_policy, writable_roots, request_permission_enabled, @@ -567,7 +561,6 @@ impl DeveloperInstructions { sandbox_mode: SandboxMode, network_access: NetworkAccess, approval_policy: AskForApproval, - guardian_approval_enabled: bool, exec_policy: &Policy, writable_roots: Option>, request_permission_enabled: bool, @@ -581,7 +574,6 @@ impl DeveloperInstructions { )) .concat(DeveloperInstructions::from( approval_policy, - guardian_approval_enabled, exec_policy, request_permission_enabled, )) @@ -1504,6 +1496,30 @@ mod tests { ); } + #[test] + fn macos_seatbelt_profile_extensions_deserializes_tool_schema_aliases() { + let permissions = + serde_json::from_value::(serde_json::json!({ + "preferences": "read_write", + "automations": ["com.apple.Notes"], + "accessibility": true, + "calendar": true + })) + .expect("deserialize macos permissions"); + + assert_eq!( + permissions, + MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::ReadWrite, + macos_automation: MacOsAutomationPermission::BundleIds(vec![ + "com.apple.Notes".to_string(), + ]), + macos_accessibility: true, + macos_calendar: true, + } + ); + } + #[test] fn macos_automation_permission_deserializes_all_and_none() { let all = serde_json::from_str::("\"all\"") @@ -1639,7 +1655,6 @@ mod tests { SandboxMode::WorkspaceWrite, NetworkAccess::Enabled, AskForApproval::OnRequest, - false, &Policy::empty(), None, false, @@ -1669,7 +1684,6 @@ mod tests { let instructions = DeveloperInstructions::from_policy( &policy, AskForApproval::UnlessTrusted, - false, &Policy::empty(), &PathBuf::from("/tmp"), false, @@ -1692,7 +1706,6 @@ mod tests { SandboxMode::WorkspaceWrite, NetworkAccess::Enabled, AskForApproval::OnRequest, - false, &exec_policy, None, false, @@ -1710,7 +1723,6 @@ mod tests { SandboxMode::WorkspaceWrite, NetworkAccess::Enabled, AskForApproval::OnRequest, - false, &Policy::empty(), None, true, @@ -1721,23 +1733,6 @@ mod tests { assert!(text.contains("additional_permissions")); } - #[test] - fn includes_guardian_feature_guidance_for_on_request_when_enabled() { - let instructions = DeveloperInstructions::from_permissions_with_network( - SandboxMode::WorkspaceWrite, - NetworkAccess::Enabled, - AskForApproval::OnRequest, - true, - &Policy::empty(), - None, - false, - ); - - let text = instructions.into_text(); - assert!(text.contains("guardian subagent")); - assert!(text.contains("approval prompts")); - } - #[test] fn render_command_prefix_list_sorts_by_len_then_total_len_then_alphabetical() { let prefixes = vec![ diff --git a/codex-rs/protocol/src/permissions.rs b/codex-rs/protocol/src/permissions.rs index 4e2f2fbad7..9624f36cd6 100644 --- a/codex-rs/protocol/src/permissions.rs +++ b/codex-rs/protocol/src/permissions.rs @@ -67,12 +67,33 @@ pub enum FileSystemSpecialPath { }, Tmpdir, SlashTmp, + /// WARNING: `:special_path` tokens are part of config compatibility. + /// Do not make older runtimes reject newly introduced tokens. + /// New parser support should be additive, while unknown values must stay + /// representable so config from a newer Codex degrades to warn-and-ignore + /// instead of failing to load. Codex 0.112.0 rejected unknown values here, + /// which broke forward compatibility for newer config. + /// Preserves future special-path tokens so older runtimes can ignore them + /// without rejecting config authored by a newer release. + Unknown { + path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + subpath: Option, + }, } impl FileSystemSpecialPath { pub fn project_roots(subpath: Option) -> Self { Self::ProjectRoots { subpath } } + + pub fn unknown(path: impl Into, subpath: Option) -> Self { + Self::Unknown { + path: path.into(), + subpath, + } + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] @@ -163,6 +184,34 @@ impl FileSystemSandboxPolicy { } } + /// Converts a legacy sandbox policy into an equivalent filesystem policy + /// for the provided cwd. + /// + /// Legacy `WorkspaceWrite` policies may list readable roots that live + /// under an already-writable root. Those paths were redundant in the + /// legacy model and should not become read-only carveouts when projected + /// into split filesystem policy. + pub fn from_legacy_sandbox_policy(sandbox_policy: &SandboxPolicy, cwd: &Path) -> Self { + let mut file_system_policy = Self::from(sandbox_policy); + if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) { + let legacy_writable_roots = sandbox_policy.get_writable_roots_with_cwd(cwd); + file_system_policy.entries.retain(|entry| { + if entry.access != FileSystemAccessMode::Read { + return true; + } + + match &entry.path { + FileSystemPath::Path { path } => !legacy_writable_roots + .iter() + .any(|root| root.is_path_writable(path.as_path())), + FileSystemPath::Special { .. } => true, + } + }); + } + + file_system_policy + } + /// Returns true when filesystem reads are unrestricted. pub fn has_full_disk_read_access(&self) -> bool { match self.kind { @@ -236,7 +285,13 @@ impl FileSystemSandboxPolicy { } let cwd_absolute = AbsolutePathBuf::from_absolute_path(cwd).ok(); - let unreadable_roots = self.get_unreadable_roots_with_cwd(cwd); + let read_only_roots = dedup_absolute_paths( + self.entries + .iter() + .filter(|entry| !entry.access.can_write()) + .filter_map(|entry| resolve_file_system_path(&entry.path, cwd_absolute.as_ref())) + .collect(), + ); let mut writable_roots = Vec::new(); if self.has_root_access(FileSystemAccessMode::can_write) && let Some(cwd_absolute) = cwd_absolute.as_ref() @@ -260,9 +315,13 @@ impl FileSystemSandboxPolicy { .into_iter() .map(|root| { let mut read_only_subpaths = default_read_only_subpaths_for_writable_root(&root); + // Narrower explicit non-write entries carve out broader writable roots. + // More specific write entries still remain writable because they appear + // as separate WritableRoot values and are checked independently. read_only_subpaths.extend( - unreadable_roots + read_only_roots .iter() + .filter(|path| path.as_path() != root.as_path()) .filter(|path| path.as_path().starts_with(root.as_path())) .cloned(), ); @@ -399,6 +458,7 @@ impl FileSystemSandboxPolicy { readable_roots.push(path); } } + FileSystemSpecialPath::Unknown { .. } => {} }, } } @@ -596,7 +656,9 @@ fn resolve_file_system_special_path( cwd: Option<&AbsolutePathBuf>, ) -> Option { match value { - FileSystemSpecialPath::Root | FileSystemSpecialPath::Minimal => None, + FileSystemSpecialPath::Root + | FileSystemSpecialPath::Minimal + | FileSystemSpecialPath::Unknown { .. } => None, FileSystemSpecialPath::CurrentWorkingDirectory => { let cwd = cwd?; Some(cwd.clone()) @@ -741,3 +803,36 @@ fn resolve_gitdir_from_file(dot_git: &AbsolutePathBuf) -> Option std::io::Result<()> { + let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::unknown(":future_special_path", None), + }, + access: FileSystemAccessMode::Write, + }]); + + let sandbox_policy = policy.to_legacy_sandbox_policy( + NetworkSandboxPolicy::Restricted, + Path::new("/tmp/workspace"), + )?; + + assert_eq!( + sandbox_policy, + SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::Restricted { + include_platform_defaults: false, + readable_roots: Vec::new(), + }, + network_access: false, + } + ); + Ok(()) + } +} diff --git a/codex-rs/protocol/src/prompts/permissions/approval_policy/guardian.md b/codex-rs/protocol/src/prompts/permissions/approval_policy/guardian.md deleted file mode 100644 index 779dfdcf81..0000000000 --- a/codex-rs/protocol/src/prompts/permissions/approval_policy/guardian.md +++ /dev/null @@ -1,3 +0,0 @@ -Guardian approvals are enabled. While `approval_policy` is still `on-request`, approval prompts are routed to a guardian subagent instead of the user. Use `sandbox_permissions: "require_escalated"` with a concise `justification` when you need unsandboxed execution, and use `sandbox_permissions: "with_additional_permissions"` plus `additional_permissions` when you need broader sandboxed access. Codex will ask the guardian subagent to assess the risk automatically. - -Do not message the user before requesting escalation. If the guardian rejects an action, do not attempt the same outcome via workaround, indirect execution, or policy circumvention. Either choose a materially safer alternative or stop and ask the user for guidance. diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index f51e3e6b72..f9b7e88eb5 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -41,6 +41,8 @@ use crate::num_format::format_with_separators; use crate::openai_models::ReasoningEffort as ReasoningEffortConfig; use crate::parse_command::ParsedCommand; use crate::plan_tool::UpdatePlanArgs; +use crate::request_permissions::RequestPermissionsEvent; +use crate::request_permissions::RequestPermissionsResponse; use crate::request_user_input::RequestUserInputResponse; use crate::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; @@ -56,6 +58,7 @@ use ts_rs::TS; pub use crate::approvals::ApplyPatchApprovalRequestEvent; pub use crate::approvals::ElicitationAction; pub use crate::approvals::ExecApprovalRequestEvent; +pub use crate::approvals::ExecApprovalRequestSkillMetadata; pub use crate::approvals::ExecPolicyAmendment; pub use crate::approvals::NetworkApprovalContext; pub use crate::approvals::NetworkApprovalProtocol; @@ -68,6 +71,7 @@ pub use crate::permissions::FileSystemSandboxKind; pub use crate::permissions::FileSystemSandboxPolicy; pub use crate::permissions::FileSystemSpecialPath; pub use crate::permissions::NetworkSandboxPolicy; +pub use crate::request_permissions::RequestPermissionsArgs; pub use crate::request_user_input::RequestUserInputEvent; /// Open/close tags for special user-input blocks. Used across crates to avoid @@ -355,6 +359,14 @@ pub enum Op { response: RequestUserInputResponse, }, + /// Resolve a request_permissions tool call. + RequestPermissionsResponse { + /// Call id for the in-flight request. + id: String, + /// User-granted permissions. + response: RequestPermissionsResponse, + }, + /// Resolve a dynamic tool call request. DynamicToolResponse { /// Call id for the in-flight request. @@ -514,6 +526,8 @@ pub struct RejectConfig { pub sandbox_approval: bool, /// Reject prompts triggered by execpolicy `prompt` rules. pub rules: bool, + /// Reject approval prompts related to built-in permission requests. + pub request_permissions: bool, /// Reject MCP elicitation prompts. pub mcp_elicitations: bool, } @@ -527,6 +541,10 @@ impl RejectConfig { self.rules } + pub const fn rejects_request_permissions(self) -> bool { + self.request_permissions + } + pub const fn rejects_mcp_elicitations(self) -> bool { self.mcp_elicitations } @@ -1143,6 +1161,8 @@ pub enum EventMsg { ExecApprovalRequest(ExecApprovalRequestEvent), + RequestPermissions(RequestPermissionsEvent), + RequestUserInput(RequestUserInputEvent), DynamicToolCallRequest(DynamicToolCallRequest), @@ -3197,12 +3217,6 @@ mod tests { use tempfile::NamedTempFile; use tempfile::TempDir; - fn sorted_paths(paths: Vec) -> Vec { - let mut sorted: Vec = paths.into_iter().map(|path| path.to_path_buf()).collect(); - sorted.sort(); - sorted - } - fn sorted_writable_roots(roots: Vec) -> Vec<(PathBuf, Vec)> { let mut sorted_roots: Vec<(PathBuf, Vec)> = roots .into_iter() @@ -3220,6 +3234,53 @@ mod tests { sorted_roots } + fn sandbox_policy_allows_read(policy: &SandboxPolicy, path: &Path, cwd: &Path) -> bool { + if policy.has_full_disk_read_access() { + return true; + } + + policy + .get_readable_roots_with_cwd(cwd) + .iter() + .any(|root| path.starts_with(root.as_path())) + || policy + .get_writable_roots_with_cwd(cwd) + .iter() + .any(|root| path.starts_with(root.root.as_path())) + } + + fn sandbox_policy_allows_write(policy: &SandboxPolicy, path: &Path, cwd: &Path) -> bool { + if policy.has_full_disk_write_access() { + return true; + } + + policy + .get_writable_roots_with_cwd(cwd) + .iter() + .any(|root| root.is_path_writable(path)) + } + + fn sandbox_policy_probe_paths(policy: &SandboxPolicy, cwd: &Path) -> Vec { + let mut paths = vec![cwd.to_path_buf()]; + paths.extend( + policy + .get_readable_roots_with_cwd(cwd) + .into_iter() + .map(|path| path.to_path_buf()), + ); + for root in policy.get_writable_roots_with_cwd(cwd) { + paths.push(root.root.to_path_buf()); + paths.extend( + root.read_only_subpaths + .into_iter() + .map(|path| path.to_path_buf()), + ); + } + paths.sort(); + paths.dedup(); + paths + } + fn assert_same_sandbox_policy_semantics( expected: &SandboxPolicy, actual: &SandboxPolicy, @@ -3241,14 +3302,25 @@ mod tests { actual.include_platform_defaults(), expected.include_platform_defaults() ); - assert_eq!( - sorted_paths(actual.get_readable_roots_with_cwd(cwd)), - sorted_paths(expected.get_readable_roots_with_cwd(cwd)) - ); - assert_eq!( - sorted_writable_roots(actual.get_writable_roots_with_cwd(cwd)), - sorted_writable_roots(expected.get_writable_roots_with_cwd(cwd)) - ); + let mut probe_paths = sandbox_policy_probe_paths(expected, cwd); + probe_paths.extend(sandbox_policy_probe_paths(actual, cwd)); + probe_paths.sort(); + probe_paths.dedup(); + + for path in probe_paths { + assert_eq!( + sandbox_policy_allows_read(actual, &path, cwd), + sandbox_policy_allows_read(expected, &path, cwd), + "read access mismatch for {}", + path.display() + ); + assert_eq!( + sandbox_policy_allows_write(actual, &path, cwd), + sandbox_policy_allows_write(expected, &path, cwd), + "write access mismatch for {}", + path.display() + ); + } } #[test] @@ -3284,6 +3356,7 @@ mod tests { RejectConfig { sandbox_approval: false, rules: false, + request_permissions: false, mcp_elicitations: true, } .rejects_mcp_elicitations() @@ -3292,12 +3365,35 @@ mod tests { !RejectConfig { sandbox_approval: false, rules: false, + request_permissions: false, mcp_elicitations: false, } .rejects_mcp_elicitations() ); } + #[test] + fn reject_config_request_permissions_flag_is_field_driven() { + assert!( + RejectConfig { + sandbox_approval: false, + rules: false, + request_permissions: true, + mcp_elicitations: false, + } + .rejects_request_permissions() + ); + assert!( + !RejectConfig { + sandbox_approval: false, + rules: false, + request_permissions: false, + mcp_elicitations: false, + } + .rejects_request_permissions() + ); + } + #[test] fn workspace_write_restricted_read_access_includes_effective_writable_roots() { let cwd = if cfg!(windows) { @@ -3471,6 +3567,67 @@ mod tests { ); } + #[test] + fn restricted_file_system_policy_treats_read_entries_as_read_only_subpaths() { + let cwd = TempDir::new().expect("tempdir"); + let docs = + AbsolutePathBuf::resolve_path_against_base("docs", cwd.path()).expect("resolve docs"); + let docs_public = AbsolutePathBuf::resolve_path_against_base("docs/public", cwd.path()) + .expect("resolve docs/public"); + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: docs.clone() }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: docs_public.clone(), + }, + access: FileSystemAccessMode::Write, + }, + ]); + + assert!(!policy.has_full_disk_write_access()); + assert_eq!( + sorted_writable_roots(policy.get_writable_roots_with_cwd(cwd.path())), + vec![ + (cwd.path().to_path_buf(), vec![docs.to_path_buf()]), + (docs_public.to_path_buf(), Vec::new()), + ] + ); + } + + #[test] + fn legacy_workspace_write_nested_readable_root_stays_writable() { + let cwd = TempDir::new().expect("tempdir"); + let docs = + AbsolutePathBuf::resolve_path_against_base("docs", cwd.path()).expect("resolve docs"); + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: ReadOnlyAccess::Restricted { + include_platform_defaults: true, + readable_roots: vec![docs], + }, + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + + assert_eq!( + sorted_writable_roots( + FileSystemSandboxPolicy::from_legacy_sandbox_policy(&policy, cwd.path()) + .get_writable_roots_with_cwd(cwd.path()) + ), + vec![(cwd.path().to_path_buf(), Vec::new())] + ); + } + #[test] fn file_system_policy_rejects_legacy_bridge_for_non_workspace_writes() { let cwd = if cfg!(windows) { @@ -3508,6 +3665,8 @@ mod tests { .expect("resolve readable root"); let writable_root = AbsolutePathBuf::resolve_path_against_base("writable", cwd.path()) .expect("resolve writable root"); + let nested_readable_root = AbsolutePathBuf::resolve_path_against_base("docs", cwd.path()) + .expect("resolve nested readable root"); let policies = [ SandboxPolicy::DangerFullAccess, SandboxPolicy::ExternalSandbox { @@ -3544,10 +3703,20 @@ mod tests { exclude_tmpdir_env_var: false, exclude_slash_tmp: true, }, + SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: ReadOnlyAccess::Restricted { + include_platform_defaults: true, + readable_roots: vec![nested_readable_root], + }, + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }, ]; for expected in policies { - let actual = FileSystemSandboxPolicy::from(&expected) + let actual = FileSystemSandboxPolicy::from_legacy_sandbox_policy(&expected, cwd.path()) .to_legacy_sandbox_policy(NetworkSandboxPolicy::from(&expected), cwd.path()) .expect("legacy bridge should preserve legacy policy semantics"); diff --git a/codex-rs/protocol/src/request_permissions.rs b/codex-rs/protocol/src/request_permissions.rs new file mode 100644 index 0000000000..fc66e9b3db --- /dev/null +++ b/codex-rs/protocol/src/request_permissions.rs @@ -0,0 +1,40 @@ +use crate::models::PermissionProfile; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum PermissionGrantScope { + #[default] + Turn, + Session, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct RequestPermissionsArgs { + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, + pub permissions: PermissionProfile, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct RequestPermissionsResponse { + pub permissions: PermissionProfile, + #[serde(default)] + pub scope: PermissionGrantScope, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct RequestPermissionsEvent { + /// Responses API call id for the associated tool call, if available. + pub call_id: String, + /// Turn ID that this request belongs to. + /// Uses `#[serde(default)]` for backwards compatibility. + #[serde(default)] + pub turn_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, + pub permissions: PermissionProfile, +} diff --git a/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs b/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs index 05ba6089d0..284e1194c3 100644 --- a/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use std::io::ErrorKind; use std::net::SocketAddr; use std::sync::Arc; +use std::time::Duration; use axum::Router; use axum::body::Body; @@ -44,6 +45,7 @@ use serde::Deserialize; use serde_json::json; use tokio::sync::Mutex; use tokio::task; +use tokio::time::sleep; #[derive(Clone)] struct TestToolServer { @@ -275,15 +277,25 @@ fn parse_bind_addr() -> Result> { async fn main() -> Result<(), Box> { let bind_addr = parse_bind_addr()?; let session_failure_state = SessionFailureState::default(); - let listener = match tokio::net::TcpListener::bind(&bind_addr).await { - Ok(listener) => listener, - Err(err) if err.kind() == ErrorKind::PermissionDenied => { - eprintln!( - "failed to bind to {bind_addr}: {err}. make sure the process has network access" - ); - return Ok(()); + const MAX_BIND_RETRIES: u32 = 20; + const BIND_RETRY_DELAY: Duration = Duration::from_millis(50); + + let mut bind_retries = 0; + let listener = loop { + match tokio::net::TcpListener::bind(&bind_addr).await { + Ok(listener) => break listener, + Err(err) if err.kind() == ErrorKind::PermissionDenied => { + eprintln!( + "failed to bind to {bind_addr}: {err}. make sure the process has network access" + ); + return Ok(()); + } + Err(err) if err.kind() == ErrorKind::AddrInUse && bind_retries < MAX_BIND_RETRIES => { + bind_retries += 1; + sleep(BIND_RETRY_DELAY).await; + } + Err(err) => return Err(err.into()), } - Err(err) => return Err(err.into()), }; eprintln!("starting rmcp streamable http test server on http://{bind_addr}/mcp"); diff --git a/codex-rs/rmcp-client/tests/process_group_cleanup.rs b/codex-rs/rmcp-client/tests/process_group_cleanup.rs index 8ed9ba305a..bb033af875 100644 --- a/codex-rs/rmcp-client/tests/process_group_cleanup.rs +++ b/codex-rs/rmcp-client/tests/process_group_cleanup.rs @@ -24,8 +24,13 @@ async fn wait_for_pid_file(path: &Path) -> Result { for _ in 0..50 { match fs::read_to_string(path) { Ok(content) => { - let pid = content - .trim() + let trimmed = content.trim(); + if trimmed.is_empty() { + tokio::time::sleep(Duration::from_millis(100)).await; + continue; + } + + let pid = trimmed .parse::() .with_context(|| format!("failed to parse pid from {}", path.display()))?; return Ok(pid); diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index e649455da6..34d8d85a92 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1171,6 +1171,15 @@ impl App { )) } } + EventMsg::RequestPermissions(ev) => Some(ThreadInteractiveRequest::Approval( + ApprovalRequest::Permissions { + thread_id, + thread_label, + call_id: ev.call_id.clone(), + reason: ev.reason.clone(), + permissions: ev.permissions.clone(), + }, + )), _ => None, } } @@ -1694,6 +1703,10 @@ impl App { .enabled(codex_core::features::Feature::DefaultModeRequestUserInput), }, )); + // TODO(xl): Move into PluginManager once this no longer depends on config feature gating. + thread_manager + .plugins_manager() + .maybe_start_curated_repo_sync_for_config(&config); let mut model = thread_manager .get_models_manager() .get_default_model(&config.model, RefreshStrategy::Offline) @@ -3237,6 +3250,30 @@ impl App { "E X E C".to_string(), )); } + ApprovalRequest::Permissions { + permissions, + reason, + .. + } => { + let _ = tui.enter_alt_screen(); + let mut lines = Vec::new(); + if let Some(reason) = reason { + lines.push(Line::from(vec!["Reason: ".into(), reason.italic()])); + lines.push(Line::from("")); + } + if let Some(rule_line) = + crate::bottom_pane::format_additional_permissions_rule(&permissions) + { + lines.push(Line::from(vec![ + "Permission rule: ".into(), + rule_line.cyan(), + ])); + } + self.overlay = Some(Overlay::new_static_with_renderables( + vec![Box::new(Paragraph::new(lines).wrap(Wrap { trim: false }))], + "P E R M I S S I O N S".to_string(), + )); + } ApprovalRequest::McpElicitation { server_name, message, @@ -3915,6 +3952,7 @@ mod tests { proposed_execpolicy_amendment: None, proposed_network_policy_amendments: None, additional_permissions: None, + skill_metadata: None, available_decisions: None, parsed_cmd: Vec::new(), }, @@ -5078,6 +5116,7 @@ mod tests { proposed_execpolicy_amendment: None, proposed_network_policy_amendments: None, additional_permissions: None, + skill_metadata: None, available_decisions: None, parsed_cmd: Vec::new(), }, @@ -5169,6 +5208,7 @@ mod tests { proposed_execpolicy_amendment: None, proposed_network_policy_amendments: None, additional_permissions: None, + skill_metadata: None, available_decisions: None, parsed_cmd: Vec::new(), }, diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 8ed6d2653a..02efb2ec1b 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -39,6 +39,8 @@ pub(super) struct PendingInteractiveReplayState { patch_approval_call_ids: HashSet, patch_approval_call_ids_by_turn_id: HashMap>, elicitation_requests: HashSet, + request_permissions_call_ids: HashSet, + request_permissions_call_ids_by_turn_id: HashMap>, request_user_input_call_ids: HashSet, request_user_input_call_ids_by_turn_id: HashMap>, } @@ -50,6 +52,7 @@ impl PendingInteractiveReplayState { EventMsg::ExecApprovalRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) | EventMsg::ElicitationRequest(_) + | EventMsg::RequestPermissions(_) | EventMsg::ExecCommandBegin(_) | EventMsg::PatchApplyBegin(_) | EventMsg::TurnComplete(_) @@ -64,6 +67,7 @@ impl PendingInteractiveReplayState { Op::ExecApproval { .. } | Op::PatchApproval { .. } | Op::ResolveElicitation { .. } + | Op::RequestPermissionsResponse { .. } | Op::UserInputAnswer { .. } | Op::Shutdown ) @@ -99,6 +103,13 @@ impl PendingInteractiveReplayState { request_id.clone(), )); } + Op::RequestPermissionsResponse { id, .. } => { + self.request_permissions_call_ids.remove(id); + Self::remove_call_id_from_turn_map( + &mut self.request_permissions_call_ids_by_turn_id, + id, + ); + } // `Op::UserInputAnswer` identifies the turn, not the prompt call_id. The UI // answers queued prompts for the same turn in FIFO order, so remove the oldest // queued call_id for that turn. @@ -166,17 +177,26 @@ impl PendingInteractiveReplayState { .or_default() .push(ev.call_id.clone()); } + EventMsg::RequestPermissions(ev) => { + self.request_permissions_call_ids.insert(ev.call_id.clone()); + self.request_permissions_call_ids_by_turn_id + .entry(ev.turn_id.clone()) + .or_default() + .push(ev.call_id.clone()); + } // A turn ending (normally or aborted/replaced) invalidates any unresolved - // turn-scoped approvals and request_user_input prompts from that turn. + // turn-scoped approvals, permission prompts, and request_user_input prompts. EventMsg::TurnComplete(ev) => { self.clear_exec_approval_turn(&ev.turn_id); self.clear_patch_approval_turn(&ev.turn_id); + self.clear_request_permissions_turn(&ev.turn_id); self.clear_request_user_input_turn(&ev.turn_id); } EventMsg::TurnAborted(ev) => { if let Some(turn_id) = &ev.turn_id { self.clear_exec_approval_turn(turn_id); self.clear_patch_approval_turn(turn_id); + self.clear_request_permissions_turn(turn_id); self.clear_request_user_input_turn(turn_id); } } @@ -228,6 +248,23 @@ impl PendingInteractiveReplayState { .remove(&ev.turn_id); } } + EventMsg::RequestPermissions(ev) => { + self.request_permissions_call_ids.remove(&ev.call_id); + let mut remove_turn_entry = false; + if let Some(call_ids) = self + .request_permissions_call_ids_by_turn_id + .get_mut(&ev.turn_id) + { + call_ids.retain(|call_id| call_id != &ev.call_id); + if call_ids.is_empty() { + remove_turn_entry = true; + } + } + if remove_turn_entry { + self.request_permissions_call_ids_by_turn_id + .remove(&ev.turn_id); + } + } _ => {} } } @@ -250,6 +287,9 @@ impl PendingInteractiveReplayState { EventMsg::RequestUserInput(ev) => { self.request_user_input_call_ids.contains(&ev.call_id) } + EventMsg::RequestPermissions(ev) => { + self.request_permissions_call_ids.contains(&ev.call_id) + } _ => true, } } @@ -258,6 +298,7 @@ impl PendingInteractiveReplayState { !self.exec_approval_call_ids.is_empty() || !self.patch_approval_call_ids.is_empty() || !self.elicitation_requests.is_empty() + || !self.request_permissions_call_ids.is_empty() } fn clear_request_user_input_turn(&mut self, turn_id: &str) { @@ -268,6 +309,14 @@ impl PendingInteractiveReplayState { } } + fn clear_request_permissions_turn(&mut self, turn_id: &str) { + if let Some(call_ids) = self.request_permissions_call_ids_by_turn_id.remove(turn_id) { + for call_id in call_ids { + self.request_permissions_call_ids.remove(&call_id); + } + } + } + fn clear_exec_approval_turn(&mut self, turn_id: &str) { if let Some(call_ids) = self.exec_approval_call_ids_by_turn_id.remove(turn_id) { for call_id in call_ids { @@ -317,6 +366,8 @@ impl PendingInteractiveReplayState { self.patch_approval_call_ids.clear(); self.patch_approval_call_ids_by_turn_id.clear(); self.elicitation_requests.clear(); + self.request_permissions_call_ids.clear(); + self.request_permissions_call_ids_by_turn_id.clear(); self.request_user_input_call_ids.clear(); self.request_user_input_call_ids_by_turn_id.clear(); } @@ -402,6 +453,7 @@ mod tests { proposed_execpolicy_amendment: None, proposed_network_policy_amendments: None, additional_permissions: None, + skill_metadata: None, available_decisions: None, parsed_cmd: Vec::new(), }, @@ -545,6 +597,7 @@ mod tests { proposed_execpolicy_amendment: None, proposed_network_policy_amendments: None, additional_permissions: None, + skill_metadata: None, available_decisions: None, parsed_cmd: Vec::new(), }, @@ -634,6 +687,7 @@ mod tests { proposed_execpolicy_amendment: None, proposed_network_policy_amendments: None, additional_permissions: None, + skill_metadata: None, available_decisions: None, parsed_cmd: Vec::new(), }, diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 55fc2ed037..a13252939d 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -28,6 +28,7 @@ use codex_protocol::protocol::NetworkApprovalContext; use codex_protocol::protocol::NetworkPolicyRuleAction; use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; +use codex_protocol::request_permissions::PermissionGrantScope; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -53,6 +54,13 @@ pub(crate) enum ApprovalRequest { network_approval_context: Option, additional_permissions: Option, }, + Permissions { + thread_id: ThreadId, + thread_label: Option, + call_id: String, + reason: Option, + permissions: PermissionProfile, + }, ApplyPatch { thread_id: ThreadId, thread_label: Option, @@ -74,6 +82,7 @@ impl ApprovalRequest { fn thread_id(&self) -> ThreadId { match self { ApprovalRequest::Exec { thread_id, .. } + | ApprovalRequest::Permissions { thread_id, .. } | ApprovalRequest::ApplyPatch { thread_id, .. } | ApprovalRequest::McpElicitation { thread_id, .. } => *thread_id, } @@ -82,6 +91,7 @@ impl ApprovalRequest { fn thread_label(&self) -> Option<&str> { match self { ApprovalRequest::Exec { thread_label, .. } + | ApprovalRequest::Permissions { thread_label, .. } | ApprovalRequest::ApplyPatch { thread_label, .. } | ApprovalRequest::McpElicitation { thread_label, .. } => thread_label.as_deref(), } @@ -156,6 +166,10 @@ impl ApprovalOverlay { }, ), ), + ApprovalRequest::Permissions { .. } => ( + permissions_options(), + "Would you like to grant these permissions?".to_string(), + ), ApprovalRequest::ApplyPatch { .. } => ( patch_options(), "Would you like to make the following edits?".to_string(), @@ -206,6 +220,14 @@ impl ApprovalOverlay { (ApprovalRequest::Exec { id, command, .. }, ApprovalDecision::Review(decision)) => { self.handle_exec_decision(id, command, decision.clone()); } + ( + ApprovalRequest::Permissions { + call_id, + permissions, + .. + }, + ApprovalDecision::Review(decision), + ) => self.handle_permissions_decision(call_id, permissions, decision.clone()), (ApprovalRequest::ApplyPatch { id, .. }, ApprovalDecision::Review(decision)) => { self.handle_patch_decision(id, decision.clone()); } @@ -246,6 +268,51 @@ impl ApprovalOverlay { }); } + fn handle_permissions_decision( + &self, + call_id: &str, + permissions: &PermissionProfile, + decision: ReviewDecision, + ) { + let Some(request) = self.current_request.as_ref() else { + return; + }; + let granted_permissions = match decision { + ReviewDecision::Approved | ReviewDecision::ApprovedForSession => permissions.clone(), + ReviewDecision::Denied | ReviewDecision::Abort => Default::default(), + ReviewDecision::ApprovedExecpolicyAmendment { .. } + | ReviewDecision::NetworkPolicyAmendment { .. } => Default::default(), + }; + let scope = if matches!(decision, ReviewDecision::ApprovedForSession) { + PermissionGrantScope::Session + } else { + PermissionGrantScope::Turn + }; + if request.thread_label().is_none() { + let message = if granted_permissions.is_empty() { + "You did not grant additional permissions" + } else if matches!(scope, PermissionGrantScope::Session) { + "You granted additional permissions for this session" + } else { + "You granted additional permissions" + }; + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + crate::history_cell::PlainHistoryCell::new(vec![message.into()]), + ))); + } + let thread_id = request.thread_id(); + self.app_event_tx.send(AppEvent::SubmitThreadOp { + thread_id, + op: Op::RequestPermissionsResponse { + id: call_id.to_string(), + response: codex_protocol::request_permissions::RequestPermissionsResponse { + permissions: granted_permissions, + scope, + }, + }, + }); + } + fn handle_patch_decision(&self, id: &str, decision: ReviewDecision) { let Some(thread_id) = self .current_request @@ -367,6 +434,13 @@ impl BottomPaneView for ApprovalOverlay { ApprovalRequest::Exec { id, command, .. } => { self.handle_exec_decision(id, command, ReviewDecision::Abort); } + ApprovalRequest::Permissions { + call_id, + permissions, + .. + } => { + self.handle_permissions_decision(call_id, permissions, ReviewDecision::Abort); + } ApprovalRequest::ApplyPatch { id, .. } => { self.handle_patch_decision(id, ReviewDecision::Abort); } @@ -474,6 +548,32 @@ fn build_header(request: &ApprovalRequest) -> Box { } Box::new(Paragraph::new(header).wrap(Wrap { trim: false })) } + ApprovalRequest::Permissions { + thread_label, + reason, + permissions, + .. + } => { + let mut header: Vec> = Vec::new(); + if let Some(thread_label) = thread_label { + header.push(Line::from(vec![ + "Thread: ".into(), + thread_label.clone().bold(), + ])); + header.push(Line::from("")); + } + if let Some(reason) = reason { + header.push(Line::from(vec!["Reason: ".into(), reason.clone().italic()])); + header.push(Line::from("")); + } + if let Some(rule_line) = format_additional_permissions_rule(permissions) { + header.push(Line::from(vec![ + "Permission rule: ".into(), + rule_line.cyan(), + ])); + } + Box::new(Paragraph::new(header).wrap(Wrap { trim: false })) + } ApprovalRequest::ApplyPatch { thread_label, reason, @@ -641,7 +741,7 @@ fn exec_options( .collect() } -fn format_additional_permissions_rule( +pub(crate) fn format_additional_permissions_rule( additional_permissions: &PermissionProfile, ) -> Option { let mut parts = Vec::new(); @@ -732,6 +832,29 @@ fn patch_options() -> Vec { ] } +fn permissions_options() -> Vec { + vec![ + ApprovalOption { + label: "Yes, grant these permissions".to_string(), + decision: ApprovalDecision::Review(ReviewDecision::Approved), + display_shortcut: None, + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], + }, + ApprovalOption { + label: "Yes, grant these permissions for this session".to_string(), + decision: ApprovalDecision::Review(ReviewDecision::ApprovedForSession), + display_shortcut: None, + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))], + }, + ApprovalOption { + label: "No, continue without permissions".to_string(), + decision: ApprovalDecision::Review(ReviewDecision::Denied), + display_shortcut: None, + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], + }, + ] +} + fn elicitation_options() -> Vec { vec![ ApprovalOption { @@ -816,6 +939,25 @@ mod tests { } } + fn make_permissions_request() -> ApprovalRequest { + ApprovalRequest::Permissions { + thread_id: ThreadId::new(), + thread_label: None, + call_id: "test".to_string(), + reason: Some("need workspace access".to_string()), + permissions: PermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(true), + }), + file_system: Some(FileSystemPermissions { + read: Some(vec![absolute_path("/tmp/readme.txt")]), + write: Some(vec![absolute_path("/tmp/out.txt")]), + }), + ..Default::default() + }, + } + } + #[test] fn ctrl_c_aborts_and_clears_queue() { let (tx, _rx) = unbounded_channel::(); @@ -1107,6 +1249,49 @@ mod tests { ); } + #[test] + fn permissions_options_use_expected_labels() { + let labels: Vec = permissions_options() + .into_iter() + .map(|option| option.label) + .collect(); + assert_eq!( + labels, + vec![ + "Yes, grant these permissions".to_string(), + "Yes, grant these permissions for this session".to_string(), + "No, continue without permissions".to_string(), + ] + ); + } + + #[test] + fn permissions_session_shortcut_submits_session_scope() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let mut view = + ApprovalOverlay::new(make_permissions_request(), tx, Features::with_defaults()); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); + + let mut saw_op = false; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::SubmitThreadOp { + op: Op::RequestPermissionsResponse { response, .. }, + .. + } = ev + { + assert_eq!(response.scope, PermissionGrantScope::Session); + saw_op = true; + break; + } + } + assert!( + saw_op, + "expected permission approval decision to emit a session-scoped response" + ); + } + #[test] fn additional_permissions_prompt_shows_permission_rule_line() { let (tx, _rx) = unbounded_channel::(); @@ -1186,6 +1371,17 @@ mod tests { ); } + #[test] + fn permissions_prompt_snapshot() { + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let view = ApprovalOverlay::new(make_permissions_request(), tx, Features::with_defaults()); + assert_snapshot!( + "approval_overlay_permissions_prompt", + normalize_snapshot_paths(render_overlay_lines(&view, 120)) + ); + } + #[test] fn additional_permissions_macos_prompt_snapshot() { let (tx, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 62142ed227..5a37899b00 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -51,6 +51,7 @@ pub(crate) use app_link_view::AppLinkView; pub(crate) use app_link_view::AppLinkViewParams; pub(crate) use approval_overlay::ApprovalOverlay; pub(crate) use approval_overlay::ApprovalRequest; +pub(crate) use approval_overlay::format_additional_permissions_rule; pub(crate) use mcp_server_elicitation::McpServerElicitationFormRequest; pub(crate) use mcp_server_elicitation::McpServerElicitationOverlay; pub(crate) use request_user_input::RequestUserInputOverlay; diff --git a/codex-rs/tui/src/bottom_pane/pending_input_preview.rs b/codex-rs/tui/src/bottom_pane/pending_input_preview.rs index c52b16c144..315e311e02 100644 --- a/codex-rs/tui/src/bottom_pane/pending_input_preview.rs +++ b/codex-rs/tui/src/bottom_pane/pending_input_preview.rs @@ -12,11 +12,13 @@ use crate::wrapping::adaptive_wrap_lines; /// Widget that displays pending steers plus user messages queued while a turn is in progress. /// -/// The widget shows pending steers first, then queued user messages. It only -/// shows the edit hint at the bottom (e.g. "⌥ + ↑ edit") when there are actual -/// queued user messages to pop back into the composer. Because some terminals -/// intercept certain modifier-key combinations, the displayed binding is -/// configurable via [`set_edit_binding`](Self::set_edit_binding). +/// The widget renders pending steers first, then queued user messages, as two +/// labeled sections. Pending steers explain that they will be submitted after +/// the next tool/result boundary unless the user presses Esc to interrupt and +/// send them immediately. The edit hint at the bottom only appears when there +/// are actual queued user messages to pop back into the composer. Because some +/// terminals intercept certain modifier-key combinations, the displayed +/// binding is configurable via [`set_edit_binding`](Self::set_edit_binding). pub(crate) struct PendingInputPreview { pub pending_steers: Vec, pub queued_messages: Vec, @@ -55,6 +57,15 @@ impl PendingInputPreview { } } + fn push_section_header(lines: &mut Vec>, width: u16, header: Line<'static>) { + let mut spans = vec!["• ".dim()]; + spans.extend(header.spans); + lines.extend(adaptive_wrap_lines( + std::iter::once(Line::from(spans)), + RtOptions::new(width as usize).subsequent_indent(Line::from(" ".dim())), + )); + } + fn as_renderable(&self, width: u16) -> Box { if (self.pending_steers.is_empty() && self.queued_messages.is_empty()) || width < 4 { return Box::new(()); @@ -62,28 +73,48 @@ impl PendingInputPreview { let mut lines = vec![]; - for steer in &self.pending_steers { - let wrapped = adaptive_wrap_lines( - steer.lines().map(|line| Line::from(line.dim())), - RtOptions::new(width as usize) - .initial_indent(Line::from(" ! pending steer: ".dim())) - .subsequent_indent(Line::from(" ")), + if !self.pending_steers.is_empty() { + Self::push_section_header( + &mut lines, + width, + Line::from(vec![ + "Messages to be submitted after next tool call".into(), + " (press ".dim(), + key_hint::plain(KeyCode::Esc).into(), + " to interrupt and send immediately)".dim(), + ]), ); - Self::push_truncated_preview_lines(&mut lines, wrapped, Line::from(" …".dim())); + + for steer in &self.pending_steers { + let wrapped = adaptive_wrap_lines( + steer.lines().map(|line| Line::from(line.dim())), + RtOptions::new(width as usize) + .initial_indent(Line::from(" ↳ ".dim())) + .subsequent_indent(Line::from(" ")), + ); + Self::push_truncated_preview_lines(&mut lines, wrapped, Line::from(" …".dim())); + } } - for message in &self.queued_messages { - let wrapped = adaptive_wrap_lines( - message.lines().map(|line| Line::from(line.dim().italic())), - RtOptions::new(width as usize) - .initial_indent(Line::from(" ↳ ".dim())) - .subsequent_indent(Line::from(" ")), - ); - Self::push_truncated_preview_lines( - &mut lines, - wrapped, - Line::from(" …".dim().italic()), - ); + if !self.queued_messages.is_empty() { + if !lines.is_empty() { + lines.push(Line::from("")); + } + Self::push_section_header(&mut lines, width, "Queued follow-up messages".into()); + + for message in &self.queued_messages { + let wrapped = adaptive_wrap_lines( + message.lines().map(|line| Line::from(line.dim().italic())), + RtOptions::new(width as usize) + .initial_indent(Line::from(" ↳ ".dim())) + .subsequent_indent(Line::from(" ")), + ); + Self::push_truncated_preview_lines( + &mut lines, + wrapped, + Line::from(" …".dim().italic()), + ); + } } if !self.queued_messages.is_empty() { @@ -91,7 +122,7 @@ impl PendingInputPreview { Line::from(vec![ " ".into(), self.edit_binding.into(), - " edit".into(), + " edit last queued message".into(), ]) .dim(), ); @@ -131,7 +162,7 @@ mod tests { fn desired_height_one_message() { let mut queue = PendingInputPreview::new(); queue.queued_messages.push("Hello, world!".to_string()); - assert_eq!(queue.desired_height(40), 2); + assert_eq!(queue.desired_height(40), 3); } #[test] @@ -219,8 +250,8 @@ mod tests { let width = 36; let height = queue.desired_height(width); assert_eq!( - height, 2, - "expected one message row plus hint row for URL-like token" + height, 3, + "expected header, one message row, and hint row for URL-like token" ); let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_permissions_prompt.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_permissions_prompt.snap new file mode 100644 index 0000000000..e7a21c42cc --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_permissions_prompt.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/bottom_pane/approval_overlay.rs +expression: "normalize_snapshot_paths(render_overlay_lines(&view, 120))" +--- + + Would you like to grant these permissions? + + Reason: need workspace access + + Permission rule: network; read `/tmp/readme.txt`; write `/tmp/out.txt` + +› 1. Yes, grant these permissions (y) + 2. Yes, grant these permissions for this session (a) + 3. No, continue without permissions (n) + + Press enter to confirm or esc to cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_many_line_message.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_many_line_message.snap index d2afbf7dbd..4af8aa4d76 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_many_line_message.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_many_line_message.snap @@ -1,27 +1,30 @@ --- -source: tui/src/bottom_pane/queued_user_messages.rs +source: tui/src/bottom_pane/pending_input_preview.rs expression: "format!(\"{buf:?}\")" --- Buffer { - area: Rect { x: 0, y: 0, width: 40, height: 5 }, + area: Rect { x: 0, y: 0, width: 40, height: 6 }, content: [ + "• Queued follow-up messages ", " ↳ This is ", " a message ", " with many ", " … ", - " ⌥ + ↑ edit ", + " ⌥ + ↑ edit last queued message ", ], styles: [ x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 11, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 13, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 11, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, x: 13, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 5, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 14, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 13, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 5, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 34, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, ] } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_more_than_three_messages.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_more_than_three_messages.snap index 9d7527d16f..6a5312f602 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_more_than_three_messages.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_more_than_three_messages.snap @@ -1,30 +1,33 @@ --- -source: tui/src/bottom_pane/queued_user_messages.rs +source: tui/src/bottom_pane/pending_input_preview.rs expression: "format!(\"{buf:?}\")" --- Buffer { - area: Rect { x: 0, y: 0, width: 40, height: 5 }, + area: Rect { x: 0, y: 0, width: 40, height: 6 }, content: [ + "• Queued follow-up messages ", " ↳ Hello, world! ", " ↳ This is another message ", " ↳ This is a third message ", " ↳ This is a fourth message ", - " ⌥ + ↑ edit ", + " ⌥ + ↑ edit last queued message ", ], styles: [ x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 27, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 17, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, x: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, x: 4, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 28, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 27, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 14, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 28, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 34, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, ] } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_multiline_pending_steer_uses_single_prefix_and_truncates.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_multiline_pending_steer_uses_single_prefix_and_truncates.snap index eaf9edfc69..be89f767a8 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_multiline_pending_steer_uses_single_prefix_and_truncates.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_multiline_pending_steer_uses_single_prefix_and_truncates.snap @@ -3,21 +3,27 @@ source: tui/src/bottom_pane/pending_input_preview.rs expression: "format!(\"{buf:?}\")" --- Buffer { - area: Rect { x: 0, y: 0, width: 48, height: 4 }, + area: Rect { x: 0, y: 0, width: 48, height: 6 }, content: [ - " ! pending steer: First line ", + "• Messages to be submitted after next tool call ", + " (press esc to interrupt and send immediately) ", + " ↳ First line ", " Second line ", " Third line ", " … ", ], styles: [ x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 29, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 15, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 47, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, x: 14, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 5, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 15, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 14, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 5, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, ] } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_message.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_message.snap index d47fa97863..9816a4dc85 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_message.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_message.snap @@ -1,18 +1,21 @@ --- -source: tui/src/bottom_pane/queued_user_messages.rs +source: tui/src/bottom_pane/pending_input_preview.rs expression: "format!(\"{buf:?}\")" --- Buffer { - area: Rect { x: 0, y: 0, width: 40, height: 2 }, + area: Rect { x: 0, y: 0, width: 40, height: 3 }, content: [ + "• Queued follow-up messages ", " ↳ Hello, world! ", - " ⌥ + ↑ edit ", + " ⌥ + ↑ edit last queued message ", ], styles: [ x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 17, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 34, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, ] } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_pending_steer.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_pending_steer.snap index d738b7dc09..9a8e3b96d1 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_pending_steer.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_pending_steer.snap @@ -1,15 +1,20 @@ --- source: tui/src/bottom_pane/pending_input_preview.rs -assertion_line: 237 expression: "format!(\"{buf:?}\")" --- Buffer { - area: Rect { x: 0, y: 0, width: 48, height: 1 }, + area: Rect { x: 0, y: 0, width: 48, height: 3 }, content: [ - " ! pending steer: Please continue. ", + "• Messages to be submitted after next tool call ", + " (press esc to interrupt and send immediately) ", + " ↳ Please continue. ", ], styles: [ x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 35, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 47, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 20, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, ] } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap index 6979d507df..12744049fa 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap @@ -1,25 +1,34 @@ --- source: tui/src/bottom_pane/pending_input_preview.rs -assertion_line: 252 expression: "format!(\"{buf:?}\")" --- Buffer { - area: Rect { x: 0, y: 0, width: 52, height: 4 }, + area: Rect { x: 0, y: 0, width: 52, height: 8 }, content: [ - " ! pending steer: Please continue. ", - " ! pending steer: Check the last command output. ", + "• Messages to be submitted after next tool call ", + " (press esc to interrupt and send immediately) ", + " ↳ Please continue. ", + " ↳ Check the last command output. ", + " ", + "• Queued follow-up messages ", " ↳ Queued follow-up question ", - " ⌥ + ↑ edit ", + " ⌥ + ↑ edit last queued message ", ], styles: [ x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 35, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 49, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 47, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 29, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 20, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 14, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 34, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 29, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 34, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, ] } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_two_messages.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_two_messages.snap index 1f020fec64..51e600c7fc 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_two_messages.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_two_messages.snap @@ -1,22 +1,25 @@ --- -source: tui/src/bottom_pane/queued_user_messages.rs +source: tui/src/bottom_pane/pending_input_preview.rs expression: "format!(\"{buf:?}\")" --- Buffer { - area: Rect { x: 0, y: 0, width: 40, height: 3 }, + area: Rect { x: 0, y: 0, width: 40, height: 4 }, content: [ + "• Queued follow-up messages ", " ↳ Hello, world! ", " ↳ This is another message ", - " ⌥ + ↑ edit ", + " ⌥ + ↑ edit last queued message ", ], styles: [ x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 27, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 17, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 14, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 34, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, ] } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_wrapped_message.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_wrapped_message.snap index 4f2917a6c4..54b78f0823 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_wrapped_message.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_wrapped_message.snap @@ -1,25 +1,28 @@ --- -source: tui/src/bottom_pane/queued_user_messages.rs +source: tui/src/bottom_pane/pending_input_preview.rs expression: "format!(\"{buf:?}\")" --- Buffer { - area: Rect { x: 0, y: 0, width: 40, height: 4 }, + area: Rect { x: 0, y: 0, width: 40, height: 5 }, content: [ + "• Queued follow-up messages ", " ↳ This is a longer message that should", " be wrapped ", " ↳ This is another message ", - " ⌥ + ↑ edit ", + " ⌥ + ↑ edit last queued message ", ], styles: [ x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 14, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 14, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 27, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 34, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, ] } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap index 47581631c2..de6d21ee7e 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap @@ -2,8 +2,9 @@ source: tui/src/bottom_pane/mod.rs expression: "render_snapshot(&pane, area)" --- +• Queued follow-up messages ↳ Queued follow-up question - ⌥ + ↑ edit + ⌥ + ↑ edit last queued message › Ask Codex to do anything diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap index a82f6512cf..350cbfe27d 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap @@ -4,8 +4,9 @@ expression: "render_snapshot(&pane, area)" --- • Working (0s • esc to interrupt) +• Queued follow-up messages ↳ Queued follow-up question - ⌥ + ↑ edit + ⌥ + ↑ edit last queued message › Ask Codex to do anything diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_with_details_and_queued_messages_snapshot.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_with_details_and_queued_messages_snapshot.snap index b714c69d88..65e21260dd 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_with_details_and_queued_messages_snapshot.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_with_details_and_queued_messages_snapshot.snap @@ -6,8 +6,9 @@ expression: "render_snapshot(&pane, area)" └ First detail line Second detail line +• Queued follow-up messages ↳ Queued follow-up question - ⌥ + ↑ edit + ⌥ + ↑ edit last queued message › Ask Codex to do anything diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 9e9d750b4a..aff0a9f410 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -142,6 +142,7 @@ use codex_protocol::protocol::ViewImageToolCallEvent; use codex_protocol::protocol::WarningEvent; use codex_protocol::protocol::WebSearchBeginEvent; use codex_protocol::protocol::WebSearchEndEvent; +use codex_protocol::request_permissions::RequestPermissionsEvent; use codex_protocol::request_user_input::RequestUserInputEvent; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; @@ -630,6 +631,9 @@ pub(crate) struct ChatWidget { // The bottom pane shows these above queued drafts until core records the // corresponding user message item. pending_steers: VecDeque, + // When set, the next interrupt should resubmit all pending steers as one + // fresh user turn instead of restoring them into the composer. + submit_pending_steers_after_interrupt: bool, /// Terminal-appropriate keybinding for popping the most-recently queued /// message back into the composer. Determined once at construction time via /// [`queued_message_edit_binding_for_terminal`] and propagated to @@ -922,6 +926,45 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) } } +fn merge_user_messages(messages: Vec) -> UserMessage { + let mut combined = UserMessage { + text: String::new(), + text_elements: Vec::new(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + mention_bindings: Vec::new(), + }; + let total_remote_images = messages + .iter() + .map(|message| message.remote_image_urls.len()) + .sum::(); + let mut next_image_label = total_remote_images + 1; + + for (idx, message) in messages.into_iter().enumerate() { + if idx > 0 { + combined.text.push('\n'); + } + let UserMessage { + text, + text_elements, + local_images, + remote_image_urls, + mention_bindings, + } = remap_placeholders_for_message(message, &mut next_image_label); + append_text_with_rebased_elements( + &mut combined.text, + &mut combined.text_elements, + &text, + text_elements, + ); + combined.local_images.extend(local_images); + combined.remote_image_urls.extend(remote_image_urls); + combined.mention_bindings.extend(mention_bindings); + } + + combined +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum ReplayKind { ResumeInitialMessages, @@ -1521,6 +1564,7 @@ impl ChatWidget { } fn on_task_complete(&mut self, last_agent_message: Option, from_replay: bool) { + self.submit_pending_steers_after_interrupt = false; if let Some(message) = last_agent_message.as_ref() && !message.trim().is_empty() { @@ -1874,6 +1918,7 @@ impl ChatWidget { } fn on_server_overloaded_error(&mut self, message: String) { + self.submit_pending_steers_after_interrupt = false; self.finalize_turn(); let message = if message.trim().is_empty() { @@ -1888,6 +1933,7 @@ impl ChatWidget { } fn on_error(&mut self, message: String) { + self.submit_pending_steers_after_interrupt = false; self.finalize_turn(); self.add_to_history(history_cell::new_error_event(message)); self.request_redraw(); @@ -1978,15 +2024,35 @@ impl ChatWidget { if reason == TurnAbortReason::Interrupted { self.clear_unified_exec_processes(); } + let send_pending_steers_immediately = self.submit_pending_steers_after_interrupt; + self.submit_pending_steers_after_interrupt = false; if reason != TurnAbortReason::ReviewEnded { - self.add_to_history(history_cell::new_error_event( - "Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue.".to_owned(), - )); + if send_pending_steers_immediately { + self.add_to_history(history_cell::new_info_event( + "Model interrupted to submit steer instructions.".to_owned(), + None, + )); + } else { + self.add_to_history(history_cell::new_error_event( + "Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue.".to_owned(), + )); + } } // Core clears pending_input before emitting TurnAborted, so any unacknowledged steers // still tracked here must be restored locally instead of waiting for a later commit. - if let Some(combined) = self.drain_pending_messages_for_restore() { + if send_pending_steers_immediately { + let pending_steers: Vec = self + .pending_steers + .drain(..) + .map(|pending| pending.user_message) + .collect(); + if !pending_steers.is_empty() { + self.submit_user_message(merge_user_messages(pending_steers)); + } else if let Some(combined) = self.drain_pending_messages_for_restore() { + self.restore_user_message_to_composer(combined); + } + } else if let Some(combined) = self.drain_pending_messages_for_restore() { self.restore_user_message_to_composer(combined); } self.refresh_pending_input_preview(); @@ -2027,42 +2093,7 @@ impl ChatWidget { to_merge.push(existing_message); } - let mut combined = UserMessage { - text: String::new(), - text_elements: Vec::new(), - local_images: Vec::new(), - remote_image_urls: Vec::new(), - mention_bindings: Vec::new(), - }; - let total_remote_images = to_merge - .iter() - .map(|message| message.remote_image_urls.len()) - .sum::(); - let mut next_image_label = total_remote_images + 1; - - for (idx, message) in to_merge.into_iter().enumerate() { - if idx > 0 { - combined.text.push('\n'); - } - let UserMessage { - text, - text_elements, - local_images, - remote_image_urls, - mention_bindings, - } = remap_placeholders_for_message(message, &mut next_image_label); - append_text_with_rebased_elements( - &mut combined.text, - &mut combined.text_elements, - &text, - text_elements, - ); - combined.local_images.extend(local_images); - combined.remote_image_urls.extend(remote_image_urls); - combined.mention_bindings.extend(mention_bindings); - } - - Some(combined) + Some(merge_user_messages(to_merge)) } fn restore_user_message_to_composer(&mut self, user_message: UserMessage) { @@ -2203,6 +2234,14 @@ impl ChatWidget { ); } + fn on_request_permissions(&mut self, ev: RequestPermissionsEvent) { + let ev2 = ev.clone(); + self.defer_or_handle( + |q| q.push_request_permissions(ev), + |s| s.handle_request_permissions_now(ev2), + ); + } + fn on_exec_command_begin(&mut self, ev: ExecCommandBeginEvent) { self.flush_answer_stream_with_separator(); if is_unified_exec_source(ev.source) { @@ -2314,10 +2353,15 @@ impl ChatWidget { fn on_image_generation_end(&mut self, event: ImageGenerationEndEvent) { self.flush_answer_stream_with_separator(); + let saved_to = event.saved_path.as_deref().and_then(|saved_path| { + std::path::Path::new(saved_path) + .parent() + .map(|parent| parent.display().to_string()) + }); self.add_to_history(history_cell::new_image_generation_call( event.call_id, - event.status, event.revised_prompt, + saved_to, )); self.request_redraw(); } @@ -2938,6 +2982,20 @@ impl ChatWidget { self.request_redraw(); } + pub(crate) fn handle_request_permissions_now(&mut self, ev: RequestPermissionsEvent) { + self.flush_answer_stream_with_separator(); + let request = ApprovalRequest::Permissions { + thread_id: self.thread_id.unwrap_or_default(), + thread_label: None, + call_id: ev.call_id, + reason: ev.reason, + permissions: ev.permissions, + }; + self.bottom_pane + .push_approval_request(request, &self.config.features); + self.request_redraw(); + } + pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) { // Ensure the status indicator is visible while the command runs. self.bottom_pane.ensure_status_indicator(); @@ -3161,6 +3219,7 @@ impl ChatWidget { forked_from: None, queued_user_messages: VecDeque::new(), pending_steers: VecDeque::new(), + submit_pending_steers_after_interrupt: false, queued_message_edit_binding, show_welcome_banner: is_first_run, startup_tooltip_override, @@ -3347,6 +3406,7 @@ impl ChatWidget { plan_item_active: false, queued_user_messages: VecDeque::new(), pending_steers: VecDeque::new(), + submit_pending_steers_after_interrupt: false, queued_message_edit_binding, show_welcome_banner: is_first_run, startup_tooltip_override, @@ -3517,6 +3577,7 @@ impl ChatWidget { forked_from: None, queued_user_messages: VecDeque::new(), pending_steers: VecDeque::new(), + submit_pending_steers_after_interrupt: false, queued_message_edit_binding, show_welcome_banner: false, startup_tooltip_override: None, @@ -3656,6 +3717,19 @@ impl ChatWidget { return; } + if matches!(key_event.code, KeyCode::Esc) + && matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) + && !self.pending_steers.is_empty() + && self.bottom_pane.is_task_running() + && self.bottom_pane.no_modal_or_popup_active() + { + self.submit_pending_steers_after_interrupt = true; + if !self.submit_op(Op::Interrupt) { + self.submit_pending_steers_after_interrupt = false; + } + return; + } + match key_event { KeyEvent { code: KeyCode::BackTab, @@ -4798,6 +4872,7 @@ impl ChatWidget { self.on_interrupted_turn(ev.reason); } TurnAbortReason::Replaced => { + self.submit_pending_steers_after_interrupt = false; self.pending_steers.clear(); self.refresh_pending_input_preview(); self.on_error("Turn aborted: replaced by a new task".to_owned()) @@ -4820,6 +4895,9 @@ impl ChatWidget { EventMsg::RequestUserInput(ev) => { self.on_request_user_input(ev); } + EventMsg::RequestPermissions(ev) => { + self.on_request_permissions(ev); + } EventMsg::ExecCommandBegin(ev) => self.on_exec_command_begin(ev), EventMsg::TerminalInteraction(delta) => self.on_terminal_interaction(delta), EventMsg::ExecCommandOutputDelta(delta) => self.on_exec_command_output_delta(delta), diff --git a/codex-rs/tui/src/chatwidget/interrupts.rs b/codex-rs/tui/src/chatwidget/interrupts.rs index 8216398448..0a3fc80016 100644 --- a/codex-rs/tui/src/chatwidget/interrupts.rs +++ b/codex-rs/tui/src/chatwidget/interrupts.rs @@ -8,6 +8,7 @@ use codex_protocol::protocol::ExecCommandEndEvent; use codex_protocol::protocol::McpToolCallBeginEvent; use codex_protocol::protocol::McpToolCallEndEvent; use codex_protocol::protocol::PatchApplyEndEvent; +use codex_protocol::request_permissions::RequestPermissionsEvent; use codex_protocol::request_user_input::RequestUserInputEvent; use super::ChatWidget; @@ -17,6 +18,7 @@ pub(crate) enum QueuedInterrupt { ExecApproval(ExecApprovalRequestEvent), ApplyPatchApproval(ApplyPatchApprovalRequestEvent), Elicitation(ElicitationRequestEvent), + RequestPermissions(RequestPermissionsEvent), RequestUserInput(RequestUserInputEvent), ExecBegin(ExecCommandBeginEvent), ExecEnd(ExecCommandEndEvent), @@ -55,6 +57,11 @@ impl InterruptManager { self.queue.push_back(QueuedInterrupt::Elicitation(ev)); } + pub(crate) fn push_request_permissions(&mut self, ev: RequestPermissionsEvent) { + self.queue + .push_back(QueuedInterrupt::RequestPermissions(ev)); + } + pub(crate) fn push_user_input(&mut self, ev: RequestUserInputEvent) { self.queue.push_back(QueuedInterrupt::RequestUserInput(ev)); } @@ -85,6 +92,7 @@ impl InterruptManager { QueuedInterrupt::ExecApproval(ev) => chat.handle_exec_approval_now(ev), QueuedInterrupt::ApplyPatchApproval(ev) => chat.handle_apply_patch_approval_now(ev), QueuedInterrupt::Elicitation(ev) => chat.handle_elicitation_request_now(ev), + QueuedInterrupt::RequestPermissions(ev) => chat.handle_request_permissions_now(ev), QueuedInterrupt::RequestUserInput(ev) => chat.handle_request_user_input_now(ev), QueuedInterrupt::ExecBegin(ev) => chat.handle_exec_begin_now(ev), QueuedInterrupt::ExecEnd(ev) => chat.handle_exec_end_now(ev), diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap index ebffeb8f53..2e961c3759 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap @@ -5,6 +5,7 @@ expression: term.backend().vt100().screen().contents() • Working (0s • esc to interrupt) +• Queued follow-up messages ↳ Hello, world! 0 ↳ Hello, world! 1 ↳ Hello, world! 2 @@ -21,7 +22,6 @@ expression: term.backend().vt100().screen().contents() ↳ Hello, world! 13 ↳ Hello, world! 14 ↳ Hello, world! 15 - ↳ Hello, world! 16 › Ask Codex to do anything diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_popup_includes_guardian_approval.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_popup_includes_guardian_approval.snap deleted file mode 100644 index 55093f626b..0000000000 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_popup_includes_guardian_approval.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: popup ---- - Experimental features - Toggle experimental features. Changes are saved to config.toml. - -› [ ] JavaScript REPL Enable a persistent Node-backed JavaScript REPL for interactive website debugging - and other inline JavaScript execution capabilities. Requires Node >= v22.22.0 - installed. - [ ] Multi-agents Ask Codex to spawn multiple agents to parallelize the work and win in efficiency. - [ ] Apps Use a connected ChatGPT App using "$". Install Apps via /apps command. Restart - Codex after enabling. - [ ] Guardian approvals Let a guardian subagent review `on-request` approval prompts instead of showing - them to you, including sandbox escapes and blocked network access. - [ ] Prevent sleep while running Keep your computer awake while Codex is running a thread. - - Press space to select or enter to save for next conversation diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_popup_includes_guardian_approval_linux.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_popup_includes_guardian_approval_linux.snap deleted file mode 100644 index 6f51ad7a9d..0000000000 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_popup_includes_guardian_approval_linux.snap +++ /dev/null @@ -1,19 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: popup ---- - Experimental features - Toggle experimental features. Changes are saved to config.toml. - -› [ ] JavaScript REPL Enable a persistent Node-backed JavaScript REPL for interactive website debugging - and other inline JavaScript execution capabilities. Requires Node >= v22.22.0 - installed. - [ ] Bubblewrap sandbox Try the new linux sandbox based on bubblewrap. - [ ] Multi-agents Ask Codex to spawn multiple agents to parallelize the work and win in efficiency. - [ ] Apps Use a connected ChatGPT App using "$". Install Apps via /apps command. Restart - Codex after enabling. - [ ] Guardian approvals Let a guardian subagent review `on-request` approval prompts instead of showing - them to you, including sandbox escapes and blocked network access. - [ ] Prevent sleep while running Keep your computer awake while Codex is running a thread. - - Press space to select or enter to save for next conversation diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__image_generation_call_history_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__image_generation_call_history_snapshot.snap index 9ed57a3689..05f2b371ce 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__image_generation_call_history_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__image_generation_call_history_snapshot.snap @@ -3,5 +3,6 @@ source: tui/src/chatwidget/tests.rs assertion_line: 5889 expression: combined --- -• Generated Image - └ completed: A tiny blue square +• Generated Image: + └ A tiny blue square + └ Saved to: /tmp/project diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupted_turn_pending_steers_message.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupted_turn_pending_steers_message.snap new file mode 100644 index 0000000000..0aa872cfcf --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupted_turn_pending_steers_message.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: info +--- +• Model interrupted to submit steer instructions. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap index ce28175ea6..79c08c42ed 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap @@ -11,11 +11,11 @@ expression: term.backend().vt100().screen().contents() - • Working (0s • esc to interrupt) +• Queued follow-up messages ↳ Queued while /review is running. - ⌥ + ↑ edit + ⌥ + ↑ edit last queued message › Ask Codex to do anything diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 7307891106..98e5005c7a 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1866,6 +1866,7 @@ async fn make_chatwidget_manual( startup_tooltip_override: None, queued_user_messages: VecDeque::new(), pending_steers: VecDeque::new(), + submit_pending_steers_after_interrupt: false, queued_message_edit_binding: crate::key_hint::alt(KeyCode::Up), suppress_session_configured_redraw: false, pending_notification: None, @@ -1913,6 +1914,17 @@ fn next_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> Op { } } +fn next_interrupt_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) { + loop { + match op_rx.try_recv() { + Ok(Op::Interrupt) => return, + Ok(_) => continue, + Err(TryRecvError::Empty) => panic!("expected interrupt op but queue was empty"), + Err(TryRecvError::Disconnected) => panic!("expected interrupt op but channel closed"), + } + } +} + fn assert_no_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) { while let Ok(op) = op_rx.try_recv() { assert!( @@ -3188,6 +3200,7 @@ async fn exec_approval_emits_proposed_command_and_decision_history() { proposed_execpolicy_amendment: None, proposed_network_policy_amendments: None, additional_permissions: None, + skill_metadata: None, available_decisions: None, parsed_cmd: vec![], }; @@ -3238,6 +3251,7 @@ async fn exec_approval_uses_approval_id_when_present() { proposed_execpolicy_amendment: None, proposed_network_policy_amendments: None, additional_permissions: None, + skill_metadata: None, available_decisions: None, parsed_cmd: vec![], }), @@ -3279,6 +3293,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() { proposed_execpolicy_amendment: None, proposed_network_policy_amendments: None, additional_permissions: None, + skill_metadata: None, available_decisions: None, parsed_cmd: vec![], }; @@ -3334,6 +3349,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() { proposed_execpolicy_amendment: None, proposed_network_policy_amendments: None, additional_permissions: None, + skill_metadata: None, available_decisions: None, parsed_cmd: vec![], }; @@ -4374,6 +4390,107 @@ async fn manual_interrupt_restores_pending_steers_to_composer() { ); } +#[tokio::test] +async fn esc_interrupt_sends_all_pending_steers_immediately_and_keeps_existing_draft() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + chat.on_task_started(); + chat.on_agent_message_delta("Final answer line\n".to_string()); + + chat.bottom_pane + .set_composer_text("first pending steer".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "first pending steer".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected Op::UserTurn, got {other:?}"), + } + + chat.bottom_pane + .set_composer_text("second pending steer".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "second pending steer".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected Op::UserTurn, got {other:?}"), + } + + chat.queued_user_messages + .push_back(UserMessage::from("queued draft".to_string())); + chat.refresh_pending_input_preview(); + chat.bottom_pane + .set_composer_text("still editing".to_string(), Vec::new(), Vec::new()); + + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + next_interrupt_op(&mut op_rx); + + chat.on_interrupted_turn(TurnAbortReason::Interrupted); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "first pending steer\nsecond pending steer".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected merged pending steers to submit, got {other:?}"), + } + + assert!(chat.pending_steers.is_empty()); + assert_eq!(chat.bottom_pane.composer_text(), "still editing"); + assert_eq!(chat.queued_user_messages.len(), 1); + assert_eq!( + chat.queued_user_messages.front().unwrap().text, + "queued draft" + ); + + let inserted = drain_insert_history(&mut rx); + assert!( + inserted + .iter() + .any(|cell| lines_to_single_string(cell).contains("first pending steer")) + ); + assert!( + inserted + .iter() + .any(|cell| lines_to_single_string(cell).contains("second pending steer")) + ); +} + +#[tokio::test] +async fn esc_with_pending_steers_overrides_agent_command_interrupt_behavior() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + chat.on_task_started(); + + chat.bottom_pane + .set_composer_text("pending steer".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match next_submit_op(&mut op_rx) { + Op::UserTurn { .. } => {} + other => panic!("expected Op::UserTurn, got {other:?}"), + } + + chat.bottom_pane + .set_composer_text("/agent ".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + next_interrupt_op(&mut op_rx); + assert_eq!(chat.bottom_pane.composer_text(), "/agent "); +} + #[tokio::test] async fn manual_interrupt_restores_pending_steer_mention_bindings_to_composer() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; @@ -6171,7 +6288,7 @@ async fn image_generation_call_adds_history_cell() { status: "completed".into(), revised_prompt: Some("A tiny blue square".into()), result: "Zm9v".into(), - saved_path: None, + saved_path: Some("/tmp/project/ig-1.png".into()), }), }); @@ -6245,6 +6362,42 @@ async fn interrupted_turn_error_message_snapshot() { assert_snapshot!("interrupted_turn_error_message", last); } +// Snapshot test: interrupting specifically to submit pending steers shows an +// informational banner instead of the generic "tell the model what to do +// differently" error prompt. +#[tokio::test] +async fn interrupted_turn_pending_steers_message_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + chat.pending_steers.push_back(pending_steer("steer 1")); + chat.submit_pending_steers_after_interrupt = true; + + chat.handle_codex_event(Event { + id: "task-1".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); + + chat.handle_codex_event(Event { + id: "task-1".into(), + msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { + turn_id: Some("turn-1".to_string()), + reason: TurnAbortReason::Interrupted, + }), + }); + + let cells = drain_insert_history(&mut rx); + let info = cells + .iter() + .map(|cell| lines_to_single_string(cell)) + .find(|line| line.contains("Model interrupted to submit steer instructions.")) + .expect("expected steer interrupt info message to be inserted"); + assert_snapshot!("interrupted_turn_pending_steers_message", info); +} + /// Opening custom prompt from the review popup, pressing Esc returns to the /// parent popup, pressing Esc again dismisses all panels (back to normal mode). #[tokio::test] @@ -6992,14 +7145,30 @@ async fn experimental_popup_shows_js_repl_node_requirement() { #[tokio::test] async fn experimental_popup_includes_guardian_approval() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let guardian_stage = FEATURES + .iter() + .find(|spec| spec.id == Feature::GuardianApproval) + .map(|spec| spec.stage) + .expect("expected guardian approval feature metadata"); + let guardian_name = guardian_stage + .experimental_menu_name() + .expect("expected guardian approval experimental menu name"); + let guardian_description = guardian_stage + .experimental_menu_description() + .expect("expected guardian approval experimental description"); chat.open_experimental_popup(); let popup = render_bottom_popup(&chat, 120); - #[cfg(target_os = "linux")] - assert_snapshot!("experimental_popup_includes_guardian_approval_linux", popup); - #[cfg(not(target_os = "linux"))] - assert_snapshot!("experimental_popup_includes_guardian_approval", popup); + let normalized_popup = popup.split_whitespace().collect::>().join(" "); + assert!( + popup.contains(guardian_name), + "expected guardian approvals entry in experimental popup, got:\n{popup}" + ); + assert!( + normalized_popup.contains(guardian_description), + "expected guardian approvals description in experimental popup, got:\n{popup}" + ); } #[tokio::test] @@ -7965,6 +8134,7 @@ async fn approval_modal_exec_snapshot() -> anyhow::Result<()> { ])), proposed_network_policy_amendments: None, additional_permissions: None, + skill_metadata: None, available_decisions: None, parsed_cmd: vec![], }; @@ -8026,6 +8196,7 @@ async fn approval_modal_exec_without_reason_snapshot() -> anyhow::Result<()> { ])), proposed_network_policy_amendments: None, additional_permissions: None, + skill_metadata: None, available_decisions: None, parsed_cmd: vec![], }; @@ -8074,6 +8245,7 @@ async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot() proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)), proposed_network_policy_amendments: None, additional_permissions: None, + skill_metadata: None, available_decisions: None, parsed_cmd: vec![], }; @@ -8441,6 +8613,7 @@ async fn status_widget_and_approval_modal_snapshot() { ])), proposed_network_policy_amendments: None, additional_permissions: None, + skill_metadata: None, available_decisions: None, parsed_cmd: vec![], }; diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 1167a391ec..e6feef2cfd 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -2215,15 +2215,18 @@ pub(crate) fn new_view_image_tool_call(path: PathBuf, cwd: &Path) -> PlainHistor pub(crate) fn new_image_generation_call( call_id: String, - status: String, revised_prompt: Option, + saved_to: Option, ) -> PlainHistoryCell { let detail = revised_prompt.unwrap_or_else(|| call_id.clone()); - let lines: Vec> = vec![ - vec!["• ".dim(), "Generated Image".bold()].into(), - vec![" └ ".dim(), format!("{status}: {detail}").dim()].into(), + let mut lines: Vec> = vec![ + vec!["• ".dim(), "Generated Image:".bold()].into(), + vec![" └ ".dim(), detail.dim()].into(), ]; + if let Some(saved_to) = saved_to { + lines.push(vec![" └ ".dim(), format!("Saved to: {saved_to}").dim()].into()); + } PlainHistoryCell { lines } } diff --git a/codex-rs/utils/pty/src/tests.rs b/codex-rs/utils/pty/src/tests.rs index 528bdf9890..e6c6b925fd 100644 --- a/codex-rs/utils/pty/src/tests.rs +++ b/codex-rs/utils/pty/src/tests.rs @@ -130,52 +130,36 @@ async fn collect_output_until_exit( } async fn wait_for_python_repl_ready( - writer: &tokio::sync::mpsc::Sender>, output_rx: &mut tokio::sync::broadcast::Receiver>, timeout_ms: u64, - newline: &str, + ready_marker: &str, ) -> anyhow::Result> { let mut collected = Vec::new(); - let marker = "__codex_pty_ready__"; let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms); - let probe_window = tokio::time::Duration::from_millis(if cfg!(windows) { 750 } else { 250 }); while tokio::time::Instant::now() < deadline { - writer - .send(format!("print('{marker}'){newline}").into_bytes()) - .await?; - - let probe_deadline = tokio::time::Instant::now() + probe_window; - loop { - let now = tokio::time::Instant::now(); - if now >= deadline || now >= probe_deadline { - break; - } - let remaining = std::cmp::min( - deadline.saturating_duration_since(now), - probe_deadline.saturating_duration_since(now), - ); - match tokio::time::timeout(remaining, output_rx.recv()).await { - Ok(Ok(chunk)) => { - collected.extend_from_slice(&chunk); - if String::from_utf8_lossy(&collected).contains(marker) { - return Ok(collected); - } + let now = tokio::time::Instant::now(); + let remaining = deadline.saturating_duration_since(now); + match tokio::time::timeout(remaining, output_rx.recv()).await { + Ok(Ok(chunk)) => { + collected.extend_from_slice(&chunk); + if String::from_utf8_lossy(&collected).contains(ready_marker) { + return Ok(collected); } - Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => continue, - Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => { - anyhow::bail!( - "PTY output closed while waiting for Python REPL readiness: {:?}", - String::from_utf8_lossy(&collected) - ); - } - Err(_) => break, } + Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => continue, + Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => { + anyhow::bail!( + "PTY output closed while waiting for Python REPL readiness: {:?}", + String::from_utf8_lossy(&collected) + ); + } + Err(_) => break, } } anyhow::bail!( - "timed out waiting for Python REPL readiness in PTY: {:?}", + "timed out waiting for Python REPL readiness marker {ready_marker:?} in PTY: {:?}", String::from_utf8_lossy(&collected) ); } @@ -254,10 +238,17 @@ async fn pty_python_repl_emits_output_and_exits() -> anyhow::Result<()> { return Ok(()); }; + let ready_marker = "__codex_pty_ready__"; + let args = vec![ + "-i".to_string(), + "-q".to_string(), + "-c".to_string(), + format!("print('{ready_marker}')"), + ]; let env_map: HashMap = std::env::vars().collect(); let spawned = spawn_pty_process( &python, - &[], + &args, Path::new("."), &env_map, &None, @@ -269,7 +260,7 @@ async fn pty_python_repl_emits_output_and_exits() -> anyhow::Result<()> { let newline = if cfg!(windows) { "\r\n" } else { "\n" }; let startup_timeout_ms = if cfg!(windows) { 10_000 } else { 5_000 }; let mut output = - wait_for_python_repl_ready(&writer, &mut output_rx, startup_timeout_ms, newline).await?; + wait_for_python_repl_ready(&mut output_rx, startup_timeout_ms, ready_marker).await?; writer .send(format!("print('hello from pty'){newline}").into_bytes()) .await?;