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 07566dceb8..c2a157f9dd 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 @@ -8509,6 +8509,32 @@ }, "ConfigLayerSource": { "oneOf": [ + { + "description": "Default configuration supplied with the installed Codex package.", + "properties": { + "file": { + "allOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + } + ], + "description": "Path to the packaged default configuration file." + }, + "type": { + "enum": [ + "packagedDefaults" + ], + "title": "PackagedDefaultsConfigLayerSourceType", + "type": "string" + } + }, + "required": [ + "file", + "type" + ], + "title": "PackagedDefaultsConfigLayerSource", + "type": "object" + }, { "description": "Managed preferences layer delivered by MDM (macOS only).", "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 4915f919cc..adb7afe132 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 @@ -4674,6 +4674,32 @@ }, "ConfigLayerSource": { "oneOf": [ + { + "description": "Default configuration supplied with the installed Codex package.", + "properties": { + "file": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Path to the packaged default configuration file." + }, + "type": { + "enum": [ + "packagedDefaults" + ], + "title": "PackagedDefaultsConfigLayerSourceType", + "type": "string" + } + }, + "required": [ + "file", + "type" + ], + "title": "PackagedDefaultsConfigLayerSource", + "type": "object" + }, { "description": "Managed preferences layer delivered by MDM (macOS only).", "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..3d028211e7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -477,6 +477,32 @@ }, "ConfigLayerSource": { "oneOf": [ + { + "description": "Default configuration supplied with the installed Codex package.", + "properties": { + "file": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Path to the packaged default configuration file." + }, + "type": { + "enum": [ + "packagedDefaults" + ], + "title": "PackagedDefaultsConfigLayerSourceType", + "type": "string" + } + }, + "required": [ + "file", + "type" + ], + "title": "PackagedDefaultsConfigLayerSource", + "type": "object" + }, { "description": "Managed preferences layer delivered by MDM (macOS only).", "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..3ea3a5f818 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigWriteResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigWriteResponse.json @@ -22,6 +22,32 @@ }, "ConfigLayerSource": { "oneOf": [ + { + "description": "Default configuration supplied with the installed Codex package.", + "properties": { + "file": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Path to the packaged default configuration file." + }, + "type": { + "enum": [ + "packagedDefaults" + ], + "title": "PackagedDefaultsConfigLayerSourceType", + "type": "string" + } + }, + "required": [ + "file", + "type" + ], + "title": "PackagedDefaultsConfigLayerSource", + "type": "object" + }, { "description": "Managed preferences layer delivered by MDM (macOS only).", "properties": { diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index 0da5f9db67..79ff2a4489 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index c231609cc3..0407afe6b5 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ 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..431d9dc33d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigLayerSource.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigLayerSource.ts @@ -3,7 +3,11 @@ // 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"; -export type ConfigLayerSource = { "type": "mdm", domain: string, key: string, } | { "type": "system", +export type ConfigLayerSource = { "type": "packagedDefaults", +/** + * Path to the packaged default configuration file. + */ +file: AbsolutePathBuf, } | { "type": "mdm", domain: string, key: string, } | { "type": "system", /** * This is the path to the system config.toml file, though it is not * guaranteed to exist. 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 2f0f26294a..5b04cdf532 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -27,6 +27,14 @@ use std::path::PathBuf; #[ts(tag = "type")] #[ts(export_to = "v2/")] pub enum ConfigLayerSource { + /// Default configuration supplied with the installed Codex package. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + PackagedDefaults { + /// Path to the packaged default configuration file. + file: AbsolutePathBuf, + }, + /// Managed preferences layer delivered by MDM (macOS only). #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] @@ -102,6 +110,7 @@ impl ConfigLayerSource { /// from a layer with a lower precedence. pub fn precedence(&self) -> i16 { match self { + ConfigLayerSource::PackagedDefaults { .. } => -10, ConfigLayerSource::Mdm { .. } => 0, ConfigLayerSource::System { .. } => 10, ConfigLayerSource::EnterpriseManaged { .. } => 15, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 035fcffbb7..3616f7e55b 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -272,7 +272,7 @@ 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 runtime-effective config after resolving config layering and managed requirements, 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`. When configured, the `packagedDefaults` layer has the lowest precedence. - `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. The response also includes connector candidates inferred from detected source sessions, with a normalized display `name`, the number of detected sessions that used the connector, and the source metadata field used for detection. - `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. Successful session entries include the original imported title when one was available. Connector candidates include a normalized display `name`, the number of imported sessions that used the connector, and the source metadata field used for detection. diff --git a/codex-rs/app-server/src/config_layer.rs b/codex-rs/app-server/src/config_layer.rs index e7d98fc28b..c59278b5ee 100644 --- a/codex-rs/app-server/src/config_layer.rs +++ b/codex-rs/app-server/src/config_layer.rs @@ -13,6 +13,9 @@ use codex_config::ConfigLayerSource; /// function instead of a `From` implementation. pub(crate) fn config_layer_source_to_api(source: ConfigLayerSource) -> ApiConfigLayerSource { match source { + ConfigLayerSource::PackagedDefaults { file } => { + ApiConfigLayerSource::PackagedDefaults { file } + } ConfigLayerSource::Mdm { domain, key } => ApiConfigLayerSource::Mdm { domain, key }, ConfigLayerSource::System { file } => ApiConfigLayerSource::System { file }, ConfigLayerSource::EnterpriseManaged { id, name } => { diff --git a/codex-rs/app-server/src/config_manager_service.rs b/codex-rs/app-server/src/config_manager_service.rs index 1605e372b1..3927fc7b8a 100644 --- a/codex-rs/app-server/src/config_manager_service.rs +++ b/codex-rs/app-server/src/config_manager_service.rs @@ -781,6 +781,9 @@ fn value_at_semantic_path<'a>(root: &'a TomlValue, segments: &[String]) -> Optio fn override_message(layer: &ConfigLayerSource) -> String { match layer { + ConfigLayerSource::PackagedDefaults { file } => { + format!("Overridden by packaged defaults: {}", file.display()) + } ConfigLayerSource::Mdm { domain, key: _ } => { format!("Overridden by managed policy (MDM): {domain}") } diff --git a/codex-rs/config/src/config_layer_source.rs b/codex-rs/config/src/config_layer_source.rs index 15257af584..c044ff5593 100644 --- a/codex-rs/config/src/config_layer_source.rs +++ b/codex-rs/config/src/config_layer_source.rs @@ -4,6 +4,8 @@ use serde_json::Value as JsonValue; /// Provenance for one layer in the effective Codex configuration. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ConfigLayerSource { + /// Default configuration supplied with the installed Codex package. + PackagedDefaults { file: AbsolutePathBuf }, /// Managed preferences delivered by MDM. Mdm { domain: String, key: String }, /// Host-wide configuration loaded from a file. @@ -30,6 +32,7 @@ impl ConfigLayerSource { /// from a layer with a lower precedence. pub fn precedence(&self) -> i16 { match self { + ConfigLayerSource::PackagedDefaults { .. } => -10, ConfigLayerSource::Mdm { .. } => 0, ConfigLayerSource::System { .. } => 10, ConfigLayerSource::EnterpriseManaged { .. } => 15, @@ -74,6 +77,9 @@ pub struct ConfigLayer { pub fn format_config_layer_source(source: &ConfigLayerSource, config_toml_file: &str) -> String { match source { + ConfigLayerSource::PackagedDefaults { file } => { + format!("packaged defaults ({})", file.as_path().display()) + } ConfigLayerSource::Mdm { domain, key } => { format!("MDM ({domain}:{key})") } diff --git a/codex-rs/config/src/diagnostics.rs b/codex-rs/config/src/diagnostics.rs index 0fb9ff8764..0036932887 100644 --- a/codex-rs/config/src/diagnostics.rs +++ b/codex-rs/config/src/diagnostics.rs @@ -244,6 +244,7 @@ where fn config_path_for_layer(layer: &ConfigLayerEntry, config_toml_file: &str) -> Option { match &layer.name { + ConfigLayerSource::PackagedDefaults { file } => Some(file.to_path_buf()), ConfigLayerSource::System { file } => Some(file.to_path_buf()), ConfigLayerSource::User { file, .. } => Some(file.to_path_buf()), ConfigLayerSource::Project { dot_codex_folder } => { diff --git a/codex-rs/config/src/loader/mod.rs b/codex-rs/config/src/loader/mod.rs index 8d01ec755c..cc661fc8d5 100644 --- a/codex-rs/config/src/loader/mod.rs +++ b/codex-rs/config/src/loader/mod.rs @@ -101,6 +101,7 @@ async fn first_layer_config_error_from_entries(layers: &[ConfigLayerEntry]) -> O /// /// Configuration is built up from multiple layers in the following order: /// +/// - package: optional default configuration supplied with the Codex package /// - admin: managed preferences (*) /// - system `/etc/codex/config.toml` (Unix) or /// `%ProgramData%\OpenAI\Codex\config.toml` (Windows) @@ -134,6 +135,36 @@ pub async fn load_config_layers_state( strict_config, cloud_config_bundle, } = options.into(); + let packaged_defaults_layer = if let Some(file) = &overrides.packaged_defaults_path { + let config = layer_io::read_config_from_path( + fs, + file, + /*log_missing_as_info*/ false, + strict_config, + ) + .await? + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + format!("packaged defaults config file {} not found", file.display()), + ) + })?; + let base_dir = file.as_path().parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!( + "packaged defaults config file {} has no parent directory", + file.display() + ), + ) + })?; + Some(ConfigLayerEntry::new( + ConfigLayerSource::PackagedDefaults { file: file.clone() }, + resolve_relative_paths_in_config_toml(config, base_dir)?, + )) + } else { + None + }; let active_user_profile = overrides.user_config_profile.clone(); let ignore_managed_requirements = overrides.ignore_managed_requirements; let ignore_user_config = overrides.ignore_user_config; @@ -218,6 +249,7 @@ pub async fn load_config_layers_state( .map_err(io::Error::other)?; let mut layers = Vec::::new(); + layers.extend(packaged_defaults_layer); let cli_overrides_layer = if cli_overrides.is_empty() { None diff --git a/codex-rs/config/src/loader/tests.rs b/codex-rs/config/src/loader/tests.rs index 15a4707401..8df26eb6cf 100644 --- a/codex-rs/config/src/loader/tests.rs +++ b/codex-rs/config/src/loader/tests.rs @@ -115,6 +115,112 @@ impl ExecutorFileSystem for TestFileSystem { } } +#[tokio::test] +async fn packaged_defaults_have_lower_precedence_than_existing_config_layers() { + let tmp = tempdir().expect("tempdir"); + let packaged_defaults_path = + AbsolutePathBuf::resolve_path_against_base("packaged-defaults.toml", tmp.path()); + let system_config_path = tmp.path().join("system.toml"); + let user_config_path = tmp.path().join(CONFIG_TOML_FILE); + + std::fs::write( + packaged_defaults_path.as_path(), + r#" +model = "packaged-model" +model_provider = "packaged-provider" +model_context_window = 120000 +"#, + ) + .expect("write packaged defaults"); + std::fs::write( + &system_config_path, + r#" +model = "system-model" +model_provider = "system-provider" +"#, + ) + .expect("write system config"); + std::fs::write(&user_config_path, r#"model = "user-model""#).expect("write user config"); + + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.packaged_defaults_path = Some(packaged_defaults_path.clone()); + overrides.system_config_path = Some(system_config_path.clone()); + + let stack = load_config_layers_state( + &TestFileSystem, + tmp.path(), + /*cwd*/ None, + &[( + "model".to_string(), + TomlValue::String("session-model".to_string()), + )], + overrides, + &crate::NoopThreadConfigLoader, + ) + .await + .expect("load config layers"); + + assert_eq!( + stack + .all_layers_low_to_high() + .map(|layer| layer.name.clone()) + .collect::>(), + vec![ + ConfigLayerSource::PackagedDefaults { + file: packaged_defaults_path, + }, + ConfigLayerSource::System { + file: AbsolutePathBuf::from_absolute_path(system_config_path) + .expect("absolute system config path"), + }, + ConfigLayerSource::User { + file: AbsolutePathBuf::from_absolute_path(user_config_path) + .expect("absolute user config path"), + profile: None, + }, + ConfigLayerSource::SessionFlags, + ] + ); + assert_eq!( + stack.effective_config(), + toml::toml! { + model = "session-model" + model_provider = "system-provider" + model_context_window = 120000 + } + .into() + ); +} + +#[tokio::test] +async fn missing_packaged_defaults_file_returns_an_error() { + let tmp = tempdir().expect("tempdir"); + let packaged_defaults_path = + AbsolutePathBuf::resolve_path_against_base("packaged-defaults.toml", tmp.path()); + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.packaged_defaults_path = Some(packaged_defaults_path.clone()); + + let err = load_config_layers_state( + &TestFileSystem, + tmp.path(), + /*cwd*/ None, + &[], + overrides, + &crate::NoopThreadConfigLoader, + ) + .await + .expect_err("an explicitly configured packaged defaults file must exist"); + + assert_eq!(err.kind(), io::ErrorKind::NotFound); + assert_eq!( + err.to_string(), + format!( + "packaged defaults config file {} not found", + packaged_defaults_path.display() + ) + ); +} + #[tokio::test] async fn profile_v2_rejects_matching_legacy_profile_in_base_user_config() { let tmp = tempdir().expect("tempdir"); diff --git a/codex-rs/config/src/state.rs b/codex-rs/config/src/state.rs index d967a8aed7..bda2b7d1a7 100644 --- a/codex-rs/config/src/state.rs +++ b/codex-rs/config/src/state.rs @@ -41,6 +41,8 @@ impl From for ConfigLoadOptions { /// LoaderOverrides overrides managed configuration inputs (primarily for tests). #[derive(Debug, Default, Clone)] pub struct LoaderOverrides { + /// Optional configuration file supplied with the installed Codex package. + pub packaged_defaults_path: Option, pub user_config_path: Option, pub user_config_profile: Option, pub managed_config_path: Option, @@ -64,6 +66,7 @@ impl LoaderOverrides { pub fn without_managed_config_for_tests() -> Self { let base = std::env::temp_dir().join("codex-config-tests"); Self { + packaged_defaults_path: None, user_config_path: None, user_config_profile: None, managed_config_path: Some(base.join("managed_config.toml")), @@ -212,6 +215,7 @@ impl ConfigLayerEntry { // Get the `.codex/` folder associated with this config layer, if any. pub fn config_folder(&self) -> Option { match &self.name { + ConfigLayerSource::PackagedDefaults { .. } => None, ConfigLayerSource::Mdm { .. } => None, ConfigLayerSource::System { file } => file.parent(), ConfigLayerSource::EnterpriseManaged { .. } => None, diff --git a/codex-rs/ext/skills/src/host_roots.rs b/codex-rs/ext/skills/src/host_roots.rs index ae9367cc4e..df9c419fbe 100644 --- a/codex-rs/ext/skills/src/host_roots.rs +++ b/codex-rs/ext/skills/src/host_roots.rs @@ -117,7 +117,8 @@ fn roots_from_layer_stack( SkillScope::Admin, )); } - ConfigLayerSource::Mdm { .. } + ConfigLayerSource::PackagedDefaults { .. } + | ConfigLayerSource::Mdm { .. } | ConfigLayerSource::EnterpriseManaged { .. } | ConfigLayerSource::SessionFlags | ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index 9bfaf952d3..d94078dfd6 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -375,7 +375,8 @@ fn load_toml_hooks_from_layer( fn config_toml_source_path(layer: &ConfigLayerEntry) -> AbsolutePathBuf { match &layer.name { - ConfigLayerSource::System { file } + ConfigLayerSource::PackagedDefaults { file } + | ConfigLayerSource::System { file } | ConfigLayerSource::User { file, .. } | ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => file.clone(), ConfigLayerSource::Project { dot_codex_folder } => layer @@ -716,6 +717,7 @@ fn hook_trusted_hash(is_managed: bool, state: Option<&HookStateToml>) -> Option< fn hook_metadata_for_config_layer_source(source: &ConfigLayerSource) -> (HookSource, bool) { match source { + ConfigLayerSource::PackagedDefaults { .. } => (HookSource::Unknown, false), ConfigLayerSource::System { .. } => (HookSource::System, true), ConfigLayerSource::User { .. } => (HookSource::User, false), ConfigLayerSource::Project { .. } => (HookSource::Project, false), diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index 538ef3e73c..bb9e3cc6d3 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -404,7 +404,8 @@ fn render_non_file_layer_details(layer: &ConfigLayerEntry) -> Vec> ConfigLayerSource::Mdm { .. } | ConfigLayerSource::EnterpriseManaged { .. } | ConfigLayerSource::LegacyManagedConfigTomlFromMdm => render_non_file_layer_value(layer), - ConfigLayerSource::System { .. } + ConfigLayerSource::PackagedDefaults { .. } + | ConfigLayerSource::System { .. } | ConfigLayerSource::User { .. } | ConfigLayerSource::Project { .. } | ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => Vec::new(), @@ -467,7 +468,8 @@ fn non_file_layer_value_label(source: &ConfigLayerSource) -> &'static str { "MDM value" } ConfigLayerSource::EnterpriseManaged { .. } => "Enterprise-managed config value", - ConfigLayerSource::SessionFlags + ConfigLayerSource::PackagedDefaults { .. } + | ConfigLayerSource::SessionFlags | ConfigLayerSource::System { .. } | ConfigLayerSource::User { .. } | ConfigLayerSource::Project { .. }