From 4b345a8de9b37ee3f8b29c888d6565855bcc24a1 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 9 Nov 2026 23:15:18 +0000 Subject: [PATCH] Treat workspace access denials as terminal errors (#60480) ## Why A `403` with `codex_workspace_access_denied` indicates that the account cannot use Codex in the selected workspace. Retrying or falling back to HTTP does not resolve that denial. ## What changed Map `codex_workspace_access_denied` and `invalid_workspace_selected` responses to a shared workspace access denial error, and rename the error variant to reflect its broader scope. Keep workspace denials excluded from compaction model fallback. ## Testing Extend API error mapping tests to cover the new code on `403` and preserve ordinary handling for `503`. Add WebSocket coverage verifying that an explicit workspace access denial does not trigger HTTP fallback. GitOrigin-RevId: e601a22a0fa783a4ce5401a98ff9c8d3d9d1b074 --- codex-rs/codex-api/src/api_bridge.rs | 7 ++++--- codex-rs/codex-api/src/api_bridge_tests.rs | 8 ++++---- codex-rs/core/src/compact_model_fallback.rs | 2 +- codex-rs/core/tests/suite/websocket_fallback.rs | 1 + codex-rs/protocol/src/error.rs | 10 +++++----- 5 files changed, 15 insertions(+), 13 deletions(-) diff --git a/codex-rs/codex-api/src/api_bridge.rs b/codex-rs/codex-api/src/api_bridge.rs index a0e2e732ab..6a6823676e 100644 --- a/codex-rs/codex-api/src/api_bridge.rs +++ b/codex-rs/codex-api/src/api_bridge.rs @@ -290,9 +290,10 @@ fn map_api_error_details(err: ApiError) -> CodexErr { }); let code = body_code.or(response.identity_error_code.as_deref()); match (status, code) { - (http::StatusCode::FORBIDDEN, Some("invalid_workspace_selected")) => { - CodexErrorDetails::InvalidWorkspaceSelected(response).into() - } + ( + http::StatusCode::FORBIDDEN, + Some("invalid_workspace_selected" | "codex_workspace_access_denied"), + ) => CodexErrorDetails::WorkspaceAccessDenied(response).into(), (http::StatusCode::FORBIDDEN, Some("access_program_not_enabled")) => { CodexErrorDetails::AccessProgramNotEnabled(ProviderRejection::Http( response, diff --git a/codex-rs/codex-api/src/api_bridge_tests.rs b/codex-rs/codex-api/src/api_bridge_tests.rs index 97e9c338eb..c318bce45a 100644 --- a/codex-rs/codex-api/src/api_bridge_tests.rs +++ b/codex-rs/codex-api/src/api_bridge_tests.rs @@ -814,12 +814,15 @@ fn only_an_explicit_workspace_403_stops_retries() { use http::StatusCode; let provider = r#"{"error":{"code":"invalid_workspace_selected"}}"#; let detail = r#"{"detail":{"code":"invalid_workspace_selected"}}"#; + let access_denied = r#"{"detail":{"code":"codex_workspace_access_denied"}}"#; let other = r#"{"detail":{"code":"route_rate_limited"}}"#; let workspace_header = http::HeaderValue::from_str(&base64::engine::general_purpose::STANDARD.encode(provider)) .unwrap(); for (status, body, with_header, terminal) in [ (StatusCode::FORBIDDEN, detail, false, true), + (StatusCode::FORBIDDEN, access_denied, false, true), + (StatusCode::SERVICE_UNAVAILABLE, access_denied, false, false), (StatusCode::FORBIDDEN, provider, false, true), (StatusCode::FORBIDDEN, "", true, true), (StatusCode::FORBIDDEN, "", false, false), @@ -845,10 +848,7 @@ fn only_an_explicit_workspace_403_stops_retries() { retry_after: None, })); assert_eq!( - matches!( - err.details(), - CodexErrorDetails::InvalidWorkspaceSelected(_) - ), + matches!(err.details(), CodexErrorDetails::WorkspaceAccessDenied(_)), terminal, "{err}" ); diff --git a/codex-rs/core/src/compact_model_fallback.rs b/codex-rs/core/src/compact_model_fallback.rs index f712431406..b8b55e8981 100644 --- a/codex-rs/core/src/compact_model_fallback.rs +++ b/codex-rs/core/src/compact_model_fallback.rs @@ -17,7 +17,7 @@ pub(crate) fn should_retry_with_current_model( CodexErrorDetails::TurnAborted | CodexErrorDetails::Interrupted | CodexErrorDetails::SessionBudgetExceeded - | CodexErrorDetails::InvalidWorkspaceSelected(_) + | CodexErrorDetails::WorkspaceAccessDenied(_) | CodexErrorDetails::ContentPolicyViolation { .. } | CodexErrorDetails::ResponseIncomplete(_) ) || (previous.model_info().slug == current.model_info().slug diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index 0c7d4ed5c5..2c82395619 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -129,6 +129,7 @@ async fn websocket_fallback_switches_to_http_after_retries_exhausted() -> Result #[test_case::test_case(true, Some("invalid_workspace_selected"), 3, 0; "workspace denial on websocket")] #[test_case::test_case(false, Some("invalid_workspace_selected"), 0, 2; "workspace denial on http")] +#[test_case::test_case(true, Some("codex_workspace_access_denied"), 3, 0; "workspace permission denied on websocket")] #[test_case::test_case(true, Some("access_program_not_enabled"), 3, 0; "access program denial on websocket")] #[test_case::test_case(false, Some("access_program_not_enabled"), 0, 2; "access program denial on http")] #[test_case::test_case(true, None, 3, 4; "bare websocket and http denials")] diff --git a/codex-rs/protocol/src/error.rs b/codex-rs/protocol/src/error.rs index 46c5612073..ecb3eb47e6 100644 --- a/codex-rs/protocol/src/error.rs +++ b/codex-rs/protocol/src/error.rs @@ -148,9 +148,9 @@ pub enum CodexErrorDetails { /// surface a polite FunctionCallOutput back to the model instead of crashing the CLI. #[error("interrupted (Ctrl-C). Something went wrong? Hit `/feedback` to report the issue.")] Interrupted, - /// The server rejected the selected workspace. + /// The account cannot use Codex in the selected workspace. #[error("{0}")] - InvalidWorkspaceSelected(UnexpectedResponseError), + WorkspaceAccessDenied(UnexpectedResponseError), /// The provider denied the access program required for this request. #[error("{0}")] AccessProgramNotEnabled(ProviderRejection), @@ -479,7 +479,7 @@ impl CodexErr { | CodexErrorDetails::FlexUnavailable | CodexErrorDetails::CyberPolicy { .. } | CodexErrorDetails::BioPolicy { .. } - | CodexErrorDetails::InvalidWorkspaceSelected(_) + | CodexErrorDetails::WorkspaceAccessDenied(_) | CodexErrorDetails::AccessProgramNotEnabled(_) | CodexErrorDetails::MisalignmentPolicyViolation { .. } => None, CodexErrorDetails::ServerOverloaded @@ -556,7 +556,7 @@ impl CodexErr { http_status_code: self.http_status_code_value(), }, CodexErrorDetails::ConnectionFailed(_) - | CodexErrorDetails::InvalidWorkspaceSelected(_) + | CodexErrorDetails::WorkspaceAccessDenied(_) | CodexErrorDetails::AccessProgramNotEnabled(_) | CodexErrorDetails::UnexpectedStatus(_) => CodexErrorInfo::HttpConnectionFailed { http_status_code: self.http_status_code_value(), @@ -604,7 +604,7 @@ impl CodexErr { CodexErrorDetails::ModelNotFound { status, .. } | CodexErrorDetails::ContentPolicyViolation { status, .. } => *status, CodexErrorDetails::RetryLimit(err) => Some(err.status), - CodexErrorDetails::InvalidWorkspaceSelected(err) + CodexErrorDetails::WorkspaceAccessDenied(err) | CodexErrorDetails::AccessProgramNotEnabled(ProviderRejection::Http(err)) | CodexErrorDetails::UnexpectedStatus(err) => Some(err.status), CodexErrorDetails::ConnectionFailed(err) => err.source.status(),