mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
968a3ac9c1
## Why `PathUri::from_abs_path` can fail for absolute paths that do not have a normal `file:` URI representation, forcing filesystem call sites to handle a conversion error even though the original path can be preserved losslessly. ## What Make `from_abs_path` infallible and migrate its callers. Unrepresentable paths use `file:///%00/bad/path/<base64>`, encoding Unix bytes or Windows UTF-16LE; `to_abs_path` validates and decodes that fallback. The leading encoded null reserves a namespace that cannot collide with a real Unix or Windows path, and fallback URIs remain opaque to lexical path operations. ## Validation Added path-URI coverage for Unix null and non-UTF-8 paths, Windows device/verbatim and non-Unicode paths, serialization, malformed fallbacks, opaque lexical operations, invalid native payloads, and literal `/bad/path` collision resistance.
1554 lines
56 KiB
Rust
1554 lines
56 KiB
Rust
mod layer_io;
|
|
#[cfg(target_os = "macos")]
|
|
mod macos;
|
|
#[cfg(test)]
|
|
mod tests;
|
|
|
|
use self::layer_io::LoadedConfigLayers;
|
|
use crate::CONFIG_TOML_FILE;
|
|
use crate::CloudConfigBundleLayers;
|
|
use crate::ProfileV2Name;
|
|
use crate::RequirementsLayerEntry;
|
|
use crate::compose_requirements;
|
|
use crate::config_requirements::RequirementSource;
|
|
use crate::config_requirements::SandboxModeRequirement;
|
|
use crate::config_toml::ConfigToml;
|
|
use crate::config_toml::ProjectConfig;
|
|
use crate::diagnostics::ConfigError;
|
|
use crate::diagnostics::config_error_from_toml;
|
|
use crate::diagnostics::first_layer_config_error_from_entries as typed_first_layer_config_error_from_entries;
|
|
use crate::diagnostics::io_error_from_config_error;
|
|
use crate::merge::merge_toml_values;
|
|
use crate::overrides::build_cli_overrides_layer;
|
|
use crate::project_root_markers::default_project_root_markers;
|
|
use crate::project_root_markers::project_root_markers_from_config;
|
|
use crate::state::ConfigLayerEntry;
|
|
use crate::state::ConfigLayerStack;
|
|
use crate::state::ConfigLoadOptions;
|
|
use crate::state::LoaderOverrides;
|
|
use crate::strict_config::config_error_from_ignored_toml_value_fields;
|
|
use crate::strict_config::ignored_toml_value_field;
|
|
use crate::strict_config::unknown_feature_toml_value_field;
|
|
use crate::thread_config::ThreadConfigContext;
|
|
use crate::thread_config::ThreadConfigLoader;
|
|
use codex_app_server_protocol::ConfigLayerSource;
|
|
use codex_file_system::ExecutorFileSystem;
|
|
use codex_git_utils::resolve_root_git_project_for_trust;
|
|
use codex_protocol::config_types::ApprovalsReviewer;
|
|
use codex_protocol::config_types::SandboxMode;
|
|
use codex_protocol::config_types::TrustLevel;
|
|
use codex_protocol::protocol::AskForApproval;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use codex_utils_absolute_path::AbsolutePathBufGuard;
|
|
use codex_utils_path_uri::PathUri;
|
|
use dunce::canonicalize as normalize_path;
|
|
use serde::Deserialize;
|
|
use std::io;
|
|
use std::path::Path;
|
|
#[cfg(windows)]
|
|
use std::path::PathBuf;
|
|
use toml::Value as TomlValue;
|
|
|
|
#[cfg(unix)]
|
|
const SYSTEM_CONFIG_TOML_FILE_UNIX: &str = "/etc/codex/config.toml";
|
|
|
|
#[cfg(windows)]
|
|
const DEFAULT_PROGRAM_DATA_DIR_WINDOWS: &str = r"C:\ProgramData";
|
|
|
|
// Project-local config comes from repository contents, so it should not get to
|
|
// choose where a user's credentials are sent or which local commands are run.
|
|
// These settings are still supported from user, system, managed, and runtime
|
|
// config layers.
|
|
const PROJECT_LOCAL_CONFIG_DENYLIST: &[&str] = &[
|
|
"openai_base_url",
|
|
"chatgpt_base_url",
|
|
"apps_mcp_product_sku",
|
|
"model_provider",
|
|
"model_providers",
|
|
"notify",
|
|
"profile",
|
|
"profiles",
|
|
"experimental_realtime_webrtc_call_base_url",
|
|
"experimental_realtime_ws_base_url",
|
|
"otel",
|
|
];
|
|
|
|
async fn first_layer_config_error_from_entries(layers: &[ConfigLayerEntry]) -> Option<ConfigError> {
|
|
typed_first_layer_config_error_from_entries::<ConfigToml>(layers, CONFIG_TOML_FILE).await
|
|
}
|
|
|
|
/// To build up the set of admin-enforced constraints, requirements layers are
|
|
/// collected in ascending precedence order, matching config layers, and then
|
|
/// composed with config-style TOML merging plus field-specific handling for
|
|
/// hooks, rules, deny-read permissions, and remote sandbox config:
|
|
///
|
|
/// - system `/etc/codex/requirements.toml` (Unix) or
|
|
/// `%ProgramData%\OpenAI\Codex\requirements.toml` (Windows)
|
|
/// - cloud: enterprise-managed cloud config bundle requirements
|
|
/// - legacy: managed_config.toml reinterpreted as requirements.toml
|
|
/// - admin: managed preferences (*)
|
|
///
|
|
/// For backwards compatibility, we also load from
|
|
/// `managed_config.toml` and map it to `requirements.toml`.
|
|
///
|
|
/// Configuration is built up from multiple layers in the following order:
|
|
///
|
|
/// - admin: managed preferences (*)
|
|
/// - system `/etc/codex/config.toml` (Unix) or
|
|
/// `%ProgramData%\OpenAI\Codex\config.toml` (Windows)
|
|
/// - cloud enterprise-managed cloud config bundle fragments
|
|
/// - user `${CODEX_HOME}/config.toml`
|
|
/// - profile `${CODEX_HOME}/<name>.config.toml`, when selected
|
|
/// - cwd `${PWD}/config.toml` (loaded but disabled when the directory is untrusted)
|
|
/// - tree parent directories up to root looking for `./.codex/config.toml` (loaded but disabled when untrusted)
|
|
/// - repo `$(git rev-parse --show-toplevel)/.codex/config.toml` (loaded but disabled when untrusted)
|
|
/// - runtime e.g., --config flags, model selector in UI
|
|
///
|
|
/// (*) Only available on macOS via managed device profiles.
|
|
///
|
|
/// See https://developers.openai.com/codex/security for details.
|
|
///
|
|
/// When loading the config stack for a thread, there should be a `cwd`
|
|
/// associated with it such that `cwd` should be `Some(...)`. Only for
|
|
/// thread-agnostic config loading (e.g., for the app server's `/config`
|
|
/// endpoint) should `cwd` be `None`.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn load_config_layers_state(
|
|
fs: &dyn ExecutorFileSystem,
|
|
codex_home: &Path,
|
|
cwd: Option<AbsolutePathBuf>,
|
|
cli_overrides: &[(String, TomlValue)],
|
|
options: impl Into<ConfigLoadOptions>,
|
|
thread_config_loader: &dyn ThreadConfigLoader,
|
|
) -> io::Result<ConfigLayerStack> {
|
|
let ConfigLoadOptions {
|
|
loader_overrides: overrides,
|
|
strict_config,
|
|
cloud_config_bundle,
|
|
} = options.into();
|
|
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;
|
|
let ignore_user_and_project_exec_policy_rules =
|
|
overrides.ignore_user_and_project_exec_policy_rules;
|
|
let mut requirements_layers = Vec::new();
|
|
let mut bundle_requirements_layers = Vec::new();
|
|
let mut system_requirements_layer = None;
|
|
let managed_preferences_requirements_layer;
|
|
let mut cloud_config_layers = Vec::new();
|
|
|
|
if !ignore_managed_requirements {
|
|
if let Some(bundle) = cloud_config_bundle.get().await.map_err(io::Error::other)? {
|
|
let cloud_config_base_dir = AbsolutePathBuf::from_absolute_path(codex_home)?;
|
|
let bundle_layers = if strict_config {
|
|
CloudConfigBundleLayers::from_bundle_strict_config(bundle, &cloud_config_base_dir)?
|
|
} else {
|
|
CloudConfigBundleLayers::from_bundle(bundle, &cloud_config_base_dir)?
|
|
};
|
|
let CloudConfigBundleLayers {
|
|
enterprise_managed_config,
|
|
enterprise_managed_requirements,
|
|
} = bundle_layers;
|
|
bundle_requirements_layers = enterprise_managed_requirements;
|
|
cloud_config_layers = enterprise_managed_config;
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
managed_preferences_requirements_layer = macos::load_managed_admin_requirements_layer(
|
|
overrides
|
|
.macos_managed_config_requirements_base64
|
|
.as_deref(),
|
|
)
|
|
.await?;
|
|
}
|
|
#[cfg(not(target_os = "macos"))]
|
|
{
|
|
managed_preferences_requirements_layer = None;
|
|
}
|
|
|
|
// Honor the system requirements.toml location.
|
|
let requirements_toml_file = system_requirements_toml_file_with_overrides(&overrides)?;
|
|
system_requirements_layer = load_requirements_toml(fs, &requirements_toml_file).await?;
|
|
} else {
|
|
managed_preferences_requirements_layer = None;
|
|
}
|
|
|
|
let loaded_config_layers =
|
|
layer_io::load_config_layers_internal(fs, codex_home, overrides.clone(), strict_config)
|
|
.await?;
|
|
if !ignore_managed_requirements {
|
|
requirements_layers.extend(system_requirements_layer);
|
|
requirements_layers.extend(bundle_requirements_layers);
|
|
// Continue to support the legacy `managed_config.toml` locations as
|
|
// requirements layers for backwards compatibility.
|
|
requirements_layers.extend(requirements_layers_from_legacy_scheme(
|
|
loaded_config_layers.clone(),
|
|
)?);
|
|
requirements_layers.extend(managed_preferences_requirements_layer);
|
|
}
|
|
|
|
let config_requirements_toml = compose_requirements(requirements_layers)?.unwrap_or_default();
|
|
|
|
let thread_config_context = ThreadConfigContext {
|
|
thread_id: None,
|
|
cwd: cwd.clone(),
|
|
};
|
|
let thread_config_layers = thread_config_loader
|
|
.load_config_layers(thread_config_context)
|
|
.await
|
|
.map_err(io::Error::other)?;
|
|
|
|
let mut layers = Vec::<ConfigLayerEntry>::new();
|
|
|
|
let cli_overrides_layer = if cli_overrides.is_empty() {
|
|
None
|
|
} else {
|
|
let cli_overrides_layer = build_cli_overrides_layer(cli_overrides);
|
|
let base_dir = cwd
|
|
.as_ref()
|
|
.map(AbsolutePathBuf::as_path)
|
|
.unwrap_or(codex_home);
|
|
if strict_config {
|
|
validate_cli_overrides_strictly(&cli_overrides_layer, base_dir)?;
|
|
}
|
|
Some(resolve_relative_paths_in_config_toml(
|
|
cli_overrides_layer,
|
|
base_dir,
|
|
)?)
|
|
};
|
|
|
|
// Include an entry for the "system" config folder, loading its config.toml,
|
|
// if it exists.
|
|
let system_config_toml_file = system_config_toml_file_with_overrides(&overrides)?;
|
|
let system_layer = load_config_toml_for_required_layer(
|
|
fs,
|
|
&system_config_toml_file,
|
|
strict_config,
|
|
|config_toml| {
|
|
ConfigLayerEntry::new(
|
|
ConfigLayerSource::System {
|
|
file: system_config_toml_file.clone(),
|
|
},
|
|
config_toml,
|
|
)
|
|
},
|
|
)
|
|
.await?;
|
|
layers.push(system_layer);
|
|
layers.extend(cloud_config_layers);
|
|
|
|
// Add the base user config layer. When profile-v2 is selected, add the
|
|
// profile config as a second user layer on top so the profile only needs to
|
|
// contain overrides.
|
|
let active_user_file = overrides.user_config_path(codex_home)?;
|
|
let base_user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home);
|
|
let base_user_layer = load_user_config_layer(
|
|
fs,
|
|
&base_user_file,
|
|
/*profile*/ None,
|
|
ignore_user_config,
|
|
strict_config,
|
|
)
|
|
.await?;
|
|
if let Some(active_user_profile) = active_user_profile.as_ref()
|
|
&& let Some(base_user_config) = base_user_layer.config.as_table()
|
|
{
|
|
let legacy_profile_is_selected = base_user_config
|
|
.get("profile")
|
|
.and_then(TomlValue::as_str)
|
|
.is_some_and(|profile| profile == active_user_profile.as_str());
|
|
let legacy_profile_table_exists = base_user_config
|
|
.get("profiles")
|
|
.and_then(TomlValue::as_table)
|
|
.is_some_and(|profiles| profiles.contains_key(active_user_profile.as_str()));
|
|
if legacy_profile_is_selected || legacy_profile_table_exists {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!(
|
|
"--profile `{active_user_profile}` cannot be used while {} contains legacy `profile = \"{active_user_profile}\"` or `[profiles.{active_user_profile}]` config; move those settings into {} and remove the legacy profile selector/table. See https://developers.openai.com/codex/config-advanced#profiles for more information.",
|
|
base_user_file.as_path().display(),
|
|
active_user_file.as_path().display()
|
|
),
|
|
));
|
|
}
|
|
}
|
|
layers.push(base_user_layer);
|
|
|
|
if active_user_file != base_user_file {
|
|
layers.push(
|
|
load_user_config_layer(
|
|
fs,
|
|
&active_user_file,
|
|
active_user_profile.as_ref(),
|
|
ignore_user_config,
|
|
strict_config,
|
|
)
|
|
.await?,
|
|
);
|
|
}
|
|
|
|
let mut startup_warnings = None;
|
|
if let Some(cwd) = cwd {
|
|
let mut merged_so_far = TomlValue::Table(toml::map::Map::new());
|
|
for layer in &layers {
|
|
merge_toml_values(&mut merged_so_far, &layer.config);
|
|
}
|
|
if let Some(cli_overrides_layer) = cli_overrides_layer.as_ref() {
|
|
merge_toml_values(&mut merged_so_far, cli_overrides_layer);
|
|
}
|
|
|
|
let project_root_markers = match project_root_markers_from_config(&merged_so_far) {
|
|
Ok(markers) => markers.unwrap_or_else(default_project_root_markers),
|
|
Err(err) => {
|
|
if let Some(config_error) = first_layer_config_error_from_entries(&layers).await {
|
|
return Err(io_error_from_config_error(
|
|
io::ErrorKind::InvalidData,
|
|
config_error,
|
|
/*source*/ None,
|
|
));
|
|
}
|
|
return Err(err);
|
|
}
|
|
};
|
|
let project_trust_context = match project_trust_context(
|
|
fs,
|
|
&merged_so_far,
|
|
&cwd,
|
|
&project_root_markers,
|
|
codex_home,
|
|
&active_user_file,
|
|
)
|
|
.await
|
|
{
|
|
Ok(context) => context,
|
|
Err(err) => {
|
|
let source = err
|
|
.get_ref()
|
|
.and_then(|err| err.downcast_ref::<toml::de::Error>())
|
|
.cloned();
|
|
if let Some(config_error) = first_layer_config_error_from_entries(&layers).await {
|
|
return Err(io_error_from_config_error(
|
|
io::ErrorKind::InvalidData,
|
|
config_error,
|
|
source,
|
|
));
|
|
}
|
|
return Err(err);
|
|
}
|
|
};
|
|
let project_layers = load_project_layers(
|
|
fs,
|
|
&cwd,
|
|
&project_trust_context.project_root,
|
|
&project_trust_context,
|
|
codex_home,
|
|
strict_config,
|
|
)
|
|
.await?;
|
|
layers.extend(project_layers.layers);
|
|
startup_warnings = Some(project_layers.startup_warnings);
|
|
}
|
|
|
|
// Add a layer for runtime overrides from the CLI or UI, if any exist.
|
|
if let Some(cli_overrides_layer) = cli_overrides_layer {
|
|
layers.push(ConfigLayerEntry::new(
|
|
ConfigLayerSource::SessionFlags,
|
|
cli_overrides_layer,
|
|
));
|
|
}
|
|
|
|
for thread_config_layer in thread_config_layers {
|
|
insert_layer_by_precedence(&mut layers, thread_config_layer);
|
|
}
|
|
|
|
// Make a best-effort to support the legacy `managed_config.toml` as a
|
|
// config layer on top of everything else. For fields in
|
|
// `managed_config.toml` that do not have an equivalent in
|
|
// `ConfigRequirements`, note users can still override these values on a
|
|
// per-turn basis in the TUI and VS Code.
|
|
let LoadedConfigLayers {
|
|
managed_config,
|
|
managed_config_from_mdm,
|
|
} = loaded_config_layers;
|
|
if let Some(config) = managed_config {
|
|
let managed_parent = config.file.as_path().parent().ok_or_else(|| {
|
|
io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!(
|
|
"Managed config file {} has no parent directory",
|
|
config.file.as_path().display()
|
|
),
|
|
)
|
|
})?;
|
|
let managed_config =
|
|
resolve_relative_paths_in_config_toml(config.managed_config, managed_parent)?;
|
|
layers.push(ConfigLayerEntry::new(
|
|
ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: config.file },
|
|
managed_config,
|
|
));
|
|
}
|
|
if let Some(config) = managed_config_from_mdm {
|
|
// As a general rule, config from MDM should _not_ include relative
|
|
// 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. 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,
|
|
));
|
|
}
|
|
|
|
let config_layer_stack = ConfigLayerStack::new(
|
|
layers,
|
|
config_requirements_toml.clone().try_into()?,
|
|
config_requirements_toml.into_toml(),
|
|
)?
|
|
.with_user_and_project_exec_policy_rules_ignored(ignore_user_and_project_exec_policy_rules);
|
|
Ok(match startup_warnings {
|
|
Some(startup_warnings) => config_layer_stack.with_startup_warnings(startup_warnings),
|
|
None => config_layer_stack,
|
|
})
|
|
}
|
|
|
|
async fn load_user_config_layer(
|
|
fs: &dyn ExecutorFileSystem,
|
|
user_file: &AbsolutePathBuf,
|
|
profile: Option<&ProfileV2Name>,
|
|
ignore_user_config: bool,
|
|
strict_config: bool,
|
|
) -> io::Result<ConfigLayerEntry> {
|
|
let profile = profile.map(ToString::to_string);
|
|
if ignore_user_config {
|
|
return Ok(ConfigLayerEntry::new(
|
|
ConfigLayerSource::User {
|
|
file: user_file.clone(),
|
|
profile,
|
|
},
|
|
TomlValue::Table(toml::map::Map::new()),
|
|
));
|
|
}
|
|
|
|
load_config_toml_for_required_layer(fs, user_file, strict_config, |config_toml| {
|
|
ConfigLayerEntry::new(
|
|
ConfigLayerSource::User {
|
|
file: user_file.clone(),
|
|
profile: profile.clone(),
|
|
},
|
|
config_toml,
|
|
)
|
|
})
|
|
.await
|
|
}
|
|
|
|
fn insert_layer_by_precedence(layers: &mut Vec<ConfigLayerEntry>, layer: ConfigLayerEntry) {
|
|
match layers
|
|
.iter()
|
|
.position(|existing| existing.name.precedence() > layer.name.precedence())
|
|
{
|
|
Some(index) => layers.insert(index, layer),
|
|
None => layers.push(layer),
|
|
}
|
|
}
|
|
|
|
/// Attempts to load a config.toml file from `config_toml`.
|
|
/// - If the file exists and is valid TOML, passes the parsed `toml::Value` to
|
|
/// `create_entry` and returns the resulting layer entry.
|
|
/// - If the file does not exist, uses an empty `Table` with `create_entry` and
|
|
/// returns the resulting layer entry.
|
|
/// - If there is an error reading the file or parsing the TOML, returns an
|
|
/// error.
|
|
async fn load_config_toml_for_required_layer(
|
|
fs: &dyn ExecutorFileSystem,
|
|
toml_file: &AbsolutePathBuf,
|
|
strict_config: bool,
|
|
create_entry: impl FnOnce(TomlValue) -> ConfigLayerEntry,
|
|
) -> io::Result<ConfigLayerEntry> {
|
|
let toml_file_uri = PathUri::from_abs_path(toml_file);
|
|
let toml_value = match fs.read_file_text(&toml_file_uri, /*sandbox*/ None).await {
|
|
Ok(contents) => {
|
|
let config_parent = toml_file.as_path().parent().ok_or_else(|| {
|
|
io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!(
|
|
"Config file {} has no parent directory",
|
|
toml_file.as_path().display()
|
|
),
|
|
)
|
|
})?;
|
|
let config: TomlValue = toml::from_str(&contents).map_err(|err| {
|
|
let config_error =
|
|
config_error_from_toml(toml_file.as_path(), &contents, err.clone());
|
|
io_error_from_config_error(io::ErrorKind::InvalidData, config_error, Some(err))
|
|
})?;
|
|
if strict_config {
|
|
validate_config_toml_strictly(
|
|
toml_file.as_path(),
|
|
&contents,
|
|
&config,
|
|
config_parent,
|
|
)?;
|
|
}
|
|
resolve_relative_paths_in_config_toml(config, config_parent)
|
|
}
|
|
Err(e) => {
|
|
if e.kind() == io::ErrorKind::NotFound {
|
|
Ok(TomlValue::Table(toml::map::Map::new()))
|
|
} else {
|
|
Err(io::Error::new(
|
|
e.kind(),
|
|
format!(
|
|
"Failed to read config file {}: {e}",
|
|
toml_file.as_path().display()
|
|
),
|
|
))
|
|
}
|
|
}
|
|
}?;
|
|
|
|
Ok(create_entry(toml_value))
|
|
}
|
|
|
|
fn validate_config_toml_strictly(
|
|
toml_file: &Path,
|
|
contents: &str,
|
|
value: &TomlValue,
|
|
base_dir: &Path,
|
|
) -> io::Result<()> {
|
|
let _guard = AbsolutePathBufGuard::new(base_dir);
|
|
if let Some(config_error) = config_error_from_ignored_toml_value_fields::<ConfigToml>(
|
|
toml_file,
|
|
contents,
|
|
value.clone(),
|
|
) {
|
|
Err(io_error_from_config_error(
|
|
io::ErrorKind::InvalidData,
|
|
config_error,
|
|
/*source*/ None,
|
|
))
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn validate_cli_overrides_strictly(
|
|
cli_overrides_layer: &TomlValue,
|
|
base_dir: &Path,
|
|
) -> io::Result<()> {
|
|
let _guard = AbsolutePathBufGuard::new(base_dir);
|
|
if let Some(ignored_path) = ignored_toml_value_field::<ConfigToml>(cli_overrides_layer.clone())
|
|
{
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!("unknown configuration field `{ignored_path}` in -c/--config override"),
|
|
));
|
|
}
|
|
|
|
if let Some(ignored_path) = unknown_feature_toml_value_field(cli_overrides_layer) {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!("unknown configuration field `{ignored_path}` in -c/--config override"),
|
|
));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// If available, load requirements from the platform's system `requirements.toml`
|
|
/// location as a requirements layer.
|
|
pub async fn load_requirements_toml(
|
|
fs: &dyn ExecutorFileSystem,
|
|
requirements_toml_file: &AbsolutePathBuf,
|
|
) -> io::Result<Option<RequirementsLayerEntry>> {
|
|
let requirements_toml_file_uri = PathUri::from_abs_path(requirements_toml_file);
|
|
match fs
|
|
.read_file_text(&requirements_toml_file_uri, /*sandbox*/ None)
|
|
.await
|
|
{
|
|
Ok(contents) => {
|
|
let requirements_parent = requirements_toml_file.parent().ok_or_else(|| {
|
|
io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!(
|
|
"Requirements file {} has no parent directory",
|
|
requirements_toml_file.as_ref().display()
|
|
),
|
|
)
|
|
})?;
|
|
let base_dir = AbsolutePathBuf::from_absolute_path(requirements_parent)?;
|
|
Ok(Some(
|
|
RequirementsLayerEntry::from_toml(
|
|
RequirementSource::SystemRequirementsToml {
|
|
file: requirements_toml_file.clone(),
|
|
},
|
|
contents,
|
|
)
|
|
.with_base_dir(base_dir),
|
|
))
|
|
}
|
|
Err(e) => {
|
|
if e.kind() != io::ErrorKind::NotFound {
|
|
Err(io::Error::new(
|
|
e.kind(),
|
|
format!(
|
|
"Failed to read requirements file {}: {e}",
|
|
requirements_toml_file.as_path().display(),
|
|
),
|
|
))
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn system_requirements_toml_file() -> io::Result<AbsolutePathBuf> {
|
|
AbsolutePathBuf::from_absolute_path(Path::new("/etc/codex/requirements.toml"))
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn system_requirements_toml_file() -> io::Result<AbsolutePathBuf> {
|
|
windows_system_requirements_toml_file()
|
|
}
|
|
|
|
fn system_requirements_toml_file_with_overrides(
|
|
overrides: &LoaderOverrides,
|
|
) -> io::Result<AbsolutePathBuf> {
|
|
match &overrides.system_requirements_path {
|
|
Some(path) => AbsolutePathBuf::from_absolute_path(path),
|
|
None => system_requirements_toml_file(),
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
pub fn system_config_toml_file() -> io::Result<AbsolutePathBuf> {
|
|
AbsolutePathBuf::from_absolute_path(Path::new(SYSTEM_CONFIG_TOML_FILE_UNIX))
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
pub fn system_config_toml_file() -> io::Result<AbsolutePathBuf> {
|
|
windows_system_config_toml_file()
|
|
}
|
|
|
|
fn system_config_toml_file_with_overrides(
|
|
overrides: &LoaderOverrides,
|
|
) -> io::Result<AbsolutePathBuf> {
|
|
match &overrides.system_config_path {
|
|
Some(path) => AbsolutePathBuf::from_absolute_path(path),
|
|
None => system_config_toml_file(),
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn windows_codex_system_dir() -> PathBuf {
|
|
let program_data = windows_program_data_dir_from_known_folder().unwrap_or_else(|err| {
|
|
tracing::warn!(
|
|
error = %err,
|
|
"Failed to resolve ProgramData known folder; using default path"
|
|
);
|
|
PathBuf::from(DEFAULT_PROGRAM_DATA_DIR_WINDOWS)
|
|
});
|
|
program_data.join("OpenAI").join("Codex")
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn windows_system_requirements_toml_file() -> io::Result<AbsolutePathBuf> {
|
|
let requirements_toml_file = windows_codex_system_dir().join("requirements.toml");
|
|
AbsolutePathBuf::try_from(requirements_toml_file)
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn windows_system_config_toml_file() -> io::Result<AbsolutePathBuf> {
|
|
let config_toml_file = windows_codex_system_dir().join("config.toml");
|
|
AbsolutePathBuf::try_from(config_toml_file)
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn windows_program_data_dir_from_known_folder() -> io::Result<PathBuf> {
|
|
use std::ffi::OsString;
|
|
use std::os::windows::ffi::OsStringExt;
|
|
use windows_sys::Win32::System::Com::CoTaskMemFree;
|
|
use windows_sys::Win32::UI::Shell::FOLDERID_ProgramData;
|
|
use windows_sys::Win32::UI::Shell::KF_FLAG_DEFAULT;
|
|
use windows_sys::Win32::UI::Shell::SHGetKnownFolderPath;
|
|
|
|
let mut path_ptr = std::ptr::null_mut::<u16>();
|
|
let known_folder_flags = u32::try_from(KF_FLAG_DEFAULT).map_err(|_| {
|
|
io::Error::other(format!(
|
|
"KF_FLAG_DEFAULT did not fit in u32: {KF_FLAG_DEFAULT}"
|
|
))
|
|
})?;
|
|
// Known folder IDs reference:
|
|
// https://learn.microsoft.com/en-us/windows/win32/shell/knownfolderid
|
|
// SAFETY: SHGetKnownFolderPath initializes path_ptr with a CoTaskMem-allocated,
|
|
// null-terminated UTF-16 string on success.
|
|
let hr = unsafe {
|
|
SHGetKnownFolderPath(&FOLDERID_ProgramData, known_folder_flags, 0, &mut path_ptr)
|
|
};
|
|
if hr != 0 {
|
|
return Err(io::Error::other(format!(
|
|
"SHGetKnownFolderPath(FOLDERID_ProgramData) failed with HRESULT {hr:#010x}"
|
|
)));
|
|
}
|
|
if path_ptr.is_null() {
|
|
return Err(io::Error::other(
|
|
"SHGetKnownFolderPath(FOLDERID_ProgramData) returned a null pointer",
|
|
));
|
|
}
|
|
|
|
// SAFETY: path_ptr is a valid null-terminated UTF-16 string allocated by
|
|
// SHGetKnownFolderPath and must be freed with CoTaskMemFree.
|
|
let path = unsafe {
|
|
let mut len = 0usize;
|
|
while *path_ptr.add(len) != 0 {
|
|
len += 1;
|
|
}
|
|
let wide = std::slice::from_raw_parts(path_ptr, len);
|
|
let path = PathBuf::from(OsString::from_wide(wide));
|
|
CoTaskMemFree(path_ptr.cast());
|
|
path
|
|
};
|
|
|
|
Ok(path)
|
|
}
|
|
|
|
fn requirements_layers_from_legacy_scheme(
|
|
loaded_config_layers: LoadedConfigLayers,
|
|
) -> io::Result<Vec<RequirementsLayerEntry>> {
|
|
// List the file-backed legacy layer first because requirements layers are
|
|
// composed lowest-precedence to highest-precedence, and MDM has higher
|
|
// precedence than the legacy managed_config.toml file.
|
|
let LoadedConfigLayers {
|
|
managed_config,
|
|
managed_config_from_mdm,
|
|
} = loaded_config_layers;
|
|
|
|
let layer_count =
|
|
usize::from(managed_config.is_some()) + usize::from(managed_config_from_mdm.is_some());
|
|
let mut layers = Vec::with_capacity(layer_count);
|
|
for (source, config) in managed_config
|
|
.map(|c| {
|
|
(
|
|
RequirementSource::LegacyManagedConfigTomlFromFile { file: c.file },
|
|
c.managed_config,
|
|
)
|
|
})
|
|
.into_iter()
|
|
.chain(managed_config_from_mdm.map(|config| {
|
|
(
|
|
RequirementSource::LegacyManagedConfigTomlFromMdm,
|
|
config.managed_config,
|
|
)
|
|
}))
|
|
{
|
|
let legacy_config: LegacyManagedConfigToml =
|
|
config.try_into().map_err(|err: toml::de::Error| {
|
|
io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!("Failed to parse config requirements as TOML: {err}"),
|
|
)
|
|
})?;
|
|
|
|
layers.push(RequirementsLayerEntry::from_toml_value(
|
|
source,
|
|
legacy_requirements_to_toml_value(legacy_config)?,
|
|
));
|
|
}
|
|
|
|
Ok(layers)
|
|
}
|
|
|
|
fn legacy_requirements_to_toml_value(legacy: LegacyManagedConfigToml) -> io::Result<TomlValue> {
|
|
let LegacyManagedConfigToml {
|
|
approval_policy,
|
|
approvals_reviewer,
|
|
sandbox_mode,
|
|
} = legacy;
|
|
let mut table = toml::map::Map::new();
|
|
if let Some(approval_policy) = approval_policy {
|
|
table.insert(
|
|
"allowed_approval_policies".to_string(),
|
|
toml_value_from_serializable(vec![approval_policy])?,
|
|
);
|
|
}
|
|
if let Some(approvals_reviewer) = approvals_reviewer {
|
|
let mut allowed_reviewers = vec![approvals_reviewer];
|
|
if approvals_reviewer == ApprovalsReviewer::AutoReview {
|
|
allowed_reviewers.push(ApprovalsReviewer::User);
|
|
}
|
|
table.insert(
|
|
"allowed_approvals_reviewers".to_string(),
|
|
toml_value_from_serializable(allowed_reviewers)?,
|
|
);
|
|
}
|
|
if let Some(sandbox_mode) = sandbox_mode {
|
|
let required_mode: SandboxModeRequirement = sandbox_mode.into();
|
|
// Allowing read-only is a requirement for Codex to function correctly.
|
|
// So in this backfill path, we append read-only if it's not already specified.
|
|
let mut allowed_modes = vec![SandboxModeRequirement::ReadOnly];
|
|
if required_mode != SandboxModeRequirement::ReadOnly {
|
|
allowed_modes.push(required_mode);
|
|
}
|
|
table.insert(
|
|
"allowed_sandbox_modes".to_string(),
|
|
toml_value_from_serializable(allowed_modes)?,
|
|
);
|
|
}
|
|
Ok(TomlValue::Table(table))
|
|
}
|
|
|
|
fn toml_value_from_serializable<T: serde::Serialize>(value: T) -> io::Result<TomlValue> {
|
|
TomlValue::try_from(value).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))
|
|
}
|
|
|
|
struct ProjectTrustContext {
|
|
project_root: AbsolutePathBuf,
|
|
project_root_key: String,
|
|
project_root_lookup_keys: Vec<String>,
|
|
checkout_root: Option<AbsolutePathBuf>,
|
|
repo_root: Option<AbsolutePathBuf>,
|
|
repo_root_key: Option<String>,
|
|
repo_root_lookup_keys: Option<Vec<String>>,
|
|
projects_trust: std::collections::HashMap<String, TrustLevel>,
|
|
user_config_file: AbsolutePathBuf,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct ProjectTrustConfigToml {
|
|
projects: Option<std::collections::HashMap<String, ProjectConfig>>,
|
|
}
|
|
|
|
struct ProjectTrustDecision {
|
|
trust_level: Option<TrustLevel>,
|
|
trust_key: String,
|
|
}
|
|
|
|
impl ProjectTrustDecision {
|
|
fn is_trusted(&self) -> bool {
|
|
matches!(self.trust_level, Some(TrustLevel::Trusted))
|
|
}
|
|
}
|
|
|
|
impl ProjectTrustContext {
|
|
fn decision_for_dir(&self, dir: &AbsolutePathBuf) -> ProjectTrustDecision {
|
|
for dir_key in normalized_project_trust_keys(dir.as_path()) {
|
|
if let Some((trust_key, trust_level)) =
|
|
project_trust_for_lookup_key(&self.projects_trust, &dir_key)
|
|
{
|
|
return ProjectTrustDecision {
|
|
trust_level: Some(trust_level),
|
|
trust_key,
|
|
};
|
|
}
|
|
}
|
|
|
|
for project_root_key in &self.project_root_lookup_keys {
|
|
if let Some((trust_key, trust_level)) =
|
|
project_trust_for_lookup_key(&self.projects_trust, project_root_key)
|
|
{
|
|
return ProjectTrustDecision {
|
|
trust_level: Some(trust_level),
|
|
trust_key,
|
|
};
|
|
}
|
|
}
|
|
|
|
if let Some(repo_root_lookup_keys) = self.repo_root_lookup_keys.as_ref() {
|
|
for repo_root_key in repo_root_lookup_keys {
|
|
if let Some((trust_key, trust_level)) =
|
|
project_trust_for_lookup_key(&self.projects_trust, repo_root_key)
|
|
{
|
|
return ProjectTrustDecision {
|
|
trust_level: Some(trust_level),
|
|
trust_key,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
ProjectTrustDecision {
|
|
trust_level: None,
|
|
trust_key: self
|
|
.repo_root_key
|
|
.clone()
|
|
.unwrap_or_else(|| self.project_root_key.clone()),
|
|
}
|
|
}
|
|
|
|
fn disabled_reason_for_decision(&self, decision: &ProjectTrustDecision) -> Option<String> {
|
|
if decision.is_trusted() {
|
|
return None;
|
|
}
|
|
|
|
let gated_features = "project-local config, hooks, and exec policies";
|
|
let trust_key = decision.trust_key.as_str();
|
|
let user_config_file = self.user_config_file.as_path().display();
|
|
match decision.trust_level {
|
|
Some(TrustLevel::Untrusted) => Some(format!(
|
|
"{trust_key} is marked as untrusted in {user_config_file}. To load {gated_features}, mark it trusted."
|
|
)),
|
|
_ => Some(format!(
|
|
"To load {gated_features}, add {trust_key} as a trusted project in {user_config_file}."
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn root_checkout_hooks_folder_for_dir(&self, dir: &AbsolutePathBuf) -> Option<AbsolutePathBuf> {
|
|
let checkout_root = self.checkout_root.as_ref()?;
|
|
let repo_root = self.repo_root.as_ref()?;
|
|
// Regular checkouts resolve both paths to the same root; linked worktrees do not.
|
|
if checkout_root == repo_root {
|
|
return None;
|
|
}
|
|
|
|
let relative_dir = dir.as_path().strip_prefix(checkout_root.as_path()).ok()?;
|
|
Some(repo_root.join(relative_dir).join(".codex"))
|
|
}
|
|
}
|
|
|
|
fn project_layer_entry(
|
|
dot_codex_folder: &AbsolutePathBuf,
|
|
config: TomlValue,
|
|
disabled_reason: Option<String>,
|
|
hooks_config_folder_override: Option<AbsolutePathBuf>,
|
|
) -> ConfigLayerEntry {
|
|
let source = ConfigLayerSource::Project {
|
|
dot_codex_folder: dot_codex_folder.clone(),
|
|
};
|
|
|
|
let entry = if let Some(reason) = disabled_reason {
|
|
ConfigLayerEntry::new_disabled(source, config, reason)
|
|
} else {
|
|
ConfigLayerEntry::new(source, config)
|
|
};
|
|
entry.with_hooks_config_folder_override(hooks_config_folder_override)
|
|
}
|
|
|
|
fn sanitize_project_config(config: &mut TomlValue) -> Vec<String> {
|
|
let Some(table) = config.as_table_mut() else {
|
|
return Vec::new();
|
|
};
|
|
|
|
let mut ignored_keys = Vec::new();
|
|
for key in PROJECT_LOCAL_CONFIG_DENYLIST {
|
|
if table.remove(*key).is_some() {
|
|
ignored_keys.push((*key).to_string());
|
|
}
|
|
}
|
|
|
|
ignored_keys
|
|
}
|
|
|
|
fn project_ignored_config_keys_warning(
|
|
dot_codex_folder: &AbsolutePathBuf,
|
|
ignored_keys: &[String],
|
|
) -> String {
|
|
let config_path = dot_codex_folder.join(CONFIG_TOML_FILE);
|
|
let ignored_keys = ignored_keys.join(", ");
|
|
format!(
|
|
concat!(
|
|
"Ignored unsupported project-local config keys in {config_path}: {ignored_keys}. ",
|
|
"If you want these settings to apply, manually set them in your ",
|
|
"user-level config.toml."
|
|
),
|
|
config_path = config_path.display(),
|
|
ignored_keys = ignored_keys,
|
|
)
|
|
}
|
|
|
|
async fn project_trust_context(
|
|
fs: &dyn ExecutorFileSystem,
|
|
merged_config: &TomlValue,
|
|
cwd: &AbsolutePathBuf,
|
|
project_root_markers: &[String],
|
|
config_base_dir: &Path,
|
|
user_config_file: &AbsolutePathBuf,
|
|
) -> io::Result<ProjectTrustContext> {
|
|
let project_trust_config: ProjectTrustConfigToml = {
|
|
let _guard = AbsolutePathBufGuard::new(config_base_dir);
|
|
merged_config
|
|
.clone()
|
|
.try_into()
|
|
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?
|
|
};
|
|
|
|
let project_root = find_project_root(fs, cwd, project_root_markers).await?;
|
|
let projects = project_trust_config.projects.unwrap_or_default();
|
|
|
|
let project_root_lookup_keys = normalized_project_trust_keys(project_root.as_path());
|
|
let project_root_key = project_root_lookup_keys
|
|
.first()
|
|
.cloned()
|
|
.unwrap_or_else(|| project_trust_key(project_root.as_path()));
|
|
let checkout_root = find_git_checkout_root(fs, cwd).await;
|
|
let repo_root = resolve_root_git_project_for_trust(fs, cwd).await;
|
|
let repo_root_lookup_keys = repo_root
|
|
.as_ref()
|
|
.map(|root| normalized_project_trust_keys(root.as_path()));
|
|
let repo_root_key = repo_root_lookup_keys
|
|
.as_ref()
|
|
.and_then(|keys| keys.first().cloned());
|
|
|
|
let projects_trust = projects
|
|
.into_iter()
|
|
.filter_map(|(key, project)| project.trust_level.map(|trust_level| (key, trust_level)))
|
|
.collect();
|
|
|
|
Ok(ProjectTrustContext {
|
|
project_root,
|
|
project_root_key,
|
|
project_root_lookup_keys,
|
|
checkout_root,
|
|
repo_root,
|
|
repo_root_key,
|
|
repo_root_lookup_keys,
|
|
projects_trust,
|
|
user_config_file: user_config_file.clone(),
|
|
})
|
|
}
|
|
|
|
/// Canonicalize the path and convert it to a string to be used as a key in the
|
|
/// projects trust map. On Windows, strips UNC, when possible, to try to ensure
|
|
/// that different paths that point to the same location have the same key.
|
|
pub fn project_trust_key(path: &Path) -> String {
|
|
normalized_project_trust_keys(path)
|
|
.into_iter()
|
|
.next()
|
|
.unwrap_or_else(|| normalize_project_trust_lookup_key(path.to_string_lossy().to_string()))
|
|
}
|
|
|
|
fn normalized_project_trust_keys(path: &Path) -> Vec<String> {
|
|
let normalized_path = normalize_project_trust_lookup_key(path.to_string_lossy().to_string());
|
|
let normalized_canonical_path = normalize_project_trust_lookup_key(
|
|
normalize_path(path)
|
|
.unwrap_or_else(|_| path.to_path_buf())
|
|
.to_string_lossy()
|
|
.to_string(),
|
|
);
|
|
if normalized_path == normalized_canonical_path {
|
|
vec![normalized_canonical_path]
|
|
} else {
|
|
vec![normalized_canonical_path, normalized_path]
|
|
}
|
|
}
|
|
|
|
fn normalize_project_trust_lookup_key(key: String) -> String {
|
|
if cfg!(windows) {
|
|
key.to_ascii_lowercase()
|
|
} else {
|
|
key
|
|
}
|
|
}
|
|
fn project_trust_for_lookup_key(
|
|
projects_trust: &std::collections::HashMap<String, TrustLevel>,
|
|
lookup_key: &str,
|
|
) -> Option<(String, TrustLevel)> {
|
|
if let Some(trust_level) = projects_trust.get(lookup_key).copied() {
|
|
return Some((lookup_key.to_string(), trust_level));
|
|
}
|
|
|
|
let mut normalized_matches: Vec<_> = projects_trust
|
|
.iter()
|
|
.filter(|(key, _)| normalize_project_trust_lookup_key((*key).clone()) == lookup_key)
|
|
.collect();
|
|
normalized_matches.sort_by_key(|(key, _)| *key);
|
|
normalized_matches
|
|
.first()
|
|
.map(|(key, trust_level)| ((**key).clone(), **trust_level))
|
|
}
|
|
/// Takes a `toml::Value` parsed from a config.toml file and walks through it,
|
|
/// resolving any `AbsolutePathBuf` fields against `base_dir`, returning a new
|
|
/// `toml::Value` with the same shape but with paths resolved.
|
|
///
|
|
/// This ensures that multiple config layers can be merged together correctly
|
|
/// even if they were loaded from different directories.
|
|
#[doc(hidden)]
|
|
pub fn resolve_relative_paths_in_config_toml(
|
|
value_from_config_toml: TomlValue,
|
|
base_dir: &Path,
|
|
) -> io::Result<TomlValue> {
|
|
// Use the serialize/deserialize round-trip to convert the
|
|
// `toml::Value` into a `ConfigToml` with `AbsolutePath
|
|
let _guard = AbsolutePathBufGuard::new(base_dir);
|
|
let Ok(resolved) = value_from_config_toml.clone().try_into::<ConfigToml>() else {
|
|
return Ok(value_from_config_toml);
|
|
};
|
|
drop(_guard);
|
|
|
|
let resolved_value = TomlValue::try_from(resolved).map_err(|e| {
|
|
io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!("Failed to serialize resolved config: {e}"),
|
|
)
|
|
})?;
|
|
|
|
Ok(copy_shape_from_original(
|
|
&value_from_config_toml,
|
|
&resolved_value,
|
|
))
|
|
}
|
|
|
|
/// Ensure that every field in `original` is present in the returned
|
|
/// `toml::Value`, taking the value from `resolved` where possible. This ensures
|
|
/// the fields that we "removed" during the serialize/deserialize round-trip in
|
|
/// `resolve_config_paths` are preserved, out of an abundance of caution.
|
|
fn copy_shape_from_original(original: &TomlValue, resolved: &TomlValue) -> TomlValue {
|
|
match (original, resolved) {
|
|
(TomlValue::Table(original_table), TomlValue::Table(resolved_table)) => {
|
|
let mut table = toml::map::Map::new();
|
|
for (key, original_value) in original_table {
|
|
let resolved_value = resolved_table.get(key).unwrap_or(original_value);
|
|
table.insert(
|
|
key.clone(),
|
|
copy_shape_from_original(original_value, resolved_value),
|
|
);
|
|
}
|
|
TomlValue::Table(table)
|
|
}
|
|
(TomlValue::Array(original_array), TomlValue::Array(resolved_array)) => {
|
|
let mut items = Vec::new();
|
|
for (index, original_value) in original_array.iter().enumerate() {
|
|
let resolved_value = resolved_array.get(index).unwrap_or(original_value);
|
|
items.push(copy_shape_from_original(original_value, resolved_value));
|
|
}
|
|
TomlValue::Array(items)
|
|
}
|
|
(_, resolved_value) => resolved_value.clone(),
|
|
}
|
|
}
|
|
|
|
async fn find_project_root(
|
|
fs: &dyn ExecutorFileSystem,
|
|
cwd: &AbsolutePathBuf,
|
|
project_root_markers: &[String],
|
|
) -> io::Result<AbsolutePathBuf> {
|
|
if project_root_markers.is_empty() {
|
|
return Ok(cwd.clone());
|
|
}
|
|
|
|
for ancestor in cwd.ancestors() {
|
|
for marker in project_root_markers {
|
|
let marker_path = ancestor.join(marker);
|
|
let marker_path_uri = PathUri::from_abs_path(&marker_path);
|
|
if fs
|
|
.get_metadata(&marker_path_uri, /*sandbox*/ None)
|
|
.await
|
|
.is_ok()
|
|
{
|
|
return Ok(ancestor);
|
|
}
|
|
}
|
|
}
|
|
Ok(cwd.clone())
|
|
}
|
|
|
|
async fn find_git_checkout_root(
|
|
fs: &dyn ExecutorFileSystem,
|
|
cwd: &AbsolutePathBuf,
|
|
) -> Option<AbsolutePathBuf> {
|
|
let cwd_uri = PathUri::from_abs_path(cwd);
|
|
let base = match fs.get_metadata(&cwd_uri, /*sandbox*/ None).await {
|
|
Ok(metadata) if metadata.is_directory => cwd.clone(),
|
|
_ => cwd.parent()?,
|
|
};
|
|
|
|
for dir in base.ancestors() {
|
|
let dot_git = dir.join(".git");
|
|
let dot_git_uri = PathUri::from_abs_path(&dot_git);
|
|
if fs
|
|
.get_metadata(&dot_git_uri, /*sandbox*/ None)
|
|
.await
|
|
.is_ok()
|
|
{
|
|
return Some(dir);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
struct LoadedProjectLayers {
|
|
layers: Vec<ConfigLayerEntry>,
|
|
startup_warnings: Vec<String>,
|
|
}
|
|
|
|
/// Return the appropriate list of layers (each with
|
|
/// [ConfigLayerSource::Project] as the source) between `cwd` and
|
|
/// `project_root`, inclusive. The list is ordered in _increasing_ precdence,
|
|
/// starting from folders closest to `project_root` (which is the lowest
|
|
/// precedence) to those closest to `cwd` (which is the highest precedence).
|
|
/// Any warnings are stack-level startup messages, not additional config layers.
|
|
async fn load_project_layers(
|
|
fs: &dyn ExecutorFileSystem,
|
|
cwd: &AbsolutePathBuf,
|
|
project_root: &AbsolutePathBuf,
|
|
trust_context: &ProjectTrustContext,
|
|
codex_home: &Path,
|
|
strict_config: bool,
|
|
) -> io::Result<LoadedProjectLayers> {
|
|
let codex_home_abs = AbsolutePathBuf::from_absolute_path(codex_home)?;
|
|
let codex_home_normalized =
|
|
normalize_path(codex_home_abs.as_path()).unwrap_or_else(|_| codex_home_abs.to_path_buf());
|
|
let mut dirs = cwd
|
|
.ancestors()
|
|
.scan(false, |done, a| {
|
|
if *done {
|
|
None
|
|
} else {
|
|
if &a == project_root {
|
|
*done = true;
|
|
}
|
|
Some(a)
|
|
}
|
|
})
|
|
.collect::<Vec<_>>();
|
|
dirs.reverse();
|
|
|
|
let mut layers = Vec::new();
|
|
let mut startup_warnings = Vec::new();
|
|
for dir in dirs {
|
|
let dot_codex_abs = dir.join(".codex");
|
|
let dot_codex_uri = PathUri::from_abs_path(&dot_codex_abs);
|
|
if !fs
|
|
.get_metadata(&dot_codex_uri, /*sandbox*/ None)
|
|
.await
|
|
.map(|metadata| metadata.is_directory)
|
|
.unwrap_or(false)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
let decision = trust_context.decision_for_dir(&dir);
|
|
let disabled_reason = trust_context.disabled_reason_for_decision(&decision);
|
|
let hooks_config_folder_override = trust_context.root_checkout_hooks_folder_for_dir(&dir);
|
|
let dot_codex_normalized =
|
|
normalize_path(dot_codex_abs.as_path()).unwrap_or_else(|_| dot_codex_abs.to_path_buf());
|
|
if dot_codex_abs == codex_home_abs || dot_codex_normalized == codex_home_normalized {
|
|
continue;
|
|
}
|
|
let config_file = dot_codex_abs.join(CONFIG_TOML_FILE);
|
|
let config_file_uri = PathUri::from_abs_path(&config_file);
|
|
match fs.read_file_text(&config_file_uri, /*sandbox*/ None).await {
|
|
Ok(contents) => {
|
|
let config: TomlValue = match toml::from_str(&contents) {
|
|
Ok(config) => config,
|
|
Err(e) => {
|
|
if decision.is_trusted() {
|
|
let config_file_display = config_file.as_path().display();
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!(
|
|
"Error parsing project config file {config_file_display}: {e}"
|
|
),
|
|
));
|
|
}
|
|
layers.push(project_layer_entry(
|
|
&dot_codex_abs,
|
|
TomlValue::Table(toml::map::Map::new()),
|
|
disabled_reason.clone(),
|
|
hooks_config_folder_override.clone(),
|
|
));
|
|
continue;
|
|
}
|
|
};
|
|
let mut config = config;
|
|
if disabled_reason.is_none() && strict_config {
|
|
validate_config_toml_strictly(
|
|
config_file.as_path(),
|
|
&contents,
|
|
&config,
|
|
dot_codex_abs.as_path(),
|
|
)?;
|
|
}
|
|
let ignored_project_config_keys = sanitize_project_config(&mut config);
|
|
let config =
|
|
resolve_relative_paths_in_config_toml(config, dot_codex_abs.as_path())?;
|
|
let config = merge_root_checkout_project_hooks(
|
|
fs,
|
|
config,
|
|
hooks_config_folder_override.as_ref(),
|
|
decision.is_trusted(),
|
|
)
|
|
.await?;
|
|
if disabled_reason.is_none() && !ignored_project_config_keys.is_empty() {
|
|
startup_warnings.push(project_ignored_config_keys_warning(
|
|
&dot_codex_abs,
|
|
&ignored_project_config_keys,
|
|
));
|
|
}
|
|
let entry = project_layer_entry(
|
|
&dot_codex_abs,
|
|
config,
|
|
disabled_reason.clone(),
|
|
hooks_config_folder_override.clone(),
|
|
);
|
|
layers.push(entry);
|
|
}
|
|
Err(err) => {
|
|
if err.kind() == io::ErrorKind::NotFound {
|
|
// If there is no config.toml file, record an empty entry
|
|
// for this project layer, as this may still have subfolders
|
|
// that are significant in the overall ConfigLayerStack.
|
|
let config = merge_root_checkout_project_hooks(
|
|
fs,
|
|
TomlValue::Table(toml::map::Map::new()),
|
|
hooks_config_folder_override.as_ref(),
|
|
decision.is_trusted(),
|
|
)
|
|
.await?;
|
|
layers.push(project_layer_entry(
|
|
&dot_codex_abs,
|
|
config,
|
|
disabled_reason,
|
|
hooks_config_folder_override,
|
|
));
|
|
} else {
|
|
let config_file_display = config_file.as_path().display();
|
|
return Err(io::Error::new(
|
|
err.kind(),
|
|
format!("Failed to read project config file {config_file_display}: {err}"),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(LoadedProjectLayers {
|
|
layers,
|
|
startup_warnings,
|
|
})
|
|
}
|
|
|
|
/// For linked worktrees, preserve ordinary worktree-local project config while
|
|
/// replacing only hook declarations with the matching root-checkout layer.
|
|
async fn merge_root_checkout_project_hooks(
|
|
fs: &dyn ExecutorFileSystem,
|
|
mut config: TomlValue,
|
|
hooks_config_folder_override: Option<&AbsolutePathBuf>,
|
|
is_trusted: bool,
|
|
) -> io::Result<TomlValue> {
|
|
let Some(hooks_config_folder) = hooks_config_folder_override else {
|
|
return Ok(config);
|
|
};
|
|
let hooks_config_file = hooks_config_folder.join(CONFIG_TOML_FILE);
|
|
let hooks_config_file_uri = PathUri::from_abs_path(&hooks_config_file);
|
|
let root_config = match fs
|
|
.read_file_text(&hooks_config_file_uri, /*sandbox*/ None)
|
|
.await
|
|
{
|
|
Ok(contents) => {
|
|
let parsed: TomlValue = match toml::from_str(&contents) {
|
|
Ok(parsed) => parsed,
|
|
Err(err) => {
|
|
if is_trusted {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!(
|
|
"Error parsing project hooks config file {}: {err}",
|
|
hooks_config_file.as_path().display()
|
|
),
|
|
));
|
|
}
|
|
TomlValue::Table(toml::map::Map::new())
|
|
}
|
|
};
|
|
resolve_relative_paths_in_config_toml(parsed, hooks_config_folder.as_path())?
|
|
}
|
|
Err(err) if err.kind() == io::ErrorKind::NotFound => {
|
|
TomlValue::Table(toml::map::Map::new())
|
|
}
|
|
Err(err) => {
|
|
return Err(io::Error::new(
|
|
err.kind(),
|
|
format!(
|
|
"Failed to read project hooks config file {}: {err}",
|
|
hooks_config_file.as_path().display()
|
|
),
|
|
));
|
|
}
|
|
};
|
|
|
|
let Some(config_table) = config.as_table_mut() else {
|
|
return Ok(config);
|
|
};
|
|
config_table.remove("hooks");
|
|
if let Some(hooks) = root_config.get("hooks") {
|
|
config_table.insert("hooks".to_string(), hooks.clone());
|
|
}
|
|
Ok(config)
|
|
}
|
|
/// The legacy mechanism for specifying admin-enforced configuration is to read
|
|
/// from a file like `/etc/codex/managed_config.toml` that has the same
|
|
/// structure as `config.toml` where fields like `approval_policy` can specify
|
|
/// exactly one value rather than a list of allowed values.
|
|
///
|
|
/// If present, re-interpret `managed_config.toml` as a `requirements.toml`
|
|
/// where each specified field is treated as a constraint. Most fields allow
|
|
/// only the specified value. `approvals_reviewer = "auto_review"` also allows
|
|
/// `user` so people can opt out of the auto-reviewer.
|
|
#[derive(Deserialize, Debug, Clone, Default, PartialEq)]
|
|
struct LegacyManagedConfigToml {
|
|
approval_policy: Option<AskForApproval>,
|
|
approvals_reviewer: Option<ApprovalsReviewer>,
|
|
sandbox_mode: Option<SandboxMode>,
|
|
}
|
|
|
|
// Cannot name this `mod tests` because of tests.rs in this folder.
|
|
#[cfg(test)]
|
|
mod unit_tests {
|
|
use super::*;
|
|
#[cfg(windows)]
|
|
use std::path::Path;
|
|
use tempfile::tempdir;
|
|
|
|
#[test]
|
|
fn ensure_resolve_relative_paths_in_config_toml_preserves_all_fields() -> anyhow::Result<()> {
|
|
let tmp = tempdir()?;
|
|
let base_dir = tmp.path();
|
|
let contents = r#"
|
|
# This is a field recognized by config.toml that is an AbsolutePathBuf in
|
|
# the ConfigToml struct.
|
|
model_instructions_file = "./some_file.md"
|
|
|
|
# This is a field recognized by config.toml.
|
|
model = "gpt-1000"
|
|
|
|
# This is a field not recognized by config.toml.
|
|
foo = "xyzzy"
|
|
"#;
|
|
let user_config: TomlValue = toml::from_str(contents)?;
|
|
|
|
let normalized_toml_value = resolve_relative_paths_in_config_toml(user_config, base_dir)?;
|
|
let mut expected_toml_value = toml::map::Map::new();
|
|
expected_toml_value.insert(
|
|
"model_instructions_file".to_string(),
|
|
TomlValue::String(
|
|
AbsolutePathBuf::resolve_path_against_base("./some_file.md", base_dir)
|
|
.as_path()
|
|
.to_string_lossy()
|
|
.to_string(),
|
|
),
|
|
);
|
|
expected_toml_value.insert(
|
|
"model".to_string(),
|
|
TomlValue::String("gpt-1000".to_string()),
|
|
);
|
|
expected_toml_value.insert("foo".to_string(), TomlValue::String("xyzzy".to_string()));
|
|
assert_eq!(normalized_toml_value, TomlValue::Table(expected_toml_value));
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_managed_config_backfill_includes_read_only_sandbox_mode() -> io::Result<()> {
|
|
let legacy = LegacyManagedConfigToml {
|
|
approval_policy: None,
|
|
approvals_reviewer: None,
|
|
sandbox_mode: Some(SandboxMode::WorkspaceWrite),
|
|
};
|
|
|
|
assert_eq!(
|
|
legacy_requirements_to_toml_value(legacy)?,
|
|
TomlValue::Table(toml::map::Map::from_iter([(
|
|
"allowed_sandbox_modes".to_string(),
|
|
TomlValue::Array(vec![
|
|
TomlValue::String("read-only".to_string()),
|
|
TomlValue::String("workspace-write".to_string()),
|
|
]),
|
|
)]))
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_managed_config_backfill_allows_user_when_guardian_is_required() -> io::Result<()> {
|
|
let legacy = LegacyManagedConfigToml {
|
|
approval_policy: None,
|
|
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
|
|
sandbox_mode: None,
|
|
};
|
|
|
|
assert_eq!(
|
|
legacy_requirements_to_toml_value(legacy)?,
|
|
TomlValue::Table(toml::map::Map::from_iter([(
|
|
"allowed_approvals_reviewers".to_string(),
|
|
TomlValue::Array(vec![
|
|
TomlValue::String("auto_review".to_string()),
|
|
TomlValue::String("user".to_string()),
|
|
]),
|
|
)]))
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_managed_config_backfill_preserves_user_only_approvals_reviewer() -> io::Result<()> {
|
|
let legacy = LegacyManagedConfigToml {
|
|
approval_policy: None,
|
|
approvals_reviewer: Some(ApprovalsReviewer::User),
|
|
sandbox_mode: None,
|
|
};
|
|
|
|
assert_eq!(
|
|
legacy_requirements_to_toml_value(legacy)?,
|
|
TomlValue::Table(toml::map::Map::from_iter([(
|
|
"allowed_approvals_reviewers".to_string(),
|
|
TomlValue::Array(vec![TomlValue::String("user".to_string())]),
|
|
)]))
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn windows_system_requirements_toml_file_uses_expected_suffix() {
|
|
let expected = windows_program_data_dir_from_known_folder()
|
|
.unwrap_or_else(|_| PathBuf::from(DEFAULT_PROGRAM_DATA_DIR_WINDOWS))
|
|
.join("OpenAI")
|
|
.join("Codex")
|
|
.join("requirements.toml");
|
|
assert_eq!(
|
|
windows_system_requirements_toml_file()
|
|
.expect("requirements.toml path")
|
|
.as_path(),
|
|
expected.as_path()
|
|
);
|
|
assert!(
|
|
windows_system_requirements_toml_file()
|
|
.expect("requirements.toml path")
|
|
.as_path()
|
|
.ends_with(Path::new("OpenAI").join("Codex").join("requirements.toml"))
|
|
);
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn windows_system_config_toml_file_uses_expected_suffix() {
|
|
let expected = windows_program_data_dir_from_known_folder()
|
|
.unwrap_or_else(|_| PathBuf::from(DEFAULT_PROGRAM_DATA_DIR_WINDOWS))
|
|
.join("OpenAI")
|
|
.join("Codex")
|
|
.join("config.toml");
|
|
assert_eq!(
|
|
windows_system_config_toml_file()
|
|
.expect("config.toml path")
|
|
.as_path(),
|
|
expected.as_path()
|
|
);
|
|
assert!(
|
|
windows_system_config_toml_file()
|
|
.expect("config.toml path")
|
|
.as_path()
|
|
.ends_with(Path::new("OpenAI").join("Codex").join("config.toml"))
|
|
);
|
|
}
|
|
}
|