[codex] Support multiple forced ChatGPT workspaces (#18161)

## Summary

This change lets `forced_chatgpt_workspace_id` accept multiple workspace
IDs instead of a single value.

It keeps the existing config key name, adds backward-compatible parsing
for a single string in `config.toml`, and normalizes the setting into an
allowed workspace list across login enforcement, app-server config
surfaces, and local ChatGPT auth helpers.

## Why

Workspace-restricted deployments may need to allow more than one ChatGPT
workspace without dropping the guardrail entirely.

## Server-side impact

Codex's local server and app-server protocol needed changes because they
previously assumed a single workspace ID. The local login flow now
matches the auth backend interface by sending the allowed workspace list
as a single comma-separated `allowed_workspace_id` query parameter.

## Validation

This was tested with:

- A single workspace config
- With multi-workspace configs
- With multiple workspaces in the config
- The user only being a part of a subset of them

All were successful.

Automated coverage:

- `cargo test -p codex-login`
- `cargo test -p codex-app-server-protocol`
- `cargo test -p codex-tui local_chatgpt_auth`
- `cargo test --locked -p codex-app-server
login_account_chatgpt_includes_forced_workspace_allowlist_query_param`
This commit is contained in:
rreichel3-oai authored and GitHub committed 2026-05-14 17:11:36 -04:00
1 parent 32b45a43e2
commit 02a7205250
22 files changed
+673 -117

No files matched your search

+49
View File
@@ -2725,6 +2725,55 @@ async fn runtime_config_resolves_terminal_resize_reflow_defaults_and_overrides()
);
}
#[tokio::test]
async fn forced_chatgpt_workspace_id_empty_values_disable_runtime_restriction()
-> std::io::Result<()> {
let cases: Vec<(&str, &str, Option<Vec<&str>>)> = vec![
("unset", "", None),
("empty string", r#"forced_chatgpt_workspace_id = """#, None),
(
"whitespace string",
r#"forced_chatgpt_workspace_id = " ""#,
None,
),
("empty list", r#"forced_chatgpt_workspace_id = []"#, None),
(
"blank list entries",
r#"forced_chatgpt_workspace_id = ["", " "]"#,
None,
),
(
"mixed list entries",
r#"forced_chatgpt_workspace_id = ["", " 123e4567-e89b-42d3-a456-426614174000 ", "123e4567-e89b-42d3-a456-426614174001"]"#,
Some(vec![
"123e4567-e89b-42d3-a456-426614174000",
"123e4567-e89b-42d3-a456-426614174001",
]),
),
];
for (name, toml, expected) in cases {
let cfg_toml: ConfigToml = toml::from_str(toml)
.unwrap_or_else(|err| panic!("{name} should parse forced_chatgpt_workspace_id: {err}"));
let config = Config::load_from_base_config_with_overrides(
cfg_toml,
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await?;
let expected = expected.map(|values| {
values
.into_iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
});
assert_eq!(config.forced_chatgpt_workspace_id, expected, "{name}");
}
Ok(())
}
#[tokio::test]
async fn legacy_remote_thread_store_endpoint_is_rejected() {
let cfg: ConfigToml =
+15 -13
View File
@@ -752,15 +752,14 @@ pub struct Config {
/// instructions inserted into developer messages when realtime becomes
/// active.
pub experimental_realtime_start_instructions: Option<String>,
/// Experimental / do not use. When set, app-server fetches thread-scoped
/// config from a remote service at this endpoint.
pub experimental_thread_config_endpoint: Option<String>,
/// Experimental / do not use. Selects the thread persistence backend.
pub experimental_thread_store: ThreadStoreConfig,
/// 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<Vec<String>>,
/// When set, restricts the login mechanism users may use.
pub forced_login_method: Option<ForcedLoginMethod>,
@@ -891,7 +890,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<Vec<String>> {
self.forced_chatgpt_workspace_id.clone()
}
@@ -2849,15 +2848,18 @@ 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
.clone()
.map(codex_config::config_toml::ForcedChatgptWorkspaceIds::into_vec)
.map(|values| {
values
.into_iter()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
})
.filter(|values| !values.is_empty());
let forced_login_method = cfg.forced_login_method;