Skip to content

Configuration Overrides

Configuration override systems solve the same fundamental problem: how to let users customize behavior at multiple scopes (global, project, session) with predictable precedence. Every AI coding agent has reinvented this wheel differently. The challenge is not just “read a config file” — it’s merging nested structures across layers, interpolating environment variables, handling untrusted project configs, and providing escape hatches for enterprise administrators who need to constrain what users can override.

Reference: references/aider/ at commit b9050e1d

Aider leans entirely on Python’s configargparse library, which unifies config files, environment variables, and CLI arguments into a single argument parser.

The entry point is aider/main.py:main() (line 451). Config file discovery follows this order:

# aider/main.py:464-477
conf_fname = Path(".aider.conf.yml")
default_config_files = []
default_config_files += [conf_fname.resolve()] # CWD
if git_root:
git_conf = Path(git_root) / conf_fname # git root
if git_conf not in default_config_files:
default_config_files.append(git_conf)
default_config_files.append(Path.home() / conf_fname) # homedir

The search order is CWD → git root → home directory. After initial parsing, the list is reversed (line 494) so the parser re-reads with homedir as the lowest-priority default and CWD as the highest.

# aider/args.py:35-42
def get_parser(default_config_files, git_root):
parser = configargparse.ArgumentParser(
description="aider is AI pair programming in your terminal",
add_config_file_help=True,
default_config_files=default_config_files,
config_file_parser_class=configargparse.YAMLConfigFileParser,
auto_env_var_prefix="AIDER_",
)

The auto_env_var_prefix="AIDER_" setting means every CLI flag automatically maps to an environment variable. For example, --model becomes AIDER_MODEL, --dark-mode becomes AIDER_DARK_MODE. This is handled entirely by configargparse — Aider doesn’t write any env-var-to-flag mapping code.

From lowest to highest priority:

  1. Defaults — Hardcoded in args.py via parser.add_argument(default=...)
  2. Home directory .aider.conf.yml — User-wide defaults
  3. Git root .aider.conf.yml — Project-level settings
  4. CWD .aider.conf.yml — Directory-specific overrides
  5. Environment variablesAIDER_* prefix, auto-mapped
  6. .env files — Loaded via python-dotenv with override=True from home → git root → CWD → custom path
  7. CLI flags — Highest priority, always wins
  • --api-key provider=key — Shortcut that sets {PROVIDER}_API_KEY env var (line 601-610)
  • --set-env NAME=value — Arbitrary env var injection (line 590-598)
  • Model settings files — Separate .aider.model.settings.yml chain with its own generate_search_path_list() (home → git root → CWD → custom path), loaded via models.register_models()
  • Model metadata.aider.model.metadata.json with similar search path, plus a bundled resource file

Everything flows through argparse. There is no separate “config object” or “config layer stack.” The parser itself IS the merge engine. This makes the system simple but means there’s no way to inspect which layer a value came from at runtime.


Reference: references/codex/codex-rs/ at commit 4ab44e2c5

Codex has the most sophisticated config system of the three: a formal layer stack with precedence ordering, TOML-based configs, trust-gated project layers, enterprise MDM support, and runtime CLI overrides.

The core abstraction is ConfigLayerStack in codex-rs/config/src/state.rs. It holds a Vec<ConfigLayerEntry> ordered from lowest to highest precedence:

// codex-rs/app-server-protocol/src/protocol/v2.rs:290-303
impl ConfigLayerSource {
pub fn precedence(&self) -> i16 {
match self {
ConfigLayerSource::Mdm { .. } => 0,
ConfigLayerSource::System { .. } => 10,
ConfigLayerSource::User { .. } => 20,
ConfigLayerSource::Project { .. } => 25,
ConfigLayerSource::SessionFlags => 30,
ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => 40,
ConfigLayerSource::LegacyManagedConfigTomlFromMdm => 50,
}
}
}

Each layer is a ConfigLayerEntry containing a toml::Value tree, a version fingerprint, and an optional disabled_reason (for untrusted project configs).

The main loader is load_config_layers_state() in codex-rs/core/src/config_loader/mod.rs (line 102). It constructs the stack in this order:

  1. System/etc/codex/config.toml (Unix) or %ProgramData%\OpenAI\Codex\config.toml (Windows)
  2. User$CODEX_HOME/config.toml (typically ~/.codex/config.toml)
  3. Project.codex/config.toml in each ancestor directory from project root to CWD, subject to trust checks
  4. SessionFlags — CLI -c key=value overrides
  5. Legacy managedmanaged_config.toml from file and/or macOS MDM (being phased out in favor of requirements.toml)

