mirror of
https://github.com/openai/codex.git
synced 2026-11-14 21:40:39 +00:00
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.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string>;
|
||||
@@ -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";
|
||||
|
||||
@@ -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<JsonValue> | { [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<JsonValue> | { [key in string]?: JsonValue } | null });
|
||||
|
||||
@@ -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<AskForApproval>,
|
||||
pub sandbox_mode: Option<SandboxMode>,
|
||||
pub sandbox_settings: Option<SandboxSettings>,
|
||||
pub forced_chatgpt_workspace_id: Option<String>,
|
||||
pub forced_chatgpt_workspace_id: Option<ForcedChatgptWorkspaceIds>,
|
||||
pub forced_login_method: Option<ForcedLoginMethod>,
|
||||
pub model: Option<String>,
|
||||
pub model_reasoning_effort: Option<ReasoningEffort>,
|
||||
|
||||
@@ -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<ApprovalsReviewer>,
|
||||
pub sandbox_mode: Option<SandboxMode>,
|
||||
pub sandbox_workspace_write: Option<SandboxWorkspaceWrite>,
|
||||
pub forced_chatgpt_workspace_id: Option<String>,
|
||||
pub forced_chatgpt_workspace_id: Option<ForcedChatgptWorkspaceIds>,
|
||||
pub forced_login_method: Option<ForcedLoginMethod>,
|
||||
pub web_search: Option<WebSearchMode>,
|
||||
pub tools: Option<ToolsV2>,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<String>,
|
||||
forced_chatgpt_workspace_id: Option<ForcedChatgptWorkspaceIds>,
|
||||
cli_auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||||
) -> std::io::Result<()> {
|
||||
let opts = ServerOptions::new(
|
||||
|
||||
@@ -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<String>,
|
||||
|
||||
/// 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<String>,
|
||||
pub forced_chatgpt_workspace_id: Option<ForcedChatgptWorkspaceIds>,
|
||||
|
||||
/// When set, restricts the login mechanism users may use.
|
||||
#[serde(default)]
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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<PrecedenceTestFixture> {
|
||||
let toml = r#"
|
||||
model = "o3"
|
||||
|
||||
@@ -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<String>,
|
||||
/// When set, restricts ChatGPT login to a specific workspace identifier.
|
||||
pub forced_chatgpt_workspace_id: Option<String>,
|
||||
/// When set, restricts ChatGPT login to one or more workspace identifiers.
|
||||
pub forced_chatgpt_workspace_id: Option<ForcedChatgptWorkspaceIds>,
|
||||
|
||||
/// When set, restricts the login mechanism users may use.
|
||||
pub forced_login_method: Option<ForcedLoginMethod>,
|
||||
@@ -623,7 +624,7 @@ impl AuthManagerConfig for Config {
|
||||
self.cli_auth_credentials_store_mode
|
||||
}
|
||||
|
||||
fn forced_chatgpt_workspace_id(&self) -> Option<String> {
|
||||
fn forced_chatgpt_workspace_id(&self) -> Option<ForcedChatgptWorkspaceIds> {
|
||||
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;
|
||||
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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<ForcedLoginMethod>,
|
||||
pub forced_chatgpt_workspace_id: Option<String>,
|
||||
pub forced_chatgpt_workspace_id: Option<ForcedChatgptWorkspaceIds>,
|
||||
}
|
||||
|
||||
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<CachedAuth>,
|
||||
enable_codex_api_key_env: bool,
|
||||
auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||||
forced_chatgpt_workspace_id: RwLock<Option<String>>,
|
||||
forced_chatgpt_workspace_id: RwLock<Option<ForcedChatgptWorkspaceIds>>,
|
||||
refresh_lock: AsyncMutex<()>,
|
||||
external_auth: RwLock<Option<Arc<dyn ExternalAuth>>>,
|
||||
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<String>;
|
||||
/// Returns the workspace IDs that ChatGPT auth should be restricted to, if any.
|
||||
fn forced_chatgpt_workspace_id(&self) -> Option<ForcedChatgptWorkspaceIds>;
|
||||
}
|
||||
|
||||
impl Debug for AuthManager {
|
||||
@@ -1445,7 +1447,10 @@ impl AuthManager {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_forced_chatgpt_workspace_id(&self, workspace_id: Option<String>) {
|
||||
pub fn set_forced_chatgpt_workspace_id(
|
||||
&self,
|
||||
workspace_id: Option<ForcedChatgptWorkspaceIds>,
|
||||
) {
|
||||
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<String> {
|
||||
pub fn forced_chatgpt_workspace_id(&self) -> Option<ForcedChatgptWorkspaceIds> {
|
||||
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,
|
||||
),
|
||||
)));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<String>,
|
||||
pub forced_chatgpt_workspace_id: Option<String>,
|
||||
pub forced_chatgpt_workspace_id: Option<ForcedChatgptWorkspaceIds>,
|
||||
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<String>,
|
||||
forced_chatgpt_workspace_id: Option<ForcedChatgptWorkspaceIds>,
|
||||
cli_auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||||
) -> Self {
|
||||
Self {
|
||||
@@ -153,7 +154,7 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result<LoginServer> {
|
||||
&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<String, serde_json::Value> {
|
||||
|
||||
/// 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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
|
||||
@@ -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<String>),
|
||||
}
|
||||
|
||||
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<Self> {
|
||||
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::<Vec<_>>();
|
||||
|
||||
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;
|
||||
|
||||
|
||||
@@ -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<LocalChatgptAuth, String> {
|
||||
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");
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user