Enforce managed authentication backend settings (#39043)

## What changed

- Add `cli_auth_credentials_store` and `chatgpt_base_url` as exact managed requirements loaded from local requirements configuration.
- Apply these requirements to runtime and bootstrap authentication configuration, overriding user-configured values.
- Expose the managed values through `configRequirements/read` and reject attempts to change them through config write APIs.
- Ignore these local-only authentication requirements in cloud-managed requirement layers.

## Testing

- Cover requirement loading and composition, runtime and bootstrap overrides, app-server responses, and read-only config writes.

GitOrigin-RevId: 8ea9c096386072036ca5441f3d48af8707988a65
This commit is contained in:
Abhinav
2026-08-17 18:35:54 +00:00
committed by copyberry
parent d0fd4e830a
commit 0f21cb3413
24 changed files with 458 additions and 22 deletions
@@ -7603,6 +7603,15 @@
}
]
},
"CliAuthCredentialsStoreMode": {
"enum": [
"file",
"keyring",
"auto",
"ephemeral"
],
"type": "string"
},
"CodexErrorInfo": {
"description": "This translation layer make sure that we expose codex error code in camel case.\n\nWhen an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.",
"oneOf": [
@@ -8962,12 +8971,28 @@
}
]
},
"chatgptBaseUrl": {
"type": [
"string",
"null"
]
},
"checkForUpdateOnStartup": {
"type": [
"boolean",
"null"
]
},
"cliAuthCredentialsStore": {
"anyOf": [
{
"$ref": "#/definitions/v2/CliAuthCredentialsStoreMode"
},
{
"type": "null"
}
]
},
"computerUse": {
"anyOf": [
{
@@ -1368,6 +1368,15 @@
}
]
},
"CliAuthCredentialsStoreMode": {
"enum": [
"file",
"keyring",
"auto",
"ephemeral"
],
"type": "string"
},
"ClientInfo": {
"properties": {
"name": {
@@ -5040,12 +5049,28 @@
}
]
},
"chatgptBaseUrl": {
"type": [
"string",
"null"
]
},
"checkForUpdateOnStartup": {
"type": [
"boolean",
"null"
]
},
"cliAuthCredentialsStore": {
"anyOf": [
{
"$ref": "#/definitions/CliAuthCredentialsStoreMode"
},
{
"type": "null"
}
]
},
"computerUse": {
"anyOf": [
{
@@ -93,6 +93,15 @@
},
"type": "object"
},
"CliAuthCredentialsStoreMode": {
"enum": [
"file",
"keyring",
"auto",
"ephemeral"
],
"type": "string"
},
"ComputerUseRequirements": {
"properties": {
"allowLockedComputerUse": {
@@ -195,12 +204,28 @@
}
]
},
"chatgptBaseUrl": {
"type": [
"string",
"null"
]
},
"checkForUpdateOnStartup": {
"type": [
"boolean",
"null"
]
},
"cliAuthCredentialsStore": {
"anyOf": [
{
"$ref": "#/definitions/CliAuthCredentialsStoreMode"
},
{
"type": "null"
}
]
},
"computerUse": {
"anyOf": [
{
@@ -0,0 +1,5 @@
// 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.
export type CliAuthCredentialsStoreMode = "file" | "keyring" | "auto" | "ephemeral";
@@ -6,6 +6,7 @@ import type { WebSearchMode } from "../WebSearchMode";
import type { AskForApproval } from "./AskForApproval";
import type { AutoReviewRequirements } from "./AutoReviewRequirements";
import type { BrowserUseRequirements } from "./BrowserUseRequirements";
import type { CliAuthCredentialsStoreMode } from "./CliAuthCredentialsStoreMode";
import type { ComputerUseRequirements } from "./ComputerUseRequirements";
import type { FeedbackRequirements } from "./FeedbackRequirements";
import type { ModelsRequirements } from "./ModelsRequirements";
@@ -13,4 +14,4 @@ import type { ResidencyRequirement } from "./ResidencyRequirement";
import type { SandboxMode } from "./SandboxMode";
import type { WindowsSandboxSetupMode } from "./WindowsSandboxSetupMode";
export type ConfigRequirements = {allowedApprovalPolicies: Array<AskForApproval> | null, allowedSandboxModes: Array<SandboxMode> | null, allowedWindowsSandboxImplementations: Array<WindowsSandboxSetupMode> | null, allowedPermissionProfiles: { [key in string]?: boolean } | null, defaultPermissions: string | null, allowedWebSearchModes: Array<WebSearchMode> | null, allowManagedHooksOnly: boolean | null, allowAppshots: boolean | null, allowRemoteControl: boolean | null, computerUse: ComputerUseRequirements | null, browserUse: BrowserUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null, autoReview: AutoReviewRequirements | null, models: ModelsRequirements | null, sqliteHome: PathUri | null, logDir: PathUri | null, modelCatalogJson: PathUri | null, checkForUpdateOnStartup: boolean | null, allowLoginShell: boolean | null, feedback: FeedbackRequirements | null, windowsSandboxPrivateDesktop: boolean | null};
export type ConfigRequirements = {cliAuthCredentialsStore: CliAuthCredentialsStoreMode | null, chatgptBaseUrl: string | null, allowedApprovalPolicies: Array<AskForApproval> | null, allowedSandboxModes: Array<SandboxMode> | null, allowedWindowsSandboxImplementations: Array<WindowsSandboxSetupMode> | null, allowedPermissionProfiles: { [key in string]?: boolean } | null, defaultPermissions: string | null, allowedWebSearchModes: Array<WebSearchMode> | null, allowManagedHooksOnly: boolean | null, allowAppshots: boolean | null, allowRemoteControl: boolean | null, computerUse: ComputerUseRequirements | null, browserUse: BrowserUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null, autoReview: AutoReviewRequirements | null, models: ModelsRequirements | null, sqliteHome: PathUri | null, logDir: PathUri | null, modelCatalogJson: PathUri | null, checkForUpdateOnStartup: boolean | null, allowLoginShell: boolean | null, feedback: FeedbackRequirements | null, windowsSandboxPrivateDesktop: boolean | null};
@@ -51,6 +51,7 @@ export type { CapabilityRootLocation } from "./CapabilityRootLocation";
export type { ChatgptAuthTokensRefreshParams } from "./ChatgptAuthTokensRefreshParams";
export type { ChatgptAuthTokensRefreshReason } from "./ChatgptAuthTokensRefreshReason";
export type { ChatgptAuthTokensRefreshResponse } from "./ChatgptAuthTokensRefreshResponse";
export type { CliAuthCredentialsStoreMode } from "./CliAuthCredentialsStoreMode";
export type { CodexErrorInfo } from "./CodexErrorInfo";
export type { CollabAgentState } from "./CollabAgentState";
export type { CollabAgentStatus } from "./CollabAgentStatus";
@@ -383,6 +383,8 @@ pub struct ConfigReadResponse {
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ConfigRequirements {
pub cli_auth_credentials_store: Option<CliAuthCredentialsStoreMode>,
pub chatgpt_base_url: Option<String>,
#[experimental(nested)]
pub allowed_approval_policies: Option<Vec<AskForApproval>>,
#[experimental("configRequirements/read.allowedApprovalsReviewers")]
@@ -417,6 +419,16 @@ pub struct ConfigRequirements {
pub windows_sandbox_private_desktop: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/", rename_all = "camelCase")]
pub enum CliAuthCredentialsStoreMode {
File,
Keyring,
Auto,
Ephemeral,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -1994,6 +1994,8 @@ fn config_approvals_reviewer_is_marked_experimental() {
fn config_requirements_granular_allowed_approval_policy_is_marked_experimental() {
let reason =
crate::experimental_api::ExperimentalApi::experimental_reason(&ConfigRequirements {
cli_auth_credentials_store: None,
chatgpt_base_url: None,
allowed_approval_policies: Some(vec![AskForApproval::Granular {
sandbox_approval: true,
rules: true,
+1 -1
View File
@@ -288,7 +288,7 @@ Example with notification opt-out:
- `externalAgentConfig/import/readHistories` — read completed import histories and connector candidates detected from successfully imported session histories. Successful session entries include the original imported title when one was available. Connector candidates include a normalized display `name`, the number of imported sessions that used the connector, and the source metadata field used for detection.
- `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface. Writes that overlap a managed requirement are rejected with `configRequirementReadonly`.
- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads, including multiple `desktop.*` edits. Session-static model, reasoning-effort, Plan-mode reasoning-effort, service-tier, and personality defaults do not reload existing threads.
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including exact managed values (`sqliteHome`, `logDir`, `modelCatalogJson`, `checkForUpdateOnStartup`, `allowLoginShell`, `feedback.enabled`, and `windowsSandboxPrivateDesktop`), allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), the layered permission-profile allow map (`allowedPermissionProfiles`), the managed permission-profile default (`defaultPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), remote-control policy (`allowRemoteControl`; `false` force-disables remote control while `true` or `null` preserves existing behavior), computer use policy (`computerUse`), Browser Use policy (`browserUse.disableAutoReview`), pinned feature values (`featureRequirements`, including the default-allowed `in_app_updates` policy that administrators can set to `false`), managed lifecycle hooks (`hooks`, including command handlers with optional `additionalContextLimit` and `mcp_tool` handlers with `server`, `tool`, `input`, `timeoutSec`, and `statusMessage`), `enforceResidency`, managed automatic review (`autoReview.requiredOnModels` and `autoReview.ignoreRules`), model defaults (`models.newThread.model`, `models.newThread.modelReasoningEffort`, and `models.newThread.serviceTier`), and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`.
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including exact managed values (`cliAuthCredentialsStore`, `chatgptBaseUrl`, `sqliteHome`, `logDir`, `modelCatalogJson`, `checkForUpdateOnStartup`, `allowLoginShell`, `feedback.enabled`, and `windowsSandboxPrivateDesktop`), allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), the layered permission-profile allow map (`allowedPermissionProfiles`), the managed permission-profile default (`defaultPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), remote-control policy (`allowRemoteControl`; `false` force-disables remote control while `true` or `null` preserves existing behavior), computer use policy (`computerUse`), Browser Use policy (`browserUse.disableAutoReview`), pinned feature values (`featureRequirements`, including the default-allowed `in_app_updates` policy that administrators can set to `false`), managed lifecycle hooks (`hooks`, including command handlers with optional `additionalContextLimit` and `mcp_tool` handlers with `server`, `tool`, `input`, `timeoutSec`, and `statusMessage`), `enforceResidency`, managed automatic review (`autoReview.requiredOnModels` and `autoReview.ignoreRules`), model defaults (`models.newThread.model`, `models.newThread.modelReasoningEffort`, and `models.newThread.serviceTier`), and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`.
### Example: Start or resume a thread
@@ -9,6 +9,7 @@ use crate::outgoing_message::OutgoingMessageSender;
use codex_analytics::AnalyticsEventsClient;
use codex_app_server_protocol::AutoReviewRequirements;
use codex_app_server_protocol::BrowserUseRequirements;
use codex_app_server_protocol::CliAuthCredentialsStoreMode;
use codex_app_server_protocol::ClientResponsePayload;
use codex_app_server_protocol::ComputerUseRequirements;
use codex_app_server_protocol::ConfigBatchWriteParams;
@@ -352,6 +353,23 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR
.and_then(|windows| windows.sandbox_private_desktop);
ConfigRequirements {
cli_auth_credentials_store: requirements.cli_auth_credentials_store.map(
|mode| match mode {
codex_config::types::AuthCredentialsStoreMode::File => {
CliAuthCredentialsStoreMode::File
}
codex_config::types::AuthCredentialsStoreMode::Keyring => {
CliAuthCredentialsStoreMode::Keyring
}
codex_config::types::AuthCredentialsStoreMode::Auto => {
CliAuthCredentialsStoreMode::Auto
}
codex_config::types::AuthCredentialsStoreMode::Ephemeral => {
CliAuthCredentialsStoreMode::Ephemeral
}
},
),
chatgpt_base_url: requirements.chatgpt_base_url,
allowed_approval_policies: requirements.allowed_approval_policies.map(|policies| {
policies
.into_iter()
@@ -8,6 +8,7 @@ use codex_app_server_protocol::ApprovalsReviewer;
use codex_app_server_protocol::AppsConfig;
use codex_app_server_protocol::AppsDefaultConfig;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::CliAuthCredentialsStoreMode;
use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::ConfigEdit;
use codex_app_server_protocol::ConfigLayerSource;
@@ -47,6 +48,104 @@ fn write_config(codex_home: &TempDir, contents: &str) -> Result<()> {
)?)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn managed_auth_settings_are_exposed_enforced_and_read_only() -> Result<()> {
let codex_home = TempDir::new()?;
write_config(
&codex_home,
r#"cli_auth_credentials_store = "file"
chatgpt_base_url = "https://user.example/backend-api/"
"#,
)?;
std::fs::write(
codex_home.path().join("requirements.toml"),
r#"cli_auth_credentials_store = "ephemeral"
chatgpt_base_url = "https://managed.example/backend-api/"
"#,
)?;
let mut app_server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let requirements_id = app_server.send_config_requirements_read_request().await?;
let requirements: ConfigRequirementsReadResponse = timeout(
DEFAULT_READ_TIMEOUT,
app_server.read_response(requirements_id),
)
.await??;
let requirements = requirements.requirements.expect("managed requirements");
assert_eq!(
(
requirements.cli_auth_credentials_store,
requirements.chatgpt_base_url.as_deref(),
),
(
Some(CliAuthCredentialsStoreMode::Ephemeral),
Some("https://managed.example/backend-api/"),
),
);
let config_id = app_server
.send_config_read_request(ConfigReadParams {
include_layers: false,
cwd: None,
})
.await?;
let config: ConfigReadResponse =
timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(config_id)).await??;
assert_eq!(
(
config.config.additional.get("cli_auth_credentials_store"),
config.config.additional.get("chatgpt_base_url"),
),
(
Some(&json!("ephemeral")),
Some(&json!("https://managed.example/backend-api/")),
),
);
for (field, value) in [
("cli_auth_credentials_store", json!("file")),
(
"chatgpt_base_url",
json!("https://user.example/backend-api/"),
),
] {
let write_id = app_server
.send_config_value_write_request(ConfigValueWriteParams {
file_path: None,
key_path: field.to_string(),
value,
merge_strategy: MergeStrategy::Replace,
expected_version: None,
})
.await?;
let error: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
app_server.read_stream_until_error_message(RequestId::Integer(write_id)),
)
.await??;
assert_eq!(
error
.error
.data
.as_ref()
.and_then(|data| data.get("config_write_error_code"))
.and_then(serde_json::Value::as_str),
Some("configRequirementReadonly"),
);
}
assert_eq!(
std::fs::read_to_string(codex_home.path().join("config.toml"))?,
"cli_auth_credentials_store = \"file\"\nchatgpt_base_url = \"https://user.example/backend-api/\"\n",
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_requirements_read_includes_remote_control_and_managed_hooks() -> Result<()> {
let codex_home = TempDir::new()?;
+59 -1
View File
@@ -30,6 +30,7 @@ use crate::config_toml::ConfigToml;
use crate::mcp_requirements::validate_mcp_server_requirement;
use crate::mcp_types::AppToolApproval;
use crate::permissions_toml::PermissionProfileToml;
use crate::types::AuthCredentialsStoreMode;
use crate::types::FeedbackConfigToml;
use crate::types::WindowsSandboxModeToml;
@@ -155,6 +156,8 @@ impl<T> std::ops::DerefMut for ConstrainedWithSource<T> {
pub struct ConfigRequirements {
pub allowed_login_methods: Option<Sourced<Vec<ForcedLoginMethod>>>,
pub allowed_chatgpt_workspaces: Option<Sourced<Vec<String>>>,
pub cli_auth_credentials_store: Option<Sourced<AuthCredentialsStoreMode>>,
pub chatgpt_base_url: Option<Sourced<String>>,
pub sqlite_home: Option<Sourced<AbsolutePathBuf>>,
pub log_dir: Option<Sourced<AbsolutePathBuf>>,
pub model_catalog_json: Option<Sourced<AbsolutePathBuf>>,
@@ -192,6 +195,8 @@ impl Default for ConfigRequirements {
Self {
allowed_login_methods: None,
allowed_chatgpt_workspaces: None,
cli_auth_credentials_store: None,
chatgpt_base_url: None,
sqlite_home: None,
log_dir: None,
model_catalog_json: None,
@@ -917,6 +922,8 @@ pub(crate) fn merge_app_requirements_descending(
pub struct ConfigRequirementsToml {
pub allowed_login_methods: Option<Vec<ForcedLoginMethod>>,
pub allowed_chatgpt_workspaces: Option<Vec<String>>,
pub cli_auth_credentials_store: Option<AuthCredentialsStoreMode>,
pub chatgpt_base_url: Option<String>,
pub sqlite_home: Option<AbsolutePathBuf>,
pub log_dir: Option<AbsolutePathBuf>,
pub model_catalog_json: Option<AbsolutePathBuf>,
@@ -1017,6 +1024,8 @@ impl<T> std::ops::Deref for Sourced<T> {
pub struct ConfigRequirementsWithSources {
pub allowed_login_methods: Option<Sourced<Vec<ForcedLoginMethod>>>,
pub allowed_chatgpt_workspaces: Option<Sourced<Vec<String>>>,
pub cli_auth_credentials_store: Option<Sourced<AuthCredentialsStoreMode>>,
pub chatgpt_base_url: Option<Sourced<String>>,
pub sqlite_home: Option<Sourced<AbsolutePathBuf>>,
pub log_dir: Option<Sourced<AbsolutePathBuf>>,
pub model_catalog_json: Option<Sourced<AbsolutePathBuf>>,
@@ -1071,6 +1080,8 @@ impl ConfigRequirementsWithSources {
let ConfigRequirementsToml {
allowed_login_methods: _,
allowed_chatgpt_workspaces: _,
cli_auth_credentials_store: _,
chatgpt_base_url: _,
sqlite_home: _,
log_dir: _,
model_catalog_json: _,
@@ -1120,6 +1131,8 @@ impl ConfigRequirementsWithSources {
{
allowed_login_methods,
allowed_chatgpt_workspaces,
cli_auth_credentials_store,
chatgpt_base_url,
sqlite_home,
log_dir,
model_catalog_json,
@@ -1197,6 +1210,8 @@ impl ConfigRequirementsWithSources {
let ConfigRequirementsWithSources {
allowed_login_methods,
allowed_chatgpt_workspaces,
cli_auth_credentials_store,
chatgpt_base_url,
sqlite_home,
log_dir,
model_catalog_json,
@@ -1232,6 +1247,8 @@ impl ConfigRequirementsWithSources {
ConfigRequirementsToml {
allowed_login_methods: allowed_login_methods.map(|sourced| sourced.value),
allowed_chatgpt_workspaces: allowed_chatgpt_workspaces.map(|sourced| sourced.value),
cli_auth_credentials_store: cli_auth_credentials_store.map(|sourced| sourced.value),
chatgpt_base_url: chatgpt_base_url.map(|sourced| sourced.value),
sqlite_home: sqlite_home.map(|sourced| sourced.value),
log_dir: log_dir.map(|sourced| sourced.value),
model_catalog_json: model_catalog_json.map(|sourced| sourced.value),
@@ -1334,6 +1351,8 @@ impl ConfigRequirementsToml {
pub fn is_empty(&self) -> bool {
self.allowed_login_methods.is_none()
&& self.allowed_chatgpt_workspaces.is_none()
&& self.cli_auth_credentials_store.is_none()
&& self.chatgpt_base_url.is_none()
&& self.sqlite_home.is_none()
&& self.log_dir.is_none()
&& self.model_catalog_json.is_none()
@@ -1419,6 +1438,8 @@ impl ConfigRequirementsToml {
};
}
apply_exact!(cli_auth_credentials_store);
apply_exact!(chatgpt_base_url);
apply_exact!(sqlite_home);
apply_exact!(log_dir);
apply_exact!(model_catalog_json);
@@ -1450,7 +1471,7 @@ impl ConfigRequirementsToml {
/// Returns the exact managed field affected by editing `segments`.
pub fn exact_requirement_for_config_path(&self, segments: &[String]) -> Option<&'static str> {
let managed_fields: [(bool, &[&str], &'static str); 7] = [
let managed_fields: [(bool, &[&str], &'static str); 9] = [
(self.sqlite_home.is_some(), &["sqlite_home"], "sqlite_home"),
(self.log_dir.is_some(), &["log_dir"], "log_dir"),
(
@@ -1484,6 +1505,16 @@ impl ConfigRequirementsToml {
&["windows", "sandbox_private_desktop"],
"windows.sandbox_private_desktop",
),
(
self.cli_auth_credentials_store.is_some(),
&["cli_auth_credentials_store"],
"cli_auth_credentials_store",
),
(
self.chatgpt_base_url.is_some(),
&["chatgpt_base_url"],
"chatgpt_base_url",
),
];
managed_fields
@@ -1531,6 +1562,8 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
let ConfigRequirementsWithSources {
allowed_login_methods,
allowed_chatgpt_workspaces,
cli_auth_credentials_store,
chatgpt_base_url,
sqlite_home,
log_dir,
model_catalog_json,
@@ -1889,6 +1922,8 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
Ok(ConfigRequirements {
allowed_login_methods,
allowed_chatgpt_workspaces,
cli_auth_credentials_store,
chatgpt_base_url,
sqlite_home,
log_dir,
model_catalog_json,
@@ -1975,6 +2010,8 @@ mod tests {
let managed_path = AbsolutePathBuf::try_from(std::env::temp_dir().join("managed"))
.expect("managed path should be absolute");
let requirements = ConfigRequirementsToml {
cli_auth_credentials_store: Some(AuthCredentialsStoreMode::Ephemeral),
chatgpt_base_url: Some("https://managed.example/backend-api/".to_string()),
sqlite_home: Some(managed_path.clone()),
log_dir: Some(managed_path.clone()),
model_catalog_json: Some(managed_path),
@@ -1990,6 +2027,11 @@ mod tests {
..Default::default()
};
let cases: &[(&[&str], Option<&str>)] = &[
(
&["cli_auth_credentials_store"],
Some("cli_auth_credentials_store"),
),
(&["chatgpt_base_url"], Some("chatgpt_base_url")),
(&["sqlite_home"], Some("sqlite_home")),
(&["log_dir"], Some("log_dir")),
(&["model_catalog_json"], Some("model_catalog_json")),
@@ -2046,6 +2088,8 @@ mod tests {
let ConfigRequirementsToml {
allowed_login_methods,
allowed_chatgpt_workspaces,
cli_auth_credentials_store,
chatgpt_base_url,
sqlite_home,
log_dir,
model_catalog_json,
@@ -2084,6 +2128,10 @@ mod tests {
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
allowed_chatgpt_workspaces: allowed_chatgpt_workspaces
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
cli_auth_credentials_store: cli_auth_credentials_store
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
chatgpt_base_url: chatgpt_base_url
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
sqlite_home: sqlite_home.map(|value| Sourced::new(value, RequirementSource::Unknown)),
log_dir: log_dir.map(|value| Sourced::new(value, RequirementSource::Unknown)),
model_catalog_json: model_catalog_json
@@ -2389,6 +2437,8 @@ mod tests {
let other = ConfigRequirementsToml {
allowed_login_methods: Some(vec![ForcedLoginMethod::Chatgpt]),
allowed_chatgpt_workspaces: Some(vec!["managed-workspace".to_string()]),
cli_auth_credentials_store: Some(AuthCredentialsStoreMode::Keyring),
chatgpt_base_url: Some("https://managed.example/backend-api/".to_string()),
sqlite_home: Some(sqlite_home.clone()),
log_dir: Some(log_dir.clone()),
model_catalog_json: Some(model_catalog_json.clone()),
@@ -2436,6 +2486,14 @@ mod tests {
vec!["managed-workspace".to_string()],
source.clone(),
)),
cli_auth_credentials_store: Some(Sourced::new(
AuthCredentialsStoreMode::Keyring,
source.clone(),
)),
chatgpt_base_url: Some(Sourced::new(
"https://managed.example/backend-api/".to_string(),
source.clone(),
)),
sqlite_home: Some(Sourced::new(sqlite_home, source.clone())),
log_dir: Some(Sourced::new(log_dir, source.clone())),
model_catalog_json: Some(Sourced::new(model_catalog_json, source.clone())),
+48
View File
@@ -192,6 +192,54 @@ model_provider = "system-provider"
);
}
#[tokio::test]
async fn ignoring_login_requirements_preserves_local_auth_backend_requirements() {
let tmp = tempdir().expect("tempdir");
let requirements_path = tmp.path().join("requirements.toml");
std::fs::write(
&requirements_path,
r#"allowed_login_methods = ["chatgpt"]
allowed_chatgpt_workspaces = ["managed-workspace"]
cli_auth_credentials_store = "keyring"
chatgpt_base_url = "https://managed.example/backend-api/"
"#,
)
.expect("write local authentication requirements");
let mut overrides = LoaderOverrides::without_managed_config_for_tests();
overrides.system_requirements_path = Some(requirements_path);
overrides.ignore_login_requirements = true;
let stack = load_config_layers_state(
&TestFileSystem,
tmp.path(),
/*cwd*/ None,
&[],
overrides,
&crate::NoopThreadConfigLoader,
)
.await
.expect("load configuration with remote login exemptions");
let requirements = stack.requirements();
assert_eq!(requirements.allowed_login_methods, None);
assert_eq!(requirements.allowed_chatgpt_workspaces, None);
assert_eq!(
requirements
.cli_auth_credentials_store
.as_ref()
.map(|required| required.value),
Some(crate::types::AuthCredentialsStoreMode::Keyring)
);
assert_eq!(
requirements
.chatgpt_base_url
.as_ref()
.map(|required| required.value.as_str()),
Some("https://managed.example/backend-api/")
);
}
#[tokio::test]
async fn missing_packaged_defaults_file_returns_an_error() {
let tmp = tempdir().expect("tempdir");
@@ -8,6 +8,14 @@ use toml::Value as TomlValue;
use super::stack::RequirementsCompositionError;
// Authentication requirements that cloud-managed layers cannot set.
const LOCAL_ONLY_AUTH_REQUIREMENTS: &[&str] = &[
"allowed_login_methods",
"allowed_chatgpt_workspaces",
"cli_auth_credentials_store",
"chatgpt_base_url",
];
#[derive(Clone, Debug)]
pub struct RequirementsLayerEntry {
pub(super) source: RequirementSource,
@@ -82,8 +90,9 @@ impl ComposableRequirementsLayer {
// These fields can only be set locally; ignore them before validating cloud policy.
if matches!(source, RequirementSource::EnterpriseManaged { .. }) {
remove_top_level_field(&mut regular_toml, "allowed_login_methods");
remove_top_level_field(&mut regular_toml, "allowed_chatgpt_workspaces");
for field in LOCAL_ONLY_AUTH_REQUIREMENTS {
remove_top_level_field(&mut regular_toml, field);
}
}
let requirements = parse_layer_requirements(
@@ -213,6 +213,8 @@ fn populate_merged_regular_fields_with_sources(
let ConfigRequirementsToml {
allowed_login_methods,
allowed_chatgpt_workspaces,
cli_auth_credentials_store,
chatgpt_base_url,
sqlite_home,
log_dir,
model_catalog_json,
@@ -249,6 +251,8 @@ fn populate_merged_regular_fields_with_sources(
set_sourced!(allowed_login_methods, &["allowed_login_methods"]);
set_sourced!(allowed_chatgpt_workspaces, &["allowed_chatgpt_workspaces"]);
set_sourced!(cli_auth_credentials_store, &["cli_auth_credentials_store"]);
set_sourced!(chatgpt_base_url, &["chatgpt_base_url"]);
set_sourced!(sqlite_home, &["sqlite_home"]);
set_sourced!(log_dir, &["log_dir"]);
set_sourced!(model_catalog_json, &["model_catalog_json"]);
@@ -61,17 +61,26 @@ fn empty_layers_compose_to_none() {
fn cloud_auth_requirements_do_not_override_local_or_discard_other_policy() {
let local = RequirementsLayerEntry::from_toml(
RequirementSource::Unknown,
"allowed_login_methods = [\"api\"]",
r#"allowed_login_methods = ["api"]
cli_auth_credentials_store = "keyring"
chatgpt_base_url = "https://managed.example/backend-api/""#,
);
let cloud = layer(
"req_cloud",
"Cloud policy",
"allowed_login_methods = [\"saml\"]\nallowed_chatgpt_workspaces = \"invalid\"\nallow_login_shell = false",
r#"allowed_login_methods = ["saml"]
allowed_chatgpt_workspaces = "invalid"
cli_auth_credentials_store = "invalid"
chatgpt_base_url = false
allow_login_shell = false"#,
);
assert_eq!(
compose(vec![local, cloud]).expect("cloud auth cannot invalidate enterprise policy"),
Some(expected_requirements(
"allowed_login_methods = [\"api\"]\nallow_login_shell = false"
r#"allowed_login_methods = ["api"]
cli_auth_credentials_store = "keyring"
chatgpt_base_url = "https://managed.example/backend-api/"
allow_login_shell = false"#
))
);
}
+10 -10
View File
@@ -41,6 +41,7 @@ pub fn bootstrap_auth_config(
bootstrap_config: &ConfigTomlLoadResult,
) -> std::io::Result<AuthConfig> {
let config = &bootstrap_config.config_toml;
let requirements = bootstrap_config.config_layer_stack.requirements();
// Empty legacy workspace settings mean unrestricted, not an empty allowlist.
let forced_chatgpt_workspace_id = config
.forced_chatgpt_workspace_id
@@ -54,26 +55,25 @@ pub fn bootstrap_auth_config(
.collect::<Vec<_>>()
})
.filter(|workspaces| !workspaces.is_empty());
let auth_config = AuthConfig {
let mut auth_config = AuthConfig {
codex_home: codex_home.to_path_buf(),
auth_credentials_store_mode: config.cli_auth_credentials_store.unwrap_or_default(),
keyring_backend_kind: resolve_bootstrap_auth_keyring_backend_kind(bootstrap_config)?,
forced_login_method: config.forced_login_method,
chatgpt_base_url: config.chatgpt_base_url.clone(),
forced_chatgpt_workspace_id,
managed_auth_policy: bootstrap_config
.config_layer_stack
.requirements()
.managed_auth_policy(),
managed_auth_policy: requirements.managed_auth_policy(),
auth_route_config: resolve_bootstrap_auth_route_config(
config,
bootstrap_config
.config_layer_stack
.requirements()
.feature_requirements
.as_ref(),
requirements.feature_requirements.as_ref(),
)?,
};
if let Some(required) = requirements.cli_auth_credentials_store.as_ref() {
auth_config.auth_credentials_store_mode = required.value;
}
if let Some(required) = requirements.chatgpt_base_url.as_ref() {
auth_config.chatgpt_base_url = Some(required.value.clone());
}
auth_config.validate()?;
Ok(auth_config)
}
@@ -7,6 +7,7 @@ use codex_config::RequirementSource;
use codex_config::Sourced;
use codex_config::config_toml::ConfigToml;
use codex_config::config_toml::ForcedChatgptWorkspaceIds;
use codex_config::types::AuthCredentialsStoreMode;
use codex_features::FeaturesToml;
use codex_protocol::config_types::ForcedLoginMethod;
use pretty_assertions::assert_eq;
@@ -126,6 +127,42 @@ fn managed_auth_restrictions_intersect_workspaces_and_fail_closed() {
);
}
#[test]
fn bootstrap_auth_config_applies_managed_store_and_chatgpt_base_url() {
let configured_store = AuthCredentialsStoreMode::File;
let configured_url = "https://user.example/backend-api/";
let managed_store = AuthCredentialsStoreMode::Keyring;
let managed_url = "https://managed.example/backend-api/";
let config_toml = ConfigToml {
cli_auth_credentials_store: Some(configured_store),
chatgpt_base_url: Some(configured_url.to_string()),
..Default::default()
};
let requirements = ConfigRequirements {
cli_auth_credentials_store: Some(Sourced::new(managed_store, RequirementSource::Unknown)),
chatgpt_base_url: Some(Sourced::new(
managed_url.to_string(),
RequirementSource::Unknown,
)),
..Default::default()
};
let bootstrap_config = ConfigTomlLoadResult {
config_toml,
config_layer_stack: ConfigLayerStack::new(
Vec::new(),
requirements,
ConfigRequirementsToml::default(),
)
.expect("requirements should stack"),
};
let auth_config = bootstrap_auth_config(Path::new("codex-home"), &bootstrap_config)
.expect("managed authentication settings should resolve");
assert_eq!(auth_config.auth_credentials_store_mode, managed_store);
assert_eq!(auth_config.chatgpt_base_url.as_deref(), Some(managed_url));
}
fn config_toml_load_result(
config_toml: ConfigToml,
feature_requirements: Option<Sourced<FeatureRequirementsToml>>,
+49
View File
@@ -5735,6 +5735,53 @@ async fn config_resolves_explicit_keyring_auth_store_mode() -> std::io::Result<(
Ok(())
}
#[tokio::test]
async fn config_applies_managed_auth_store_and_chatgpt_base_url() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let managed_store = AuthCredentialsStoreMode::Keyring;
let managed_url = "https://managed.example/backend-api/";
let requirements = codex_config::ConfigRequirements {
cli_auth_credentials_store: Some(Sourced::new(managed_store, RequirementSource::Unknown)),
chatgpt_base_url: Some(Sourced::new(
managed_url.to_string(),
RequirementSource::Unknown,
)),
..Default::default()
};
let config_layer_stack = ConfigLayerStack::new(
Vec::new(),
requirements,
codex_config::ConfigRequirementsToml::default(),
)?;
let config = Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
ConfigToml {
cli_auth_credentials_store: Some(AuthCredentialsStoreMode::File),
chatgpt_base_url: Some("https://user.example/backend-api/".to_string()),
..Default::default()
},
ConfigOverrides {
cwd: Some(codex_home.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
config_layer_stack,
)
.await?;
assert_eq!(config.cli_auth_credentials_store_mode, managed_store);
assert_eq!(config.chatgpt_base_url, managed_url);
assert!(config.startup_warnings.iter().any(|warning| {
warning.contains("Configured value for `cli_auth_credentials_store` is overridden")
}));
assert!(config.startup_warnings.iter().any(|warning| {
warning.contains("Configured value for `chatgpt_base_url` is overridden")
}));
Ok(())
}
#[tokio::test]
async fn config_resolves_default_oauth_store_mode() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
@@ -9487,6 +9534,8 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset()
let requirements_toml = codex_config::ConfigRequirementsToml {
allowed_login_methods: None,
allowed_chatgpt_workspaces: None,
cli_auth_credentials_store: None,
chatgpt_base_url: None,
sqlite_home: None,
log_dir: None,
model_catalog_json: None,
+9 -4
View File
@@ -3116,6 +3116,8 @@ impl Config {
let ConfigRequirements {
allowed_login_methods: _,
allowed_chatgpt_workspaces: _,
cli_auth_credentials_store,
chatgpt_base_url: _,
sqlite_home: _,
log_dir: _,
model_catalog_json: _,
@@ -4015,10 +4017,13 @@ impl Config {
include_environment_context,
// The config.toml omits "_mode" because it's a config file. However, "_mode"
// is important in code to differentiate the mode from the store implementation.
cli_auth_credentials_store_mode: resolve_cli_auth_credentials_store_mode(
cfg.cli_auth_credentials_store.unwrap_or_default(),
env!("CARGO_PKG_VERSION"),
),
cli_auth_credentials_store_mode: match cli_auth_credentials_store {
Some(required) => required.value,
None => resolve_cli_auth_credentials_store_mode(
cfg.cli_auth_credentials_store.unwrap_or_default(),
env!("CARGO_PKG_VERSION"),
),
},
mcp_servers,
non_prefixed_mcp_tool_servers,
// The config.toml omits "_mode" because it's a config file. However, "_mode"
+2
View File
@@ -28,6 +28,8 @@ pub(super) fn apply_to_config(
};
}
apply_exact!(cli_auth_credentials_store);
apply_exact!(chatgpt_base_url);
apply_exact!(sqlite_home);
apply_exact!(log_dir);
apply_exact!(model_catalog_json);
+2
View File
@@ -933,6 +933,8 @@ interrupt_message = false
let requirements_toml = ConfigRequirementsToml {
allowed_login_methods: None,
allowed_chatgpt_workspaces: None,
cli_auth_credentials_store: None,
chatgpt_base_url: None,
sqlite_home: Some(sqlite_home),
log_dir: Some(log_dir),
model_catalog_json: Some(model_catalog_json),