Support forced workspace ids in requirements

Allow requirements.toml and cloud requirements to specify forced_chatgpt_workspace_id using the same single string or array shape as config.toml.

Prefer the requirements value during config resolution, expose it through configRequirements/read, and regenerate app-server schema fixtures.

Document requirements precedence and add targeted coverage for requirements parsing, config resolution, API mapping, and cloud requirements fixtures.
This commit is contained in:
rreichel3-oai
2026-04-15 16:54:04 -04:00
parent cad30d2689
commit 0f54c3a97b
14 changed files with 198 additions and 7 deletions
@@ -6922,6 +6922,16 @@
"object",
"null"
]
},
"forcedChatgptWorkspaceId": {
"anyOf": [
{
"$ref": "#/definitions/v2/ForcedChatgptWorkspaceIds"
},
{
"type": "null"
}
]
}
},
"type": "object"
@@ -3539,6 +3539,16 @@
"object",
"null"
]
},
"forcedChatgptWorkspaceId": {
"anyOf": [
{
"$ref": "#/definitions/ForcedChatgptWorkspaceIds"
},
{
"type": "null"
}
]
}
},
"type": "object"
@@ -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",
@@ -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<AskForApproval> | null, allowedSandboxModes: Array<SandboxMode> | null, allowedWebSearchModes: Array<WebSearchMode> | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null};
export type ConfigRequirements = {allowedApprovalPolicies: Array<AskForApproval> | null, allowedSandboxModes: Array<SandboxMode> | null, allowedWebSearchModes: Array<WebSearchMode> | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null, forcedChatgptWorkspaceId: ForcedChatgptWorkspaceIds | null};
@@ -873,6 +873,7 @@ pub struct ConfigRequirements {
pub enforce_residency: Option<ResidencyRequirement>,
#[experimental("configRequirements/read.network")]
pub network: Option<NetworkRequirements>,
pub forced_chatgpt_workspace_id: Option<ForcedChatgptWorkspaceIds>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -7700,6 +7701,7 @@ mod tests {
feature_requirements: None,
enforce_residency: None,
network: None,
forced_chatgpt_workspace_id: None,
});
assert_eq!(reason, Some("askForApproval.granular"));
+18
View File
@@ -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,
+16
View File
@@ -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,
@@ -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<NetworkRequirementsToml>,
pub guardian_policy_config: Option<String>,
pub forced_chatgpt_workspace_id: Option<ForcedChatgptWorkspaceIds>,
}
/// Value paired with the requirement source it came from, for better error
@@ -542,6 +544,7 @@ pub struct ConfigRequirementsWithSources {
pub enforce_residency: Option<Sourced<ResidencyRequirement>>,
pub network: Option<Sourced<NetworkRequirementsToml>>,
pub guardian_policy_config: Option<Sourced<String>>,
pub forced_chatgpt_workspace_id: Option<Sourced<ForcedChatgptWorkspaceIds>>,
}
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<ConfigRequirementsWithSources> 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(
+35
View File
@@ -4477,6 +4477,39 @@ fn forced_chatgpt_workspace_id_accepts_string_and_list() -> std::io::Result<()>
Ok(())
}
#[test]
fn requirements_forced_chatgpt_workspace_id_accepts_string_and_list() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let requirements_toml: crate::config_loader::ConfigRequirementsToml =
toml::from_str(r#"forced_chatgpt_workspace_id = [" org_a ", "", "org_b"]"#)
.expect("requirements workspace id list should parse");
let config_layer_stack =
ConfigLayerStack::new(Vec::new(), Default::default(), requirements_toml)
.map_err(std::io::Error::other)?;
let config = Config::load_config_with_layer_stack(
ConfigToml {
forced_chatgpt_workspace_id: Some(ForcedChatgptWorkspaceIds::Single(
"org_config".to_string(),
)),
..Default::default()
},
ConfigOverrides::default(),
codex_home.abs(),
config_layer_stack,
)?;
assert_eq!(
config.forced_chatgpt_workspace_id,
Some(ForcedChatgptWorkspaceIds::Multiple(vec![
"org_a".to_string(),
"org_b".to_string()
]))
);
Ok(())
}
fn create_test_fixture() -> std::io::Result<PrecedenceTestFixture> {
let toml = r#"
model = "o3"
@@ -5172,6 +5205,7 @@ fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() -> any
enforce_residency: None,
network: None,
guardian_policy_config: None,
forced_chatgpt_workspace_id: None,
};
let requirement_source = crate::config_loader::RequirementSource::Unknown;
let requirement_source_for_error = requirement_source.clone();
@@ -5793,6 +5827,7 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s
enforce_residency: None,
network: None,
guardian_policy_config: None,
forced_chatgpt_workspace_id: None,
};
let config = ConfigBuilder::without_managed_config_for_tests()
+15 -3
View File
@@ -1817,9 +1817,12 @@ impl Config {
let include_apply_patch_tool_flag = features.enabled(Feature::ApplyPatchFreeform);
let use_experimental_unified_exec_tool = features.enabled(Feature::UnifiedExec);
let forced_chatgpt_workspace_id = cfg
.forced_chatgpt_workspace_id
.and_then(ForcedChatgptWorkspaceIds::normalized);
let forced_chatgpt_workspace_id =
forced_chatgpt_workspace_id_from_requirements(config_layer_stack.requirements_toml())
.or_else(|| {
cfg.forced_chatgpt_workspace_id
.and_then(ForcedChatgptWorkspaceIds::normalized)
});
let forced_login_method = cfg.forced_login_method;
@@ -2312,6 +2315,15 @@ fn guardian_policy_config_from_requirements(
})
}
fn forced_chatgpt_workspace_id_from_requirements(
requirements_toml: &ConfigRequirementsToml,
) -> Option<ForcedChatgptWorkspaceIds> {
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;
+7
View File
@@ -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<AskForApproval>,
approvals_reviewer: Option<ApprovalsReviewer>,
sandbox_mode: Option<SandboxMode>,
forced_chatgpt_workspace_id: Option<ForcedChatgptWorkspaceIds>,
}
impl From<LegacyManagedConfigToml> for ConfigRequirementsToml {
@@ -895,6 +897,7 @@ impl From<LegacyManagedConfigToml> 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<LegacyManagedConfigToml> 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);
+3
View File
@@ -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)) });
+2
View File
@@ -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,
+5 -3
View File
@@ -57,8 +57,8 @@ The generated JSON Schema for `config.toml` lives at `codex-rs/core/config.schem
## Managed ChatGPT Workspace Restrictions
Managed deployments can restrict ChatGPT login to one or more workspace IDs with
`forced_chatgpt_workspace_id`. Existing single-workspace configurations remain
valid:
`forced_chatgpt_workspace_id` in either `config.toml` or `requirements.toml`.
Existing single-workspace configurations remain valid:
```toml
forced_chatgpt_workspace_id = "workspace_id"
@@ -73,7 +73,9 @@ forced_chatgpt_workspace_id = ["workspace_id_a", "workspace_id_b"]
Codex trims whitespace from configured workspace IDs and ignores empty entries.
An empty list remains an active allowlist and allows no ChatGPT workspace. If
this setting is present, ChatGPT credentials whose token workspace ID is not in
the configured allowlist are rejected and logged out.
the configured allowlist are rejected and logged out. A value from
`requirements.toml` takes precedence over ordinary config because requirements
are managed policy.
## SQLite State DB