From 6092d88f235417c77212536bb874fc413907da2b Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Thu, 11 Jun 2026 20:31:29 +0000 Subject: [PATCH] Add requirements.toml support for managed config fields --- .../codex_app_server_protocol.schemas.json | 118 ++++ .../codex_app_server_protocol.v2.schemas.json | 118 ++++ .../v2/ConfigRequirementsReadResponse.json | 122 ++++ .../typescript/v2/AuthCredentialsStoreMode.ts | 5 + .../typescript/v2/ConfigRequirements.ts | 12 +- .../typescript/v2/FeedbackRequirements.ts | 5 + .../schema/typescript/v2/index.ts | 2 + .../src/protocol/v2/config.rs | 32 ++ .../src/protocol/v2/tests.rs | 13 + codex-rs/app-server/README.md | 2 +- .../request_processors/config_processor.rs | 117 ++++ codex-rs/config/src/config_requirements.rs | 526 +++++++++++++++++- .../config/src/requirements_layers/stack.rs | 27 + .../src/requirements_layers/stack_tests.rs | 101 ++++ codex-rs/core/src/config/config_tests.rs | 262 +++++++++ codex-rs/core/src/config/mod.rs | 362 +++++++++++- codex-rs/exec/src/lib.rs | 4 +- .../tui/src/chatwidget/tests/permissions.rs | 1 + codex-rs/tui/src/debug_config.rs | 24 + codex-rs/tui/src/lib.rs | 11 +- 20 files changed, 1821 insertions(+), 43 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AuthCredentialsStoreMode.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/FeedbackRequirements.ts diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 82646d0ec6..e1f8e9106d 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -6687,6 +6687,15 @@ } ] }, + "AuthCredentialsStoreMode": { + "enum": [ + "file", + "keyring", + "auto", + "ephemeral" + ], + "type": "string" + }, "AuthMode": { "description": "Authentication mode for OpenAI-backed providers.", "oneOf": [ @@ -8082,6 +8091,12 @@ "null" ] }, + "allowLoginShell": { + "type": [ + "boolean", + "null" + ] + }, "allowManagedHooksOnly": { "type": [ "boolean", @@ -8097,6 +8112,24 @@ "null" ] }, + "allowedChatgptWorkspaces": { + "additionalProperties": { + "type": "boolean" + }, + "type": [ + "object", + "null" + ] + }, + "allowedLoginMethods": { + "additionalProperties": { + "type": "boolean" + }, + "type": [ + "object", + "null" + ] + }, "allowedPermissionProfiles": { "additionalProperties": { "type": "boolean" @@ -8133,6 +8166,28 @@ "null" ] }, + "chatgptBaseUrl": { + "type": [ + "string", + "null" + ] + }, + "checkForUpdateOnStartup": { + "type": [ + "boolean", + "null" + ] + }, + "cliAuthCredentialsStore": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AuthCredentialsStoreMode" + }, + { + "type": "null" + } + ] + }, "computerUse": { "anyOf": [ { @@ -8167,6 +8222,58 @@ "object", "null" ] + }, + "feedback": { + "anyOf": [ + { + "$ref": "#/definitions/v2/FeedbackRequirements" + }, + { + "type": "null" + } + ] + }, + "logDir": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "modelCatalogJson": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "otel": { + "description": "Lossless config-native OTEL requirements, including exporter headers." + }, + "shellEnvironmentPolicy": { + "description": "Lossless config-native shell environment policy requirements." + }, + "sqliteHome": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "windowsSandboxPrivateDesktop": { + "type": [ + "boolean", + "null" + ] } }, "type": "object" @@ -8920,6 +9027,17 @@ ], "type": "string" }, + "FeedbackRequirements": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "FeedbackUploadParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 6be7e76eb7..dff3aa561d 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -965,6 +965,15 @@ } ] }, + "AuthCredentialsStoreMode": { + "enum": [ + "file", + "keyring", + "auto", + "ephemeral" + ], + "type": "string" + }, "AuthMode": { "description": "Authentication mode for OpenAI-backed providers.", "oneOf": [ @@ -4404,6 +4413,12 @@ "null" ] }, + "allowLoginShell": { + "type": [ + "boolean", + "null" + ] + }, "allowManagedHooksOnly": { "type": [ "boolean", @@ -4419,6 +4434,24 @@ "null" ] }, + "allowedChatgptWorkspaces": { + "additionalProperties": { + "type": "boolean" + }, + "type": [ + "object", + "null" + ] + }, + "allowedLoginMethods": { + "additionalProperties": { + "type": "boolean" + }, + "type": [ + "object", + "null" + ] + }, "allowedPermissionProfiles": { "additionalProperties": { "type": "boolean" @@ -4455,6 +4488,28 @@ "null" ] }, + "chatgptBaseUrl": { + "type": [ + "string", + "null" + ] + }, + "checkForUpdateOnStartup": { + "type": [ + "boolean", + "null" + ] + }, + "cliAuthCredentialsStore": { + "anyOf": [ + { + "$ref": "#/definitions/AuthCredentialsStoreMode" + }, + { + "type": "null" + } + ] + }, "computerUse": { "anyOf": [ { @@ -4489,6 +4544,58 @@ "object", "null" ] + }, + "feedback": { + "anyOf": [ + { + "$ref": "#/definitions/FeedbackRequirements" + }, + { + "type": "null" + } + ] + }, + "logDir": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "modelCatalogJson": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "otel": { + "description": "Lossless config-native OTEL requirements, including exporter headers." + }, + "shellEnvironmentPolicy": { + "description": "Lossless config-native shell environment policy requirements." + }, + "sqliteHome": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "windowsSandboxPrivateDesktop": { + "type": [ + "boolean", + "null" + ] } }, "type": "object" @@ -5242,6 +5349,17 @@ ], "type": "string" }, + "FeedbackRequirements": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "FeedbackUploadParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index def89d64e3..415bce7a68 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -1,6 +1,10 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ @@ -60,6 +64,15 @@ } ] }, + "AuthCredentialsStoreMode": { + "enum": [ + "file", + "keyring", + "auto", + "ephemeral" + ], + "type": "string" + }, "ComputerUseRequirements": { "properties": { "allowLockedComputerUse": { @@ -79,6 +92,12 @@ "null" ] }, + "allowLoginShell": { + "type": [ + "boolean", + "null" + ] + }, "allowManagedHooksOnly": { "type": [ "boolean", @@ -94,6 +113,24 @@ "null" ] }, + "allowedChatgptWorkspaces": { + "additionalProperties": { + "type": "boolean" + }, + "type": [ + "object", + "null" + ] + }, + "allowedLoginMethods": { + "additionalProperties": { + "type": "boolean" + }, + "type": [ + "object", + "null" + ] + }, "allowedPermissionProfiles": { "additionalProperties": { "type": "boolean" @@ -130,6 +167,28 @@ "null" ] }, + "chatgptBaseUrl": { + "type": [ + "string", + "null" + ] + }, + "checkForUpdateOnStartup": { + "type": [ + "boolean", + "null" + ] + }, + "cliAuthCredentialsStore": { + "anyOf": [ + { + "$ref": "#/definitions/AuthCredentialsStoreMode" + }, + { + "type": "null" + } + ] + }, "computerUse": { "anyOf": [ { @@ -164,6 +223,58 @@ "object", "null" ] + }, + "feedback": { + "anyOf": [ + { + "$ref": "#/definitions/FeedbackRequirements" + }, + { + "type": "null" + } + ] + }, + "logDir": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "modelCatalogJson": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "otel": { + "description": "Lossless config-native OTEL requirements, including exporter headers." + }, + "shellEnvironmentPolicy": { + "description": "Lossless config-native shell environment policy requirements." + }, + "sqliteHome": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "windowsSandboxPrivateDesktop": { + "type": [ + "boolean", + "null" + ] } }, "type": "object" @@ -268,6 +379,17 @@ ], "type": "object" }, + "FeedbackRequirements": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "ManagedHooksRequirements": { "properties": { "PermissionRequest": { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AuthCredentialsStoreMode.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AuthCredentialsStoreMode.ts new file mode 100644 index 0000000000..e8714cc4b9 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AuthCredentialsStoreMode.ts @@ -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 AuthCredentialsStoreMode = "file" | "keyring" | "auto" | "ephemeral"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts index 29704982ff..0dcc242131 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts @@ -1,11 +1,21 @@ // 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 { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { WebSearchMode } from "../WebSearchMode"; +import type { JsonValue } from "../serde_json/JsonValue"; import type { AskForApproval } from "./AskForApproval"; +import type { AuthCredentialsStoreMode } from "./AuthCredentialsStoreMode"; import type { ComputerUseRequirements } from "./ComputerUseRequirements"; +import type { FeedbackRequirements } from "./FeedbackRequirements"; import type { ResidencyRequirement } from "./ResidencyRequirement"; import type { SandboxMode } from "./SandboxMode"; import type { WindowsSandboxSetupMode } from "./WindowsSandboxSetupMode"; -export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedWindowsSandboxImplementations: Array | null, allowedPermissionProfiles: { [key in string]?: boolean } | null, defaultPermissions: string | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, allowAppshots: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null}; +export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedWindowsSandboxImplementations: Array | null, allowedPermissionProfiles: { [key in string]?: boolean } | null, defaultPermissions: string | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, allowAppshots: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null, allowedLoginMethods: { [key in string]?: boolean } | null, allowedChatgptWorkspaces: { [key in string]?: boolean } | null, cliAuthCredentialsStore: AuthCredentialsStoreMode | null, chatgptBaseUrl: string | null, sqliteHome: AbsolutePathBuf | null, logDir: AbsolutePathBuf | null, modelCatalogJson: AbsolutePathBuf | null, checkForUpdateOnStartup: boolean | null, /** + * Lossless config-native OTEL requirements, including exporter headers. + */ +otel: JsonValue | null, allowLoginShell: boolean | null, /** + * Lossless config-native shell environment policy requirements. + */ +shellEnvironmentPolicy: JsonValue | null, feedback: FeedbackRequirements | null, windowsSandboxPrivateDesktop: boolean | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackRequirements.ts new file mode 100644 index 0000000000..8d0a2002ee --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackRequirements.ts @@ -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 FeedbackRequirements = { enabled: boolean | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index b4cd88aadf..5602c4fb75 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -35,6 +35,7 @@ export type { AppsListResponse } from "./AppsListResponse"; export type { AskForApproval } from "./AskForApproval"; export type { AttestationGenerateParams } from "./AttestationGenerateParams"; export type { AttestationGenerateResponse } from "./AttestationGenerateResponse"; +export type { AuthCredentialsStoreMode } from "./AuthCredentialsStoreMode"; export type { AutoReviewDecisionSource } from "./AutoReviewDecisionSource"; export type { ByteRange } from "./ByteRange"; export type { CancelLoginAccountParams } from "./CancelLoginAccountParams"; @@ -108,6 +109,7 @@ export type { ExternalAgentConfigImportParams } from "./ExternalAgentConfigImpor export type { ExternalAgentConfigImportResponse } from "./ExternalAgentConfigImportResponse"; export type { ExternalAgentConfigMigrationItem } from "./ExternalAgentConfigMigrationItem"; export type { ExternalAgentConfigMigrationItemType } from "./ExternalAgentConfigMigrationItemType"; +export type { FeedbackRequirements } from "./FeedbackRequirements"; export type { FeedbackUploadParams } from "./FeedbackUploadParams"; export type { FeedbackUploadResponse } from "./FeedbackUploadResponse"; export type { FileChangeApprovalDecision } from "./FileChangeApprovalDecision"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index 227adffc30..2d889f9605 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -387,6 +387,38 @@ pub struct ConfigRequirements { pub enforce_residency: Option, #[experimental("configRequirements/read.network")] pub network: Option, + pub allowed_login_methods: Option>, + pub allowed_chatgpt_workspaces: Option>, + pub cli_auth_credentials_store: Option, + pub chatgpt_base_url: Option, + pub sqlite_home: Option, + pub log_dir: Option, + pub model_catalog_json: Option, + pub check_for_update_on_startup: Option, + /// Lossless config-native OTEL requirements, including exporter headers. + pub otel: Option, + pub allow_login_shell: Option, + /// Lossless config-native shell environment policy requirements. + pub shell_environment_policy: Option, + pub feedback: Option, + pub windows_sandbox_private_desktop: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum AuthCredentialsStoreMode { + File, + Keyring, + Auto, + Ephemeral, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FeedbackRequirements { + pub enabled: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index c570b87ce4..a0de745440 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -1708,6 +1708,19 @@ fn config_requirements_granular_allowed_approval_policy_is_marked_experimental() hooks: None, enforce_residency: None, network: None, + 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, + check_for_update_on_startup: None, + otel: None, + allow_login_shell: None, + shell_environment_policy: None, + feedback: None, + windows_sandbox_private_desktop: None, }); assert_eq!(reason, Some("askForApproval.granular")); diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 42ac510a8a..875a7fd041 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -234,7 +234,7 @@ Example with notification opt-out: - `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin/session `details` returned by detect. When a request includes migration items, the server emits `externalAgentConfig/import/completed` once after the full import finishes (immediately after the response when everything completed synchronously, or after background imports finish). - `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. - `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. -- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), the layered permission-profile allow map (`allowedPermissionProfiles`), the managed permission-profile default (`defaultPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, 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 allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`, `allowedLoginMethods`, `allowedChatgptWorkspaces`), the layered permission-profile allow map (`allowedPermissionProfiles`), the managed permission-profile default (`defaultPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, `network` constraints, and pinned runtime settings such as credential storage, ChatGPT base URL, state/log/model-catalog paths, update checks, login-shell and environment policy, feedback, Windows private-desktop use, and OTEL configuration. OTEL exporter responses include their complete headers map. ### Example: Start or resume a thread diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index bdc8013950..bccf0a40fb 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -7,6 +7,7 @@ use crate::error_code::invalid_request; use crate::outgoing_message::ConnectionRequestId; use crate::outgoing_message::OutgoingMessageSender; use codex_analytics::AnalyticsEventsClient; +use codex_app_server_protocol::AuthCredentialsStoreMode as ApiAuthCredentialsStoreMode; use codex_app_server_protocol::ClientResponsePayload; use codex_app_server_protocol::ComputerUseRequirements; use codex_app_server_protocol::ConfigBatchWriteParams; @@ -21,6 +22,7 @@ use codex_app_server_protocol::ConfiguredHookHandler; use codex_app_server_protocol::ConfiguredHookMatcherGroup; use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams; use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse; +use codex_app_server_protocol::FeedbackRequirements; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::ManagedHooksRequirements; use codex_app_server_protocol::ModelProviderCapabilitiesReadResponse; @@ -314,6 +316,11 @@ impl ConfigRequestProcessor { } fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigRequirements { + let windows_sandbox_private_desktop = requirements + .windows + .as_ref() + .and_then(|windows| windows.sandbox_private_desktop); + ConfigRequirements { allowed_approval_policies: requirements.allowed_approval_policies.map(|policies| { policies @@ -375,6 +382,40 @@ 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), + allowed_login_methods: requirements.allowed_login_methods, + allowed_chatgpt_workspaces: requirements.allowed_chatgpt_workspaces, + cli_auth_credentials_store: requirements + .cli_auth_credentials_store + .map(map_auth_credentials_store_mode_to_api), + chatgpt_base_url: requirements.chatgpt_base_url, + sqlite_home: requirements.sqlite_home, + log_dir: requirements.log_dir, + model_catalog_json: requirements.model_catalog_json, + check_for_update_on_startup: requirements.check_for_update_on_startup, + otel: requirements.otel.map(|otel| json!(otel)), + allow_login_shell: requirements.allow_login_shell, + shell_environment_policy: requirements + .shell_environment_policy + .map(|policy| json!(policy)), + feedback: requirements.feedback.map(|feedback| FeedbackRequirements { + enabled: feedback.enabled, + }), + windows_sandbox_private_desktop, + } +} + +fn map_auth_credentials_store_mode_to_api( + mode: codex_config::types::AuthCredentialsStoreMode, +) -> ApiAuthCredentialsStoreMode { + match mode { + codex_config::types::AuthCredentialsStoreMode::File => ApiAuthCredentialsStoreMode::File, + codex_config::types::AuthCredentialsStoreMode::Keyring => { + ApiAuthCredentialsStoreMode::Keyring + } + codex_config::types::AuthCredentialsStoreMode::Auto => ApiAuthCredentialsStoreMode::Auto, + codex_config::types::AuthCredentialsStoreMode::Ephemeral => { + ApiAuthCredentialsStoreMode::Ephemeral + } } } @@ -569,8 +610,15 @@ mod tests { use codex_config::ComputerUseRequirementsToml; use codex_config::ConfigRequirementsToml; use codex_config::WindowsRequirementsToml; + use codex_config::types::OtelConfigToml; + use codex_config::types::OtelExporterKind; + use codex_config::types::OtelHttpProtocol as CoreOtelHttpProtocol; + use codex_config::types::ShellEnvironmentPolicyToml; + use codex_protocol::config_types::ShellEnvironmentPolicyInherit as CoreShellEnvironmentPolicyInherit; use pretty_assertions::assert_eq; + use serde_json::json; use std::collections::BTreeMap; + use std::collections::HashMap; #[test] fn requirements_api_includes_allow_managed_hooks_only() { @@ -643,6 +691,7 @@ mod tests { codex_config::types::WindowsSandboxModeToml::Elevated, codex_config::types::WindowsSandboxModeToml::Unelevated, ]), + sandbox_private_desktop: Some(false), }), ..ConfigRequirementsToml::default() }); @@ -654,5 +703,73 @@ mod tests { WindowsSandboxSetupMode::Unelevated, ]) ); + assert_eq!(mapped.windows_sandbox_private_desktop, Some(false)); + } + + #[test] + fn requirements_api_preserves_complex_config_values_and_otel_headers() { + let headers = HashMap::from([("Authorization".to_string(), "Bearer secret".to_string())]); + let shell_set = HashMap::from([("MANAGED".to_string(), "true".to_string())]); + + let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { + otel: Some(OtelConfigToml { + log_user_prompt: Some(false), + environment: Some("production".to_string()), + exporter: Some(OtelExporterKind::OtlpHttp { + endpoint: "https://otel.example.com/v1/logs".to_string(), + headers, + protocol: CoreOtelHttpProtocol::Json, + tls: None, + }), + trace_exporter: None, + metrics_exporter: None, + span_attributes: Some(BTreeMap::from([("team".to_string(), "codex".to_string())])), + tracestate: None, + }), + shell_environment_policy: Some(ShellEnvironmentPolicyToml { + inherit: Some(CoreShellEnvironmentPolicyInherit::Core), + ignore_default_excludes: Some(false), + exclude: Some(vec!["*SECRET*".to_string()]), + r#set: Some(shell_set.clone()), + include_only: None, + experimental_use_profile: Some(false), + }), + ..ConfigRequirementsToml::default() + }); + + assert_eq!( + mapped.otel, + Some(json!({ + "log_user_prompt": false, + "environment": "production", + "exporter": { + "otlp-http": { + "endpoint": "https://otel.example.com/v1/logs", + "headers": { + "Authorization": "Bearer secret" + }, + "protocol": "json", + "tls": null + } + }, + "trace_exporter": null, + "metrics_exporter": null, + "span_attributes": { + "team": "codex" + }, + "tracestate": null + })) + ); + assert_eq!( + mapped.shell_environment_policy, + Some(json!({ + "inherit": "core", + "ignore_default_excludes": false, + "exclude": ["*SECRET*"], + "set": shell_set, + "include_only": null, + "experimental_use_profile": false + })) + ); } } diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 1fbdebbd0a..a4e68d5f65 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -20,6 +20,10 @@ use crate::ConstraintError; use crate::ManagedHooksRequirementsToml; use crate::mcp_types::AppToolApproval; use crate::permissions_toml::PermissionProfileToml; +use crate::types::AuthCredentialsStoreMode; +use crate::types::FeedbackConfigToml; +use crate::types::OtelConfigToml; +use crate::types::ShellEnvironmentPolicyToml; use crate::types::WindowsSandboxModeToml; #[derive(Debug, Clone, PartialEq, Eq)] @@ -142,10 +146,23 @@ impl std::ops::DerefMut for ConstrainedWithSource { /// normalization. #[derive(Debug, Clone, PartialEq)] pub struct ConfigRequirements { + pub allowed_login_methods: Option>>, + pub allowed_chatgpt_workspaces: Option>>, + pub cli_auth_credentials_store: Option>, + pub chatgpt_base_url: Option>, + pub sqlite_home: Option>, + pub log_dir: Option>, + pub model_catalog_json: Option>, + pub check_for_update_on_startup: Option>, + pub otel: Option>, + pub allow_login_shell: Option>, + pub shell_environment_policy: Option>, + pub feedback: Option>, pub approval_policy: ConstrainedWithSource, pub approvals_reviewer: ConstrainedWithSource, pub permission_profile: ConstrainedWithSource, pub windows_sandbox_mode: ConstrainedWithSource>, + pub windows_sandbox_private_desktop: Option>, pub web_search_mode: ConstrainedWithSource, pub allow_managed_hooks_only: Option>, pub allow_appshots: Option>, @@ -167,6 +184,18 @@ pub struct ConfigRequirements { impl Default for ConfigRequirements { fn default() -> Self { 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, + check_for_update_on_startup: None, + otel: None, + allow_login_shell: None, + shell_environment_policy: None, + feedback: None, approval_policy: ConstrainedWithSource::new( Constrained::allow_any_from_default(), /*source*/ None, @@ -183,6 +212,7 @@ impl Default for ConfigRequirements { Constrained::allow_any(/*initial_value*/ None), /*source*/ None, ), + windows_sandbox_private_desktop: None, web_search_mode: ConstrainedWithSource::new( Constrained::allow_any(WebSearchMode::Cached), /*source*/ None, @@ -714,11 +744,12 @@ impl ComputerUseRequirementsToml { #[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] pub struct WindowsRequirementsToml { pub allowed_sandbox_implementations: Option>, + pub sandbox_private_desktop: Option, } impl WindowsRequirementsToml { pub fn is_empty(&self) -> bool { - self.allowed_sandbox_implementations.is_none() + self.allowed_sandbox_implementations.is_none() && self.sandbox_private_desktop.is_none() } } @@ -819,6 +850,18 @@ pub(crate) fn merge_app_requirements_descending( /// Base config deserialized from system `requirements.toml` or MDM. #[derive(Deserialize, Debug, Clone, Default, PartialEq)] pub struct ConfigRequirementsToml { + pub allowed_login_methods: Option>, + pub allowed_chatgpt_workspaces: Option>, + pub cli_auth_credentials_store: Option, + pub chatgpt_base_url: Option, + pub sqlite_home: Option, + pub log_dir: Option, + pub model_catalog_json: Option, + pub check_for_update_on_startup: Option, + pub otel: Option, + pub allow_login_shell: Option, + pub shell_environment_policy: Option, + pub feedback: Option, pub allowed_approval_policies: Option>, pub allowed_approvals_reviewers: Option>, pub allowed_sandbox_modes: Option>, @@ -874,6 +917,18 @@ impl std::ops::Deref for Sourced { #[derive(Debug, Clone, Default, PartialEq)] pub struct ConfigRequirementsWithSources { + pub allowed_login_methods: Option>>, + pub allowed_chatgpt_workspaces: Option>>, + pub cli_auth_credentials_store: Option>, + pub chatgpt_base_url: Option>, + pub sqlite_home: Option>, + pub log_dir: Option>, + pub model_catalog_json: Option>, + pub check_for_update_on_startup: Option>, + pub otel: Option>, + pub allow_login_shell: Option>, + pub shell_environment_policy: Option>, + pub feedback: Option>, pub allowed_approval_policies: Option>>, pub allowed_approvals_reviewers: Option>>, pub allowed_sandbox_modes: Option>>, @@ -915,6 +970,18 @@ impl ConfigRequirementsWithSources { // Destructure without `..` so adding fields to `ConfigRequirementsToml` // forces this merge logic to be updated. let ConfigRequirementsToml { + allowed_login_methods: _, + allowed_chatgpt_workspaces: _, + cli_auth_credentials_store: _, + chatgpt_base_url: _, + sqlite_home: _, + log_dir: _, + model_catalog_json: _, + check_for_update_on_startup: _, + otel: _, + allow_login_shell: _, + shell_environment_policy: _, + feedback: _, allowed_approval_policies: _, allowed_approvals_reviewers: _, allowed_sandbox_modes: _, @@ -951,6 +1018,18 @@ impl ConfigRequirementsWithSources { other, source, { + allowed_login_methods, + allowed_chatgpt_workspaces, + cli_auth_credentials_store, + chatgpt_base_url, + sqlite_home, + log_dir, + model_catalog_json, + check_for_update_on_startup, + otel, + allow_login_shell, + shell_environment_policy, + feedback, allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, @@ -984,6 +1063,18 @@ impl ConfigRequirementsWithSources { pub fn into_toml(self) -> ConfigRequirementsToml { let ConfigRequirementsWithSources { + allowed_login_methods, + allowed_chatgpt_workspaces, + cli_auth_credentials_store, + chatgpt_base_url, + sqlite_home, + log_dir, + model_catalog_json, + check_for_update_on_startup, + otel, + allow_login_shell, + shell_environment_policy, + feedback, allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, @@ -1006,6 +1097,18 @@ impl ConfigRequirementsWithSources { guardian_policy_config, } = self; 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), + check_for_update_on_startup: check_for_update_on_startup.map(|sourced| sourced.value), + otel: otel.map(|sourced| sourced.value), + allow_login_shell: allow_login_shell.map(|sourced| sourced.value), + shell_environment_policy: shell_environment_policy.map(|sourced| sourced.value), + feedback: feedback.map(|sourced| sourced.value), allowed_approval_policies: allowed_approval_policies.map(|sourced| sourced.value), allowed_approvals_reviewers: allowed_approvals_reviewers.map(|sourced| sourced.value), allowed_sandbox_modes: allowed_sandbox_modes.map(|sourced| sourced.value), @@ -1095,7 +1198,37 @@ impl ConfigRequirementsToml { } pub fn is_empty(&self) -> bool { - self.allowed_approval_policies.is_none() + 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() + && self.check_for_update_on_startup.is_none() + && self.otel.as_ref().is_none_or(|otel| { + otel.log_user_prompt.is_none() + && otel.environment.is_none() + && otel.exporter.is_none() + && otel.trace_exporter.is_none() + && otel.metrics_exporter.is_none() + && otel.span_attributes.is_none() + && otel.tracestate.is_none() + }) + && self.allow_login_shell.is_none() + && self.shell_environment_policy.as_ref().is_none_or(|policy| { + policy.inherit.is_none() + && policy.ignore_default_excludes.is_none() + && policy.exclude.is_none() + && policy.r#set.is_none() + && policy.include_only.is_none() + && policy.experimental_use_profile.is_none() + }) + && self + .feedback + .as_ref() + .is_none_or(|feedback| feedback.enabled.is_none()) + && self.allowed_approval_policies.is_none() && self.allowed_approvals_reviewers.is_none() && self.allowed_sandbox_modes.is_none() && self.allowed_permission_profiles.is_none() @@ -1148,6 +1281,18 @@ impl TryFrom for ConfigRequirements { // config loading and requirements API projection. The normalized // constraints below only need the compiled PermissionProfile envelope. let ConfigRequirementsWithSources { + allowed_login_methods, + allowed_chatgpt_workspaces, + cli_auth_credentials_store, + chatgpt_base_url, + sqlite_home, + log_dir, + model_catalog_json, + check_for_update_on_startup, + otel, + allow_login_shell, + shell_environment_policy, + feedback, allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, @@ -1170,6 +1315,37 @@ impl TryFrom for ConfigRequirements { guardian_policy_config, } = toml; + if let Some(Sourced { + value: methods, + source, + }) = &allowed_login_methods + && let Some(unknown) = methods + .keys() + .find(|method| method.as_str() != "api" && method.as_str() != "chatgpt") + { + return Err(ConstraintError::InvalidValue { + field_name: "allowed_login_methods", + candidate: unknown.clone(), + allowed: "[\"api\", \"chatgpt\"]".to_string(), + requirement_source: source.clone(), + }); + } + if let Some(Sourced { + value: workspaces, + source, + }) = &allowed_chatgpt_workspaces + && let Some(empty) = workspaces + .keys() + .find(|workspace| workspace.trim().is_empty()) + { + return Err(ConstraintError::InvalidValue { + field_name: "allowed_chatgpt_workspaces", + candidate: empty.clone(), + allowed: "non-empty workspace identifiers".to_string(), + requirement_source: source.clone(), + }); + } + let approval_policy = match allowed_approval_policies { Some(Sourced { value: policies, @@ -1267,42 +1443,60 @@ impl TryFrom for ConfigRequirements { /*source*/ None, ), }; - let windows_sandbox_mode = match windows { + let (windows_sandbox_mode, windows_sandbox_private_desktop) = match windows { Some(Sourced { value: WindowsRequirementsToml { - allowed_sandbox_implementations: Some(implementations), + allowed_sandbox_implementations, + sandbox_private_desktop, }, source: requirement_source, }) => { - if implementations.is_empty() { - return Err(ConstraintError::empty_field( - "windows.allowed_sandbox_implementations", - )); - } - // Prefer elevated when both Windows sandbox implementations are allowed. - let initial_value = if implementations.contains(&WindowsSandboxModeToml::Elevated) { - WindowsSandboxModeToml::Elevated - } else { - WindowsSandboxModeToml::Unelevated - }; + let sandbox_private_desktop = sandbox_private_desktop + .map(|value| Sourced::new(value, requirement_source.clone())); + let sandbox_mode = match allowed_sandbox_implementations { + Some(implementations) => { + if implementations.is_empty() { + return Err(ConstraintError::empty_field( + "windows.allowed_sandbox_implementations", + )); + } + // Prefer elevated when both Windows sandbox implementations are allowed. + let initial_value = + if implementations.contains(&WindowsSandboxModeToml::Elevated) { + WindowsSandboxModeToml::Elevated + } else { + WindowsSandboxModeToml::Unelevated + }; - let requirement_source_for_error = requirement_source.clone(); - let constrained = - Constrained::new(Some(initial_value), move |candidate| match candidate { - Some(candidate) if implementations.contains(candidate) => Ok(()), - _ => Err(ConstraintError::InvalidValue { - field_name: "windows.sandbox", - candidate: format!("{candidate:?}"), - allowed: format!("{implementations:?}"), - requirement_source: requirement_source_for_error.clone(), - }), - })?; - ConstrainedWithSource::new(constrained, Some(requirement_source)) + let requirement_source_for_error = requirement_source.clone(); + let constrained = Constrained::new( + Some(initial_value), + move |candidate| match candidate { + Some(candidate) if implementations.contains(candidate) => Ok(()), + _ => Err(ConstraintError::InvalidValue { + field_name: "windows.sandbox", + candidate: format!("{candidate:?}"), + allowed: format!("{implementations:?}"), + requirement_source: requirement_source_for_error.clone(), + }), + }, + )?; + ConstrainedWithSource::new(constrained, Some(requirement_source)) + } + None => ConstrainedWithSource::new( + Constrained::allow_any(/*initial_value*/ None), + /*source*/ None, + ), + }; + (sandbox_mode, sandbox_private_desktop) } - Some(_) | None => ConstrainedWithSource::new( - Constrained::allow_any(/*initial_value*/ None), - /*source*/ None, + None => ( + ConstrainedWithSource::new( + Constrained::allow_any(/*initial_value*/ None), + /*source*/ None, + ), + None, ), }; let exec_policy = match rules { @@ -1427,10 +1621,23 @@ impl TryFrom for ConfigRequirements { }); let guardian_policy_config_source = guardian_policy_config.map(|sourced| sourced.source); Ok(ConfigRequirements { + allowed_login_methods, + allowed_chatgpt_workspaces, + cli_auth_credentials_store, + chatgpt_base_url, + sqlite_home, + log_dir, + model_catalog_json, + check_for_update_on_startup, + otel, + allow_login_shell, + shell_environment_policy, + feedback, approval_policy, approvals_reviewer, permission_profile, windows_sandbox_mode, + windows_sandbox_private_desktop, web_search_mode, allow_managed_hooks_only, allow_appshots, @@ -1516,6 +1723,18 @@ mod tests { fn with_unknown_source(toml: ConfigRequirementsToml) -> ConfigRequirementsWithSources { let ConfigRequirementsToml { + allowed_login_methods, + allowed_chatgpt_workspaces, + cli_auth_credentials_store, + chatgpt_base_url, + sqlite_home, + log_dir, + model_catalog_json, + check_for_update_on_startup, + otel, + allow_login_shell, + shell_environment_policy, + feedback, allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, @@ -1539,6 +1758,26 @@ mod tests { guardian_policy_config, } = toml; ConfigRequirementsWithSources { + allowed_login_methods: allowed_login_methods + .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 + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + check_for_update_on_startup: check_for_update_on_startup + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + otel: otel.map(|value| Sourced::new(value, RequirementSource::Unknown)), + allow_login_shell: allow_login_shell + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + shell_environment_policy: shell_environment_policy + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + feedback: feedback.map(|value| Sourced::new(value, RequirementSource::Unknown)), allowed_approval_policies: allowed_approval_policies .map(|value| Sourced::new(value, RequirementSource::Unknown)), allowed_approvals_reviewers: allowed_approvals_reviewers @@ -1707,6 +1946,152 @@ mod tests { Ok(()) } + #[test] + fn deserialize_managed_config_replacement_requirements() -> Result<()> { + let sqlite_home = std::env::temp_dir().join("codex-sqlite"); + let log_dir = std::env::temp_dir().join("codex-logs"); + let model_catalog_json = std::env::temp_dir().join("models.json"); + let requirements: ConfigRequirementsToml = from_str(&format!( + r#" + allowed_login_methods = {{ chatgpt = true, api = false }} + allowed_chatgpt_workspaces = {{ workspace_1 = true, workspace_2 = false }} + cli_auth_credentials_store = "keyring" + chatgpt_base_url = "https://chatgpt.example.com" + sqlite_home = {:?} + log_dir = {:?} + model_catalog_json = {:?} + check_for_update_on_startup = false + allow_login_shell = false + + [otel] + log_user_prompt = false + environment = "prod" + span_attributes = {{ team = "codex" }} + + [shell_environment_policy] + ignore_default_excludes = false + set = {{ MANAGED_ENV = "1" }} + + [feedback] + enabled = false + + [windows] + sandbox_private_desktop = false + "#, + sqlite_home.display().to_string(), + log_dir.display().to_string(), + model_catalog_json.display().to_string(), + ))?; + + assert_eq!( + requirements.allowed_login_methods, + Some(BTreeMap::from([ + ("api".to_string(), false), + ("chatgpt".to_string(), true), + ])) + ); + assert_eq!( + requirements.allowed_chatgpt_workspaces, + Some(BTreeMap::from([ + ("workspace_1".to_string(), true), + ("workspace_2".to_string(), false), + ])) + ); + assert_eq!( + requirements.cli_auth_credentials_store, + Some(AuthCredentialsStoreMode::Keyring) + ); + assert_eq!( + requirements.chatgpt_base_url.as_deref(), + Some("https://chatgpt.example.com") + ); + assert_eq!( + requirements.sqlite_home.as_deref(), + Some(sqlite_home.as_path()) + ); + assert_eq!(requirements.log_dir.as_deref(), Some(log_dir.as_path())); + assert_eq!( + requirements.model_catalog_json.as_deref(), + Some(model_catalog_json.as_path()) + ); + assert_eq!(requirements.check_for_update_on_startup, Some(false)); + assert_eq!(requirements.allow_login_shell, Some(false)); + assert_eq!( + requirements + .otel + .as_ref() + .and_then(|otel| otel.span_attributes.as_ref()), + Some(&BTreeMap::from([("team".to_string(), "codex".to_string())])) + ); + assert_eq!( + requirements + .shell_environment_policy + .as_ref() + .and_then(|policy| policy.r#set.as_ref()) + .and_then(|set| set.get("MANAGED_ENV")) + .map(String::as_str), + Some("1") + ); + assert_eq!( + requirements.feedback, + Some(FeedbackConfigToml { + enabled: Some(false), + }) + ); + assert_eq!( + requirements.windows, + Some(WindowsRequirementsToml { + allowed_sandbox_implementations: None, + sandbox_private_desktop: Some(false), + }) + ); + assert!(!requirements.is_empty()); + Ok(()) + } + + #[test] + fn normalize_rejects_unknown_login_method() -> Result<()> { + let config: ConfigRequirementsToml = from_str( + r#" + [allowed_login_methods] + chatgpt = true + sso = true + "#, + )?; + + assert_eq!( + ConfigRequirements::try_from(with_unknown_source(config)), + Err(ConstraintError::InvalidValue { + field_name: "allowed_login_methods", + candidate: "sso".to_string(), + allowed: "[\"api\", \"chatgpt\"]".to_string(), + requirement_source: RequirementSource::Unknown, + }) + ); + Ok(()) + } + + #[test] + fn normalize_rejects_blank_chatgpt_workspace_id() -> Result<()> { + let config: ConfigRequirementsToml = from_str( + r#" + [allowed_chatgpt_workspaces] + " " = true + "#, + )?; + + assert_eq!( + ConfigRequirements::try_from(with_unknown_source(config)), + Err(ConstraintError::InvalidValue { + field_name: "allowed_chatgpt_workspaces", + candidate: " ".to_string(), + allowed: "non-empty workspace identifiers".to_string(), + requirement_source: RequirementSource::Unknown, + }) + ); + Ok(()) + } + #[test] fn merge_unset_fields_copies_every_field_and_sets_sources() { let mut target = ConfigRequirementsWithSources::default(); @@ -1729,6 +2114,27 @@ mod tests { let computer_use = ComputerUseRequirementsToml { allow_locked_computer_use: Some(false), }; + let allowed_login_methods = + BTreeMap::from([("api".to_string(), true), ("chatgpt".to_string(), false)]); + let allowed_chatgpt_workspaces = BTreeMap::from([("workspace-1".to_string(), true)]); + let sqlite_home = AbsolutePathBuf::try_from(std::env::temp_dir().join("sqlite")) + .expect("absolute sqlite home"); + let log_dir = AbsolutePathBuf::try_from(std::env::temp_dir().join("logs")) + .expect("absolute log directory"); + let model_catalog_json = + AbsolutePathBuf::try_from(std::env::temp_dir().join("managed-model-catalog.json")) + .expect("absolute model catalog path"); + let otel = OtelConfigToml { + log_user_prompt: Some(false), + ..Default::default() + }; + let shell_environment_policy = ShellEnvironmentPolicyToml { + ignore_default_excludes: Some(false), + ..Default::default() + }; + let feedback = FeedbackConfigToml { + enabled: Some(false), + }; let enforce_residency = ResidencyRequirement::Us; let enforce_source = source.clone(); let guardian_policy_config = "Use the company-managed guardian policy.".to_string(); @@ -1736,6 +2142,18 @@ mod tests { // Intentionally constructed without `..Default::default()` so adding a new field to // `ConfigRequirementsToml` forces this test to be updated. let other = ConfigRequirementsToml { + allowed_login_methods: Some(allowed_login_methods.clone()), + allowed_chatgpt_workspaces: Some(allowed_chatgpt_workspaces.clone()), + cli_auth_credentials_store: Some(AuthCredentialsStoreMode::Keyring), + chatgpt_base_url: Some("https://chatgpt.example.com".to_string()), + sqlite_home: Some(sqlite_home.clone()), + log_dir: Some(log_dir.clone()), + model_catalog_json: Some(model_catalog_json.clone()), + check_for_update_on_startup: Some(false), + otel: Some(otel.clone()), + allow_login_shell: Some(false), + shell_environment_policy: Some(shell_environment_policy.clone()), + feedback: Some(feedback.clone()), allowed_approval_policies: Some(allowed_approval_policies.clone()), allowed_approvals_reviewers: Some(allowed_approvals_reviewers.clone()), allowed_sandbox_modes: Some(allowed_sandbox_modes.clone()), @@ -1764,6 +2182,30 @@ mod tests { assert_eq!( target, ConfigRequirementsWithSources { + allowed_login_methods: Some(Sourced::new(allowed_login_methods, source.clone(),)), + allowed_chatgpt_workspaces: Some(Sourced::new( + allowed_chatgpt_workspaces, + source.clone(), + )), + cli_auth_credentials_store: Some(Sourced::new( + AuthCredentialsStoreMode::Keyring, + source.clone(), + )), + chatgpt_base_url: Some(Sourced::new( + "https://chatgpt.example.com".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(),)), + check_for_update_on_startup: Some(Sourced::new(false, source.clone())), + otel: Some(Sourced::new(otel, source.clone())), + allow_login_shell: Some(Sourced::new(false, source.clone())), + shell_environment_policy: Some(Sourced::new( + shell_environment_policy, + source.clone(), + )), + feedback: Some(Sourced::new(feedback, source.clone())), allowed_approval_policies: Some(Sourced::new( allowed_approval_policies, source.clone() @@ -1824,6 +2266,18 @@ mod tests { assert_eq!( empty_target, ConfigRequirementsWithSources { + 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, + check_for_update_on_startup: None, + otel: None, + allow_login_shell: None, + shell_environment_policy: None, + feedback: None, allowed_approval_policies: Some(Sourced::new( vec![AskForApproval::OnRequest], source_location, @@ -1877,6 +2331,18 @@ mod tests { assert_eq!( populated_target, ConfigRequirementsWithSources { + 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, + check_for_update_on_startup: None, + otel: None, + allow_login_shell: None, + shell_environment_policy: None, + feedback: None, allowed_approval_policies: Some(Sourced::new( vec![AskForApproval::Never], existing_source, diff --git a/codex-rs/config/src/requirements_layers/stack.rs b/codex-rs/config/src/requirements_layers/stack.rs index 0396cda237..b730028363 100644 --- a/codex-rs/config/src/requirements_layers/stack.rs +++ b/codex-rs/config/src/requirements_layers/stack.rs @@ -173,6 +173,18 @@ fn populate_merged_regular_fields_with_sources( // Destructure without `..` so every new requirements field must choose // whether it belongs in the regular TOML merge path or in a special merger. let ConfigRequirementsToml { + allowed_login_methods, + allowed_chatgpt_workspaces, + cli_auth_credentials_store, + chatgpt_base_url, + sqlite_home, + log_dir, + model_catalog_json, + check_for_update_on_startup, + otel, + allow_login_shell, + shell_environment_policy, + feedback, allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, @@ -196,6 +208,21 @@ fn populate_merged_regular_fields_with_sources( guardian_policy_config, } = requirements; + 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"]); + set_sourced!( + check_for_update_on_startup, + &["check_for_update_on_startup"] + ); + set_sourced!(otel, &["otel"]); + set_sourced!(allow_login_shell, &["allow_login_shell"]); + set_sourced!(shell_environment_policy, &["shell_environment_policy"]); + set_sourced!(feedback, &["feedback"]); set_sourced!(allowed_approval_policies, &["allowed_approval_policies"]); set_sourced!( allowed_approvals_reviewers, diff --git a/codex-rs/config/src/requirements_layers/stack_tests.rs b/codex-rs/config/src/requirements_layers/stack_tests.rs index 82a99a7f0a..bc23ed26b7 100644 --- a/codex-rs/config/src/requirements_layers/stack_tests.rs +++ b/codex-rs/config/src/requirements_layers/stack_tests.rs @@ -291,6 +291,107 @@ approval_mode = "approve" ); } +#[test] +fn managed_config_replacement_tables_merge_recursively() { + let composed = compose_requirements_for_hostname( + vec![ + layer( + "req_low", + "Low", + r#" +[allowed_login_methods] +chatgpt = true +api = true + +[allowed_chatgpt_workspaces] +workspace_a = true +workspace_b = true + +[otel.span_attributes] +region = "us" +team = "low" + +[shell_environment_policy.set] +LOW = "1" +SHARED = "low" + +[windows] +sandbox_private_desktop = true +"#, + ), + layer( + "req_high", + "High", + r#" +[allowed_login_methods] +api = false + +[allowed_chatgpt_workspaces] +workspace_b = false + +[otel.span_attributes] +team = "high" + +[shell_environment_policy.set] +HIGH = "1" +SHARED = "high" + +[feedback] +enabled = false +"#, + ), + ], + /*hostname*/ None, + ) + .expect("compose requirements") + .expect("requirements present"); + + assert_eq!( + composed.clone().into_toml(), + expected_requirements( + r#" +[allowed_login_methods] +chatgpt = true +api = false + +[allowed_chatgpt_workspaces] +workspace_a = true +workspace_b = false + +[otel.span_attributes] +region = "us" +team = "high" + +[shell_environment_policy.set] +LOW = "1" +HIGH = "1" +SHARED = "high" + +[feedback] +enabled = false + +[windows] +sandbox_private_desktop = true +"#, + ) + ); + assert_eq!( + composed.allowed_login_methods.map(|sourced| sourced.source), + Some(RequirementSource::Composite { + sources: vec![ + RequirementSource::EnterpriseManaged { + id: "req_high".to_string(), + name: "High".to_string(), + }, + RequirementSource::EnterpriseManaged { + id: "req_low".to_string(), + name: "Low".to_string(), + }, + ], + }) + ); +} + #[test] fn merged_table_source_is_composite_in_priority_order() { let high_source = RequirementSource::EnterpriseManaged { diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 58893f8c04..20d3a54aa1 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -8343,6 +8343,18 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() let fixture = create_test_fixture()?; 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, + check_for_update_on_startup: None, + otel: None, + allow_login_shell: None, + shell_environment_policy: None, + feedback: None, allowed_approval_policies: None, allowed_approvals_reviewers: None, allowed_sandbox_modes: None, @@ -9335,6 +9347,256 @@ async fn explicit_approval_policy_falls_back_when_disallowed_by_requirements() - Ok(()) } +#[tokio::test] +async fn exact_requirements_override_only_managed_leaves() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let catalog_path = codex_home.path().join("required-models.json"); + let mut catalog = bundled_models_response() + .unwrap_or_else(|err| panic!("bundled models.json should parse: {err}")); + catalog.models = catalog.models.into_iter().take(1).collect(); + std::fs::write( + &catalog_path, + serde_json::to_string(&catalog).expect("serialize catalog"), + )?; + let required_sqlite_home = codex_home.path().join("required-state"); + let required_log_dir = codex_home.path().join("required-logs"); + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#" +forced_chatgpt_workspace_id = ["A", "C"] +cli_auth_credentials_store = "file" +chatgpt_base_url = "https://user.example/backend-api" +check_for_update_on_startup = true +allow_login_shell = true + +[shell_environment_policy] +ignore_default_excludes = true +exclude = ["USER_*"] + +[otel] +environment = "user" + +[otel.span_attributes] +configured = "kept" +managed = "old" + +[feedback] +enabled = true + +[windows] +sandbox_private_desktop = true +"#, + )?; + + let requirements = format!( + r#" +cli_auth_credentials_store = "keyring" +chatgpt_base_url = "https://managed.example/backend-api" +sqlite_home = "{}" +log_dir = "{}" +model_catalog_json = "{}" +check_for_update_on_startup = false +allow_login_shell = false + +[allowed_login_methods] +chatgpt = true +api = false + +[allowed_chatgpt_workspaces] +"A" = true +"B" = true + +[shell_environment_policy] +ignore_default_excludes = false +experimental_use_profile = true + +[otel] +log_user_prompt = false +environment = "managed" + +[otel.span_attributes] +managed = "required" + +[feedback] +enabled = false + +[windows] +sandbox_private_desktop = false +"#, + required_sqlite_home.display(), + required_log_dir.display(), + catalog_path.display(), + ); + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement(requirements), + ) + .build() + .await?; + + let expected_shell_environment_policy: ShellEnvironmentPolicy = + codex_config::types::ShellEnvironmentPolicyToml { + ignore_default_excludes: Some(false), + exclude: Some(vec!["USER_*".to_string()]), + experimental_use_profile: Some(true), + ..Default::default() + } + .into(); + assert_eq!( + ( + config.forced_login_method, + config.forced_chatgpt_workspace_id.clone(), + config.cli_auth_credentials_store_mode, + config.chatgpt_base_url.clone(), + config.sqlite_home.clone(), + config.log_dir.clone(), + config.model_catalog.clone(), + config.check_for_update_on_startup, + config.permissions.allow_login_shell, + config.permissions.shell_environment_policy.clone(), + config.feedback_enabled, + config.permissions.windows_sandbox_private_desktop, + ), + ( + Some(ForcedLoginMethod::Chatgpt), + Some(vec!["A".to_string()]), + AuthCredentialsStoreMode::Keyring, + "https://managed.example/backend-api".to_string(), + required_sqlite_home, + required_log_dir, + Some(catalog), + false, + false, + expected_shell_environment_policy, + false, + false, + ) + ); + assert_eq!( + config.otel.span_attributes, + BTreeMap::from([ + ("configured".to_string(), "kept".to_string()), + ("managed".to_string(), "required".to_string()), + ]) + ); + Ok(()) +} + +#[tokio::test] +async fn invalid_required_otel_trace_metadata_fails_closed() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let err = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + "[otel.span_attributes]\n\"\" = \"missing-key\"\n", + ), + ) + .build() + .await + .expect_err("invalid managed OTEL metadata should fail startup"); + + assert_eq!(err.kind(), ErrorKind::InvalidInput); + assert!( + err.to_string() + .contains("invalid required `otel.span_attributes`") + ); + Ok(()) +} + +#[tokio::test] +async fn conflicting_login_method_requirements_fail_closed() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + "forced_login_method = \"api\"\n", + )?; + let err = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + "[allowed_login_methods]\nchatgpt = true\n", + ), + ) + .build() + .await + .expect_err("conflicting login restrictions should fail closed"); + + assert_eq!(err.kind(), ErrorKind::InvalidInput); + assert!( + err.to_string() + .contains("conflicts with `allowed_login_methods`") + ); + Ok(()) +} + +#[tokio::test] +async fn workspace_requirements_can_disable_all_chatgpt_workspaces() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[allowed_chatgpt_workspaces] +"A" = false +"#, + ), + ) + .build() + .await?; + + assert_eq!(config.forced_chatgpt_workspace_id, Some(Vec::new())); + Ok(()) +} + +#[tokio::test] +async fn bootstrap_config_applies_local_auth_requirements() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let requirements_path = codex_home.path().join("requirements.toml"); + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#" +cli_auth_credentials_store = "file" +chatgpt_base_url = "https://user.example/backend-api" +"#, + )?; + std::fs::write( + &requirements_path, + r#" +cli_auth_credentials_store = "keyring" +chatgpt_base_url = "https://managed.example/backend-api" +"#, + )?; + let mut loader_overrides = LoaderOverrides::without_managed_config_for_tests(); + loader_overrides.system_requirements_path = Some(requirements_path); + + let config = load_bootstrap_config_as_toml_with_cli_and_load_options( + codex_home.path(), + Some(&codex_home.path().abs()), + Vec::new(), + ConfigLoadOptions { + loader_overrides, + ..Default::default() + }, + ) + .await?; + + assert_eq!( + (config.cli_auth_credentials_store, config.chatgpt_base_url,), + ( + Some(AuthCredentialsStoreMode::Keyring), + Some("https://managed.example/backend-api".to_string()), + ) + ); + Ok(()) +} + #[tokio::test] async fn feature_requirements_normalize_effective_feature_values() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 597033e4e1..7f2695d9b1 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -110,7 +110,9 @@ use rmcp::model::FormElicitationCapability; use rmcp::model::UrlElicitationCapability; use serde::Deserialize; use serde::Serialize; +use serde::de::DeserializeOwned; use std::collections::BTreeMap; +use std::collections::BTreeSet; use std::collections::HashMap; use std::collections::HashSet; use std::collections::hash_map::Entry; @@ -1661,6 +1663,41 @@ pub async fn load_config_as_toml_with_cli_and_load_options( Ok(cfg) } +/// Load the raw merged config needed to bootstrap cloud requirements, applying +/// exact auth requirements from every layer available to this load. +/// +/// The initial bootstrap call has no cloud loader, so system and MDM +/// requirements determine how the cloud bundle is fetched. A later call may +/// include cloud requirements, but those values cannot retroactively change +/// the loader that fetched them. +pub async fn load_bootstrap_config_as_toml_with_cli_and_load_options( + codex_home: &Path, + cwd: Option<&AbsolutePathBuf>, + cli_overrides: Vec<(String, TomlValue)>, + options: impl Into, +) -> std::io::Result { + let config_layer_stack = load_config_layers_state( + LOCAL_FS.as_ref(), + codex_home, + cwd.cloned(), + &cli_overrides, + options, + &codex_config::NoopThreadConfigLoader, + ) + .await?; + + let merged_toml = config_layer_stack.effective_config(); + let mut cfg = deserialize_config_toml_with_base(merged_toml, codex_home)?; + let requirements = config_layer_stack.requirements_toml(); + if let Some(required) = requirements.cli_auth_credentials_store { + cfg.cli_auth_credentials_store = Some(required); + } + if let Some(required) = requirements.chatgpt_base_url.as_ref() { + cfg.chatgpt_base_url = Some(required.clone()); + } + Ok(cfg) +} + pub fn deserialize_config_toml_with_base( root_value: TomlValue, config_base_dir: &Path, @@ -1818,6 +1855,171 @@ where Ok(false) } +fn apply_exact_requirement( + field_name: &'static str, + configured_value: &mut Option, + requirement: Option>, + startup_warnings: &mut Vec, +) where + T: Clone + PartialEq + std::fmt::Debug, +{ + let Some(Sourced { value, source }) = requirement else { + return; + }; + if configured_value + .as_ref() + .is_some_and(|configured| configured != &value) + { + tracing::warn!( + ?source, + ?value, + "configured value is overridden by an exact requirement for {field_name}" + ); + startup_warnings.push(format!( + "Configured value for `{field_name}` is overridden by the required value {value:?} from {source}." + )); + } + *configured_value = Some(value); +} + +fn merge_exact_requirement( + field_name: &'static str, + configured_value: T, + requirement: Option>, + startup_warnings: &mut Vec, +) -> std::io::Result +where + T: Clone + PartialEq + std::fmt::Debug + Serialize + DeserializeOwned, +{ + let Some(Sourced { value, source }) = requirement else { + return Ok(configured_value); + }; + let mut configured_toml = TomlValue::try_from(configured_value).map_err(|err| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("failed to serialize configured `{field_name}` value: {err}"), + ) + })?; + let required_toml = TomlValue::try_from(value).map_err(|err| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("failed to serialize required `{field_name}` value: {err}"), + ) + })?; + if toml_values_conflict(&configured_toml, &required_toml) { + tracing::warn!( + ?source, + "configured value contains leaves overridden by exact requirements for {field_name}" + ); + startup_warnings.push(format!( + "Configured leaves under `{field_name}` are overridden by requirements from {source}." + )); + } + codex_config::merge_toml_values(&mut configured_toml, &required_toml); + configured_toml.try_into().map_err(|err| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("failed to deserialize required `{field_name}` value: {err}"), + ) + }) +} + +fn toml_values_conflict(configured: &TomlValue, required: &TomlValue) -> bool { + match (configured, required) { + (TomlValue::Table(configured), TomlValue::Table(required)) => { + required.iter().any(|(key, required)| { + configured + .get(key) + .is_some_and(|configured| toml_values_conflict(configured, required)) + }) + } + _ => configured != required, + } +} + +fn validate_otel_requirement( + requirement: &codex_config::types::OtelConfigToml, +) -> std::io::Result<()> { + if let Some(span_attributes) = requirement.span_attributes.as_ref() { + codex_otel::validate_span_attributes(span_attributes).map_err(|err| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("invalid required `otel.span_attributes`: {err}"), + ) + })?; + } + if let Some(tracestate) = requirement.tracestate.as_ref() { + codex_otel::validate_tracestate_entries(tracestate).map_err(|err| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("invalid required `otel.tracestate`: {err}"), + ) + })?; + } + Ok(()) +} + +fn apply_otel_requirement( + configured: &mut Option, + requirement: Sourced, + startup_warnings: &mut Vec, +) { + let Sourced { + value: required, + source, + } = requirement; + let configured = configured.get_or_insert_default(); + let mut conflict = false; + + macro_rules! replace_leaf { + ($field:ident) => { + if let Some(required) = required.$field { + conflict |= configured + .$field + .as_ref() + .is_some_and(|configured| configured != &required); + configured.$field = Some(required); + } + }; + } + + replace_leaf!(log_user_prompt); + replace_leaf!(environment); + replace_leaf!(exporter); + replace_leaf!(trace_exporter); + replace_leaf!(metrics_exporter); + + if let Some(required) = required.span_attributes { + let configured = configured.span_attributes.get_or_insert_default(); + conflict |= required + .iter() + .any(|(key, value)| configured.get(key).is_some_and(|current| current != value)); + configured.extend(required); + } + if let Some(required) = required.tracestate { + let configured = configured.tracestate.get_or_insert_default(); + for (member, required_fields) in required { + let configured_fields = configured.entry(member).or_default(); + conflict |= required_fields.iter().any(|(key, value)| { + configured_fields + .get(key) + .is_some_and(|current| current != value) + }); + configured_fields.extend(required_fields); + } + } + + if conflict { + tracing::warn!( + ?source, + "configured OTEL leaves are overridden by exact requirements" + ); + startup_warnings.push(format!( + "Configured leaves under `otel` are overridden by requirements from {source}." + )); + } +} + fn mcp_server_matches_requirement( requirement: &McpServerRequirement, server: &McpServerConfig, @@ -2566,7 +2768,7 @@ impl Config { pub(crate) async fn load_config_with_layer_stack( fs: &dyn ExecutorFileSystem, - cfg: ConfigToml, + mut cfg: ConfigToml, overrides: ConfigOverrides, codex_home: AbsolutePathBuf, config_layer_stack: ConfigLayerStack, @@ -2590,6 +2792,19 @@ impl Config { permission_profile: mut constrained_permission_profile, windows_sandbox_mode: mut constrained_windows_sandbox_mode, web_search_mode: mut constrained_web_search_mode, + allowed_login_methods, + allowed_chatgpt_workspaces, + cli_auth_credentials_store: required_cli_auth_credentials_store, + chatgpt_base_url: required_chatgpt_base_url, + sqlite_home: required_sqlite_home, + log_dir: required_log_dir, + model_catalog_json: required_model_catalog_json, + check_for_update_on_startup: required_check_for_update_on_startup, + otel: required_otel, + allow_login_shell: required_allow_login_shell, + shell_environment_policy: required_shell_environment_policy, + feedback: required_feedback, + windows_sandbox_private_desktop: required_windows_sandbox_private_desktop, allow_managed_hooks_only: _, allow_appshots: _, computer_use: _, @@ -2608,6 +2823,77 @@ impl Config { .startup_warnings() .unwrap_or_default() .to_vec(); + let cli_auth_credentials_store_is_required = + required_cli_auth_credentials_store.is_some(); + apply_exact_requirement( + "cli_auth_credentials_store", + &mut cfg.cli_auth_credentials_store, + required_cli_auth_credentials_store, + &mut startup_warnings, + ); + apply_exact_requirement( + "chatgpt_base_url", + &mut cfg.chatgpt_base_url, + required_chatgpt_base_url, + &mut startup_warnings, + ); + apply_exact_requirement( + "sqlite_home", + &mut cfg.sqlite_home, + required_sqlite_home, + &mut startup_warnings, + ); + apply_exact_requirement( + "log_dir", + &mut cfg.log_dir, + required_log_dir, + &mut startup_warnings, + ); + apply_exact_requirement( + "model_catalog_json", + &mut cfg.model_catalog_json, + required_model_catalog_json, + &mut startup_warnings, + ); + apply_exact_requirement( + "check_for_update_on_startup", + &mut cfg.check_for_update_on_startup, + required_check_for_update_on_startup, + &mut startup_warnings, + ); + apply_exact_requirement( + "allow_login_shell", + &mut cfg.allow_login_shell, + required_allow_login_shell, + &mut startup_warnings, + ); + cfg.shell_environment_policy = merge_exact_requirement( + "shell_environment_policy", + cfg.shell_environment_policy, + required_shell_environment_policy, + &mut startup_warnings, + )?; + if let Some(required_otel) = required_otel { + validate_otel_requirement(&required_otel.value)?; + apply_otel_requirement(&mut cfg.otel, required_otel, &mut startup_warnings); + } + if let Some(required_feedback) = required_feedback { + cfg.feedback = Some(merge_exact_requirement( + "feedback", + cfg.feedback.take().unwrap_or_default(), + Some(required_feedback), + &mut startup_warnings, + )?); + } + if let Some(required) = required_windows_sandbox_private_desktop { + let windows = cfg.windows.get_or_insert_default(); + apply_exact_requirement( + "windows.sandbox_private_desktop", + &mut windows.sandbox_private_desktop, + Some(required), + &mut startup_warnings, + ); + } let user_instructions = AgentsMdManager::load_global_instructions( LOCAL_FS.as_ref(), Some(&codex_home), @@ -3202,8 +3488,68 @@ impl Config { .collect::>() }) .filter(|values| !values.is_empty()); + let forced_chatgpt_workspace_id = match allowed_chatgpt_workspaces { + Some(requirement) => { + let allowed = requirement + .value + .into_iter() + .filter_map(|(workspace, allowed)| allowed.then_some(workspace)) + .collect::>(); + Some(match forced_chatgpt_workspace_id { + Some(configured) => configured + .into_iter() + .filter(|workspace| allowed.contains(workspace)) + .collect(), + None => allowed.into_iter().collect(), + }) + } + None => forced_chatgpt_workspace_id, + }; - let forced_login_method = cfg.forced_login_method; + let forced_login_method = match allowed_login_methods { + Some(requirement) => { + let chatgpt_allowed = requirement + .value + .get("chatgpt") + .copied() + .unwrap_or(false); + let api_allowed = requirement + .value + .get("api") + .copied() + .unwrap_or(false); + match cfg.forced_login_method { + Some(ForcedLoginMethod::Chatgpt) if chatgpt_allowed => { + Some(ForcedLoginMethod::Chatgpt) + } + Some(ForcedLoginMethod::Api) if api_allowed => Some(ForcedLoginMethod::Api), + Some(configured) => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "configured `forced_login_method = \"{configured}\"` conflicts with `allowed_login_methods` from {}", + requirement.source + ), + )); + } + None => match (chatgpt_allowed, api_allowed) { + (true, true) => None, + (true, false) => Some(ForcedLoginMethod::Chatgpt), + (false, true) => Some(ForcedLoginMethod::Api), + (false, false) => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "`allowed_login_methods` from {} does not allow any login method", + requirement.source + ), + )); + } + }, + } + } + None => cfg.forced_login_method, + }; let model = model.or(cfg.model); let notices = cfg.notice.unwrap_or_default(); @@ -3464,10 +3810,14 @@ 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: { + let configured = cfg.cli_auth_credentials_store.unwrap_or_default(); + if cli_auth_credentials_store_is_required { + configured + } else { + resolve_cli_auth_credentials_store_mode(configured, env!("CARGO_PKG_VERSION")) + } + }, mcp_servers, // 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. diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index fbecfe5620..09386a9b11 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -63,7 +63,7 @@ use codex_core::config::Config; use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; use codex_core::config::find_codex_home; -use codex_core::config::load_config_as_toml_with_cli_and_load_options; +use codex_core::config::load_bootstrap_config_as_toml_with_cli_and_load_options; use codex_core::config::resolve_oss_provider; use codex_core::config::resolve_profile_v2_config_path; use codex_core::find_thread_meta_by_name_str; @@ -617,7 +617,7 @@ async fn load_config_toml_or_exit( strict_config: bool, cloud_config_bundle: CloudConfigBundleLoader, ) -> codex_config::config_toml::ConfigToml { - match load_config_as_toml_with_cli_and_load_options( + match load_bootstrap_config_as_toml_with_cli_and_load_options( codex_home, cwd, cli_kv_overrides, diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index a9f6f7abe1..bd40737986 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -54,6 +54,7 @@ fn windows_sandbox_requirements_stack( let requirements_toml = codex_config::ConfigRequirementsToml { windows: Some(codex_config::WindowsRequirementsToml { allowed_sandbox_implementations: Some(allowed_sandbox_implementations), + sandbox_private_desktop: None, }), ..Default::default() }; diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index d1dde975f3..005355e825 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -744,6 +744,18 @@ mod tests { }; let requirements_toml = 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, + check_for_update_on_startup: None, + otel: None, + allow_login_shell: None, + shell_environment_policy: None, + feedback: None, allowed_approval_policies: Some(vec![AskForApproval::OnRequest.to_core()]), allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]), allowed_sandbox_modes: Some(vec![SandboxModeRequirement::ReadOnly]), @@ -1103,6 +1115,18 @@ approval_policy = "never" }; let requirements_toml = 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, + check_for_update_on_startup: None, + otel: None, + allow_login_shell: None, + shell_environment_policy: None, + feedback: None, allowed_approval_policies: None, allowed_approvals_reviewers: None, allowed_sandbox_modes: None, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 4a82da9332..101cabd744 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -7,7 +7,7 @@ use crate::legacy_core::check_execpolicy_for_warnings; use crate::legacy_core::config::Config; use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; -use crate::legacy_core::config::load_config_as_toml_with_cli_and_load_options; +use crate::legacy_core::config::load_bootstrap_config_as_toml_with_cli_and_load_options; use crate::legacy_core::config::resolve_oss_provider; use crate::legacy_core::config::resolve_profile_v2_config_path; use crate::legacy_core::format_exec_policy_error_with_source; @@ -1190,7 +1190,12 @@ pub async fn run_main( .config_layer_stack .effective_config() .as_table() - .is_some_and(|table| table.contains_key("log_dir")); + .is_some_and(|table| table.contains_key("log_dir")) + || config + .config_layer_stack + .requirements_toml() + .log_dir + .is_some(); #[allow(clippy::print_stderr)] match check_execpolicy_for_warnings(&config.config_layer_stack).await { @@ -1997,7 +2002,7 @@ async fn load_config_toml_or_exit( strict_config: bool, cloud_config_bundle: CloudConfigBundleLoader, ) -> codex_config::config_toml::ConfigToml { - match load_config_as_toml_with_cli_and_load_options( + match load_bootstrap_config_as_toml_with_cli_and_load_options( codex_home, cwd, cli_kv_overrides,