From cad30d2689b791eb2622613401f6b0a13774ff62 Mon Sep 17 00:00:00 2001 From: rreichel3-oai Date: Wed, 15 Apr 2026 13:06:26 -0400 Subject: [PATCH 1/4] Support multiple forced ChatGPT workspace ids Add a shared ForcedChatgptWorkspaceIds config type that accepts the existing single string form or an array of workspace IDs. Update ChatGPT login, device-code, external auth, app-server, CLI, and TUI enforcement paths to allow any configured workspace. Regenerate config/app-server schemas and document the single-value and array config forms. --- .../codex_app_server_protocol.schemas.json | 24 ++++- .../codex_app_server_protocol.v2.schemas.json | 24 ++++- .../schema/json/v2/ConfigReadResponse.json | 24 ++++- .../typescript/ForcedChatgptWorkspaceIds.ts | 11 +++ .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/Config.ts | 3 +- .../app-server-protocol/src/protocol/v1.rs | 3 +- .../app-server-protocol/src/protocol/v2.rs | 3 +- .../app-server/src/codex_message_processor.rs | 7 +- codex-rs/cli/src/login.rs | 3 +- codex-rs/config/src/config_toml.rs | 5 +- codex-rs/core/config.schema.json | 22 ++++- codex-rs/core/src/config/config_tests.rs | 53 ++++++++++ codex-rs/core/src/config/mod.rs | 19 ++-- codex-rs/login/src/auth/auth_tests.rs | 35 ++++++- codex-rs/login/src/auth/manager.rs | 32 +++--- codex-rs/login/src/device_code_auth.rs | 2 +- codex-rs/login/src/server.rs | 97 ++++++++++++++++--- .../login/tests/suite/device_code_login.rs | 5 +- .../login/tests/suite/login_server_e2e.rs | 11 ++- codex-rs/protocol/src/config_types.rs | 63 ++++++++++++ codex-rs/tui/src/local_chatgpt_auth.rs | 23 +++-- docs/config.md | 21 ++++ 23 files changed, 421 insertions(+), 70 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/ForcedChatgptWorkspaceIds.ts 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 d0ce368b78d..dd92d485d0d 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 @@ -6431,9 +6431,13 @@ ] }, "forced_chatgpt_workspace_id": { - "type": [ - "string", - "null" + "anyOf": [ + { + "$ref": "#/definitions/v2/ForcedChatgptWorkspaceIds" + }, + { + "type": "null" + } ] }, "forced_login_method": { @@ -7643,6 +7647,20 @@ ], "type": "object" }, + "ForcedChatgptWorkspaceIds": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "Workspace IDs that ChatGPT auth is allowed to use.\n\nThe config parser accepts the historical single-string form and the newer list form for deployments that allow multiple ChatGPT workspaces." + }, "ForcedLoginMethod": { "enum": [ "chatgpt", 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 aca9b238800..0651001e7b7 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 @@ -3048,9 +3048,13 @@ ] }, "forced_chatgpt_workspace_id": { - "type": [ - "string", - "null" + "anyOf": [ + { + "$ref": "#/definitions/ForcedChatgptWorkspaceIds" + }, + { + "type": "null" + } ] }, "forced_login_method": { @@ -4260,6 +4264,20 @@ ], "type": "object" }, + "ForcedChatgptWorkspaceIds": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "Workspace IDs that ChatGPT auth is allowed to use.\n\nThe config parser accepts the historical single-string form and the newer list form for deployments that allow multiple ChatGPT workspaces." + }, "ForcedLoginMethod": { "enum": [ "chatgpt", 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 58f8186266f..cc5e33320a0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -234,9 +234,13 @@ ] }, "forced_chatgpt_workspace_id": { - "type": [ - "string", - "null" + "anyOf": [ + { + "$ref": "#/definitions/ForcedChatgptWorkspaceIds" + }, + { + "type": "null" + } ] }, "forced_login_method": { @@ -577,6 +581,20 @@ } ] }, + "ForcedChatgptWorkspaceIds": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "Workspace IDs that ChatGPT auth is allowed to use.\n\nThe config parser accepts the historical single-string form and the newer list form for deployments that allow multiple ChatGPT workspaces." + }, "ForcedLoginMethod": { "enum": [ "chatgpt", diff --git a/codex-rs/app-server-protocol/schema/typescript/ForcedChatgptWorkspaceIds.ts b/codex-rs/app-server-protocol/schema/typescript/ForcedChatgptWorkspaceIds.ts new file mode 100644 index 00000000000..a8b5b682cf2 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/ForcedChatgptWorkspaceIds.ts @@ -0,0 +1,11 @@ +// 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. + +/** + * Workspace IDs that ChatGPT auth is allowed to use. + * + * The config parser accepts the historical single-string form and the newer + * list form for deployments that allow multiple ChatGPT workspaces. + */ +export type ForcedChatgptWorkspaceIds = string | Array; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 7bbb417fdc9..344be137684 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -16,6 +16,7 @@ export type { ExecCommandApprovalParams } from "./ExecCommandApprovalParams"; export type { ExecCommandApprovalResponse } from "./ExecCommandApprovalResponse"; export type { ExecPolicyAmendment } from "./ExecPolicyAmendment"; export type { FileChange } from "./FileChange"; +export type { ForcedChatgptWorkspaceIds } from "./ForcedChatgptWorkspaceIds"; export type { ForcedLoginMethod } from "./ForcedLoginMethod"; export type { FunctionCallOutputBody } from "./FunctionCallOutputBody"; export type { FunctionCallOutputContentItem } from "./FunctionCallOutputContentItem"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts index 508fe84e92f..2198004d904 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Config.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 { ForcedChatgptWorkspaceIds } from "../ForcedChatgptWorkspaceIds"; import type { ForcedLoginMethod } from "../ForcedLoginMethod"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; @@ -20,4 +21,4 @@ export type Config = {model: string | null, review_model: string | null, model_c * [UNSTABLE] Optional default for where approval requests are routed for * review. */ -approvals_reviewer: ApprovalsReviewer | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: string | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, service_tier: ServiceTier | null, analytics: AnalyticsConfig | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); +approvals_reviewer: ApprovalsReviewer | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: ForcedChatgptWorkspaceIds | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, service_tier: ServiceTier | null, analytics: AnalyticsConfig | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index 6aa2e9fa30c..49595789cb3 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use codex_git_utils::GitSha; use codex_protocol::ThreadId; +use codex_protocol::config_types::ForcedChatgptWorkspaceIds; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; @@ -198,7 +199,7 @@ pub struct UserSavedConfig { pub approval_policy: Option, pub sandbox_mode: Option, pub sandbox_settings: Option, - pub forced_chatgpt_workspace_id: Option, + pub forced_chatgpt_workspace_id: Option, pub forced_login_method: Option, pub model: Option, pub model_reasoning_effort: Option, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 5897625de17..f220c6d5b61 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -18,6 +18,7 @@ use codex_protocol::approvals::NetworkPolicyRuleAction as CoreNetworkPolicyRuleA use codex_protocol::config_types::ApprovalsReviewer as CoreApprovalsReviewer; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::CollaborationModeMask as CoreCollaborationModeMask; +use codex_protocol::config_types::ForcedChatgptWorkspaceIds; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Personality; @@ -744,7 +745,7 @@ pub struct Config { pub approvals_reviewer: Option, pub sandbox_mode: Option, pub sandbox_workspace_write: Option, - pub forced_chatgpt_workspace_id: Option, + pub forced_chatgpt_workspace_id: Option, pub forced_login_method: Option, pub web_search: Option, pub tools: Option, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 42cb8b2d1a1..d6f48f22ff4 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1627,13 +1627,14 @@ impl CodexMessageProcessor { } } - if let Some(expected_workspace) = self.config.forced_chatgpt_workspace_id.as_deref() - && chatgpt_account_id != expected_workspace + if let Some(allowed_workspace_ids) = self.config.forced_chatgpt_workspace_id.as_ref() + && !allowed_workspace_ids.contains(&chatgpt_account_id) { + let allowed_description = allowed_workspace_ids.description(); let error = JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, message: format!( - "External auth must use workspace {expected_workspace}, but received {chatgpt_account_id:?}." + "External auth must use {allowed_description}, but received {chatgpt_account_id:?}." ), data: None, }; diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index bd17a546a1f..2d7dff983af 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -17,6 +17,7 @@ use codex_login::login_with_api_key; use codex_login::logout; use codex_login::run_device_code_login; use codex_login::run_login_server; +use codex_protocol::config_types::ForcedChatgptWorkspaceIds; use codex_protocol::config_types::ForcedLoginMethod; use codex_utils_cli::CliConfigOverrides; use std::fs::OpenOptions; @@ -112,7 +113,7 @@ fn print_login_server_start(actual_port: u16, auth_url: &str) { pub async fn login_with_chatgpt( codex_home: PathBuf, - forced_chatgpt_workspace_id: Option, + forced_chatgpt_workspace_id: Option, cli_auth_credentials_store_mode: AuthCredentialsStoreMode, ) -> std::io::Result<()> { let opts = ServerOptions::new( diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 92e5304fe58..9b3fd6baeaf 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -36,6 +36,7 @@ use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::OLLAMA_CHAT_PROVIDER_REMOVED_ERROR; use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID; use codex_model_provider_info::OPENAI_PROVIDER_ID; +use codex_protocol::config_types::ForcedChatgptWorkspaceIds; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; @@ -149,9 +150,9 @@ pub struct ConfigToml { /// Set to an empty string to disable automatic commit attribution. pub commit_attribution: Option, - /// When set, restricts ChatGPT login to a specific workspace identifier. + /// When set, restricts ChatGPT login to one or more workspace identifiers. #[serde(default)] - pub forced_chatgpt_workspace_id: Option, + pub forced_chatgpt_workspace_id: Option, /// When set, restricts the login mechanism users may use. #[serde(default)] diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 9280895b5ec..0615d61c0a3 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -672,6 +672,20 @@ "FilesystemPermissionsToml": { "type": "object" }, + "ForcedChatgptWorkspaceIds": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "Workspace IDs that ChatGPT auth is allowed to use.\n\nThe config parser accepts the historical single-string form and the newer list form for deployments that allow multiple ChatGPT workspaces." + }, "ForcedLoginMethod": { "enum": [ "chatgpt", @@ -2407,9 +2421,13 @@ "description": "Optional URI-based file opener. If set, citations to files in the model output will be hyperlinked using the specified URI scheme." }, "forced_chatgpt_workspace_id": { + "allOf": [ + { + "$ref": "#/definitions/ForcedChatgptWorkspaceIds" + } + ], "default": null, - "description": "When set, restricts ChatGPT login to a specific workspace identifier.", - "type": "string" + "description": "When set, restricts ChatGPT login to one or more workspace identifiers." }, "forced_login_method": { "allOf": [ diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index b2931e10558..2723dc9ece7 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -48,6 +48,7 @@ use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID; use codex_model_provider_info::WireApi; use codex_models_manager::bundled_models_response; +use codex_protocol::config_types::ForcedChatgptWorkspaceIds; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; @@ -4424,6 +4425,58 @@ fn model_catalog_json_rejects_empty_catalog() -> std::io::Result<()> { Ok(()) } +#[test] +fn forced_chatgpt_workspace_id_accepts_string_and_list() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let single_cfg: ConfigToml = toml::from_str(r#"forced_chatgpt_workspace_id = " org_single ""#) + .expect("single workspace id should parse"); + + let single_config = Config::load_from_base_config_with_overrides( + single_cfg, + ConfigOverrides::default(), + codex_home.abs(), + )?; + + assert_eq!( + single_config.forced_chatgpt_workspace_id, + Some(ForcedChatgptWorkspaceIds::Single("org_single".to_string())) + ); + + let list_cfg: ConfigToml = + toml::from_str(r#"forced_chatgpt_workspace_id = [" org_a ", "", "org_b"]"#) + .expect("workspace id list should parse"); + + let list_config = Config::load_from_base_config_with_overrides( + list_cfg, + ConfigOverrides::default(), + codex_home.abs(), + )?; + + assert_eq!( + list_config.forced_chatgpt_workspace_id, + Some(ForcedChatgptWorkspaceIds::Multiple(vec![ + "org_a".to_string(), + "org_b".to_string() + ])) + ); + + let empty_list_cfg: ConfigToml = toml::from_str(r#"forced_chatgpt_workspace_id = []"#) + .expect("empty workspace id list should parse"); + + let empty_list_config = Config::load_from_base_config_with_overrides( + empty_list_cfg, + ConfigOverrides::default(), + codex_home.abs(), + )?; + + assert_eq!( + empty_list_config.forced_chatgpt_workspace_id, + Some(ForcedChatgptWorkspaceIds::Multiple(Vec::new())) + ); + + Ok(()) +} + fn create_test_fixture() -> std::io::Result { let toml = r#" model = "o3" diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index b58acbf6511..b8ebf0f7382 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -63,6 +63,7 @@ use codex_model_provider_info::OLLAMA_CHAT_PROVIDER_REMOVED_ERROR; use codex_model_provider_info::built_in_model_providers; use codex_models_manager::ModelsManagerConfig; use codex_protocol::config_types::AltScreenMode; +use codex_protocol::config_types::ForcedChatgptWorkspaceIds; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; @@ -523,8 +524,8 @@ pub struct Config { /// instructions inserted into developer messages when realtime becomes /// active. pub experimental_realtime_start_instructions: Option, - /// When set, restricts ChatGPT login to a specific workspace identifier. - pub forced_chatgpt_workspace_id: Option, + /// When set, restricts ChatGPT login to one or more workspace identifiers. + pub forced_chatgpt_workspace_id: Option, /// When set, restricts the login mechanism users may use. pub forced_login_method: Option, @@ -623,7 +624,7 @@ impl AuthManagerConfig for Config { self.cli_auth_credentials_store_mode } - fn forced_chatgpt_workspace_id(&self) -> Option { + fn forced_chatgpt_workspace_id(&self) -> Option { self.forced_chatgpt_workspace_id.clone() } } @@ -1816,15 +1817,9 @@ impl Config { let include_apply_patch_tool_flag = features.enabled(Feature::ApplyPatchFreeform); let use_experimental_unified_exec_tool = features.enabled(Feature::UnifiedExec); - let forced_chatgpt_workspace_id = - cfg.forced_chatgpt_workspace_id.as_ref().and_then(|value| { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - }); + let forced_chatgpt_workspace_id = cfg + .forced_chatgpt_workspace_id + .and_then(ForcedChatgptWorkspaceIds::normalized); let forced_login_method = cfg.forced_login_method; diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index 8a16f5939c0..ff4af8a2a41 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -8,6 +8,7 @@ use codex_protocol::auth::KnownPlan as InternalKnownPlan; use codex_protocol::auth::PlanType as InternalPlanType; use base64::Engine; +use codex_protocol::config_types::ForcedChatgptWorkspaceIds; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ModelProviderAuthInfo; use pretty_assertions::assert_eq; @@ -650,7 +651,8 @@ async fn build_config( codex_home: codex_home.to_path_buf(), auth_credentials_store_mode: AuthCredentialsStoreMode::File, forced_login_method, - forced_chatgpt_workspace_id, + forced_chatgpt_workspace_id: forced_chatgpt_workspace_id + .map(ForcedChatgptWorkspaceIds::Single), } } @@ -765,6 +767,37 @@ async fn enforce_login_restrictions_allows_matching_workspace() { ); } +#[tokio::test] +#[serial(codex_api_key)] +async fn enforce_login_restrictions_allows_workspace_from_list() { + let codex_home = tempdir().unwrap(); + let _jwt = write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: Some("pro".to_string()), + chatgpt_account_id: Some("org_mine".to_string()), + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let config = AuthConfig { + codex_home: codex_home.path().to_path_buf(), + auth_credentials_store_mode: AuthCredentialsStoreMode::File, + forced_login_method: None, + forced_chatgpt_workspace_id: Some(ForcedChatgptWorkspaceIds::Multiple(vec![ + "org_allowed".to_string(), + "org_mine".to_string(), + ])), + }; + + super::enforce_login_restrictions(&config).expect("listed workspace should succeed"); + assert!( + codex_home.path().join("auth.json").exists(), + "auth.json should remain when restrictions pass" + ); +} + #[tokio::test] async fn enforce_login_restrictions_allows_api_key_if_login_method_not_set_but_forced_chatgpt_workspace_id_is_set() { diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 8768171d951..d31c28721dc 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -37,6 +37,7 @@ use codex_protocol::auth::KnownPlan as InternalKnownPlan; use codex_protocol::auth::PlanType as InternalPlanType; use codex_protocol::auth::RefreshTokenFailedError; use codex_protocol::auth::RefreshTokenFailedReason; +use codex_protocol::config_types::ForcedChatgptWorkspaceIds; use serde_json::Value; use thiserror::Error; @@ -540,7 +541,7 @@ pub struct AuthConfig { pub codex_home: PathBuf, pub auth_credentials_store_mode: AuthCredentialsStoreMode, pub forced_login_method: Option, - pub forced_chatgpt_workspace_id: Option, + pub forced_chatgpt_workspace_id: Option, } pub fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<()> { @@ -578,7 +579,7 @@ pub fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<()> { } } - if let Some(expected_account_id) = config.forced_chatgpt_workspace_id.as_deref() { + if let Some(allowed_workspace_ids) = config.forced_chatgpt_workspace_id.as_ref() { if !auth.is_chatgpt_auth() { return Ok(()); } @@ -598,13 +599,14 @@ pub fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<()> { // workspace is the external identifier for account id. let chatgpt_account_id = token_data.id_token.chatgpt_account_id.as_deref(); - if chatgpt_account_id != Some(expected_account_id) { + if chatgpt_account_id.is_none_or(|actual| !allowed_workspace_ids.contains(actual)) { + let allowed_description = allowed_workspace_ids.description(); let message = match chatgpt_account_id { Some(actual) => format!( - "Login is restricted to workspace {expected_account_id}, but current credentials belong to {actual}. Logging out." + "Login is restricted to {allowed_description}, but current credentials belong to {actual}. Logging out." ), None => format!( - "Login is restricted to workspace {expected_account_id}, but current credentials lack a workspace identifier. Logging out." + "Login is restricted to {allowed_description}, but current credentials lack a workspace identifier. Logging out." ), }; return logout_with_message( @@ -1155,7 +1157,7 @@ pub struct AuthManager { inner: RwLock, enable_codex_api_key_env: bool, auth_credentials_store_mode: AuthCredentialsStoreMode, - forced_chatgpt_workspace_id: RwLock>, + forced_chatgpt_workspace_id: RwLock>, refresh_lock: AsyncMutex<()>, external_auth: RwLock>>, auth_state_tx: watch::Sender<()>, @@ -1174,8 +1176,8 @@ pub trait AuthManagerConfig { /// Returns the CLI auth credential storage mode for auth loading. fn cli_auth_credentials_store_mode(&self) -> AuthCredentialsStoreMode; - /// Returns the workspace ID that ChatGPT auth should be restricted to, if any. - fn forced_chatgpt_workspace_id(&self) -> Option; + /// Returns the workspace IDs that ChatGPT auth should be restricted to, if any. + fn forced_chatgpt_workspace_id(&self) -> Option; } impl Debug for AuthManager { @@ -1445,7 +1447,10 @@ impl AuthManager { } } - pub fn set_forced_chatgpt_workspace_id(&self, workspace_id: Option) { + pub fn set_forced_chatgpt_workspace_id( + &self, + workspace_id: Option, + ) { if let Ok(mut guard) = self.forced_chatgpt_workspace_id.write() && *guard != workspace_id { @@ -1454,7 +1459,7 @@ impl AuthManager { } } - pub fn forced_chatgpt_workspace_id(&self) -> Option { + pub fn forced_chatgpt_workspace_id(&self) -> Option { self.forced_chatgpt_workspace_id .read() .ok() @@ -1698,12 +1703,13 @@ impl AuthManager { "external auth refresh did not return ChatGPT metadata", ))); }; - if let Some(expected_workspace_id) = forced_chatgpt_workspace_id.as_deref() - && chatgpt_metadata.account_id != expected_workspace_id + if let Some(allowed_workspace_ids) = forced_chatgpt_workspace_id.as_ref() + && !allowed_workspace_ids.contains(&chatgpt_metadata.account_id) { + let allowed_description = allowed_workspace_ids.description(); return Err(RefreshTokenError::Transient(std::io::Error::other( format!( - "external auth refresh returned workspace {:?}, expected {expected_workspace_id:?}", + "external auth refresh returned workspace {:?}, expected {allowed_description}", chatgpt_metadata.account_id, ), ))); diff --git a/codex-rs/login/src/device_code_auth.rs b/codex-rs/login/src/device_code_auth.rs index 4b9cb7c3215..c33f73dd4ff 100644 --- a/codex-rs/login/src/device_code_auth.rs +++ b/codex-rs/login/src/device_code_auth.rs @@ -204,7 +204,7 @@ pub async fn complete_device_code_login( .map_err(|err| std::io::Error::other(format!("device code exchange failed: {err}")))?; if let Err(message) = crate::server::ensure_workspace_allowed( - opts.forced_chatgpt_workspace_id.as_deref(), + opts.forced_chatgpt_workspace_id.as_ref(), &tokens.id_token, ) { return Err(io::Error::new(io::ErrorKind::PermissionDenied, message)); diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index 0c7b8101843..b8ce5c0f15b 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -36,6 +36,7 @@ use chrono::Utc; use codex_app_server_protocol::AuthMode; use codex_client::build_reqwest_client_with_custom_ca; use codex_config::types::AuthCredentialsStoreMode; +use codex_protocol::config_types::ForcedChatgptWorkspaceIds; use codex_utils_template::Template; use rand::RngCore; use serde_json::Value as JsonValue; @@ -64,7 +65,7 @@ pub struct ServerOptions { pub port: u16, pub open_browser: bool, pub force_state: Option, - pub forced_chatgpt_workspace_id: Option, + pub forced_chatgpt_workspace_id: Option, pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode, } @@ -73,7 +74,7 @@ impl ServerOptions { pub fn new( codex_home: PathBuf, client_id: String, - forced_chatgpt_workspace_id: Option, + forced_chatgpt_workspace_id: Option, cli_auth_credentials_store_mode: AuthCredentialsStoreMode, ) -> Self { Self { @@ -153,7 +154,7 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result { &redirect_uri, &pkce, &state, - opts.forced_chatgpt_workspace_id.as_deref(), + opts.forced_chatgpt_workspace_id.as_ref(), ); if opts.open_browser { @@ -333,7 +334,7 @@ async fn process_request( { Ok(tokens) => { if let Err(message) = ensure_workspace_allowed( - opts.forced_chatgpt_workspace_id.as_deref(), + opts.forced_chatgpt_workspace_id.as_ref(), &tokens.id_token, ) { eprintln!("Workspace restriction error: {message}"); @@ -471,7 +472,7 @@ fn build_authorize_url( redirect_uri: &str, pkce: &PkceCodes, state: &str, - forced_chatgpt_workspace_id: Option<&str>, + forced_chatgpt_workspace_id: Option<&ForcedChatgptWorkspaceIds>, ) -> String { let mut query = vec![ ("response_type".to_string(), "code".to_string()), @@ -492,8 +493,13 @@ fn build_authorize_url( ("state".to_string(), state.to_string()), ("originator".to_string(), originator().value), ]; - if let Some(workspace_id) = forced_chatgpt_workspace_id { - query.push(("allowed_workspace_id".to_string(), workspace_id.to_string())); + if let Some(workspace_ids) = forced_chatgpt_workspace_id { + query.extend( + workspace_ids + .ids() + .iter() + .map(|workspace_id| ("allowed_workspace_id".to_string(), workspace_id.to_string())), + ); } let qs = query .into_iter() @@ -870,22 +876,25 @@ fn jwt_auth_claims(jwt: &str) -> serde_json::Map { /// Validates the ID token against an optional workspace restriction. pub(crate) fn ensure_workspace_allowed( - expected: Option<&str>, + allowed_workspace_ids: Option<&ForcedChatgptWorkspaceIds>, id_token: &str, ) -> Result<(), String> { - let Some(expected) = expected else { + let Some(allowed_workspace_ids) = allowed_workspace_ids else { return Ok(()); }; let claims = jwt_auth_claims(id_token); let Some(actual) = claims.get("chatgpt_account_id").and_then(JsonValue::as_str) else { - return Err("Login is restricted to a specific workspace, but the token did not include an chatgpt_account_id claim.".to_string()); + return Err("Login is restricted to a specific workspace, but the token did not include a chatgpt_account_id claim.".to_string()); }; - if actual == expected { + if allowed_workspace_ids.contains(actual) { Ok(()) } else { - Err(format!("Login is restricted to workspace id {expected}.")) + Err(format!( + "Login is restricted to {}.", + allowed_workspace_ids.description() + )) } } @@ -1093,9 +1102,14 @@ pub(crate) async fn obtain_api_key( } #[cfg(test)] mod tests { + use base64::Engine as _; + use codex_protocol::config_types::ForcedChatgptWorkspaceIds; use pretty_assertions::assert_eq; + use serde_json::json; use super::TokenEndpointErrorDetail; + use super::build_authorize_url; + use super::ensure_workspace_allowed; use super::html_escape; use super::is_missing_codex_entitlement_error; use super::parse_token_endpoint_error; @@ -1103,6 +1117,65 @@ mod tests { use super::redact_sensitive_url_parts; use super::render_login_error_page; use super::sanitize_url_for_logging; + use crate::pkce::PkceCodes; + + fn workspace_jwt(workspace_id: &str) -> String { + let header = json!({"alg": "none", "typ": "JWT"}); + let payload = json!({ + "https://api.openai.com/auth": { + "chatgpt_account_id": workspace_id, + } + }); + let encode = |value: serde_json::Value| { + base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&value).expect("jwt part should serialize")) + }; + format!("{}.{}.sig", encode(header), encode(payload)) + } + + #[test] + fn build_authorize_url_repeats_allowed_workspace_id_for_list() { + let pkce = PkceCodes { + code_verifier: "verifier".to_string(), + code_challenge: "challenge".to_string(), + }; + let workspace_ids = + ForcedChatgptWorkspaceIds::Multiple(vec!["org_a".to_string(), "org b".to_string()]); + + let url = build_authorize_url( + "https://auth.example", + "client", + "http://localhost/callback", + &pkce, + "state", + Some(&workspace_ids), + ); + + assert!(url.contains("allowed_workspace_id=org_a")); + assert!(url.contains("allowed_workspace_id=org%20b")); + assert_eq!(url.matches("allowed_workspace_id=").count(), 2); + } + + #[test] + fn ensure_workspace_allowed_accepts_any_workspace_from_list() { + let workspace_ids = ForcedChatgptWorkspaceIds::Multiple(vec![ + "org_allowed".to_string(), + "org_actual".to_string(), + ]); + + ensure_workspace_allowed(Some(&workspace_ids), &workspace_jwt("org_actual")) + .expect("listed workspace should be allowed"); + } + + #[test] + fn ensure_workspace_allowed_rejects_empty_workspace_list() { + let workspace_ids = ForcedChatgptWorkspaceIds::Multiple(Vec::new()); + + let err = ensure_workspace_allowed(Some(&workspace_ids), &workspace_jwt("org_actual")) + .expect_err("empty workspace list should reject ChatGPT auth"); + + assert!(err.contains("Login is restricted to a configured workspace")); + } #[test] fn parse_token_endpoint_error_prefers_error_description() { diff --git a/codex-rs/login/tests/suite/device_code_login.rs b/codex-rs/login/tests/suite/device_code_login.rs index bed94c7005d..baf8ffa45af 100644 --- a/codex-rs/login/tests/suite/device_code_login.rs +++ b/codex-rs/login/tests/suite/device_code_login.rs @@ -7,6 +7,7 @@ use codex_config::types::AuthCredentialsStoreMode; use codex_login::ServerOptions; use codex_login::auth::load_auth_dot_json; use codex_login::run_device_code_login; +use codex_protocol::config_types::ForcedChatgptWorkspaceIds; use serde_json::json; use std::sync::Arc; use std::sync::atomic::AtomicUsize; @@ -183,7 +184,9 @@ async fn device_code_login_rejects_workspace_mismatch() -> anyhow::Result<()> { let issuer = mock_server.uri(); let mut opts = server_opts(&codex_home, issuer, AuthCredentialsStoreMode::File); - opts.forced_chatgpt_workspace_id = Some("org-required".to_string()); + opts.forced_chatgpt_workspace_id = Some(ForcedChatgptWorkspaceIds::Single( + "org-required".to_string(), + )); let err = run_device_code_login(opts) .await diff --git a/codex-rs/login/tests/suite/login_server_e2e.rs b/codex-rs/login/tests/suite/login_server_e2e.rs index 9522f5b0b0a..bfffd46f376 100644 --- a/codex-rs/login/tests/suite/login_server_e2e.rs +++ b/codex-rs/login/tests/suite/login_server_e2e.rs @@ -10,6 +10,7 @@ use base64::Engine; use codex_config::types::AuthCredentialsStoreMode; use codex_login::ServerOptions; use codex_login::run_login_server; +use codex_protocol::config_types::ForcedChatgptWorkspaceIds; use core_test_support::skip_if_no_network; use tempfile::tempdir; @@ -117,7 +118,9 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> { port: 0, open_browser: false, force_state: Some(state), - forced_chatgpt_workspace_id: Some(chatgpt_account_id.to_string()), + forced_chatgpt_workspace_id: Some(ForcedChatgptWorkspaceIds::Single( + chatgpt_account_id.to_string(), + )), }; let server = run_login_server(opts)?; assert!( @@ -217,7 +220,9 @@ async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> { port: 0, open_browser: false, force_state: Some(state.clone()), - forced_chatgpt_workspace_id: Some("org-required".to_string()), + forced_chatgpt_workspace_id: Some(ForcedChatgptWorkspaceIds::Single( + "org-required".to_string(), + )), }; let server = run_login_server(opts)?; assert!( @@ -234,7 +239,7 @@ async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> { assert!(resp.status().is_success()); let body = resp.text().await?; assert!( - body.contains("Login is restricted to workspace id org-required"), + body.contains("Login is restricted to workspace org-required"), "error body should mention workspace restriction" ); diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 5fd09c24b36..9811c687970 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -264,6 +264,69 @@ pub enum ForcedLoginMethod { Api, } +/// Workspace IDs that ChatGPT auth is allowed to use. +/// +/// The config parser accepts the historical single-string form and the newer +/// list form for deployments that allow multiple ChatGPT workspaces. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(untagged)] +pub enum ForcedChatgptWorkspaceIds { + Single(String), + Multiple(Vec), +} + +impl ForcedChatgptWorkspaceIds { + /// Trims configured IDs and removes empty entries. + /// + /// A legacy empty string normalizes to `None` for compatibility. An + /// explicitly configured list remains active even if all entries are empty, + /// which avoids accidentally disabling a managed workspace restriction. + pub fn normalized(self) -> Option { + match self { + Self::Single(id) => { + let trimmed = id.trim(); + (!trimmed.is_empty()).then(|| Self::Single(trimmed.to_string())) + } + Self::Multiple(ids) => { + let ids = ids + .into_iter() + .filter_map(|id| { + let trimmed = id.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + }) + .collect::>(); + + match ids.as_slice() { + [id] => Some(Self::Single(id.clone())), + _ => Some(Self::Multiple(ids)), + } + } + } + } + + /// Returns the allowed workspace IDs in config order. + pub fn ids(&self) -> &[String] { + match self { + Self::Single(id) => std::slice::from_ref(id), + Self::Multiple(ids) => ids.as_slice(), + } + } + + /// Returns true when `workspace_id` is one of the configured IDs. + pub fn contains(&self, workspace_id: &str) -> bool { + self.ids().iter().any(|id| id == workspace_id) + } + + /// Human-readable description for login and auth error messages. + pub fn description(&self) -> String { + match self.ids() { + [] => "a configured workspace".to_string(), + [id] => format!("workspace {id}"), + ids => format!("one of workspaces {}", ids.join(", ")), + } + } +} + const DEFAULT_PROVIDER_AUTH_TIMEOUT_MS: u64 = 5_000; const DEFAULT_PROVIDER_AUTH_REFRESH_INTERVAL_MS: u64 = 300_000; diff --git a/codex-rs/tui/src/local_chatgpt_auth.rs b/codex-rs/tui/src/local_chatgpt_auth.rs index 1f84b289a78..262b8a9f90d 100644 --- a/codex-rs/tui/src/local_chatgpt_auth.rs +++ b/codex-rs/tui/src/local_chatgpt_auth.rs @@ -5,6 +5,7 @@ use std::path::Path; use codex_app_server_protocol::AuthMode; use codex_config::types::AuthCredentialsStoreMode; use codex_login::load_auth_dot_json; +use codex_protocol::config_types::ForcedChatgptWorkspaceIds; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct LocalChatgptAuth { @@ -16,7 +17,7 @@ pub(crate) struct LocalChatgptAuth { pub(crate) fn load_local_chatgpt_auth( codex_home: &Path, auth_credentials_store_mode: AuthCredentialsStoreMode, - forced_chatgpt_workspace_id: Option<&str>, + forced_chatgpt_workspace_id: Option<&ForcedChatgptWorkspaceIds>, ) -> Result { let auth = load_auth_dot_json(codex_home, auth_credentials_store_mode) .map_err(|err| format!("failed to load local auth: {err}"))? @@ -33,11 +34,12 @@ pub(crate) fn load_local_chatgpt_auth( .account_id .or(tokens.id_token.chatgpt_account_id.clone()) .ok_or_else(|| "local ChatGPT auth is missing chatgpt account id".to_string())?; - if let Some(expected_workspace) = forced_chatgpt_workspace_id - && chatgpt_account_id != expected_workspace + if let Some(allowed_workspace_ids) = forced_chatgpt_workspace_id + && !allowed_workspace_ids.contains(&chatgpt_account_id) { + let allowed_description = allowed_workspace_ids.description(); return Err(format!( - "local ChatGPT auth must use workspace {expected_workspace}, but found {chatgpt_account_id:?}" + "local ChatGPT auth must use {allowed_description}, but found {chatgpt_account_id:?}" )); } @@ -114,15 +116,20 @@ mod tests { .expect("chatgpt auth should save"); } + fn workspace_ids(id: &str) -> ForcedChatgptWorkspaceIds { + ForcedChatgptWorkspaceIds::Single(id.to_string()) + } + #[test] fn loads_local_chatgpt_auth_from_managed_auth() { let codex_home = TempDir::new().expect("tempdir"); write_chatgpt_auth(codex_home.path(), "business"); + let forced_workspace_ids = workspace_ids("workspace-1"); let auth = load_local_chatgpt_auth( codex_home.path(), AuthCredentialsStoreMode::File, - Some("workspace-1"), + Some(&forced_workspace_ids), ) .expect("chatgpt auth should load"); @@ -175,6 +182,7 @@ mod tests { fn prefers_managed_auth_over_external_ephemeral_tokens() { let codex_home = TempDir::new().expect("tempdir"); write_chatgpt_auth(codex_home.path(), "business"); + let forced_workspace_ids = workspace_ids("workspace-1"); login_with_chatgpt_auth_tokens( codex_home.path(), &fake_jwt("user@example.com", "workspace-2", "enterprise"), @@ -186,7 +194,7 @@ mod tests { let auth = load_local_chatgpt_auth( codex_home.path(), AuthCredentialsStoreMode::File, - Some("workspace-1"), + Some(&forced_workspace_ids), ) .expect("managed auth should win"); @@ -198,11 +206,12 @@ mod tests { fn preserves_usage_based_plan_type_wire_name() { let codex_home = TempDir::new().expect("tempdir"); write_chatgpt_auth(codex_home.path(), "self_serve_business_usage_based"); + let forced_workspace_ids = workspace_ids("workspace-1"); let auth = load_local_chatgpt_auth( codex_home.path(), AuthCredentialsStoreMode::File, - Some("workspace-1"), + Some(&forced_workspace_ids), ) .expect("chatgpt auth should load"); diff --git a/docs/config.md b/docs/config.md index c314ce22837..ab5637c27e8 100644 --- a/docs/config.md +++ b/docs/config.md @@ -54,6 +54,27 @@ When Codex knows which client started the turn, the legacy notify JSON payload a The generated JSON Schema for `config.toml` lives at `codex-rs/core/config.schema.json`. +## Managed ChatGPT Workspace Restrictions + +Managed deployments can restrict ChatGPT login to one or more workspace IDs with +`forced_chatgpt_workspace_id`. Existing single-workspace configurations remain +valid: + +```toml +forced_chatgpt_workspace_id = "workspace_id" +``` + +To allow any of several workspaces, set the same key to a list: + +```toml +forced_chatgpt_workspace_id = ["workspace_id_a", "workspace_id_b"] +``` + +Codex trims whitespace from configured workspace IDs and ignores empty entries. +An empty list remains an active allowlist and allows no ChatGPT workspace. If +this setting is present, ChatGPT credentials whose token workspace ID is not in +the configured allowlist are rejected and logged out. + ## SQLite State DB Codex stores the SQLite-backed state DB under `sqlite_home` (config key) or the From 0f54c3a97b426ffe0661516e40df27e792efb8cb Mon Sep 17 00:00:00 2001 From: rreichel3-oai Date: Wed, 15 Apr 2026 14:32:08 -0400 Subject: [PATCH 2/4] Support forced workspace ids in requirements Allow requirements.toml and cloud requirements to specify forced_chatgpt_workspace_id using the same single string or array shape as config.toml. Prefer the requirements value during config resolution, expose it through configRequirements/read, and regenerate app-server schema fixtures. Document requirements precedence and add targeted coverage for requirements parsing, config resolution, API mapping, and cloud requirements fixtures. --- .../codex_app_server_protocol.schemas.json | 10 ++++ .../codex_app_server_protocol.v2.schemas.json | 10 ++++ .../v2/ConfigRequirementsReadResponse.json | 24 +++++++++ .../typescript/v2/ConfigRequirements.ts | 3 +- .../app-server-protocol/src/protocol/v2.rs | 2 + codex-rs/app-server/src/config_api.rs | 18 +++++++ codex-rs/cloud-requirements/src/lib.rs | 16 ++++++ codex-rs/config/src/config_requirements.rs | 49 +++++++++++++++++++ codex-rs/core/src/config/config_tests.rs | 35 +++++++++++++ codex-rs/core/src/config/mod.rs | 18 +++++-- codex-rs/core/src/config_loader/mod.rs | 7 +++ codex-rs/core/src/config_loader/tests.rs | 3 ++ codex-rs/tui/src/debug_config.rs | 2 + docs/config.md | 8 +-- 14 files changed, 198 insertions(+), 7 deletions(-) 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 dd92d485d0d..9c017d9975c 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 @@ -6922,6 +6922,16 @@ "object", "null" ] + }, + "forcedChatgptWorkspaceId": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ForcedChatgptWorkspaceIds" + }, + { + "type": "null" + } + ] } }, "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 0651001e7b7..42d9af35c6a 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 @@ -3539,6 +3539,16 @@ "object", "null" ] + }, + "forcedChatgptWorkspaceId": { + "anyOf": [ + { + "$ref": "#/definitions/ForcedChatgptWorkspaceIds" + }, + { + "type": "null" + } + ] } }, "type": "object" 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 614575a9555..eb3ad4e7047 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -106,10 +106,34 @@ "object", "null" ] + }, + "forcedChatgptWorkspaceId": { + "anyOf": [ + { + "$ref": "#/definitions/ForcedChatgptWorkspaceIds" + }, + { + "type": "null" + } + ] } }, "type": "object" }, + "ForcedChatgptWorkspaceIds": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "Workspace IDs that ChatGPT auth is allowed to use.\n\nThe config parser accepts the historical single-string form and the newer list form for deployments that allow multiple ChatGPT workspaces." + }, "NetworkDomainPermission": { "enum": [ "allow", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts index 47a99453fe3..b04edd06d10 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts @@ -1,9 +1,10 @@ // 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 { ForcedChatgptWorkspaceIds } from "../ForcedChatgptWorkspaceIds"; import type { WebSearchMode } from "../WebSearchMode"; import type { AskForApproval } from "./AskForApproval"; import type { ResidencyRequirement } from "./ResidencyRequirement"; import type { SandboxMode } from "./SandboxMode"; -export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedWebSearchModes: Array | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null}; +export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedWebSearchModes: Array | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null, forcedChatgptWorkspaceId: ForcedChatgptWorkspaceIds | null}; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index f220c6d5b61..e42c0d6614b 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -873,6 +873,7 @@ pub struct ConfigRequirements { pub enforce_residency: Option, #[experimental("configRequirements/read.network")] pub network: Option, + pub forced_chatgpt_workspace_id: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -7700,6 +7701,7 @@ mod tests { feature_requirements: None, enforce_residency: None, network: None, + forced_chatgpt_workspace_id: None, }); assert_eq!(reason, Some("askForApproval.granular")); diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index 4d0b450d739..9a88ad2694b 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -398,6 +398,7 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR .enforce_residency .map(map_residency_requirement_to_api), network: requirements.network.map(map_network_requirements_to_api), + forced_chatgpt_workspace_id: requirements.forced_chatgpt_workspace_id, } } @@ -567,6 +568,12 @@ mod tests { codex_core::config_loader::WebSearchModeRequirement::Cached, ]), guardian_policy_config: None, + forced_chatgpt_workspace_id: Some( + codex_protocol::config_types::ForcedChatgptWorkspaceIds::Multiple(vec![ + "workspace-a".to_string(), + "workspace-b".to_string(), + ]), + ), feature_requirements: Some(codex_core::config_loader::FeatureRequirementsToml { entries: std::collections::BTreeMap::from([ ("apps".to_string(), false), @@ -642,6 +649,15 @@ mod tests { mapped.enforce_residency, Some(codex_app_server_protocol::ResidencyRequirement::Us), ); + assert_eq!( + mapped.forced_chatgpt_workspace_id, + Some( + codex_protocol::config_types::ForcedChatgptWorkspaceIds::Multiple(vec![ + "workspace-a".to_string(), + "workspace-b".to_string(), + ]) + ), + ); assert_eq!( mapped.network, Some(NetworkRequirements { @@ -676,6 +692,7 @@ mod tests { allowed_sandbox_modes: None, allowed_web_search_modes: None, guardian_policy_config: None, + forced_chatgpt_workspace_id: None, feature_requirements: None, mcp_servers: None, apps: None, @@ -733,6 +750,7 @@ mod tests { allowed_sandbox_modes: None, allowed_web_search_modes: Some(Vec::new()), guardian_policy_config: None, + forced_chatgpt_workspace_id: None, feature_requirements: None, mcp_servers: None, apps: None, diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs index 6ec59602895..fda14b0ab9d 100644 --- a/codex-rs/cloud-requirements/src/lib.rs +++ b/codex-rs/cloud-requirements/src/lib.rs @@ -1166,6 +1166,7 @@ mod tests { allowed_sandbox_modes: None, allowed_web_search_modes: None, guardian_policy_config: None, + forced_chatgpt_workspace_id: None, feature_requirements: None, mcp_servers: None, apps: None, @@ -1195,6 +1196,7 @@ mod tests { allowed_sandbox_modes: None, allowed_web_search_modes: None, guardian_policy_config: None, + forced_chatgpt_workspace_id: None, feature_requirements: None, mcp_servers: None, apps: None, @@ -1224,6 +1226,7 @@ mod tests { allowed_sandbox_modes: None, allowed_web_search_modes: None, guardian_policy_config: None, + forced_chatgpt_workspace_id: None, feature_requirements: None, mcp_servers: None, apps: None, @@ -1270,6 +1273,7 @@ mod tests { allowed_sandbox_modes: None, allowed_web_search_modes: None, guardian_policy_config: None, + forced_chatgpt_workspace_id: None, feature_requirements: None, mcp_servers: None, apps: None, @@ -1352,6 +1356,7 @@ enabled = false allowed_sandbox_modes: None, allowed_web_search_modes: None, guardian_policy_config: None, + forced_chatgpt_workspace_id: None, feature_requirements: None, mcp_servers: None, apps: None, @@ -1424,6 +1429,7 @@ enabled = false allowed_sandbox_modes: None, allowed_web_search_modes: None, guardian_policy_config: None, + forced_chatgpt_workspace_id: None, feature_requirements: None, mcp_servers: None, apps: None, @@ -1494,6 +1500,7 @@ enabled = false allowed_sandbox_modes: None, allowed_web_search_modes: None, guardian_policy_config: None, + forced_chatgpt_workspace_id: None, feature_requirements: None, mcp_servers: None, apps: None, @@ -1691,6 +1698,7 @@ enabled = false allowed_sandbox_modes: None, allowed_web_search_modes: None, guardian_policy_config: None, + forced_chatgpt_workspace_id: None, feature_requirements: None, mcp_servers: None, apps: None, @@ -1726,6 +1734,7 @@ enabled = false allowed_sandbox_modes: None, allowed_web_search_modes: None, guardian_policy_config: None, + forced_chatgpt_workspace_id: None, feature_requirements: None, mcp_servers: None, apps: None, @@ -1781,6 +1790,7 @@ enabled = false allowed_sandbox_modes: None, allowed_web_search_modes: None, guardian_policy_config: None, + forced_chatgpt_workspace_id: None, feature_requirements: None, mcp_servers: None, apps: None, @@ -1831,6 +1841,7 @@ enabled = false allowed_sandbox_modes: None, allowed_web_search_modes: None, guardian_policy_config: None, + forced_chatgpt_workspace_id: None, feature_requirements: None, mcp_servers: None, apps: None, @@ -1885,6 +1896,7 @@ enabled = false allowed_sandbox_modes: None, allowed_web_search_modes: None, guardian_policy_config: None, + forced_chatgpt_workspace_id: None, feature_requirements: None, mcp_servers: None, apps: None, @@ -1940,6 +1952,7 @@ enabled = false allowed_sandbox_modes: None, allowed_web_search_modes: None, guardian_policy_config: None, + forced_chatgpt_workspace_id: None, feature_requirements: None, mcp_servers: None, apps: None, @@ -1995,6 +2008,7 @@ enabled = false allowed_sandbox_modes: None, allowed_web_search_modes: None, guardian_policy_config: None, + forced_chatgpt_workspace_id: None, feature_requirements: None, mcp_servers: None, apps: None, @@ -2083,6 +2097,7 @@ enabled = false allowed_sandbox_modes: None, allowed_web_search_modes: None, guardian_policy_config: None, + forced_chatgpt_workspace_id: None, feature_requirements: None, mcp_servers: None, apps: None, @@ -2110,6 +2125,7 @@ enabled = false allowed_sandbox_modes: None, allowed_web_search_modes: None, guardian_policy_config: None, + forced_chatgpt_workspace_id: None, feature_requirements: None, mcp_servers: None, apps: None, diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 7abedc62f15..333aca2d1a4 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -1,4 +1,5 @@ use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ForcedChatgptWorkspaceIds; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::WebSearchMode; use codex_protocol::protocol::AskForApproval; @@ -505,6 +506,7 @@ pub struct ConfigRequirementsToml { #[serde(rename = "experimental_network")] pub network: Option, pub guardian_policy_config: Option, + pub forced_chatgpt_workspace_id: Option, } /// Value paired with the requirement source it came from, for better error @@ -542,6 +544,7 @@ pub struct ConfigRequirementsWithSources { pub enforce_residency: Option>, pub network: Option>, pub guardian_policy_config: Option>, + pub forced_chatgpt_workspace_id: Option>, } impl ConfigRequirementsWithSources { @@ -574,6 +577,7 @@ impl ConfigRequirementsWithSources { enforce_residency: _, network: _, guardian_policy_config: _, + forced_chatgpt_workspace_id: _, } = &other; let mut other = other; @@ -599,6 +603,7 @@ impl ConfigRequirementsWithSources { enforce_residency, network, guardian_policy_config, + forced_chatgpt_workspace_id, } ); @@ -624,6 +629,7 @@ impl ConfigRequirementsWithSources { enforce_residency, network, guardian_policy_config, + forced_chatgpt_workspace_id, } = self; ConfigRequirementsToml { allowed_approval_policies: allowed_approval_policies.map(|sourced| sourced.value), @@ -637,6 +643,7 @@ impl ConfigRequirementsWithSources { enforce_residency: enforce_residency.map(|sourced| sourced.value), network: network.map(|sourced| sourced.value), guardian_policy_config: guardian_policy_config.map(|sourced| sourced.value), + forced_chatgpt_workspace_id: forced_chatgpt_workspace_id.map(|sourced| sourced.value), } } } @@ -696,6 +703,11 @@ impl ConfigRequirementsToml { .guardian_policy_config .as_deref() .is_none_or(|value| value.trim().is_empty()) + && self + .forced_chatgpt_workspace_id + .clone() + .and_then(ForcedChatgptWorkspaceIds::normalized) + .is_none() } } @@ -715,6 +727,7 @@ impl TryFrom for ConfigRequirements { enforce_residency, network, guardian_policy_config: _guardian_policy_config, + forced_chatgpt_workspace_id: _forced_chatgpt_workspace_id, } = toml; let approval_policy = match allowed_approval_policies { @@ -968,6 +981,7 @@ mod tests { enforce_residency, network, guardian_policy_config, + forced_chatgpt_workspace_id, } = toml; ConfigRequirementsWithSources { allowed_approval_policies: allowed_approval_policies @@ -988,6 +1002,8 @@ mod tests { network: network.map(|value| Sourced::new(value, RequirementSource::Unknown)), guardian_policy_config: guardian_policy_config .map(|value| Sourced::new(value, RequirementSource::Unknown)), + forced_chatgpt_workspace_id: forced_chatgpt_workspace_id + .map(|value| Sourced::new(value, RequirementSource::Unknown)), } } @@ -1013,6 +1029,10 @@ mod tests { let enforce_residency = ResidencyRequirement::Us; let enforce_source = source.clone(); let guardian_policy_config = "Use the company-managed guardian policy.".to_string(); + let forced_chatgpt_workspace_id = ForcedChatgptWorkspaceIds::Multiple(vec![ + "workspace-a".to_string(), + "workspace-b".to_string(), + ]); // Intentionally constructed without `..Default::default()` so adding a new field to // `ConfigRequirementsToml` forces this test to be updated. @@ -1028,6 +1048,7 @@ mod tests { enforce_residency: Some(enforce_residency), network: None, guardian_policy_config: Some(guardian_policy_config.clone()), + forced_chatgpt_workspace_id: Some(forced_chatgpt_workspace_id.clone()), }; target.merge_unset_fields(source.clone(), other); @@ -1058,6 +1079,10 @@ mod tests { enforce_residency: Some(Sourced::new(enforce_residency, enforce_source)), network: None, guardian_policy_config: Some(Sourced::new(guardian_policy_config, source)), + forced_chatgpt_workspace_id: Some(Sourced::new( + forced_chatgpt_workspace_id, + RequirementSource::LegacyManagedConfigTomlFromMdm + )), } ); } @@ -1094,6 +1119,7 @@ mod tests { enforce_residency: None, network: None, guardian_policy_config: None, + forced_chatgpt_workspace_id: None, } ); Ok(()) @@ -1138,6 +1164,7 @@ mod tests { enforce_residency: None, network: None, guardian_policy_config: None, + forced_chatgpt_workspace_id: None, } ); Ok(()) @@ -1193,6 +1220,28 @@ Use the cloud-managed guardian policy. Ok(()) } + #[test] + fn deserialize_forced_chatgpt_workspace_id() -> Result<()> { + let single: ConfigRequirementsToml = + from_str(r#"forced_chatgpt_workspace_id = "workspace-a""#)?; + assert_eq!( + single.forced_chatgpt_workspace_id, + Some(ForcedChatgptWorkspaceIds::Single("workspace-a".to_string())) + ); + + let multiple: ConfigRequirementsToml = + from_str(r#"forced_chatgpt_workspace_id = ["workspace-a", "workspace-b"]"#)?; + assert_eq!( + multiple.forced_chatgpt_workspace_id, + Some(ForcedChatgptWorkspaceIds::Multiple(vec![ + "workspace-a".to_string(), + "workspace-b".to_string(), + ])) + ); + + Ok(()) + } + #[test] fn blank_guardian_policy_config_is_empty() -> Result<()> { let requirements: ConfigRequirementsToml = from_str( diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 2723dc9ece7..f35dcb55416 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -4477,6 +4477,39 @@ fn forced_chatgpt_workspace_id_accepts_string_and_list() -> std::io::Result<()> Ok(()) } +#[test] +fn requirements_forced_chatgpt_workspace_id_accepts_string_and_list() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let requirements_toml: crate::config_loader::ConfigRequirementsToml = + toml::from_str(r#"forced_chatgpt_workspace_id = [" org_a ", "", "org_b"]"#) + .expect("requirements workspace id list should parse"); + let config_layer_stack = + ConfigLayerStack::new(Vec::new(), Default::default(), requirements_toml) + .map_err(std::io::Error::other)?; + + let config = Config::load_config_with_layer_stack( + ConfigToml { + forced_chatgpt_workspace_id: Some(ForcedChatgptWorkspaceIds::Single( + "org_config".to_string(), + )), + ..Default::default() + }, + ConfigOverrides::default(), + codex_home.abs(), + config_layer_stack, + )?; + + assert_eq!( + config.forced_chatgpt_workspace_id, + Some(ForcedChatgptWorkspaceIds::Multiple(vec![ + "org_a".to_string(), + "org_b".to_string() + ])) + ); + + Ok(()) +} + fn create_test_fixture() -> std::io::Result { let toml = r#" model = "o3" @@ -5172,6 +5205,7 @@ fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() -> any enforce_residency: None, network: None, guardian_policy_config: None, + forced_chatgpt_workspace_id: None, }; let requirement_source = crate::config_loader::RequirementSource::Unknown; let requirement_source_for_error = requirement_source.clone(); @@ -5793,6 +5827,7 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s enforce_residency: None, network: None, guardian_policy_config: None, + forced_chatgpt_workspace_id: None, }; let config = ConfigBuilder::without_managed_config_for_tests() diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index b8ebf0f7382..e26972e57a6 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1817,9 +1817,12 @@ impl Config { let include_apply_patch_tool_flag = features.enabled(Feature::ApplyPatchFreeform); let use_experimental_unified_exec_tool = features.enabled(Feature::UnifiedExec); - let forced_chatgpt_workspace_id = cfg - .forced_chatgpt_workspace_id - .and_then(ForcedChatgptWorkspaceIds::normalized); + let forced_chatgpt_workspace_id = + forced_chatgpt_workspace_id_from_requirements(config_layer_stack.requirements_toml()) + .or_else(|| { + cfg.forced_chatgpt_workspace_id + .and_then(ForcedChatgptWorkspaceIds::normalized) + }); let forced_login_method = cfg.forced_login_method; @@ -2312,6 +2315,15 @@ fn guardian_policy_config_from_requirements( }) } +fn forced_chatgpt_workspace_id_from_requirements( + requirements_toml: &ConfigRequirementsToml, +) -> Option { + requirements_toml + .forced_chatgpt_workspace_id + .clone() + .and_then(ForcedChatgptWorkspaceIds::normalized) +} + fn toml_uses_deprecated_instructions_file(value: &TomlValue) -> bool { let Some(table) = value.as_table() else { return false; diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 36fc956bf44..b8c80e948c1 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -13,6 +13,7 @@ use codex_config::config_toml::ConfigToml; use codex_config::config_toml::ProjectConfig; use codex_git_utils::resolve_root_git_project_for_trust; use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ForcedChatgptWorkspaceIds; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::TrustLevel; use codex_protocol::protocol::AskForApproval; @@ -885,6 +886,7 @@ struct LegacyManagedConfigToml { approval_policy: Option, approvals_reviewer: Option, sandbox_mode: Option, + forced_chatgpt_workspace_id: Option, } impl From for ConfigRequirementsToml { @@ -895,6 +897,7 @@ impl From for ConfigRequirementsToml { approval_policy, approvals_reviewer, sandbox_mode, + forced_chatgpt_workspace_id, } = legacy; if let Some(approval_policy) = approval_policy { config_requirements_toml.allowed_approval_policies = Some(vec![approval_policy]); @@ -916,6 +919,7 @@ impl From for ConfigRequirementsToml { } config_requirements_toml.allowed_sandbox_modes = Some(allowed_modes); } + config_requirements_toml.forced_chatgpt_workspace_id = forced_chatgpt_workspace_id; config_requirements_toml } } @@ -971,6 +975,7 @@ foo = "xyzzy" approval_policy: None, approvals_reviewer: None, sandbox_mode: Some(SandboxMode::WorkspaceWrite), + forced_chatgpt_workspace_id: None, }; let requirements = ConfigRequirementsToml::from(legacy); @@ -990,6 +995,7 @@ foo = "xyzzy" approval_policy: None, approvals_reviewer: Some(ApprovalsReviewer::GuardianSubagent), sandbox_mode: None, + forced_chatgpt_workspace_id: None, }; let requirements = ConfigRequirementsToml::from(legacy); @@ -1009,6 +1015,7 @@ foo = "xyzzy" approval_policy: None, approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_mode: None, + forced_chatgpt_workspace_id: None, }; let requirements = ConfigRequirementsToml::from(legacy); diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index ed69682e86d..1ae6f8df530 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -637,6 +637,7 @@ allowed_approval_policies = ["on-request"] enforce_residency: None, network: None, guardian_policy_config: None, + forced_chatgpt_workspace_id: None, })) }), ) @@ -689,6 +690,7 @@ allowed_approval_policies = ["on-request"] enforce_residency: None, network: None, guardian_policy_config: None, + forced_chatgpt_workspace_id: None, }, ); load_requirements_toml(&mut config_requirements_toml, &requirements_file).await?; @@ -730,6 +732,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> enforce_residency: None, network: None, guardian_policy_config: None, + forced_chatgpt_workspace_id: None, }; let expected = requirements.clone(); let cloud_requirements = CloudRequirementsLoader::new(async move { Ok(Some(requirements)) }); diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index 16af4c24fbe..1926361e5b9 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -612,6 +612,7 @@ mod tests { allowed_sandbox_modes: Some(vec![SandboxModeRequirement::ReadOnly]), allowed_web_search_modes: Some(vec![WebSearchModeRequirement::Cached]), guardian_policy_config: None, + forced_chatgpt_workspace_id: None, feature_requirements: Some(FeatureRequirementsToml { entries: BTreeMap::from([("guardian_approval".to_string(), true)]), }), @@ -805,6 +806,7 @@ approval_policy = "never" allowed_sandbox_modes: None, allowed_web_search_modes: Some(Vec::new()), guardian_policy_config: None, + forced_chatgpt_workspace_id: None, feature_requirements: None, mcp_servers: None, apps: None, diff --git a/docs/config.md b/docs/config.md index ab5637c27e8..29c1483df55 100644 --- a/docs/config.md +++ b/docs/config.md @@ -57,8 +57,8 @@ The generated JSON Schema for `config.toml` lives at `codex-rs/core/config.schem ## Managed ChatGPT Workspace Restrictions Managed deployments can restrict ChatGPT login to one or more workspace IDs with -`forced_chatgpt_workspace_id`. Existing single-workspace configurations remain -valid: +`forced_chatgpt_workspace_id` in either `config.toml` or `requirements.toml`. +Existing single-workspace configurations remain valid: ```toml forced_chatgpt_workspace_id = "workspace_id" @@ -73,7 +73,9 @@ forced_chatgpt_workspace_id = ["workspace_id_a", "workspace_id_b"] Codex trims whitespace from configured workspace IDs and ignores empty entries. An empty list remains an active allowlist and allows no ChatGPT workspace. If this setting is present, ChatGPT credentials whose token workspace ID is not in -the configured allowlist are rejected and logged out. +the configured allowlist are rejected and logged out. A value from +`requirements.toml` takes precedence over ordinary config because requirements +are managed policy. ## SQLite State DB From 6e327bd0577c09f34b7b35c94a8cf170317ba54e Mon Sep 17 00:00:00 2001 From: rreichel3-oai Date: Wed, 15 Apr 2026 17:09:21 -0400 Subject: [PATCH 3/4] codex: fix CI failure on PR #17959 --- codex-rs/core/src/agent_identity.rs | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/codex-rs/core/src/agent_identity.rs b/codex-rs/core/src/agent_identity.rs index 13a07cd9616..b5168b60da3 100644 --- a/codex-rs/core/src/agent_identity.rs +++ b/codex-rs/core/src/agent_identity.rs @@ -13,6 +13,7 @@ use codex_login::AgentIdentityAuthRecord; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_login::default_client::create_client; +use codex_protocol::config_types::ForcedChatgptWorkspaceIds; use codex_protocol::protocol::SessionSource; use ed25519_dalek::SigningKey; use ed25519_dalek::VerifyingKey; @@ -375,19 +376,26 @@ impl StoredAgentIdentity { } impl AgentIdentityBinding { - fn from_auth(auth: &CodexAuth, forced_workspace_id: Option) -> Option { + fn from_auth( + auth: &CodexAuth, + forced_workspace_ids: Option, + ) -> Option { if !auth.is_chatgpt_auth() { return None; } let token_data = auth.get_token_data().ok()?; - let resolved_account_id = - forced_workspace_id - .filter(|value| !value.is_empty()) - .or(token_data - .account_id - .clone() - .filter(|value| !value.is_empty()))?; + let token_account_id = token_data + .account_id + .clone() + .filter(|value| !value.is_empty()); + let resolved_account_id = match forced_workspace_ids { + Some(forced_workspace_ids) => token_account_id + .clone() + .filter(|account_id| forced_workspace_ids.contains(account_id)) + .or_else(|| forced_workspace_ids.ids().first().cloned()), + None => token_account_id, + }?; Some(Self { binding_id: format!("chatgpt-account-{resolved_account_id}"), From 089fc325d463bb0504cb876b8e25de492f21dee1 Mon Sep 17 00:00:00 2001 From: rreichel3-oai Date: Wed, 15 Apr 2026 17:12:44 -0400 Subject: [PATCH 4/4] codex: normalize rebase fallout on PR #17959 --- codex-rs/login/src/auth/manager.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index d31c28721dc..9fc00cfedb2 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -1447,10 +1447,7 @@ impl AuthManager { } } - pub fn set_forced_chatgpt_workspace_id( - &self, - workspace_id: Option, - ) { + pub fn set_forced_chatgpt_workspace_id(&self, workspace_id: Option) { if let Ok(mut guard) = self.forced_chatgpt_workspace_id.write() && *guard != workspace_id {