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..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 @@ -6431,9 +6431,13 @@ ] }, "forced_chatgpt_workspace_id": { - "type": [ - "string", - "null" + "anyOf": [ + { + "$ref": "#/definitions/v2/ForcedChatgptWorkspaceIds" + }, + { + "type": "null" + } ] }, "forced_login_method": { @@ -6918,6 +6922,16 @@ "object", "null" ] + }, + "forcedChatgptWorkspaceId": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ForcedChatgptWorkspaceIds" + }, + { + "type": "null" + } + ] } }, "type": "object" @@ -7643,6 +7657,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..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 @@ -3048,9 +3048,13 @@ ] }, "forced_chatgpt_workspace_id": { - "type": [ - "string", - "null" + "anyOf": [ + { + "$ref": "#/definitions/ForcedChatgptWorkspaceIds" + }, + { + "type": "null" + } ] }, "forced_login_method": { @@ -3535,6 +3539,16 @@ "object", "null" ] + }, + "forcedChatgptWorkspaceId": { + "anyOf": [ + { + "$ref": "#/definitions/ForcedChatgptWorkspaceIds" + }, + { + "type": "null" + } + ] } }, "type": "object" @@ -4260,6 +4274,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/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/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/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/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..e42c0d6614b 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, @@ -872,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)] @@ -7699,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/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/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/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/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/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/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}"), diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index b2931e10558..f35dcb55416 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,91 @@ 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(()) +} + +#[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" @@ -5119,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(); @@ -5740,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 b58acbf6511..e26972e57a6 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() } } @@ -1817,14 +1818,11 @@ impl Config { 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()) - } - }); + 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; @@ -2317,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/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..9fc00cfedb2 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,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 { @@ -1454,7 +1456,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 +1700,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/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/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..29c1483df55 100644 --- a/docs/config.md +++ b/docs/config.md @@ -54,6 +54,29 @@ 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` in either `config.toml` or `requirements.toml`. +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. A value from +`requirements.toml` takes precedence over ordinary config because requirements +are managed policy. + ## SQLite State DB Codex stores the SQLite-backed state DB under `sqlite_home` (config key) or the