Files
codex/codex-rs/utils/cli/src/shared_options.rs
T
jif-oai deedf3b2c4 feat: add layered --profile-v2 config files (#17141)
## Why

`--profile-v2 <name>` gives launchers and runtime entry points a named
profile config without making each profile duplicate the base user
config. The base `$CODEX_HOME/config.toml` still loads first, then
`$CODEX_HOME/<name>.config.toml` layers above it and becomes the active
writable user config for that session.

That keeps shared defaults, plugin/MCP setup, and managed/user
constraints in one place while letting a named profile override only the
pieces that need to differ.

## What Changed

- Added the shared `--profile-v2 <name>` runtime option with validated
plain names, now represented by `ProfileV2Name`.
- Extended config layer state so the base user config and selected
profile config are both `User` layers; APIs expose the active user layer
and merged effective user config.
- Threaded profile selection through runtime entry points: `codex`,
`codex exec`, `codex review`, `codex resume`, `codex fork`, and `codex
debug prompt-input`.
- Made user-facing config writes go to the selected profile file when
active, including TUI/settings persistence, app-server config writes,
and MCP/app tool approval persistence.
- Made plugin, marketplace, MCP, hooks, and config reload paths read
from the merged user config so base and profile layers both participate.
- Updated app-server config layer schemas to mark profile-backed user
layers.

## Limits

`--profile-v2` is still rejected for config-management subcommands such
as feature, MCP, and marketplace edits. Those paths remain tied to the
base `config.toml` until they have explicit profile-selection semantics.

Some adjacent background writes may still update base or global state
rather than the selected profile:

- marketplace auto-upgrade metadata
- automatic MCP dependency installs from skills
- remote plugin sync or uninstall config edits
- personality migration marker/default writes

## Verification

Added targeted coverage for profile name validation, layer
ordering/merging, selected-profile writes, app-server config writes,
session hot reload, plugin config merging, hooks/config fixture updates,
and MCP/app approval persistence.

---------

Co-authored-by: Codex <noreply@openai.com>
2026-05-14 15:16:15 +02:00

191 lines
6.3 KiB
Rust

//! Shared command-line flags used by both interactive and non-interactive Codex entry points.
use crate::SandboxModeCliArg;
use clap::Args;
use codex_protocol::config_types::ProfileV2Name;
use std::path::PathBuf;
#[derive(Args, Debug, Default)]
pub struct SharedCliOptions {
/// Optional image(s) to attach to the initial prompt.
#[arg(
long = "image",
short = 'i',
value_name = "FILE",
value_delimiter = ',',
num_args = 1..
)]
pub images: Vec<PathBuf>,
/// Model the agent should use.
#[arg(long, short = 'm')]
pub model: Option<String>,
/// Use open-source provider.
#[arg(long = "oss", default_value_t = false)]
pub oss: bool,
/// Specify which local provider to use (lmstudio or ollama).
/// If not specified with --oss, will use config default or show selection.
#[arg(long = "local-provider")]
pub oss_provider: Option<String>,
/// Configuration profile from config.toml to specify default options.
#[arg(long = "profile", short = 'p')]
pub config_profile: Option<String>,
/// Layer $CODEX_HOME/<name>.config.toml on top of the base user config.
#[arg(long = "profile-v2")]
pub config_profile_v2: Option<ProfileV2Name>,
/// Select the sandbox policy to use when executing model-generated shell
/// commands.
#[arg(long = "sandbox", short = 's')]
pub sandbox_mode: Option<SandboxModeCliArg>,
/// Skip all confirmation prompts and execute commands without sandboxing.
/// EXTREMELY DANGEROUS. Intended solely for running in environments that are externally sandboxed.
#[arg(
long = "dangerously-bypass-approvals-and-sandbox",
alias = "yolo",
default_value_t = false
)]
pub dangerously_bypass_approvals_and_sandbox: bool,
/// Run enabled hooks without requiring persisted hook trust for this invocation.
/// DANGEROUS. Intended only for automation that already vets hook sources.
#[arg(long = "dangerously-bypass-hook-trust", default_value_t = false)]
pub bypass_hook_trust: bool,
/// Tell the agent to use the specified directory as its working root.
#[clap(long = "cd", short = 'C', value_name = "DIR")]
pub cwd: Option<PathBuf>,
/// Additional directories that should be writable alongside the primary workspace.
#[arg(long = "add-dir", value_name = "DIR", value_hint = clap::ValueHint::DirPath)]
pub add_dir: Vec<PathBuf>,
}
impl SharedCliOptions {
pub fn inherit_exec_root_options(&mut self, root: &Self) {
let self_selected_sandbox_mode =
self.sandbox_mode.is_some() || self.dangerously_bypass_approvals_and_sandbox;
let Self {
images,
model,
oss,
oss_provider,
config_profile,
config_profile_v2,
sandbox_mode,
dangerously_bypass_approvals_and_sandbox,
bypass_hook_trust,
cwd,
add_dir,
} = self;
let Self {
images: root_images,
model: root_model,
oss: root_oss,
oss_provider: root_oss_provider,
config_profile: root_config_profile,
config_profile_v2: root_config_profile_v2,
sandbox_mode: root_sandbox_mode,
dangerously_bypass_approvals_and_sandbox: root_dangerously_bypass_approvals_and_sandbox,
bypass_hook_trust: root_bypass_hook_trust,
cwd: root_cwd,
add_dir: root_add_dir,
} = root;
if model.is_none() {
model.clone_from(root_model);
}
if *root_oss {
*oss = true;
}
if oss_provider.is_none() {
oss_provider.clone_from(root_oss_provider);
}
if config_profile.is_none() {
config_profile.clone_from(root_config_profile);
}
if config_profile_v2.is_none() {
config_profile_v2.clone_from(root_config_profile_v2);
}
if sandbox_mode.is_none() {
*sandbox_mode = *root_sandbox_mode;
}
if !self_selected_sandbox_mode {
*dangerously_bypass_approvals_and_sandbox =
*root_dangerously_bypass_approvals_and_sandbox;
}
if !*bypass_hook_trust {
*bypass_hook_trust = *root_bypass_hook_trust;
}
if cwd.is_none() {
cwd.clone_from(root_cwd);
}
if !root_images.is_empty() {
let mut merged_images = root_images.clone();
merged_images.append(images);
*images = merged_images;
}
if !root_add_dir.is_empty() {
let mut merged_add_dir = root_add_dir.clone();
merged_add_dir.append(add_dir);
*add_dir = merged_add_dir;
}
}
pub fn apply_subcommand_overrides(&mut self, subcommand: Self) {
let subcommand_selected_sandbox_mode = subcommand.sandbox_mode.is_some()
|| subcommand.dangerously_bypass_approvals_and_sandbox;
let Self {
images,
model,
oss,
oss_provider,
config_profile,
config_profile_v2,
sandbox_mode,
dangerously_bypass_approvals_and_sandbox,
bypass_hook_trust,
cwd,
add_dir,
} = subcommand;
if let Some(model) = model {
self.model = Some(model);
}
if oss {
self.oss = true;
}
if let Some(oss_provider) = oss_provider {
self.oss_provider = Some(oss_provider);
}
if let Some(config_profile) = config_profile {
self.config_profile = Some(config_profile);
}
if let Some(config_profile_v2) = config_profile_v2 {
self.config_profile_v2 = Some(config_profile_v2);
}
if subcommand_selected_sandbox_mode {
self.sandbox_mode = sandbox_mode;
self.dangerously_bypass_approvals_and_sandbox =
dangerously_bypass_approvals_and_sandbox;
}
if bypass_hook_trust {
self.bypass_hook_trust = true;
}
if let Some(cwd) = cwd {
self.cwd = Some(cwd);
}
if !images.is_empty() {
self.images = images;
}
if !add_dir.is_empty() {
self.add_dir.extend(add_dir);
}
}
}