From 1e59dc5bdaa30c5cc8a488753b3a89c18f77c1bc Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 4 Aug 2026 17:44:18 +0000 Subject: [PATCH] Trust undecided local projects automatically (#36935) ## What changed - Replace the TUI directory-trust prompt with automatic trust for local projects whose trust level is unset. Keep explicit trust settings and remote workspaces unchanged. - Persist trust for the resolved Git or configured project root, then reload configuration so project-local settings take effect. - Fall back to an in-memory trust override and an embedded app server when the config update cannot be persisted. ## Testing - Cover persisted and in-memory trust, custom project-root markers, and the working directories selected by resume and fork flows. GitOrigin-RevId: 8fd51eb4cd88267073324bfd7dc4106a56d7c745 --- codex-rs/config/src/loader/mod.rs | 3 + codex-rs/config/src/state.rs | 17 + codex-rs/core/src/agent/role.rs | 3 +- codex-rs/core/src/agent/role_tests.rs | 5 + codex-rs/core/src/config/config_tests.rs | 49 +- codex-rs/core/src/config/mod.rs | 15 +- codex-rs/tui/src/lib.rs | 454 +++++++++++------- codex-rs/tui/src/onboarding/mod.rs | 1 - .../tui/src/onboarding/onboarding_screen.rs | 239 ++------- ..._tests__renders_snapshot_for_git_repo.snap | 15 - ...sts__renders_snapshot_for_trust_error.snap | 19 - .../tui/src/onboarding/trust_directory.rs | 258 ---------- 12 files changed, 388 insertions(+), 690 deletions(-) delete mode 100644 codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__renders_snapshot_for_git_repo.snap delete mode 100644 codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__renders_snapshot_for_trust_error.snap delete mode 100644 codex-rs/tui/src/onboarding/trust_directory.rs diff --git a/codex-rs/config/src/loader/mod.rs b/codex-rs/config/src/loader/mod.rs index 7c2a703e92..3d478ab7af 100644 --- a/codex-rs/config/src/loader/mod.rs +++ b/codex-rs/config/src/loader/mod.rs @@ -293,6 +293,7 @@ pub async fn load_config_layers_state( } let mut startup_warnings = None; + let mut project_root = None; if let Some(cwd) = cwd { let mut merged_so_far = TomlValue::Table(toml::map::Map::new()); for layer in &layers { @@ -350,6 +351,7 @@ pub async fn load_config_layers_state( strict_config, ) .await?; + project_root = Some(project_trust_context.project_root.clone()); layers.extend(project_layers.layers); startup_warnings = Some(project_layers.startup_warnings); } @@ -432,6 +434,7 @@ pub async fn load_config_layers_state( config_requirements_toml.clone().try_into()?, config_requirements_toml.into_toml(), )? + .with_project_root(project_root) .with_user_and_project_exec_policy_rules_ignored(ignore_user_and_project_exec_policy_rules); Ok(match startup_warnings { Some(startup_warnings) => config_layer_stack.with_startup_warnings(startup_warnings), diff --git a/codex-rs/config/src/state.rs b/codex-rs/config/src/state.rs index 9c0c1d8b56..c2f6e0864b 100644 --- a/codex-rs/config/src/state.rs +++ b/codex-rs/config/src/state.rs @@ -256,6 +256,9 @@ pub struct ConfigLayerStack { /// `None` means the loader did not check for stack-level warnings, while /// `Some(vec![])` means it checked and found nothing to report. startup_warnings: Option>, + + /// Project root resolved from the non-project config layers. + project_root: Option, } impl ConfigLayerStack { @@ -272,9 +275,21 @@ impl ConfigLayerStack { requirements_toml, ignore_user_and_project_exec_policy_rules: false, startup_warnings: None, + project_root: None, }) } + /// Records the project root resolved while loading this stack. + pub fn with_project_root(mut self, project_root: Option) -> Self { + self.project_root = project_root; + self + } + + /// Returns the project root resolved from the non-project layers. + pub fn project_root(&self) -> Option<&AbsolutePathBuf> { + self.project_root.as_ref() + } + pub fn with_user_and_project_exec_policy_rules_ignored( mut self, ignore_user_and_project_exec_policy_rules: bool, @@ -401,6 +416,7 @@ impl ConfigLayerStack { ignore_user_and_project_exec_policy_rules: self .ignore_user_and_project_exec_policy_rules, startup_warnings: self.startup_warnings.clone(), + project_root: self.project_root.clone(), }) } @@ -435,6 +451,7 @@ impl ConfigLayerStack { ignore_user_and_project_exec_policy_rules: self .ignore_user_and_project_exec_policy_rules, startup_warnings: self.startup_warnings.clone(), + project_root: self.project_root.clone(), } } diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index 54a1b52bdd..aab82a25ce 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -219,7 +219,8 @@ mod reload { layers, config.config_layer_stack.requirements().clone(), config.config_layer_stack.requirements_toml().clone(), - )?) + )? + .with_project_root(config.config_layer_stack.project_root().cloned())) } fn deserialize_effective_config( diff --git a/codex-rs/core/src/agent/role_tests.rs b/codex-rs/core/src/agent/role_tests.rs index ad64052fb2..0c9006a12b 100644 --- a/codex-rs/core/src/agent/role_tests.rs +++ b/codex-rs/core/src/agent/role_tests.rs @@ -159,6 +159,7 @@ async fn apply_role_preserves_unspecified_keys() { TomlValue::String("base-model".to_string()), )]) .await; + let project_root = config.config_layer_stack.project_root().cloned(); config.codex_linux_sandbox_exe = Some(PathBuf::from("/tmp/codex-linux-sandbox")); config.main_execve_wrapper_exe = Some(PathBuf::from("/tmp/codex-execve-wrapper")); let role_path = write_role_config( @@ -187,6 +188,10 @@ async fn apply_role_preserves_unspecified_keys() { (config.model.as_deref(), config.model_reasoning_effort), (Some("spawn-model"), Some(ReasoningEffort::Low)), ); + assert_eq!( + config.config_layer_stack.project_root(), + project_root.as_ref() + ); assert_eq!( config.codex_linux_sandbox_exe, Some(PathBuf::from("/tmp/codex-linux-sandbox")) diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index c7e8ddfcca..4d8c344b2d 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -9535,6 +9535,41 @@ async fn active_project_does_not_match_configured_alias_for_canonical_cwd() -> a Ok(()) } +#[tokio::test] +async fn active_project_honors_custom_project_root_markers() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let project_root = codex_home.path().join("project"); + let nested = project_root.join("nested"); + std::fs::create_dir_all(&nested)?; + std::fs::write(project_root.join(".hg"), "")?; + let project_key = project_root.display().to_string().replace('\\', "\\\\"); + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + format!( + r#"project_root_markers = [".hg"] + +[projects."{project_key}"] +trust_level = "untrusted" +"#, + ), + )?; + + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + cwd: Some(nested), + ..Default::default() + }) + .build() + .await?; + + assert_eq!( + config.active_project.trust_level, + Some(TrustLevel::Untrusted) + ); + Ok(()) +} + #[test] fn test_set_default_oss_provider() -> std::io::Result<()> { let temp_dir = TempDir::new()?; @@ -10478,6 +10513,10 @@ save_fields_resolved_from_model_catalog = false #[tokio::test] async fn debug_config_lockfile_load_path_loads_lock_from_nested_table() -> std::io::Result<()> { let codex_home = TempDir::new()?; + let project_root = codex_home.path().join("project"); + let nested = project_root.join("nested"); + std::fs::create_dir_all(&nested)?; + std::fs::write(project_root.join(".hg"), "")?; let lock_path = codex_home.path().join("session.config.lock.toml"); std::fs::write( &lock_path, @@ -10493,7 +10532,9 @@ codex_version = "older-version" std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), format!( - r#"[debug.config_lockfile] + r#"project_root_markers = [".hg"] + +[debug.config_lockfile] load_path = '{}' allow_codex_version_mismatch = true save_fields_resolved_from_model_catalog = false @@ -10504,10 +10545,14 @@ save_fields_resolved_from_model_catalog = false let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) - .fallback_cwd(Some(codex_home.path().to_path_buf())) + .fallback_cwd(Some(nested)) .build() .await?; + assert_eq!( + config.config_layer_stack.project_root(), + Some(&project_root.abs()) + ); assert!(config.config_lock_toml.is_some()); assert!(config.config_lock_allow_codex_version_mismatch); assert!(!config.config_lock_save_fields_resolved_from_model_catalog); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index a46090ef30..40858afb70 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1478,7 +1478,8 @@ impl ConfigBuilder { vec![lock_layer], config_layer_stack.requirements().clone(), config_layer_stack.requirements_toml().clone(), - )?; + )? + .with_project_root(config_layer_stack.project_root().cloned()); let mut config = Config::load_config_with_layer_stack( LOCAL_FS.as_ref(), lock_config_toml, @@ -1788,6 +1789,7 @@ impl Config { .requirements_toml() .clone(), )? + .with_project_root(refreshed_config.config_layer_stack.project_root().cloned()) .with_user_and_project_exec_policy_rules_ignored( refreshed_config .config_layer_stack @@ -3325,12 +3327,21 @@ impl Config { .into_iter() .map(|path| AbsolutePathBuf::resolve_path_against_base(path, resolved_cwd.as_path())) .collect(); + let project_root = config_layer_stack + .project_root() + .cloned() + .unwrap_or_else(|| resolved_cwd.clone()); let repo_root = resolve_root_git_project_for_trust(fs, &resolved_cwd).await; let active_project = cfg .get_active_project( resolved_cwd.as_path(), - repo_root.as_ref().map(AbsolutePathBuf::as_path), + Some(project_root.as_path()), ) + .or_else(|| { + repo_root.as_ref().and_then(|repo_root| { + cfg.get_active_project(repo_root.as_path(), /*repo_root*/ None) + }) + }) .unwrap_or(ProjectConfig { trust_level: None }); let permission_config_syntax = resolve_permission_config_syntax( &config_layer_stack, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 490122d053..1f7e563231 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -24,6 +24,7 @@ pub use app::ExitReason; use app_server_session::AppServerSession; use app_server_session::ThreadParamsMode; use codex_app_server_client::AppServerClient; +use codex_app_server_client::AppServerRequestHandle; use codex_app_server_client::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY; use codex_app_server_client::InProcessAppServerClient; use codex_app_server_client::InProcessClientStartArgs; @@ -42,10 +43,15 @@ use codex_cloud_config::cloud_config_bundle_loader_for_storage; use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLoadError; use codex_config::LoaderOverrides; +use codex_config::build_cli_overrides_layer; use codex_config::format_config_error_with_source; +use codex_config::loader::project_trust_key; +use codex_config::merge_toml_values; use codex_config::types::ResumeCwdMode; use codex_exec_server::EnvironmentManager; use codex_exec_server::ExecServerRuntimePaths; +use codex_exec_server::LOCAL_FS; +use codex_git_utils::resolve_root_git_project_for_trust; use codex_login::AuthConfig; use codex_login::AuthRouteConfig; use codex_login::default_client::originator; @@ -212,8 +218,7 @@ pub(crate) mod test_backend; #[cfg(test)] pub(crate) mod test_support; -use crate::onboarding::onboarding_screen::OnboardingScreenArgs; -use crate::onboarding::onboarding_screen::run_onboarding_app; +use crate::onboarding::onboarding_screen::run_login_onboarding; use crate::startup_hooks_review::StartupHooksReviewOutcome; use crate::startup_hooks_review::load_startup_hooks_review_entry; use crate::startup_hooks_review::maybe_run_startup_hooks_review; @@ -1322,12 +1327,12 @@ async fn run_ratatui_app( arg0_paths: Arg0DispatchPaths, loader_overrides: LoaderOverrides, strict_config: bool, - app_server_target: AppServerTarget, + mut app_server_target: AppServerTarget, remote_cwd_override: Option, initial_config: Config, manually_selected_oss_provider: Option, overrides: ConfigOverrides, - cli_kv_overrides: Vec<(String, toml::Value)>, + mut cli_kv_overrides: Vec<(String, toml::Value)>, mut cloud_config_bundle: CloudConfigBundleLoader, feedback: codex_feedback::CodexFeedback, log_db: Option, @@ -1383,7 +1388,7 @@ async fn run_ratatui_app( // Initialize high-fidelity session event logging if enabled. session_log::maybe_init(&initial_config); - let app_server_session = match start_app_server( + let mut app_server_session = match start_app_server( &app_server_target, arg0_paths.clone(), initial_config.clone(), @@ -1419,45 +1424,20 @@ async fn run_ratatui_app( "Failed to persist selected OSS provider preference" ); } - let mut app_server = Some(app_server_session); - - let should_show_trust_screen_flag = - !uses_remote_workspace && should_show_trust_screen(&initial_config); - #[cfg(target_os = "windows")] - let mut trust_decision_was_made = false; let login_status = if initial_config.model_provider.requires_openai_auth { - let Some(app_server) = app_server.as_mut() else { - unreachable!("app server should exist when auth is required"); - }; - get_login_status(app_server, &initial_config).await? + get_login_status(&mut app_server_session, &initial_config).await? } else { LoginStatus::NotAuthenticated }; - let should_show_onboarding = - should_show_onboarding(login_status, &initial_config, should_show_trust_screen_flag); + let show_login_screen = initial_config.model_provider.requires_openai_auth + && login_status == LoginStatus::NotAuthenticated; - let config = if should_show_onboarding { - let show_login_screen = should_show_login_screen(login_status, &initial_config); - let onboarding_result = run_onboarding_app( - OnboardingScreenArgs { - show_login_screen, - show_trust_screen: should_show_trust_screen_flag, - login_status, - app_server_request_handle: app_server - .as_ref() - .map(AppServerSession::request_handle), - config: initial_config.clone(), - }, - if show_login_screen { - app_server.as_mut() - } else { - None - }, - &mut tui, - ) - .await?; - if onboarding_result.should_exit { - shutdown_app_server_if_present(app_server.take()).await; + let mut reload_after_login = false; + if show_login_screen { + let onboarding_should_exit = + run_login_onboarding(initial_config.clone(), &mut app_server_session, &mut tui).await?; + if onboarding_should_exit { + shutdown_app_server_if_present(Some(app_server_session)).await; terminal_restore_guard.restore_silently(); session_log::log_session_end(); let _ = tui.terminal.clear(); @@ -1469,14 +1449,9 @@ async fn run_ratatui_app( exit_reason: ExitReason::UserRequested, }); } - #[cfg(target_os = "windows")] - { - trust_decision_was_made = onboarding_result.directory_trust_persisted; - } - // If this onboarding run included the login step, always refresh the cloud config bundle - // and rebuild config. This avoids missing newly available cloud-managed policy due to login - // status detection edge cases. - if show_login_screen && !uses_remote_workspace { + if !uses_remote_workspace { + // Always refresh the cloud config bundle and rebuild config after login. This avoids + // missing newly available cloud-managed policy due to login status detection edge cases. cloud_config_bundle = cloud_config_bundle_loader_for_storage( initial_config.codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, @@ -1486,27 +1461,25 @@ async fn run_ratatui_app( initial_config.auth_route_config(), ) .await; + reload_after_login = true; } + } - // If the user made an explicit trust decision, or we showed the login flow, reload config - // so current process state reflects persisted trust/auth changes. - if onboarding_result.directory_trust_persisted - || (show_login_screen && !uses_remote_workspace) - { - load_config_or_exit( - cli_kv_overrides.clone(), - overrides.clone(), - loader_overrides.clone(), - cloud_config_bundle.clone(), - strict_config, - ) - .await - } else { - initial_config - } + let config = if reload_after_login { + load_config_or_exit( + cli_kv_overrides.clone(), + overrides.clone(), + loader_overrides.clone(), + cloud_config_bundle.clone(), + strict_config, + ) + .await } else { initial_config }; + let uses_remote_workspace_or_environment = + uses_remote_workspace_or_environment(&app_server_target, &environment_manager); + let mut app_server = Some(app_server_session); let mut missing_session_exit = |id_str: &str, action: &str| { error!("Error finding conversation path: {id_str}"); @@ -1653,7 +1626,7 @@ async fn run_ratatui_app( &session_selection, cli.cwd.as_deref(), uses_remote_workspace, - uses_remote_workspace_or_environment(&app_server_target, &environment_manager), + uses_remote_workspace_or_environment, ) .await { @@ -1706,52 +1679,11 @@ async fn run_ratatui_app( _ => config, }; - // Configure syntax highlighting theme from the final config — onboarding - // and resume/fork can both reload config with a different tui_theme, so - // this must happen after the last possible reload. - if let Some(w) = crate::render::highlight::set_theme_override( - config.tui_theme.clone(), - find_codex_home().ok().map(AbsolutePathBuf::into_path_buf), - ) { - config.startup_warnings.push(w); - } - - set_default_client_residency_requirement(config.enforce_residency.value()); - let should_show_trust_screen = should_show_trust_screen(&config); - #[cfg(target_os = "windows")] - let windows_sandbox_level = crate::windows_sandbox::level_from_config(&config); - #[cfg(target_os = "windows")] - let required_elevated_sandbox_needs_setup = windows_sandbox_level - == WindowsSandboxLevel::Elevated - && config - .config_layer_stack - .requirements() - .windows_sandbox_mode - .source - .is_some() - && !crate::windows_sandbox::sandbox_setup_is_complete(config.codex_home.as_path()); - #[cfg(target_os = "windows")] - let should_prompt_windows_sandbox_nux_at_startup = (trust_decision_was_made - && windows_sandbox_level == WindowsSandboxLevel::Disabled) - || required_elevated_sandbox_needs_setup; - #[cfg(not(target_os = "windows"))] - let should_prompt_windows_sandbox_nux_at_startup = false; - - let Cli { - prompt, - shared, - no_alt_screen, - .. - } = cli; - let images = shared.into_inner().images; - - let use_alt_screen = determine_alt_screen_mode(no_alt_screen, config.tui_alternate_screen); - tui.set_alt_screen_enabled(use_alt_screen); let mut app_server = match app_server { Some(app_server) => app_server, None => match start_app_server( &app_server_target, - arg0_paths, + arg0_paths.clone(), config.clone(), cli_kv_overrides.clone(), loader_overrides.clone(), @@ -1776,6 +1708,102 @@ async fn run_ratatui_app( }, }; + let project_trust_was_undecided = + !uses_remote_workspace_or_environment && config.active_project.trust_level.is_none(); + let project_trust_outcome = persist_trust_for_undecided_project( + uses_remote_workspace_or_environment, + &config, + app_server.request_handle(), + &mut cli_kv_overrides, + ) + .await; + let project_trust_was_added = project_trust_outcome != ProjectTrustOutcome::Unchanged; + let project_trust_is_in_memory = project_trust_outcome == ProjectTrustOutcome::InMemory; + config = if project_trust_was_added { + load_config_or_exit_with_fallback_cwd( + cli_kv_overrides.clone(), + overrides.clone(), + loader_overrides.clone(), + cloud_config_bundle.clone(), + strict_config, + Some(config.cwd.to_path_buf()), + ) + .await + } else { + config + }; + if project_trust_is_in_memory { + shutdown_app_server_if_present(Some(app_server)).await; + app_server_target = AppServerTarget::Embedded; + let fallback_app_server = start_app_server( + &app_server_target, + arg0_paths, + config.clone(), + cli_kv_overrides.clone(), + loader_overrides.clone(), + strict_config, + cloud_config_bundle.clone(), + feedback.clone(), + log_db.clone(), + state_db.clone(), + environment_manager.clone(), + ) + .await + .inspect_err(|_err| { + terminal_restore_guard.restore_silently(); + session_log::log_session_end(); + })?; + app_server = + AppServerSession::new(fallback_app_server, app_server_target.thread_params_mode()) + .with_remote_cwd_override(remote_cwd_override.clone()); + } + + // Configure syntax highlighting theme from the final config — onboarding + // and resume/fork can both reload config with a different tui_theme, so + // this must happen after the last possible reload. + if let Some(w) = crate::render::highlight::set_theme_override( + config.tui_theme.clone(), + find_codex_home().ok().map(AbsolutePathBuf::into_path_buf), + ) { + config.startup_warnings.push(w); + } + + set_default_client_residency_requirement(config.enforce_residency.value()); + let is_first_run = project_trust_was_undecided + && matches!( + session_selection, + resume_picker::SessionSelection::StartFresh + ); + #[cfg(target_os = "windows")] + let windows_sandbox_level = crate::windows_sandbox::level_from_config(&config); + #[cfg(target_os = "windows")] + let required_elevated_sandbox_needs_setup = windows_sandbox_level + == WindowsSandboxLevel::Elevated + && config + .config_layer_stack + .requirements() + .windows_sandbox_mode + .source + .is_some() + && !crate::windows_sandbox::sandbox_setup_is_complete(config.codex_home.as_path()); + #[cfg(target_os = "windows")] + let should_prompt_windows_sandbox_nux_at_startup = (project_trust_was_added + && windows_sandbox_level == WindowsSandboxLevel::Disabled) + || required_elevated_sandbox_needs_setup; + #[cfg(not(target_os = "windows"))] + let should_prompt_windows_sandbox_nux_at_startup = false; + + let Cli { + prompt, + shared, + no_alt_screen, + .. + } = cli; + let images = shared.into_inner().images; + + let use_alt_screen = determine_alt_screen_mode(no_alt_screen, config.tui_alternate_screen); + tui.set_alt_screen_enabled(use_alt_screen); + // Persistent app-server resumes may attach to an already-running thread, // where resume config overrides are ignored. let is_persistent_resume = !matches!(&app_server_target, AppServerTarget::Embedded) @@ -1819,7 +1847,7 @@ async fn run_ratatui_app( images, session_selection, feedback, - should_show_trust_screen, // Proxy to: is it a first run in this directory? + is_first_run, should_prompt_windows_sandbox_nux_at_startup, app_server_target, state_db, @@ -2007,31 +2035,53 @@ async fn load_bootstrap_config_or_exit( } } -/// Determine if the user has decided whether to trust the current directory. -fn should_show_trust_screen(config: &Config) -> bool { - config.active_project.trust_level.is_none() +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ProjectTrustOutcome { + Unchanged, + Persisted, + InMemory, } -fn should_show_onboarding( - login_status: LoginStatus, +async fn persist_trust_for_undecided_project( + uses_remote_workspace_or_environment: bool, config: &Config, - show_trust_screen: bool, -) -> bool { - if show_trust_screen { - return true; + request_handle: AppServerRequestHandle, + cli_overrides: &mut Vec<(String, toml::Value)>, +) -> ProjectTrustOutcome { + if uses_remote_workspace_or_environment || config.active_project.trust_level.is_some() { + return ProjectTrustOutcome::Unchanged; } - should_show_login_screen(login_status, config) -} - -fn should_show_login_screen(login_status: LoginStatus, config: &Config) -> bool { - // Only show the login screen for providers that actually require OpenAI auth - // (OpenAI or equivalents). For OSS/other providers, skip login entirely. - if !config.model_provider.requires_openai_auth { - return false; + let trust_target = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &config.cwd) + .await + .or_else(|| config.config_layer_stack.project_root().cloned()) + .unwrap_or_else(|| config.cwd.clone()); + if let Err(err) = config_update::write_trusted_project(request_handle, &trust_target).await { + let error = config_update::format_config_error(&err); + warn!( + project = %trust_target.display(), + "failed to persist trusted project state; continuing with in-memory trust: {error}" + ); + let project = toml::Value::Table(toml::map::Map::from_iter([( + "trust_level".to_string(), + toml::Value::String("trusted".to_string()), + )])); + let trust_override = toml::Value::Table(toml::map::Map::from_iter([( + "projects".to_string(), + toml::Value::Table(toml::map::Map::from_iter([( + project_trust_key(trust_target.as_path()), + project, + )])), + )])); + let mut merged_overrides = build_cli_overrides_layer(cli_overrides); + merge_toml_values(&mut merged_overrides, &trust_override); + let toml::Value::Table(merged_overrides) = merged_overrides else { + unreachable!("CLI overrides always build a table"); + }; + *cli_overrides = merged_overrides.into_iter().collect(); + return ProjectTrustOutcome::InMemory; } - - login_status == LoginStatus::NotAuthenticated + ProjectTrustOutcome::Persisted } #[cfg(test)] @@ -2044,10 +2094,8 @@ mod tests { use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; - use codex_config::config_toml::ProjectConfig; use codex_utils_absolute_path::test_support::PathExt; use pretty_assertions::assert_eq; - use serial_test::serial; use tempfile::TempDir; async fn build_config(temp_dir: &TempDir) -> std::io::Result { @@ -2242,6 +2290,83 @@ mod tests { .await } + #[tokio::test] + async fn automatic_project_trust_persists_or_falls_back_in_memory() -> color_eyre::Result<()> { + use codex_protocol::config_types::TrustLevel; + + let temp_dir = TempDir::new()?; + let codex_home = temp_dir.path().join("codex-home"); + let repo = temp_dir.path().join("repo"); + let nested = repo.join("nested"); + let sibling = repo.join("sibling"); + std::fs::create_dir_all(&codex_home)?; + std::fs::create_dir_all(repo.join(".git"))?; + std::fs::create_dir_all(&nested)?; + std::fs::create_dir_all(&sibling)?; + let loader_overrides = LoaderOverrides::without_managed_config_for_tests(); + let config = ConfigBuilder::default() + .codex_home(codex_home.clone()) + .loader_overrides(loader_overrides.clone()) + .harness_overrides(ConfigOverrides { + cwd: Some(nested), + ..Default::default() + }) + .build() + .await?; + assert_eq!(config.active_project.trust_level, None); + let app_server = start_test_embedded_app_server(config.clone()).await?; + + let mut cli_overrides = Vec::new(); + let outcome = persist_trust_for_undecided_project( + /*uses_remote_workspace_or_environment*/ false, + &config, + AppServerRequestHandle::InProcess(app_server.request_handle()), + &mut cli_overrides, + ) + .await; + + assert_eq!(outcome, ProjectTrustOutcome::Persisted); + let reloaded = ConfigBuilder::default() + .codex_home(codex_home) + .loader_overrides(loader_overrides) + .harness_overrides(ConfigOverrides { + cwd: Some(sibling), + ..Default::default() + }) + .build() + .await?; + assert_eq!( + reloaded.active_project.trust_level, + Some(TrustLevel::Trusted) + ); + let request_handle = AppServerRequestHandle::InProcess(app_server.request_handle()); + app_server.shutdown().await?; + + let mut fallback_overrides = vec![( + "projects.existing.trust_level".to_string(), + toml::Value::String("untrusted".to_string()), + )]; + let outcome = persist_trust_for_undecided_project( + /*uses_remote_workspace_or_environment*/ false, + &config, + request_handle, + &mut fallback_overrides, + ) + .await; + + assert_eq!(outcome, ProjectTrustOutcome::InMemory); + let fallback_overrides = codex_config::build_cli_overrides_layer(&fallback_overrides); + assert_eq!( + fallback_overrides["projects"][project_trust_key(&repo)]["trust_level"].as_str(), + Some("trusted") + ); + assert_eq!( + fallback_overrides["projects"]["existing"]["trust_level"].as_str(), + Some("untrusted") + ); + Ok(()) + } + #[tokio::test] async fn startup_resume_and_fork_use_configured_or_explicit_cwd() -> color_eyre::Result<()> { for (action, configured_mode, has_explicit_cwd, expected_directory) in [ @@ -2314,7 +2439,7 @@ mod tests { ResolveCwdOutcome::Exit => panic!("configured cwd should not exit startup"), }; let final_config = ConfigBuilder::default() - .codex_home(codex_home) + .codex_home(codex_home.clone()) .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) .harness_overrides(ConfigOverrides { cwd: cwd_override.map(Path::to_path_buf), @@ -2335,6 +2460,22 @@ mod tests { Arc::new(EnvironmentManager::default_for_tests()), ) .await?; + let mut cli_overrides = Vec::new(); + let outcome = persist_trust_for_undecided_project( + /*uses_remote_workspace_or_environment*/ false, + &final_config, + app_server.request_handle(), + &mut cli_overrides, + ) + .await; + assert_eq!(outcome, ProjectTrustOutcome::Persisted); + let config_toml: toml::Value = + toml::from_str(&std::fs::read_to_string(codex_home.join("config.toml"))?)?; + assert!( + config_toml["projects"].as_table().is_some_and( + |projects| projects.contains_key(&project_trust_key(&expected_cwd)) + ) + ); let started = match action { CwdPromptAction::Resume => { app_server @@ -3077,22 +3218,6 @@ mod tests { Ok(()) } - #[tokio::test] - #[serial] - async fn windows_shows_trust_prompt_without_sandbox() -> std::io::Result<()> { - let temp_dir = TempDir::new()?; - let mut config = build_config(&temp_dir).await?; - config.active_project = ProjectConfig { trust_level: None }; - config.set_windows_sandbox_enabled(/*value*/ false); - - let should_show = should_show_trust_screen(&config); - assert!( - should_show, - "Trust prompt should be shown when project trust is undecided" - ); - Ok(()) - } - #[tokio::test] async fn embedded_app_server_supports_thread_start_rpc() -> color_eyre::Result<()> { let temp_dir = TempDir::new()?; @@ -3274,45 +3399,6 @@ mod tests { Ok(()) } - #[tokio::test] - #[serial] - async fn windows_shows_trust_prompt_with_sandbox() -> std::io::Result<()> { - let temp_dir = TempDir::new()?; - let mut config = build_config(&temp_dir).await?; - config.active_project = ProjectConfig { trust_level: None }; - config.set_windows_sandbox_enabled(/*value*/ true); - - let should_show = should_show_trust_screen(&config); - if cfg!(target_os = "windows") { - assert!( - should_show, - "Windows trust prompt should be shown on native Windows with sandbox enabled" - ); - } else { - assert!( - should_show, - "Non-Windows should still show trust prompt when project is untrusted" - ); - } - Ok(()) - } - #[tokio::test] - async fn untrusted_project_skips_trust_prompt() -> std::io::Result<()> { - use codex_protocol::config_types::TrustLevel; - let temp_dir = TempDir::new()?; - let mut config = build_config(&temp_dir).await?; - config.active_project = ProjectConfig { - trust_level: Some(TrustLevel::Untrusted), - }; - - let should_show = should_show_trust_screen(&config); - assert!( - !should_show, - "Trust prompt should not be shown for projects explicitly marked as untrusted" - ); - Ok(()) - } - #[tokio::test] async fn config_rebuild_changes_trust_defaults_with_cwd() -> std::io::Result<()> { let temp_dir = TempDir::new()?; diff --git a/codex-rs/tui/src/onboarding/mod.rs b/codex-rs/tui/src/onboarding/mod.rs index 016d086c54..68616d5024 100644 --- a/codex-rs/tui/src/onboarding/mod.rs +++ b/codex-rs/tui/src/onboarding/mod.rs @@ -1,7 +1,6 @@ mod auth; mod keys; pub(crate) mod onboarding_screen; -mod trust_directory; pub(crate) use auth::mark_underlined_hyperlink; pub(crate) use auth::mark_url_hyperlink; mod welcome; diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index af151409b6..7cd4924874 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -1,7 +1,7 @@ //! Onboarding screen orchestration and top-level keyboard routing. //! //! The onboarding flow is a small state machine over visible steps -//! (welcome/auth/trust). This module decides which step receives key/paste +//! (welcome/auth). This module decides which step receives key/paste //! events and enforces flow-level safety rules that cut across individual step //! widgets. //! @@ -13,10 +13,6 @@ use codex_app_server_client::AppServerEvent; use codex_app_server_client::AppServerRequestHandle; use codex_app_server_protocol::ServerNotification; -use codex_exec_server::LOCAL_FS; -use codex_git_utils::resolve_root_git_project_for_trust; -#[cfg(target_os = "windows")] -use codex_protocol::config_types::WindowsSandboxLevel; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -32,16 +28,12 @@ use codex_protocol::config_types::ForcedLoginMethod; use crate::LoginStatus; use crate::app_server_session::AppServerSession; -use crate::config_update::format_config_error; -use crate::config_update::write_trusted_project; use crate::key_hint::KeyBindingListExt; use crate::legacy_core::config::Config; use crate::onboarding::auth::AuthModeWidget; use crate::onboarding::auth::SignInOption; use crate::onboarding::auth::SignInState; use crate::onboarding::keys; -use crate::onboarding::trust_directory::TrustDirectorySelection; -use crate::onboarding::trust_directory::TrustDirectoryWidget; use crate::onboarding::welcome::WelcomeWidget; use crate::tui::FrameRequester; use crate::tui::Tui; @@ -54,7 +46,6 @@ use std::sync::RwLock; enum Step { Welcome(WelcomeWidget), Auth(AuthModeWidget), - TrustDirectory(TrustDirectoryWidget), } pub(crate) trait KeyboardHandler { @@ -80,19 +71,6 @@ pub(crate) struct OnboardingScreen { should_exit: bool, } -pub(crate) struct OnboardingScreenArgs { - pub show_trust_screen: bool, - pub show_login_screen: bool, - pub login_status: LoginStatus, - pub app_server_request_handle: Option, - pub config: Config, -} - -pub(crate) struct OnboardingResult { - pub directory_trust_persisted: bool, - pub should_exit: bool, -} - #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] struct ApiKeyEntryContext { /// True when onboarding is currently rendering the API-key entry state. @@ -102,64 +80,33 @@ struct ApiKeyEntryContext { } impl OnboardingScreen { - pub(crate) async fn new(tui: &mut Tui, args: OnboardingScreenArgs) -> Self { - let OnboardingScreenArgs { - show_trust_screen, - show_login_screen, - login_status, - app_server_request_handle, - config, - } = args; - let cwd = config.cwd.to_path_buf(); + fn new( + tui: &mut Tui, + app_server_request_handle: AppServerRequestHandle, + config: Config, + ) -> Self { let forced_login_method = config.forced_login_method; let mut steps: Vec = Vec::new(); steps.push(Step::Welcome(WelcomeWidget::new( - !matches!(login_status, LoginStatus::NotAuthenticated), + /*is_logged_in*/ false, tui.frame_requester(), config.animations, ))); - if show_login_screen { - let highlighted_mode = match forced_login_method { - Some(ForcedLoginMethod::Api) => SignInOption::ApiKey, - _ => SignInOption::ChatGpt, - }; - if let Some(app_server_request_handle) = app_server_request_handle { - steps.push(Step::Auth(AuthModeWidget { - request_frame: tui.frame_requester(), - highlighted_mode, - error: Arc::new(RwLock::new(None)), - sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)), - login_status, - app_server_request_handle, - forced_login_method, - animations_enabled: config.animations, - animations_suppressed: std::cell::Cell::new(false), - })); - } else { - tracing::warn!("skipping onboarding login step without app-server request handle"); - } - } - #[cfg(target_os = "windows")] - let show_windows_create_sandbox_hint = - crate::windows_sandbox::level_from_config(&config) == WindowsSandboxLevel::Disabled; - #[cfg(not(target_os = "windows"))] - let show_windows_create_sandbox_hint = false; - let highlighted = TrustDirectorySelection::Trust; - if show_trust_screen { - let trust_target = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &config.cwd) - .await - .map(Into::into) - .unwrap_or_else(|| cwd.clone()); - steps.push(Step::TrustDirectory(TrustDirectoryWidget { - cwd, - trust_target, - show_windows_create_sandbox_hint, - should_quit: false, - selection: None, - highlighted, - error: None, - })) - } + let highlighted_mode = match forced_login_method { + Some(ForcedLoginMethod::Api) => SignInOption::ApiKey, + _ => SignInOption::ChatGpt, + }; + steps.push(Step::Auth(AuthModeWidget { + request_frame: tui.frame_requester(), + highlighted_mode, + error: Arc::new(RwLock::new(None)), + sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)), + login_status: LoginStatus::NotAuthenticated, + app_server_request_handle, + forced_login_method, + animations_enabled: config.animations, + animations_suppressed: std::cell::Cell::new(false), + })); Self { request_frame: tui.frame_requester(), steps, @@ -203,7 +150,7 @@ impl OnboardingScreen { // material so terminal selection is not interrupted by redraws. self.current_steps().into_iter().any(|step| match step { Step::Auth(widget) => widget.should_suppress_animations(), - Step::Welcome(_) | Step::TrustDirectory(_) => false, + Step::Welcome(_) => false, }) } @@ -236,7 +183,7 @@ impl OnboardingScreen { fn auth_widget_mut(&mut self) -> Option<&mut AuthModeWidget> { self.steps.iter_mut().find_map(|step| match step { Step::Auth(widget) => Some(widget), - Step::Welcome(_) | Step::TrustDirectory(_) => None, + Step::Welcome(_) => None, }) } @@ -308,16 +255,6 @@ impl KeyboardHandler for OnboardingScreen { if let Some(active_step) = self.current_steps_mut().into_iter().last() { active_step.handle_key_event(key_event); } - if self.steps.iter().any(|step| { - if let Step::TrustDirectory(widget) = step { - widget.should_quit() - } else { - false - } - }) { - self.should_exit = true; - self.is_done = true; - } } self.request_frame.schedule_frame(); } @@ -359,7 +296,6 @@ impl WidgetRef for &OnboardingScreen { match step { Step::Welcome(widget) => widget.set_animations_suppressed(suppress_animations), Step::Auth(widget) => widget.set_animations_suppressed(suppress_animations), - Step::TrustDirectory(_) => {} } } @@ -432,7 +368,6 @@ impl KeyboardHandler for Step { match self { Step::Welcome(widget) => widget.handle_key_event(key_event), Step::Auth(widget) => widget.handle_key_event(key_event), - Step::TrustDirectory(widget) => widget.handle_key_event(key_event), } } @@ -440,7 +375,6 @@ impl KeyboardHandler for Step { match self { Step::Welcome(_) => {} Step::Auth(widget) => widget.handle_paste(pasted), - Step::TrustDirectory(widget) => widget.handle_paste(pasted), } } } @@ -450,7 +384,6 @@ impl StepStateProvider for Step { match self { Step::Welcome(w) => w.get_step_state(), Step::Auth(w) => w.get_step_state(), - Step::TrustDirectory(w) => w.get_step_state(), } } } @@ -464,23 +397,18 @@ impl WidgetRef for Step { Step::Auth(widget) => { widget.render_ref(area, buf); } - Step::TrustDirectory(widget) => { - widget.render_ref(area, buf); - } } } } -pub(crate) async fn run_onboarding_app( - args: OnboardingScreenArgs, - mut app_server: Option<&mut AppServerSession>, +pub(crate) async fn run_login_onboarding( + config: Config, + app_server: &mut AppServerSession, tui: &mut Tui, -) -> Result { +) -> Result { use tokio_stream::StreamExt; - let app_server_request_handle = args.app_server_request_handle.clone(); - let mut onboarding_screen = OnboardingScreen::new(tui, args).await; - let mut directory_trust_persisted = false; + let mut onboarding_screen = OnboardingScreen::new(tui, app_server.request_handle(), config); // One-time guard to fully clear the screen after ChatGPT login success message is shown let mut did_full_clear_after_success = false; @@ -499,13 +427,6 @@ pub(crate) async fn run_onboarding_app( match event { TuiEvent::Key(key_event) => { onboarding_screen.handle_key_event(key_event); - if !directory_trust_persisted { - directory_trust_persisted = persist_selected_trust( - &mut onboarding_screen, - app_server_request_handle.clone(), - ) - .await; - } } TuiEvent::Paste(text) => { onboarding_screen.handle_paste(text); @@ -548,12 +469,7 @@ pub(crate) async fn run_onboarding_app( } } } - event = async { - match app_server.as_mut() { - Some(app_server) => app_server.next_event().await, - None => None, - } - }, if app_server.is_some() => { + event = app_server.next_event() => { if let Some(event) = event { match event { AppServerEvent::ServerNotification(notification) => { @@ -569,75 +485,16 @@ pub(crate) async fn run_onboarding_app( } } } - Ok(OnboardingResult { - directory_trust_persisted, - should_exit: onboarding_screen.should_exit(), - }) -} - -async fn persist_selected_trust( - onboarding_screen: &mut OnboardingScreen, - request_handle: Option, -) -> bool { - let Some((trust_step_index, trust_target)) = onboarding_screen - .steps - .iter() - .enumerate() - .find_map(|(index, step)| { - if let Step::TrustDirectory(widget) = step - && widget.selection == Some(TrustDirectorySelection::Trust) - { - return Some((index, widget.trust_target.clone())); - } - None - }) - else { - return false; - }; - - let result = match request_handle { - Some(request_handle) => write_trusted_project(request_handle, &trust_target) - .await - .map(|_| ()), - None => Err(color_eyre::eyre::eyre!("app server unavailable")), - }; - - match result { - Ok(()) => true, - Err(error) => { - let error = format_config_error(&error); - tracing::error!( - "failed to persist trusted project state for {}: {error}", - trust_target.display() - ); - if let Step::TrustDirectory(widget) = &mut onboarding_screen.steps[trust_step_index] { - widget.selection = None; - widget.error = Some(format!( - "Failed to set trust for {}: {error}", - trust_target.display() - )); - } - false - } - } + Ok(onboarding_screen.should_exit()) } #[cfg(test)] mod tests { use super::ApiKeyEntryContext; - use super::OnboardingScreen; - use super::Step; - use super::StepStateProvider; - use super::persist_selected_trust; use super::suppress_quit_while_typing_api_key; - use crate::onboarding::trust_directory::TrustDirectorySelection; - use crate::onboarding::trust_directory::TrustDirectoryWidget; - use crate::tui::FrameRequester; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; - use pretty_assertions::assert_eq; - use std::path::PathBuf; #[test] fn suppresses_printable_quit_key_during_api_key_entry() { @@ -686,38 +543,4 @@ mod tests { ); assert!(!suppressed); } - - #[tokio::test] - async fn trust_persistence_failure_keeps_trust_step_in_progress() { - let mut onboarding_screen = OnboardingScreen { - request_frame: FrameRequester::test_dummy(), - steps: vec![Step::TrustDirectory(TrustDirectoryWidget { - cwd: PathBuf::from("/workspace/project"), - trust_target: PathBuf::from("/workspace/project"), - show_windows_create_sandbox_hint: false, - should_quit: false, - selection: Some(TrustDirectorySelection::Trust), - highlighted: TrustDirectorySelection::Trust, - error: None, - })], - is_done: false, - should_exit: false, - }; - - let persisted = - persist_selected_trust(&mut onboarding_screen, /*request_handle*/ None).await; - - assert!(!persisted); - let Step::TrustDirectory(widget) = &onboarding_screen.steps[0] else { - panic!("trust step should remain present"); - }; - assert_eq!(widget.selection, None); - assert_eq!(widget.get_step_state(), super::StepState::InProgress); - assert!( - widget - .error - .as_deref() - .is_some_and(|error| error.contains("app server unavailable")) - ); - } } diff --git a/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__renders_snapshot_for_git_repo.snap b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__renders_snapshot_for_git_repo.snap deleted file mode 100644 index 0ffd8d35ca..0000000000 --- a/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__renders_snapshot_for_git_repo.snap +++ /dev/null @@ -1,15 +0,0 @@ ---- -source: tui/src/onboarding/trust_directory.rs -expression: terminal.backend() ---- -> You are in /workspace/project - - Do you trust the contents of this directory? Working with untrusted - contents comes with higher risk of prompt injection. Trusting the - directory allows project-local config, hooks, and exec policies to - load. - -› 1. Yes, continue - 2. No, quit - - Press enter to continue diff --git a/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__renders_snapshot_for_trust_error.snap b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__renders_snapshot_for_trust_error.snap deleted file mode 100644 index 6f7a495d1a..0000000000 --- a/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__renders_snapshot_for_trust_error.snap +++ /dev/null @@ -1,19 +0,0 @@ ---- -source: tui/src/onboarding/trust_directory.rs -expression: terminal.backend() ---- -> You are in /workspace/project - - Do you trust the contents of this directory? Working with untrusted - contents comes with higher risk of prompt injection. Trusting the - directory allows project-local config, hooks, and exec policies to - load. - -› 1. Yes, continue - 2. No, quit - - Failed to set trust for /workspace/project: config/batchWrite failed - in TUI: Invalid configuration: features.fast_mode=true is not - supported; allowed set [fast_mode=false] - - Press enter to continue diff --git a/codex-rs/tui/src/onboarding/trust_directory.rs b/codex-rs/tui/src/onboarding/trust_directory.rs deleted file mode 100644 index 8a98cbca6b..0000000000 --- a/codex-rs/tui/src/onboarding/trust_directory.rs +++ /dev/null @@ -1,258 +0,0 @@ -use std::path::PathBuf; - -use crossterm::event::KeyEvent; -use crossterm::event::KeyEventKind; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::style::Stylize; -use ratatui::text::Line; -use ratatui::widgets::Paragraph; -use ratatui::widgets::WidgetRef; -use ratatui::widgets::Wrap; - -use crate::key_hint::KeyBindingListExt; -use crate::onboarding::keys; -use crate::onboarding::onboarding_screen::KeyboardHandler; -use crate::onboarding::onboarding_screen::StepStateProvider; -use crate::render::Insets; -use crate::render::renderable::ColumnRenderable; -use crate::render::renderable::Renderable; -use crate::render::renderable::RenderableExt as _; -use crate::selection_list::selection_option_row; - -use super::onboarding_screen::StepState; -pub(crate) struct TrustDirectoryWidget { - pub cwd: PathBuf, - pub trust_target: PathBuf, - pub show_windows_create_sandbox_hint: bool, - pub should_quit: bool, - pub selection: Option, - pub highlighted: TrustDirectorySelection, - pub error: Option, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum TrustDirectorySelection { - Trust, - Quit, -} - -impl WidgetRef for &TrustDirectoryWidget { - fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let mut column = ColumnRenderable::new(); - - column.push(Line::from(vec![ - "> ".into(), - "You are in ".bold(), - self.cwd.to_string_lossy().to_string().into(), - ])); - column.push(""); - - if self.cwd != self.trust_target { - #[allow(clippy::disallowed_methods)] - let git_root_warning = Paragraph::new(format!( - "Note: You’re in a subdirectory of a Git project. Trusting will apply to the repository root: {}", - self.trust_target.display() - )) - .yellow(); - column.push( - git_root_warning - .wrap(Wrap { trim: true }) - .inset(Insets::tlbr( - /*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0, - )), - ); - column.push(""); - } - - column.push( - Paragraph::new( - "Do you trust the contents of this directory? Working with untrusted \ - contents comes with higher risk of prompt injection. Trusting the \ - directory allows project-local config, hooks, and exec policies to load." - .to_string(), - ) - .wrap(Wrap { trim: true }) - .inset(Insets::tlbr( - /*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0, - )), - ); - column.push(""); - - let options: Vec<(&str, TrustDirectorySelection)> = vec![ - ("Yes, continue", TrustDirectorySelection::Trust), - ("No, quit", TrustDirectorySelection::Quit), - ]; - - for (idx, (text, selection)) in options.iter().enumerate() { - column.push(selection_option_row( - idx, - text.to_string(), - self.highlighted == *selection, - )); - } - - column.push(""); - - if let Some(error) = &self.error { - column.push( - Paragraph::new(error.to_string()) - .red() - .wrap(Wrap { trim: true }) - .inset(Insets::tlbr( - /*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0, - )), - ); - column.push(""); - } - - column.push( - Line::from(vec![ - "Press ".dim(), - keys::CONFIRM[0].into(), - if self.show_windows_create_sandbox_hint { - " to continue and create a sandbox...".dim() - } else { - " to continue".dim() - }, - ]) - .inset(Insets::tlbr( - /*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0, - )), - ); - - column.render(area, buf); - } -} - -impl KeyboardHandler for TrustDirectoryWidget { - fn handle_key_event(&mut self, key_event: KeyEvent) { - if key_event.kind == KeyEventKind::Release { - return; - } - - if keys::MOVE_UP.is_pressed(key_event) { - self.highlighted = TrustDirectorySelection::Trust; - } else if keys::MOVE_DOWN.is_pressed(key_event) { - self.highlighted = TrustDirectorySelection::Quit; - } else if keys::SELECT_FIRST.is_pressed(key_event) { - self.handle_trust(); - } else if keys::SELECT_SECOND.is_pressed(key_event) - || keys::QUIT.is_pressed(key_event) - || keys::CANCEL.is_pressed(key_event) - { - self.handle_quit(); - } else if keys::CONFIRM.is_pressed(key_event) { - match self.highlighted { - TrustDirectorySelection::Trust => self.handle_trust(), - TrustDirectorySelection::Quit => self.handle_quit(), - } - } - } -} - -impl StepStateProvider for TrustDirectoryWidget { - fn get_step_state(&self) -> StepState { - if self.selection.is_some() || self.should_quit { - StepState::Complete - } else { - StepState::InProgress - } - } -} - -impl TrustDirectoryWidget { - fn handle_trust(&mut self) { - self.highlighted = TrustDirectorySelection::Trust; - self.error = None; - self.selection = Some(TrustDirectorySelection::Trust); - } - - fn handle_quit(&mut self) { - self.highlighted = TrustDirectorySelection::Quit; - self.should_quit = true; - } - - pub fn should_quit(&self) -> bool { - self.should_quit - } -} - -#[cfg(test)] -mod tests { - use crate::test_backend::VT100Backend; - - use super::*; - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyEventKind; - use crossterm::event::KeyModifiers; - use pretty_assertions::assert_eq; - use ratatui::Terminal; - use std::path::PathBuf; - - fn widget(error: Option) -> TrustDirectoryWidget { - TrustDirectoryWidget { - cwd: PathBuf::from("/workspace/project"), - trust_target: PathBuf::from("/workspace/project"), - show_windows_create_sandbox_hint: false, - should_quit: false, - selection: None, - highlighted: TrustDirectorySelection::Trust, - error, - } - } - - #[test] - fn release_event_does_not_change_selection() { - let mut widget = TrustDirectoryWidget { - cwd: PathBuf::from("."), - trust_target: PathBuf::from("."), - show_windows_create_sandbox_hint: false, - should_quit: false, - selection: None, - highlighted: TrustDirectorySelection::Quit, - error: None, - }; - - let release = KeyEvent { - kind: KeyEventKind::Release, - ..KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE) - }; - widget.handle_key_event(release); - assert_eq!(widget.selection, None); - - let press = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE); - widget.handle_key_event(press); - assert!(widget.should_quit); - } - - #[test] - fn renders_snapshot_for_git_repo() { - let widget = widget(/*error*/ None); - - let mut terminal = - Terminal::new(VT100Backend::new(/*width*/ 70, /*height*/ 14)).expect("terminal"); - terminal - .draw(|f| (&widget).render_ref(f.area(), f.buffer_mut())) - .expect("draw"); - - insta::assert_snapshot!(terminal.backend()); - } - - #[test] - fn renders_snapshot_for_trust_error() { - let widget = widget(Some( - "Failed to set trust for /workspace/project: config/batchWrite failed in TUI: Invalid configuration: features.fast_mode=true is not supported; allowed set [fast_mode=false]" - .to_string(), - )); - - let mut terminal = - Terminal::new(VT100Backend::new(/*width*/ 70, /*height*/ 18)).expect("terminal"); - terminal - .draw(|f| (&widget).render_ref(f.area(), f.buffer_mut())) - .expect("draw"); - - insta::assert_snapshot!(terminal.backend()); - } -}