diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 2eaaa1c8..d79f0f33 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -5,6 +5,7 @@ use std::path::PathBuf; use clap::ArgAction; use clap::Parser; +use codex_core::SandboxModeCliArg; use codex_exec::Cli as ExecCli; use codex_interactive::Cli as InteractiveCli; use codex_repl::Cli as ReplCli; @@ -70,6 +71,10 @@ struct SeatbeltCommand { #[arg(long = "writable-root", short = 'w', value_name = "DIR", action = ArgAction::Append, use_value_delimiter = false)] writable_roots: Vec, + /// Configure the process restrictions for the command. + #[arg(long = "sandbox", short = 's')] + sandbox_policy: SandboxModeCliArg, + /// Full command args to run under seatbelt. #[arg(trailing_var_arg = true)] command: Vec, @@ -101,9 +106,10 @@ async fn main() -> anyhow::Result<()> { Some(Subcommand::Debug(debug_args)) => match debug_args.cmd { DebugCommand::Seatbelt(SeatbeltCommand { command, + sandbox_policy, writable_roots, }) => { - seatbelt::run_seatbelt(command, writable_roots).await?; + seatbelt::run_seatbelt(command, sandbox_policy.into(), writable_roots).await?; } }, } diff --git a/codex-rs/cli/src/seatbelt.rs b/codex-rs/cli/src/seatbelt.rs index c395d96c..d328f552 100644 --- a/codex-rs/cli/src/seatbelt.rs +++ b/codex-rs/cli/src/seatbelt.rs @@ -1,11 +1,13 @@ use codex_core::exec::create_seatbelt_command; +use codex_core::protocol::SandboxPolicy; use std::path::PathBuf; pub(crate) async fn run_seatbelt( command: Vec, + sandbox_policy: SandboxPolicy, writable_roots: Vec, ) -> anyhow::Result<()> { - let seatbelt_command = create_seatbelt_command(command, &writable_roots); + let seatbelt_command = create_seatbelt_command(command, sandbox_policy, &writable_roots); let status = tokio::process::Command::new(seatbelt_command[0].clone()) .args(&seatbelt_command[1..]) .spawn() diff --git a/codex-rs/core/src/approval_mode_cli_arg.rs b/codex-rs/core/src/approval_mode_cli_arg.rs index eb90b24d..0da6a89e 100644 --- a/codex-rs/core/src/approval_mode_cli_arg.rs +++ b/codex-rs/core/src/approval_mode_cli_arg.rs @@ -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 diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index cfeb7e40..2f80e505 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -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()); diff --git a/codex-rs/core/src/codex_wrapper.rs b/codex-rs/core/src/codex_wrapper.rs index 8d19683f..3aeff676 100644 --- a/codex-rs/core/src/codex_wrapper.rs +++ b/codex-rs/core/src/codex_wrapper.rs @@ -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, ) -> anyhow::Result<(CodexWrapper, Event, Arc)> { 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?; diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index b5574cea..d9ad3336 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -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, + /// 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, } +/// Optional overrides for user configuration (e.g., from CLI flags). +#[derive(Default, Debug, Clone)] +pub struct ConfigOverrides { + pub model: Option, + pub approval_policy: Option, + pub sandbox_policy: Option, +} + impl Config { - /// Load ~/.codex/config.toml and ~/.codex/instructions.md (if present). - /// Returns `None` if neither file exists. - pub fn load() -> Option { - 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 { + 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 { - 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 { + let config_toml_path = codex_dir()?.join("config.toml"); + match std::fs::read_to_string(&config_toml_path) { + Ok(contents) => toml::from_str::(&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::("").expect("empty string should parse as TOML") } fn load_instructions() -> Option { @@ -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 { diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index aa414e62..4ce07acf 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -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, writable_roots: &[PathBuf]) -> Vec { +pub fn create_seatbelt_command( + command: Vec, + sandbox_policy: SandboxPolicy, + writable_roots: &[PathBuf], +) -> Vec { let (policies, cli_args): (Vec, Vec) = writable_roots .iter() .enumerate() @@ -166,6 +170,14 @@ pub fn create_seatbelt_command(command: Vec, 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 { diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs index 96c4ea48..139e2f2f 100644 --- a/codex-rs/core/src/protocol.rs +++ b/codex-rs/core/src/protocol.rs @@ -26,7 +26,7 @@ pub enum Op { /// Configure the model session. ConfigureSession { /// If not specified, server will use its default model. - model: Option, + model: String, /// Model instructions instructions: Option, /// 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, diff --git a/codex-rs/core/tests/live_agent.rs b/codex-rs/core/tests/live_agent.rs index 823cd73a..23876498 100644 --- a/codex-rs/core/tests/live_agent.rs +++ b/codex-rs/core/tests/live_agent.rs @@ -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, }, diff --git a/codex-rs/core/tests/previous_response_id.rs b/codex-rs/core/tests/previous_response_id.rs index de1309e8..24c86916 100644 --- a/codex-rs/core/tests/previous_response_id.rs +++ b/codex-rs/core/tests/previous_response_id.rs @@ -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, }, diff --git a/codex-rs/core/tests/stream_no_completed.rs b/codex-rs/core/tests/stream_no_completed.rs index c732a5fd..e696ea97 100644 --- a/codex-rs/core/tests/stream_no_completed.rs +++ b/codex-rs/core/tests/stream_no_completed.rs @@ -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, }, diff --git a/codex-rs/exec/Cargo.toml b/codex-rs/exec/Cargo.toml index f214f900..491dd4c1 100644 --- a/codex-rs/exec/Cargo.toml +++ b/codex-rs/exec/Cargo.toml @@ -14,7 +14,7 @@ path = "src/lib.rs" [dependencies] anyhow = "1" clap = { version = "4", features = ["derive"] } -codex-core = { path = "../core" } +codex-core = { path = "../core", features = ["cli"] } tokio = { version = "1", features = [ "io-std", "macros", diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 299e8587..1613845a 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -1,4 +1,5 @@ use clap::Parser; +use codex_core::SandboxModeCliArg; use std::path::PathBuf; #[derive(Parser, Debug)] @@ -12,6 +13,12 @@ pub struct Cli { #[arg(long, short = 'm')] pub model: Option, + /// Configure the process restrictions when a command is executed. + /// + /// Uses OS-specific sandboxing tools; Seatbelt on OSX, landlock+seccomp on Linux. + #[arg(long = "sandbox", short = 's')] + pub sandbox_policy: Option, + /// Allow running Codex outside a Git repository. #[arg(long = "skip-git-repo-check", default_value_t = false)] pub skip_git_repo_check: bool, diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index ab7d735e..daa07e46 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -3,13 +3,14 @@ use std::sync::Arc; pub use cli::Cli; use codex_core::codex_wrapper; +use codex_core::config::Config; +use codex_core::config::ConfigOverrides; use codex_core::protocol::AskForApproval; use codex_core::protocol::Event; use codex_core::protocol::EventMsg; use codex_core::protocol::FileChange; use codex_core::protocol::InputItem; use codex_core::protocol::Op; -use codex_core::protocol::SandboxPolicy; use codex_core::util::is_inside_git_repo; use tracing::debug; use tracing::error; @@ -33,6 +34,7 @@ pub async fn run_main(cli: Cli) -> anyhow::Result<()> { let Cli { images, model, + sandbox_policy, skip_git_repo_check, disable_response_storage, prompt, @@ -47,17 +49,17 @@ pub async fn run_main(cli: Cli) -> anyhow::Result<()> { std::process::exit(1); } - // TODO(mbolin): We are reworking the CLI args right now, so this will - // likely come from a new --execution-policy arg. - let approval_policy = AskForApproval::Never; - let sandbox_policy = SandboxPolicy::NetworkAndFileWriteRestricted; - let (codex_wrapper, event, ctrl_c) = codex_wrapper::init_codex( - approval_policy, - sandbox_policy, - disable_response_storage, - model, - ) - .await?; + // Load configuration and determine approval policy + let overrides = ConfigOverrides { + model: model.clone(), + // This CLI is intended to be headless and has no affordances for asking + // the user for approval. + approval_policy: Some(AskForApproval::Never), + sandbox_policy: sandbox_policy.map(Into::into), + }; + let config = Config::load_with_overrides(overrides)?; + let (codex_wrapper, event, ctrl_c) = + codex_wrapper::init_codex(config, disable_response_storage).await?; let codex = Arc::new(codex_wrapper); info!("Codex initialized with event: {event:?}"); diff --git a/codex-rs/interactive/src/cli.rs b/codex-rs/interactive/src/cli.rs index ffb61dfc..6d35a49a 100644 --- a/codex-rs/interactive/src/cli.rs +++ b/codex-rs/interactive/src/cli.rs @@ -21,8 +21,8 @@ pub struct Cli { /// Configure the process restrictions when a command is executed. /// /// Uses OS-specific sandboxing tools; Seatbelt on OSX, landlock+seccomp on Linux. - #[arg(long = "sandbox", short = 's', value_enum, default_value_t = SandboxModeCliArg::NetworkAndFileWriteRestricted)] - pub sandbox_policy: SandboxModeCliArg, + #[arg(long = "sandbox", short = 's')] + pub sandbox_policy: Option, /// Allow running Codex outside a Git repository. #[arg(long = "skip-git-repo-check", default_value_t = false)] diff --git a/codex-rs/repl/src/cli.rs b/codex-rs/repl/src/cli.rs index ec6c6525..a6b5bb73 100644 --- a/codex-rs/repl/src/cli.rs +++ b/codex-rs/repl/src/cli.rs @@ -34,14 +34,14 @@ pub struct Cli { pub no_ansi: bool, /// Configure when the model requires human approval before executing a command. - #[arg(long = "ask-for-approval", short = 'a', value_enum, default_value_t = ApprovalModeCliArg::OnFailure)] - pub approval_policy: ApprovalModeCliArg, + #[arg(long = "ask-for-approval", short = 'a')] + pub approval_policy: Option, /// Configure the process restrictions when a command is executed. /// /// Uses OS-specific sandboxing tools; Seatbelt on OSX, landlock+seccomp on Linux. - #[arg(long = "sandbox", short = 's', value_enum, default_value_t = SandboxModeCliArg::NetworkAndFileWriteRestricted)] - pub sandbox_policy: SandboxModeCliArg, + #[arg(long = "sandbox", short = 's')] + pub sandbox_policy: Option, /// Allow running Codex outside a Git repository. By default the CLI /// aborts early when the current working directory is **not** inside a diff --git a/codex-rs/repl/src/lib.rs b/codex-rs/repl/src/lib.rs index 0f9c47e4..74e54181 100644 --- a/codex-rs/repl/src/lib.rs +++ b/codex-rs/repl/src/lib.rs @@ -4,6 +4,7 @@ use std::io::Write; use std::sync::Arc; use codex_core::config::Config; +use codex_core::config::ConfigOverrides; use codex_core::protocol; use codex_core::protocol::FileChange; use codex_core::util::is_inside_git_repo; @@ -75,12 +76,18 @@ pub async fn run_main(cli: Cli) -> anyhow::Result<()> { // Initialize logging before any other work so early errors are captured. init_logger(cli.verbose, !cli.no_ansi); - let config = Config::load().unwrap_or_default(); + // Load config file and apply CLI overrides (model & approval policy) + let overrides = ConfigOverrides { + model: cli.model.clone(), + approval_policy: cli.approval_policy.map(Into::into), + sandbox_policy: cli.sandbox_policy.map(Into::into), + }; + let config = Config::load_with_overrides(overrides)?; codex_main(cli, config, ctrl_c).await } -async fn codex_main(mut cli: Cli, cfg: Config, ctrl_c: Arc) -> anyhow::Result<()> { +async fn codex_main(cli: Cli, cfg: Config, ctrl_c: Arc) -> anyhow::Result<()> { let mut builder = Codex::builder(); if let Some(path) = cli.record_submissions { builder = builder.record_submissions(path); @@ -93,10 +100,10 @@ async fn codex_main(mut cli: Cli, cfg: Config, ctrl_c: Arc) -> anyhow::R let init = protocol::Submission { id: init_id.clone(), op: protocol::Op::ConfigureSession { - model: cli.model.or(cfg.model), + model: cfg.model, instructions: cfg.instructions, - approval_policy: cli.approval_policy.into(), - sandbox_policy: cli.sandbox_policy.into(), + approval_policy: cfg.approval_policy, + sandbox_policy: cfg.sandbox_policy, disable_response_storage: cli.disable_response_storage, }, }; @@ -133,8 +140,8 @@ async fn codex_main(mut cli: Cli, cfg: Config, ctrl_c: Arc) -> anyhow::R // run loop let mut reader = InputReader::new(ctrl_c.clone()); loop { - let text = match cli.prompt.take() { - Some(input) => input, + let text = match &cli.prompt { + Some(input) => input.clone(), None => match reader.request_input().await? { Some(input) => input, None => { diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 8f27ce6e..c5da0b56 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -4,10 +4,9 @@ use crate::git_warning_screen::GitWarningOutcome; use crate::git_warning_screen::GitWarningScreen; use crate::scroll_event_helper::ScrollEventHelper; use crate::tui; -use codex_core::protocol::AskForApproval; +use codex_core::config::Config; use codex_core::protocol::Event; use codex_core::protocol::Op; -use codex_core::protocol::SandboxPolicy; use color_eyre::eyre::Result; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -34,12 +33,10 @@ pub(crate) struct App<'a> { impl App<'_> { pub(crate) fn new( - approval_policy: AskForApproval, - sandbox_policy: SandboxPolicy, + config: Config, initial_prompt: Option, show_git_warning: bool, initial_images: Vec, - model: Option, disable_response_storage: bool, ) -> Self { let (app_event_tx, app_event_rx) = channel(); @@ -80,12 +77,10 @@ impl App<'_> { } let chat_widget = ChatWidget::new( - approval_policy, - sandbox_policy, + config, app_event_tx.clone(), initial_prompt.clone(), initial_images, - model, disable_response_storage, ); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b852638c..e2224f99 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -3,12 +3,11 @@ use std::sync::mpsc::Sender; use std::sync::Arc; use codex_core::codex_wrapper::init_codex; -use codex_core::protocol::AskForApproval; +use codex_core::config::Config; use codex_core::protocol::Event; use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; -use codex_core::protocol::SandboxPolicy; use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; use ratatui::layout::Constraint; @@ -34,7 +33,7 @@ pub(crate) struct ChatWidget<'a> { conversation_history: ConversationHistoryWidget, bottom_pane: BottomPane<'a>, input_focus: InputFocus, - approval_policy: AskForApproval, + config: Config, cwd: std::path::PathBuf, } @@ -46,12 +45,10 @@ enum InputFocus { impl ChatWidget<'_> { pub(crate) fn new( - approval_policy: AskForApproval, - sandbox_policy: SandboxPolicy, + config: Config, app_event_tx: Sender, initial_prompt: Option, initial_images: Vec, - model: Option, disable_response_storage: bool, ) -> Self { let (codex_op_tx, mut codex_op_rx) = unbounded_channel::(); @@ -63,23 +60,17 @@ impl ChatWidget<'_> { let app_event_tx_clone = app_event_tx.clone(); // Create the Codex asynchronously so the UI loads as quickly as possible. + let config_for_agent_loop = config.clone(); tokio::spawn(async move { - // Initialize session; storage enabled by default - let (codex, session_event, _ctrl_c) = match init_codex( - approval_policy, - sandbox_policy, - disable_response_storage, - model, - ) - .await - { - Ok(vals) => vals, - Err(e) => { - // TODO(mbolin): This error needs to be surfaced to the user. - tracing::error!("failed to initialize codex: {e}"); - return; - } - }; + let (codex, session_event, _ctrl_c) = + match init_codex(config_for_agent_loop, disable_response_storage).await { + Ok(vals) => vals, + Err(e) => { + // TODO: surface this error to the user. + tracing::error!("failed to initialize codex: {e}"); + return; + } + }; // Forward the captured `SessionInitialized` event that was consumed // inside `init_codex()` so it can be rendered in the UI. @@ -115,7 +106,7 @@ impl ChatWidget<'_> { has_input_focus: true, }), input_focus: InputFocus::BottomPane, - approval_policy, + config, cwd: cwd.clone(), }; @@ -243,11 +234,8 @@ impl ChatWidget<'_> { match msg { EventMsg::SessionConfigured { model } => { // Record session information at the top of the conversation. - self.conversation_history.add_session_info( - model, - self.cwd.clone(), - self.approval_policy, - ); + self.conversation_history + .add_session_info(&self.config, model, self.cwd.clone()); self.request_redraw()?; } EventMsg::AgentMessage { message } => { diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index db25ad2b..f336b0c3 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -18,14 +18,14 @@ pub struct Cli { pub model: Option, /// Configure when the model requires human approval before executing a command. - #[arg(long = "ask-for-approval", short = 'a', value_enum, default_value_t = ApprovalModeCliArg::OnFailure)] - pub approval_policy: ApprovalModeCliArg, + #[arg(long = "ask-for-approval", short = 'a')] + pub approval_policy: Option, /// Configure the process restrictions when a command is executed. /// /// Uses OS-specific sandboxing tools; Seatbelt on OSX, landlock+seccomp on Linux. - #[arg(long = "sandbox", short = 's', value_enum, default_value_t = SandboxModeCliArg::NetworkAndFileWriteRestricted)] - pub sandbox_policy: SandboxModeCliArg, + #[arg(long = "sandbox", short = 's')] + pub sandbox_policy: Option, /// Allow running Codex outside a Git repository. #[arg(long = "skip-git-repo-check", default_value_t = false)] diff --git a/codex-rs/tui/src/conversation_history_widget.rs b/codex-rs/tui/src/conversation_history_widget.rs index 27b5e9b3..de1dbba9 100644 --- a/codex-rs/tui/src/conversation_history_widget.rs +++ b/codex-rs/tui/src/conversation_history_widget.rs @@ -1,6 +1,7 @@ use crate::history_cell::CommandOutput; use crate::history_cell::HistoryCell; use crate::history_cell::PatchEventType; +use codex_core::config::Config; use codex_core::protocol::FileChange; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -181,13 +182,10 @@ impl ConversationHistoryWidget { self.add_to_history(HistoryCell::new_patch_event(event_type, changes)); } - pub fn add_session_info( - &mut self, - model: String, - cwd: std::path::PathBuf, - approval_policy: codex_core::protocol::AskForApproval, - ) { - self.add_to_history(HistoryCell::new_session_info(model, cwd, approval_policy)); + /// Note `model` could differ from `config.model` if the agent decided to + /// use a different model than the one requested by the user. + pub fn add_session_info(&mut self, config: &Config, model: String, cwd: PathBuf) { + self.add_to_history(HistoryCell::new_session_info(config, model, cwd)); } pub fn add_active_exec_command(&mut self, call_id: String, command: Vec) { diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index d6ebc248..f9bb1817 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1,4 +1,5 @@ use codex_ansi_escape::ansi_escape_line; +use codex_core::config::Config; use codex_core::protocol::FileChange; use ratatui::prelude::*; use ratatui::style::Color; @@ -144,9 +145,9 @@ impl HistoryCell { } pub(crate) fn new_session_info( + config: &Config, model: String, cwd: std::path::PathBuf, - approval_policy: codex_core::protocol::AskForApproval, ) -> Self { let mut lines: Vec> = Vec::new(); @@ -158,7 +159,11 @@ impl HistoryCell { ])); lines.push(Line::from(vec![ "↳ approval: ".bold(), - format!("{:?}", approval_policy).into(), + format!("{:?}", config.approval_policy).into(), + ])); + lines.push(Line::from(vec![ + "↳ sandbox: ".bold(), + format!("{:?}", config.sandbox_policy).into(), ])); lines.push(Line::from("")); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index d0f5f664..8e987ad7 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -4,6 +4,8 @@ #![deny(clippy::print_stdout, clippy::print_stderr)] use app::App; +use codex_core::config::Config; +use codex_core::config::ConfigOverrides; use codex_core::util::is_inside_git_repo; use log_layer::TuiLogLayer; use std::fs::OpenOptions; @@ -31,6 +33,23 @@ pub use cli::Cli; pub fn run_main(cli: Cli) -> std::io::Result<()> { assert_env_var_set(); + let config = { + // Load configuration and support CLI overrides. + let overrides = ConfigOverrides { + model: cli.model.clone(), + approval_policy: cli.approval_policy.map(Into::into), + sandbox_policy: cli.sandbox_policy.map(Into::into), + }; + #[allow(clippy::print_stderr)] + match Config::load_with_overrides(overrides) { + Ok(config) => config, + Err(err) => { + eprintln!("Error loading configuration: {err}"); + std::process::exit(1); + } + } + }; + let log_dir = codex_core::config::log_dir()?; std::fs::create_dir_all(&log_dir)?; // Open (or create) your log file, appending to it. @@ -79,7 +98,7 @@ pub fn run_main(cli: Cli) -> std::io::Result<()> { // `--allow-no-git-exec` flag. let show_git_warning = !cli.skip_git_repo_check && !is_inside_git_repo(); - try_run_ratatui_app(cli, show_git_warning, log_rx); + try_run_ratatui_app(cli, config, show_git_warning, log_rx); Ok(()) } @@ -89,16 +108,18 @@ pub fn run_main(cli: Cli) -> std::io::Result<()> { )] fn try_run_ratatui_app( cli: Cli, + config: Config, show_git_warning: bool, log_rx: tokio::sync::mpsc::UnboundedReceiver, ) { - if let Err(report) = run_ratatui_app(cli, show_git_warning, log_rx) { + if let Err(report) = run_ratatui_app(cli, config, show_git_warning, log_rx) { eprintln!("Error: {report:?}"); } } fn run_ratatui_app( cli: Cli, + config: Config, show_git_warning: bool, mut log_rx: tokio::sync::mpsc::UnboundedReceiver, ) -> color_eyre::Result<()> { @@ -116,23 +137,14 @@ fn run_ratatui_app( let Cli { prompt, images, - approval_policy, - sandbox_policy: sandbox, - model, disable_response_storage, .. } = cli; - - let approval_policy = approval_policy.into(); - let sandbox_policy = sandbox.into(); - let mut app = App::new( - approval_policy, - sandbox_policy, + config, prompt, show_git_warning, images, - model, disable_response_storage, );