mirror of
https://github.com/openai/codex.git
synced 2026-05-29 15:30:22 +00:00
## Why The TUI can run against a remote app server, but several high-traffic settings still persisted by editing the local config file. That sends remote sessions' preference writes to the wrong machine and lets local disk state drift from the app-server-owned config. This is **[1 of 4]** in a stacked series that moves TUI-owned config mutations onto app-server APIs. ## What changed - Added a small TUI helper for typed app-server config writes. - Routed primary interactive preference writes through `config/batchWrite`. - Preserved existing profile scoping for settings that already support `profiles.<profile>.*` overrides. ## Config keys affected - `model` - `model_reasoning_effort` - `personality` - `service_tier` - `plan_mode_reasoning_effort` - `approvals_reviewer` - `notice.fast_default_opt_out` - Profile-scoped equivalents under `profiles.<profile>.*` ## Suggested manual validation - Connect the TUI to a remote app server, change `model` and `model_reasoning_effort`, reconnect, and confirm the remote config retained both values while the local `config.toml` did not change. - Change `personality`, `plan_mode_reasoning_effort`, and the explicit auto-review selection, then reconnect and confirm those choices persist through the app server. - Clear the service tier back to default and confirm `service_tier` is cleared while `notice.fast_default_opt_out = true` is persisted remotely. - Repeat one setting change with an active profile and confirm the write lands under `profiles.<profile>.*`. ## Stack 1. [#22913](https://github.com/openai/codex/pull/22913) `[1 of 4]` primary settings writes 2. [#22914](https://github.com/openai/codex/pull/22914) `[2 of 4]` app and skill enablement 3. [#22915](https://github.com/openai/codex/pull/22915) `[3 of 4]` feature and memory toggles 4. [#22916](https://github.com/openai/codex/pull/22916) `[4 of 4]` startup and onboarding bookkeeping
691 lines
23 KiB
Rust
691 lines
23 KiB
Rust
use crate::config_manager::ConfigManager;
|
|
use codex_app_server_protocol::Config as ApiConfig;
|
|
use codex_app_server_protocol::ConfigBatchWriteParams;
|
|
use codex_app_server_protocol::ConfigLayerMetadata;
|
|
use codex_app_server_protocol::ConfigLayerSource;
|
|
use codex_app_server_protocol::ConfigReadParams;
|
|
use codex_app_server_protocol::ConfigReadResponse;
|
|
use codex_app_server_protocol::ConfigValueWriteParams;
|
|
use codex_app_server_protocol::ConfigWriteErrorCode;
|
|
use codex_app_server_protocol::ConfigWriteResponse;
|
|
use codex_app_server_protocol::MergeStrategy;
|
|
use codex_app_server_protocol::OverriddenMetadata;
|
|
use codex_app_server_protocol::WriteStatus;
|
|
use codex_config::CONFIG_TOML_FILE;
|
|
use codex_config::ConfigLayerEntry;
|
|
use codex_config::ConfigLayerStack;
|
|
use codex_config::ConfigLayerStackOrdering;
|
|
use codex_config::ConfigRequirementsToml;
|
|
use codex_config::config_toml::ConfigToml;
|
|
use codex_config::merge_toml_values;
|
|
use codex_core::config::deserialize_config_toml_with_base;
|
|
use codex_core::config::edit::ConfigEdit;
|
|
use codex_core::config::edit::ConfigEditsBuilder;
|
|
use codex_core::config::validate_feature_requirements_for_config_toml;
|
|
use codex_core::path_utils;
|
|
use codex_core::path_utils::SymlinkWritePaths;
|
|
use codex_core::path_utils::resolve_symlink_write_paths;
|
|
use codex_core::path_utils::write_atomically;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use serde_json::Value as JsonValue;
|
|
use std::borrow::Cow;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use thiserror::Error;
|
|
use tokio::task;
|
|
use toml::Value as TomlValue;
|
|
use toml_edit::Item as TomlItem;
|
|
|
|
#[derive(Debug, Error)]
|
|
pub(crate) enum ConfigManagerError {
|
|
#[error("{message}")]
|
|
Write {
|
|
code: ConfigWriteErrorCode,
|
|
message: String,
|
|
},
|
|
|
|
#[error("{context}: {source}")]
|
|
Io {
|
|
context: &'static str,
|
|
#[source]
|
|
source: std::io::Error,
|
|
},
|
|
|
|
#[error("{context}: {source}")]
|
|
Json {
|
|
context: &'static str,
|
|
#[source]
|
|
source: serde_json::Error,
|
|
},
|
|
|
|
#[error("{context}: {source}")]
|
|
Toml {
|
|
context: &'static str,
|
|
#[source]
|
|
source: toml::de::Error,
|
|
},
|
|
|
|
#[error("{context}: {source}")]
|
|
Anyhow {
|
|
context: &'static str,
|
|
#[source]
|
|
source: anyhow::Error,
|
|
},
|
|
}
|
|
|
|
impl ConfigManagerError {
|
|
fn write(code: ConfigWriteErrorCode, message: impl Into<String>) -> Self {
|
|
Self::Write {
|
|
code,
|
|
message: message.into(),
|
|
}
|
|
}
|
|
|
|
fn io(context: &'static str, source: std::io::Error) -> Self {
|
|
Self::Io { context, source }
|
|
}
|
|
|
|
fn json(context: &'static str, source: serde_json::Error) -> Self {
|
|
Self::Json { context, source }
|
|
}
|
|
|
|
fn toml(context: &'static str, source: toml::de::Error) -> Self {
|
|
Self::Toml { context, source }
|
|
}
|
|
|
|
fn anyhow(context: &'static str, source: anyhow::Error) -> Self {
|
|
Self::Anyhow { context, source }
|
|
}
|
|
|
|
pub(crate) fn write_error_code(&self) -> Option<ConfigWriteErrorCode> {
|
|
match self {
|
|
Self::Write { code, .. } => Some(code.clone()),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ConfigManager {
|
|
pub(crate) async fn read(
|
|
&self,
|
|
params: ConfigReadParams,
|
|
) -> Result<ConfigReadResponse, ConfigManagerError> {
|
|
let layers = match params.cwd.as_deref() {
|
|
Some(cwd) => {
|
|
let cwd = AbsolutePathBuf::try_from(PathBuf::from(cwd)).map_err(|err| {
|
|
ConfigManagerError::io("failed to resolve config cwd to an absolute path", err)
|
|
})?;
|
|
self.load_config_layers(Some(cwd)).await.map_err(|err| {
|
|
ConfigManagerError::io("failed to read configuration layers", err)
|
|
})?
|
|
}
|
|
None => self.load_thread_agnostic_config().await.map_err(|err| {
|
|
ConfigManagerError::io("failed to read configuration layers", err)
|
|
})?,
|
|
};
|
|
|
|
let effective = layers.effective_config();
|
|
|
|
let effective_config_toml: ConfigToml = effective
|
|
.try_into()
|
|
.map_err(|err| ConfigManagerError::toml("invalid configuration", err))?;
|
|
|
|
let json_value = serde_json::to_value(&effective_config_toml)
|
|
.map_err(|err| ConfigManagerError::json("failed to serialize configuration", err))?;
|
|
let config: ApiConfig = serde_json::from_value(json_value)
|
|
.map_err(|err| ConfigManagerError::json("failed to deserialize configuration", err))?;
|
|
|
|
Ok(ConfigReadResponse {
|
|
config,
|
|
origins: layers.origins(),
|
|
layers: params.include_layers.then(|| {
|
|
layers
|
|
.get_layers(
|
|
ConfigLayerStackOrdering::HighestPrecedenceFirst,
|
|
/*include_disabled*/ true,
|
|
)
|
|
.iter()
|
|
.map(|layer| layer.as_layer())
|
|
.collect()
|
|
}),
|
|
})
|
|
}
|
|
|
|
pub(crate) async fn read_requirements(
|
|
&self,
|
|
) -> Result<Option<ConfigRequirementsToml>, ConfigManagerError> {
|
|
let layers = self
|
|
.load_thread_agnostic_config()
|
|
.await
|
|
.map_err(|err| ConfigManagerError::io("failed to read configuration layers", err))?;
|
|
|
|
let requirements = layers.requirements_toml().clone();
|
|
if requirements.is_empty() {
|
|
Ok(None)
|
|
} else {
|
|
Ok(Some(requirements))
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn write_value(
|
|
&self,
|
|
params: ConfigValueWriteParams,
|
|
) -> Result<ConfigWriteResponse, ConfigManagerError> {
|
|
let edits = vec![(params.key_path, params.value, params.merge_strategy)];
|
|
self.apply_edits(params.file_path, params.expected_version, edits)
|
|
.await
|
|
}
|
|
|
|
pub(crate) async fn batch_write(
|
|
&self,
|
|
params: ConfigBatchWriteParams,
|
|
) -> Result<ConfigWriteResponse, ConfigManagerError> {
|
|
let edits = params
|
|
.edits
|
|
.into_iter()
|
|
.map(|edit| (edit.key_path, edit.value, edit.merge_strategy))
|
|
.collect();
|
|
|
|
self.apply_edits(params.file_path, params.expected_version, edits)
|
|
.await
|
|
}
|
|
|
|
async fn apply_edits(
|
|
&self,
|
|
file_path: Option<String>,
|
|
expected_version: Option<String>,
|
|
edits: Vec<(String, JsonValue, MergeStrategy)>,
|
|
) -> Result<ConfigWriteResponse, ConfigManagerError> {
|
|
let allowed_path = self
|
|
.user_config_path()
|
|
.map_err(|err| ConfigManagerError::io("failed to resolve user config path", err))?;
|
|
let provided_path = match file_path {
|
|
Some(path) => AbsolutePathBuf::from_absolute_path(PathBuf::from(path))
|
|
.map_err(|err| ConfigManagerError::io("failed to resolve user config path", err))?,
|
|
None => allowed_path.clone(),
|
|
};
|
|
|
|
if !paths_match(&allowed_path, &provided_path) {
|
|
return Err(ConfigManagerError::write(
|
|
ConfigWriteErrorCode::ConfigLayerReadonly,
|
|
"Only writes to the user config are allowed",
|
|
));
|
|
}
|
|
|
|
let layers = self
|
|
.load_thread_agnostic_config()
|
|
.await
|
|
.map_err(|err| ConfigManagerError::io("failed to load configuration", err))?;
|
|
let user_layer = match layers.get_active_user_layer() {
|
|
Some(layer) => Cow::Borrowed(layer),
|
|
None => Cow::Owned(create_empty_user_layer(&allowed_path).await?),
|
|
};
|
|
|
|
if let Some(expected) = expected_version.as_deref()
|
|
&& expected != user_layer.version
|
|
{
|
|
return Err(ConfigManagerError::write(
|
|
ConfigWriteErrorCode::ConfigVersionConflict,
|
|
"Configuration was modified since last read. Fetch latest version and retry.",
|
|
));
|
|
}
|
|
|
|
let mut user_config = user_layer.config.clone();
|
|
let mut parsed_segments = Vec::new();
|
|
let mut config_edits = Vec::new();
|
|
|
|
for (key_path, value, strategy) in edits.into_iter() {
|
|
let segments = parse_key_path(&key_path).map_err(|message| {
|
|
ConfigManagerError::write(ConfigWriteErrorCode::ConfigValidationError, message)
|
|
})?;
|
|
let original_value = value_at_path(&user_config, &segments).cloned();
|
|
let parsed_value = parse_value(value).map_err(|message| {
|
|
ConfigManagerError::write(ConfigWriteErrorCode::ConfigValidationError, message)
|
|
})?;
|
|
|
|
apply_merge(&mut user_config, &segments, parsed_value.as_ref(), strategy).map_err(
|
|
|err| match err {
|
|
MergeError::Validation(message) => ConfigManagerError::write(
|
|
ConfigWriteErrorCode::ConfigValidationError,
|
|
message,
|
|
),
|
|
},
|
|
)?;
|
|
|
|
let updated_value = value_at_path(&user_config, &segments).cloned();
|
|
if original_value != updated_value {
|
|
let edit = match updated_value {
|
|
Some(value) => ConfigEdit::SetPath {
|
|
segments: segments.clone(),
|
|
value: toml_value_to_item(&value).map_err(|err| {
|
|
ConfigManagerError::anyhow("failed to build config edits", err)
|
|
})?,
|
|
},
|
|
None => ConfigEdit::ClearPath {
|
|
segments: segments.clone(),
|
|
},
|
|
};
|
|
config_edits.push(edit);
|
|
}
|
|
|
|
parsed_segments.push(segments);
|
|
}
|
|
|
|
validate_config(&user_config).map_err(|err| {
|
|
ConfigManagerError::write(
|
|
ConfigWriteErrorCode::ConfigValidationError,
|
|
format!("Invalid configuration: {err}"),
|
|
)
|
|
})?;
|
|
let user_config_toml =
|
|
deserialize_config_toml_with_base(user_config.clone(), self.codex_home()).map_err(
|
|
|err| {
|
|
ConfigManagerError::write(
|
|
ConfigWriteErrorCode::ConfigValidationError,
|
|
format!("Invalid configuration: {err}"),
|
|
)
|
|
},
|
|
)?;
|
|
validate_feature_requirements_for_config_toml(
|
|
&user_config_toml,
|
|
layers.requirements().feature_requirements.as_ref(),
|
|
)
|
|
.map_err(|err| {
|
|
ConfigManagerError::write(
|
|
ConfigWriteErrorCode::ConfigValidationError,
|
|
format!("Invalid configuration: {err}"),
|
|
)
|
|
})?;
|
|
let updated_layers = layers.with_user_config(&provided_path, user_config.clone());
|
|
let effective = updated_layers.effective_config();
|
|
validate_config(&effective).map_err(|err| {
|
|
ConfigManagerError::write(
|
|
ConfigWriteErrorCode::ConfigValidationError,
|
|
format!("Invalid configuration: {err}"),
|
|
)
|
|
})?;
|
|
|
|
if !config_edits.is_empty() {
|
|
ConfigEditsBuilder::for_config_path(provided_path.as_path())
|
|
.with_edits(config_edits)
|
|
.apply()
|
|
.await
|
|
.map_err(|err| ConfigManagerError::anyhow("failed to persist config.toml", err))?;
|
|
}
|
|
|
|
let overridden = first_overridden_edit(&updated_layers, &effective, &parsed_segments);
|
|
let status = overridden
|
|
.as_ref()
|
|
.map(|_| WriteStatus::OkOverridden)
|
|
.unwrap_or(WriteStatus::Ok);
|
|
|
|
Ok(ConfigWriteResponse {
|
|
status,
|
|
version: updated_layers
|
|
.get_active_user_layer()
|
|
.ok_or_else(|| {
|
|
ConfigManagerError::write(
|
|
ConfigWriteErrorCode::UserLayerNotFound,
|
|
"user layer not found in updated layers",
|
|
)
|
|
})?
|
|
.version
|
|
.clone(),
|
|
file_path: provided_path,
|
|
overridden_metadata: overridden,
|
|
})
|
|
}
|
|
|
|
/// Loads a "thread-agnostic" config, which means the config layers do not
|
|
/// include any in-repo .codex/ folders because there is no cwd/project root
|
|
/// associated with this query.
|
|
async fn load_thread_agnostic_config(&self) -> std::io::Result<ConfigLayerStack> {
|
|
self.load_config_layers(/*cwd*/ None).await
|
|
}
|
|
}
|
|
|
|
async fn create_empty_user_layer(
|
|
config_toml: &AbsolutePathBuf,
|
|
) -> Result<ConfigLayerEntry, ConfigManagerError> {
|
|
let SymlinkWritePaths {
|
|
read_path,
|
|
write_path,
|
|
} = resolve_symlink_write_paths(config_toml.as_path())
|
|
.map_err(|err| ConfigManagerError::io("failed to resolve user config path", err))?;
|
|
let toml_value = match read_path {
|
|
Some(path) => match tokio::fs::read_to_string(&path).await {
|
|
Ok(contents) => toml::from_str(&contents).map_err(|e| {
|
|
ConfigManagerError::toml("failed to parse existing user config.toml", e)
|
|
})?,
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
|
write_empty_user_config(write_path.clone()).await?;
|
|
TomlValue::Table(toml::map::Map::new())
|
|
}
|
|
Err(err) => {
|
|
return Err(ConfigManagerError::io(
|
|
"failed to read user config.toml",
|
|
err,
|
|
));
|
|
}
|
|
},
|
|
None => {
|
|
write_empty_user_config(write_path).await?;
|
|
TomlValue::Table(toml::map::Map::new())
|
|
}
|
|
};
|
|
Ok(ConfigLayerEntry::new(
|
|
ConfigLayerSource::User {
|
|
file: config_toml.clone(),
|
|
profile: None,
|
|
},
|
|
toml_value,
|
|
))
|
|
}
|
|
|
|
async fn write_empty_user_config(write_path: PathBuf) -> Result<(), ConfigManagerError> {
|
|
task::spawn_blocking(move || write_atomically(&write_path, ""))
|
|
.await
|
|
.map_err(|err| ConfigManagerError::anyhow("config persistence task panicked", err.into()))?
|
|
.map_err(|err| ConfigManagerError::io("failed to create empty user config.toml", err))
|
|
}
|
|
|
|
fn parse_value(value: JsonValue) -> Result<Option<TomlValue>, String> {
|
|
if value.is_null() {
|
|
return Ok(None);
|
|
}
|
|
|
|
serde_json::from_value::<TomlValue>(value)
|
|
.map(Some)
|
|
.map_err(|err| format!("invalid value: {err}"))
|
|
}
|
|
|
|
fn parse_key_path(path: &str) -> Result<Vec<String>, String> {
|
|
if path.trim().is_empty() {
|
|
return Err("keyPath must not be empty".to_string());
|
|
}
|
|
|
|
let mut segments = Vec::new();
|
|
let mut segment = String::new();
|
|
let mut chars = path.chars();
|
|
let mut quoted = false;
|
|
|
|
// Split on dots unless they appear inside a quoted segment. Bare segments
|
|
// intentionally stay permissive so existing paths like `sample@catalog`
|
|
// remain valid.
|
|
while let Some(ch) = chars.next() {
|
|
match ch {
|
|
'"' if segment.is_empty() && !quoted => quoted = true,
|
|
'"' if quoted => quoted = false,
|
|
'\\' if quoted => {
|
|
// Quoted segments may escape punctuation that would otherwise
|
|
// participate in parsing, such as `.` or `"`.
|
|
let Some(escaped) = chars.next() else {
|
|
return Err("unterminated escape in keyPath".to_string());
|
|
};
|
|
segment.push(escaped);
|
|
}
|
|
'.' if !quoted => {
|
|
if segment.is_empty() {
|
|
return Err("keyPath segments must not be empty".to_string());
|
|
}
|
|
segments.push(std::mem::take(&mut segment));
|
|
}
|
|
'"' => return Err("invalid quoted keyPath segment".to_string()),
|
|
_ => segment.push(ch),
|
|
}
|
|
}
|
|
|
|
if quoted {
|
|
return Err("unterminated quoted keyPath segment".to_string());
|
|
}
|
|
if segment.is_empty() {
|
|
return Err("keyPath segments must not be empty".to_string());
|
|
}
|
|
|
|
segments.push(segment);
|
|
Ok(segments)
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
enum MergeError {
|
|
Validation(String),
|
|
}
|
|
|
|
fn apply_merge(
|
|
root: &mut TomlValue,
|
|
segments: &[String],
|
|
value: Option<&TomlValue>,
|
|
strategy: MergeStrategy,
|
|
) -> Result<bool, MergeError> {
|
|
let Some(value) = value else {
|
|
return clear_path(root, segments);
|
|
};
|
|
|
|
let Some((last, parents)) = segments.split_last() else {
|
|
return Err(MergeError::Validation(
|
|
"keyPath must not be empty".to_string(),
|
|
));
|
|
};
|
|
|
|
let mut current = root;
|
|
|
|
for segment in parents {
|
|
match current {
|
|
TomlValue::Table(table) => {
|
|
current = table
|
|
.entry(segment.clone())
|
|
.or_insert_with(|| TomlValue::Table(toml::map::Map::new()));
|
|
}
|
|
_ => {
|
|
*current = TomlValue::Table(toml::map::Map::new());
|
|
if let TomlValue::Table(table) = current {
|
|
current = table
|
|
.entry(segment.clone())
|
|
.or_insert_with(|| TomlValue::Table(toml::map::Map::new()));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let table = current.as_table_mut().ok_or_else(|| {
|
|
MergeError::Validation("cannot set value on non-table parent".to_string())
|
|
})?;
|
|
|
|
if matches!(strategy, MergeStrategy::Upsert)
|
|
&& let Some(existing) = table.get_mut(last)
|
|
&& matches!(existing, TomlValue::Table(_))
|
|
&& matches!(value, TomlValue::Table(_))
|
|
{
|
|
merge_toml_values(existing, value);
|
|
return Ok(true);
|
|
}
|
|
|
|
let changed = table
|
|
.get(last)
|
|
.map(|existing| Some(existing) != Some(value))
|
|
.unwrap_or(true);
|
|
table.insert(last.clone(), value.clone());
|
|
Ok(changed)
|
|
}
|
|
|
|
fn clear_path(root: &mut TomlValue, segments: &[String]) -> Result<bool, MergeError> {
|
|
let Some((last, parents)) = segments.split_last() else {
|
|
return Err(MergeError::Validation(
|
|
"keyPath must not be empty".to_string(),
|
|
));
|
|
};
|
|
|
|
let mut current = root;
|
|
for segment in parents {
|
|
match current {
|
|
TomlValue::Table(table) => {
|
|
let Some(next) = table.get_mut(segment) else {
|
|
return Ok(false);
|
|
};
|
|
current = next;
|
|
}
|
|
_ => return Ok(false),
|
|
}
|
|
}
|
|
|
|
let Some(parent) = current.as_table_mut() else {
|
|
return Ok(false);
|
|
};
|
|
|
|
Ok(parent.remove(last).is_some())
|
|
}
|
|
|
|
fn toml_value_to_item(value: &TomlValue) -> anyhow::Result<TomlItem> {
|
|
match value {
|
|
TomlValue::Table(table) => {
|
|
let mut table_item = toml_edit::Table::new();
|
|
table_item.set_implicit(false);
|
|
for (key, val) in table {
|
|
table_item.insert(key, toml_value_to_item(val)?);
|
|
}
|
|
Ok(TomlItem::Table(table_item))
|
|
}
|
|
other => Ok(TomlItem::Value(toml_value_to_value(other)?)),
|
|
}
|
|
}
|
|
|
|
fn toml_value_to_value(value: &TomlValue) -> anyhow::Result<toml_edit::Value> {
|
|
match value {
|
|
TomlValue::String(val) => Ok(toml_edit::Value::from(val.clone())),
|
|
TomlValue::Integer(val) => Ok(toml_edit::Value::from(*val)),
|
|
TomlValue::Float(val) => Ok(toml_edit::Value::from(*val)),
|
|
TomlValue::Boolean(val) => Ok(toml_edit::Value::from(*val)),
|
|
TomlValue::Datetime(val) => Ok(toml_edit::Value::from(*val)),
|
|
TomlValue::Array(items) => {
|
|
let mut array = toml_edit::Array::new();
|
|
for item in items {
|
|
array.push(toml_value_to_value(item)?);
|
|
}
|
|
Ok(toml_edit::Value::Array(array))
|
|
}
|
|
TomlValue::Table(table) => {
|
|
let mut inline = toml_edit::InlineTable::new();
|
|
for (key, val) in table {
|
|
inline.insert(key, toml_value_to_value(val)?);
|
|
}
|
|
Ok(toml_edit::Value::InlineTable(inline))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn validate_config(value: &TomlValue) -> Result<(), toml::de::Error> {
|
|
let _: ConfigToml = value.clone().try_into()?;
|
|
Ok(())
|
|
}
|
|
|
|
fn paths_match(expected: impl AsRef<Path>, provided: impl AsRef<Path>) -> bool {
|
|
path_utils::paths_match_after_normalization(expected, provided)
|
|
}
|
|
|
|
fn value_at_path<'a>(root: &'a TomlValue, segments: &[String]) -> Option<&'a TomlValue> {
|
|
let mut current = root;
|
|
for segment in segments {
|
|
match current {
|
|
TomlValue::Table(table) => {
|
|
current = table.get(segment)?;
|
|
}
|
|
TomlValue::Array(items) => {
|
|
let idx = segment.parse::<i64>().ok()?;
|
|
let idx = usize::try_from(idx).ok()?;
|
|
current = items.get(idx)?;
|
|
}
|
|
_ => return None,
|
|
}
|
|
}
|
|
Some(current)
|
|
}
|
|
|
|
fn override_message(layer: &ConfigLayerSource) -> String {
|
|
match layer {
|
|
ConfigLayerSource::Mdm { domain, key: _ } => {
|
|
format!("Overridden by managed policy (MDM): {domain}")
|
|
}
|
|
ConfigLayerSource::System { file } => {
|
|
format!("Overridden by managed config (system): {}", file.display())
|
|
}
|
|
ConfigLayerSource::Project { dot_codex_folder } => format!(
|
|
"Overridden by project config: {}/{CONFIG_TOML_FILE}",
|
|
dot_codex_folder.display(),
|
|
),
|
|
ConfigLayerSource::SessionFlags => "Overridden by session flags".to_string(),
|
|
ConfigLayerSource::User { file, .. } => {
|
|
format!("Overridden by user config: {}", file.display())
|
|
}
|
|
ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => {
|
|
format!(
|
|
"Overridden by legacy managed_config.toml: {}",
|
|
file.display()
|
|
)
|
|
}
|
|
ConfigLayerSource::LegacyManagedConfigTomlFromMdm => {
|
|
"Overridden by legacy managed configuration from MDM".to_string()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn compute_override_metadata(
|
|
layers: &ConfigLayerStack,
|
|
effective: &TomlValue,
|
|
segments: &[String],
|
|
) -> Option<OverriddenMetadata> {
|
|
let user_value = match layers.get_active_user_layer() {
|
|
Some(user_layer) => value_at_path(&user_layer.config, segments),
|
|
None => return None,
|
|
};
|
|
let effective_value = value_at_path(effective, segments);
|
|
|
|
if user_value.is_some() && user_value == effective_value {
|
|
return None;
|
|
}
|
|
|
|
if user_value.is_none() && effective_value.is_none() {
|
|
return None;
|
|
}
|
|
|
|
let overriding_layer = find_effective_layer(layers, segments)?;
|
|
let message = override_message(&overriding_layer.name);
|
|
|
|
Some(OverriddenMetadata {
|
|
message,
|
|
overriding_layer,
|
|
effective_value: effective_value
|
|
.and_then(|value| serde_json::to_value(value).ok())
|
|
.unwrap_or(JsonValue::Null),
|
|
})
|
|
}
|
|
|
|
fn first_overridden_edit(
|
|
layers: &ConfigLayerStack,
|
|
effective: &TomlValue,
|
|
edits: &[Vec<String>],
|
|
) -> Option<OverriddenMetadata> {
|
|
for segments in edits {
|
|
if let Some(meta) = compute_override_metadata(layers, effective, segments) {
|
|
return Some(meta);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn find_effective_layer(
|
|
layers: &ConfigLayerStack,
|
|
segments: &[String],
|
|
) -> Option<ConfigLayerMetadata> {
|
|
for layer in layers.layers_high_to_low() {
|
|
if let Some(meta) = value_at_path(&layer.config, segments).map(|_| layer.metadata()) {
|
|
return Some(meta);
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "config_manager_service_tests.rs"]
|
|
mod tests;
|