Files
codex/prs/bolinfest/PR-1929.md
2025-09-02 15:17:45 -07:00

49 KiB
Raw Blame History

PR #1929: [config] Onboarding flow with persistence

Description

Summary

In collaboration with @gpeal: upgrade the onboarding flow, and persist user settings.

Testing

Tested a few scenarios locally

Full Diff

diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
index eabd9f35db..4eddf7bd7b 100644
--- a/codex-rs/Cargo.lock
+++ b/codex-rs/Cargo.lock
@@ -708,6 +708,7 @@ dependencies = [
  "tokio-test",
  "tokio-util",
  "toml 0.9.4",
+ "toml_edit 0.23.3",
  "tracing",
  "tree-sitter",
  "tree-sitter-bash",
@@ -3273,7 +3274,7 @@ version = "3.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35"
 dependencies = [
- "toml_edit",
+ "toml_edit 0.22.27",
 ]
 
 [[package]]
@@ -4800,7 +4801,7 @@ dependencies = [
  "serde",
  "serde_spanned 0.6.9",
  "toml_datetime 0.6.11",
- "toml_edit",
+ "toml_edit 0.22.27",
 ]
 
 [[package]]
@@ -4849,11 +4850,24 @@ dependencies = [
  "winnow",
 ]
 
+[[package]]
+name = "toml_edit"
+version = "0.23.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17d3b47e6b7a040216ae5302712c94d1cf88c95b47efa80e2c59ce96c878267e"
+dependencies = [
+ "indexmap 2.10.0",
+ "toml_datetime 0.7.0",
+ "toml_parser",
+ "toml_writer",
+ "winnow",
+]
+
 [[package]]
 name = "toml_parser"
-version = "1.0.1"
+version = "1.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "97200572db069e74c512a14117b296ba0a80a30123fbbb5aa1f4a348f639ca30"
+checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10"
 dependencies = [
  "winnow",
 ]
diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml
index e9d6970ded..006a218abf 100644
--- a/codex-rs/core/Cargo.toml
+++ b/codex-rs/core/Cargo.toml
@@ -36,6 +36,7 @@ sha1 = "0.10.6"
 shlex = "1.3.0"
 similar = "2.7.0"
 strum_macros = "0.27.2"
+tempfile = "3"
 thiserror = "2.0.12"
 time = { version = "0.3", features = ["formatting", "local-offset", "macros"] }
 tokio = { version = "1", features = [
@@ -47,6 +48,7 @@ tokio = { version = "1", features = [
 ] }
 tokio-util = "0.7.14"
 toml = "0.9.4"
+toml_edit = "0.23.3"
 tracing = { version = "0.1.41", features = ["log"] }
 tree-sitter = "0.25.8"
 tree-sitter-bash = "0.25.0"
diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs
index 081306dab1..723ee5f817 100644
--- a/codex-rs/core/src/config.rs
+++ b/codex-rs/core/src/config.rs
@@ -22,13 +22,17 @@ use serde::Deserialize;
 use std::collections::HashMap;
 use std::path::Path;
 use std::path::PathBuf;
+use tempfile::NamedTempFile;
 use toml::Value as TomlValue;
+use toml_edit::DocumentMut;
 
 /// Maximum number of bytes of the documentation that will be embedded. Larger
 /// files are *silently truncated* to this size so we do not take up too much of
 /// the context window.
 pub(crate) const PROJECT_DOC_MAX_BYTES: usize = 32 * 1024; // 32 KiB
 
+const CONFIG_TOML_FILE: &str = "config.toml";
+
 /// Application configuration loaded from disk and merged with overrides.
 #[derive(Debug, Clone, PartialEq)]
 pub struct Config {
@@ -191,10 +195,28 @@ impl Config {
     }
 }
 
+pub fn load_config_as_toml_with_cli_overrides(
+    codex_home: &Path,
+    cli_overrides: Vec<(String, TomlValue)>,
+) -> std::io::Result<ConfigToml> {
+    let mut root_value = load_config_as_toml(codex_home)?;
+
+    for (path, value) in cli_overrides.into_iter() {
+        apply_toml_override(&mut root_value, &path, value);
+    }
+
+    let cfg: ConfigToml = root_value.try_into().map_err(|e| {
+        tracing::error!("Failed to deserialize overridden config: {e}");
+        std::io::Error::new(std::io::ErrorKind::InvalidData, e)
+    })?;
+
+    Ok(cfg)
+}
+
 /// Read `CODEX_HOME/config.toml` and return it as a generic TOML value. Returns
 /// an empty TOML table when the file does not exist.
-fn load_config_as_toml(codex_home: &Path) -> std::io::Result<TomlValue> {
-    let config_path = codex_home.join("config.toml");
+pub fn load_config_as_toml(codex_home: &Path) -> std::io::Result<TomlValue> {
+    let config_path = codex_home.join(CONFIG_TOML_FILE);
     match std::fs::read_to_string(&config_path) {
         Ok(contents) => match toml::from_str::<TomlValue>(&contents) {
             Ok(val) => Ok(val),
@@ -214,6 +236,35 @@ fn load_config_as_toml(codex_home: &Path) -> std::io::Result<TomlValue> {
     }
 }
 
+/// Patch `CODEX_HOME/config.toml` project state.
+/// Use with caution.
+pub fn set_project_trusted(codex_home: &Path, project_path: &Path) -> anyhow::Result<()> {
+    let config_path = codex_home.join(CONFIG_TOML_FILE);
+    // Parse existing config if present; otherwise start a new document.
+    let mut doc = match std::fs::read_to_string(config_path.clone()) {
+        Ok(s) => s.parse::<DocumentMut>()?,
+        Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
+        Err(e) => return Err(e.into()),
+    };
+
+    // Mark the project as trusted. toml_edit is very good at handling
+    // missing properties
+    let project_key = project_path.to_string_lossy().to_string();
+    doc["projects"][project_key.as_str()]["trust_level"] = toml_edit::value("trusted");
+
+    // ensure codex_home exists
+    std::fs::create_dir_all(codex_home)?;
+
+    // create a tmp_file
+    let tmp_file = NamedTempFile::new_in(codex_home)?;
+    std::fs::write(tmp_file.path(), doc.to_string())?;
+
+    // atomically move the tmp file into config.toml
+    tmp_file.persist(config_path)?;
+
+    Ok(())
+}
+
 /// Apply a single dotted-path override onto a TOML value.
 fn apply_toml_override(root: &mut TomlValue, path: &str, value: TomlValue) {
     use toml::value::Table;
@@ -350,6 +401,13 @@ pub struct ConfigToml {
 
     /// The value for the `originator` header included with Responses API requests.
     pub internal_originator: Option<String>,
+
+    pub projects: Option<HashMap<String, ProjectConfig>>,
+}
+
+#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
+pub struct ProjectConfig {
+    pub trust_level: Option<String>,
 }
 
 impl ConfigToml {
@@ -377,6 +435,36 @@ impl ConfigToml {
             SandboxMode::DangerFullAccess => SandboxPolicy::DangerFullAccess,
         }
     }
+
+    pub fn is_cwd_trusted(&self, resolved_cwd: &Path) -> bool {
+        let projects = self.projects.clone().unwrap_or_default();
+
+        projects
+            .get(&resolved_cwd.to_string_lossy().to_string())
+            .map(|p| p.trust_level.clone().unwrap_or("".to_string()) == "trusted")
+            .unwrap_or(false)
+    }
+
+    pub fn get_config_profile(
+        &self,
+        override_profile: Option<String>,
+    ) -> Result<ConfigProfile, std::io::Error> {
+        let profile = override_profile.or_else(|| self.profile.clone());
+
+        match profile {
+            Some(key) => {
+                if let Some(profile) = self.profiles.get(key.as_str()) {
+                    return Ok(profile.clone());
+                }
+
+                Err(std::io::Error::new(
+                    std::io::ErrorKind::NotFound,
+                    format!("config profile `{key}` not found"),
+                ))
+            }
+            None => Ok(ConfigProfile::default()),
+        }
+    }
 }
 
 /// Optional overrides for user configuration (e.g., from CLI flags).
diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs
index c789798bcd..9008ad307d 100644
--- a/codex-rs/core/src/protocol.rs
+++ b/codex-rs/core/src/protocol.rs
@@ -139,7 +139,6 @@ pub enum AskForApproval {
     /// Under this policy, only "known safe" commands—as determined by
     /// `is_safe_command()`—that **only read files** are autoapproved.
     /// Everything else will ask the user to approve.
-    #[default]
     #[serde(rename = "untrusted")]
     #[strum(serialize = "untrusted")]
     UnlessTrusted,
@@ -151,6 +150,7 @@ pub enum AskForApproval {
     OnFailure,
 
     /// The model decides when to ask the user for approval.
+    #[default]
     OnRequest,
 
     /// Never ask the user to approve commands. Failures are immediately returned
diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs
index 5d7f1281ee..6ed57898b2 100644
--- a/codex-rs/exec/src/lib.rs
+++ b/codex-rs/exec/src/lib.rs
@@ -181,7 +181,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
     event_processor.print_config_summary(&config, &prompt);
 
     if !skip_git_repo_check && !is_inside_git_repo(&config.cwd.to_path_buf()) {
-        eprintln!("Not inside a Git repo and --skip-git-repo-check was not specified.");
+        eprintln!("Not inside a trusted directory and --skip-git-repo-check was not specified.");
         std::process::exit(1);
     }
 
diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs
index 1ba8883b0b..d71a331e3b 100644
--- a/codex-rs/tui/src/app.rs
+++ b/codex-rs/tui/src/app.rs
@@ -13,7 +13,6 @@ use codex_core::config::Config;
 use codex_core::protocol::Event;
 use codex_core::protocol::EventMsg;
 use codex_core::protocol::Op;
-use codex_core::util::is_inside_git_repo;
 use color_eyre::eyre::Result;
 use crossterm::SynchronizedUpdate;
 use crossterm::event::KeyCode;
@@ -71,7 +70,7 @@ pub(crate) struct App<'a> {
 /// deferred until after the Git warning screen is dismissed.
 #[derive(Clone, Debug)]
 pub(crate) struct ChatWidgetArgs {
-    config: Config,
+    pub(crate) config: Config,
     initial_prompt: Option<String>,
     initial_images: Vec<PathBuf>,
     enhanced_keys_supported: bool,
@@ -81,8 +80,8 @@ impl App<'_> {
     pub(crate) fn new(
         config: Config,
         initial_prompt: Option<String>,
-        skip_git_repo_check: bool,
         initial_images: Vec<std::path::PathBuf>,
+        show_trust_screen: bool,
     ) -> Self {
         let (app_event_tx, app_event_rx) = channel();
         let app_event_tx = AppEventSender::new(app_event_tx);
@@ -134,9 +133,7 @@ impl App<'_> {
         }
 
         let show_login_screen = should_show_login_screen(&config);
-        let show_git_warning =
-            !skip_git_repo_check && !is_inside_git_repo(&config.cwd.to_path_buf());
-        let app_state = if show_login_screen || show_git_warning {
+        let app_state = if show_login_screen || show_trust_screen {
             let chat_widget_args = ChatWidgetArgs {
                 config: config.clone(),
                 initial_prompt,
@@ -149,7 +146,7 @@ impl App<'_> {
                     codex_home: config.codex_home.clone(),
                     cwd: config.cwd.clone(),
                     show_login_screen,
-                    show_git_warning,
+                    show_trust_screen,
                     chat_widget_args,
                 }),
             }
diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs
index 078936dc33..91ee9cfdc7 100644
--- a/codex-rs/tui/src/cli.rs
+++ b/codex-rs/tui/src/cli.rs
@@ -54,10 +54,6 @@ pub struct Cli {
     #[clap(long = "cd", short = 'C', value_name = "DIR")]
     pub cwd: Option<PathBuf>,
 
-    /// Allow running Codex outside a Git repository.
-    #[arg(long = "skip-git-repo-check", default_value_t = false)]
-    pub skip_git_repo_check: bool,
-
     #[clap(skip)]
     pub config_overrides: CliConfigOverrides,
 }
diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs
index 0e809afdbe..057d25168b 100644
--- a/codex-rs/tui/src/lib.rs
+++ b/codex-rs/tui/src/lib.rs
@@ -6,8 +6,12 @@ use app::App;
 use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID;
 use codex_core::config::Config;
 use codex_core::config::ConfigOverrides;
+use codex_core::config::ConfigToml;
+use codex_core::config::find_codex_home;
+use codex_core::config::load_config_as_toml_with_cli_overrides;
 use codex_core::config_types::SandboxMode;
 use codex_core::protocol::AskForApproval;
+use codex_core::protocol::SandboxPolicy;
 use codex_login::load_auth;
 use codex_ollama::DEFAULT_OSS_MODEL;
 use log_layer::TuiLogLayer;
@@ -89,33 +93,38 @@ pub async fn run_main(
         None
     };
 
-    let config = {
+    // canonicalize the cwd
+    let cwd = cli.cwd.clone().map(|p| p.canonicalize().unwrap_or(p));
+
+    let overrides = ConfigOverrides {
+        model,
+        approval_policy,
+        sandbox_mode,
+        cwd,
+        model_provider: model_provider_override,
+        config_profile: cli.config_profile.clone(),
+        codex_linux_sandbox_exe,
+        base_instructions: None,
+        include_plan_tool: Some(true),
+        disable_response_storage: cli.oss.then_some(true),
+        show_raw_agent_reasoning: cli.oss.then_some(true),
+    };
+
+    // Parse `-c` overrides from the CLI.
+    let cli_kv_overrides = match cli.config_overrides.parse_overrides() {
+        Ok(v) => v,
+        #[allow(clippy::print_stderr)]
+        Err(e) => {
+            eprintln!("Error parsing -c overrides: {e}");
+            std::process::exit(1);
+        }
+    };
+
+    let mut config = {
         // Load configuration and support CLI overrides.
-        let overrides = ConfigOverrides {
-            model,
-            approval_policy,
-            sandbox_mode,
-            cwd: cli.cwd.clone().map(|p| p.canonicalize().unwrap_or(p)),
-            model_provider: model_provider_override,
-            config_profile: cli.config_profile.clone(),
-            codex_linux_sandbox_exe,
-            base_instructions: None,
-            include_plan_tool: Some(true),
-            disable_response_storage: cli.oss.then_some(true),
-            show_raw_agent_reasoning: cli.oss.then_some(true),
-        };
-        // Parse `-c` overrides from the CLI.
-        let cli_kv_overrides = match cli.config_overrides.parse_overrides() {
-            Ok(v) => v,
-            #[allow(clippy::print_stderr)]
-            Err(e) => {
-                eprintln!("Error parsing -c overrides: {e}");
-                std::process::exit(1);
-            }
-        };
 
         #[allow(clippy::print_stderr)]
-        match Config::load_with_cli_overrides(cli_kv_overrides, overrides) {
+        match Config::load_with_cli_overrides(cli_kv_overrides.clone(), overrides) {
             Ok(config) => config,
             Err(err) => {
                 eprintln!("Error loading configuration: {err}");
@@ -124,6 +133,34 @@ pub async fn run_main(
         }
     };
 
+    // we load config.toml here to determine project state.
+    #[allow(clippy::print_stderr)]
+    let config_toml = {
+        let codex_home = match find_codex_home() {
+            Ok(codex_home) => codex_home,
+            Err(err) => {
+                eprintln!("Error finding codex home: {err}");
+                std::process::exit(1);
+            }
+        };
+
+        match load_config_as_toml_with_cli_overrides(&codex_home, cli_kv_overrides) {
+            Ok(config_toml) => config_toml,
+            Err(err) => {
+                eprintln!("Error loading config.toml: {err}");
+                std::process::exit(1);
+            }
+        }
+    };
+
+    let should_show_trust_screen = determine_repo_trust_state(
+        &mut config,
+        &config_toml,
+        approval_policy,
+        sandbox_mode,
+        cli.config_profile.clone(),
+    )?;
+
     let log_dir = codex_core::config::log_dir(&config)?;
     std::fs::create_dir_all(&log_dir)?;
     // Open (or create) your log file, appending to it.
@@ -204,12 +241,14 @@ pub async fn run_main(
         eprintln!("");
     }
 
-    run_ratatui_app(cli, config, log_rx).map_err(|err| std::io::Error::other(err.to_string()))
+    run_ratatui_app(cli, config, should_show_trust_screen, log_rx)
+        .map_err(|err| std::io::Error::other(err.to_string()))
 }
 
 fn run_ratatui_app(
     cli: Cli,
     config: Config,
+    should_show_trust_screen: bool,
     mut log_rx: tokio::sync::mpsc::UnboundedReceiver<String>,
 ) -> color_eyre::Result<codex_core::protocol::TokenUsage> {
     color_eyre::install()?;
@@ -227,7 +266,7 @@ fn run_ratatui_app(
     terminal.clear()?;
 
     let Cli { prompt, images, .. } = cli;
-    let mut app = App::new(config.clone(), prompt, cli.skip_git_repo_check, images);
+    let mut app = App::new(config.clone(), prompt, images, should_show_trust_screen);
 
     // Bridge log receiver into the AppEvent channel so latest log lines update the UI.
     {
@@ -277,3 +316,39 @@ fn should_show_login_screen(config: &Config) -> bool {
         false
     }
 }
+
+/// Determine if user has configured a sandbox / approval policy,
+/// or if the current cwd project is trusted, and updates the config
+/// accordingly.
+fn determine_repo_trust_state(
+    config: &mut Config,
+    config_toml: &ConfigToml,
+    approval_policy_overide: Option<AskForApproval>,
+    sandbox_mode_override: Option<SandboxMode>,
+    config_profile_override: Option<String>,
+) -> std::io::Result<bool> {
+    let config_profile = config_toml.get_config_profile(config_profile_override)?;
+
+    if approval_policy_overide.is_some() || sandbox_mode_override.is_some() {
+        // if the user has overridden either approval policy or sandbox mode,
+        // skip the trust flow
+        Ok(false)
+    } else if config_profile.approval_policy.is_some() {
+        // if the user has specified settings in a config profile, skip the trust flow
+        // todo: profile sandbox mode?
+        Ok(false)
+    } else if config_toml.approval_policy.is_some() || config_toml.sandbox_mode.is_some() {
+        // if the user has specified either approval policy or sandbox mode in config.toml
+        // skip the trust flow
+        Ok(false)
+    } else if config_toml.is_cwd_trusted(&config.cwd) {
+        // if the current cwd project is trusted and no config has been set
+        // skip the trust flow and set the approval policy and sandbox mode
+        config.approval_policy = AskForApproval::OnRequest;
+        config.sandbox_policy = SandboxPolicy::new_workspace_write_policy();
+        Ok(false)
+    } else {
+        // if none of the above conditions are met, show the trust screen
+        Ok(true)
+    }
+}
diff --git a/codex-rs/tui/src/onboarding/continue_to_chat.rs b/codex-rs/tui/src/onboarding/continue_to_chat.rs
index 071d0851da..01e31d900a 100644
--- a/codex-rs/tui/src/onboarding/continue_to_chat.rs
+++ b/codex-rs/tui/src/onboarding/continue_to_chat.rs
@@ -8,12 +8,14 @@ use crate::app_event_sender::AppEventSender;
 use crate::onboarding::onboarding_screen::StepStateProvider;
 
 use super::onboarding_screen::StepState;
+use std::sync::Arc;
+use std::sync::Mutex;
 
 /// This doesn't render anything explicitly but serves as a signal that we made it to the end and
 /// we should continue to the chat.
 pub(crate) struct ContinueToChatWidget {
     pub event_tx: AppEventSender,
-    pub chat_widget_args: ChatWidgetArgs,
+    pub chat_widget_args: Arc<Mutex<ChatWidgetArgs>>,
 }
 
 impl StepStateProvider for ContinueToChatWidget {
@@ -24,7 +26,9 @@ impl StepStateProvider for ContinueToChatWidget {
 
 impl WidgetRef for &ContinueToChatWidget {
     fn render_ref(&self, _area: Rect, _buf: &mut Buffer) {
-        self.event_tx
-            .send(AppEvent::OnboardingComplete(self.chat_widget_args.clone()));
+        if let Ok(args) = self.chat_widget_args.lock() {
+            self.event_tx
+                .send(AppEvent::OnboardingComplete(args.clone()));
+        }
     }
 }
diff --git a/codex-rs/tui/src/onboarding/git_warning.rs b/codex-rs/tui/src/onboarding/git_warning.rs
deleted file mode 100644
index e4e5747404..0000000000
--- a/codex-rs/tui/src/onboarding/git_warning.rs
+++ /dev/null
@@ -1,126 +0,0 @@
-use std::path::PathBuf;
-
-use codex_core::util::is_inside_git_repo;
-use crossterm::event::KeyCode;
-use crossterm::event::KeyEvent;
-use ratatui::buffer::Buffer;
-use ratatui::layout::Rect;
-use ratatui::prelude::Widget;
-use ratatui::style::Modifier;
-use ratatui::style::Style;
-use ratatui::style::Stylize;
-use ratatui::text::Line;
-use ratatui::text::Span;
-use ratatui::widgets::Paragraph;
-use ratatui::widgets::WidgetRef;
-use ratatui::widgets::Wrap;
-
-use crate::app_event::AppEvent;
-use crate::app_event_sender::AppEventSender;
-use crate::colors::LIGHT_BLUE;
-
-use crate::onboarding::onboarding_screen::KeyboardHandler;
-use crate::onboarding::onboarding_screen::StepStateProvider;
-
-use super::onboarding_screen::StepState;
-
-pub(crate) struct GitWarningWidget {
-    pub event_tx: AppEventSender,
-    pub cwd: PathBuf,
-    pub selection: Option<GitWarningSelection>,
-    pub highlighted: GitWarningSelection,
-}
-
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub(crate) enum GitWarningSelection {
-    Continue,
-    Exit,
-}
-
-impl WidgetRef for &GitWarningWidget {
-    fn render_ref(&self, area: Rect, buf: &mut Buffer) {
-        let mut lines: Vec<Line> = vec![
-            Line::from(vec![
-                Span::raw("> "),
-                Span::raw("You are running Codex in "),
-                Span::styled(
-                    self.cwd.to_string_lossy().to_string(),
-                    Style::default().add_modifier(Modifier::BOLD),
-                ),
-                Span::raw(". This folder is not version controlled."),
-            ]),
-            Line::from(""),
-            Line::from("  Do you want to continue?"),
-            Line::from(""),
-        ];
-
-        let create_option =
-            |idx: usize, option: GitWarningSelection, text: &str| -> Line<'static> {
-                let is_selected = self.highlighted == option;
-                if is_selected {
-                    Line::from(vec![
-                        Span::styled(
-                            format!("> {}. ", idx + 1),
-                            Style::default().fg(LIGHT_BLUE).add_modifier(Modifier::DIM),
-                        ),
-                        Span::styled(text.to_owned(), Style::default().fg(LIGHT_BLUE)),
-                    ])
-                } else {
-                    Line::from(format!("  {}. {}", idx + 1, text))
-                }
-            };
-
-        lines.push(create_option(0, GitWarningSelection::Continue, "Yes"));
-        lines.push(create_option(1, GitWarningSelection::Exit, "No"));
-        lines.push(Line::from(""));
-        lines.push(Line::from("  Press Enter to continue").add_modifier(Modifier::DIM));
-
-        Paragraph::new(lines)
-            .wrap(Wrap { trim: false })
-            .render(area, buf);
-    }
-}
-
-impl KeyboardHandler for GitWarningWidget {
-    fn handle_key_event(&mut self, key_event: KeyEvent) {
-        match key_event.code {
-            KeyCode::Up | KeyCode::Char('k') => {
-                self.highlighted = GitWarningSelection::Continue;
-            }
-            KeyCode::Down | KeyCode::Char('j') => {
-                self.highlighted = GitWarningSelection::Exit;
-            }
-            KeyCode::Char('1') => self.handle_continue(),
-            KeyCode::Char('2') => self.handle_quit(),
-            KeyCode::Enter => match self.highlighted {
-                GitWarningSelection::Continue => self.handle_continue(),
-                GitWarningSelection::Exit => self.handle_quit(),
-            },
-            _ => {}
-        }
-    }
-}
-
-impl StepStateProvider for GitWarningWidget {
-    fn get_step_state(&self) -> StepState {
-        let is_git_repo = is_inside_git_repo(&self.cwd);
-        match is_git_repo {
-            true => StepState::Hidden,
-            false => match self.selection {
-                Some(_) => StepState::Complete,
-                None => StepState::InProgress,
-            },
-        }
-    }
-}
-
-impl GitWarningWidget {
-    fn handle_continue(&mut self) {
-        self.selection = Some(GitWarningSelection::Continue);
-    }
-
-    fn handle_quit(&mut self) {
-        self.highlighted = GitWarningSelection::Exit;
-        self.event_tx.send(AppEvent::ExitRequest);
-    }
-}
diff --git a/codex-rs/tui/src/onboarding/mod.rs b/codex-rs/tui/src/onboarding/mod.rs
index 645cda22d9..c116936851 100644
--- a/codex-rs/tui/src/onboarding/mod.rs
+++ b/codex-rs/tui/src/onboarding/mod.rs
@@ -1,5 +1,5 @@
 mod auth;
 mod continue_to_chat;
-mod git_warning;
 pub mod onboarding_screen;
+mod trust_directory;
 mod welcome;
diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs
index 7ce7d16c47..a104f777c2 100644
--- a/codex-rs/tui/src/onboarding/onboarding_screen.rs
+++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs
@@ -1,3 +1,4 @@
+use codex_core::util::is_inside_git_repo;
 use crossterm::event::KeyEvent;
 use ratatui::buffer::Buffer;
 use ratatui::layout::Rect;
@@ -11,16 +12,18 @@ use crate::app_event_sender::AppEventSender;
 use crate::onboarding::auth::AuthModeWidget;
 use crate::onboarding::auth::SignInState;
 use crate::onboarding::continue_to_chat::ContinueToChatWidget;
-use crate::onboarding::git_warning::GitWarningSelection;
-use crate::onboarding::git_warning::GitWarningWidget;
+use crate::onboarding::trust_directory::TrustDirectorySelection;
+use crate::onboarding::trust_directory::TrustDirectoryWidget;
 use crate::onboarding::welcome::WelcomeWidget;
 use std::path::PathBuf;
+use std::sync::Arc;
+use std::sync::Mutex;
 
 #[allow(clippy::large_enum_variant)]
 enum Step {
     Welcome(WelcomeWidget),
     Auth(AuthModeWidget),
-    GitWarning(GitWarningWidget),
+    TrustDirectory(TrustDirectoryWidget),
     ContinueToChat(ContinueToChatWidget),
 }
 
@@ -49,7 +52,7 @@ pub(crate) struct OnboardingScreenArgs {
     pub codex_home: PathBuf,
     pub cwd: PathBuf,
     pub show_login_screen: bool,
-    pub show_git_warning: bool,
+    pub show_trust_screen: bool,
 }
 
 impl OnboardingScreen {
@@ -60,7 +63,7 @@ impl OnboardingScreen {
             codex_home,
             cwd,
             show_login_screen,
-            show_git_warning,
+            show_trust_screen,
         } = args;
         let mut steps: Vec<Step> = vec![Step::Welcome(WelcomeWidget {
             is_logged_in: !show_login_screen,
@@ -71,20 +74,33 @@ impl OnboardingScreen {
                 highlighted_mode: AuthMode::ChatGPT,
                 error: None,
                 sign_in_state: SignInState::PickMode,
-                codex_home,
+                codex_home: codex_home.clone(),
             }))
         }
-        if show_git_warning {
-            steps.push(Step::GitWarning(GitWarningWidget {
-                event_tx: event_tx.clone(),
+        let is_git_repo = is_inside_git_repo(&cwd);
+        let highlighted = if is_git_repo {
+            TrustDirectorySelection::Trust
+        } else {
+            // Default to not trusting the directory if it's not a git repo.
+            TrustDirectorySelection::DontTrust
+        };
+        // Share ChatWidgetArgs between steps so changes in the TrustDirectory step
+        // are reflected when continuing to chat.
+        let shared_chat_args = Arc::new(Mutex::new(chat_widget_args));
+        if show_trust_screen {
+            steps.push(Step::TrustDirectory(TrustDirectoryWidget {
                 cwd,
+                codex_home,
+                is_git_repo,
                 selection: None,
-                highlighted: GitWarningSelection::Continue,
+                highlighted,
+                error: None,
+                chat_widget_args: shared_chat_args.clone(),
             }))
         }
         steps.push(Step::ContinueToChat(ContinueToChatWidget {
             event_tx: event_tx.clone(),
-            chat_widget_args,
+            chat_widget_args: shared_chat_args,
         }));
         // TODO: add git warning.
         Self { event_tx, steps }
@@ -215,7 +231,7 @@ impl KeyboardHandler for Step {
         match self {
             Step::Welcome(_) | Step::ContinueToChat(_) => (),
             Step::Auth(widget) => widget.handle_key_event(key_event),
-            Step::GitWarning(widget) => widget.handle_key_event(key_event),
+            Step::TrustDirectory(widget) => widget.handle_key_event(key_event),
         }
     }
 }
@@ -225,7 +241,7 @@ impl StepStateProvider for Step {
         match self {
             Step::Welcome(w) => w.get_step_state(),
             Step::Auth(w) => w.get_step_state(),
-            Step::GitWarning(w) => w.get_step_state(),
+            Step::TrustDirectory(w) => w.get_step_state(),
             Step::ContinueToChat(w) => w.get_step_state(),
         }
     }
@@ -240,7 +256,7 @@ impl WidgetRef for Step {
             Step::Auth(widget) => {
                 widget.render_ref(area, buf);
             }
-            Step::GitWarning(widget) => {
+            Step::TrustDirectory(widget) => {
                 widget.render_ref(area, buf);
             }
             Step::ContinueToChat(widget) => {
diff --git a/codex-rs/tui/src/onboarding/trust_directory.rs b/codex-rs/tui/src/onboarding/trust_directory.rs
new file mode 100644
index 0000000000..3be9bac1ac
--- /dev/null
+++ b/codex-rs/tui/src/onboarding/trust_directory.rs
@@ -0,0 +1,179 @@
+use std::path::PathBuf;
+
+use codex_core::config::set_project_trusted;
+use codex_core::protocol::AskForApproval;
+use codex_core::protocol::SandboxPolicy;
+use crossterm::event::KeyCode;
+use crossterm::event::KeyEvent;
+use ratatui::buffer::Buffer;
+use ratatui::layout::Rect;
+use ratatui::prelude::Widget;
+use ratatui::style::Color;
+use ratatui::style::Modifier;
+use ratatui::style::Style;
+use ratatui::style::Stylize;
+use ratatui::text::Line;
+use ratatui::text::Span;
+use ratatui::widgets::Paragraph;
+use ratatui::widgets::WidgetRef;
+use ratatui::widgets::Wrap;
+
+use crate::colors::LIGHT_BLUE;
+
+use crate::onboarding::onboarding_screen::KeyboardHandler;
+use crate::onboarding::onboarding_screen::StepStateProvider;
+
+use super::onboarding_screen::StepState;
+use crate::app::ChatWidgetArgs;
+use std::sync::Arc;
+use std::sync::Mutex;
+
+pub(crate) struct TrustDirectoryWidget {
+    pub codex_home: PathBuf,
+    pub cwd: PathBuf,
+    pub is_git_repo: bool,
+    pub selection: Option<TrustDirectorySelection>,
+    pub highlighted: TrustDirectorySelection,
+    pub error: Option<String>,
+    pub chat_widget_args: Arc<Mutex<ChatWidgetArgs>>,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum TrustDirectorySelection {
+    Trust,
+    DontTrust,
+}
+
+impl WidgetRef for &TrustDirectoryWidget {
+    fn render_ref(&self, area: Rect, buf: &mut Buffer) {
+        let mut lines: Vec<Line> = vec![
+            Line::from(vec![
+                Span::raw("> "),
+                Span::styled(
+                    "You are running Codex in ",
+                    Style::default().add_modifier(Modifier::BOLD),
+                ),
+                Span::raw(self.cwd.to_string_lossy().to_string()),
+            ]),
+            Line::from(""),
+        ];
+
+        if self.is_git_repo {
+            lines.push(Line::from(
+                "  Since this folder is version controlled, you may wish to allow Codex",
+            ));
+            lines.push(Line::from(
+                "  to work in this folder without asking for approval.",
+            ));
+        } else {
+            lines.push(Line::from(
+                "  Since this folder is not version controlled, we recommend requiring",
+            ));
+            lines.push(Line::from("  approval of all edits and commands."));
+        }
+        lines.push(Line::from(""));
+
+        let create_option =
+            |idx: usize, option: TrustDirectorySelection, text: &str| -> Line<'static> {
+                let is_selected = self.highlighted == option;
+                if is_selected {
+                    Line::from(vec![
+                        Span::styled(
+                            format!("> {}. ", idx + 1),
+                            Style::default().fg(LIGHT_BLUE).add_modifier(Modifier::DIM),
+                        ),
+                        Span::styled(text.to_owned(), Style::default().fg(LIGHT_BLUE)),
+                    ])
+                } else {
+                    Line::from(format!("  {}. {}", idx + 1, text))
+                }
+            };
+
+        if self.is_git_repo {
+            lines.push(create_option(
+                0,
+                TrustDirectorySelection::Trust,
+                "Yes, allow Codex to work in this folder without asking for approval",
+            ));
+            lines.push(create_option(
+                1,
+                TrustDirectorySelection::DontTrust,
+                "No, ask me to approve edits and commands",
+            ));
+        } else {
+            lines.push(create_option(
+                0,
+                TrustDirectorySelection::Trust,
+                "Allow Codex to work in this folder without asking for approval",
+            ));
+            lines.push(create_option(
+                1,
+                TrustDirectorySelection::DontTrust,
+                "Require approval of edits and commands",
+            ));
+        }
+        lines.push(Line::from(""));
+        if let Some(error) = &self.error {
+            lines.push(Line::from(format!("  {error}")).fg(Color::Red));
+            lines.push(Line::from(""));
+        }
+        lines.push(Line::from("  Press Enter to continue").add_modifier(Modifier::DIM));
+
+        Paragraph::new(lines)
+            .wrap(Wrap { trim: false })
+            .render(area, buf);
+    }
+}
+
+impl KeyboardHandler for TrustDirectoryWidget {
+    fn handle_key_event(&mut self, key_event: KeyEvent) {
+        match key_event.code {
+            KeyCode::Up | KeyCode::Char('k') => {
+                self.highlighted = TrustDirectorySelection::Trust;
+            }
+            KeyCode::Down | KeyCode::Char('j') => {
+                self.highlighted = TrustDirectorySelection::DontTrust;
+            }
+            KeyCode::Char('1') => self.handle_trust(),
+            KeyCode::Char('2') => self.handle_dont_trust(),
+            KeyCode::Enter => match self.highlighted {
+                TrustDirectorySelection::Trust => self.handle_trust(),
+                TrustDirectorySelection::DontTrust => self.handle_dont_trust(),
+            },
+            _ => {}
+        }
+    }
+}
+
+impl StepStateProvider for TrustDirectoryWidget {
+    fn get_step_state(&self) -> StepState {
+        match self.selection {
+            Some(_) => StepState::Complete,
+            None => StepState::InProgress,
+        }
+    }
+}
+
+impl TrustDirectoryWidget {
+    fn handle_trust(&mut self) {
+        if let Err(e) = set_project_trusted(&self.codex_home, &self.cwd) {
+            tracing::error!("Failed to set project trusted: {e:?}");
+            self.error = Some(e.to_string());
+            // self.error = Some("Failed to set project trusted".to_string());
+        }
+
+        // Update the in-memory chat config for this session to a more permissive
+        // policy suitable for a trusted workspace.
+        if let Ok(mut args) = self.chat_widget_args.lock() {
+            args.config.approval_policy = AskForApproval::OnRequest;
+            args.config.sandbox_policy = SandboxPolicy::new_workspace_write_policy();
+        }
+
+        self.selection = Some(TrustDirectorySelection::Trust);
+    }
+
+    fn handle_dont_trust(&mut self) {
+        self.highlighted = TrustDirectorySelection::DontTrust;
+        self.selection = Some(TrustDirectorySelection::DontTrust);
+    }
+}

Review Comments

codex-rs/core/Cargo.toml

@@ -53,6 +53,8 @@ tree-sitter-bash = "0.25.0"
 uuid = { version = "1", features = ["serde", "v4"] }
 whoami = "1.6.0"
 wildmatch = "2.4.0"
+toml_edit = "0.23.3"

alpha :P

codex-rs/core/src/config.rs

@@ -214,6 +225,33 @@ fn load_config_as_toml(codex_home: &Path) -> std::io::Result<TomlValue> {
     }
 }
 
+/// Patch `CODEX_HOME/config.toml` project state.
+/// Use with caution.
+pub fn set_project_trusted(
+    codex_home: &Path,
+    project_path: &Path,
+    trusted: bool,
+) -> anyhow::Result<()> {
+    let config_path = codex_home.join("config.toml");
+
+    // Parse existing config if present; otherwise start a new document.
+    let mut doc = match std::fs::read_to_string(&config_path) {
+        Ok(s) => s.parse::<DocumentMut>()?,
+        Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
+        Err(e) => return Err(e.into()),
+    };
+
+    let project_key = project_path.to_string_lossy().to_string();
+    doc["projects"][project_key.as_str()]["trusted"] = toml_edit::value(trusted);
+
+    if let Some(parent) = config_path.parent() {
+        std::fs::create_dir_all(parent)?;
+    }
+    std::fs::write(config_path, doc.to_string())?;

We should maybe write to a temp file in the folder and then mv it so writes are atomic.

@@ -214,6 +225,33 @@ fn load_config_as_toml(codex_home: &Path) -> std::io::Result<TomlValue> {
     }
 }
 
+/// Patch `CODEX_HOME/config.toml` project state.
+/// Use with caution.
+pub fn set_project_trusted(
+    codex_home: &Path,
+    project_path: &Path,
+    trusted: bool,
+) -> anyhow::Result<()> {
+    let config_path = codex_home.join("config.toml");
+
+    // Parse existing config if present; otherwise start a new document.
+    let mut doc = match std::fs::read_to_string(&config_path) {
+        Ok(s) => s.parse::<DocumentMut>()?,
+        Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
+        Err(e) => return Err(e.into()),
+    };
+
+    let project_key = project_path.to_string_lossy().to_string();
+    doc["projects"][project_key.as_str()]["trusted"] = toml_edit::value(trusted);
+
+    if let Some(parent) = config_path.parent() {
+        std::fs::create_dir_all(parent)?;
+    }

By construction, parent is codex_home, so just use that?

@@ -214,6 +225,33 @@ fn load_config_as_toml(codex_home: &Path) -> std::io::Result<TomlValue> {
     }
 }
 
+/// Patch `CODEX_HOME/config.toml` project state.
+/// Use with caution.
+pub fn set_project_trusted(
+    codex_home: &Path,
+    project_path: &Path,
+    trusted: bool,
+) -> anyhow::Result<()> {
+    let config_path = codex_home.join("config.toml");
+
+    // Parse existing config if present; otherwise start a new document.
+    let mut doc = match std::fs::read_to_string(&config_path) {
+        Ok(s) => s.parse::<DocumentMut>()?,
+        Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
+        Err(e) => return Err(e.into()),
+    };
+
+    let project_key = project_path.to_string_lossy().to_string();
+    doc["projects"][project_key.as_str()]["trusted"] = toml_edit::value(trusted);

What happens if doc does not have a "projects" key: does it create an entry on demand? How does this not panic?

@@ -518,6 +565,41 @@ impl Config {
             Self::get_base_instructions(experimental_instructions_path, &resolved_cwd)?;
         let base_instructions = base_instructions.or(file_base_instructions);
 
+        // Let's begin.
+        // First: load approval_policy and sandbox_mode from overrides, config
+        // profile, or config.toml.
+        let mut approval_policy = approval_policy
+            .or(config_profile.approval_policy)
+            .or(cfg.approval_policy.clone());
+        // TODO: Add sandbox_mode to the config profile? Doesn't
+        // appear to exist right now
+        let mut sandbox_mode = sandbox_mode.or(cfg.sandbox_mode.clone());
+        let mut projects = cfg.projects.clone().unwrap_or_default();
+
+        // Second: Default to "trusted" if the user has configured approval policy

What does "trusted" mean, particularly if sandbox mode is "read only"?

@@ -518,6 +565,41 @@ impl Config {
             Self::get_base_instructions(experimental_instructions_path, &resolved_cwd)?;
         let base_instructions = base_instructions.or(file_base_instructions);
 
+        // Let's begin.
+        // First: load approval_policy and sandbox_mode from overrides, config
+        // profile, or config.toml.
+        let mut approval_policy = approval_policy
+            .or(config_profile.approval_policy)
+            .or(cfg.approval_policy.clone());
+        // TODO: Add sandbox_mode to the config profile? Doesn't
+        // appear to exist right now
+        let mut sandbox_mode = sandbox_mode.or(cfg.sandbox_mode.clone());
+        let mut projects = cfg.projects.clone().unwrap_or_default();
+
+        // Second: Default to "trusted" if the user has configured approval policy
+        // or sandbox mode, regardless of projects config. If you've modified the
+        // config.toml, we'll respect your decision.
+        //
+        // This is a bit of a hack, but it allows us to skip tui onboarding
+        // for now.
+        if approval_policy.is_some() || sandbox_mode.is_some() {
+            projects.insert(

What if projects has an existing entry for cwd where trusted=false? Should this override it? Should it write it back?

@@ -214,6 +225,33 @@ fn load_config_as_toml(codex_home: &Path) -> std::io::Result<TomlValue> {
     }
 }
 
+/// Patch `CODEX_HOME/config.toml` project state.
+/// Use with caution.
+pub fn set_project_trusted(
+    codex_home: &Path,
+    project_path: &Path,
+    trusted: bool,
+) -> anyhow::Result<()> {
+    let config_path = codex_home.join("config.toml");
+
+    // Parse existing config if present; otherwise start a new document.
+    let mut doc = match std::fs::read_to_string(&config_path) {
+        Ok(s) => s.parse::<DocumentMut>()?,
+        Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
+        Err(e) => return Err(e.into()),
+    };
+
+    let project_key = project_path.to_string_lossy().to_string();
+    doc["projects"][project_key.as_str()]["trusted"] = toml_edit::value(trusted);

I see, DocumentMut implements IndexMut<&str>.

@@ -518,6 +565,41 @@ impl Config {
             Self::get_base_instructions(experimental_instructions_path, &resolved_cwd)?;
         let base_instructions = base_instructions.or(file_base_instructions);
 
+        // Let's begin.
+        // First: load approval_policy and sandbox_mode from overrides, config
+        // profile, or config.toml.
+        let mut approval_policy = approval_policy
+            .or(config_profile.approval_policy)
+            .or(cfg.approval_policy);
+        // TODO: Add sandbox_mode to the config profile? Doesn't
+        // appear to exist right now
+        let mut sandbox_mode = sandbox_mode.or(cfg.sandbox_mode);
+        let mut projects = cfg.projects.clone().unwrap_or_default();
+
+        // Second: Default to "trusted" if the user has configured approval policy
+        // or sandbox mode, regardless of projects config. If you've modified the
+        // config.toml, we'll respect your decision.
+        //
+        // This is a bit of a hack, but it allows us to skip tui onboarding
+        // for now.
+        if approval_policy.is_some() || sandbox_mode.is_some() {
+            projects.insert(
+                resolved_cwd.to_string_lossy().to_string(),
+                ProjectConfig {
+                    trusted: Some(true),
+                },
+            );
+        } else if let Some(project) = projects.get(&resolved_cwd.to_string_lossy().to_string()) {
+            // Third: If we have a project config, and it's trusted, set the approval policy and sandbox mode
+            if project.trusted.unwrap_or(false) {
+                approval_policy = Some(AskForApproval::OnRequest);
+                sandbox_mode = Some(SandboxMode::WorkspaceWrite);

So if I run the Codex CLI once with --sandbox read-only, we would set it as trusted, and the next time, if I didn't run any flags, we would run it with workplace-write?

@@ -518,6 +565,41 @@ impl Config {
             Self::get_base_instructions(experimental_instructions_path, &resolved_cwd)?;
         let base_instructions = base_instructions.or(file_base_instructions);
 
+        // Let's begin.
+        // First: load approval_policy and sandbox_mode from overrides, config
+        // profile, or config.toml.
+        let mut approval_policy = approval_policy
+            .or(config_profile.approval_policy)
+            .or(cfg.approval_policy.clone());
+        // TODO: Add sandbox_mode to the config profile? Doesn't
+        // appear to exist right now
+        let mut sandbox_mode = sandbox_mode.or(cfg.sandbox_mode.clone());
+        let mut projects = cfg.projects.clone().unwrap_or_default();
+
+        // Second: Default to "trusted" if the user has configured approval policy

What if I ran codex exec --sandbox read-only?

@@ -518,6 +565,41 @@ impl Config {
             Self::get_base_instructions(experimental_instructions_path, &resolved_cwd)?;
         let base_instructions = base_instructions.or(file_base_instructions);
 
+        // Let's begin.
+        // First: load approval_policy and sandbox_mode from overrides, config
+        // profile, or config.toml.
+        let mut approval_policy = approval_policy
+            .or(config_profile.approval_policy)
+            .or(cfg.approval_policy.clone());
+        // TODO: Add sandbox_mode to the config profile? Doesn't
+        // appear to exist right now
+        let mut sandbox_mode = sandbox_mode.or(cfg.sandbox_mode.clone());
+        let mut projects = cfg.projects.clone().unwrap_or_default();
+
+        // Second: Default to "trusted" if the user has configured approval policy

This logic is in core, but it seems you are making strong assumptions about the UI here?

@@ -666,6 +715,28 @@ pub fn find_codex_home() -> std::io::Result<PathBuf> {
     Ok(p)
 }
 
+pub fn resolve_cwd(cwd: Option<PathBuf>) -> std::io::Result<PathBuf> {

I think this function can go away?

codex-rs/core/src/protocol.rs

@@ -151,6 +150,7 @@ pub enum AskForApproval {
     OnFailure,
 
     /// The model decides when to ask the user for approval.
+    #[default]

Update docs?

codex-rs/exec/src/lib.rs

@@ -180,11 +178,6 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
     // is using.
     event_processor.print_config_summary(&config, &prompt);
 
-    if !skip_git_repo_check && !is_inside_git_repo(&config.cwd.to_path_buf()) {

I thought there was still going to be a case where we exit for codex exec even if not in a Git repo?

codex-rs/tui/src/lib.rs

@@ -277,3 +322,33 @@ fn should_show_login_screen(config: &Config) -> bool {
         false
     }
 }
+
+fn should_show_trust_screen(
+    config: &mut Config,
+    config_toml: &ConfigToml,
+    approval_policy_overide: Option<AskForApproval>,
+    sandbox_mode_override: Option<SandboxMode>,
+    cwd: Option<PathBuf>,

Why does this take cwd as an arg instead of using config.cwd?

We should be sure we are honoring --cwd if the user passed it in...

@@ -277,3 +322,33 @@ fn should_show_login_screen(config: &Config) -> bool {
         false
     }
 }
+
+fn should_show_trust_screen(
+    config: &mut Config,
+    config_toml: &ConfigToml,
+    approval_policy_overide: Option<AskForApproval>,
+    sandbox_mode_override: Option<SandboxMode>,
+    cwd: Option<PathBuf>,
+) -> std::io::Result<bool> {
+    let cwd = cwd.map(|p| p.canonicalize().unwrap_or(p));
+    let resolved_cwd = resolve_cwd(cwd)?;
+
+    if approval_policy_overide.is_some() || sandbox_mode_override.is_some() {

Note the user could have passed -c sandbox_mode=read-only...

codex-rs/tui/src/onboarding/continue_to_chat.rs

@@ -8,12 +8,14 @@ use crate::app_event_sender::AppEventSender;
 use crate::onboarding::onboarding_screen::StepStateProvider;
 
 use super::onboarding_screen::StepState;
+use std::sync::Arc;
+use std::sync::Mutex;
 
 /// This doesn't render anything explicitly but serves as a signal that we made it to the end and
 /// we should continue to the chat.
 pub(crate) struct ContinueToChatWidget {
     pub event_tx: AppEventSender,
-    pub chat_widget_args: ChatWidgetArgs,
+    pub chat_widget_args: Arc<Mutex<ChatWidgetArgs>>,

Maybe I'm missing where this happens, but I don't see where this is mutated (though it is cloned), so does it need Arc<Mutex>?