Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions codex-rs/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 18 additions & 0 deletions codex-rs/chat-completions/MERGE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,21 @@ The patch release branched from the same release base as 0.157.0, so it needed
a second merge commit. Its Windows process-launch fixes merged without source
conflicts. The workspace version in `Cargo.toml` was resolved to `0.157.1`,
and Cargo regenerated the workspace package versions in `Cargo.lock`.

## 2026-09-28: codex-local default flags

Fork-only CLI hunks to retain on each upstream merge:

- `cli/src/local_defaults.rs` and `cli/src/local_defaults_tests.rs` read
`default_args` from `<codex_home>/codex-local.toml`, insert allowed `-c`,
`-m`, and `-p` flags after argv[0] for runtime invocations, and honor
`CODEX_LOCAL_NO_DEFAULTS=1`.
- `cli/src/main.rs` calls `apply_local_defaults` before `MultitoolCli` parsing.
- `cli/Cargo.toml` adds `codex-utils-home-dir`, with the corresponding lockfile
entry.

If upstream changes the runtime branch in `profile_v2_for_subcommand`, update
`RUNTIME_SUBCOMMANDS`. Its parity test forces classification of new subcommands.
The `-m` and `-p` deduplication uses clap argument IDs `model` and
`config_profile_v2` from `SharedCliOptions`; update those constants if upstream
renames the IDs.
1 change: 1 addition & 0 deletions codex-rs/cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ codex-terminal-detection = { workspace = true }
codex-thread-store = { workspace = true }
codex-tui = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-home-dir = { workspace = true }
codex-utils-path = { workspace = true }
codex-utils-path-uri = { workspace = true }
crossterm = { workspace = true, features = ["event-stream"] }
Expand Down
231 changes: 231 additions & 0 deletions codex-rs/cli/src/local_defaults.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
//! codex-local default flags.
//!
//! The fork binary (installed by the harness as `codex-local`) reads
//! `default_args` from `<codex_home>/codex-local.toml` and inserts them right
//! after argv[0] before clap parses the command line. Only a closed set of
//! flags is allowed (`-c`/`--config`, `-m`/`--model`, `-p`/`--profile`), and
//! defaults are applied only to runtime invocations (the TUI and the
//! subcommands that accept a root `--profile`, see
//! `profile_v2_for_subcommand` in `main.rs`).
//!
//! Set `CODEX_LOCAL_NO_DEFAULTS=1` to disable defaults for one invocation.

use crate::MultitoolCli;
use clap::CommandFactory;
use clap::FromArgMatches;
use clap::parser::ValueSource;
use std::ffi::OsString;
use std::path::Path;

/// File in `codex_home` holding the defaults.
pub(crate) const LOCAL_DEFAULTS_FILE: &str = "codex-local.toml";

/// Env var that disables defaults when set to `1`.
pub(crate) const NO_DEFAULTS_ENV: &str = "CODEX_LOCAL_NO_DEFAULTS";

/// Subcommands that accept a root `--profile` (mirrors the match arm in
/// `profile_v2_for_subcommand`). `debug` is deliberately not listed: only
/// `debug prompt-input` accepts a profile, and `debug models` must never break.
pub(crate) const RUNTIME_SUBCOMMANDS: &[&str] = &[
"agents",
"exec",
"review",
"resume",
"queue",
"archive",
"delete",
"unarchive",
"fork",
"mcp",
"sandbox",
];

/// Arg ids (as generated by clap derive from `SharedCliOptions`).
const MODEL_ID: &str = "model";
const PROFILE_ID: &str = "config_profile_v2";

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DefaultFlag {
Config,
Model,
Profile,
}

impl DefaultFlag {
fn from_name(name: &str) -> Option<Self> {
match name {
"-c" | "--config" => Some(Self::Config),
"-m" | "--model" => Some(Self::Model),
"-p" | "--profile" => Some(Self::Profile),
_ => None,
}
}

fn long(self) -> &'static str {
match self {
Self::Config => "--config",
Self::Model => "--model",
Self::Profile => "--profile",
}
}
}

/// Applies `codex-local.toml` defaults to `args` using the real environment.
pub(crate) fn apply_local_defaults(args: Vec<OsString>) -> Vec<OsString> {
let no_defaults = std::env::var_os(NO_DEFAULTS_ENV).is_some_and(|value| value == "1");
if no_defaults {
return args;
}
// If CODEX_HOME is invalid, skip defaults; the normal config load reports it.
let codex_home = codex_utils_home_dir::find_codex_home().ok();
apply_local_defaults_with(
args,
codex_home.as_ref().map(AsRef::as_ref),
no_defaults,
|message| eprintln!("{message}"),
)
}