The -c key=value flag is defined in codex-rs/utils/cli/src/config_override.rs:

// CliConfigOverrides struct
#[arg(short = 'c', long = "config", value_name = "key=value",
action = ArgAction::Append, global = true)]
pub raw_overrides: Vec<String>,

The value portion is parsed as TOML first, falling back to a raw string. This means -c model="gpt-5.2-codex" produces a TOML string, while -c 'sandbox_permissions=["disk-full-read-access"]' produces a TOML array. Dotted paths like -c shell_environment_policy.inherit=all drill into nested tables.

The override tree is built by build_cli_overrides_layer() in codex-rs/config/src/overrides.rs, which walks each dotted path and calls apply_toml_override() to insert values into a TOML table hierarchy.

codex-rs/config/src/merge.rs
pub fn merge_toml_values(base: &mut TomlValue, overlay: &TomlValue) {
if let TomlValue::Table(overlay_table) = overlay
&& let TomlValue::Table(base_table) = base
{
for (key, value) in overlay_table {
if let Some(existing) = base_table.get_mut(key) {
merge_toml_values(existing, value);
} else {
base_table.insert(key.clone(), value.clone());
}
}
} else {
*base = overlay.clone();
}
}

Tables merge recursively; non-table values replace entirely. Arrays are replaced, not concatenated. The effective config is computed by ConfigLayerStack::effective_config(), which merges all enabled layers from lowest to highest precedence.

Project configs from .codex/config.toml are loaded but potentially disabled. The trust decision is per-directory, falling back to project root, then repo root. The user must explicitly add directories to projects_trust in their user config. Untrusted layers get a disabled_reason and are skipped during merge.

The Config struct (v2.rs:408) includes profile: Option<String> and profiles: HashMap<String, ProfileV2>. A profile bundles model, approval policy, reasoning effort, and other model-specific settings that can be switched as a unit.

ConfigLayerStack::origins() returns a HashMap<String, ConfigLayerMetadata> that maps each config key path to the layer that set it. This enables the TUI to show users where each setting came from.


Reference: references/opencode/ at commit 7ed449974

OpenCode uses a multi-tier JSONC loading system with Zod schema validation, environment variable interpolation, and a custom merge() wrapper built on mergeDeep() from remeda.

The full chain is in packages/opencode/src/config/config.ts, in the Config.state() function (line ~56):

PrioritySourceLocation
1 (lowest)Remote.well-known/opencode from auth URL
2Global~/.config/opencode/{config,opencode}.json{c}
3Custom$OPENCODE_CONFIG env var path
4Projectopencode.json{c} found via findUp()
5.opencode dirs.opencode/opencode.json{c} in project/global dirs
6Inline$OPENCODE_CONFIG_CONTENT env var (raw JSON)
7 (highest)Managed/etc/opencode/ (Linux), /Library/Application Support/opencode/ (macOS), %ProgramData%\opencode\ (Windows)

The managed config directory (getManagedConfigDir(), line 43) is intended for enterprise deployments and has the highest precedence, overriding everything.

Config files are JSONC (JSON with Comments), parsed via the jsonc-parser library with allowTrailingComma: true. This is a significant UX improvement over strict JSON — users can comment out settings and use trailing commas.

The load() function (line 1244) performs two kinds of interpolation before parsing JSONC:

  1. Environment variables: {env:VAR_NAME}process.env[VAR_NAME]
  2. File references: {file:path/to/file} → contents of the referenced file, with ~ expansion and relative-to-config-dir resolution

Commented-out {file:...} references are skipped (line 1257).

OpenCode uses a custom merge(target, source) helper layered on mergeDeep(). The helper explicitly concatenates and de-duplicates plugin and instructions arrays (Array.from(new Set([...]))), while other keys follow normal deep-merge behavior.

// config.ts:1203-1209 (global config loading)
let result: Info = pipe(
{},
mergeDeep(await loadFile(path.join(Global.Path.config, "config.json"))),
mergeDeep(await loadFile(path.join(Global.Path.config, "opencode.json"))),
mergeDeep(await loadFile(path.join(Global.Path.config, "opencode.jsonc"))),
)

Every loaded config file is validated against the Config.Info Zod schema (line 1311: Info.safeParse(data)). Invalid configs throw InvalidError with detailed issue information. The schema is strict(), meaning unknown keys cause validation failures.

