mirror of
https://github.com/openai/codex.git
synced 2026-05-28 15:00:16 +00:00
Add cloud managed config layer support
Introduce an explicit enterprise-managed config layer source and the client-side machinery to materialize cloud-delivered config TOML fragments into the normal config stack. The new ConfigLayerSource::EnterpriseManaged variant carries the backend layer id and display name so diagnostics and debug output can point admins at the exact cloud layer that needs fixing. Add codex_config::cloud_config_layers to build config layers from delivered fragments. The composition keeps backend layer order deterministic, resolves relative path settings against a supplied base directory for consistency with existing MDM-delivered config semantics, and stores the raw TOML with that base directory on ConfigLayerEntry so typed diagnostics can reparse non-file layers without relying on a synthetic filesystem path. Keep this v1 pull-based and snapshot-oriented. The bundle loader/cache work can feed these helpers, but this change does not introduce dynamic refresh or announce/push semantics. Consumers continue to read the config state they are already handed. Tighten provenance and diagnostics for non-file layers: enterprise-managed layers render as enterprise-managed config values in debug output, syntax/type errors use the layer display name, and synthetic hook source paths include the enterprise layer name/id when a filesystem path is needed for existing hook metadata surfaces. Split hook provenance semantically by adding HookSource::CloudManagedConfig. Hooks delivered through enterprise-managed config layers now report cloud_managed_config / cloudManagedConfig, while hooks delivered through requirements remain CloudRequirements. The TUI labels the new source as Cloud-managed config, and analytics/core metric mappings were updated to include the new source. Regenerate app-server protocol JSON and TypeScript schema fixtures for the new ConfigLayerSource and HookSource wire values. Verification: just write-app-server-schema; cargo test -p codex-app-server-protocol; cargo test -p codex-hooks hook_metadata_for_config_layer_source; cargo test -p codex-core hook_run_metric_tags; cargo test -p codex-analytics hook_run_metadata; just fmt; just fix -p codex-protocol -p codex-app-server-protocol -p codex-hooks -p codex-analytics -p codex-core -p codex-tui.
This commit is contained in:
@@ -1005,6 +1005,7 @@ fn analytics_hook_source(source: HookSource) -> &'static str {
|
||||
HookSource::SessionFlags => "session_flags",
|
||||
HookSource::Plugin => "plugin",
|
||||
HookSource::CloudRequirements => "cloud_requirements",
|
||||
HookSource::CloudManagedConfig => "cloud_managed_config",
|
||||
HookSource::LegacyManagedConfigFile => "legacy_managed_config_file",
|
||||
HookSource::LegacyManagedConfigMdm => "legacy_managed_config_mdm",
|
||||
HookSource::Unknown => "unknown",
|
||||
|
||||
@@ -2002,6 +2002,7 @@
|
||||
"sessionFlags",
|
||||
"plugin",
|
||||
"cloudRequirements",
|
||||
"cloudManagedConfig",
|
||||
"legacyManagedConfigFile",
|
||||
"legacyManagedConfigMdm",
|
||||
"unknown"
|
||||
|
||||
@@ -7557,6 +7557,33 @@
|
||||
"title": "SystemConfigLayerSource",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Enterprise-managed config layer delivered by the cloud config bundle.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"description": "Stable identifier for the delivered layer.",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "Admin-facing name for the delivered layer. This is surfaced in diagnostics so users know which cloud layer needs administrator attention.",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"enterpriseManaged"
|
||||
],
|
||||
"title": "EnterpriseManagedConfigLayerSourceType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"type"
|
||||
],
|
||||
"title": "EnterpriseManagedConfigLayerSource",
|
||||
"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": {
|
||||
@@ -10036,6 +10063,7 @@
|
||||
"sessionFlags",
|
||||
"plugin",
|
||||
"cloudRequirements",
|
||||
"cloudManagedConfig",
|
||||
"legacyManagedConfigFile",
|
||||
"legacyManagedConfigMdm",
|
||||
"unknown"
|
||||
|
||||
@@ -3926,6 +3926,33 @@
|
||||
"title": "SystemConfigLayerSource",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Enterprise-managed config layer delivered by the cloud config bundle.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"description": "Stable identifier for the delivered layer.",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "Admin-facing name for the delivered layer. This is surfaced in diagnostics so users know which cloud layer needs administrator attention.",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"enterpriseManaged"
|
||||
],
|
||||
"title": "EnterpriseManagedConfigLayerSourceType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"type"
|
||||
],
|
||||
"title": "EnterpriseManagedConfigLayerSource",
|
||||
"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": {
|
||||
@@ -6516,6 +6543,7 @@
|
||||
"sessionFlags",
|
||||
"plugin",
|
||||
"cloudRequirements",
|
||||
"cloudManagedConfig",
|
||||
"legacyManagedConfigFile",
|
||||
"legacyManagedConfigMdm",
|
||||
"unknown"
|
||||
|
||||
@@ -498,6 +498,33 @@
|
||||
"title": "SystemConfigLayerSource",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Enterprise-managed config layer delivered by the cloud config bundle.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"description": "Stable identifier for the delivered layer.",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "Admin-facing name for the delivered layer. This is surfaced in diagnostics so users know which cloud layer needs administrator attention.",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"enterpriseManaged"
|
||||
],
|
||||
"title": "EnterpriseManagedConfigLayerSourceType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"type"
|
||||
],
|
||||
"title": "EnterpriseManagedConfigLayerSource",
|
||||
"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": {
|
||||
|
||||
@@ -73,6 +73,33 @@
|
||||
"title": "SystemConfigLayerSource",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Enterprise-managed config layer delivered by the cloud config bundle.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"description": "Stable identifier for the delivered layer.",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "Admin-facing name for the delivered layer. This is surfaced in diagnostics so users know which cloud layer needs administrator attention.",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"enterpriseManaged"
|
||||
],
|
||||
"title": "EnterpriseManagedConfigLayerSourceType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"type"
|
||||
],
|
||||
"title": "EnterpriseManagedConfigLayerSource",
|
||||
"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": {
|
||||
|
||||
@@ -166,6 +166,7 @@
|
||||
"sessionFlags",
|
||||
"plugin",
|
||||
"cloudRequirements",
|
||||
"cloudManagedConfig",
|
||||
"legacyManagedConfigFile",
|
||||
"legacyManagedConfigMdm",
|
||||
"unknown"
|
||||
|
||||
@@ -166,6 +166,7 @@
|
||||
"sessionFlags",
|
||||
"plugin",
|
||||
"cloudRequirements",
|
||||
"cloudManagedConfig",
|
||||
"legacyManagedConfigFile",
|
||||
"legacyManagedConfigMdm",
|
||||
"unknown"
|
||||
|
||||
@@ -130,6 +130,7 @@
|
||||
"sessionFlags",
|
||||
"plugin",
|
||||
"cloudRequirements",
|
||||
"cloudManagedConfig",
|
||||
"legacyManagedConfigFile",
|
||||
"legacyManagedConfigMdm",
|
||||
"unknown"
|
||||
|
||||
@@ -8,7 +8,17 @@ export type ConfigLayerSource = { "type": "mdm", domain: string, key: string, }
|
||||
* This is the path to the system config.toml file, though it is not
|
||||
* guaranteed to exist.
|
||||
*/
|
||||
file: AbsolutePathBuf, } | { "type": "user",
|
||||
file: AbsolutePathBuf, } | { "type": "enterpriseManaged",
|
||||
/**
|
||||
* Stable identifier for the delivered layer.
|
||||
*/
|
||||
id: string,
|
||||
/**
|
||||
* Admin-facing name for the delivered layer. This is surfaced in
|
||||
* diagnostics so users know which cloud layer needs administrator
|
||||
* attention.
|
||||
*/
|
||||
name: string, } | { "type": "user",
|
||||
/**
|
||||
* This is the path to the user's config.toml file, though it is not
|
||||
* guaranteed to exist.
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type HookSource = "system" | "user" | "project" | "mdm" | "sessionFlags" | "plugin" | "cloudRequirements" | "legacyManagedConfigFile" | "legacyManagedConfigMdm" | "unknown";
|
||||
export type HookSource = "system" | "user" | "project" | "mdm" | "sessionFlags" | "plugin" | "cloudRequirements" | "cloudManagedConfig" | "legacyManagedConfigFile" | "legacyManagedConfigMdm" | "unknown";
|
||||
|
||||
@@ -42,6 +42,19 @@ pub enum ConfigLayerSource {
|
||||
file: AbsolutePathBuf,
|
||||
},
|
||||
|
||||
/// Enterprise-managed config layer delivered by the cloud config bundle.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
EnterpriseManaged {
|
||||
/// Stable identifier for the delivered layer.
|
||||
id: String,
|
||||
|
||||
/// Admin-facing name for the delivered layer. This is surfaced in
|
||||
/// diagnostics so users know which cloud layer needs administrator
|
||||
/// attention.
|
||||
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
|
||||
@@ -89,6 +102,7 @@ impl ConfigLayerSource {
|
||||
match self {
|
||||
ConfigLayerSource::Mdm { .. } => 0,
|
||||
ConfigLayerSource::System { .. } => 10,
|
||||
ConfigLayerSource::EnterpriseManaged { .. } => 15,
|
||||
ConfigLayerSource::User { profile, .. } => {
|
||||
if profile.is_some() {
|
||||
21
|
||||
|
||||
@@ -48,6 +48,7 @@ v2_enum_from_core!(
|
||||
SessionFlags,
|
||||
Plugin,
|
||||
CloudRequirements,
|
||||
CloudManagedConfig,
|
||||
LegacyManagedConfigFile,
|
||||
LegacyManagedConfigMdm,
|
||||
Unknown,
|
||||
|
||||
@@ -624,6 +624,9 @@ fn override_message(layer: &ConfigLayerSource) -> String {
|
||||
ConfigLayerSource::System { file } => {
|
||||
format!("Overridden by managed config (system): {}", file.display())
|
||||
}
|
||||
ConfigLayerSource::EnterpriseManaged { id: _, name } => {
|
||||
format!("Overridden by enterprise-managed config: {name}")
|
||||
}
|
||||
ConfigLayerSource::Project { dot_codex_folder } => format!(
|
||||
"Overridden by project config: {}/{CONFIG_TOML_FILE}",
|
||||
dot_codex_folder.display(),
|
||||
|
||||
312
codex-rs/config/src/cloud_config_layers.rs
Normal file
312
codex-rs/config/src/cloud_config_layers.rs
Normal file
@@ -0,0 +1,312 @@
|
||||
use crate::ConfigLayerEntry;
|
||||
use crate::ConfigLayerSource;
|
||||
use crate::TomlValue;
|
||||
use crate::loader::resolve_relative_paths_in_config_toml;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use std::fmt;
|
||||
use std::io;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Config fragment delivered by the cloud config bundle.
|
||||
///
|
||||
/// The bundle orders fragments from highest precedence to lowest precedence.
|
||||
/// This module returns config layers in stack order, so callers can append the
|
||||
/// result between system and user config without re-sorting.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CloudConfigFragment {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub contents: String,
|
||||
}
|
||||
|
||||
impl CloudConfigFragment {
|
||||
fn source_ref(&self) -> CloudConfigFragmentSource {
|
||||
CloudConfigFragmentSource {
|
||||
id: self.id.clone(),
|
||||
name: self.name.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CloudConfigFragmentSource {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl fmt::Display for CloudConfigFragmentSource {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{} ({})", self.name, self.id)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, PartialEq, Eq)]
|
||||
pub enum CloudConfigLayerError {
|
||||
#[error("failed to parse cloud config fragment {fragment}: {message}")]
|
||||
Parse {
|
||||
fragment: CloudConfigFragmentSource,
|
||||
message: String,
|
||||
},
|
||||
#[error("invalid cloud config fragment {fragment}: {message}")]
|
||||
Invalid {
|
||||
fragment: CloudConfigFragmentSource,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn cloud_config_layers_from_fragments(
|
||||
fragments: impl IntoIterator<Item = CloudConfigFragment>,
|
||||
base_dir: &AbsolutePathBuf,
|
||||
) -> Result<Vec<ConfigLayerEntry>, CloudConfigLayerError> {
|
||||
let mut layers = Vec::new();
|
||||
for fragment in fragments {
|
||||
let source_ref = fragment.source_ref();
|
||||
let raw_toml = fragment.contents;
|
||||
let value: TomlValue =
|
||||
toml::from_str(&raw_toml).map_err(|err| CloudConfigLayerError::Parse {
|
||||
fragment: source_ref.clone(),
|
||||
message: err.to_string(),
|
||||
})?;
|
||||
let resolved =
|
||||
resolve_relative_paths_in_config_toml(value, base_dir.as_path()).map_err(|err| {
|
||||
CloudConfigLayerError::Invalid {
|
||||
fragment: source_ref.clone(),
|
||||
message: err.to_string(),
|
||||
}
|
||||
})?;
|
||||
layers.push(ConfigLayerEntry::new_with_raw_toml(
|
||||
ConfigLayerSource::EnterpriseManaged {
|
||||
id: fragment.id,
|
||||
name: fragment.name,
|
||||
},
|
||||
resolved,
|
||||
raw_toml,
|
||||
base_dir.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
// Bundle fragments arrive highest-priority first, while ConfigLayerStack
|
||||
// folds lowest-priority to highest-priority.
|
||||
layers.reverse();
|
||||
Ok(layers)
|
||||
}
|
||||
|
||||
impl From<CloudConfigLayerError> for io::Error {
|
||||
fn from(error: CloudConfigLayerError) -> Self {
|
||||
io::Error::new(io::ErrorKind::InvalidData, error)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::CONFIG_TOML_FILE;
|
||||
use crate::ConfigLayerStack;
|
||||
use crate::ConfigLayerStackOrdering;
|
||||
use crate::ConfigRequirements;
|
||||
use crate::ConfigRequirementsToml;
|
||||
use crate::config_toml::ConfigToml;
|
||||
use crate::first_layer_config_error_from_entries;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_absolute_path::test_support::PathBufExt;
|
||||
use codex_utils_absolute_path::test_support::test_path_buf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::Path;
|
||||
|
||||
fn fragment(id: &str, name: &str, contents: &str) -> CloudConfigFragment {
|
||||
CloudConfigFragment {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
contents: contents.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn toml(contents: &str) -> TomlValue {
|
||||
toml::from_str(contents).expect("test TOML should parse")
|
||||
}
|
||||
|
||||
fn base_dir() -> AbsolutePathBuf {
|
||||
test_path_buf("/var/lib/codex").abs()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_are_returned_in_stack_order() {
|
||||
let base_dir = base_dir();
|
||||
let layers = cloud_config_layers_from_fragments(
|
||||
vec![
|
||||
fragment("high", "High priority", "model = \"cloud-high\""),
|
||||
fragment("low", "Low priority", "model_provider = \"cloud-low\""),
|
||||
],
|
||||
&base_dir,
|
||||
)
|
||||
.expect("cloud config layers should compose");
|
||||
|
||||
assert_eq!(
|
||||
layers
|
||||
.iter()
|
||||
.map(|layer| layer.name.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
ConfigLayerSource::EnterpriseManaged {
|
||||
id: "low".to_string(),
|
||||
name: "Low priority".to_string(),
|
||||
},
|
||||
ConfigLayerSource::EnterpriseManaged {
|
||||
id: "high".to_string(),
|
||||
name: "High priority".to_string(),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enterprise_layers_precede_user_and_override_system() {
|
||||
let base_dir = base_dir();
|
||||
let mut layers = vec![ConfigLayerEntry::new(
|
||||
ConfigLayerSource::System {
|
||||
file: test_path_buf("/etc/codex/config.toml").abs(),
|
||||
},
|
||||
toml(
|
||||
r#"
|
||||
model = "system"
|
||||
model_provider = "system"
|
||||
review_model = "system-review"
|
||||
"#,
|
||||
),
|
||||
)];
|
||||
layers.extend(
|
||||
cloud_config_layers_from_fragments(
|
||||
vec![
|
||||
fragment("high", "High priority", "model_provider = \"cloud-high\""),
|
||||
fragment("low", "Low priority", "review_model = \"cloud-low-review\""),
|
||||
],
|
||||
&base_dir,
|
||||
)
|
||||
.expect("cloud config layers should compose"),
|
||||
);
|
||||
layers.push(ConfigLayerEntry::new(
|
||||
ConfigLayerSource::User {
|
||||
file: test_path_buf("/home/alice/.codex/config.toml").abs(),
|
||||
profile: None,
|
||||
},
|
||||
toml("model = \"user\""),
|
||||
));
|
||||
|
||||
let stack = ConfigLayerStack::new(
|
||||
layers,
|
||||
ConfigRequirements::default(),
|
||||
ConfigRequirementsToml::default(),
|
||||
)
|
||||
.expect("stack should be ordered");
|
||||
|
||||
assert_eq!(
|
||||
stack
|
||||
.get_layers(
|
||||
ConfigLayerStackOrdering::LowestPrecedenceFirst,
|
||||
/*include_disabled*/ false
|
||||
)
|
||||
.iter()
|
||||
.map(|layer| layer.name.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
ConfigLayerSource::System {
|
||||
file: test_path_buf("/etc/codex/config.toml").abs(),
|
||||
},
|
||||
ConfigLayerSource::EnterpriseManaged {
|
||||
id: "low".to_string(),
|
||||
name: "Low priority".to_string(),
|
||||
},
|
||||
ConfigLayerSource::EnterpriseManaged {
|
||||
id: "high".to_string(),
|
||||
name: "High priority".to_string(),
|
||||
},
|
||||
ConfigLayerSource::User {
|
||||
file: test_path_buf("/home/alice/.codex/config.toml").abs(),
|
||||
profile: None,
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
stack.effective_config(),
|
||||
toml(
|
||||
r#"
|
||||
model = "user"
|
||||
model_provider = "cloud-high"
|
||||
review_model = "cloud-low-review"
|
||||
"#,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_absolute_path_fields_resolve_against_base_dir() {
|
||||
let base_dir = base_dir();
|
||||
let layers = cloud_config_layers_from_fragments(
|
||||
vec![fragment(
|
||||
"cfg_123",
|
||||
"Base policy",
|
||||
"model_instructions_file = \"instructions.md\"",
|
||||
)],
|
||||
&base_dir,
|
||||
)
|
||||
.expect("relative paths should match existing MDM semantics");
|
||||
|
||||
let path = layers[0]
|
||||
.config
|
||||
.get("model_instructions_file")
|
||||
.and_then(TomlValue::as_str)
|
||||
.expect("path should be present");
|
||||
let expected =
|
||||
AbsolutePathBuf::resolve_path_against_base("instructions.md", base_dir.as_path());
|
||||
assert_eq!(path, expected.to_string_lossy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_relative_path_fields_are_allowed_and_resolved() {
|
||||
let base_dir = base_dir();
|
||||
let layers = cloud_config_layers_from_fragments(
|
||||
vec![fragment(
|
||||
"cfg_123",
|
||||
"Base policy",
|
||||
"model_instructions_file = \"~/instructions.md\"",
|
||||
)],
|
||||
&base_dir,
|
||||
)
|
||||
.expect("home-relative paths should be accepted");
|
||||
|
||||
let path = layers[0]
|
||||
.config
|
||||
.get("model_instructions_file")
|
||||
.and_then(TomlValue::as_str)
|
||||
.expect("path should be present");
|
||||
let expected =
|
||||
AbsolutePathBuf::resolve_path_against_base("~/instructions.md", base_dir.as_path());
|
||||
assert_eq!(path, expected.to_string_lossy());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raw_toml_diagnostics_use_enterprise_layer_name() {
|
||||
let base_dir = base_dir();
|
||||
let layers = cloud_config_layers_from_fragments(
|
||||
vec![fragment(
|
||||
"cfg_123",
|
||||
"Base policy",
|
||||
"model_instructions_file = \"instructions.md\"\nmodel = 1",
|
||||
)],
|
||||
&base_dir,
|
||||
)
|
||||
.expect("cloud config layers should parse");
|
||||
|
||||
let error = first_layer_config_error_from_entries::<ConfigToml>(&layers, CONFIG_TOML_FILE)
|
||||
.await
|
||||
.expect("invalid raw TOML should produce a layer diagnostic");
|
||||
|
||||
assert_eq!(
|
||||
error.path,
|
||||
Path::new("enterprise-managed config Base policy (cfg_123)").to_path_buf()
|
||||
);
|
||||
assert_eq!(error.range.start.line, 2);
|
||||
assert_eq!(error.range.start.column, 9);
|
||||
assert!(error.message.contains("invalid type: integer `1`"));
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,6 @@ impl std::error::Error for ConfigLoadError {
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum ConfigDiagnosticSource<'a> {
|
||||
Path(&'a Path),
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
DisplayName(&'a str),
|
||||
}
|
||||
|
||||
@@ -97,7 +96,6 @@ impl ConfigDiagnosticSource<'_> {
|
||||
pub(crate) fn to_path_buf(self) -> PathBuf {
|
||||
match self {
|
||||
ConfigDiagnosticSource::Path(path) => path.to_path_buf(),
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
ConfigDiagnosticSource::DisplayName(name) => PathBuf::from(name),
|
||||
}
|
||||
}
|
||||
@@ -201,6 +199,27 @@ where
|
||||
I: IntoIterator<Item = &'a ConfigLayerEntry>,
|
||||
{
|
||||
for layer in layers {
|
||||
if let Some(contents) = layer.raw_toml() {
|
||||
let source_name = display_name_for_layer(layer, config_toml_file);
|
||||
let Some(base_dir) = layer.raw_toml_base_dir() else {
|
||||
tracing::debug!(
|
||||
"Skipping raw TOML diagnostics for {source_name} because it has no base directory"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
// Match the base directory used when the raw non-file layer was
|
||||
// parsed into the runtime layer so diagnostics resolve relative
|
||||
// path fields with the same semantics.
|
||||
let _absolute_path_base = AbsolutePathBufGuard::new(base_dir.as_path());
|
||||
if let Some(error) = config_error_from_typed_toml_for_source::<T>(
|
||||
ConfigDiagnosticSource::DisplayName(&source_name),
|
||||
contents,
|
||||
) {
|
||||
return Some(error);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(path) = config_path_for_layer(layer, config_toml_file) else {
|
||||
continue;
|
||||
};
|
||||
@@ -235,11 +254,32 @@ 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::SessionFlags
|
||||
| ConfigLayerSource::LegacyManagedConfigTomlFromMdm => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn display_name_for_layer(layer: &ConfigLayerEntry, config_toml_file: &str) -> String {
|
||||
match &layer.name {
|
||||
ConfigLayerSource::EnterpriseManaged { id, name } => {
|
||||
format!("enterprise-managed config {name} ({id})")
|
||||
}
|
||||
ConfigLayerSource::Mdm { domain, key } => format!("managed policy {domain}:{key}"),
|
||||
ConfigLayerSource::SessionFlags => "session flags".to_string(),
|
||||
ConfigLayerSource::LegacyManagedConfigTomlFromMdm => {
|
||||
"legacy managed configuration from MDM".to_string()
|
||||
}
|
||||
ConfigLayerSource::System { file }
|
||||
| ConfigLayerSource::User { file, .. }
|
||||
| ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => file.display().to_string(),
|
||||
ConfigLayerSource::Project { dot_codex_folder } => dot_codex_folder
|
||||
.join(config_toml_file)
|
||||
.display()
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn text_range_from_span(contents: &str, span: std::ops::Range<usize>) -> TextRange {
|
||||
let start = position_for_offset(contents, span.start);
|
||||
let end_index = if span.end > span.start {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod cloud_config_layers;
|
||||
mod cloud_requirements;
|
||||
mod cloud_requirements_composition;
|
||||
mod config_requirements;
|
||||
@@ -29,6 +30,10 @@ pub mod types;
|
||||
|
||||
pub const CONFIG_TOML_FILE: &str = "config.toml";
|
||||
|
||||
pub use cloud_config_layers::CloudConfigFragment;
|
||||
pub use cloud_config_layers::CloudConfigFragmentSource;
|
||||
pub use cloud_config_layers::CloudConfigLayerError;
|
||||
pub use cloud_config_layers::cloud_config_layers_from_fragments;
|
||||
pub use cloud_requirements::CloudRequirementsLoadError;
|
||||
pub use cloud_requirements::CloudRequirementsLoadErrorCode;
|
||||
pub use cloud_requirements::CloudRequirementsLoader;
|
||||
|
||||
@@ -366,13 +366,18 @@ pub async fn load_config_layers_state(
|
||||
// paths, starting with `./`, but a path starting with `~/` _is_ a
|
||||
// supported use case. Because resolve_relative_paths_in_config_toml()
|
||||
// relies on AbsolutePathBufGuard to resolve `~/`, we must supply a
|
||||
// value for base_dir, so codex_home is as good a value as any.
|
||||
let managed_config =
|
||||
resolve_relative_paths_in_config_toml(config.managed_config, codex_home)?;
|
||||
// value for base_dir. Preserve that same base on the layer so later
|
||||
// raw-TOML diagnostics parse with the same path semantics.
|
||||
let raw_toml_base_dir = AbsolutePathBuf::from_absolute_path(codex_home)?;
|
||||
let managed_config = resolve_relative_paths_in_config_toml(
|
||||
config.managed_config,
|
||||
raw_toml_base_dir.as_path(),
|
||||
)?;
|
||||
layers.push(ConfigLayerEntry::new_with_raw_toml(
|
||||
ConfigLayerSource::LegacyManagedConfigTomlFromMdm,
|
||||
managed_config,
|
||||
config.raw_toml,
|
||||
raw_toml_base_dir,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -97,33 +97,47 @@ impl LoaderOverrides {
|
||||
pub struct ConfigLayerEntry {
|
||||
pub name: ConfigLayerSource,
|
||||
pub config: TomlValue,
|
||||
pub raw_toml: Option<String>,
|
||||
pub version: String,
|
||||
pub disabled_reason: Option<String>,
|
||||
raw_toml: Option<RawTomlLayer>,
|
||||
hooks_config_folder_override: Option<AbsolutePathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
struct RawTomlLayer {
|
||||
contents: String,
|
||||
base_dir: AbsolutePathBuf,
|
||||
}
|
||||
|
||||
impl ConfigLayerEntry {
|
||||
pub fn new(name: ConfigLayerSource, config: TomlValue) -> Self {
|
||||
let version = version_for_toml(&config);
|
||||
Self {
|
||||
name,
|
||||
config,
|
||||
raw_toml: None,
|
||||
version,
|
||||
disabled_reason: None,
|
||||
raw_toml: None,
|
||||
hooks_config_folder_override: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_with_raw_toml(name: ConfigLayerSource, config: TomlValue, raw_toml: String) -> Self {
|
||||
pub fn new_with_raw_toml(
|
||||
name: ConfigLayerSource,
|
||||
config: TomlValue,
|
||||
raw_toml: String,
|
||||
raw_toml_base_dir: AbsolutePathBuf,
|
||||
) -> Self {
|
||||
let version = version_for_toml(&config);
|
||||
Self {
|
||||
name,
|
||||
config,
|
||||
raw_toml: Some(raw_toml),
|
||||
version,
|
||||
disabled_reason: None,
|
||||
raw_toml: Some(RawTomlLayer {
|
||||
contents: raw_toml,
|
||||
base_dir: raw_toml_base_dir,
|
||||
}),
|
||||
hooks_config_folder_override: None,
|
||||
}
|
||||
}
|
||||
@@ -137,9 +151,9 @@ impl ConfigLayerEntry {
|
||||
Self {
|
||||
name,
|
||||
config,
|
||||
raw_toml: None,
|
||||
version,
|
||||
disabled_reason: Some(disabled_reason.into()),
|
||||
raw_toml: None,
|
||||
hooks_config_folder_override: None,
|
||||
}
|
||||
}
|
||||
@@ -149,7 +163,13 @@ impl ConfigLayerEntry {
|
||||
}
|
||||
|
||||
pub fn raw_toml(&self) -> Option<&str> {
|
||||
self.raw_toml.as_deref()
|
||||
self.raw_toml
|
||||
.as_ref()
|
||||
.map(|raw_toml| raw_toml.contents.as_str())
|
||||
}
|
||||
|
||||
pub fn raw_toml_base_dir(&self) -> Option<&AbsolutePathBuf> {
|
||||
self.raw_toml.as_ref().map(|raw_toml| &raw_toml.base_dir)
|
||||
}
|
||||
|
||||
pub(crate) fn with_hooks_config_folder_override(
|
||||
@@ -181,6 +201,7 @@ impl ConfigLayerEntry {
|
||||
match &self.name {
|
||||
ConfigLayerSource::Mdm { .. } => None,
|
||||
ConfigLayerSource::System { file } => file.parent(),
|
||||
ConfigLayerSource::EnterpriseManaged { .. } => None,
|
||||
ConfigLayerSource::User { file, .. } => file.parent(),
|
||||
ConfigLayerSource::Project { dot_codex_folder } => Some(dot_codex_folder.clone()),
|
||||
ConfigLayerSource::SessionFlags => None,
|
||||
|
||||
@@ -339,6 +339,7 @@ fn skill_roots_from_layer_stack_inner(
|
||||
});
|
||||
}
|
||||
ConfigLayerSource::Mdm { .. }
|
||||
| ConfigLayerSource::EnterpriseManaged { .. }
|
||||
| ConfigLayerSource::SessionFlags
|
||||
| ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. }
|
||||
| ConfigLayerSource::LegacyManagedConfigTomlFromMdm => {}
|
||||
|
||||
@@ -701,6 +701,7 @@ fn hook_run_metric_tags(run: &HookRunSummary) -> [(&'static str, &'static str);
|
||||
HookSource::SessionFlags => "session_flags",
|
||||
HookSource::Plugin => "plugin",
|
||||
HookSource::CloudRequirements => "cloud_requirements",
|
||||
HookSource::CloudManagedConfig => "cloud_managed_config",
|
||||
HookSource::LegacyManagedConfigFile => "legacy_managed_config_file",
|
||||
HookSource::LegacyManagedConfigMdm => "legacy_managed_config_mdm",
|
||||
HookSource::Unknown => "unknown",
|
||||
|
||||
@@ -364,6 +364,9 @@ fn config_toml_source_path(layer: &ConfigLayerEntry) -> AbsolutePathBuf {
|
||||
ConfigLayerSource::Mdm { domain, key } => {
|
||||
synthetic_layer_path(&format!("<mdm:{domain}:{key}>/{CONFIG_TOML_FILE}"))
|
||||
}
|
||||
ConfigLayerSource::EnterpriseManaged { id, name } => synthetic_layer_path(&format!(
|
||||
"<enterprise-managed:{name}:{id}>/{CONFIG_TOML_FILE}"
|
||||
)),
|
||||
ConfigLayerSource::LegacyManagedConfigTomlFromMdm => {
|
||||
synthetic_layer_path("<legacy-managed-config.toml-mdm>/managed_config.toml")
|
||||
}
|
||||
@@ -589,6 +592,7 @@ fn hook_metadata_for_config_layer_source(source: &ConfigLayerSource) -> (HookSou
|
||||
ConfigLayerSource::User { .. } => (HookSource::User, false),
|
||||
ConfigLayerSource::Project { .. } => (HookSource::Project, false),
|
||||
ConfigLayerSource::Mdm { .. } => (HookSource::Mdm, true),
|
||||
ConfigLayerSource::EnterpriseManaged { .. } => (HookSource::CloudManagedConfig, true),
|
||||
ConfigLayerSource::SessionFlags => (HookSource::SessionFlags, false),
|
||||
ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => {
|
||||
(HookSource::LegacyManagedConfigFile, true)
|
||||
@@ -994,6 +998,13 @@ mod tests {
|
||||
}),
|
||||
(HookSource::Mdm, true),
|
||||
);
|
||||
assert_eq!(
|
||||
super::hook_metadata_for_config_layer_source(&ConfigLayerSource::EnterpriseManaged {
|
||||
id: "cfg_123".to_string(),
|
||||
name: "Base policy".to_string(),
|
||||
}),
|
||||
(HookSource::CloudManagedConfig, true),
|
||||
);
|
||||
assert_eq!(
|
||||
super::hook_metadata_for_config_layer_source(&ConfigLayerSource::SessionFlags),
|
||||
(HookSource::SessionFlags, false),
|
||||
|
||||
@@ -1374,6 +1374,7 @@ pub enum HookSource {
|
||||
SessionFlags,
|
||||
Plugin,
|
||||
CloudRequirements,
|
||||
CloudManagedConfig,
|
||||
LegacyManagedConfigFile,
|
||||
LegacyManagedConfigMdm,
|
||||
#[default]
|
||||
|
||||
@@ -778,6 +778,7 @@ fn detail_source_value(hook: &HookMetadata) -> String {
|
||||
HookSource::System
|
||||
| HookSource::Mdm
|
||||
| HookSource::CloudRequirements
|
||||
| HookSource::CloudManagedConfig
|
||||
| HookSource::LegacyManagedConfigFile
|
||||
| HookSource::LegacyManagedConfigMdm => config_source_label(hook.source).to_string(),
|
||||
_ => format!(
|
||||
@@ -797,6 +798,7 @@ fn config_source_label(source: HookSource) -> &'static str {
|
||||
HookSource::SessionFlags => "Session flags",
|
||||
HookSource::Plugin => unreachable!("plugin hooks are handled by summary_source"),
|
||||
HookSource::CloudRequirements => "Admin config",
|
||||
HookSource::CloudManagedConfig => "Cloud-managed config",
|
||||
HookSource::LegacyManagedConfigFile => "Admin config",
|
||||
HookSource::LegacyManagedConfigMdm => "Admin config",
|
||||
HookSource::Unknown => "Unknown source",
|
||||
|
||||
@@ -267,9 +267,9 @@ fn render_debug_config_lines(stack: &ConfigLayerStack) -> Vec<Line<'static>> {
|
||||
fn render_non_file_layer_details(layer: &ConfigLayerEntry) -> Vec<Line<'static>> {
|
||||
match &layer.name {
|
||||
ConfigLayerSource::SessionFlags => render_session_flag_details(&layer.config),
|
||||
ConfigLayerSource::Mdm { .. } | ConfigLayerSource::LegacyManagedConfigTomlFromMdm => {
|
||||
render_mdm_layer_details(layer)
|
||||
}
|
||||
ConfigLayerSource::Mdm { .. }
|
||||
| ConfigLayerSource::EnterpriseManaged { .. }
|
||||
| ConfigLayerSource::LegacyManagedConfigTomlFromMdm => render_non_file_layer_value(layer),
|
||||
ConfigLayerSource::System { .. }
|
||||
| ConfigLayerSource::User { .. }
|
||||
| ConfigLayerSource::Project { .. }
|
||||
@@ -308,21 +308,36 @@ fn format_managed_hooks_requirements(hooks: &ManagedHooksRequirementsToml) -> St
|
||||
join_or_empty(parts)
|
||||
}
|
||||
|
||||
fn render_mdm_layer_details(layer: &ConfigLayerEntry) -> Vec<Line<'static>> {
|
||||
fn render_non_file_layer_value(layer: &ConfigLayerEntry) -> Vec<Line<'static>> {
|
||||
let label = non_file_layer_value_label(&layer.name);
|
||||
let value = layer
|
||||
.raw_toml()
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| format_toml_value(&layer.config));
|
||||
if value.is_empty() {
|
||||
return vec![" MDM value: <empty>".dim().into()];
|
||||
return vec![format!(" {label}: <empty>").dim().into()];
|
||||
}
|
||||
|
||||
if value.contains('\n') {
|
||||
let mut lines = vec![" MDM value:".into()];
|
||||
let mut lines = vec![format!(" {label}:").into()];
|
||||
lines.extend(value.lines().map(|line| format!(" {line}").into()));
|
||||
lines
|
||||
} else {
|
||||
vec![format!(" MDM value: {value}").into()]
|
||||
vec![format!(" {label}: {value}").into()]
|
||||
}
|
||||
}
|
||||
|
||||
fn non_file_layer_value_label(source: &ConfigLayerSource) -> &'static str {
|
||||
match source {
|
||||
ConfigLayerSource::Mdm { .. } | ConfigLayerSource::LegacyManagedConfigTomlFromMdm => {
|
||||
"MDM value"
|
||||
}
|
||||
ConfigLayerSource::EnterpriseManaged { .. } => "Enterprise-managed config value",
|
||||
ConfigLayerSource::SessionFlags
|
||||
| ConfigLayerSource::System { .. }
|
||||
| ConfigLayerSource::User { .. }
|
||||
| ConfigLayerSource::Project { .. }
|
||||
| ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => "Layer value",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,6 +411,9 @@ fn format_config_layer_source(source: &ConfigLayerSource) -> String {
|
||||
ConfigLayerSource::System { file } => {
|
||||
format!("system ({})", file.as_path().display())
|
||||
}
|
||||
ConfigLayerSource::EnterpriseManaged { id, name } => {
|
||||
format!("enterprise-managed ({name}, {id})")
|
||||
}
|
||||
ConfigLayerSource::User { file, .. } => {
|
||||
format!("user ({})", file.as_path().display())
|
||||
}
|
||||
@@ -896,12 +914,18 @@ model = "managed_model"
|
||||
approval_policy = "never"
|
||||
"#;
|
||||
let mdm_value = toml::from_str::<TomlValue>(raw_mdm_toml).expect("MDM value");
|
||||
let mdm_base_dir = if cfg!(windows) {
|
||||
absolute_path("C:\\codex")
|
||||
} else {
|
||||
absolute_path("/var/lib/codex")
|
||||
};
|
||||
|
||||
let stack = ConfigLayerStack::new(
|
||||
vec![ConfigLayerEntry::new_with_raw_toml(
|
||||
ConfigLayerSource::LegacyManagedConfigTomlFromMdm,
|
||||
mdm_value,
|
||||
raw_mdm_toml.to_string(),
|
||||
mdm_base_dir,
|
||||
)],
|
||||
ConfigRequirements::default(),
|
||||
ConfigRequirementsToml::default(),
|
||||
@@ -916,6 +940,44 @@ approval_policy = "never"
|
||||
assert!(rendered.contains("approval_policy = \"never\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_config_output_shows_enterprise_managed_layer_value() {
|
||||
let raw_cloud_toml = r#"
|
||||
# managed by cloud
|
||||
model = "enterprise_model"
|
||||
approval_policy = "never"
|
||||
"#;
|
||||
let cloud_value = toml::from_str::<TomlValue>(raw_cloud_toml).expect("cloud value");
|
||||
let cloud_base_dir = if cfg!(windows) {
|
||||
absolute_path("C:\\codex")
|
||||
} else {
|
||||
absolute_path("/var/lib/codex")
|
||||
};
|
||||
|
||||
let stack = ConfigLayerStack::new(
|
||||
vec![ConfigLayerEntry::new_with_raw_toml(
|
||||
ConfigLayerSource::EnterpriseManaged {
|
||||
id: "cfg_123".to_string(),
|
||||
name: "Base policy".to_string(),
|
||||
},
|
||||
cloud_value,
|
||||
raw_cloud_toml.to_string(),
|
||||
cloud_base_dir,
|
||||
)],
|
||||
ConfigRequirements::default(),
|
||||
ConfigRequirementsToml::default(),
|
||||
)
|
||||
.expect("config layer stack");
|
||||
|
||||
let rendered = render_to_text(&render_debug_config_lines(&stack));
|
||||
assert!(rendered.contains("enterprise-managed (Base policy, cfg_123) (enabled)"));
|
||||
assert!(rendered.contains("Enterprise-managed config value:"));
|
||||
assert!(!rendered.contains("MDM value:"));
|
||||
assert!(rendered.contains("# managed by cloud"));
|
||||
assert!(rendered.contains("model = \"enterprise_model\""));
|
||||
assert!(rendered.contains("approval_policy = \"never\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_config_output_normalizes_empty_web_search_mode_list() {
|
||||
let requirements = ConfigRequirements {
|
||||
|
||||
Reference in New Issue
Block a user