fn apply_local_defaults_with(
args: Vec<OsString>,
codex_home: Option<&Path>,
no_defaults: bool,
mut warn: impl FnMut(&str),
) -> Vec<OsString> {
if no_defaults || args.is_empty() {
return args;
}
let Some(codex_home) = codex_home else {
return args;
};
let path = codex_home.join(LOCAL_DEFAULTS_FILE);
let contents = match std::fs::read_to_string(&path) {
Ok(contents) => contents,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return args,
Err(err) => {
warn(&format!(
"warning: ignoring {}: failed to read: {err}",
path.display()
));
return args;
}
};
let defaults = match parse_default_args(&contents) {
Ok(defaults) => defaults,
Err(reason) => {
warn(&format!("warning: ignoring {}: {reason}", path.display()));
return args;
}
};
if defaults.is_empty() {
return args;
}

// Lenient pre-parse of the original argv with clap's own flag table. On a
// parse error, pass argv through so clap reports the error normally.
let mut matches = match MultitoolCli::command().try_get_matches_from(args.iter()) {
Ok(matches) => matches,
Err(_) => return args,
};
let user = user_flags(&matches);
let subcommand = matches.subcommand_name().map(str::to_string);
if MultitoolCli::from_arg_matches_mut(&mut matches).is_err() {
return args;
}
if let Some(subcommand) = subcommand
&& !RUNTIME_SUBCOMMANDS.contains(&subcommand.as_str())
{
return args;
}

let mut inserted: Vec<OsString> = Vec::new();
for (flag, value) in defaults {
let keep = match flag {
DefaultFlag::Config => true,
DefaultFlag::Model => !user.model && !user.profile,
DefaultFlag::Profile => !user.profile,
};
if keep {
inserted.push(OsString::from(format!("{}={value}", flag.long())));
}
}

let mut out = Vec::with_capacity(args.len() + inserted.len());
let mut iter = args.into_iter();
out.extend(iter.next());
out.extend(inserted);
out.extend(iter);
out
}

#[derive(Default)]
struct UserFlags {
model: bool,
profile: bool,
}

/// Reports whether the user passed `-m`/`-p` (any alias or form) at the root or
/// in any nested subcommand.
fn user_flags(matches: &clap::ArgMatches) -> UserFlags {
let mut flags = UserFlags::default();
let mut current = Some(matches);
while let Some(level) = current {
flags.model |= set_on_command_line(level, MODEL_ID);
flags.profile |= set_on_command_line(level, PROFILE_ID);
current = level.subcommand().map(|(_, sub)| sub);
}
flags
}

fn set_on_command_line(matches: &clap::ArgMatches, id: &str) -> bool {
// `value_source` panics in debug builds for ids unknown to this command.
matches.ids().any(|known| known.as_str() == id)
&& matches.value_source(id) == Some(ValueSource::CommandLine)
}

/// Parses and validates `default_args`. Every entry must form an allowed
/// flag/value pair; anything else rejects the whole file.
fn parse_default_args(contents: &str) -> Result<Vec<(DefaultFlag, String)>, String> {
let table: toml::Table = contents
.parse()
.map_err(|err| format!("invalid TOML: {err}"))?;
let raw = table
.get("default_args")
.ok_or_else(|| "missing `default_args`".to_string())?;
let items = raw
.as_array()
.ok_or_else(|| "`default_args` must be an array of strings".to_string())?;
let tokens = items
.iter()
.map(|item| item.as_str())
.collect::<Option<Vec<_>>>()
.ok_or_else(|| "`default_args` must be an array of strings".to_string())?;

let mut defaults = Vec::new();
let mut iter = tokens.into_iter();
while let Some(token) = iter.next() {
let (name, inline_value) = match token.split_once('=') {
Some((name, value)) if name.starts_with('-') => (name, Some(value)),
_ => (token, None),
};
let flag = DefaultFlag::from_name(name).ok_or_else(|| {
format!("`{token}` is not allowed in `default_args` (allowed: -c/--config, -m/--model, -p/--profile)")
})?;
let value = match inline_value {
Some(value) => value,
None => match iter.next() {
Some(value) if !value.starts_with('-') => value,
_ => return Err(format!("`{name}` in `default_args` is missing a value")),
},
};
if value.is_empty() {
return Err(format!("`{name}` in `default_args` has an empty value"));
}
defaults.push((flag, value.to_string()));
}
Ok(defaults)
}

#[cfg(test)]
#[path = "local_defaults_tests.rs"]
mod tests;
Loading
Loading