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 8f5d8087b3..27446544f4 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 @@ -8542,6 +8542,12 @@ "null" ] }, + "allowLoginShell": { + "type": [ + "boolean", + "null" + ] + }, "allowManagedHooksOnly": { "type": [ "boolean", @@ -8599,6 +8605,12 @@ "null" ] }, + "checkForUpdateOnStartup": { + "type": [ + "boolean", + "null" + ] + }, "computerUse": { "anyOf": [ { @@ -8634,6 +8646,28 @@ "null" ] }, + "feedback": { + "anyOf": [ + { + "$ref": "#/definitions/v2/FeedbackRequirements" + }, + { + "type": "null" + } + ] + }, + "logDir": { + "type": [ + "string", + "null" + ] + }, + "modelCatalogJson": { + "type": [ + "string", + "null" + ] + }, "models": { "anyOf": [ { @@ -8643,6 +8677,18 @@ "type": "null" } ] + }, + "sqliteHome": { + "type": [ + "string", + "null" + ] + }, + "windowsSandboxPrivateDesktop": { + "type": [ + "boolean", + "null" + ] } }, "type": "object" @@ -9943,6 +9989,17 @@ ], "type": "string" }, + "FeedbackRequirements": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "FeedbackUploadParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -13724,6 +13781,9 @@ } ] }, + "PathUri": { + "type": "string" + }, "PermissionProfileListParams": { "$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 04829826c1..3960c8e0eb 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 @@ -4720,6 +4720,12 @@ "null" ] }, + "allowLoginShell": { + "type": [ + "boolean", + "null" + ] + }, "allowManagedHooksOnly": { "type": [ "boolean", @@ -4777,6 +4783,12 @@ "null" ] }, + "checkForUpdateOnStartup": { + "type": [ + "boolean", + "null" + ] + }, "computerUse": { "anyOf": [ { @@ -4812,6 +4824,28 @@ "null" ] }, + "feedback": { + "anyOf": [ + { + "$ref": "#/definitions/FeedbackRequirements" + }, + { + "type": "null" + } + ] + }, + "logDir": { + "type": [ + "string", + "null" + ] + }, + "modelCatalogJson": { + "type": [ + "string", + "null" + ] + }, "models": { "anyOf": [ { @@ -4821,6 +4855,18 @@ "type": "null" } ] + }, + "sqliteHome": { + "type": [ + "string", + "null" + ] + }, + "windowsSandboxPrivateDesktop": { + "type": [ + "boolean", + "null" + ] } }, "type": "object" @@ -6121,6 +6167,17 @@ ], "type": "string" }, + "FeedbackRequirements": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "FeedbackUploadParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -10066,6 +10123,9 @@ } ] }, + "PathUri": { + "type": "string" + }, "PermissionProfileListParams": { "$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 b1eaf4eb07..6863fb468b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -78,6 +78,12 @@ "null" ] }, + "allowLoginShell": { + "type": [ + "boolean", + "null" + ] + }, "allowManagedHooksOnly": { "type": [ "boolean", @@ -135,6 +141,12 @@ "null" ] }, + "checkForUpdateOnStartup": { + "type": [ + "boolean", + "null" + ] + }, "computerUse": { "anyOf": [ { @@ -170,6 +182,28 @@ "null" ] }, + "feedback": { + "anyOf": [ + { + "$ref": "#/definitions/FeedbackRequirements" + }, + { + "type": "null" + } + ] + }, + "logDir": { + "type": [ + "string", + "null" + ] + }, + "modelCatalogJson": { + "type": [ + "string", + "null" + ] + }, "models": { "anyOf": [ { @@ -179,6 +213,18 @@ "type": "null" } ] + }, + "sqliteHome": { + "type": [ + "string", + "null" + ] + }, + "windowsSandboxPrivateDesktop": { + "type": [ + "boolean", + "null" + ] } }, "type": "object" @@ -292,6 +338,17 @@ ], "type": "object" }, + "FeedbackRequirements": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "ManagedHooksRequirements": { "properties": { "PermissionRequest": { diff --git a/codex-rs/app-server-protocol/schema/typescript/PathUri.ts b/codex-rs/app-server-protocol/schema/typescript/PathUri.ts new file mode 100644 index 0000000000..5b32b954d3 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/PathUri.ts @@ -0,0 +1,30 @@ +// 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. + +/** + * An immutable, cross-platform representation of a `file:` URI. + * + * Only the `file:` scheme is currently accepted. Construction validates the + * URL, and the URI cannot be mutated after construction. [`Self::basename`], + * [`Self::parent`], and [`Self::join`] operate on URI path segments without + * interpreting them using the operating system running Codex. Fallback URIs + * created by [`Self::from_abs_path`] are opaque to these lexical operations. + * + * `file:` paths retain their URI spelling so they can be parsed independently + * of the current host. A local POSIX `file:` URI can also retain + * percent-encoded non-UTF-8 bytes for lossless native round trips. + * + * Like [VS Code resources], path operations use `/` URI separators on every + * host. Lexical path operations preserve a URL authority without interpreting + * Windows drive or UNC roots from path text. Native path normalization, + * filesystem aliases, symlinks, case sensitivity, and Unicode normalization + * are not resolved. + * + * Serde represents a `PathUri` as its canonical URI string. Deserialization + * accepts only valid `file:` URI strings. These strings round-trip through + * their canonical URL form, including encoded non-UTF-8 path bytes. + * + * [VS Code resources]: https://github.com/microsoft/vscode/blob/main/src/vs/base/common/resources.ts + */ +export type PathUri = string; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index e3e88adbf8..8080f942ad 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -55,6 +55,7 @@ export type { MultiAgentMode } from "./MultiAgentMode"; export type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment"; export type { NetworkPolicyRuleAction } from "./NetworkPolicyRuleAction"; export type { ParsedCommand } from "./ParsedCommand"; +export type { PathUri } from "./PathUri"; export type { Personality } from "./Personality"; export type { PlanType } from "./PlanType"; export type { RealtimeConversationVersion } from "./RealtimeConversationVersion"; 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 e2fd573a32..9fcb9c97a1 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts @@ -1,12 +1,14 @@ // 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 { PathUri } from "../PathUri"; import type { WebSearchMode } from "../WebSearchMode"; import type { AskForApproval } from "./AskForApproval"; import type { ComputerUseRequirements } from "./ComputerUseRequirements"; +import type { FeedbackRequirements } from "./FeedbackRequirements"; import type { ModelsRequirements } from "./ModelsRequirements"; 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, allowRemoteControl: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null, models: ModelsRequirements | 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, allowRemoteControl: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | 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}; 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 f818e310ed..820d399306 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -129,6 +129,7 @@ export type { ExternalAgentConfigMigrationItem } from "./ExternalAgentConfigMigr export type { ExternalAgentConfigMigrationItemType } from "./ExternalAgentConfigMigrationItemType"; export type { ExternalAgentImportedConnectorCandidate } from "./ExternalAgentImportedConnectorCandidate"; export type { ExternalAgentImportedConnectorSource } from "./ExternalAgentImportedConnectorSource"; +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/export.rs b/codex-rs/app-server-protocol/src/export.rs index 012f62a8ec..03cfa8e632 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -46,7 +46,6 @@ const JSON_V1_ALLOWLIST: &[&str] = &["InitializeParams", "InitializeResponse"]; const EXPERIMENTAL_CLIENT_METHOD_DEPENDENCY_TYPES: &[&str] = &[ "EnvironmentShellInfo", "EnvironmentStatusKind", - "PathUri", "RemoteControlClient", "RemoteControlClientsListOrder", "ThreadBackgroundTerminal", 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 ee32f9416c..a1bf83f95e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -12,6 +12,7 @@ use codex_protocol::config_types::WebSearchMode; use codex_protocol::config_types::WebSearchToolConfig; use codex_protocol::openai_models::ReasoningEffort; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; @@ -337,6 +338,7 @@ pub struct ConfigWriteResponse { #[ts(export_to = "v2/")] pub enum ConfigWriteErrorCode { ConfigLayerReadonly, + ConfigRequirementReadonly, ConfigVersionConflict, ConfigValidationError, ConfigPathNotFound, @@ -392,6 +394,16 @@ pub struct ConfigRequirements { #[experimental("configRequirements/read.network")] pub network: Option, pub models: Option, + #[schemars(with = "Option")] + pub sqlite_home: Option, + #[schemars(with = "Option")] + pub log_dir: Option, + #[schemars(with = "Option")] + pub model_catalog_json: Option, + pub check_for_update_on_startup: Option, + pub allow_login_shell: Option, + pub feedback: Option, + pub windows_sandbox_private_desktop: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] @@ -410,6 +422,13 @@ pub struct NewThreadModelDefaults { pub service_tier: Option, } +#[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)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] 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 7fce5e3f02..5c9445307f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -1807,11 +1807,46 @@ fn config_requirements_granular_allowed_approval_policy_is_marked_experimental() enforce_residency: None, network: None, models: None, + sqlite_home: None, + log_dir: None, + model_catalog_json: None, + check_for_update_on_startup: None, + allow_login_shell: None, + feedback: None, + windows_sandbox_private_desktop: None, }); assert_eq!(reason, Some("askForApproval.granular")); } +#[test] +fn config_requirements_read_accepts_foreign_path_uris() { + let response: ConfigRequirementsReadResponse = serde_json::from_value(json!({ + "requirements": { + "sqliteHome": "file:///C:/Users/alice/.codex/state", + "logDir": "file:///C:/Users/alice/.codex/logs", + "modelCatalogJson": "file:///C:/Users/alice/.codex/models.json" + } + })) + .expect("requirements response with foreign paths should deserialize"); + let requirements = response + .requirements + .expect("requirements should be present"); + + assert_eq!( + requirements.sqlite_home, + Some(PathUri::parse("file:///C:/Users/alice/.codex/state").expect("valid URI")) + ); + assert_eq!( + requirements.log_dir, + Some(PathUri::parse("file:///C:/Users/alice/.codex/logs").expect("valid URI")) + ); + assert_eq!( + requirements.model_catalog_json, + Some(PathUri::parse("file:///C:/Users/alice/.codex/models.json").expect("valid URI")) + ); +} + #[test] fn client_request_thread_start_granular_approval_policy_is_marked_experimental() { let reason = crate::experimental_api::ExperimentalApi::experimental_reason( diff --git a/codex-rs/app-server-protocol/tests/schema_fixtures.rs b/codex-rs/app-server-protocol/tests/schema_fixtures.rs index 20823466e7..3fef9d6d0b 100644 --- a/codex-rs/app-server-protocol/tests/schema_fixtures.rs +++ b/codex-rs/app-server-protocol/tests/schema_fixtures.rs @@ -16,6 +16,14 @@ fn typescript_schema_fixtures_match_generated() -> Result<()> { .context("generate in-memory typescript schema fixtures")?; assert_schema_trees_match("typescript", &fixture_tree, &generated_tree)?; + let config_requirements = generated_tree + .get(Path::new("v2/ConfigRequirements.ts")) + .context("generated ConfigRequirements.ts should exist")?; + anyhow::ensure!( + !String::from_utf8_lossy(config_requirements).contains("../PathUri") + || generated_tree.contains_key(Path::new("PathUri.ts")), + "stable ConfigRequirements.ts imports PathUri but PathUri.ts was not generated" + ); Ok(()) } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 449d9fc5bb..3d5df268b2 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -242,13 +242,13 @@ Example with notification opt-out: - `mcpServer/tool/call` — call a tool on a thread's configured MCP server by `threadId`, `server`, `tool`, optional `arguments`, and optional `_meta`, returning the MCP tool result. - `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional absolute `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`. - `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id. -- `config/read` — fetch the effective config on disk after resolving config layering, including opaque `desktop` values stored in `config.toml`. +- `config/read` — fetch the runtime-effective config after resolving config layering and managed requirements, including opaque `desktop` values stored in `config.toml`. - `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome`, optional `cwds`, and an optional `migrationSource` selector. Omitted, `null`, or unrecognized migration-source values retain the default behavior. The deprecated optional `source` field remains accepted for compatibility but does not select the migration source. Each detected item includes `cwd` (`null` for home), and multi-item migrations may additionally include structured `details` with plugin ids, skill names, memory, session metadata, or other artifact names. - `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any `details` returned by detect. Pass the same optional `migrationSource` used for detection so the server reads from the matching source; omitted, `null`, or unrecognized values retain the default behavior. The optional `source` identifies the product that initiated the import, while the optional opaque `providerId` attributes analytics to the provider selected by that product without affecting migration-source selection. The response acknowledges the synchronous import phase with an `importId`. Expected migration failures are reported as per-item failures rather than JSON-RPC errors, so the server still returns that `importId` and emits `externalAgentConfig/import/completed` with the same ID once all synchronous and background work finishes. The completion notification contains type-level `itemTypeResults` with successes and failures, including raw failure messages for the client to report separately. - `externalAgentConfig/import/readHistories` — read completed import histories and connector candidates detected from successfully imported session histories. 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. +- `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. -- `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`), remote-control policy (`allowRemoteControl`; `false` force-disables remote control while `true` or `null` preserves existing behavior), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`, including each command handler's optional `additionalContextLimit`), `enforceResidency`, managed new-thread 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 (`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`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`, including each command handler's optional `additionalContextLimit`), `enforceResidency`, managed new-thread 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 diff --git a/codex-rs/app-server/src/config_manager_service.rs b/codex-rs/app-server/src/config_manager_service.rs index dff2c9196e..6d3651ebcc 100644 --- a/codex-rs/app-server/src/config_manager_service.rs +++ b/codex-rs/app-server/src/config_manager_service.rs @@ -130,19 +130,31 @@ impl ConfigManager { }; let effective = layers.effective_config(); - let effective_config_toml: ConfigToml = effective + let mut effective_config_toml: ConfigToml = effective .try_into() .map_err(|err| ConfigManagerError::toml("invalid configuration", err))?; + layers + .requirements_toml() + .apply_exact_to_config(&mut effective_config_toml); + effective_config_toml.allow_login_shell.get_or_insert(true); let json_value = serde_json::to_value(&effective_config_toml) .map_err(|err| ConfigManagerError::json("failed to serialize configuration", err))?; let config: ApiConfig = serde_json::from_value(json_value) .map_err(|err| ConfigManagerError::json("failed to deserialize configuration", err))?; + let mut origins = layers.origins(); + origins.retain(|path, _| { + let segments = path.split('.').map(str::to_string).collect::>(); + layers + .requirements_toml() + .exact_requirement_for_config_path(&segments) + .is_none() + }); + Ok(ConfigReadResponse { config, - origins: layers - .origins() + origins: origins .into_iter() .map(|(path, metadata)| (path, config_layer_metadata_to_api(metadata))) .collect(), @@ -283,6 +295,15 @@ impl ConfigManager { let mut segments = parse_key_path(&key_path).map_err(|message| { ConfigManagerError::write(ConfigWriteErrorCode::ConfigValidationError, message) })?; + if let Some(field) = layers + .requirements_toml() + .exact_requirement_for_config_path(&segments) + { + return Err(ConfigManagerError::write( + ConfigWriteErrorCode::ConfigRequirementReadonly, + format!("`{field}` is managed by requirements and cannot be changed"), + )); + } if (value.is_null() || matches!(strategy, MergeStrategy::Upsert)) && let Some(pattern) = shell_environment_filter_entry(&user_config, &segments) .map(|(pattern, _)| pattern.clone()) diff --git a/codex-rs/app-server/src/config_manager_service_tests.rs b/codex-rs/app-server/src/config_manager_service_tests.rs index ba717f6754..c88e1b1ece 100644 --- a/codex-rs/app-server/src/config_manager_service_tests.rs +++ b/codex-rs/app-server/src/config_manager_service_tests.rs @@ -777,6 +777,164 @@ personality = true ); } +#[tokio::test] +async fn write_value_rejects_exact_managed_requirement() { + let tmp = tempdir().expect("tempdir"); + let path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&path, "allow_login_shell = true\n").unwrap(); + + let service = ConfigManager::new_for_tests( + tmp.path().to_path_buf(), + vec![], + LoaderOverrides::without_managed_config_for_tests(), + CloudConfigBundleFixture::loader_with_enterprise_requirement("allow_login_shell = false"), + ); + + let error = service + .write_value(ConfigValueWriteParams { + file_path: Some(path.display().to_string()), + key_path: "allow_login_shell".to_string(), + value: serde_json::json!(true), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect_err("managed exact field should be read-only"); + + assert_eq!( + error.write_error_code(), + Some(ConfigWriteErrorCode::ConfigRequirementReadonly) + ); + assert!(error.to_string().contains("`allow_login_shell`")); + assert_eq!( + std::fs::read_to_string(path).unwrap(), + "allow_login_shell = true\n" + ); +} + +fn toml_path(tmp: &Path, name: &str) -> String { + tmp.join(name).to_string_lossy().replace('\\', "\\\\") +} + +#[tokio::test] +async fn read_omits_origins_for_exact_managed_values() { + for has_user_values in [true, false] { + let tmp = tempdir().expect("tempdir"); + let user_config = if has_user_values { + format!( + r#"model = "user-model" +sqlite_home = "{}" +allow_login_shell = true + +[feedback] +enabled = true +"#, + toml_path(tmp.path(), "user-sqlite"), + ) + } else { + "model = \"user-model\"\n".to_string() + }; + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), user_config).unwrap(); + + let requirements = format!( + r#"sqlite_home = "{}" +allow_login_shell = false + +[feedback] +enabled = false +"#, + toml_path(tmp.path(), "managed-sqlite"), + ); + let service = ConfigManager::new_for_tests( + tmp.path().to_path_buf(), + vec![], + LoaderOverrides::without_managed_config_for_tests(), + CloudConfigBundleFixture::loader_with_enterprise_requirement(requirements), + ); + + let response = service + .read(ConfigReadParams { + include_layers: false, + cwd: None, + }) + .await + .expect("config read should succeed"); + + assert_eq!( + response.config.additional.get("sqlite_home"), + Some(&serde_json::json!(tmp.path().join("managed-sqlite"))) + ); + assert_eq!( + response.config.additional.get("allow_login_shell"), + Some(&serde_json::json!(false)) + ); + assert_eq!( + response.config.additional.get("feedback"), + Some(&serde_json::json!({"enabled": false})) + ); + for path in ["sqlite_home", "allow_login_shell", "feedback.enabled"] { + assert!(!response.origins.contains_key(path), "origin for {path}"); + } + assert!(response.origins.contains_key("model")); + } +} + +#[tokio::test] +async fn read_materializes_default_allow_login_shell() { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "").unwrap(); + + let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf()); + let response = service + .read(ConfigReadParams { + include_layers: false, + cwd: None, + }) + .await + .expect("config read should succeed"); + + assert_eq!( + response.config.additional.get("allow_login_shell"), + Some(&serde_json::json!(true)) + ); +} + +#[tokio::test] +async fn write_value_allows_unmanaged_sibling_of_exact_requirement() { + let tmp = tempdir().expect("tempdir"); + let path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&path, "").unwrap(); + + let service = ConfigManager::new_for_tests( + tmp.path().to_path_buf(), + vec![], + LoaderOverrides::without_managed_config_for_tests(), + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[windows] +sandbox_private_desktop = false +"#, + ), + ); + + service + .write_value(ConfigValueWriteParams { + file_path: Some(path.display().to_string()), + key_path: "windows.sandbox".to_string(), + value: serde_json::json!("elevated"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect("unmanaged sibling should remain writable"); + + assert!( + std::fs::read_to_string(path) + .unwrap() + .contains("sandbox = \"elevated\"") + ); +} + #[tokio::test] async fn read_reports_managed_overrides_user_and_session_flags() { let tmp = tempdir().expect("tempdir"); 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 467d1e1986..0d09d2503c 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -21,6 +21,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; @@ -315,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 @@ -384,6 +390,15 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR service_tier: new_thread.service_tier, }), }), + sqlite_home: requirements.sqlite_home.map(Into::into), + log_dir: requirements.log_dir.map(Into::into), + model_catalog_json: requirements.model_catalog_json.map(Into::into), + check_for_update_on_startup: requirements.check_for_update_on_startup, + allow_login_shell: requirements.allow_login_shell, + feedback: requirements.feedback.map(|feedback| FeedbackRequirements { + enabled: feedback.enabled, + }), + windows_sandbox_private_desktop, } } @@ -578,13 +593,17 @@ fn config_write_error(code: ConfigWriteErrorCode, message: impl Into) -> #[cfg(test)] mod tests { use super::map_requirements_toml_to_api; + use codex_app_server_protocol::FeedbackRequirements; use codex_app_server_protocol::WindowsSandboxSetupMode; use codex_config::ComputerUseRequirementsToml; use codex_config::ConfigRequirementsToml; use codex_config::ModelsRequirementsToml; use codex_config::NewThreadModelDefaultsToml; use codex_config::WindowsRequirementsToml; + use codex_config::types::FeedbackConfigToml; use codex_protocol::openai_models::ReasoningEffort; + use codex_utils_absolute_path::AbsolutePathBuf; + use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; use std::collections::BTreeMap; @@ -694,6 +713,7 @@ mod tests { codex_config::types::WindowsSandboxModeToml::Elevated, codex_config::types::WindowsSandboxModeToml::Unelevated, ]), + sandbox_private_desktop: Some(false), }), ..ConfigRequirementsToml::default() }); @@ -705,5 +725,43 @@ mod tests { WindowsSandboxSetupMode::Unelevated, ]) ); + assert_eq!(mapped.windows_sandbox_private_desktop, Some(false)); + } + + #[test] + fn requirements_api_includes_exact_managed_values() { + let sqlite_home = AbsolutePathBuf::try_from(std::env::temp_dir().join("managed-state")) + .expect("managed sqlite home should be absolute"); + let log_dir = AbsolutePathBuf::try_from(std::env::temp_dir().join("managed-logs")) + .expect("managed log dir should be absolute"); + let model_catalog_json = + AbsolutePathBuf::try_from(std::env::temp_dir().join("managed-models.json")) + .expect("managed model catalog path should be absolute"); + let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { + 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), + allow_login_shell: Some(false), + feedback: Some(FeedbackConfigToml { + enabled: Some(false), + }), + ..ConfigRequirementsToml::default() + }); + + assert_eq!(mapped.sqlite_home, Some(PathUri::from(sqlite_home))); + assert_eq!(mapped.log_dir, Some(PathUri::from(log_dir))); + assert_eq!( + mapped.model_catalog_json, + Some(PathUri::from(model_catalog_json)) + ); + assert_eq!(mapped.check_for_update_on_startup, Some(false)); + assert_eq!(mapped.allow_login_shell, Some(false)); + assert_eq!( + mapped.feedback, + Some(FeedbackRequirements { + enabled: Some(false), + }) + ); } } diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 933008a4d4..52e4424b8f 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -20,9 +20,11 @@ use super::requirements_exec_policy::RequirementsExecPolicyToml; use crate::Constrained; use crate::ConstraintError; use crate::ManagedHooksRequirementsToml; +use crate::config_toml::ConfigToml; use crate::mcp_requirements::McpServerRequirement; use crate::mcp_types::AppToolApproval; use crate::permissions_toml::PermissionProfileToml; +use crate::types::FeedbackConfigToml; use crate::types::WindowsSandboxModeToml; #[derive(Debug, Clone, PartialEq, Eq)] @@ -145,10 +147,17 @@ impl std::ops::DerefMut for ConstrainedWithSource { /// normalization. #[derive(Debug, Clone, PartialEq)] pub struct ConfigRequirements { + pub sqlite_home: Option>, + pub log_dir: Option>, + pub model_catalog_json: Option>, + pub check_for_update_on_startup: Option>, + pub allow_login_shell: 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>, @@ -172,6 +181,12 @@ pub struct ConfigRequirements { impl Default for ConfigRequirements { fn default() -> Self { Self { + sqlite_home: None, + log_dir: None, + model_catalog_json: None, + check_for_update_on_startup: None, + allow_login_shell: None, + feedback: None, approval_policy: ConstrainedWithSource::new( Constrained::allow_any_from_default(), /*source*/ None, @@ -188,6 +203,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, @@ -740,11 +756,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() } } @@ -845,6 +862,12 @@ 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 sqlite_home: Option, + pub log_dir: Option, + pub model_catalog_json: Option, + pub check_for_update_on_startup: Option, + pub allow_login_shell: Option, + pub feedback: Option, pub allowed_approval_policies: Option>, pub allowed_approvals_reviewers: Option>, pub allowed_sandbox_modes: Option>, @@ -929,6 +952,12 @@ impl std::ops::Deref for Sourced { #[derive(Debug, Clone, Default, PartialEq)] pub struct ConfigRequirementsWithSources { + pub sqlite_home: Option>, + pub log_dir: Option>, + pub model_catalog_json: Option>, + pub check_for_update_on_startup: Option>, + pub allow_login_shell: Option>, + pub feedback: Option>, pub allowed_approval_policies: Option>>, pub allowed_approvals_reviewers: Option>>, pub allowed_sandbox_modes: Option>>, @@ -973,6 +1002,12 @@ impl ConfigRequirementsWithSources { // Destructure without `..` so adding fields to `ConfigRequirementsToml` // forces this merge logic to be updated. let ConfigRequirementsToml { + sqlite_home: _, + log_dir: _, + model_catalog_json: _, + check_for_update_on_startup: _, + allow_login_shell: _, + feedback: _, allowed_approval_policies: _, allowed_approvals_reviewers: _, allowed_sandbox_modes: _, @@ -1012,6 +1047,12 @@ impl ConfigRequirementsWithSources { other, source, { + sqlite_home, + log_dir, + model_catalog_json, + check_for_update_on_startup, + allow_login_shell, + feedback, allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, @@ -1048,6 +1089,12 @@ impl ConfigRequirementsWithSources { pub fn into_toml(self) -> ConfigRequirementsToml { let ConfigRequirementsWithSources { + sqlite_home, + log_dir, + model_catalog_json, + check_for_update_on_startup, + allow_login_shell, + feedback, allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, @@ -1073,6 +1120,12 @@ impl ConfigRequirementsWithSources { guardian_policy_config, } = self; ConfigRequirementsToml { + 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), + allow_login_shell: allow_login_shell.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), @@ -1165,7 +1218,16 @@ impl ConfigRequirementsToml { } pub fn is_empty(&self) -> bool { - self.allowed_approval_policies.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.allow_login_shell.is_none() + && self + .feedback + .as_ref() + .is_none_or(|feedback| feedback == &FeedbackConfigToml::default()) + && self.allowed_approval_policies.is_none() && self.allowed_approvals_reviewers.is_none() && self.allowed_sandbox_modes.is_none() && self.allowed_permission_profiles.is_none() @@ -1217,6 +1279,92 @@ impl ConfigRequirementsToml { .as_deref() .is_none_or(|value| value.trim().is_empty()) } + + /// Applies the requirements whose values replace config values. + /// + /// This projection is shared by config/read and config-lock export so + /// both surfaces describe the same behavior as the final runtime config. + pub fn apply_exact_to_config(&self, config: &mut ConfigToml) { + macro_rules! apply_exact { + ($field:ident) => { + if let Some(value) = self.$field.as_ref() { + config.$field = Some(value.clone()); + } + }; + } + + apply_exact!(sqlite_home); + apply_exact!(log_dir); + apply_exact!(model_catalog_json); + apply_exact!(check_for_update_on_startup); + apply_exact!(allow_login_shell); + + if let Some(enabled) = self.feedback.as_ref().and_then(|feedback| feedback.enabled) { + config.feedback.get_or_insert_default().enabled = Some(enabled); + } + if let Some(sandbox_private_desktop) = self + .windows + .as_ref() + .and_then(|windows| windows.sandbox_private_desktop) + { + config + .windows + .get_or_insert_default() + .sandbox_private_desktop = Some(sandbox_private_desktop); + } + } + + /// 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] = [ + (self.sqlite_home.is_some(), &["sqlite_home"], "sqlite_home"), + (self.log_dir.is_some(), &["log_dir"], "log_dir"), + ( + self.model_catalog_json.is_some(), + &["model_catalog_json"], + "model_catalog_json", + ), + ( + self.check_for_update_on_startup.is_some(), + &["check_for_update_on_startup"], + "check_for_update_on_startup", + ), + ( + self.allow_login_shell.is_some(), + &["allow_login_shell"], + "allow_login_shell", + ), + ( + self.feedback + .as_ref() + .and_then(|feedback| feedback.enabled) + .is_some(), + &["feedback", "enabled"], + "feedback.enabled", + ), + ( + self.windows + .as_ref() + .and_then(|windows| windows.sandbox_private_desktop) + .is_some(), + &["windows", "sandbox_private_desktop"], + "windows.sandbox_private_desktop", + ), + ]; + + managed_fields + .into_iter() + .find_map(|(is_managed, managed_path, field)| { + (is_managed && config_paths_overlap(segments, managed_path)).then_some(field) + }) + } +} + +fn config_paths_overlap(segments: &[String], managed_path: &[&str]) -> bool { + segments + .iter() + .zip(managed_path) + .all(|(segment, managed_segment)| segment == managed_segment) } fn validate_mcp_server_requirements( @@ -1247,6 +1395,12 @@ impl TryFrom for ConfigRequirements { // defaults also remain there because they are initialization values, // not runtime constraints. let ConfigRequirementsWithSources { + sqlite_home, + log_dir, + model_catalog_json, + check_for_update_on_startup, + allow_login_shell, + feedback, allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, @@ -1388,42 +1542,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 { @@ -1550,10 +1722,17 @@ impl TryFrom for ConfigRequirements { }); let guardian_policy_config_source = guardian_policy_config.map(|sourced| sourced.source); Ok(ConfigRequirements { + sqlite_home, + log_dir, + model_catalog_json, + check_for_update_on_startup, + allow_login_shell, + feedback, approval_policy, approvals_reviewer, permission_profile, windows_sandbox_mode, + windows_sandbox_private_desktop, web_search_mode, allow_managed_hooks_only, allow_appshots, @@ -1623,6 +1802,59 @@ mod tests { )?) } + #[test] + fn exact_requirement_for_config_path_matches_overlapping_paths() { + let managed_path = AbsolutePathBuf::try_from(std::env::temp_dir().join("managed")) + .expect("managed path should be absolute"); + let requirements = ConfigRequirementsToml { + sqlite_home: Some(managed_path.clone()), + log_dir: Some(managed_path.clone()), + model_catalog_json: Some(managed_path), + check_for_update_on_startup: Some(false), + allow_login_shell: Some(false), + feedback: Some(FeedbackConfigToml { + enabled: Some(false), + }), + windows: Some(WindowsRequirementsToml { + sandbox_private_desktop: Some(false), + ..Default::default() + }), + ..Default::default() + }; + let cases: &[(&[&str], Option<&str>)] = &[ + (&["sqlite_home"], Some("sqlite_home")), + (&["log_dir"], Some("log_dir")), + (&["model_catalog_json"], Some("model_catalog_json")), + ( + &["check_for_update_on_startup"], + Some("check_for_update_on_startup"), + ), + (&["allow_login_shell"], Some("allow_login_shell")), + (&["feedback", "enabled"], Some("feedback.enabled")), + ( + &["windows", "sandbox_private_desktop"], + Some("windows.sandbox_private_desktop"), + ), + (&[], Some("sqlite_home")), + (&["feedback"], Some("feedback.enabled")), + ( + &["windows", "sandbox_private_desktop", "value"], + Some("windows.sandbox_private_desktop"), + ), + (&["feedback", "other"], None), + (&["windows", "sandbox"], None), + ]; + + for (segments, expected) in cases { + let segments = segments.iter().map(ToString::to_string).collect::>(); + assert_eq!( + requirements.exact_requirement_for_config_path(&segments), + *expected, + "segments: {segments:?}" + ); + } + } + #[test] fn composite_requirement_source_flattens_and_deduplicates_sources() { let mdm_source = RequirementSource::MdmManagedPreferences { @@ -1644,6 +1876,12 @@ mod tests { fn with_unknown_source(toml: ConfigRequirementsToml) -> ConfigRequirementsWithSources { let ConfigRequirementsToml { + sqlite_home, + log_dir, + model_catalog_json, + check_for_update_on_startup, + allow_login_shell, + feedback, allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, @@ -1670,6 +1908,15 @@ mod tests { guardian_policy_config, } = toml; ConfigRequirementsWithSources { + 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)), + allow_login_shell: allow_login_shell + .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 @@ -1909,6 +2156,20 @@ mod tests { service_tier: Some("fast".to_string()), }), }; + let sqlite_home = AbsolutePathBuf::try_from(std::env::temp_dir().join("managed-state")) + .expect("managed sqlite home should be absolute"); + let log_dir = AbsolutePathBuf::try_from(std::env::temp_dir().join("managed-logs")) + .expect("managed log dir should be absolute"); + let model_catalog_json = + AbsolutePathBuf::try_from(std::env::temp_dir().join("managed-models.json")) + .expect("managed model catalog path should be absolute"); + let feedback = FeedbackConfigToml { + enabled: Some(false), + }; + let windows = WindowsRequirementsToml { + allowed_sandbox_implementations: None, + sandbox_private_desktop: Some(true), + }; let enforce_residency = ResidencyRequirement::Us; let enforce_source = source.clone(); let guardian_policy_config = "Use the company-managed guardian policy.".to_string(); @@ -1916,6 +2177,12 @@ mod tests { // Intentionally constructed without `..Default::default()` so adding a new field to // `ConfigRequirementsToml` forces this test to be updated. let other = ConfigRequirementsToml { + 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), + allow_login_shell: Some(false), + 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()), @@ -1927,7 +2194,7 @@ mod tests { allow_appshots: Some(false), allow_remote_control: Some(false), computer_use: Some(computer_use.clone()), - windows: None, + windows: Some(windows.clone()), feature_requirements: Some(feature_requirements.clone()), hooks: None, mcp_servers: None, @@ -1947,6 +2214,15 @@ mod tests { assert_eq!( target, ConfigRequirementsWithSources { + 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( + /*value*/ false, + source.clone(), + )), + allow_login_shell: Some(Sourced::new(/*value*/ false, source.clone())), + feedback: Some(Sourced::new(feedback, source.clone())), allowed_approval_policies: Some(Sourced::new( allowed_approval_policies, source.clone() @@ -1975,7 +2251,7 @@ mod tests { enforce_source.clone(), )), computer_use: Some(Sourced::new(computer_use, enforce_source.clone())), - windows: None, + windows: Some(Sourced::new(windows, enforce_source.clone())), feature_requirements: Some(Sourced::new( feature_requirements, enforce_source.clone(), @@ -2039,6 +2315,7 @@ mod tests { permissions: None, models: None, guardian_policy_config: None, + ..Default::default() } ); Ok(()) @@ -2095,6 +2372,7 @@ mod tests { permissions: None, models: None, guardian_policy_config: None, + ..Default::default() } ); Ok(()) diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 40a027f33d..2c3c831ac2 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -67,10 +67,6 @@ const RESERVED_MODEL_PROVIDER_IDS: [&str; 4] = [ pub const DEFAULT_PROJECT_DOC_MAX_BYTES: usize = 32 * 1024; -const fn default_allow_login_shell() -> Option { - Some(true) -} - fn default_history() -> Option { Some(History::default()) } @@ -193,7 +189,6 @@ pub struct ConfigToml { /// If `false`, the model can never use a login shell: `login = true` /// requests are rejected, and omitting `login` defaults to a non-login /// shell. - #[serde(default = "default_allow_login_shell")] pub allow_login_shell: Option, /// Sandbox mode to use. diff --git a/codex-rs/config/src/loader/mod.rs b/codex-rs/config/src/loader/mod.rs index 408193d1bb..7c2a703e92 100644 --- a/codex-rs/config/src/loader/mod.rs +++ b/codex-rs/config/src/loader/mod.rs @@ -157,12 +157,14 @@ pub async fn load_config_layers_state( #[cfg(target_os = "macos")] { + let managed_preferences_base_dir = AbsolutePathBuf::from_absolute_path(codex_home)?; managed_preferences_requirements_layer = macos::load_managed_admin_requirements_layer( overrides .macos_managed_config_requirements_base64 .as_deref(), ) - .await?; + .await? + .map(|layer| layer.with_base_dir(managed_preferences_base_dir)); } #[cfg(not(target_os = "macos"))] { diff --git a/codex-rs/config/src/requirements_layers/layer.rs b/codex-rs/config/src/requirements_layers/layer.rs index 7329a66f62..6a4f695397 100644 --- a/codex-rs/config/src/requirements_layers/layer.rs +++ b/codex-rs/config/src/requirements_layers/layer.rs @@ -77,6 +77,7 @@ impl ComposableRequirementsLayer { .as_ref() .and_then(|_| hostname_resolver()); requirements.apply_remote_sandbox_config(hostname.as_deref()); + materialize_resolved_path_requirements(&mut regular_toml, &requirements)?; materialize_remote_sandbox_config(&mut regular_toml, &requirements)?; strip_special_fields(&mut regular_toml); @@ -140,6 +141,30 @@ fn parse_layer_requirements( } } +fn materialize_resolved_path_requirements( + layer_toml: &mut TomlValue, + requirements: &ConfigRequirementsToml, +) -> Result<(), RequirementsCompositionError> { + let Some(table) = layer_toml.as_table_mut() else { + return Ok(()); + }; + + for (key, value) in [ + ("sqlite_home", requirements.sqlite_home.as_ref()), + ("log_dir", requirements.log_dir.as_ref()), + ( + "model_catalog_json", + requirements.model_catalog_json.as_ref(), + ), + ] { + if let Some(value) = value { + table.insert(key.to_string(), toml_value_from_serializable(value)?); + } + } + + Ok(()) +} + fn materialize_remote_sandbox_config( layer_toml: &mut TomlValue, requirements: &ConfigRequirementsToml, diff --git a/codex-rs/config/src/requirements_layers/stack.rs b/codex-rs/config/src/requirements_layers/stack.rs index 9a42650bf3..9c6a6de9c3 100644 --- a/codex-rs/config/src/requirements_layers/stack.rs +++ b/codex-rs/config/src/requirements_layers/stack.rs @@ -206,6 +206,12 @@ 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 { + sqlite_home, + log_dir, + model_catalog_json, + check_for_update_on_startup, + allow_login_shell, + feedback, allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, @@ -232,6 +238,15 @@ fn populate_merged_regular_fields_with_sources( guardian_policy_config, } = requirements; + 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!(allow_login_shell, &["allow_login_shell"]); + 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 92e6bd8b46..f4935d2a1e 100644 --- a/codex-rs/config/src/requirements_layers/stack_tests.rs +++ b/codex-rs/config/src/requirements_layers/stack_tests.rs @@ -14,6 +14,7 @@ use pretty_assertions::assert_eq; use std::cell::Cell; use std::collections::BTreeMap; use tempfile::TempDir; +use tempfile::tempdir; fn layer(id: &str, name: &str, contents: &str) -> RequirementsLayerEntry { RequirementsLayerEntry::from_toml( @@ -149,6 +150,45 @@ service_tier = "fast" ); } +#[test] +fn relative_paths_resolve_against_their_own_layer_base() { + let low_dir = tempdir().expect("low-priority requirements directory"); + let high_dir = tempdir().expect("high-priority requirements directory"); + let low_base = AbsolutePathBuf::from_absolute_path(low_dir.path()).expect("absolute low base"); + let high_base = + AbsolutePathBuf::from_absolute_path(high_dir.path()).expect("absolute high base"); + + let composed = compose(vec![ + layer( + "req_low", + "Low", + "sqlite_home = \"state\"\nlog_dir = \"low-logs\"", + ) + .with_base_dir(low_base), + layer( + "req_high", + "High", + "log_dir = \"high-logs\"\nmodel_catalog_json = \"models.json\"", + ) + .with_base_dir(high_base), + ]) + .expect("compose requirements") + .expect("requirements present"); + + assert_eq!( + composed.sqlite_home.as_deref(), + Some(low_dir.path().join("state").as_path()) + ); + assert_eq!( + composed.log_dir.as_deref(), + Some(high_dir.path().join("high-logs").as_path()) + ); + assert_eq!( + composed.model_catalog_json.as_deref(), + Some(high_dir.path().join("models.json").as_path()) + ); +} + #[test] fn composition_strategy_applies_to_non_cloud_layers() { let mdm_source = RequirementSource::MdmManagedPreferences { diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 533b6ca9ef..982e3ea8a1 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -4726,7 +4726,6 @@ "description": "Agent-related settings (thread limits, etc.)." }, "allow_login_shell": { - "default": true, "description": "Whether the model may request a login shell for shell-based tools. Default to `true`\n\nIf `true`, the model may request a login shell (`login = true`), and omitting `login` defaults to using a login shell. If `false`, the model can never use a login shell: `login = true` requests are rejected, and omitting `login` defaults to a non-login shell.", "type": "boolean" }, diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index 21c53b3ee4..5b5622f14e 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -1164,6 +1164,56 @@ allowed_sandbox_modes = ["read-only"] Ok(()) } +#[cfg(target_os = "macos")] +#[tokio::test] +async fn managed_preferences_requirements_resolve_paths_against_codex_home() -> anyhow::Result<()> { + use base64::Engine; + + let tmp = tempdir()?; + let codex_home = tmp.path().join("codex-home"); + std::fs::create_dir_all(&codex_home)?; + + let mut loader_overrides = + LoaderOverrides::with_managed_config_path_for_tests(tmp.path().join("managed_config.toml")); + loader_overrides.macos_managed_config_requirements_base64 = Some( + base64::prelude::BASE64_STANDARD.encode( + r#" +sqlite_home = "state" +log_dir = "~/.codex/logs" +model_catalog_json = "models.json" +"# + .as_bytes(), + ), + ); + + let layers = load_config_layers_state( + LOCAL_FS.as_ref(), + &codex_home, + Some(AbsolutePathBuf::try_from(tmp.path())?), + &[] as &[(String, TomlValue)], + loader_overrides, + &codex_config::NoopThreadConfigLoader, + ) + .await?; + let expected_log_dir = AbsolutePathBuf::resolve_path_against_base("~/.codex/logs", &codex_home); + let requirements = layers.requirements_toml(); + + assert_eq!( + requirements.sqlite_home.as_deref(), + Some(codex_home.join("state").as_path()) + ); + assert_eq!( + requirements.log_dir.as_deref(), + Some(expected_log_dir.as_path()) + ); + assert_eq!( + requirements.model_catalog_json.as_deref(), + Some(codex_home.join("models.json").as_path()) + ); + + Ok(()) +} + #[cfg(target_os = "macos")] #[tokio::test] async fn managed_preferences_requirements_take_precedence() -> anyhow::Result<()> { diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 24849a22f2..fbba8c3332 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -9093,6 +9093,12 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() let fixture = create_test_fixture()?; let requirements_toml = codex_config::ConfigRequirementsToml { + sqlite_home: None, + log_dir: None, + model_catalog_json: None, + check_for_update_on_startup: None, + allow_login_shell: None, + feedback: None, allowed_approval_policies: None, allowed_approvals_reviewers: None, allowed_sandbox_modes: None, @@ -11710,3 +11716,134 @@ fn test_tui_notification_condition_rejects_unknown_value() { "unexpected error: {err}" ); } + +async fn load_with_enterprise_requirement( + codex_home: &TempDir, + requirements: impl Into, +) -> std::io::Result { + 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 +} + +#[tokio::test] +async fn exact_requirements_apply_to_runtime_config() -> 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"), + )?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#" +check_for_update_on_startup = true +allow_login_shell = true + +[feedback] +enabled = true + +[windows] +sandbox_private_desktop = true +"#, + )?; + + let required_sqlite_home = codex_home.path().join("required-state"); + let required_log_dir = codex_home.path().join("required-logs"); + let requirements = format!( + r#" +sqlite_home = {:?} +log_dir = {:?} +model_catalog_json = {:?} +check_for_update_on_startup = false +allow_login_shell = false + +[feedback] +enabled = false + +[windows] +sandbox_private_desktop = false +"#, + required_sqlite_home.display(), + required_log_dir.display(), + catalog_path.display(), + ); + let config = load_with_enterprise_requirement(&codex_home, requirements).await?; + + assert_eq!(config.sqlite_home, required_sqlite_home); + assert_eq!(config.log_dir, required_log_dir); + assert_eq!(config.model_catalog, Some(catalog)); + assert!(!config.check_for_update_on_startup); + assert!(!config.permissions.allow_login_shell); + assert!(!config.feedback_enabled); + assert!(!config.permissions.windows_sandbox_private_desktop); + assert!(config.startup_warnings.iter().any(|warning| { + warning.contains("Configured value for `check_for_update_on_startup` is overridden") + })); + Ok(()) +} + +#[tokio::test] +async fn absent_allow_login_shell_does_not_report_an_override() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let config = load_with_enterprise_requirement(&codex_home, "allow_login_shell = false").await?; + + assert!(!config.permissions.allow_login_shell); + assert!( + config + .startup_warnings + .iter() + .all(|warning| !warning.contains("allow_login_shell")) + ); + Ok(()) +} + +#[test] +fn sqlite_home_env_conflict_reports_an_override() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let required = AbsolutePathBuf::try_from(codex_home.path().join("required-state"))?; + let environment = codex_home.path().join("environment-state"); + let requirement = Sourced::new(required.clone(), RequirementSource::Unknown); + let mut warnings = Vec::new(); + + super::requirements::push_sqlite_home_env_override_warning( + /*configured_sqlite_home*/ None, + Some(environment.as_path()), + Some(&requirement), + &mut warnings, + ); + assert_eq!( + warnings, + vec![format!( + "Environment value for `$CODEX_SQLITE_HOME` is overridden by the required `sqlite_home` value {required:?} from {}.", + RequirementSource::Unknown + )] + ); + + warnings.clear(); + super::requirements::push_sqlite_home_env_override_warning( + /*configured_sqlite_home*/ None, + Some(required.as_path()), + Some(&requirement), + &mut warnings, + ); + assert!(warnings.is_empty()); + + super::requirements::push_sqlite_home_env_override_warning( + Some(&required), + Some(environment.as_path()), + Some(&requirement), + &mut warnings, + ); + assert!(warnings.is_empty()); + + Ok(()) +} diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 689126b4ef..0730fd0217 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -153,6 +153,7 @@ mod network_proxy_spec; mod otel; mod permission_profile_catalog; mod permissions; +mod requirements; mod resolved_permission_profile; #[cfg(test)] mod schema; @@ -3037,7 +3038,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, @@ -3058,13 +3059,31 @@ impl Config { resolve_orchestrator_feature_enabled(orchestrator.and_then(|value| value.skills.as_ref())); let orchestrator_mcp_enabled = resolve_orchestrator_feature_enabled(orchestrator.and_then(|value| value.mcp.as_ref())); - // Ensure that every field of ConfigRequirements is applied to the final - // Config. + let mut startup_warnings = config_layer_stack + .startup_warnings() + .unwrap_or_default() + .to_vec(); + let configured_sqlite_home = cfg.sqlite_home.clone(); + requirements::apply_to_config( + &mut cfg, + config_layer_stack.requirements(), + &mut startup_warnings, + ); + + // Destructure every field to ensure ConfigRequirements additions are + // either applied above or handled while constructing the final Config. let ConfigRequirements { + sqlite_home: _, + log_dir: _, + model_catalog_json: _, + check_for_update_on_startup: _, + allow_login_shell: _, + feedback: _, approval_policy: mut constrained_approval_policy, approvals_reviewer: mut constrained_approvals_reviewer, permission_profile: mut constrained_permission_profile, windows_sandbox_mode: mut constrained_windows_sandbox_mode, + windows_sandbox_private_desktop: _, web_search_mode: mut constrained_web_search_mode, allow_managed_hooks_only: _, allow_appshots: _, @@ -3082,11 +3101,6 @@ impl Config { guardian_policy_config_source: _, } = config_layer_stack.requirements().clone(); - let mut startup_warnings = config_layer_stack - .startup_warnings() - .unwrap_or_default() - .to_vec(); - // Destructure ConfigOverrides fully to ensure all overrides are applied. let ConfigOverrides { model, @@ -3754,11 +3768,18 @@ impl Config { .as_ref() .map(AbsolutePathBuf::to_path_buf) .unwrap_or_else(|| codex_home.join("log").to_path_buf()); + let sqlite_home_env = resolve_sqlite_home_env(&resolved_cwd); + requirements::push_sqlite_home_env_override_warning( + configured_sqlite_home.as_ref(), + sqlite_home_env.as_deref(), + config_layer_stack.requirements().sqlite_home.as_ref(), + &mut startup_warnings, + ); let sqlite_home = cfg .sqlite_home .as_ref() .map(AbsolutePathBuf::to_path_buf) - .or_else(|| resolve_sqlite_home_env(&resolved_cwd)) + .or(sqlite_home_env) .unwrap_or_else(|| codex_home.to_path_buf()); let original_permission_profile = permission_profile.clone(); apply_requirement_constrained_value( diff --git a/codex-rs/core/src/config/requirements.rs b/codex-rs/core/src/config/requirements.rs new file mode 100644 index 0000000000..3edba79c9c --- /dev/null +++ b/codex-rs/core/src/config/requirements.rs @@ -0,0 +1,154 @@ +use codex_config::ConfigRequirements; +use codex_config::RequirementSource; +use codex_config::Sourced; +use codex_config::config_toml::ConfigToml; +use codex_config::types::FeedbackConfigToml; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::path::Path; + +/// Applies managed requirements to regular config before final config construction. +/// +/// Managed values replace their configured counterparts, and conflicts produce +/// source-aware startup warnings. +pub(super) fn apply_to_config( + config: &mut ConfigToml, + requirements: &ConfigRequirements, + startup_warnings: &mut Vec, +) { + macro_rules! apply_exact { + ($field:ident) => { + apply_exact_requirement( + stringify!($field), + &mut config.$field, + requirements.$field.as_ref(), + startup_warnings, + ); + }; + } + + apply_exact!(sqlite_home); + apply_exact!(log_dir); + apply_exact!(model_catalog_json); + apply_exact!(check_for_update_on_startup); + apply_exact!(allow_login_shell); + apply_feedback_requirement( + &mut config.feedback, + requirements.feedback.as_ref(), + startup_warnings, + ); + if let Some(requirement) = requirements.windows_sandbox_private_desktop.as_ref() { + apply_exact_requirement( + "windows.sandbox_private_desktop", + &mut config + .windows + .get_or_insert_default() + .sandbox_private_desktop, + Some(requirement), + startup_warnings, + ); + } +} + +fn apply_exact_requirement( + field_name: &'static str, + configured_value: &mut Option, + requirement: Option<&Sourced>, + 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.clone()); +} + +fn replace_required_leaf( + configured: &mut Option, + required: &Option, +) -> bool { + let Some(required) = required else { + return false; + }; + let conflict = configured + .as_ref() + .is_some_and(|configured| configured != required); + *configured = Some(required.clone()); + conflict +} + +fn apply_feedback_requirement( + configured: &mut Option, + requirement: Option<&Sourced>, + startup_warnings: &mut Vec, +) { + let Some(Sourced { value, source }) = requirement else { + return; + }; + let FeedbackConfigToml { enabled } = value; + let configured = configured.get_or_insert_default(); + let conflict = replace_required_leaf(&mut configured.enabled, enabled); + push_structured_requirement_override_warning("feedback", conflict, source, startup_warnings); +} + +pub(super) fn push_sqlite_home_env_override_warning( + configured_sqlite_home: Option<&AbsolutePathBuf>, + sqlite_home_env: Option<&Path>, + requirement: Option<&Sourced>, + startup_warnings: &mut Vec, +) { + if configured_sqlite_home.is_some() { + return; + } + let Some(sqlite_home_env) = sqlite_home_env else { + return; + }; + let Some(Sourced { value, source }) = requirement else { + return; + }; + if sqlite_home_env == value.as_path() { + return; + } + + tracing::warn!( + ?source, + ?value, + "`CODEX_SQLITE_HOME` is overridden by an exact requirement for sqlite_home" + ); + startup_warnings.push(format!( + "Environment value for `$CODEX_SQLITE_HOME` is overridden by the required `sqlite_home` value {value:?} from {source}." + )); +} + +/// Emits one source-aware warning when a structured requirement replaces one +/// or more configured values. +fn push_structured_requirement_override_warning( + field_name: &str, + conflict: bool, + source: &RequirementSource, + startup_warnings: &mut Vec, +) { + if !conflict { + return; + } + tracing::warn!( + ?source, + "configured values are overridden by requirements for {field_name}" + ); + startup_warnings.push(format!( + "Configured values under `{field_name}` are overridden by requirements from {source}." + )); +} diff --git a/codex-rs/core/src/session/config_lock.rs b/codex-rs/core/src/session/config_lock.rs index c18ceb2b3e..00c75df317 100644 --- a/codex-rs/core/src/session/config_lock.rs +++ b/codex-rs/core/src/session/config_lock.rs @@ -93,13 +93,17 @@ fn session_configuration_to_lock_config_toml( .effective_config() .try_into() .context("failed to deserialize effective config for config lock")?; - if config.config_lock_save_fields_resolved_from_model_catalog { save_session_resolved_fields(sc, &mut lock_config); } save_config_resolved_fields(config, &mut lock_config)?; drop_lockfile_inputs(&mut lock_config); + // Apply exact managed values last so cleanup cannot discard their runtime-effective values. + config + .config_layer_stack + .requirements_toml() + .apply_exact_to_config(&mut lock_config); Ok(lock_config) } @@ -235,9 +239,23 @@ where #[cfg(test)] mod tests { use super::*; + use codex_config::test_support::CloudConfigBundleFixture; + use codex_models_manager::bundled_models_response; use pretty_assertions::assert_eq; + use std::path::Path; use std::sync::Arc; + fn write_model_catalog(path: &Path) { + 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( + path, + serde_json::to_string(&catalog).expect("serialize model catalog"), + ) + .expect("write model catalog"); + } + #[tokio::test] async fn lock_contains_prompts_and_materializes_features() { let mut sc = crate::session::tests::make_session_configuration_for_tests().await; @@ -395,6 +413,90 @@ mod tests { assert_eq!(lock.approvals_reviewer, None); } + #[tokio::test] + async fn lock_contains_exact_managed_requirements() { + let codex_home = tempfile::tempdir().expect("create temp dir"); + let sqlite_home = codex_home.path().join("managed-state"); + let log_dir = codex_home.path().join("managed-logs"); + let catalog_path = codex_home.path().join("managed-models.json"); + write_model_catalog(&catalog_path); + let requirements = format!( + r#" +sqlite_home = {:?} +log_dir = {:?} +model_catalog_json = {:?} +check_for_update_on_startup = false +allow_login_shell = false + +[feedback] +enabled = false + +[windows] +sandbox_private_desktop = false +"#, + sqlite_home.display(), + log_dir.display(), + catalog_path.display(), + ); + let mut config = crate::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 + .expect("config should load"); + config.config_lock_save_fields_resolved_from_model_catalog = false; + let mut sc = crate::session::tests::make_session_configuration_for_tests().await; + sc.original_config_do_not_use = Arc::new(config); + + let lockfile = sc.to_config_lockfile_toml().expect("lock should serialize"); + let lock = &lockfile.config; + + assert_eq!(lock.sqlite_home.as_deref(), Some(sqlite_home.as_path())); + assert_eq!(lock.log_dir.as_deref(), Some(log_dir.as_path())); + assert_eq!( + lock.model_catalog_json.as_deref(), + Some(catalog_path.as_path()) + ); + assert_eq!(lock.check_for_update_on_startup, Some(false)); + assert_eq!(lock.allow_login_shell, Some(false)); + assert_eq!( + lock.feedback.as_ref().and_then(|feedback| feedback.enabled), + Some(false) + ); + assert_eq!( + lock.windows + .as_ref() + .and_then(|windows| windows.sandbox_private_desktop), + Some(false) + ); + } + + #[tokio::test] + async fn lock_drops_unmanaged_model_catalog_input() { + let codex_home = tempfile::tempdir().expect("create temp dir"); + let catalog_path = codex_home.path().join("user-models.json"); + write_model_catalog(&catalog_path); + let config = crate::config::ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .cli_overrides(vec![( + "model_catalog_json".to_string(), + toml::Value::String(catalog_path.display().to_string()), + )]) + .build() + .await + .expect("config should load"); + let mut sc = crate::session::tests::make_session_configuration_for_tests().await; + sc.original_config_do_not_use = Arc::new(config); + + let lockfile = sc.to_config_lockfile_toml().expect("lock should serialize"); + + assert_eq!(lockfile.config.model_catalog_json, None); + } + #[tokio::test] async fn lock_validation_reports_config_diff() { let sc = crate::session::tests::make_session_configuration_for_tests().await; diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 1b3a7949f9..26773f3c93 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1244,6 +1244,39 @@ async fn user_shell_commands_do_not_inherit_managed_network_proxy() -> anyhow::R Ok(()) } +#[tokio::test] +async fn user_shell_commands_remain_login_shells_when_model_login_shells_are_disabled() +-> anyhow::Result<()> { + let (session, rx) = make_session_with_config_and_rx(|config| { + config.permissions.allow_login_shell = false; + }) + .await?; + let turn_context = session.new_default_turn().await; + let command = "echo managed-login-shell".to_string(); + let expected_command = session + .user_shell() + .derive_exec_args(&command, /*use_login_shell*/ true); + + execute_user_shell_command( + Arc::clone(&session), + turn_context, + command, + CancellationToken::new(), + UserShellCommandMode::StandaloneTurn, + ) + .await; + + loop { + let event = rx.recv().await.expect("channel open"); + if let EventMsg::ExecCommandBegin(event) = event.msg { + assert_eq!(event.command, expected_command); + break; + } + } + + Ok(()) +} + #[tokio::test] async fn get_base_instructions_no_user_content() { let prompt_with_apply_patch_instructions = diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 6021a5e6a3..949e078111 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -1419,21 +1419,21 @@ mod tests { .expect("session flags layer stack"); assert_eq!(app.resume_model_settings(), expected); - app.config.config_layer_stack = ConfigLayerStack::default().with_user_config_profile( - &profile_path, - Some(&profile), - config, - ); + app.config.config_layer_stack = ConfigLayerStack::default() + .with_user_config_profile(&profile_path, Some(&profile), config) + .expect("user config profile layer stack"); assert_eq!(app.resume_model_settings(), expected); } - app.config.config_layer_stack = ConfigLayerStack::default().with_user_config( - &profile_path, - TomlValue::Table(toml::map::Map::from_iter([( - "model_reasoning_effort".to_string(), - TomlValue::String("high".to_string()), - )])), - ); + app.config.config_layer_stack = ConfigLayerStack::default() + .with_user_config( + &profile_path, + TomlValue::Table(toml::map::Map::from_iter([( + "model_reasoning_effort".to_string(), + TomlValue::String("high".to_string()), + )])), + ) + .expect("user config layer stack"); assert_eq!( app.resume_model_settings(), crate::app_server_session::ResumeModelSettings::RestoreFromThread diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index 0cdf5d67a5..00a7b8b2ff 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -55,6 +55,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 088b5c008e..7fc1a6c590 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -160,6 +160,64 @@ fn render_debug_config_lines( lines.push("Requirements:".bold().into()); let mut requirement_lines = Vec::new(); + if let Some(sqlite_home) = requirements.sqlite_home.as_ref() { + requirement_lines.push(requirement_line( + "sqlite_home", + sqlite_home.value.display().to_string(), + Some(&sqlite_home.source), + )); + } + + if let Some(log_dir) = requirements.log_dir.as_ref() { + requirement_lines.push(requirement_line( + "log_dir", + log_dir.value.display().to_string(), + Some(&log_dir.source), + )); + } + + if let Some(model_catalog_json) = requirements.model_catalog_json.as_ref() { + requirement_lines.push(requirement_line( + "model_catalog_json", + model_catalog_json.value.display().to_string(), + Some(&model_catalog_json.source), + )); + } + + if let Some(check_for_update_on_startup) = requirements.check_for_update_on_startup.as_ref() { + requirement_lines.push(requirement_line( + "check_for_update_on_startup", + check_for_update_on_startup.value.to_string(), + Some(&check_for_update_on_startup.source), + )); + } + + if let Some(allow_login_shell) = requirements.allow_login_shell.as_ref() { + requirement_lines.push(requirement_line( + "allow_login_shell", + allow_login_shell.value.to_string(), + Some(&allow_login_shell.source), + )); + } + + if let Some(feedback) = requirements.feedback.as_ref() + && let Some(enabled) = feedback.value.enabled + { + requirement_lines.push(requirement_line( + "feedback.enabled", + enabled.to_string(), + Some(&feedback.source), + )); + } + + if let Some(sandbox_private_desktop) = requirements.windows_sandbox_private_desktop.as_ref() { + requirement_lines.push(requirement_line( + "windows.sandbox_private_desktop", + sandbox_private_desktop.value.to_string(), + Some(&sandbox_private_desktop.source), + )); + } + if let Some(policies) = requirements_toml.allowed_approval_policies.as_ref() { let value = join_or_empty(policies.iter().map(ToString::to_string).collect::>()); requirement_lines.push(requirement_line( @@ -626,7 +684,9 @@ mod tests { use codex_config::SandboxModeRequirement; use codex_config::Sourced; use codex_config::WebSearchModeRequirement; + use codex_config::WindowsRequirementsToml; use codex_config::sandbox_mode_requirement_for_permission_profile; + use codex_config::types::FeedbackConfigToml; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::WebSearchMode; use codex_protocol::models::PermissionProfile; @@ -749,8 +809,53 @@ interrupt_message = false } else { absolute_path("/home/alice/.gitconfig") }; + let sqlite_home = if cfg!(windows) { + absolute_path("C:\\Users\\alice\\.codex\\state") + } else { + absolute_path("/home/alice/.codex/state") + }; + let log_dir = if cfg!(windows) { + absolute_path("C:\\Users\\alice\\.codex\\logs") + } else { + absolute_path("/home/alice/.codex/logs") + }; + let model_catalog_json = if cfg!(windows) { + absolute_path("C:\\Users\\alice\\.codex\\models.json") + } else { + absolute_path("/home/alice/.codex/models.json") + }; let requirements = ConfigRequirements { + sqlite_home: Some(Sourced::new( + sqlite_home.clone(), + RequirementSource::LegacyManagedConfigTomlFromMdm, + )), + log_dir: Some(Sourced::new( + log_dir.clone(), + RequirementSource::LegacyManagedConfigTomlFromMdm, + )), + model_catalog_json: Some(Sourced::new( + model_catalog_json.clone(), + RequirementSource::LegacyManagedConfigTomlFromMdm, + )), + check_for_update_on_startup: Some(Sourced::new( + /*value*/ false, + RequirementSource::LegacyManagedConfigTomlFromMdm, + )), + allow_login_shell: Some(Sourced::new( + /*value*/ false, + RequirementSource::LegacyManagedConfigTomlFromMdm, + )), + feedback: Some(Sourced::new( + FeedbackConfigToml { + enabled: Some(false), + }, + RequirementSource::LegacyManagedConfigTomlFromMdm, + )), + windows_sandbox_private_desktop: Some(Sourced::new( + /*value*/ false, + RequirementSource::LegacyManagedConfigTomlFromMdm, + )), approval_policy: ConstrainedWithSource::new( Constrained::allow_any(AskForApproval::OnRequest.to_core()), Some(RequirementSource::LegacyManagedConfigTomlFromMdm), @@ -828,6 +933,14 @@ interrupt_message = false }; let requirements_toml = ConfigRequirementsToml { + sqlite_home: Some(sqlite_home), + log_dir: Some(log_dir), + model_catalog_json: Some(model_catalog_json), + check_for_update_on_startup: Some(false), + allow_login_shell: Some(false), + feedback: Some(FeedbackConfigToml { + enabled: Some(false), + }), allowed_approval_policies: Some(vec![AskForApproval::OnRequest.to_core()]), allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]), allowed_sandbox_modes: Some(vec![SandboxModeRequirement::ReadOnly]), @@ -839,7 +952,10 @@ interrupt_message = false allow_appshots: Some(false), allow_remote_control: Some(false), computer_use: None, - windows: None, + windows: Some(WindowsRequirementsToml { + allowed_sandbox_implementations: None, + sandbox_private_desktop: Some(false), + }), guardian_policy_config: Some("Use the managed guardian policy.".to_string()), feature_requirements: Some(FeatureRequirementsToml { entries: BTreeMap::from([("guardian_approval".to_string(), true)]), @@ -1220,6 +1336,7 @@ approval_policy = "never" network: None, permissions: None, models: None, + ..ConfigRequirementsToml::default() }; let stack = ConfigLayerStack::new(Vec::new(), requirements, requirements_toml) diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index b2de1c31da..c22a38fc9a 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1166,7 +1166,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(); set_default_client_residency_requirement(config.enforce_residency.value()); diff --git a/codex-rs/tui/src/snapshots/codex_tui__debug_config__tests__debug_config_requirement_sources.snap b/codex-rs/tui/src/snapshots/codex_tui__debug_config__tests__debug_config_requirement_sources.snap index b6505f775f..992e2ee760 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__debug_config__tests__debug_config_requirement_sources.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__debug_config__tests__debug_config_requirement_sources.snap @@ -8,6 +8,13 @@ Config layer stack (lowest precedence first): 1. user (/home/alice/.codex/config.toml) (enabled) Requirements: + - sqlite_home: /home/alice/.codex/state (source: MDM managed_config.toml (legacy)) + - log_dir: /home/alice/.codex/logs (source: MDM managed_config.toml (legacy)) + - model_catalog_json: /home/alice/.codex/models.json (source: MDM managed_config.toml (legacy)) + - check_for_update_on_startup: false (source: MDM managed_config.toml (legacy)) + - allow_login_shell: false (source: MDM managed_config.toml (legacy)) + - feedback.enabled: false (source: MDM managed_config.toml (legacy)) + - windows.sandbox_private_desktop: false (source: MDM managed_config.toml (legacy)) - allowed_approval_policies: on-request (source: MDM managed_config.toml (legacy)) - allowed_approvals_reviewers: auto_review (source: MDM managed_config.toml (legacy)) - allowed_sandbox_modes: read-only (source: /etc/codex/requirements.toml)