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 1102135a71..183175b889 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 @@ -7083,6 +7083,14 @@ } ] }, + "CloudManagedLayer": { + "description": "Position of a fragment within the cloud-managed configuration contract.", + "enum": [ + "baseline", + "systemOverlay" + ], + "type": "string" + }, "CodexErrorInfo": { "description": "This translation layer make sure that we expose codex error code in camel case.\n\nWhen an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.", "oneOf": [ @@ -8160,6 +8168,42 @@ "title": "EnterpriseManagedConfigLayerSource", "type": "object" }, + { + "description": "Config fragment delivered through the cloud-managed layer contract.", + "properties": { + "id": { + "description": "Stable identifier for the delivered fragment.", + "type": "string" + }, + "layer": { + "allOf": [ + { + "$ref": "#/definitions/v2/CloudManagedLayer" + } + ], + "description": "Precedence tier that contains the fragment." + }, + "name": { + "description": "Human-readable name for diagnostics and source attribution.", + "type": "string" + }, + "type": { + "enum": [ + "cloudManaged" + ], + "title": "CloudManagedConfigLayerSourceType", + "type": "string" + } + }, + "required": [ + "id", + "layer", + "name", + "type" + ], + "title": "CloudManagedConfigLayerSource", + "type": "object" + }, { "description": "User config layer from $CODEX_HOME/config.toml. This layer is special in that it is expected to be: - writable by the user - generally outside the workspace directory", "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 29dfe78034..2309115f36 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 @@ -3323,6 +3323,14 @@ ], "title": "ClientRequest" }, + "CloudManagedLayer": { + "description": "Position of a fragment within the cloud-managed configuration contract.", + "enum": [ + "baseline", + "systemOverlay" + ], + "type": "string" + }, "CodexErrorInfo": { "description": "This translation layer make sure that we expose codex error code in camel case.\n\nWhen an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.", "oneOf": [ @@ -4400,6 +4408,42 @@ "title": "EnterpriseManagedConfigLayerSource", "type": "object" }, + { + "description": "Config fragment delivered through the cloud-managed layer contract.", + "properties": { + "id": { + "description": "Stable identifier for the delivered fragment.", + "type": "string" + }, + "layer": { + "allOf": [ + { + "$ref": "#/definitions/CloudManagedLayer" + } + ], + "description": "Precedence tier that contains the fragment." + }, + "name": { + "description": "Human-readable name for diagnostics and source attribution.", + "type": "string" + }, + "type": { + "enum": [ + "cloudManaged" + ], + "title": "CloudManagedConfigLayerSourceType", + "type": "string" + } + }, + "required": [ + "id", + "layer", + "name", + "type" + ], + "title": "CloudManagedConfigLayerSource", + "type": "object" + }, { "description": "User config layer from $CODEX_HOME/config.toml. This layer is special in that it is expected to be: - writable by the user - generally outside the workspace directory", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index 19863dd1f3..23105a89a1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -237,6 +237,14 @@ } ] }, + "CloudManagedLayer": { + "description": "Position of a fragment within the cloud-managed configuration contract.", + "enum": [ + "baseline", + "systemOverlay" + ], + "type": "string" + }, "Config": { "additionalProperties": true, "properties": { @@ -555,6 +563,42 @@ "title": "EnterpriseManagedConfigLayerSource", "type": "object" }, + { + "description": "Config fragment delivered through the cloud-managed layer contract.", + "properties": { + "id": { + "description": "Stable identifier for the delivered fragment.", + "type": "string" + }, + "layer": { + "allOf": [ + { + "$ref": "#/definitions/CloudManagedLayer" + } + ], + "description": "Precedence tier that contains the fragment." + }, + "name": { + "description": "Human-readable name for diagnostics and source attribution.", + "type": "string" + }, + "type": { + "enum": [ + "cloudManaged" + ], + "title": "CloudManagedConfigLayerSourceType", + "type": "string" + } + }, + "required": [ + "id", + "layer", + "name", + "type" + ], + "title": "CloudManagedConfigLayerSource", + "type": "object" + }, { "description": "User config layer from $CODEX_HOME/config.toml. This layer is special in that it is expected to be: - writable by the user - generally outside the workspace directory", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigWriteResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigWriteResponse.json index d1a2254a92..b1d2259970 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigWriteResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigWriteResponse.json @@ -5,6 +5,14 @@ "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" }, + "CloudManagedLayer": { + "description": "Position of a fragment within the cloud-managed configuration contract.", + "enum": [ + "baseline", + "systemOverlay" + ], + "type": "string" + }, "ConfigLayerMetadata": { "properties": { "name": { @@ -100,6 +108,42 @@ "title": "EnterpriseManagedConfigLayerSource", "type": "object" }, + { + "description": "Config fragment delivered through the cloud-managed layer contract.", + "properties": { + "id": { + "description": "Stable identifier for the delivered fragment.", + "type": "string" + }, + "layer": { + "allOf": [ + { + "$ref": "#/definitions/CloudManagedLayer" + } + ], + "description": "Precedence tier that contains the fragment." + }, + "name": { + "description": "Human-readable name for diagnostics and source attribution.", + "type": "string" + }, + "type": { + "enum": [ + "cloudManaged" + ], + "title": "CloudManagedConfigLayerSourceType", + "type": "string" + } + }, + "required": [ + "id", + "layer", + "name", + "type" + ], + "title": "CloudManagedConfigLayerSource", + "type": "object" + }, { "description": "User config layer from $CODEX_HOME/config.toml. This layer is special in that it is expected to be: - writable by the user - generally outside the workspace directory", "properties": { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CloudManagedLayer.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CloudManagedLayer.ts new file mode 100644 index 0000000000..6577f7e754 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CloudManagedLayer.ts @@ -0,0 +1,8 @@ +// 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. + +/** + * Position of a fragment within the cloud-managed configuration contract. + */ +export type CloudManagedLayer = "baseline" | "systemOverlay"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigLayerSource.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigLayerSource.ts index 963c331b5d..1f0ba7fad6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigLayerSource.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigLayerSource.ts @@ -2,6 +2,7 @@ // 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 { CloudManagedLayer } from "./CloudManagedLayer"; export type ConfigLayerSource = { "type": "mdm", domain: string, key: string, } | { "type": "system", /** @@ -18,6 +19,18 @@ id: string, * diagnostics so users know which cloud layer needs administrator * attention. */ +name: string, } | { "type": "cloudManaged", +/** + * Precedence tier that contains the fragment. + */ +layer: CloudManagedLayer, +/** + * Stable identifier for the delivered fragment. + */ +id: string, +/** + * Human-readable name for diagnostics and source attribution. + */ name: string, } | { "type": "user", /** * This is the path to the user's config.toml file, though it is not 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 070d21eab8..599506d307 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -44,6 +44,7 @@ export type { CapabilityRootLocation } from "./CapabilityRootLocation"; export type { ChatgptAuthTokensRefreshParams } from "./ChatgptAuthTokensRefreshParams"; export type { ChatgptAuthTokensRefreshReason } from "./ChatgptAuthTokensRefreshReason"; export type { ChatgptAuthTokensRefreshResponse } from "./ChatgptAuthTokensRefreshResponse"; +export type { CloudManagedLayer } from "./CloudManagedLayer"; export type { CodexErrorInfo } from "./CodexErrorInfo"; export type { CollabAgentState } from "./CollabAgentState"; export type { CollabAgentStatus } from "./CollabAgentStatus"; 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 24d574a935..11f2cc2769 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -21,6 +21,15 @@ use std::collections::HashMap; use std::path::PathBuf; use ts_rs::TS; +/// Position of a fragment within the cloud-managed configuration contract. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub enum CloudManagedLayer { + Baseline, + SystemOverlay, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "camelCase")] #[ts(tag = "type")] @@ -56,6 +65,20 @@ pub enum ConfigLayerSource { name: String, }, + /// Config fragment delivered through the cloud-managed layer contract. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + CloudManaged { + /// Precedence tier that contains the fragment. + layer: CloudManagedLayer, + + /// Stable identifier for the delivered fragment. + id: String, + + /// Human-readable name for diagnostics and source attribution. + name: String, + }, + /// User config layer from $CODEX_HOME/config.toml. This layer is special /// in that it is expected to be: /// - writable by the user @@ -104,6 +127,10 @@ impl ConfigLayerSource { ConfigLayerSource::Mdm { .. } => 0, ConfigLayerSource::System { .. } => 10, ConfigLayerSource::EnterpriseManaged { .. } => 15, + ConfigLayerSource::CloudManaged { layer, .. } => match layer { + CloudManagedLayer::Baseline => -10, + CloudManagedLayer::SystemOverlay => 15, + }, ConfigLayerSource::User { profile, .. } => { if profile.is_some() { 21 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 377ecba2c2..96be2ef4bf 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -72,6 +72,45 @@ fn test_absolute_path() -> AbsolutePathBuf { absolute_path("readable") } +#[test] +fn cloud_managed_config_layers_round_trip_with_provenance() { + for (managed_layer, serialized_layer) in [ + (CloudManagedLayer::Baseline, "baseline"), + (CloudManagedLayer::SystemOverlay, "systemOverlay"), + ] { + let layer = ConfigLayer { + name: ConfigLayerSource::CloudManaged { + layer: managed_layer, + id: "policy-1".to_string(), + name: "Workspace policy".to_string(), + }, + version: "etag-1".to_string(), + config: json!({"model": "gpt-5"}), + disabled_reason: Some("conflicts with another managed fragment".to_string()), + }; + + let value = serde_json::to_value(&layer).expect("serialize cloud-managed layer"); + assert_eq!( + value, + json!({ + "name": { + "type": "cloudManaged", + "layer": serialized_layer, + "id": "policy-1", + "name": "Workspace policy", + }, + "version": "etag-1", + "config": {"model": "gpt-5"}, + "disabledReason": "conflicts with another managed fragment", + }) + ); + assert_eq!( + serde_json::from_value::(value).expect("deserialize cloud-managed layer"), + layer + ); + } +} + #[test] fn thread_sources_round_trip_as_scalar_labels() { for (source, label) in [ diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 6ee6baf2dc..fe5a506b7f 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -245,6 +245,19 @@ Example with notification opt-out: - `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`), `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`. +Cloud-managed sources in `config/read` identify the precedence tier and fragment that supplied a value: + +```json +{ + "type": "cloudManaged", + "layer": "baseline", + "id": "policy-1", + "name": "Default model policy" +} +``` + +`layer` is either `baseline` or `systemOverlay`. The existing `enterpriseManaged` source remains supported. + ### Example: Start or resume a thread Start a fresh thread when you need a new Codex conversation. diff --git a/codex-rs/app-server/src/config_layer.rs b/codex-rs/app-server/src/config_layer.rs index e7d98fc28b..b368a1462e 100644 --- a/codex-rs/app-server/src/config_layer.rs +++ b/codex-rs/app-server/src/config_layer.rs @@ -1,6 +1,8 @@ +use codex_app_server_protocol::CloudManagedLayer as ApiCloudManagedLayer; use codex_app_server_protocol::ConfigLayer as ApiConfigLayer; use codex_app_server_protocol::ConfigLayerMetadata as ApiConfigLayerMetadata; use codex_app_server_protocol::ConfigLayerSource as ApiConfigLayerSource; +use codex_config::CloudManagedLayer; use codex_config::ConfigLayer; use codex_config::ConfigLayerMetadata; use codex_config::ConfigLayerSource; @@ -18,6 +20,13 @@ pub(crate) fn config_layer_source_to_api(source: ConfigLayerSource) -> ApiConfig ConfigLayerSource::EnterpriseManaged { id, name } => { ApiConfigLayerSource::EnterpriseManaged { id, name } } + ConfigLayerSource::CloudManaged { layer, id, name } => { + let layer = match layer { + CloudManagedLayer::Baseline => ApiCloudManagedLayer::Baseline, + CloudManagedLayer::SystemOverlay => ApiCloudManagedLayer::SystemOverlay, + }; + ApiConfigLayerSource::CloudManaged { layer, id, name } + } ConfigLayerSource::User { file, profile } => ApiConfigLayerSource::User { file, profile }, ConfigLayerSource::Project { dot_codex_folder } => { ApiConfigLayerSource::Project { dot_codex_folder } @@ -61,3 +70,7 @@ pub(crate) fn config_layer_to_api(layer: ConfigLayer) -> ApiConfigLayer { disabled_reason: layer.disabled_reason, } } + +#[cfg(test)] +#[path = "config_layer_tests.rs"] +mod tests; diff --git a/codex-rs/app-server/src/config_layer_tests.rs b/codex-rs/app-server/src/config_layer_tests.rs new file mode 100644 index 0000000000..683bfe98e5 --- /dev/null +++ b/codex-rs/app-server/src/config_layer_tests.rs @@ -0,0 +1,39 @@ +use super::*; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[test] +fn cloud_managed_layers_map_to_complete_api_objects() { + for (domain_layer, api_layer) in [ + (CloudManagedLayer::Baseline, ApiCloudManagedLayer::Baseline), + ( + CloudManagedLayer::SystemOverlay, + ApiCloudManagedLayer::SystemOverlay, + ), + ] { + let layer = ConfigLayer { + name: ConfigLayerSource::CloudManaged { + layer: domain_layer, + id: "policy-1".to_string(), + name: "Workspace policy".to_string(), + }, + version: "etag-1".to_string(), + config: json!({"model": "gpt-5"}), + disabled_reason: Some("conflicts with another managed fragment".to_string()), + }; + + assert_eq!( + config_layer_to_api(layer), + ApiConfigLayer { + name: ApiConfigLayerSource::CloudManaged { + layer: api_layer, + id: "policy-1".to_string(), + name: "Workspace policy".to_string(), + }, + version: "etag-1".to_string(), + config: json!({"model": "gpt-5"}), + disabled_reason: Some("conflicts with another managed fragment".to_string()), + } + ); + } +} diff --git a/codex-rs/app-server/src/config_manager_service.rs b/codex-rs/app-server/src/config_manager_service.rs index 94980bf8e0..76986cd90c 100644 --- a/codex-rs/app-server/src/config_manager_service.rs +++ b/codex-rs/app-server/src/config_manager_service.rs @@ -633,6 +633,9 @@ fn override_message(layer: &ConfigLayerSource) -> String { ConfigLayerSource::EnterpriseManaged { id: _, name } => { format!("Overridden by enterprise-managed config: {name}") } + ConfigLayerSource::CloudManaged { layer, id: _, name } => { + format!("Overridden by cloud-managed {layer} config: {name}") + } ConfigLayerSource::Project { dot_codex_folder } => format!( "Overridden by project config: {}/{CONFIG_TOML_FILE}", dot_codex_folder.display(), diff --git a/codex-rs/config/src/config_layer_source.rs b/codex-rs/config/src/config_layer_source.rs index 15257af584..290ba265ef 100644 --- a/codex-rs/config/src/config_layer_source.rs +++ b/codex-rs/config/src/config_layer_source.rs @@ -1,5 +1,24 @@ use codex_utils_absolute_path::AbsolutePathBuf; use serde_json::Value as JsonValue; +use std::fmt; + +/// Position of a fragment within the cloud-managed configuration contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CloudManagedLayer { + /// Lowest-precedence defaults supplied by the cloud bundle. + Baseline, + /// Managed overrides applied above system configuration. + SystemOverlay, +} + +impl fmt::Display for CloudManagedLayer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CloudManagedLayer::Baseline => f.write_str("baseline"), + CloudManagedLayer::SystemOverlay => f.write_str("system-overlay"), + } + } +} /// Provenance for one layer in the effective Codex configuration. #[derive(Debug, Clone, PartialEq, Eq)] @@ -10,6 +29,12 @@ pub enum ConfigLayerSource { System { file: AbsolutePathBuf }, /// Configuration delivered by an enterprise cloud bundle. EnterpriseManaged { id: String, name: String }, + /// Configuration delivered through the generic cloud-managed layer contract. + CloudManaged { + layer: CloudManagedLayer, + id: String, + name: String, + }, /// User configuration, optionally augmented by a selected profile. User { file: AbsolutePathBuf, @@ -33,6 +58,10 @@ impl ConfigLayerSource { ConfigLayerSource::Mdm { .. } => 0, ConfigLayerSource::System { .. } => 10, ConfigLayerSource::EnterpriseManaged { .. } => 15, + ConfigLayerSource::CloudManaged { layer, .. } => match layer { + CloudManagedLayer::Baseline => -10, + CloudManagedLayer::SystemOverlay => 15, + }, ConfigLayerSource::User { profile, .. } => { if profile.is_some() { 21 @@ -83,6 +112,9 @@ pub fn format_config_layer_source(source: &ConfigLayerSource, config_toml_file: ConfigLayerSource::EnterpriseManaged { id, name } => { format!("enterprise-managed ({name}, {id})") } + ConfigLayerSource::CloudManaged { layer, id, name } => { + format!("cloud-managed {layer} ({name}, {id})") + } ConfigLayerSource::User { file, .. } => { format!("user ({})", file.as_path().display()) } @@ -101,3 +133,7 @@ pub fn format_config_layer_source(source: &ConfigLayerSource, config_toml_file: } } } + +#[cfg(test)] +#[path = "config_layer_source_tests.rs"] +mod tests; diff --git a/codex-rs/config/src/config_layer_source_tests.rs b/codex-rs/config/src/config_layer_source_tests.rs new file mode 100644 index 0000000000..46f96dae7e --- /dev/null +++ b/codex-rs/config/src/config_layer_source_tests.rs @@ -0,0 +1,34 @@ +use super::CloudManagedLayer; +use super::ConfigLayerSource; +use codex_utils_absolute_path::test_support::PathBufExt; +use codex_utils_absolute_path::test_support::test_path_buf; + +#[test] +fn cloud_managed_layers_have_their_contract_precedence() { + let baseline = ConfigLayerSource::CloudManaged { + layer: CloudManagedLayer::Baseline, + id: "baseline-1".to_string(), + name: "Baseline".to_string(), + }; + let mdm = ConfigLayerSource::Mdm { + domain: "com.openai.codex".to_string(), + key: "config".to_string(), + }; + let system = ConfigLayerSource::System { + file: test_path_buf("/etc/codex/config.toml").abs(), + }; + let overlay = ConfigLayerSource::CloudManaged { + layer: CloudManagedLayer::SystemOverlay, + id: "overlay-1".to_string(), + name: "System overlay".to_string(), + }; + let user = ConfigLayerSource::User { + file: test_path_buf("/home/test/.codex/config.toml").abs(), + profile: None, + }; + + assert!(baseline < mdm); + assert!(mdm < system); + assert!(system < overlay); + assert!(overlay < user); +} diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 00af972336..9f640417aa 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -17,6 +17,7 @@ use wildmatch::WildMatchPattern; use super::requirements_exec_policy::RequirementsExecPolicy; use super::requirements_exec_policy::RequirementsExecPolicyToml; +use crate::CloudManagedLayer; use crate::Constrained; use crate::ConstraintError; use crate::ManagedHooksRequirementsToml; @@ -43,6 +44,12 @@ pub enum RequirementSource { id: String, name: String, }, + /// A fragment delivered through the generic cloud-managed layer contract. + CloudManaged { + layer: CloudManagedLayer, + id: String, + name: String, + }, SystemRequirementsToml { file: AbsolutePathBuf, }, @@ -102,6 +109,9 @@ impl fmt::Display for RequirementSource { RequirementSource::EnterpriseManaged { id, name } => { write!(f, "enterprise-managed requirements {name} ({id})") } + RequirementSource::CloudManaged { layer, id, name } => { + write!(f, "cloud-managed {layer} requirements {name} ({id})") + } RequirementSource::SystemRequirementsToml { file } => { write!(f, "{}", file.as_path().display()) } diff --git a/codex-rs/config/src/diagnostics.rs b/codex-rs/config/src/diagnostics.rs index 789affe513..2cdea4c886 100644 --- a/codex-rs/config/src/diagnostics.rs +++ b/codex-rs/config/src/diagnostics.rs @@ -256,6 +256,7 @@ fn config_path_for_layer(layer: &ConfigLayerEntry, config_toml_file: &str) -> Op ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => Some(file.to_path_buf()), ConfigLayerSource::Mdm { .. } | ConfigLayerSource::EnterpriseManaged { .. } + | ConfigLayerSource::CloudManaged { .. } | ConfigLayerSource::SessionFlags | ConfigLayerSource::LegacyManagedConfigTomlFromMdm => None, } diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index df3e45d76d..51e5023dfa 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -48,6 +48,7 @@ pub use cloud_config_layers::cloud_config_layers_from_fragments; pub use codex_protocol::config_types::ProfileV2Name; pub use codex_protocol::config_types::ProfileV2NameParseError; pub use codex_utils_absolute_path::AbsolutePathBuf; +pub use config_layer_source::CloudManagedLayer; pub use config_layer_source::ConfigLayer; pub use config_layer_source::ConfigLayerMetadata; pub use config_layer_source::ConfigLayerSource; diff --git a/codex-rs/config/src/state.rs b/codex-rs/config/src/state.rs index 2f9db9576f..f4b659fac4 100644 --- a/codex-rs/config/src/state.rs +++ b/codex-rs/config/src/state.rs @@ -209,6 +209,7 @@ impl ConfigLayerEntry { ConfigLayerSource::Mdm { .. } => None, ConfigLayerSource::System { file } => file.parent(), ConfigLayerSource::EnterpriseManaged { .. } => None, + ConfigLayerSource::CloudManaged { .. } => None, ConfigLayerSource::User { file, .. } => file.parent(), ConfigLayerSource::Project { dot_codex_folder } => Some(dot_codex_folder.clone()), ConfigLayerSource::SessionFlags => None, diff --git a/codex-rs/core-skills/src/loader.rs b/codex-rs/core-skills/src/loader.rs index c9ace54204..c2aef71678 100644 --- a/codex-rs/core-skills/src/loader.rs +++ b/codex-rs/core-skills/src/loader.rs @@ -361,6 +361,7 @@ fn skill_roots_from_layer_stack_inner( } ConfigLayerSource::Mdm { .. } | ConfigLayerSource::EnterpriseManaged { .. } + | ConfigLayerSource::CloudManaged { .. } | ConfigLayerSource::SessionFlags | ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } | ConfigLayerSource::LegacyManagedConfigTomlFromMdm => {} diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index cab28b64a1..a288a6961a 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -291,6 +291,13 @@ fn fallback_managed_hooks_source_path( "/requirements.toml" )) } + Some(RequirementSource::CloudManaged { layer, id, name }) => { + let name = escape_xml_text(name); + let id = escape_xml_text(id); + synthetic_layer_path(&format!( + "/requirements.toml" + )) + } Some(RequirementSource::LegacyManagedConfigTomlFromMdm) => { synthetic_layer_path("/managed_config.toml") } @@ -378,6 +385,9 @@ fn config_toml_source_path(layer: &ConfigLayerEntry) -> AbsolutePathBuf { ConfigLayerSource::EnterpriseManaged { id, name } => synthetic_layer_path(&format!( "/{CONFIG_TOML_FILE}" )), + ConfigLayerSource::CloudManaged { layer, id, name } => synthetic_layer_path(&format!( + "/{CONFIG_TOML_FILE}" + )), ConfigLayerSource::LegacyManagedConfigTomlFromMdm => { synthetic_layer_path("/managed_config.toml") } @@ -619,6 +629,7 @@ fn hook_metadata_for_config_layer_source(source: &ConfigLayerSource) -> (HookSou ConfigLayerSource::Project { .. } => (HookSource::Project, false), ConfigLayerSource::Mdm { .. } => (HookSource::Mdm, true), ConfigLayerSource::EnterpriseManaged { .. } => (HookSource::CloudManagedConfig, true), + ConfigLayerSource::CloudManaged { .. } => (HookSource::CloudManagedConfig, true), ConfigLayerSource::SessionFlags => (HookSource::SessionFlags, false), ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => { (HookSource::LegacyManagedConfigFile, true) @@ -647,12 +658,14 @@ fn hook_source_for_requirement_source(source: Option<&RequirementSource>) -> Hoo hook_source_for_requirement_source(sources.first()) } Some(RequirementSource::EnterpriseManaged { .. }) => HookSource::CloudRequirements, + Some(RequirementSource::CloudManaged { .. }) => HookSource::CloudRequirements, Some(RequirementSource::Unknown) | None => HookSource::Unknown, } } #[cfg(test)] mod tests { + use codex_config::CloudManagedLayer; use codex_config::ConfigLayerEntry; use codex_config::ConfigLayerSource; use codex_config::HookEventsToml; @@ -748,6 +761,43 @@ mod tests { assert!(!source_path.contains("Name ")); } + #[test] + fn cloud_managed_synthetic_path_identifies_layer_and_escapes_display_fields() { + let source = RequirementSource::CloudManaged { + layer: CloudManagedLayer::SystemOverlay, + id: "id<&>".to_string(), + name: "Name & \"Ops\"".to_string(), + }; + + let source_path = super::fallback_managed_hooks_source_path(Some(&source)); + let source_path = source_path.display().to_string(); + + assert!(source_path.contains("cloud-managed:system-overlay")); + assert!(source_path.contains("Name <Admin> & "Ops"")); + assert!(source_path.contains("id<&>")); + assert!(!source_path.contains("Name ")); + assert_eq!( + super::hook_source_for_requirement_source(Some(&source)), + HookSource::CloudRequirements + ); + } + + #[test] + fn cloud_managed_config_source_path_identifies_layer() { + let layer = ConfigLayerEntry::new( + ConfigLayerSource::CloudManaged { + layer: CloudManagedLayer::Baseline, + id: "layer-1".to_string(), + name: "Defaults".to_string(), + }, + TomlValue::Table(Default::default()), + ); + + let source_path = super::config_toml_source_path(&layer).display().to_string(); + assert!(source_path.contains("cloud-managed:baseline:Defaults:layer-1")); + assert!(source_path.ends_with("config.toml")); + } + fn command_group(matcher: Option<&str>) -> MatcherGroup { MatcherGroup { matcher: matcher.map(str::to_string), @@ -1073,6 +1123,14 @@ mod tests { }), (HookSource::CloudManagedConfig, true), ); + assert_eq!( + super::hook_metadata_for_config_layer_source(&ConfigLayerSource::CloudManaged { + layer: CloudManagedLayer::SystemOverlay, + id: "cfg_456".to_string(), + name: "Managed overlay".to_string(), + }), + (HookSource::CloudManagedConfig, true), + ); assert_eq!( super::hook_metadata_for_config_layer_source(&ConfigLayerSource::SessionFlags), (HookSource::SessionFlags, false), diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index c02d4c8261..eebd4e5b42 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -309,6 +309,7 @@ fn render_non_file_layer_details(layer: &ConfigLayerEntry) -> Vec> ConfigLayerSource::SessionFlags => render_session_flag_details(&layer.config), ConfigLayerSource::Mdm { .. } | ConfigLayerSource::EnterpriseManaged { .. } + | ConfigLayerSource::CloudManaged { .. } | ConfigLayerSource::LegacyManagedConfigTomlFromMdm => render_non_file_layer_value(layer), ConfigLayerSource::System { .. } | ConfigLayerSource::User { .. } @@ -373,6 +374,7 @@ fn non_file_layer_value_label(source: &ConfigLayerSource) -> &'static str { "MDM value" } ConfigLayerSource::EnterpriseManaged { .. } => "Enterprise-managed config value", + ConfigLayerSource::CloudManaged { .. } => "Cloud-managed config value", ConfigLayerSource::SessionFlags | ConfigLayerSource::System { .. } | ConfigLayerSource::User { .. } @@ -557,6 +559,7 @@ mod tests { use super::session_all_proxy_url; use crate::legacy_core::config::Permissions; use codex_app_server_protocol::AskForApproval; + use codex_config::CloudManagedLayer; use codex_config::ConfigLayerEntry; use codex_config::ConfigLayerSource; use codex_config::ConfigLayerStack; @@ -800,13 +803,23 @@ mod tests { absolute_path("/home/alice/.codex/config.toml") }; let stack = ConfigLayerStack::new( - vec![ConfigLayerEntry::new( - ConfigLayerSource::User { - file: user_file, - profile: None, - }, - empty_toml_table(), - )], + vec![ + ConfigLayerEntry::new( + ConfigLayerSource::CloudManaged { + layer: CloudManagedLayer::Baseline, + id: "policy-1".to_string(), + name: "Baseline policy".to_string(), + }, + TomlValue::Table(toml::toml! { model = "managed" }), + ), + ConfigLayerEntry::new( + ConfigLayerSource::User { + file: user_file, + profile: None, + }, + empty_toml_table(), + ), + ], requirements, requirements_toml, ) 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..4a372bf506 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 @@ -5,7 +5,9 @@ expression: rendered.as_str() /debug-config Config layer stack (lowest precedence first): - 1. user (/home/alice/.codex/config.toml) (enabled) + 1. cloud-managed baseline (Baseline policy, policy-1) (enabled) + Cloud-managed config value: { model = "managed" } + 2. user (/home/alice/.codex/config.toml) (enabled) Requirements: - allowed_approval_policies: on-request (source: MDM managed_config.toml (legacy))