diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index eabd9f35..4eddf7bd 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]] @@ -4850,10 +4851,23 @@ dependencies = [ ] [[package]] -name = "toml_parser" -version = "1.0.1" +name = "toml_edit" +version = "0.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97200572db069e74c512a14117b296ba0a80a30123fbbb5aa1f4a348f639ca30" +checksum = "17d3b47e6b7a040216ae5302712c94d1cf88c95b47efa80e2c59ce96c878267e" +dependencies = [ + "indexmap 2.10.0", + "toml_datetime 0.7.0", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10" dependencies = [ "winnow", ] diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index e9d6970d..006a218a 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 081306da..723ee5f8 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 { + 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 { - let config_path = codex_home.join("config.toml"); +pub fn load_config_as_toml(codex_home: &Path) -> std::io::Result { + let config_path = codex_home.join(CONFIG_TOML_FILE); match std::fs::read_to_string(&config_path) { Ok(contents) => match toml::from_str::(&contents) { Ok(val) => Ok(val), @@ -214,6 +236,35 @@ fn load_config_as_toml(codex_home: &Path) -> std::io::Result { } } +/// 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::()?, + 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, + + pub projects: Option>, +} + +#[derive(Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct ProjectConfig { + pub trust_level: Option, } 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, + ) -> Result { + 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 c789798b..9008ad30 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 auto‑approved. /// 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 5d7f1281..6ed57898 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) -> 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 0e38aba3..86d74141 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, initial_images: Vec, enhanced_keys_supported: bool, @@ -81,8 +80,8 @@ impl App<'_> { pub(crate) fn new( config: Config, initial_prompt: Option, - skip_git_repo_check: bool, initial_images: Vec, + 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 078936dc..91ee9cfd 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, - /// 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 0e809afd..057d2516 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, ) -> color_eyre::Result { 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, + sandbox_mode_override: Option, + config_profile_override: Option, +) -> std::io::Result { + 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 071d0851..01e31d90 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>, } 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 e4e57474..00000000 --- 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, - 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 = 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 645cda22..c1169368 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 7ce7d16c..a104f777 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 = 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 00000000..3be9bac1 --- /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, + pub highlighted: TrustDirectorySelection, + pub error: Option, + pub chat_widget_args: Arc>, +} + +#[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 = 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); + } +}