OpenCode uses a Flag module (flag/flag.ts) to access environment variables:

  • OPENCODE_CONFIG — Custom config file path
  • OPENCODE_CONFIG_CONTENT — Inline config as JSON string
  • OPENCODE_CONFIG_DIR — Custom config directory (dynamically read)
  • OPENCODE_DISABLE_PROJECT_CONFIG — Skip project-level configs entirely

OpenCode exposes Config.update() and Config.updateGlobal() for programmatic config modification. Updates to JSONC files use patchJsonc() which preserves comments, while JSON files do a full serialize/deserialize round-trip. After writes, Instance.dispose() is called to force re-loading.


Codex replaces arrays. OpenCode mostly deep-merges objects, but explicitly concatenates some arrays (plugin, instructions). Both choices have tradeoffs. Replacement makes extension awkward; concatenation can make subtraction awkward without an explicit “remove” mechanism. Aider sidesteps most of this by not having a deeply nested config object model.

Codex’s trust system loads untrusted configs but marks them disabled. This is a security-conscious choice, but it means users get confusing “config loaded but not applied” states. The trust check is per-directory, so mono-repos with multiple .codex/ directories can have partially trusted configs.

Aider’s auto_env_var_prefix creates hundreds of implicit env vars. Any typo in AIDER_MODLE (vs AIDER_MODEL) silently falls back to the default. There’s no validation that an env var name actually maps to a real flag.

Each tool chose a different config format. TOML (Codex) is strict about types but verbose for nested structures. YAML (Aider) is concise but whitespace-sensitive. JSONC (OpenCode) allows comments but is still fundamentally JSON. None of these is clearly superior.

Codex’s origins() API is unique — it tells you which layer set each value. Aider and OpenCode have no equivalent, making it hard to debug “why is this setting active?” problems. This should be a first-class feature.

Enterprise Config Must Be Highest Priority

Section titled “Enterprise Config Must Be Highest Priority”

Both Codex and OpenCode learned this: managed/enterprise configs must override everything, including CLI flags. Codex’s LegacyManagedConfigTomlFromMdm (precedence 50) sits above SessionFlags (30). OpenCode’s managed config dir loads last and wins.


Follow Codex. TOML is the natural choice for a Rust project — serde + toml crate gives zero-cost deserialization into typed structs. The toml crate handles all parsing, and TOML’s explicit type system prevents the “is this a string or a number?” ambiguity common in YAML.

Adopt Codex’s ConfigLayerStack pattern. Each layer is a (source, toml::Value) pair with a numeric precedence:

pub enum ConfigSource {
System, // /etc/openoxide/config.toml
User, // ~/.openoxide/config.toml
Project, // .openoxide/config.toml (per-directory, trust-gated)
Session, // -c key=value CLI overrides
}

Use recursive table merge with explicit array strategy:

pub enum ArrayMerge {
Replace, // Default: project array replaces user array
Append, // Opt-in: project array appends to user array
}

Array keys that should concatenate (like instructions, plugins) can be annotated in the schema. This avoids both Codex’s “can’t extend” and OpenCode’s “can’t remove” problems.

Port Codex’s -c key=value approach:

#[derive(clap::Parser)]
pub struct ConfigOverrides {
#[arg(short = 'c', long = "config", value_name = "key=value",
action = ArgAction::Append, global = true)]
pub overrides: Vec<String>,
}

Parse values as TOML, fall back to raw string. Use dotted paths for nested access. This is the highest-precedence layer.

Support OPENOXIDE_* prefix for common settings, but map explicitly rather than auto-generating. Only a curated set of env vars should be recognized:

  • OPENOXIDE_MODEL — Default model
  • OPENOXIDE_HOME — Config directory override
  • OPENOXIDE_LOG — Log level
  • OPENOXIDE_CONFIG — Custom config file path

This avoids Aider’s env-var explosion while keeping the most useful overrides.

Implement from day one. Every resolved config value should carry metadata about which layer defined it:

pub struct ResolvedConfig {
pub effective: Config,
pub origins: HashMap<String, ConfigOrigin>,
}
pub struct ConfigOrigin {
pub source: ConfigSource,
pub file: Option<PathBuf>,
}

This enables the TUI to show “model: o4-mini (from ~/.openoxide/config.toml)” and helps debug precedence conflicts.

CratePurpose
tomlTOML parsing and serialization
clapCLI argument parsing with -c override support
serde + serde_jsonDeserialization and layer merging
dirsPlatform-specific config directory paths
duncePath canonicalization without UNC on Windows

Validate config files at load time using serde’s #[serde(deny_unknown_fields)] on the config struct. Unknown keys should produce clear error messages pointing to the offending file and line, similar to Codex’s diagnostics.rs module.