diff --git a/codex-rs/config/src/loader/mod.rs b/codex-rs/config/src/loader/mod.rs index 3d478ab7af..7c2a703e92 100644 --- a/codex-rs/config/src/loader/mod.rs +++ b/codex-rs/config/src/loader/mod.rs @@ -293,7 +293,6 @@ 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 { @@ -351,7 +350,6 @@ 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); } @@ -434,7 +432,6 @@ 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 c2f6e0864b..9c0c1d8b56 100644 --- a/codex-rs/config/src/state.rs +++ b/codex-rs/config/src/state.rs @@ -256,9 +256,6 @@ 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 { @@ -275,21 +272,9 @@ 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, @@ -416,7 +401,6 @@ 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(), }) } @@ -451,7 +435,6 @@ 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 aab82a25ce..54a1b52bdd 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -219,8 +219,7 @@ 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 0c9006a12b..ad64052fb2 100644 --- a/codex-rs/core/src/agent/role_tests.rs +++ b/codex-rs/core/src/agent/role_tests.rs @@ -159,7 +159,6 @@ 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( @@ -188,10 +187,6 @@ 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 c9f1344f95..ab004642fc 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -9642,41 +9642,6 @@ 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()?; @@ -10621,10 +10586,6 @@ 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, @@ -10640,9 +10601,7 @@ codex_version = "older-version" std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), format!( - r#"project_root_markers = [".hg"] - -[debug.config_lockfile] + r#"[debug.config_lockfile] load_path = '{}' allow_codex_version_mismatch = true save_fields_resolved_from_model_catalog = false @@ -10653,14 +10612,10 @@ 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(nested)) + .fallback_cwd(Some(codex_home.path().to_path_buf())) .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 e74f6668a3..1c71afa2dd 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1488,8 +1488,7 @@ 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, @@ -1799,7 +1798,6 @@ 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 @@ -3348,21 +3346,12 @@ 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(), - Some(project_root.as_path()), + repo_root.as_ref().map(AbsolutePathBuf::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 4e25dc90e2..60641a30e5 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -24,7 +24,6 @@ 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; @@ -43,15 +42,10 @@ 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; @@ -218,7 +212,8 @@ pub(crate) mod test_backend; #[cfg(test)] pub(crate) mod test_support; -use crate::onboarding::onboarding_screen::run_login_onboarding; +use crate::onboarding::onboarding_screen::OnboardingScreenArgs; +use crate::onboarding::onboarding_screen::run_onboarding_app; 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; @@ -1327,12 +1322,12 @@ async fn run_ratatui_app( arg0_paths: Arg0DispatchPaths, loader_overrides: LoaderOverrides, strict_config: bool, - mut app_server_target: AppServerTarget, + app_server_target: AppServerTarget, remote_cwd_override: Option, initial_config: Config, manually_selected_oss_provider: Option, overrides: ConfigOverrides, - mut cli_kv_overrides: Vec<(String, toml::Value)>, + cli_kv_overrides: Vec<(String, toml::Value)>, mut cloud_config_bundle: CloudConfigBundleLoader, feedback: codex_feedback::CodexFeedback, log_db: Option, @@ -1388,7 +1383,7 @@ async fn run_ratatui_app( // Initialize high-fidelity session event logging if enabled. session_log::maybe_init(&initial_config); - let mut app_server_session = match start_app_server( + let app_server_session = match start_app_server( &app_server_target, arg0_paths.clone(), initial_config.clone(), @@ -1424,20 +1419,45 @@ 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 { - get_login_status(&mut app_server_session, &initial_config).await? + 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? } else { LoginStatus::NotAuthenticated }; - let show_login_screen = initial_config.model_provider.requires_openai_auth - && login_status == LoginStatus::NotAuthenticated; + let should_show_onboarding = + should_show_onboarding(login_status, &initial_config, should_show_trust_screen_flag); - 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; + 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; terminal_restore_guard.restore_silently(); session_log::log_session_end(); let _ = tui.terminal.clear(); @@ -1449,9 +1469,14 @@ async fn run_ratatui_app( exit_reason: ExitReason::UserRequested, }); } - 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. + #[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 { cloud_config_bundle = cloud_config_bundle_loader_for_storage( initial_config.codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, @@ -1461,25 +1486,27 @@ async fn run_ratatui_app( initial_config.auth_route_config(), ) .await; - reload_after_login = true; } - } - 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 + // 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 + } } 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}"); @@ -1626,7 +1653,7 @@ async fn run_ratatui_app( &session_selection, cli.cwd.as_deref(), uses_remote_workspace, - uses_remote_workspace_or_environment, + uses_remote_workspace_or_environment(&app_server_target, &environment_manager), ) .await { @@ -1679,11 +1706,52 @@ 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.clone(), + arg0_paths, config.clone(), cli_kv_overrides.clone(), loader_overrides.clone(), @@ -1708,102 +1776,6 @@ 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) @@ -1847,7 +1819,7 @@ async fn run_ratatui_app( images, session_selection, feedback, - is_first_run, + should_show_trust_screen, // Proxy to: is it a first run in this directory? should_prompt_windows_sandbox_nux_at_startup, app_server_target, state_db, @@ -2035,53 +2007,31 @@ async fn load_bootstrap_config_or_exit( } } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum ProjectTrustOutcome { - Unchanged, - Persisted, - InMemory, +/// 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() } -async fn persist_trust_for_undecided_project( - uses_remote_workspace_or_environment: bool, +fn should_show_onboarding( + login_status: LoginStatus, config: &Config, - 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; + show_trust_screen: bool, +) -> bool { + if show_trust_screen { + return true; } - 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; + 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; } - ProjectTrustOutcome::Persisted + + login_status == LoginStatus::NotAuthenticated } #[cfg(test)] @@ -2094,8 +2044,10 @@ 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 { @@ -2290,83 +2242,6 @@ 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 [ @@ -2439,7 +2314,7 @@ mod tests { ResolveCwdOutcome::Exit => panic!("configured cwd should not exit startup"), }; let final_config = ConfigBuilder::default() - .codex_home(codex_home.clone()) + .codex_home(codex_home) .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) .harness_overrides(ConfigOverrides { cwd: cwd_override.map(Path::to_path_buf), @@ -2460,22 +2335,6 @@ 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 @@ -3218,6 +3077,22 @@ 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()?; @@ -3516,6 +3391,45 @@ 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 68616d5024..016d086c54 100644 --- a/codex-rs/tui/src/onboarding/mod.rs +++ b/codex-rs/tui/src/onboarding/mod.rs @@ -1,6 +1,7 @@ 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 7cd4924874..af151409b6 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). This module decides which step receives key/paste +//! (welcome/auth/trust). This module decides which step receives key/paste //! events and enforces flow-level safety rules that cut across individual step //! widgets. //! @@ -13,6 +13,10 @@ 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; @@ -28,12 +32,16 @@ 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; @@ -46,6 +54,7 @@ use std::sync::RwLock; enum Step { Welcome(WelcomeWidget), Auth(AuthModeWidget), + TrustDirectory(TrustDirectoryWidget), } pub(crate) trait KeyboardHandler { @@ -71,6 +80,19 @@ 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. @@ -80,33 +102,64 @@ struct ApiKeyEntryContext { } impl OnboardingScreen { - fn new( - tui: &mut Tui, - app_server_request_handle: AppServerRequestHandle, - config: Config, - ) -> Self { + 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(); let forced_login_method = config.forced_login_method; let mut steps: Vec = Vec::new(); steps.push(Step::Welcome(WelcomeWidget::new( - /*is_logged_in*/ false, + !matches!(login_status, LoginStatus::NotAuthenticated), tui.frame_requester(), config.animations, ))); - 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), - })); + 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, + })) + } Self { request_frame: tui.frame_requester(), steps, @@ -150,7 +203,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(_) => false, + Step::Welcome(_) | Step::TrustDirectory(_) => false, }) } @@ -183,7 +236,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(_) => None, + Step::Welcome(_) | Step::TrustDirectory(_) => None, }) } @@ -255,6 +308,16 @@ 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(); } @@ -296,6 +359,7 @@ 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(_) => {} } } @@ -368,6 +432,7 @@ 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), } } @@ -375,6 +440,7 @@ impl KeyboardHandler for Step { match self { Step::Welcome(_) => {} Step::Auth(widget) => widget.handle_paste(pasted), + Step::TrustDirectory(widget) => widget.handle_paste(pasted), } } } @@ -384,6 +450,7 @@ 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(), } } } @@ -397,18 +464,23 @@ 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_login_onboarding( - config: Config, - app_server: &mut AppServerSession, +pub(crate) async fn run_onboarding_app( + args: OnboardingScreenArgs, + mut app_server: Option<&mut AppServerSession>, tui: &mut Tui, -) -> Result { +) -> Result { use tokio_stream::StreamExt; - let mut onboarding_screen = OnboardingScreen::new(tui, app_server.request_handle(), config); + 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; // One-time guard to fully clear the screen after ChatGPT login success message is shown let mut did_full_clear_after_success = false; @@ -427,6 +499,13 @@ pub(crate) async fn run_login_onboarding( 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); @@ -469,7 +548,12 @@ pub(crate) async fn run_login_onboarding( } } } - event = app_server.next_event() => { + event = async { + match app_server.as_mut() { + Some(app_server) => app_server.next_event().await, + None => None, + } + }, if app_server.is_some() => { if let Some(event) = event { match event { AppServerEvent::ServerNotification(notification) => { @@ -485,16 +569,75 @@ pub(crate) async fn run_login_onboarding( } } } - Ok(onboarding_screen.should_exit()) + 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 + } + } } #[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() { @@ -543,4 +686,38 @@ 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 new file mode 100644 index 0000000000..0ffd8d35ca --- /dev/null +++ b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__renders_snapshot_for_git_repo.snap @@ -0,0 +1,15 @@ +--- +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 new file mode 100644 index 0000000000..6f7a495d1a --- /dev/null +++ b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__renders_snapshot_for_trust_error.snap @@ -0,0 +1,19 @@ +--- +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 new file mode 100644 index 0000000000..8a98cbca6b --- /dev/null +++ b/codex-rs/tui/src/onboarding/trust_directory.rs @@ -0,0 +1,258 @@ +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()); + } +}