feat: load defaults into Config and introduce ConfigOverrides (#677)
This changes how instantiating `Config` works and also adds `approval_policy` and `sandbox_policy` as fields. The idea is: * All fields of `Config` have appropriate default values. * `Config` is initially loaded from `~/.codex/config.toml`, so values in `config.toml` will override those defaults. * Clients must instantiate `Config` via `Config::load_with_overrides(ConfigOverrides)` where `ConfigOverrides` has optional overrides that are expected to be settable based on CLI flags. The `Config` should be defined early in the program and then passed down. Now functions like `init_codex()` take fewer individual parameters because they can just take a `Config`. Also, `Config::load()` used to fail silently if `~/.codex/config.toml` had a parse error and fell back to the default config. This seemed really bad because it wasn't clear why the values in my `config.toml` weren't getting picked up. I changed things so that `load_with_overrides()` returns `Result<Config>` and verified that the various CLIs print a reasonable error if `config.toml` is malformed. Finally, I also updated the TUI to show which **sandbox** value is being used, as we do for other key values like **model** and **approval**. This was also a reminder that the various values of `--sandbox` are honored on Linux but not macOS today, so I added some TODOs about fixing that.
This commit is contained in:
@@ -6,7 +6,7 @@ use clap::ValueEnum;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
|
||||
#[derive(Clone, Debug, ValueEnum)]
|
||||
#[derive(Clone, Copy, Debug, ValueEnum)]
|
||||
#[value(rename_all = "kebab-case")]
|
||||
pub enum ApprovalModeCliArg {
|
||||
/// Run all commands without asking for user approval.
|
||||
@@ -24,7 +24,7 @@ pub enum ApprovalModeCliArg {
|
||||
Never,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, ValueEnum)]
|
||||
#[derive(Clone, Copy, Debug, ValueEnum)]
|
||||
#[value(rename_all = "kebab-case")]
|
||||
pub enum SandboxModeCliArg {
|
||||
/// Network syscalls will be blocked
|
||||
|
||||
@@ -36,7 +36,6 @@ use crate::exec::process_exec_tool_call;
|
||||
use crate::exec::ExecParams;
|
||||
use crate::exec::ExecToolCallOutput;
|
||||
use crate::exec::SandboxType;
|
||||
use crate::flags::OPENAI_DEFAULT_MODEL;
|
||||
use crate::flags::OPENAI_STREAM_MAX_RETRIES;
|
||||
use crate::models::ContentItem;
|
||||
use crate::models::FunctionCallOutputPayload;
|
||||
@@ -486,7 +485,6 @@ async fn submission_loop(
|
||||
sandbox_policy,
|
||||
disable_response_storage,
|
||||
} => {
|
||||
let model = model.unwrap_or_else(|| OPENAI_DEFAULT_MODEL.to_string());
|
||||
info!(model, "Configuring session");
|
||||
let client = ModelClient::new(model.clone());
|
||||
|
||||
|
||||
@@ -2,16 +2,13 @@ use std::sync::atomic::AtomicU64;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::Event;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::Op;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::protocol::Submission;
|
||||
use crate::util::notify_on_sigint;
|
||||
use crate::Codex;
|
||||
use tokio::sync::Notify;
|
||||
use tracing::debug;
|
||||
|
||||
/// Spawn a new [`Codex`] and initialise the session.
|
||||
///
|
||||
@@ -19,21 +16,17 @@ use tracing::debug;
|
||||
/// is received as a response to the initial `ConfigureSession` submission so
|
||||
/// that callers can surface the information to the UI.
|
||||
pub async fn init_codex(
|
||||
approval_policy: AskForApproval,
|
||||
sandbox_policy: SandboxPolicy,
|
||||
config: Config,
|
||||
disable_response_storage: bool,
|
||||
model_override: Option<String>,
|
||||
) -> anyhow::Result<(CodexWrapper, Event, Arc<Notify>)> {
|
||||
let ctrl_c = notify_on_sigint();
|
||||
let config = Config::load().unwrap_or_default();
|
||||
debug!("loaded config: {config:?}");
|
||||
let codex = CodexWrapper::new(Codex::spawn(ctrl_c.clone())?);
|
||||
let init_id = codex
|
||||
.submit(Op::ConfigureSession {
|
||||
model: model_override.or_else(|| config.model.clone()),
|
||||
instructions: config.instructions,
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
model: config.model.clone(),
|
||||
instructions: config.instructions.clone(),
|
||||
approval_policy: config.approval_policy,
|
||||
sandbox_policy: config.sandbox_policy,
|
||||
disable_response_storage,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -1,39 +1,98 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::flags::OPENAI_DEFAULT_MODEL;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use dirs::home_dir;
|
||||
use serde::Deserialize;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Embedded fallback instructions that mirror the TypeScript CLI’s default system prompt. These
|
||||
/// are compiled into the binary so a clean install behaves correctly even if the user has not
|
||||
/// created `~/.codex/instructions.md`.
|
||||
/// Embedded fallback instructions that mirror the TypeScript CLI’s default
|
||||
/// system prompt. These are compiled into the binary so a clean install behaves
|
||||
/// correctly even if the user has not created `~/.codex/instructions.md`.
|
||||
const EMBEDDED_INSTRUCTIONS: &str = include_str!("../prompt.md");
|
||||
|
||||
#[derive(Default, Deserialize, Debug, Clone)]
|
||||
/// Application configuration loaded from disk and merged with overrides.
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct Config {
|
||||
pub model: Option<String>,
|
||||
/// Optional override of model selection.
|
||||
#[serde(default = "default_model")]
|
||||
pub model: String,
|
||||
/// Default approval policy for executing commands.
|
||||
#[serde(default)]
|
||||
pub approval_policy: AskForApproval,
|
||||
#[serde(default)]
|
||||
pub sandbox_policy: SandboxPolicy,
|
||||
/// System instructions.
|
||||
pub instructions: Option<String>,
|
||||
}
|
||||
|
||||
/// Optional overrides for user configuration (e.g., from CLI flags).
|
||||
#[derive(Default, Debug, Clone)]
|
||||
pub struct ConfigOverrides {
|
||||
pub model: Option<String>,
|
||||
pub approval_policy: Option<AskForApproval>,
|
||||
pub sandbox_policy: Option<SandboxPolicy>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Load ~/.codex/config.toml and ~/.codex/instructions.md (if present).
|
||||
/// Returns `None` if neither file exists.
|
||||
pub fn load() -> Option<Self> {
|
||||
let mut cfg: Config = Self::load_from_toml().unwrap_or_default();
|
||||
|
||||
// Highest precedence → user‑provided ~/.codex/instructions.md (if present)
|
||||
// Fallback → embedded default instructions baked into the binary
|
||||
/// Load configuration, optionally applying overrides (CLI flags). Merges
|
||||
/// ~/.codex/config.toml, ~/.codex/instructions.md, embedded defaults, and
|
||||
/// any values provided in `overrides` (highest precedence).
|
||||
pub fn load_with_overrides(overrides: ConfigOverrides) -> std::io::Result<Self> {
|
||||
let mut cfg: Config = Self::load_from_toml()?;
|
||||
tracing::warn!("Config parsed from config.toml: {cfg:?}");
|
||||
|
||||
// Instructions: user-provided instructions.md > embedded default.
|
||||
cfg.instructions =
|
||||
Self::load_instructions().or_else(|| Some(EMBEDDED_INSTRUCTIONS.to_string()));
|
||||
|
||||
Some(cfg)
|
||||
// Destructure ConfigOverrides fully to ensure all overrides are applied.
|
||||
let ConfigOverrides {
|
||||
model,
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
} = overrides;
|
||||
|
||||
if let Some(model) = model {
|
||||
cfg.model = model;
|
||||
}
|
||||
if let Some(approval_policy) = approval_policy {
|
||||
cfg.approval_policy = approval_policy;
|
||||
}
|
||||
if let Some(sandbox_policy) = sandbox_policy {
|
||||
cfg.sandbox_policy = sandbox_policy;
|
||||
}
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
fn load_from_toml() -> Option<Self> {
|
||||
let mut p = codex_dir().ok()?;
|
||||
p.push("config.toml");
|
||||
let contents = std::fs::read_to_string(&p).ok()?;
|
||||
toml::from_str(&contents).ok()
|
||||
/// Attempt to parse the file at `~/.codex/config.toml` into a Config.
|
||||
fn load_from_toml() -> std::io::Result<Self> {
|
||||
let config_toml_path = codex_dir()?.join("config.toml");
|
||||
match std::fs::read_to_string(&config_toml_path) {
|
||||
Ok(contents) => toml::from_str::<Self>(&contents).map_err(|e| {
|
||||
tracing::error!("Failed to parse config.toml: {e}");
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidData, e)
|
||||
}),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
tracing::info!("config.toml not found, using defaults");
|
||||
Ok(Self::load_default_config())
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to read config.toml: {e}");
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Meant to be used exclusively for tests: load_with_overrides() should be
|
||||
/// used in all other cases.
|
||||
pub fn load_default_config_for_test() -> Self {
|
||||
Self::load_default_config()
|
||||
}
|
||||
|
||||
fn load_default_config() -> Self {
|
||||
// Load from an empty string to exercise #[serde(default)] to
|
||||
// get the default values for each field.
|
||||
toml::from_str::<Self>("").expect("empty string should parse as TOML")
|
||||
}
|
||||
|
||||
fn load_instructions() -> Option<String> {
|
||||
@@ -43,6 +102,10 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
fn default_model() -> String {
|
||||
OPENAI_DEFAULT_MODEL.to_string()
|
||||
}
|
||||
|
||||
/// Returns the path to the Codex configuration directory, which is `~/.codex`.
|
||||
/// Does not verify that the directory exists.
|
||||
pub fn codex_dir() -> std::io::Result<PathBuf> {
|
||||
|
||||
@@ -98,7 +98,7 @@ pub async fn process_exec_tool_call(
|
||||
workdir,
|
||||
timeout_ms,
|
||||
} = params;
|
||||
let seatbelt_command = create_seatbelt_command(command, writable_roots);
|
||||
let seatbelt_command = create_seatbelt_command(command, sandbox_policy, writable_roots);
|
||||
exec(
|
||||
ExecParams {
|
||||
command: seatbelt_command,
|
||||
@@ -154,7 +154,11 @@ pub async fn process_exec_tool_call(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_seatbelt_command(command: Vec<String>, writable_roots: &[PathBuf]) -> Vec<String> {
|
||||
pub fn create_seatbelt_command(
|
||||
command: Vec<String>,
|
||||
sandbox_policy: SandboxPolicy,
|
||||
writable_roots: &[PathBuf],
|
||||
) -> Vec<String> {
|
||||
let (policies, cli_args): (Vec<String>, Vec<String>) = writable_roots
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -166,6 +170,14 @@ pub fn create_seatbelt_command(command: Vec<String>, writable_roots: &[PathBuf])
|
||||
})
|
||||
.unzip();
|
||||
|
||||
// TODO(ragona): The seatbelt policy should reflect the SandboxPolicy that
|
||||
// is passed, but everything is currently hardcoded to use
|
||||
// MACOS_SEATBELT_READONLY_POLICY.
|
||||
// TODO(mbolin): apply_patch calls must also honor the SandboxPolicy.
|
||||
if !matches!(sandbox_policy, SandboxPolicy::NetworkRestricted) {
|
||||
tracing::error!("specified sandbox policy {sandbox_policy:?} will not be honroed");
|
||||
}
|
||||
|
||||
let full_policy = if policies.is_empty() {
|
||||
MACOS_SEATBELT_READONLY_POLICY.to_string()
|
||||
} else {
|
||||
|
||||
@@ -26,7 +26,7 @@ pub enum Op {
|
||||
/// Configure the model session.
|
||||
ConfigureSession {
|
||||
/// If not specified, server will use its default model.
|
||||
model: Option<String>,
|
||||
model: String,
|
||||
/// Model instructions
|
||||
instructions: Option<String>,
|
||||
/// When to escalate for approval for execution
|
||||
@@ -66,11 +66,13 @@ pub enum Op {
|
||||
}
|
||||
|
||||
/// Determines how liberally commands are auto‑approved by the system.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum AskForApproval {
|
||||
/// Under this policy, only “known safe” commands—as determined by
|
||||
/// `is_safe_command()`—that **only read files** are auto‑approved.
|
||||
/// Everything else will ask the user to approve.
|
||||
#[default]
|
||||
UnlessAllowListed,
|
||||
|
||||
/// In addition to everything allowed by **`Suggest`**, commands that
|
||||
@@ -91,13 +93,15 @@ pub enum AskForApproval {
|
||||
}
|
||||
|
||||
/// Determines execution restrictions for model shell commands
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum SandboxPolicy {
|
||||
/// Network syscalls will be blocked
|
||||
NetworkRestricted,
|
||||
/// Filesystem writes will be restricted
|
||||
FileWriteRestricted,
|
||||
/// Network and filesystem writes will be restricted
|
||||
#[default]
|
||||
NetworkAndFileWriteRestricted,
|
||||
/// No restrictions; full "unsandboxed" mode
|
||||
DangerousNoRestrictions,
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::InputItem;
|
||||
use codex_core::protocol::Op;
|
||||
@@ -47,13 +47,14 @@ async fn spawn_codex() -> Codex {
|
||||
|
||||
let agent = Codex::spawn(std::sync::Arc::new(Notify::new())).unwrap();
|
||||
|
||||
let config = Config::load_default_config_for_test();
|
||||
agent
|
||||
.submit(Submission {
|
||||
id: "init".into(),
|
||||
op: Op::ConfigureSession {
|
||||
model: None,
|
||||
model: config.model,
|
||||
instructions: None,
|
||||
approval_policy: AskForApproval::OnFailure,
|
||||
approval_policy: config.approval_policy,
|
||||
sandbox_policy: SandboxPolicy::NetworkAndFileWriteRestricted,
|
||||
disable_response_storage: false,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::protocol::InputItem;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
@@ -87,13 +87,14 @@ async fn keeps_previous_response_id_between_tasks() {
|
||||
let codex = Codex::spawn(std::sync::Arc::new(tokio::sync::Notify::new())).unwrap();
|
||||
|
||||
// Init session
|
||||
let config = Config::load_default_config_for_test();
|
||||
codex
|
||||
.submit(Submission {
|
||||
id: "init".into(),
|
||||
op: Op::ConfigureSession {
|
||||
model: None,
|
||||
model: config.model,
|
||||
instructions: None,
|
||||
approval_policy: AskForApproval::OnFailure,
|
||||
approval_policy: config.approval_policy,
|
||||
sandbox_policy: SandboxPolicy::NetworkAndFileWriteRestricted,
|
||||
disable_response_storage: false,
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::protocol::InputItem;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
@@ -70,13 +70,14 @@ async fn retries_on_early_close() {
|
||||
|
||||
let codex = Codex::spawn(std::sync::Arc::new(tokio::sync::Notify::new())).unwrap();
|
||||
|
||||
let config = Config::load_default_config_for_test();
|
||||
codex
|
||||
.submit(Submission {
|
||||
id: "init".into(),
|
||||
op: Op::ConfigureSession {
|
||||
model: None,
|
||||
model: config.model,
|
||||
instructions: None,
|
||||
approval_policy: AskForApproval::OnFailure,
|
||||
approval_policy: config.approval_policy,
|
||||
sandbox_policy: SandboxPolicy::NetworkAndFileWriteRestricted,
|
||||
disable_response_storage: false,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user