Текстовый файл из учебного комплекта, не свидетельство установленного runtime. Вернуться к учебнику · Исходный файл
1 // Forbid accidental stdout/stderr writes in the *library* portion of the TUI. 2 // The standalone `codex-tui` binary prints a short help message before the 3 // alternate‑screen mode starts; that file opts‑out locally via `allow`. 4 #![recursion_limit = "256"] 5 #![deny(clippy::print_stdout, clippy::print_stderr)] 6 #![deny(clippy::disallowed_methods)] 7 use crate::legacy_core::config::Config; 8 use crate::legacy_core::config::ConfigBuilder; 9 use crate::legacy_core::config::ConfigOverrides; 10 use crate::legacy_core::config::ConfigTomlLoadResult; 11 use crate::legacy_core::config::bootstrap_auth_config; 12 use crate::legacy_core::config::load_config_toml_with_layer_stack; 13 #[cfg(test)] 14 use crate::legacy_core::config::resolve_bootstrap_http_client_factory; 15 use crate::legacy_core::config::resolve_oss_provider; 16 use crate::legacy_core::config::resolve_profile_v2_config_path; 17 use crate::session_resume::ResolveCwdOutcome; 18 use crate::session_resume::ResumeCwdContext; 19 use crate::session_resume::effective_resume_cwd_mode; 20 use crate::session_resume::read_session_cwd; 21 use crate::session_resume::resolve_cwd_for_resume_or_fork; 22 pub use crate::startup_error::LocalStateDbStartupError; 23 use additional_dirs::add_dir_warning_message; 24 use app::App; 25 pub use app::AppExitInfo; 26 pub use app::DisconnectInfo; 27 pub use app::ExitReason; 28 pub use app::ResumableThread; 29 use app_server_session::AppServerSession; 30 use app_server_session::ThreadParamsMode; 31 use codex_app_server_client::AppServerClient; 32 use codex_app_server_client::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY; 33 use codex_app_server_client::InProcessAppServerClient; 34 use codex_app_server_client::InProcessClientStartArgs; 35 use codex_app_server_client::RemoteAppServerClient; 36 use codex_app_server_client::RemoteAppServerConnectArgs; 37 pub use codex_app_server_client::RemoteAppServerEndpoint; 38 use codex_app_server_protocol::Account as AppServerAccount; 39 use codex_app_server_protocol::AskForApproval; 40 use codex_app_server_protocol::ConfigWarningNotification; 41 use codex_app_server_protocol::GetAccountResponse; 42 use codex_app_server_protocol::Thread as AppServerThread; 43 #[cfg(test)] 44 use codex_app_server_protocol::ThreadListCwdFilter; 45 use codex_app_server_protocol::ThreadListParams; 46 use codex_app_server_protocol::ThreadSortKey as AppServerThreadSortKey; 47 use codex_app_server_protocol::ThreadSourceKind; 48 use codex_cloud_config::cloud_config_bundle_loader_for_storage; 49 use codex_config::CloudConfigBundleLoader; 50 use codex_config::ConfigLoadError; 51 use codex_config::LoaderOverrides; 52 use codex_config::format_config_error_with_source; 53 use codex_config::types::ResumeCwdMode; 54 use codex_exec_server::EnvironmentManager; 55 use codex_exec_server::ExecServerRuntimePaths; 56 use codex_features::Feature; 57 use codex_login::AuthConfig; 58 use codex_login::default_client::originator; 59 use codex_login::default_client::set_default_client_residency_requirement; 60 use codex_login::enforce_login_restrictions; 61 use codex_login::is_workload_identity_selected; 62 use codex_protocol::ThreadId; 63 use codex_protocol::auth::AuthMode; 64 use codex_protocol::config_types::AltScreenMode; 65 use codex_protocol::config_types::ForcedLoginMethod; 66 use codex_protocol::config_types::SandboxMode; 67 use codex_rollout::StateDbHandle; 68 use codex_rollout::state_db; 69 use codex_state::log_db; 70 use codex_utils_absolute_path::AbsolutePathBuf; 71 use codex_utils_absolute_path::canonicalize_existing_preserving_symlinks; 72 use codex_utils_home_dir::find_codex_home; 73 use codex_utils_oss::ensure_oss_provider_ready; 74 use codex_utils_oss::get_default_model_for_oss_provider; 75 use color_eyre::eyre::WrapErr; 76 use crossterm::SynchronizedUpdate; 77 use cwd_prompt::CwdPromptAction; 78 pub use session_archive_commands::DeleteConfirmation; 79 pub use session_archive_commands::SessionArchiveAction; 80 pub use session_archive_commands::SessionArchiveCommandOptions; 81 pub use session_archive_commands::run_session_archive_command; 82 pub use session_queue_commands::run_session_queue_command; 83 use std::fs::OpenOptions; 84 use std::io::IsTerminal; 85 use std::path::Path; 86 use std::path::PathBuf; 87 use std::sync::Arc; 88 use std::time::Duration; 89 use std::time::Instant; 90 pub use token_usage::TokenUsage; 91 use tracing::error; 92 use tracing::warn; 93 use tracing_appender::non_blocking; 94 use tracing_subscriber::EnvFilter; 95 use tracing_subscriber::prelude::*; 96 use url::Url; 97 use uuid::Uuid; 98 99 pub(crate) use codex_app_server_client::legacy_core; 100 101 pub(crate) use worktree_startup::ManagedTuiWorktree; 102 103 mod additional_dirs; 104 mod analytics; 105 mod app; 106 mod app_backtrack; 107 mod app_command; 108 mod app_event; 109 mod app_event_sender; 110 mod app_info; 111 mod app_server_approval_conversions; 112 mod app_server_connection; 113 mod app_server_session; 114 mod approval_events; 115 mod async_question_reply; 116 mod backend_banners; 117 mod bottom_pane; 118 mod branch_summary; 119 mod chatwidget; 120 mod cli; 121 mod clipboard_copy; 122 mod clipboard_html; 123 mod clipboard_paste; 124 mod clock_format; 125 mod collaboration_modes; 126 mod color; 127 mod config_update; 128 pub(crate) mod custom_terminal; 129 mod daybreak; 130 mod experimental_features; 131 mod permission_discovery; 132 mod pets; 133 mod worktree_browser; 134 pub use custom_terminal::Terminal; 135 mod assistant_directives; 136 mod auto_review_denials; 137 mod cwd_prompt; 138 mod debug_config; 139 mod diff_model; 140 mod diff_render; 141 mod dynamic_tools; 142 mod dynamic_tools_mcp; 143 mod empty_state_animation; 144 mod exec_cell; 145 mod exec_command; 146 mod external_agent_config_migration; 147 mod external_editor; 148 mod file_search; 149 mod get_git_diff; 150 mod git_action_directives; 151 mod goal_display; 152 mod goal_files; 153 mod history_cell; 154 mod hooks_rpc; 155 mod ide_context; 156 mod inline_visualization; 157 pub(crate) mod insert_history; 158 pub use insert_history::insert_history_lines; 159 mod footer_hint; 160 mod key_hint; 161 mod keymap; 162 mod keymap_setup; 163 mod line_truncation; 164 pub(crate) mod live_wrap; 165 mod local_settings; 166 pub use live_wrap::RowBuilder; 167 mod local_chatgpt_auth; 168 mod managed_new_thread_defaults; 169 mod markdown; 170 mod markdown_render; 171 mod markdown_stream; 172 mod markdown_text_merge; 173 mod mention_codec; 174 mod model_catalog; 175 mod model_migration; 176 mod motion; 177 mod multi_agents; 178 mod named_session_lookup; 179 mod notifications; 180 #[cfg(any(not(debug_assertions), test))] 181 mod npm_registry; 182 pub(crate) mod onboarding; 183 mod oss_selection; 184 mod pager_overlay; 185 pub(crate) mod public_widgets; 186 mod render; 187 mod resize_reflow_cap; 188 mod resume_picker; 189 mod screen_reader; 190 mod service_tier_resolution; 191 mod session_archive_commands; 192 mod session_log; 193 mod session_queue_commands; 194 mod session_resume; 195 mod session_start; 196 mod session_state; 197 mod shortcut_help; 198 mod skills_helpers; 199 mod slash_command; 200 mod startup_draft; 201 mod startup_error; 202 mod startup_hooks_review; 203 mod startup_orchestration; 204 mod startup_preflight; 205 mod startup_presentation; 206 mod startup_recovery; 207 mod status; 208 mod status_indicator_widget; 209 mod streaming; 210 mod style; 211 mod system_motion; 212 mod task_mentions; 213 mod temporary_structured_request; 214 mod terminal_hyperlinks; 215 mod terminal_palette; 216 mod terminal_probe; 217 mod terminal_title; 218 mod terminal_visualization_instructions; 219 mod text_formatting; 220 mod text_selection; 221 mod theme_picker; 222 mod thread_color; 223 mod thread_transcript; 224 mod token_usage; 225 mod tool_output; 226 mod tooltips; 227 mod transcript_mode; 228 mod transcript_reflow; 229 mod transcript_view; 230 mod tui; 231 mod ui_consts; 232 mod unarchive_prompt; 233 pub(crate) mod update_action; 234 mod worktree_startup; 235 pub use update_action::DaemonUpdateSource; 236 pub use update_action::UpdateAction; 237 #[cfg(not(debug_assertions))] 238 pub use update_action::get_update_action; 239 mod update_prompt; 240 mod update_versions; 241 mod updates; 242 #[cfg(any(not(debug_assertions), test))] 243 mod updates_cache; 244 mod version; 245 mod vim_search; 246 mod width; 247 mod windows_sandbox; 248 mod workspace_command; 249 mod workspace_messages; 250 251 mod wrapping; 252 253 mod table_detect; 254 #[cfg(test)] 255 pub(crate) mod test_backend; 256 #[cfg(test)] 257 pub(crate) mod test_support; 258 259 use crate::onboarding::onboarding_screen::OnboardingScreenArgs; 260 use crate::onboarding::onboarding_screen::run_onboarding_app; 261 use crate::startup_hooks_review::StartupHooksReviewOutcome; 262 use crate::startup_hooks_review::load_startup_hooks_review_entry; 263 use crate::startup_hooks_review::maybe_run_startup_hooks_review; 264 use crate::tui::Tui; 265 pub use cli::Cli; 266 use codex_arg0::Arg0DispatchPaths; 267 pub use markdown_render::render_markdown_text; 268 pub use public_widgets::composer_input::ComposerAction; 269 pub use public_widgets::composer_input::ComposerInput; 270 // (tests access modules directly within the crate) 271 272 const TUI_LOG_FILE_NAME: &str = "codex-tui.log"; 273 const INTERACTIVE_OTEL_SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(/*millis*/ 500); 274 275 const AUTO_CONNECT_DAEMON_CONNECT_TIMEOUT: std::time::Duration = 276 std::time::Duration::from_millis(50); 277 278 #[allow(clippy::too_many_arguments)] 279 async fn start_embedded_app_server( 280 arg0_paths: Arg0DispatchPaths, 281 config: Config, 282 cli_kv_overrides: Vec<(String, toml::Value)>, 283 loader_overrides: LoaderOverrides, 284 strict_config: bool, 285 cloud_config_bundle: CloudConfigBundleLoader, 286 feedback: codex_feedback::CodexFeedback, 287 log_db: Option<log_db::LogDbLayer>, 288 state_db: Option<StateDbHandle>, 289 environment_manager: Arc<EnvironmentManager>, 290 ) -> color_eyre::Result<InProcessAppServerClient> { 291 start_embedded_app_server_with( 292 arg0_paths, 293 config, 294 cli_kv_overrides, 295 loader_overrides, 296 strict_config, 297 cloud_config_bundle, 298 feedback, 299 log_db, 300 state_db, 301 environment_manager, 302 InProcessAppServerClient::start, 303 ) 304 .await 305 } 306 307 #[derive(Clone, Debug, PartialEq, Eq)] 308 pub(crate) enum AppServerTarget { 309 Embedded, 310 LocalDaemon { 311 endpoint: RemoteAppServerEndpoint, 312 allow_embedded_fallback: bool, 313 }, 314 Remote { 315 endpoint: RemoteAppServerEndpoint, 316 }, 317 } 318 319 impl AppServerTarget { 320 pub(crate) fn uses_remote_workspace(&self) -> bool { 321 matches!(self, Self::Remote { .. }) 322 } 323 324 fn auth_config_for_cloud_loader(&self, mut auth_config: AuthConfig) -> AuthConfig { 325 if self.uses_remote_workspace() { 326 // Remove local auth restrictions before loading credentials for a remote 327 // workspace; the remote app server enforces its own authentication policy. 328 auth_config.forced_login_method = None; 329 auth_config.forced_chatgpt_workspace_id = None; 330 auth_config.managed_auth_policy = Default::default(); 331 } 332 auth_config 333 } 334 335 fn thread_params_mode(&self) -> ThreadParamsMode { 336 if self.uses_remote_workspace() { 337 ThreadParamsMode::Remote 338 } else { 339 ThreadParamsMode::Embedded 340 } 341 } 342 } 343 344 async fn init_state_db_for_app_server_target( 345 config: &Config, 346 app_server_target: &AppServerTarget, 347 ) -> std::io::Result<Option<StateDbHandle>> { 348 match app_server_target { 349 AppServerTarget::Embedded => state_db::try_init(config).await.map(Some).map_err(|err| { 350 let database_path = codex_state::runtime_db_path_for_corruption_error(&err) 351 .unwrap_or_else(|| config.sqlite_config().state_db_path()); 352 std::io::Error::other(LocalStateDbStartupError::new( 353 database_path, 354 format!("{err:#}"), 355 )) 356 }), 357 AppServerTarget::LocalDaemon { .. } | AppServerTarget::Remote { .. } => { 358 Ok(state_db::get_state_db(config).await) 359 } 360 } 361 } 362 363 // TODO(jif) delete after 22/11/2026. 364 fn remove_legacy_tui_log_file(codex_home: &Path) { 365 // Shared append-only TUI logs could grow without bound. Existing processes 366 // may still hold the file open, so startup cleanup is best effort. 367 let _ = std::fs::remove_file(codex_home.join("log").join(TUI_LOG_FILE_NAME)); 368 } 369 370 fn remote_addr_has_explicit_port(addr: &str, parsed: &Url) -> bool { 371 let Some(host) = parsed.host_str() else { 372 return false; 373 }; 374 if parsed.port().is_some() { 375 return true; 376 } 377 378 let Some((_, rest)) = addr.split_once("://") else { 379 return false; 380 }; 381 let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); 382 let authority = &rest[..authority_end]; 383 let host_and_port = authority 384 .rsplit_once('@') 385 .map_or(authority, |(_, host_and_port)| host_and_port); 386 let explicit_default_port = match parsed.scheme() { 387 "ws" => 80, 388 "wss" => 443, 389 _ => return false, 390 }; 391 let expected_host = if host.contains(':') { 392 format!("[{host}]") 393 } else { 394 host.to_string() 395 }; 396 host_and_port == format!("{expected_host}:{explicit_default_port}") 397 } 398 399 fn websocket_url_supports_auth_token(parsed: &Url) -> bool { 400 match (parsed.scheme(), parsed.host()) { 401 ("wss", Some(_)) => true, 402 ("ws", Some(url::Host::Domain(domain))) => domain.eq_ignore_ascii_case("localhost"), 403 ("ws", Some(url::Host::Ipv4(addr))) => addr.is_loopback(), 404 ("ws", Some(url::Host::Ipv6(addr))) => addr.is_loopback(), 405 _ => false, 406 } 407 } 408 409 pub fn resolve_remote_addr(addr: &str) -> color_eyre::Result<RemoteAppServerEndpoint> { 410 if let Some(socket_path) = addr.strip_prefix("unix://") { 411 let socket_path = if socket_path.is_empty() { 412 let codex_home = find_codex_home().wrap_err("failed to resolve CODEX_HOME")?; 413 codex_app_server_client::app_server_control_socket_path(&codex_home) 414 .map_err(color_eyre::Report::new)? 415 } else { 416 AbsolutePathBuf::relative_to_current_dir(socket_path) 417 .map_err(color_eyre::Report::new)? 418 }; 419 return Ok(RemoteAppServerEndpoint::UnixSocket { socket_path }); 420 } 421 422 let parsed = match Url::parse(addr) { 423 Ok(parsed) => parsed, 424 Err(_) => { 425 color_eyre::eyre::bail!( 426 "invalid remote address `{addr}`; expected `ws://host:port`, `wss://host:port`, `unix://`, or `unix://PATH`" 427 ); 428 } 429 }; 430 if matches!(parsed.scheme(), "ws" | "wss") 431 && parsed.host_str().is_some() 432 && remote_addr_has_explicit_port(addr, &parsed) 433 && parsed.path() == "/" 434 && parsed.query().is_none() 435 && parsed.fragment().is_none() 436 { 437 return Ok(RemoteAppServerEndpoint::WebSocket { 438 websocket_url: parsed.to_string(), 439 auth_token: None, 440 }); 441 } 442 443 color_eyre::eyre::bail!( 444 "invalid remote address `{addr}`; expected `ws://host:port`, `wss://host:port`, `unix://`, or `unix://PATH`" 445 ); 446 } 447 448 pub fn remote_addr_supports_auth_token(endpoint: &RemoteAppServerEndpoint) -> bool { 449 match endpoint { 450 RemoteAppServerEndpoint::WebSocket { websocket_url, .. } => { 451 Url::parse(websocket_url).is_ok_and(|parsed| websocket_url_supports_auth_token(&parsed)) 452 } 453 RemoteAppServerEndpoint::UnixSocket { .. } => false, 454 } 455 } 456 457 async fn connect_remote_app_server( 458 endpoint: RemoteAppServerEndpoint, 459 ) -> color_eyre::Result<AppServerClient> { 460 let app_server = RemoteAppServerClient::connect(RemoteAppServerConnectArgs { 461 endpoint, 462 client_name: "codex-tui".to_string(), 463 client_version: env!("CARGO_PKG_VERSION").to_string(), 464 experimental_api: true, 465 mcp_server_openai_form_elicitation: false, 466 opt_out_notification_methods: Vec::new(), 467 channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, 468 }) 469 .await 470 .wrap_err("failed to connect to remote app server")?; 471 Ok(AppServerClient::Remote(app_server)) 472 } 473 474 async fn maybe_probe_default_daemon_socket(codex_home: &Path) -> Option<AbsolutePathBuf> { 475 let socket_path = codex_app_server_client::app_server_control_socket_path(codex_home).ok()?; 476 #[cfg(windows)] 477 let (validated_path, _directory) = 478 codex_uds::validate_private_socket_path(socket_path.as_path()).ok()?; 479 #[cfg(windows)] 480 let validated_path = AbsolutePathBuf::from_absolute_path_checked(validated_path).ok()?; 481 #[cfg(windows)] 482 let probe_path = validated_path.as_path(); 483 #[cfg(not(windows))] 484 let probe_path = socket_path.as_path(); 485 match tokio::time::timeout( 486 AUTO_CONNECT_DAEMON_CONNECT_TIMEOUT, 487 codex_uds::UnixStream::connect(probe_path), 488 ) 489 .await 490 { 491 Ok(Ok(_stream)) => { 492 #[cfg(windows)] 493 _stream.ensure_non_elevated_peer().ok()?; 494 Some(socket_path) 495 } 496 Ok(Err(err)) => { 497 tracing::debug!(%err, socket_path = %socket_path.display(), "skipping default app-server daemon socket"); 498 None 499 } 500 Err(_) => { 501 tracing::debug!( 502 socket_path = %socket_path.display(), 503 timeout_ms = AUTO_CONNECT_DAEMON_CONNECT_TIMEOUT.as_millis(), 504 "timed out probing default app-server daemon socket" 505 ); 506 None 507 } 508 } 509 } 510 511 #[allow(clippy::too_many_arguments)] 512 async fn start_app_server( 513 target: &mut AppServerTarget, 514 arg0_paths: Arg0DispatchPaths, 515 config: Config, 516 cli_kv_overrides: Vec<(String, toml::Value)>, 517 loader_overrides: LoaderOverrides, 518 strict_config: bool, 519 cloud_config_bundle: CloudConfigBundleLoader, 520 feedback: codex_feedback::CodexFeedback, 521 log_db: Option<log_db::LogDbLayer>, 522 state_db: &mut Option<StateDbHandle>, 523 environment_manager: Arc<EnvironmentManager>, 524 ) -> color_eyre::Result<AppServerClient> { 525 let connection = if matches!(target, AppServerTarget::Embedded) { 526 None 527 } else { 528 Some(app_server_connection::connect(target).await) 529 }; 530 if let Some(connection) = connection { 531 match connection { 532 Ok(app_server) => return Ok(app_server), 533 Err(err) 534 if matches!( 535 target, 536 AppServerTarget::LocalDaemon { 537 allow_embedded_fallback: true, 538 .. 539 } 540 ) => 541 { 542 tracing::debug!(%err, "local daemon connection failed; starting embedded app server"); 543 *target = AppServerTarget::Embedded; 544 *state_db = init_state_db_for_app_server_target(&config, target).await?; 545 } 546 Err(err) if matches!(target, AppServerTarget::LocalDaemon { .. }) => { 547 return Err(color_eyre::eyre::eyre!( 548 "{err:#}\n{}", 549 daemon_startup::FAILURE_HINT 550 )); 551 } 552 Err(err) => return Err(err), 553 } 554 } 555 start_embedded_app_server( 556 arg0_paths, 557 config, 558 cli_kv_overrides, 559 loader_overrides, 560 strict_config, 561 cloud_config_bundle, 562 feedback, 563 log_db, 564 state_db.clone(), 565 environment_manager, 566 ) 567 .await 568 .map(AppServerClient::InProcess) 569 } 570 571 pub(crate) async fn start_app_server_for_picker( 572 config: &Config, 573 target: &AppServerTarget, 574 state_db: Option<StateDbHandle>, 575 environment_manager: Arc<EnvironmentManager>, 576 ) -> color_eyre::Result<AppServerSession> { 577 let mut target = target.clone(); 578 let mut state_db = state_db; 579 let app_server = start_app_server( 580 &mut target, 581 Arg0DispatchPaths::default(), 582 config.clone(), 583 Vec::new(), 584 LoaderOverrides::default(), 585 /*strict_config*/ false, 586 CloudConfigBundleLoader::default(), 587 codex_feedback::CodexFeedback::new(), 588 /*log_db*/ None, 589 &mut state_db, 590 environment_manager, 591 ) 592 .await?; 593 Ok( 594 AppServerSession::new(app_server, target.thread_params_mode()) 595 .with_local_codex_home(&config.codex_home), 596 ) 597 } 598 599 #[cfg(test)] 600 pub(crate) async fn start_embedded_app_server_for_picker( 601 config: &Config, 602 ) -> color_eyre::Result<AppServerSession> { 603 let mut target = AppServerTarget::Embedded; 604 let mut state_db = init_state_db_for_app_server_target(config, &target).await?; 605 let app_server = start_app_server( 606 &mut target, 607 Arg0DispatchPaths::default(), 608 config.clone(), 609 Vec::new(), 610 LoaderOverrides::without_managed_config_for_tests(), 611 /*strict_config*/ false, 612 CloudConfigBundleLoader::default(), 613 codex_feedback::CodexFeedback::new(), 614 /*log_db*/ None, 615 &mut state_db, 616 Arc::new(EnvironmentManager::default_for_tests()), 617 ) 618 .await?; 619 Ok( 620 AppServerSession::new(app_server, target.thread_params_mode()) 621 .with_local_codex_home(&config.codex_home), 622 ) 623 } 624 625 #[allow(clippy::too_many_arguments)] 626 async fn start_embedded_app_server_with<F, Fut>( 627 arg0_paths: Arg0DispatchPaths, 628 config: Config, 629 cli_kv_overrides: Vec<(String, toml::Value)>, 630 loader_overrides: LoaderOverrides, 631 strict_config: bool, 632 cloud_config_bundle: CloudConfigBundleLoader, 633 feedback: codex_feedback::CodexFeedback, 634 log_db: Option<log_db::LogDbLayer>, 635 state_db: Option<StateDbHandle>, 636 environment_manager: Arc<EnvironmentManager>, 637 start_client: F, 638 ) -> color_eyre::Result<InProcessAppServerClient> 639 where 640 F: FnOnce(InProcessClientStartArgs) -> Fut, 641 Fut: Future<Output = std::io::Result<InProcessAppServerClient>>, 642 { 643 let config_warnings = config 644 .startup_warnings 645 .iter() 646 .map(|warning| ConfigWarningNotification { 647 summary: warning.clone(), 648 details: None, 649 path: None, 650 range: None, 651 }) 652 .collect(); 653 let client = start_client(InProcessClientStartArgs { 654 arg0_paths, 655 config: Arc::new(config), 656 cli_overrides: cli_kv_overrides, 657 loader_overrides, 658 strict_config, 659 cloud_config_bundle, 660 feedback, 661 log_db, 662 state_db, 663 environment_manager, 664 config_warnings, 665 session_source: serde_json::from_value(serde_json::json!("cli")) 666 .unwrap_or_else(|err| panic!("cli session source should deserialize: {err}")), 667 enable_codex_api_key_env: false, 668 client_name: "codex-tui".to_string(), 669 client_version: env!("CARGO_PKG_VERSION").to_string(), 670 experimental_api: true, 671 mcp_server_openai_form_elicitation: false, 672 opt_out_notification_methods: Vec::new(), 673 channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, 674 }) 675 .await 676 .wrap_err("failed to start embedded app server")?; 677 Ok(client) 678 } 679 680 async fn shutdown_app_server_if_present(app_server: Option<AppServerSession>) { 681 if let Some(app_server) = app_server 682 && let Err(err) = app_server.shutdown().await 683 { 684 warn!(%err, "Failed to shut down temporary embedded app server"); 685 } 686 } 687 688 /// Shut down the startup app server before restoring its terminal and ending its session. 689 async fn shutdown_startup_session( 690 app_server: Option<AppServerSession>, 691 terminal_restore_guard: &mut TerminalRestoreGuard, 692 ) { 693 shutdown_app_server_if_present(app_server).await; 694 terminal_restore_guard.restore_silently(); 695 session_log::log_session_end(); 696 } 697 698 fn session_target_from_app_server_thread( 699 thread: AppServerThread, 700 ) -> Option<resume_picker::SessionTarget> { 701 match ThreadId::from_string(&thread.id) { 702 Ok(thread_id) => Some(resume_picker::SessionTarget { 703 path: thread.path, 704 thread_id, 705 cwd: Some(thread.cwd.to_path_buf()), 706 history_mode: Some(thread.history_mode), 707 }), 708 Err(err) => { 709 warn!( 710 thread_id = thread.id, 711 %err, 712 "Ignoring app-server thread with invalid thread id during TUI session lookup" 713 ); 714 None 715 } 716 } 717 } 718 719 pub(crate) fn resume_source_kinds(include_non_interactive: bool) -> Vec<ThreadSourceKind> { 720 let mut source_kinds = vec![ThreadSourceKind::Cli, ThreadSourceKind::VsCode]; 721 if include_non_interactive { 722 // `thread/list` treats omitted and empty `sourceKinds` as interactive-only, 723 // so include-non-interactive has to name the user-resumable non-interactive 724 // sources explicitly until the API grows an unfiltered request. 725 source_kinds.extend([ThreadSourceKind::Exec, ThreadSourceKind::AppServer]); 726 } 727 source_kinds 728 } 729 730 async fn lookup_session_target_with_app_server( 731 app_server: &mut AppServerSession, 732 config: &Config, 733 id_or_name: &str, 734 ) -> color_eyre::Result<Option<resume_picker::SessionTarget>> { 735 if Uuid::parse_str(id_or_name).is_ok() { 736 let thread_id = match ThreadId::from_string(id_or_name) { 737 Ok(thread_id) => thread_id, 738 Err(err) => { 739 warn!( 740 session = id_or_name, 741 %err, 742 "Failed to parse session id during TUI lookup" 743 ); 744 return Ok(None); 745 } 746 }; 747 return match app_server 748 .thread_read(thread_id, /*include_turns*/ false) 749 .await 750 { 751 Ok(thread) => Ok(session_target_from_app_server_thread(thread)), 752 Err(err) => { 753 warn!( 754 session = id_or_name, 755 %err, 756 "thread/read failed during TUI session lookup" 757 ); 758 Ok(None) 759 } 760 }; 761 } 762 763 let model_provider = 764 (!app_server.uses_remote_workspace()).then_some(config.model_provider_id.as_str()); 765 Ok(named_session_lookup::lookup( 766 app_server, 767 config.codex_home.as_path(), 768 id_or_name, 769 &[named_session_lookup::SessionCollection::Active], 770 &[resume_source_kinds(/*include_non_interactive*/ false)], 771 model_provider, 772 ) 773 .await? 774 .and_then(session_target_from_app_server_thread)) 775 } 776 777 async fn lookup_latest_session_target_with_app_server( 778 uses_remote_filesystem: bool, 779 app_server: &mut AppServerSession, 780 config: &Config, 781 cwd_filter: Option<&Path>, 782 include_non_interactive: bool, 783 ) -> color_eyre::Result<Option<resume_picker::SessionTarget>> { 784 let uses_remote_workspace = app_server.uses_remote_workspace(); 785 for lookup_mode in [ 786 LatestSessionLookupMode::StateDbOnly, 787 LatestSessionLookupMode::ScanAndRepair, 788 ] { 789 let response = app_server 790 .thread_list(latest_session_lookup_params( 791 uses_remote_filesystem, 792 uses_remote_workspace, 793 config, 794 cwd_filter, 795 include_non_interactive, 796 lookup_mode, 797 )) 798 .await?; 799 let target = response 800 .data 801 .into_iter() 802 .find_map(session_target_from_app_server_thread); 803 if target.as_ref().is_some_and(|target| { 804 uses_remote_workspace || target.path.as_deref().is_some_and(std::path::Path::exists) 805 }) { 806 return Ok(target); 807 } 808 } 809 Ok(None) 810 } 811 812 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 813 enum LatestSessionLookupMode { 814 StateDbOnly, 815 ScanAndRepair, 816 } 817 818 fn latest_session_lookup_params( 819 uses_remote_filesystem: bool, 820 uses_remote_workspace: bool, 821 config: &Config, 822 cwd_filter: Option<&Path>, 823 include_non_interactive: bool, 824 lookup_mode: LatestSessionLookupMode, 825 ) -> ThreadListParams { 826 ThreadListParams { 827 originators: None, 828 cursor: None, 829 limit: Some(1), 830 sort_key: Some(AppServerThreadSortKey::UpdatedAt), 831 sort_direction: None, 832 model_providers: if uses_remote_workspace { 833 None 834 } else { 835 Some(vec![config.model_provider_id.clone()]) 836 }, 837 source_kinds: Some(resume_source_kinds(include_non_interactive)), 838 archived: Some(false), 839 section_id: None, 840 project_id: None, 841 parent_thread_id: None, 842 ancestor_thread_id: None, 843 cwd: cwd_filter.map(|cwd| { 844 resume_picker::repository_cwd_filter( 845 cwd, 846 uses_remote_filesystem, 847 config.features.enabled(codex_features::Feature::Worktrees), 848 ) 849 }), 850 use_state_db_only: match lookup_mode { 851 LatestSessionLookupMode::StateDbOnly => true, 852 LatestSessionLookupMode::ScanAndRepair => false, 853 }, 854 search_term: None, 855 } 856 } 857 858 fn config_cwd_for_app_server_target( 859 cwd: Option<&Path>, 860 app_server_target: &AppServerTarget, 861 default_environment_is_remote: bool, 862 ) -> std::io::Result<Option<AbsolutePathBuf>> { 863 if app_server_target.uses_remote_workspace() || default_environment_is_remote { 864 return Ok(None); 865 } 866 867 let cwd = match cwd { 868 Some(path) => { 869 AbsolutePathBuf::from_absolute_path(canonicalize_existing_preserving_symlinks(path)?) 870 } 871 None => AbsolutePathBuf::current_dir(), 872 }?; 873 Ok(Some(cwd)) 874 } 875 876 fn uses_remote_workspace_or_environment( 877 app_server_target: &AppServerTarget, 878 environment_manager: &EnvironmentManager, 879 ) -> bool { 880 app_server_target.uses_remote_workspace() 881 || environment_manager 882 .default_environment() 883 .is_some_and(|environment| environment.is_remote()) 884 } 885 886 async fn resolve_startup_resume_or_fork_cwd( 887 tui: &mut Tui, 888 config: &Config, 889 app_server: Option<&mut AppServerSession>, 890 session_selection: &resume_picker::SessionSelection, 891 cwd_override: Option<&Path>, 892 uses_remote_workspace: bool, 893 uses_remote_workspace_or_environment: bool, 894 ) -> color_eyre::Result<ResolveCwdOutcome> { 895 let Some((action, target_session)) = (match session_selection { 896 resume_picker::SessionSelection::Resume(target_session) => { 897 Some((CwdPromptAction::Resume, target_session)) 898 } 899 resume_picker::SessionSelection::Fork(target_session) => { 900 Some((CwdPromptAction::Fork, target_session)) 901 } 902 _ => None, 903 }) else { 904 return Ok(ResolveCwdOutcome::Continue(None)); 905 }; 906 let local_settings = crate::local_settings::LocalSettings::from(config); 907 let resume_cwd_mode = effective_resume_cwd_mode(local_settings.tui.resume_cwd, cwd_override); 908 if uses_remote_workspace_or_environment 909 && cwd_override.is_none() 910 && matches!(resume_cwd_mode, Some(ResumeCwdMode::Current)) 911 { 912 color_eyre::eyre::bail!( 913 "`tui.resume_cwd = \"current\"` requires `--cd` when using a remote workspace" 914 ); 915 } 916 if uses_remote_workspace { 917 return Ok(ResolveCwdOutcome::Continue(Some(config.cwd.to_path_buf()))); 918 } 919 920 let history_cwd = if matches!(resume_cwd_mode, Some(ResumeCwdMode::Current)) { 921 None 922 } else if let Some(cwd) = &target_session.cwd { 923 Some(cwd.clone()) 924 } else if let Some(app_server) = app_server { 925 read_session_cwd(app_server, target_session.thread_id).await 926 } else { 927 None 928 }; 929 resolve_cwd_for_resume_or_fork( 930 tui, 931 config, 932 history_cwd, 933 action, 934 ResumeCwdContext { 935 current_cwd: config.cwd.as_path(), 936 remembered_current_cwd: config.cwd.as_path(), 937 allow_remember_current: !uses_remote_workspace_or_environment || cwd_override.is_some(), 938 mode: resume_cwd_mode, 939 }, 940 ) 941 .await 942 } 943 944 fn should_load_configured_environments( 945 loader_overrides: &LoaderOverrides, 946 app_server_target: &AppServerTarget, 947 ) -> bool { 948 !loader_overrides.ignore_user_config && !app_server_target.uses_remote_workspace() 949 } 950 951 fn latest_session_cwd_filter<'a>( 952 uses_remote_workspace: bool, 953 remote_cwd_override: Option<&'a Path>, 954 config: &'a Config, 955 show_all: bool, 956 ) -> Option<&'a Path> { 957 if show_all { 958 return None; 959 } 960 961 if uses_remote_workspace { 962 remote_cwd_override 963 } else { 964 Some(config.cwd.as_path()) 965 } 966 } 967 968 fn app_server_target_for_launch( 969 explicit_remote_endpoint: Option<RemoteAppServerEndpoint>, 970 default_daemon_socket: Option<AbsolutePathBuf>, 971 can_reuse_implicit_local_daemon: bool, 972 workload_identity_selected: bool, 973 exec_server_url: Option<&std::ffi::OsStr>, 974 ) -> std::io::Result<AppServerTarget> { 975 if workload_identity_selected { 976 if explicit_remote_endpoint.is_some() { 977 return Err(std::io::Error::new( 978 std::io::ErrorKind::InvalidInput, 979 "workload identity must be configured on the remote app-server host", 980 )); 981 } 982 return Ok(AppServerTarget::Embedded); 983 } 984 Ok(match explicit_remote_endpoint { 985 Some(endpoint) => AppServerTarget::Remote { endpoint }, 986 // A shared daemon cannot adopt this invocation's executor selection. 987 None if can_reuse_implicit_local_daemon && exec_server_url.is_none() => { 988 default_daemon_socket.map_or(AppServerTarget::Embedded, |socket_path| { 989 AppServerTarget::LocalDaemon { 990 allow_embedded_fallback: true, 991 endpoint: RemoteAppServerEndpoint::UnixSocket { socket_path }, 992 } 993 }) 994 } 995 None => AppServerTarget::Embedded, 996 }) 997 } 998 999 async fn cloud_config_bundle_for_app_server_target( 1000 app_server_target: &AppServerTarget, 1001 bootstrap_config: &ConfigTomlLoadResult, 1002 codex_home: &Path, 1003 ) -> std::io::Result<CloudConfigBundleLoader> { 1004 cloud_config_bundle_loader_for_storage( 1005 app_server_target 1006 .auth_config_for_cloud_loader(bootstrap_auth_config(codex_home, bootstrap_config)?), 1007 /*enable_codex_api_key_env*/ false, 1008 ) 1009 .await 1010 } 1011 1012 fn loader_overrides_are_default(loader_overrides: &LoaderOverrides) -> bool { 1013 let loader_overrides_are_default = loader_overrides.user_config_path.is_none() 1014 && loader_overrides.user_config_profile.is_none() 1015 && loader_overrides.managed_config_path.is_none() 1016 && loader_overrides.system_config_path.is_none() 1017 && loader_overrides.system_requirements_path.is_none() 1018 && !loader_overrides.ignore_managed_requirements 1019 && !loader_overrides.ignore_user_config 1020 && !loader_overrides.ignore_user_and_project_exec_policy_rules 1021 && loader_overrides 1022 .macos_managed_config_requirements_base64 1023 .is_none(); 1024 #[cfg(target_os = "macos")] 1025 let loader_overrides_are_default = 1026 loader_overrides_are_default && loader_overrides.managed_preferences_base64.is_none(); 1027 loader_overrides_are_default 1028 } 1029 1030 /// Restore terminal modes before a fatal startup exit bypasses destructor cleanup. 1031 fn restore_terminal_before_fatal_exit() { 1032 if crossterm::terminal::is_raw_mode_enabled().unwrap_or(false) { 1033 let _ = tui::restore_after_exit(); 1034 } 1035 startup_recovery::print_unsent_draft(); 1036 } 1037 1038 pub async fn run_main( 1039 cli: Cli, 1040 arg0_paths: Arg0DispatchPaths, 1041 loader_overrides: LoaderOverrides, 1042 explicit_remote_endpoint: Option<RemoteAppServerEndpoint>, 1043 ) -> std::io::Result<AppExitInfo> { 1044 system_motion::initialize().await; 1045 startup_recovery::scope(async move { 1046 // Startup retains a large future for the whole session. Keep it off callers' stacks, 1047 // which also need room to construct a replacement chat widget on `/new`. 1048 match Box::pin(startup_orchestration::run_main_inner( 1049 cli, 1050 arg0_paths, 1051 loader_overrides, 1052 explicit_remote_endpoint, 1053 )) 1054 .await 1055 { 1056 Err(err) if startup_draft::StartupCancelled::matches(&err) => Ok(AppExitInfo { 1057 token_usage: TokenUsage::default(), 1058 thread_id: None, 1059 resume_hint: None, 1060 disconnect_info: None, 1061 update_action: None, 1062 exit_reason: ExitReason::UserRequested, 1063 }), 1064 Err(err) => { 1065 restore_terminal_before_fatal_exit(); 1066 Err(err) 1067 } 1068 Ok(info) if matches!(&info.exit_reason, ExitReason::Fatal(_)) => { 1069 restore_terminal_before_fatal_exit(); 1070 Ok(info) 1071 } 1072 result => result, 1073 } 1074 }) 1075 .await 1076 } 1077 1078 #[allow(clippy::too_many_arguments)] 1079 async fn run_ratatui_app( 1080 mut cli: Cli, 1081 arg0_paths: Arg0DispatchPaths, 1082 loader_overrides: LoaderOverrides, 1083 strict_config: bool, 1084 mut app_server_target: AppServerTarget, 1085 remote_cwd_override: Option<PathBuf>, 1086 initial_config: Config, 1087 manually_selected_oss_provider: Option<String>, 1088 overrides: ConfigOverrides, 1089 cli_kv_overrides: Vec<(String, toml::Value)>, 1090 mut cloud_config_bundle: CloudConfigBundleLoader, 1091 feedback: codex_feedback::CodexFeedback, 1092 log_db: Option<log_db::LogDbLayer>, 1093 mut state_db: Option<StateDbHandle>, 1094 environment_manager: Arc<EnvironmentManager>, 1095 managed_worktree: Option<ManagedTuiWorktree>, 1096 daemon_startup_warning: Option<String>, 1097 launch_telemetry: daemon_telemetry::Launch<impl FnOnce(&AppServerTarget, bool)>, 1098 startup_draft: startup_draft::StartupDraft, 1099 ) -> color_eyre::Result<AppExitInfo> { 1100 let uses_remote_workspace = app_server_target.uses_remote_workspace(); 1101 let workload_identity_selected = is_workload_identity_selected(); 1102 color_eyre::install()?; 1103 1104 tooltips::announcement::prewarm(initial_config.http_client_factory()); 1105 1106 // Forward panic reports through tracing so they appear in the UI status 1107 // line, but do not swallow the default/color-eyre panic handler. 1108 // Chain to the previous hook so users still get a rich panic report 1109 // (including backtraces) after we restore the terminal. 1110 let prev_hook = std::panic::take_hook(); 1111 std::panic::set_hook(Box::new(move |info| { 1112 let _ = tui::restore_after_exit(); 1113 tracing::error!("panic: {info}"); 1114 prev_hook(info); 1115 })); 1116 let (mut tui, mut terminal_restore_guard, mut startup_draft) = startup_draft.into_parts(); 1117 1118 #[cfg(not(debug_assertions))] 1119 { 1120 use crate::update_prompt::UpdatePromptOutcome; 1121 1122 let skip_update_prompt = cli.prompt.as_ref().is_some_and(|prompt| !prompt.is_empty()); 1123 if !skip_update_prompt { 1124 startup_draft.flush_pending_events(&mut tui).await?; 1125 match update_prompt::run_update_prompt_if_needed(&mut tui, &initial_config).await? { 1126 UpdatePromptOutcome::Continue => {} 1127 UpdatePromptOutcome::RunUpdate(action) => { 1128 terminal_restore_guard.restore()?; 1129 return Ok(AppExitInfo { 1130 token_usage: crate::token_usage::TokenUsage::default(), 1131 thread_id: None, 1132 resume_hint: None, 1133 disconnect_info: None, 1134 update_action: Some(action), 1135 exit_reason: ExitReason::UserRequested, 1136 }); 1137 } 1138 } 1139 } 1140 } 1141 1142 // Initialize high-fidelity session event logging if enabled. 1143 session_log::maybe_init(&initial_config); 1144 1145 let startup_app_server = startup_draft 1146 .run_until( 1147 &mut tui, 1148 start_app_server( 1149 &mut app_server_target, 1150 arg0_paths.clone(), 1151 initial_config.clone(), 1152 cli_kv_overrides.clone(), 1153 loader_overrides.clone(), 1154 strict_config, 1155 cloud_config_bundle.clone(), 1156 feedback.clone(), 1157 log_db.clone(), 1158 &mut state_db, 1159 environment_manager.clone(), 1160 ), 1161 ) 1162 .await; 1163 launch_telemetry.record(&app_server_target, matches!(&startup_app_server, Ok(Ok(_)))); 1164 let app_server_session = match startup_app_server { 1165 Ok(Ok(app_server)) => { 1166 AppServerSession::new(app_server, app_server_target.thread_params_mode()) 1167 .with_local_codex_home(&initial_config.codex_home) 1168 } 1169 Ok(Err(err)) => { 1170 terminal_restore_guard.restore_silently(); 1171 session_log::log_session_end(); 1172 return Err(err); 1173 } 1174 Err(err) => { 1175 terminal_restore_guard.restore_silently(); 1176 session_log::log_session_end(); 1177 return Err(err.into()); 1178 } 1179 } 1180 .with_remote_cwd_override(remote_cwd_override.clone()); 1181 if let Some(provider) = manually_selected_oss_provider.as_deref() { 1182 match startup_draft 1183 .run_until( 1184 &mut tui, 1185 config_update::write_config_batch( 1186 app_server_session.request_handle(), 1187 vec![config_update::build_oss_provider_edit(provider)], 1188 ), 1189 ) 1190 .await 1191 { 1192 Ok(Ok(_)) => {} 1193 Ok(Err(err)) => { 1194 warn!( 1195 %err, 1196 provider, 1197 "Failed to persist selected OSS provider preference" 1198 ); 1199 } 1200 Err(err) => { 1201 shutdown_startup_session(Some(app_server_session), &mut terminal_restore_guard) 1202 .await; 1203 return Err(err.into()); 1204 } 1205 } 1206 } 1207 let mut app_server = Some(app_server_session); 1208 // Folder consent runs after the picker resolves the actual destination. 1209 let should_show_trust_screen_flag = false; 1210 #[cfg(target_os = "windows")] 1211 let mut trust_decision_was_made = false; 1212 let startup_model_provider = initial_config.model_provider_id.clone(); 1213 let (login_status, mut startup_account) = if workload_identity_selected { 1214 (LoginStatus::AuthMode(AuthMode::Chatgpt), None) 1215 } else { 1216 let Some(active_app_server) = app_server.as_mut() else { 1217 unreachable!("app server should exist when auth is required"); 1218 }; 1219 let login_status = startup_draft 1220 .run_until(&mut tui, get_login_status(active_app_server)) 1221 .await; 1222 match login_status { 1223 Ok(Ok((login_status, account))) => (login_status, Some(account)), 1224 Ok(Err(err)) => { 1225 shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await; 1226 return Err(err); 1227 } 1228 Err(err) => { 1229 shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await; 1230 return Err(err.into()); 1231 } 1232 } 1233 }; 1234 // Workload identity bypasses interactive login; every other provider uses account/read. 1235 let requires_openai_auth = startup_account 1236 .as_ref() 1237 .is_some_and(|account| account.requires_openai_auth); 1238 let should_show_onboarding = should_show_onboarding( 1239 login_status, 1240 requires_openai_auth, 1241 should_show_trust_screen_flag, 1242 ); 1243 1244 let config = if should_show_onboarding { 1245 if let Err(err) = startup_draft.flush_pending_events(&mut tui).await { 1246 shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await; 1247 return Err(err.into()); 1248 } 1249 // Authentication can change while any interactive onboarding screen is open. 1250 startup_account = None; 1251 let show_login_screen = should_show_login_screen(login_status, requires_openai_auth); 1252 let bedrock_setup_enabled = should_show_bedrock_setup_wizard( 1253 login_status, 1254 requires_openai_auth, 1255 &initial_config, 1256 &app_server_target, 1257 ); 1258 let onboarding_result = run_onboarding_app( 1259 OnboardingScreenArgs { 1260 show_login_screen, 1261 bedrock_setup_enabled, 1262 show_trust_screen: should_show_trust_screen_flag, 1263 remote_project_trust: None, 1264 login_status, 1265 app_server_request_handle: app_server 1266 .as_ref() 1267 .map(AppServerSession::request_handle), 1268 config: initial_config.clone(), 1269 }, 1270 if show_login_screen { 1271 app_server.as_mut() 1272 } else { 1273 None 1274 }, 1275 &mut tui, 1276 ) 1277 .await; 1278 let onboarding_result = match onboarding_result { 1279 Ok(onboarding_result) => onboarding_result, 1280 Err(err) => { 1281 shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await; 1282 return Err(err); 1283 } 1284 }; 1285 if onboarding_result.should_exit { 1286 shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await; 1287 let _ = tui.terminal.clear(); 1288 return Ok(AppExitInfo { 1289 token_usage: crate::token_usage::TokenUsage::default(), 1290 thread_id: None, 1291 resume_hint: None, 1292 disconnect_info: None, 1293 update_action: None, 1294 exit_reason: ExitReason::UserRequested, 1295 }); 1296 } 1297 #[cfg(target_os = "windows")] 1298 { 1299 trust_decision_was_made = 1300 !uses_remote_workspace && onboarding_result.directory_trust_persisted; 1301 } 1302 let reloaded_config = startup_draft 1303 .run_until(&mut tui, async { 1304 // If this onboarding run included the login step, always refresh the cloud config 1305 // bundle and rebuild config. This avoids missing newly available cloud-managed 1306 // policy due to login status detection edge cases. 1307 if show_login_screen && !uses_remote_workspace && !workload_identity_selected { 1308 cloud_config_bundle = cloud_config_bundle_loader_for_storage( 1309 initial_config.auth_config(), 1310 /*enable_codex_api_key_env*/ false, 1311 ) 1312 .await?; 1313 } 1314 1315 // Reload config when persisted trust or auth changes alter the current process. 1316 Ok::<_, std::io::Error>( 1317 if !uses_remote_workspace 1318 && (onboarding_result.directory_trust_persisted || show_login_screen) 1319 { 1320 load_config_or_exit_with_fallback_cwd( 1321 cli_kv_overrides.clone(), 1322 overrides.clone(), 1323 loader_overrides.clone(), 1324 cloud_config_bundle.clone(), 1325 strict_config, 1326 /*fallback_cwd*/ None, 1327 managed_worktree.as_ref(), 1328 ) 1329 .await 1330 } else { 1331 initial_config 1332 }, 1333 ) 1334 }) 1335 .await; 1336 match reloaded_config { 1337 Ok(Ok(config)) => config, 1338 Ok(Err(err)) | Err(err) => { 1339 shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await; 1340 return Err(err.into()); 1341 } 1342 } 1343 } else { 1344 initial_config 1345 }; 1346 startup_draft.apply_config(&config); 1347 if !(cli.resume_picker || cli.fork_picker || cli.agents_overview) 1348 && let Err(err) = startup_draft.show(&mut tui) 1349 { 1350 shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await; 1351 return Err(err.into()); 1352 } 1353 1354 let missing_session_exit = 1355 |id_str: &str, 1356 action: &str, 1357 tui: &mut Tui, 1358 terminal_restore_guard: &mut TerminalRestoreGuard| { 1359 error!("Error finding conversation path: {id_str}"); 1360 terminal_restore_guard.restore_silently(); 1361 session_log::log_session_end(); 1362 let _ = tui.terminal.clear(); 1363 Ok(AppExitInfo { 1364 token_usage: crate::token_usage::TokenUsage::default(), 1365 thread_id: None, 1366 resume_hint: None, 1367 disconnect_info: None, 1368 update_action: None, 1369 exit_reason: ExitReason::Fatal(format!( 1370 "No saved session found with ID {id_str}. Run `codex {action}` without an ID to choose from existing sessions." 1371 )), 1372 }) 1373 }; 1374 1375 crate::markdown_render::preferences::init(config.tui_rendering); 1376 // Startup pickers need the current theme before selection can reload config. 1377 // Leave the one-time override initialization below to use the final config. 1378 if (cli.resume_picker || cli.fork_picker) 1379 && let Some(name) = config.tui_theme.as_deref() 1380 && let Some(theme) = 1381 crate::render::highlight::resolve_theme_by_name(name, Some(config.codex_home.as_path())) 1382 { 1383 crate::render::highlight::set_syntax_theme(theme); 1384 } 1385 1386 let use_fork = cli.fork_picker || cli.fork_last || cli.fork_session_id.is_some(); 1387 let mut session_selection = if cli.agents_overview { 1388 resume_picker::SessionSelection::AgentsOverview 1389 } else if use_fork { 1390 if let Some(id_str) = cli.fork_session_id.as_deref() { 1391 let Some(startup_app_server) = app_server.as_mut() else { 1392 unreachable!("app server should be initialized for --fork <id>"); 1393 }; 1394 let lookup = startup_draft 1395 .run_until( 1396 &mut tui, 1397 lookup_session_target_with_app_server(startup_app_server, &config, id_str), 1398 ) 1399 .await; 1400 let target_session = match lookup { 1401 Ok(result) => result?, 1402 Err(err) => { 1403 shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await; 1404 return Err(err.into()); 1405 } 1406 }; 1407 match target_session { 1408 Some(target_session) => resume_picker::SessionSelection::Fork(target_session), 1409 None => { 1410 shutdown_app_server_if_present(app_server.take()).await; 1411 return missing_session_exit( 1412 id_str, 1413 "fork", 1414 &mut tui, 1415 &mut terminal_restore_guard, 1416 ); 1417 } 1418 } 1419 } else if cli.fork_last { 1420 let filter_cwd = latest_session_cwd_filter( 1421 uses_remote_workspace, 1422 remote_cwd_override.as_deref(), 1423 &config, 1424 cli.fork_show_all, 1425 ); 1426 let Some(startup_app_server) = app_server.as_mut() else { 1427 unreachable!("app server should be initialized for --fork --last"); 1428 }; 1429 let lookup = startup_draft 1430 .run_until( 1431 &mut tui, 1432 lookup_latest_session_target_with_app_server( 1433 uses_remote_workspace_or_environment( 1434 &app_server_target, 1435 &environment_manager, 1436 ), 1437 startup_app_server, 1438 &config, 1439 filter_cwd, 1440 /*include_non_interactive*/ false, 1441 ), 1442 ) 1443 .await; 1444 let target_session = match lookup { 1445 Ok(result) => result?, 1446 Err(err) => { 1447 shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await; 1448 return Err(err.into()); 1449 } 1450 }; 1451 match target_session { 1452 Some(target_session) => resume_picker::SessionSelection::Fork(target_session), 1453 None => resume_picker::SessionSelection::StartFresh, 1454 } 1455 } else if cli.fork_picker { 1456 if let Err(err) = startup_draft.flush_pending_events(&mut tui).await { 1457 shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await; 1458 return Err(err.into()); 1459 } 1460 let Some(app_server) = app_server.take() else { 1461 unreachable!("app server should be initialized for --fork picker"); 1462 }; 1463 let picker_local_settings = 1464 crate::local_settings::LocalSettings::for_tui(&config, &tui); 1465 match resume_picker::run_fork_picker_with_app_server( 1466 uses_remote_workspace_or_environment(&app_server_target, &environment_manager), 1467 &mut tui, 1468 &config, 1469 &picker_local_settings, 1470 cli.fork_show_all, 1471 app_server, 1472 ) 1473 .await? 1474 { 1475 resume_picker::SessionSelection::Exit => { 1476 terminal_restore_guard.restore_silently(); 1477 session_log::log_session_end(); 1478 return Ok(AppExitInfo { 1479 token_usage: crate::token_usage::TokenUsage::default(), 1480 thread_id: None, 1481 resume_hint: None, 1482 disconnect_info: None, 1483 update_action: None, 1484 exit_reason: ExitReason::UserRequested, 1485 }); 1486 } 1487 other => other, 1488 } 1489 } else { 1490 resume_picker::SessionSelection::StartFresh 1491 } 1492 } else if let Some(id_str) = cli.resume_session_id.as_deref() { 1493 let Some(startup_app_server) = app_server.as_mut() else { 1494 unreachable!("app server should be initialized for --resume <id>"); 1495 }; 1496 let lookup = startup_draft 1497 .run_until( 1498 &mut tui, 1499 lookup_session_target_with_app_server(startup_app_server, &config, id_str), 1500 ) 1501 .await; 1502 let target_session = match lookup { 1503 Ok(result) => result?, 1504 Err(err) => { 1505 shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await; 1506 return Err(err.into()); 1507 } 1508 }; 1509 match target_session { 1510 Some(target_session) => resume_picker::SessionSelection::Resume(target_session), 1511 None => { 1512 shutdown_app_server_if_present(app_server.take()).await; 1513 return missing_session_exit( 1514 id_str, 1515 "resume", 1516 &mut tui, 1517 &mut terminal_restore_guard, 1518 ); 1519 } 1520 } 1521 } else if cli.resume_last { 1522 let filter_cwd = latest_session_cwd_filter( 1523 uses_remote_workspace, 1524 remote_cwd_override.as_deref(), 1525 &config, 1526 cli.resume_show_all, 1527 ); 1528 let Some(startup_app_server) = app_server.as_mut() else { 1529 unreachable!("app server should be initialized for --resume --last"); 1530 }; 1531 let lookup = startup_draft 1532 .run_until( 1533 &mut tui, 1534 lookup_latest_session_target_with_app_server( 1535 uses_remote_workspace_or_environment(&app_server_target, &environment_manager), 1536 startup_app_server, 1537 &config, 1538 filter_cwd, 1539 cli.resume_include_non_interactive, 1540 ), 1541 ) 1542 .await; 1543 let target_session = match lookup { 1544 Ok(result) => result?, 1545 Err(err) => { 1546 shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await; 1547 return Err(err.into()); 1548 } 1549 }; 1550 match target_session { 1551 Some(target_session) => resume_picker::SessionSelection::Resume(target_session), 1552 None => resume_picker::SessionSelection::StartFresh, 1553 } 1554 } else if cli.resume_picker { 1555 if let Err(err) = startup_draft.flush_pending_events(&mut tui).await { 1556 shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await; 1557 return Err(err.into()); 1558 } 1559 let Some(app_server) = app_server.take() else { 1560 unreachable!("app server should be initialized for --resume picker"); 1561 }; 1562 let picker_local_settings = crate::local_settings::LocalSettings::for_tui(&config, &tui); 1563 match resume_picker::run_resume_picker_with_app_server( 1564 uses_remote_workspace_or_environment(&app_server_target, &environment_manager), 1565 &mut tui, 1566 &config, 1567 &picker_local_settings, 1568 cli.resume_show_all, 1569 cli.resume_include_non_interactive, 1570 app_server, 1571 ) 1572 .await? 1573 { 1574 resume_picker::SessionSelection::Exit => { 1575 terminal_restore_guard.restore_silently(); 1576 session_log::log_session_end(); 1577 return Ok(AppExitInfo { 1578 token_usage: crate::token_usage::TokenUsage::default(), 1579 thread_id: None, 1580 resume_hint: None, 1581 disconnect_info: None, 1582 update_action: None, 1583 exit_reason: ExitReason::UserRequested, 1584 }); 1585 } 1586 other => other, 1587 } 1588 } else { 1589 resume_picker::SessionSelection::StartFresh 1590 }; 1591 1592 if let Err(err) = startup_draft.update_session_selection(&mut tui, &session_selection) { 1593 shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await; 1594 return Err(err.into()); 1595 } 1596 1597 if matches!( 1598 &session_selection, 1599 resume_picker::SessionSelection::Resume(_) | resume_picker::SessionSelection::Fork(_) 1600 ) && let Err(err) = startup_draft.flush_pending_events(&mut tui).await 1601 { 1602 shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await; 1603 return Err(err.into()); 1604 } 1605 1606 let current_cwd = config.cwd.clone(); 1607 let fallback_cwd = match resolve_startup_resume_or_fork_cwd( 1608 &mut tui, 1609 &config, 1610 app_server.as_mut(), 1611 &session_selection, 1612 cli.cwd.as_deref(), 1613 uses_remote_workspace, 1614 uses_remote_workspace_or_environment(&app_server_target, &environment_manager), 1615 ) 1616 .await 1617 { 1618 Ok(ResolveCwdOutcome::Continue(cwd)) => cwd, 1619 Ok(ResolveCwdOutcome::ContinueAfterPrompt(cwd)) => { 1620 // Another daemon client can change authentication while this prompt is open. 1621 startup_account = None; 1622 Some(cwd) 1623 } 1624 Ok(ResolveCwdOutcome::Exit) => { 1625 terminal_restore_guard.restore_silently(); 1626 session_log::log_session_end(); 1627 return Ok(AppExitInfo { 1628 token_usage: crate::token_usage::TokenUsage::default(), 1629 thread_id: None, 1630 resume_hint: None, 1631 disconnect_info: None, 1632 update_action: None, 1633 exit_reason: ExitReason::UserRequested, 1634 }); 1635 } 1636 Err(err) => { 1637 terminal_restore_guard.restore_silently(); 1638 session_log::log_session_end(); 1639 return Err(err); 1640 } 1641 }; 1642 1643 if (cli.resume_picker || cli.fork_picker) 1644 && let Err(err) = startup_draft.show(&mut tui) 1645 { 1646 shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await; 1647 return Err(err.into()); 1648 } 1649 1650 let picker_cancelled_without_selection = matches!( 1651 session_selection, 1652 resume_picker::SessionSelection::StartFresh 1653 ) && (cli.resume_picker || cli.fork_picker); 1654 1655 let reloaded_config = match &session_selection { 1656 resume_picker::SessionSelection::Resume(_) | resume_picker::SessionSelection::Fork(_) => { 1657 startup_draft 1658 .run_until( 1659 &mut tui, 1660 load_config_or_exit_with_fallback_cwd( 1661 cli_kv_overrides.clone(), 1662 overrides.clone(), 1663 loader_overrides.clone(), 1664 cloud_config_bundle.clone(), 1665 strict_config, 1666 fallback_cwd, 1667 managed_worktree.as_ref(), 1668 ), 1669 ) 1670 .await 1671 } 1672 resume_picker::SessionSelection::StartFresh if picker_cancelled_without_selection => { 1673 startup_draft 1674 .run_until( 1675 &mut tui, 1676 load_config_or_exit( 1677 cli_kv_overrides.clone(), 1678 overrides.clone(), 1679 loader_overrides.clone(), 1680 cloud_config_bundle.clone(), 1681 strict_config, 1682 ), 1683 ) 1684 .await 1685 } 1686 _ => Ok(config), 1687 }; 1688 let mut config = match reloaded_config { 1689 Ok(config) => config, 1690 Err(err) => { 1691 shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await; 1692 return Err(err.into()); 1693 } 1694 }; 1695 startup_draft.apply_config(&config); 1696 1697 if config.model_provider_id != startup_model_provider { 1698 startup_account = None; 1699 if matches!(&app_server_target, AppServerTarget::Embedded) { 1700 // App-server providers are fixed at startup, so onboarding cannot 1701 // reuse a server initialized before it persisted another provider. 1702 shutdown_app_server_if_present(app_server.take()).await; 1703 } 1704 } 1705 let mut app_server = match app_server { 1706 Some(app_server) => app_server, 1707 None => match startup_draft 1708 .run_until( 1709 &mut tui, 1710 start_app_server( 1711 &mut app_server_target, 1712 arg0_paths.clone(), 1713 config.clone(), 1714 cli_kv_overrides.clone(), 1715 loader_overrides.clone(), 1716 strict_config, 1717 cloud_config_bundle.clone(), 1718 feedback.clone(), 1719 log_db.clone(), 1720 &mut state_db, 1721 environment_manager.clone(), 1722 ), 1723 ) 1724 .await 1725 { 1726 Ok(Ok(app_server)) => { 1727 // A picker can replace the server; account reads belong to their original session. 1728 startup_account = None; 1729 AppServerSession::new(app_server, app_server_target.thread_params_mode()) 1730 .with_local_codex_home(&config.codex_home) 1731 .with_remote_cwd_override(remote_cwd_override.clone()) 1732 } 1733 Ok(Err(err)) => { 1734 terminal_restore_guard.restore_silently(); 1735 session_log::log_session_end(); 1736 return Err(err); 1737 } 1738 Err(err) => { 1739 terminal_restore_guard.restore_silently(); 1740 session_log::log_session_end(); 1741 return Err(err.into()); 1742 } 1743 }, 1744 }; 1745 1746 // Remote startup keeps its existing explicit --cd trust check. Resolving other 1747 // remote folders requires authoritative project-root information from the server. 1748 if !uses_remote_workspace || remote_cwd_override.is_some() { 1749 let resumed_thread = if matches!(app_server_target, AppServerTarget::LocalDaemon { .. }) 1750 && let resume_picker::SessionSelection::Resume(target) = &session_selection 1751 { 1752 Some( 1753 startup_draft 1754 .run_until( 1755 &mut tui, 1756 app_server.thread_read(target.thread_id, /*include_turns*/ false), 1757 ) 1758 .await??, 1759 ) 1760 } else { 1761 None 1762 }; 1763 let trust_cwd = remote_cwd_override 1764 .as_deref() 1765 .unwrap_or(config.cwd.as_path()); 1766 let consent = onboarding::onboarding_screen::check_directory_trust( 1767 &mut tui, 1768 &app_server, 1769 &config, 1770 &app_server_target, 1771 trust_cwd, 1772 resumed_thread.as_ref(), 1773 Some(&mut startup_draft), 1774 ) 1775 .await?; 1776 startup_account = None; 1777 if consent.directory_trust_persisted && !uses_remote_workspace { 1778 let previous_provider = config.model_provider_id.clone(); 1779 config = load_config_or_exit_with_fallback_cwd( 1780 cli_kv_overrides.clone(), 1781 overrides.clone(), 1782 loader_overrides.clone(), 1783 cloud_config_bundle.clone(), 1784 strict_config, 1785 Some(config.cwd.to_path_buf()), 1786 managed_worktree.as_ref(), 1787 ) 1788 .await; 1789 if config.model_provider_id != previous_provider 1790 && matches!(app_server_target, AppServerTarget::Embedded) 1791 { 1792 app_server.shutdown().await?; 1793 let client = start_app_server( 1794 &mut app_server_target, 1795 arg0_paths.clone(), 1796 config.clone(), 1797 cli_kv_overrides.clone(), 1798 loader_overrides.clone(), 1799 strict_config, 1800 cloud_config_bundle.clone(), 1801 feedback.clone(), 1802 log_db.clone(), 1803 &mut state_db, 1804 environment_manager.clone(), 1805 ) 1806 .await?; 1807 app_server = AppServerSession::new(client, app_server_target.thread_params_mode()) 1808 .with_local_codex_home(&config.codex_home); 1809 } 1810 #[cfg(target_os = "windows")] 1811 { 1812 trust_decision_was_made = true; 1813 } 1814 } else if consent.should_exit { 1815 if matches!(app_server_target, AppServerTarget::Embedded) { 1816 shutdown_startup_session(Some(app_server), &mut terminal_restore_guard).await; 1817 return Ok(AppExitInfo { 1818 token_usage: crate::token_usage::TokenUsage::default(), 1819 thread_id: None, 1820 resume_hint: None, 1821 disconnect_info: None, 1822 update_action: None, 1823 exit_reason: ExitReason::UserRequested, 1824 }); 1825 } 1826 if !uses_remote_workspace { 1827 config = load_config_or_exit_with_fallback_cwd( 1828 cli_kv_overrides.clone(), 1829 overrides.clone(), 1830 loader_overrides.clone(), 1831 cloud_config_bundle.clone(), 1832 strict_config, 1833 Some(current_cwd.to_path_buf()), 1834 managed_worktree.as_ref(), 1835 ) 1836 .await; 1837 } 1838 session_selection = resume_picker::SessionSelection::AgentsOverview; 1839 cli.prompt = None; 1840 cli.images.clear(); 1841 startup_draft.update_session_selection(&mut tui, &session_selection)?; 1842 } 1843 } 1844 startup_draft.apply_config(&config); 1845 1846 // Count launches that reach final config resolution, regardless of screen policy. 1847 if config.analytics_enabled != Some(false) 1848 && config.otel.metrics_exporter != codex_config::types::OtelExporterKind::None 1849 && let Some(metrics) = codex_otel::global() 1850 { 1851 let _ = metrics.counter( 1852 "codex.tui.fullscreen_transcript", 1853 /*inc*/ 1, 1854 &[("enabled", &config.tui_fullscreen_transcript.to_string())], 1855 ); 1856 } 1857 1858 // Cloud configuration and session selection can change screen policy after first paint. 1859 let use_alt_screen = determine_alt_screen_mode(cli.no_alt_screen, config.tui_alternate_screen); 1860 let mode = crate::transcript_mode::TranscriptMode::resolve( 1861 config.tui_fullscreen_transcript, 1862 use_alt_screen, 1863 ); 1864 if use_alt_screen != tui.is_alt_screen_enabled() || mode.is_owned() != tui.is_owned_screen() { 1865 std::io::stdout().sync_update(|_| { 1866 tui.set_alt_screen_enabled(use_alt_screen); 1867 tui.set_owned_screen(mode.is_owned())?; 1868 startup_draft.redraw_if_visible(&mut tui) 1869 })??; 1870 } 1871 1872 let local_settings = crate::local_settings::LocalSettings::for_tui(&config, &tui); 1873 crate::markdown_render::preferences::init(local_settings.tui.rendering); 1874 // Configure syntax highlighting theme from the final config — onboarding 1875 // and resume/fork can both reload config with a different tui_theme, so 1876 // this must happen after the last possible reload. 1877 if let Some(w) = crate::render::highlight::set_theme_override( 1878 local_settings.tui.theme.clone(), 1879 find_codex_home().ok().map(AbsolutePathBuf::into_path_buf), 1880 ) { 1881 config.startup_warnings.push(w); 1882 } 1883 1884 set_default_client_residency_requirement(config.enforce_residency.value()); 1885 let should_show_trust_screen = should_show_trust_screen(&config); 1886 #[cfg(target_os = "windows")] 1887 let should_prompt_windows_sandbox_nux_at_startup = trust_decision_was_made; 1888 #[cfg(not(target_os = "windows"))] 1889 let should_prompt_windows_sandbox_nux_at_startup = false; 1890 1891 let Cli { 1892 prompt, 1893 shared, 1894 daemon_cli_executable, 1895 .. 1896 } = cli; 1897 let images = shared.into_inner().images; 1898 1899 // Persistent app-server resumes may attach to an already-running thread, 1900 // where resume config overrides are ignored. 1901 let is_persistent_resume = !matches!(&app_server_target, AppServerTarget::Embedded) 1902 && matches!( 1903 &session_selection, 1904 resume_picker::SessionSelection::Resume(_) 1905 ); 1906 let bypass_hook_trust_for_startup_review = config.bypass_hook_trust && !is_persistent_resume; 1907 let hooks_request_handle = app_server.request_handle(); 1908 let hooks_cwd = config.cwd.to_path_buf(); 1909 let startup_prefetch_started_at = Instant::now(); 1910 let startup_prefetch = startup_draft 1911 .run_until(&mut tui, async { 1912 tokio::join!( 1913 async { 1914 match startup_account { 1915 Some(account) => app_server.bootstrap_with_account(&config, account).await, 1916 None => app_server.bootstrap(&config).await, 1917 } 1918 }, 1919 load_startup_hooks_review_entry(hooks_request_handle, hooks_cwd), 1920 ) 1921 }) 1922 .await; 1923 let (startup_bootstrap, startup_hooks_entry) = match startup_prefetch { 1924 Ok(startup_prefetch) => startup_prefetch, 1925 Err(err) => { 1926 shutdown_startup_session(Some(app_server), &mut terminal_restore_guard).await; 1927 return Err(err.into()); 1928 } 1929 }; 1930 if let Err(err) = startup_draft.flush_pending_events(&mut tui).await { 1931 shutdown_startup_session(Some(app_server), &mut terminal_restore_guard).await; 1932 return Err(err.into()); 1933 } 1934 let startup_bootstrap = match startup_bootstrap { 1935 Ok(startup_bootstrap) => Some(startup_bootstrap), 1936 Err(err) => { 1937 shutdown_startup_session(Some(app_server), &mut terminal_restore_guard).await; 1938 return Err(err); 1939 } 1940 }; 1941 let startup_elapsed_before_app = startup_prefetch_started_at.elapsed(); 1942 let startup_hooks_review = maybe_run_startup_hooks_review( 1943 &mut app_server, 1944 &mut tui, 1945 &config, 1946 bypass_hook_trust_for_startup_review, 1947 startup_hooks_entry, 1948 ) 1949 .await; 1950 let startup_hooks_browser = match startup_hooks_review { 1951 Err(err) => { 1952 shutdown_startup_session(Some(app_server), &mut terminal_restore_guard).await; 1953 return Err(err); 1954 } 1955 Ok(StartupHooksReviewOutcome::Continue) => None, 1956 Ok(StartupHooksReviewOutcome::OpenHooksBrowser(data)) => Some(data), 1957 }; 1958 1959 // Keep the large event-loop future out of the enclosing startup futures so session 1960 // transitions have enough stack headroom to rebuild configuration and the chat widget. 1961 let app_result = Box::pin(App::run( 1962 &mut tui, 1963 app_server, 1964 config, 1965 current_cwd.to_path_buf(), 1966 cli_kv_overrides.clone(), 1967 overrides.clone(), 1968 loader_overrides.clone(), 1969 cloud_config_bundle, 1970 prompt, 1971 images, 1972 session_selection, 1973 feedback, 1974 should_show_trust_screen, // Proxy to: is it a first run in this directory? 1975 should_prompt_windows_sandbox_nux_at_startup, 1976 app_server_target, 1977 state_db, 1978 environment_manager, 1979 startup_elapsed_before_app, 1980 startup_bootstrap, 1981 startup_hooks_browser, 1982 daemon_startup_warning, 1983 startup_draft, 1984 managed_worktree, 1985 daemon_cli_executable, 1986 )) 1987 .await; 1988 1989 terminal_restore_guard.restore_silently(); 1990 // Mark the end of the recorded session. 1991 session_log::log_session_end(); 1992 // ignore error when collecting usage – report underlying error instead 1993 app_result 1994 } 1995 1996 #[expect( 1997 clippy::print_stderr, 1998 reason = "TUI should no longer be displayed, so we can write to stderr." 1999 )] 2000 fn restore() { 2001 if let Err(err) = tui::restore_after_exit() { 2002 eprintln!( 2003 "failed to restore terminal. Run `reset` or restart your terminal to recover: {err}" 2004 ); 2005 } 2006 } 2007 2008 struct TerminalRestoreGuard { 2009 active: bool, 2010 } 2011 2012 impl TerminalRestoreGuard { 2013 fn new() -> Self { 2014 Self { active: true } 2015 } 2016 2017 #[cfg_attr(debug_assertions, allow(dead_code))] 2018 fn restore(&mut self) -> color_eyre::Result<()> { 2019 if self.active { 2020 crate::tui::restore_after_exit()?; 2021 self.active = false; 2022 } 2023 Ok(()) 2024 } 2025 2026 fn restore_silently(&mut self) { 2027 if self.active { 2028 restore(); 2029 self.active = false; 2030 } 2031 } 2032 } 2033 2034 impl Drop for TerminalRestoreGuard { 2035 fn drop(&mut self) { 2036 self.restore_silently(); 2037 } 2038 } 2039 2040 /// Determine whether to use the terminal's alternate screen buffer. 2041 /// 2042 /// - If `--no-alt-screen` is explicitly passed, always disable alternate screen 2043 /// - Otherwise, respect the `tui.alternate_screen` config setting: 2044 /// - `always`: Use alternate screen 2045 /// - `never`: Inline mode only, preserves scrollback 2046 /// - `auto` (default): Use alternate screen 2047 fn determine_alt_screen_mode(no_alt_screen: bool, tui_alternate_screen: AltScreenMode) -> bool { 2048 if no_alt_screen { 2049 return false; 2050 } 2051 2052 tui_alternate_screen != AltScreenMode::Never 2053 } 2054 2055 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 2056 pub enum LoginStatus { 2057 AuthMode(AuthMode), 2058 NotAuthenticated, 2059 } 2060 2061 /// Reads the account once to determine login status and preserve the response for bootstrap. 2062 async fn get_login_status( 2063 app_server: &mut AppServerSession, 2064 ) -> color_eyre::Result<(LoginStatus, GetAccountResponse)> { 2065 let account = app_server.read_account().await?; 2066 let login_status = match &account.account { 2067 Some(AppServerAccount::ApiKey {}) => LoginStatus::AuthMode(AuthMode::ApiKey), 2068 Some(AppServerAccount::Chatgpt { .. }) => LoginStatus::AuthMode(AuthMode::Chatgpt), 2069 Some(AppServerAccount::AmazonBedrock { .. }) | None => LoginStatus::NotAuthenticated, 2070 }; 2071 Ok((login_status, account)) 2072 } 2073 2074 async fn load_config_or_exit( 2075 cli_kv_overrides: Vec<(String, toml::Value)>, 2076 overrides: ConfigOverrides, 2077 loader_overrides: LoaderOverrides, 2078 cloud_config_bundle: CloudConfigBundleLoader, 2079 strict_config: bool, 2080 ) -> Config { 2081 load_config_or_exit_with_fallback_cwd( 2082 cli_kv_overrides, 2083 overrides, 2084 loader_overrides, 2085 cloud_config_bundle, 2086 strict_config, 2087 /*fallback_cwd*/ None, 2088 /*worktree*/ None, 2089 ) 2090 .await 2091 } 2092 2093 #[allow(clippy::too_many_arguments)] 2094 async fn load_config_or_exit_with_fallback_cwd( 2095 cli_kv_overrides: Vec<(String, toml::Value)>, 2096 overrides: ConfigOverrides, 2097 loader_overrides: LoaderOverrides, 2098 cloud_config_bundle: CloudConfigBundleLoader, 2099 strict_config: bool, 2100 fallback_cwd: Option<PathBuf>, 2101 worktree: Option<&ManagedTuiWorktree>, 2102 ) -> Config { 2103 #[allow(clippy::print_stderr)] 2104 match load_config_with_worktree_source_policy( 2105 cli_kv_overrides, 2106 overrides, 2107 loader_overrides, 2108 cloud_config_bundle, 2109 strict_config, 2110 fallback_cwd, 2111 worktree, 2112 ) 2113 .await 2114 { 2115 Ok(config) => config, 2116 Err(err) => { 2117 restore_terminal_before_fatal_exit(); 2118 eprintln!("Error loading configuration: {err}"); 2119 if let Some(worktree) = worktree { 2120 worktree.report_startup_failure(); 2121 } 2122 std::process::exit(1); 2123 } 2124 } 2125 } 2126 2127 #[allow(clippy::too_many_arguments)] 2128 async fn load_config_with_worktree_source_policy( 2129 cli_kv_overrides: Vec<(String, toml::Value)>, 2130 overrides: ConfigOverrides, 2131 loader_overrides: LoaderOverrides, 2132 cloud_config_bundle: CloudConfigBundleLoader, 2133 strict_config: bool, 2134 fallback_cwd: Option<PathBuf>, 2135 worktree: Option<&ManagedTuiWorktree>, 2136 ) -> std::io::Result<Config> { 2137 let config = ConfigBuilder::default() 2138 .cli_overrides(cli_kv_overrides.clone()) 2139 .harness_overrides(overrides.clone()) 2140 .loader_overrides(loader_overrides.clone()) 2141 .strict_config(strict_config) 2142 .cloud_config_bundle(cloud_config_bundle.clone()) 2143 .fallback_cwd(fallback_cwd) 2144 .build() 2145 .await 2146 .map_err(std::io::Error::other)?; 2147 if let Some(worktree) = worktree { 2148 worktree 2149 .check_source_policy( 2150 &cli_kv_overrides, 2151 &overrides, 2152 &loader_overrides, 2153 &cloud_config_bundle, 2154 strict_config, 2155 ) 2156 .await?; 2157 } 2158 Ok(config) 2159 } 2160 2161 #[allow(clippy::print_stderr)] 2162 async fn load_bootstrap_config_or_exit( 2163 codex_home: &Path, 2164 cwd: Option<&AbsolutePathBuf>, 2165 cli_kv_overrides: Vec<(String, codex_config::TomlValue)>, 2166 loader_overrides: LoaderOverrides, 2167 strict_config: bool, 2168 cloud_config_bundle: CloudConfigBundleLoader, 2169 ) -> ConfigTomlLoadResult { 2170 match load_config_toml_with_layer_stack( 2171 codex_home, 2172 cwd, 2173 cli_kv_overrides, 2174 codex_config::ConfigLoadOptions { 2175 loader_overrides, 2176 strict_config, 2177 cloud_config_bundle, 2178 }, 2179 ) 2180 .await 2181 { 2182 Ok(config_toml) => config_toml, 2183 Err(err) => { 2184 restore_terminal_before_fatal_exit(); 2185 let config_error = err 2186 .get_ref() 2187 .and_then(|err| err.downcast_ref::<ConfigLoadError>()) 2188 .map(ConfigLoadError::config_error); 2189 if let Some(config_error) = config_error { 2190 eprintln!( 2191 "Error loading config.toml:\n{}", 2192 format_config_error_with_source(config_error) 2193 ); 2194 } else { 2195 eprintln!("Error loading config.toml: {err}"); 2196 } 2197 std::process::exit(1); 2198 } 2199 } 2200 } 2201 2202 /// Determine if the user has decided whether to trust the current directory. 2203 fn should_show_trust_screen(config: &Config) -> bool { 2204 config.active_project.trust_level.is_none() 2205 } 2206 2207 fn should_show_onboarding( 2208 login_status: LoginStatus, 2209 requires_openai_auth: bool, 2210 show_trust_screen: bool, 2211 ) -> bool { 2212 if show_trust_screen { 2213 return true; 2214 } 2215 2216 should_show_login_screen(login_status, requires_openai_auth) 2217 } 2218 2219 fn should_show_login_screen(login_status: LoginStatus, requires_openai_auth: bool) -> bool { 2220 // Only show the login screen for providers that actually require OpenAI auth 2221 // (OpenAI or equivalents). For OSS/other providers, skip login entirely. 2222 if !requires_openai_auth { 2223 return false; 2224 } 2225 2226 login_status == LoginStatus::NotAuthenticated 2227 } 2228 2229 fn should_show_bedrock_setup_wizard( 2230 login_status: LoginStatus, 2231 requires_openai_auth: bool, 2232 config: &Config, 2233 app_server_target: &AppServerTarget, 2234 ) -> bool { 2235 matches!(app_server_target, AppServerTarget::Embedded) 2236 && should_show_login_screen(login_status, requires_openai_auth) 2237 && config.features.enabled(Feature::BedrockSetupWizard) 2238 && config.model_provider_id == "openai" 2239 && config 2240 .config_layer_stack 2241 .effective_config() 2242 .get("model_provider") 2243 .is_none() 2244 && config 2245 .auth_config() 2246 .is_login_method_allowed(ForcedLoginMethod::Api) 2247 } 2248 2249 mod daemon_startup; 2250 mod daemon_telemetry; 2251 2252 #[cfg(test)] 2253 #[path = "daemon_startup_tests.rs"] 2254 mod daemon_startup_tests; 2255 2256 #[cfg(test)] 2257 pub(crate) mod tests { 2258 use super::*; 2259 use crate::legacy_core::config::ConfigBuilder; 2260 use crate::legacy_core::config::ConfigOverrides; 2261 use clap::Parser; 2262 use codex_app_server_protocol::AskForApproval; 2263 use codex_app_server_protocol::ClientRequest; 2264 use codex_app_server_protocol::RequestId; 2265 use codex_app_server_protocol::ThreadStartParams; 2266 use codex_app_server_protocol::ThreadStartResponse; 2267 use codex_config::config_toml::ProjectConfig; 2268 use codex_utils_absolute_path::test_support::PathExt; 2269 use pretty_assertions::assert_eq; 2270 use serial_test::serial; 2271 use tempfile::TempDir; 2272 2273 #[test] 2274 fn tui_startup_future_stays_bounded() { 2275 let future = run_main( 2276 Cli::parse_from(["codex"]), 2277 Arg0DispatchPaths::default(), 2278 LoaderOverrides::default(), 2279 /*explicit_remote_endpoint*/ None, 2280 ); 2281 let size = std::mem::size_of_val(&future); 2282 2283 assert!(size < 64 * 1024, "TUI startup future is {size} bytes"); 2284 } 2285 2286 async fn build_config(temp_dir: &TempDir) -> std::io::Result<Config> { 2287 ConfigBuilder::default() 2288 .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) 2289 .codex_home(temp_dir.path().to_path_buf()) 2290 .build() 2291 .await 2292 } 2293 2294 #[tokio::test] 2295 async fn server_account_requirement_controls_login_screen() -> color_eyre::Result<()> { 2296 for requires_openai_auth in [false, true] { 2297 let home = TempDir::new()?; 2298 std::fs::write( 2299 home.path().join("config.toml"), 2300 format!( 2301 r#" 2302 model_provider = "test-provider" 2303 [model_providers.test-provider] 2304 name = "Test provider" 2305 base_url = "http://example.test/v1" 2306 wire_api = "responses" 2307 requires_openai_auth = {requires_openai_auth} 2308 "# 2309 ), 2310 )?; 2311 let server_config = build_config(&home).await?; 2312 let mut server = AppServerSession::new( 2313 AppServerClient::InProcess(start_test_embedded_app_server(server_config).await?), 2314 ThreadParamsMode::Embedded, 2315 ); 2316 let (login_status, account) = get_login_status(&mut server).await?; 2317 assert_eq!(account.requires_openai_auth, requires_openai_auth); 2318 assert_eq!( 2319 should_show_login_screen(login_status, account.requires_openai_auth), 2320 requires_openai_auth 2321 ); 2322 assert!(!should_show_login_screen( 2323 LoginStatus::AuthMode(AuthMode::Chatgpt), 2324 account.requires_openai_auth 2325 )); 2326 server.shutdown().await?; 2327 } 2328 Ok(()) 2329 } 2330 2331 #[tokio::test] 2332 async fn bedrock_setup_wizard_requires_eligible_onboarding() -> color_eyre::Result<()> { 2333 let shared_endpoint = RemoteAppServerEndpoint::WebSocket { 2334 websocket_url: "ws://127.0.0.1:4500/".to_string(), 2335 auth_token: None, 2336 }; 2337 let enabled = "[features]\nbedrock_setup_wizard = true\n"; 2338 2339 for (label, config_toml, login_status, target, expected) in [ 2340 ( 2341 "disabled by default", 2342 "", 2343 LoginStatus::NotAuthenticated, 2344 AppServerTarget::Embedded, 2345 false, 2346 ), 2347 ( 2348 "enabled for the default provider", 2349 enabled, 2350 LoginStatus::NotAuthenticated, 2351 AppServerTarget::Embedded, 2352 true, 2353 ), 2354 ( 2355 "explicit provider", 2356 "model_provider = \"openai\"\n[features]\nbedrock_setup_wizard = true\n", 2357 LoginStatus::NotAuthenticated, 2358 AppServerTarget::Embedded, 2359 false, 2360 ), 2361 ( 2362 "forced ChatGPT login", 2363 "forced_login_method = \"chatgpt\"\n[features]\nbedrock_setup_wizard = true\n", 2364 LoginStatus::NotAuthenticated, 2365 AppServerTarget::Embedded, 2366 false, 2367 ), 2368 ( 2369 "existing authentication", 2370 enabled, 2371 LoginStatus::AuthMode(AuthMode::Chatgpt), 2372 AppServerTarget::Embedded, 2373 false, 2374 ), 2375 ( 2376 "shared local daemon", 2377 enabled, 2378 LoginStatus::NotAuthenticated, 2379 AppServerTarget::LocalDaemon { 2380 allow_embedded_fallback: true, 2381 endpoint: shared_endpoint.clone(), 2382 }, 2383 false, 2384 ), 2385 ( 2386 "remote app server", 2387 enabled, 2388 LoginStatus::NotAuthenticated, 2389 AppServerTarget::Remote { 2390 endpoint: shared_endpoint, 2391 }, 2392 false, 2393 ), 2394 ] { 2395 let codex_home = TempDir::new()?; 2396 std::fs::write(codex_home.path().join("config.toml"), config_toml)?; 2397 let config = build_config(&codex_home).await?; 2398 2399 assert_eq!( 2400 should_show_bedrock_setup_wizard( 2401 login_status, 2402 /*requires_openai_auth*/ true, 2403 &config, 2404 &target 2405 ), 2406 expected, 2407 "{label}" 2408 ); 2409 } 2410 2411 Ok(()) 2412 } 2413 2414 pub(crate) fn write_session_rollout( 2415 codex_home: &Path, 2416 filename_ts: &str, 2417 meta_rfc3339: &str, 2418 preview: &str, 2419 model_provider: &str, 2420 cwd: &Path, 2421 ) -> color_eyre::Result<ThreadId> { 2422 let uuid = Uuid::new_v4(); 2423 let uuid_str = uuid.to_string(); 2424 let thread_id = ThreadId::from_string(&uuid_str)?; 2425 let year = &filename_ts[0..4]; 2426 let month = &filename_ts[5..7]; 2427 let day = &filename_ts[8..10]; 2428 let rollout_path = codex_home 2429 .join("sessions") 2430 .join(year) 2431 .join(month) 2432 .join(day) 2433 .join(format!("rollout-{filename_ts}-{uuid_str}.jsonl")); 2434 let parent = rollout_path 2435 .parent() 2436 .ok_or_else(|| color_eyre::eyre::eyre!("rollout path is missing a parent directory"))?; 2437 std::fs::create_dir_all(parent)?; 2438 2439 let session_meta = codex_protocol::protocol::SessionMeta { 2440 session_id: thread_id.into(), 2441 id: thread_id, 2442 timestamp: meta_rfc3339.to_string(), 2443 cwd: cwd.to_path_buf(), 2444 originator: "codex".to_string(), 2445 cli_version: "0.0.0".to_string(), 2446 source: codex_protocol::protocol::SessionSource::Cli, 2447 model_provider: Some(model_provider.to_string()), 2448 ..Default::default() 2449 }; 2450 let session_meta = serde_json::to_value(codex_protocol::protocol::SessionMetaLine { 2451 meta: session_meta, 2452 git: None, 2453 })?; 2454 let lines = [ 2455 serde_json::json!({ 2456 "timestamp": meta_rfc3339, 2457 "type": "session_meta", 2458 "payload": session_meta, 2459 }) 2460 .to_string(), 2461 serde_json::json!({ 2462 "timestamp": meta_rfc3339, 2463 "type": "response_item", 2464 "payload": { 2465 "type": "message", 2466 "role": "user", 2467 "content": [{"type": "input_text", "text": preview}], 2468 }, 2469 }) 2470 .to_string(), 2471 serde_json::json!({ 2472 "timestamp": meta_rfc3339, 2473 "type": "event_msg", 2474 "payload": { 2475 "type": "user_message", 2476 "message": preview, 2477 "kind": "plain", 2478 }, 2479 }) 2480 .to_string(), 2481 ]; 2482 std::fs::write(&rollout_path, lines.join("\n") + "\n")?; 2483 let updated_at = 2484 chrono::DateTime::parse_from_rfc3339(meta_rfc3339)?.with_timezone(&chrono::Utc); 2485 let times = std::fs::FileTimes::new().set_modified(updated_at.into()); 2486 std::fs::OpenOptions::new() 2487 .append(true) 2488 .open(rollout_path)? 2489 .set_times(times)?; 2490 2491 Ok(thread_id) 2492 } 2493 2494 #[test] 2495 fn startup_removes_legacy_tui_log_file() -> std::io::Result<()> { 2496 let temp_dir = TempDir::new()?; 2497 let legacy_log_dir = temp_dir.path().join("log"); 2498 std::fs::create_dir_all(&legacy_log_dir)?; 2499 let legacy_log = legacy_log_dir.join(TUI_LOG_FILE_NAME); 2500 std::fs::write(&legacy_log, "legacy log")?; 2501 2502 remove_legacy_tui_log_file(temp_dir.path()); 2503 2504 assert!(!legacy_log.exists()); 2505 Ok(()) 2506 } 2507 2508 #[tokio::test] 2509 async fn startup_services_use_final_cloud_managed_http_policy() -> color_eyre::Result<()> { 2510 for (configured_respect_system_proxy, managed_respect_system_proxy) in 2511 [(true, false), (false, true)] 2512 { 2513 let codex_home = TempDir::new()?; 2514 std::fs::write( 2515 codex_home.path().join("config.toml"), 2516 format!("[features]\nrespect_system_proxy = {configured_respect_system_proxy}\n"), 2517 )?; 2518 let prepared_environment_manager = 2519 EnvironmentManager::prepare_from_codex_home(codex_home.path()).await?; 2520 let loader_overrides = LoaderOverrides::without_managed_config_for_tests(); 2521 let bootstrap_config = load_config_toml_with_layer_stack( 2522 codex_home.path(), 2523 /*cwd*/ None, 2524 Vec::new(), 2525 codex_config::ConfigLoadOptions { 2526 loader_overrides: loader_overrides.clone(), 2527 ..Default::default() 2528 }, 2529 ) 2530 .await?; 2531 let bootstrap_http_client_factory = resolve_bootstrap_http_client_factory( 2532 &bootstrap_config.config_toml, 2533 bootstrap_config 2534 .config_layer_stack 2535 .requirements() 2536 .feature_requirements 2537 .as_ref(), 2538 )?; 2539 let cloud_config_bundle = 2540 codex_config::test_support::CloudConfigBundleFixture::loader_with_enterprise_requirement( 2541 format!("[features]\nrespect_system_proxy = {managed_respect_system_proxy}\n"), 2542 ); 2543 let config = ConfigBuilder::default() 2544 .codex_home(codex_home.path().to_path_buf()) 2545 .loader_overrides(loader_overrides) 2546 .cloud_config_bundle(cloud_config_bundle) 2547 .build() 2548 .await?; 2549 let runtime_paths = ExecServerRuntimePaths::new( 2550 std::env::current_exe()?, 2551 /*codex_linux_sandbox_exe*/ None, 2552 )?; 2553 let environment_manager = prepared_environment_manager 2554 .build(Some(runtime_paths), config.http_client_factory())?; 2555 2556 assert_ne!( 2557 bootstrap_http_client_factory.outbound_proxy_policy(), 2558 config.http_client_factory().outbound_proxy_policy() 2559 ); 2560 assert_eq!( 2561 environment_manager 2562 .http_client_factory() 2563 .outbound_proxy_policy(), 2564 config.http_client_factory().outbound_proxy_policy() 2565 ); 2566 assert_eq!( 2567 config 2568 .auth_route_config() 2569 .http_client_factory() 2570 .outbound_proxy_policy(), 2571 config.http_client_factory().outbound_proxy_policy() 2572 ); 2573 assert_eq!(config.respect_system_proxy, managed_respect_system_proxy); 2574 } 2575 2576 Ok(()) 2577 } 2578 2579 pub(crate) async fn start_test_embedded_app_server( 2580 config: Config, 2581 ) -> color_eyre::Result<InProcessAppServerClient> { 2582 let state_db = 2583 init_state_db_for_app_server_target(&config, &AppServerTarget::Embedded).await?; 2584 start_embedded_app_server( 2585 Arg0DispatchPaths::default(), 2586 config, 2587 Vec::new(), 2588 LoaderOverrides::default(), 2589 /*strict_config*/ false, 2590 CloudConfigBundleLoader::default(), 2591 codex_feedback::CodexFeedback::new(), 2592 /*log_db*/ None, 2593 state_db, 2594 Arc::new(EnvironmentManager::default_for_tests()), 2595 ) 2596 .await 2597 } 2598 2599 #[tokio::test] 2600 async fn startup_resume_and_fork_use_configured_or_explicit_cwd() -> color_eyre::Result<()> { 2601 for (action, configured_mode, has_explicit_cwd, expected_directory) in [ 2602 (CwdPromptAction::Resume, "current", false, "launch"), 2603 (CwdPromptAction::Resume, "session", false, "session"), 2604 (CwdPromptAction::Resume, "session", true, "explicit"), 2605 (CwdPromptAction::Fork, "current", false, "launch"), 2606 (CwdPromptAction::Fork, "session", false, "session"), 2607 (CwdPromptAction::Fork, "session", true, "explicit"), 2608 ] { 2609 let temp_dir = TempDir::new()?; 2610 let codex_home = temp_dir.path().join("codex-home"); 2611 let launch_cwd = temp_dir.path().join("launch"); 2612 let session_cwd = temp_dir.path().join("session"); 2613 let explicit_cwd = temp_dir.path().join("explicit"); 2614 std::fs::create_dir_all(&codex_home)?; 2615 std::fs::create_dir_all(&launch_cwd)?; 2616 std::fs::create_dir_all(&session_cwd)?; 2617 std::fs::create_dir_all(&explicit_cwd)?; 2618 std::fs::write( 2619 codex_home.join("config.toml"), 2620 format!("[tui]\nresume_cwd = \"{configured_mode}\"\n"), 2621 )?; 2622 let cwd_override = has_explicit_cwd.then_some(explicit_cwd.as_path()); 2623 let config = ConfigBuilder::default() 2624 .codex_home(codex_home.clone()) 2625 .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) 2626 .harness_overrides(ConfigOverrides { 2627 cwd: Some(cwd_override.unwrap_or(launch_cwd.as_path()).to_path_buf()), 2628 ..Default::default() 2629 }) 2630 .build() 2631 .await?; 2632 let filename_timestamp = "2025-01-05T12-00-00"; 2633 let thread_id = write_session_rollout( 2634 &codex_home, 2635 filename_timestamp, 2636 "2025-01-05T12:00:00Z", 2637 "Saved user message", 2638 &config.model_provider_id, 2639 &session_cwd, 2640 )?; 2641 let rollout_path = codex_home 2642 .join("sessions/2025/01/05") 2643 .join(format!("rollout-{filename_timestamp}-{thread_id}.jsonl")); 2644 let state_db = 2645 init_state_db_for_app_server_target(&config, &AppServerTarget::Embedded).await?; 2646 let target_session = resume_picker::SessionTarget { 2647 path: Some(rollout_path), 2648 thread_id, 2649 cwd: Some(session_cwd.clone()), 2650 history_mode: None, 2651 }; 2652 let session_selection = match action { 2653 CwdPromptAction::Resume => resume_picker::SessionSelection::Resume(target_session), 2654 CwdPromptAction::Fork => resume_picker::SessionSelection::Fork(target_session), 2655 }; 2656 let mut tui = tui::test_support::make_test_tui()?; 2657 2658 let fallback_cwd = match resolve_startup_resume_or_fork_cwd( 2659 &mut tui, 2660 &config, 2661 /*app_server*/ None, 2662 &session_selection, 2663 cwd_override, 2664 /*uses_remote_workspace*/ false, 2665 /*uses_remote_workspace_or_environment*/ false, 2666 ) 2667 .await? 2668 { 2669 ResolveCwdOutcome::Continue(cwd) => cwd, 2670 ResolveCwdOutcome::ContinueAfterPrompt(_) => { 2671 panic!("configured cwd should not prompt during startup") 2672 } 2673 ResolveCwdOutcome::Exit => panic!("configured cwd should not exit startup"), 2674 }; 2675 let final_config = ConfigBuilder::default() 2676 .codex_home(codex_home) 2677 .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) 2678 .harness_overrides(ConfigOverrides { 2679 cwd: cwd_override.map(Path::to_path_buf), 2680 ..Default::default() 2681 }) 2682 .fallback_cwd(fallback_cwd) 2683 .build() 2684 .await?; 2685 let expected_cwd = temp_dir.path().join(expected_directory); 2686 assert!(!session_resume::cwds_differ( 2687 final_config.cwd.as_path(), 2688 &expected_cwd, 2689 )); 2690 let mut app_server = start_app_server_for_picker( 2691 &final_config, 2692 &AppServerTarget::Embedded, 2693 state_db, 2694 Arc::new(EnvironmentManager::default_for_tests()), 2695 ) 2696 .await?; 2697 let started = match action { 2698 CwdPromptAction::Resume => { 2699 app_server 2700 .resume_thread( 2701 &crate::local_settings::LocalSettings::from(&final_config), 2702 final_config, 2703 thread_id, 2704 app_server_session::ResumeModelSettings::RestoreFromThread, 2705 ) 2706 .await? 2707 } 2708 CwdPromptAction::Fork => { 2709 app_server 2710 .fork_thread( 2711 &crate::local_settings::LocalSettings::from(&final_config), 2712 final_config, 2713 thread_id, 2714 ) 2715 .await? 2716 } 2717 }; 2718 2719 assert!(!session_resume::cwds_differ( 2720 started.session.cwd.as_path(), 2721 &expected_cwd, 2722 )); 2723 app_server.shutdown().await?; 2724 } 2725 2726 Ok(()) 2727 } 2728 2729 #[tokio::test] 2730 async fn startup_remote_current_cwd_without_override_is_rejected() -> color_eyre::Result<()> { 2731 let temp_dir = TempDir::new()?; 2732 std::fs::write( 2733 temp_dir.path().join("config.toml"), 2734 "[tui]\nresume_cwd = \"current\"\n", 2735 )?; 2736 let config = ConfigBuilder::default() 2737 .codex_home(temp_dir.path().to_path_buf()) 2738 .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) 2739 .build() 2740 .await?; 2741 let mut tui = tui::test_support::make_test_tui()?; 2742 2743 let error = resolve_startup_resume_or_fork_cwd( 2744 &mut tui, 2745 &config, 2746 /*app_server*/ None, 2747 &resume_picker::SessionSelection::Resume(resume_picker::SessionTarget { 2748 path: None, 2749 thread_id: ThreadId::new(), 2750 cwd: None, 2751 history_mode: None, 2752 }), 2753 /*cwd_override*/ None, 2754 /*uses_remote_workspace*/ false, 2755 /*uses_remote_workspace_or_environment*/ true, 2756 ) 2757 .await 2758 .expect_err("remote current cwd should require an explicit override"); 2759 2760 assert_eq!( 2761 error.to_string(), 2762 "`tui.resume_cwd = \"current\"` requires `--cd` when using a remote workspace" 2763 ); 2764 let explicit = resolve_startup_resume_or_fork_cwd( 2765 &mut tui, 2766 &config, 2767 /*app_server*/ None, 2768 &resume_picker::SessionSelection::Resume(resume_picker::SessionTarget { 2769 path: None, 2770 thread_id: ThreadId::new(), 2771 cwd: None, 2772 history_mode: None, 2773 }), 2774 Some(Path::new("/remote-only/project")), 2775 /*uses_remote_workspace*/ true, 2776 /*uses_remote_workspace_or_environment*/ true, 2777 ) 2778 .await?; 2779 assert!( 2780 matches!(explicit, ResolveCwdOutcome::Continue(Some(cwd)) if cwd == config.cwd.as_path()) 2781 ); 2782 Ok(()) 2783 } 2784 2785 #[tokio::test] 2786 async fn startup_session_cwd_without_metadata_is_rejected() -> color_eyre::Result<()> { 2787 let temp_dir = TempDir::new()?; 2788 std::fs::write( 2789 temp_dir.path().join("config.toml"), 2790 "[tui]\nresume_cwd = \"session\"\n", 2791 )?; 2792 let config = ConfigBuilder::default() 2793 .codex_home(temp_dir.path().to_path_buf()) 2794 .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) 2795 .build() 2796 .await?; 2797 let mut tui = tui::test_support::make_test_tui()?; 2798 2799 let error = resolve_startup_resume_or_fork_cwd( 2800 &mut tui, 2801 &config, 2802 /*app_server*/ None, 2803 &resume_picker::SessionSelection::Resume(resume_picker::SessionTarget { 2804 path: None, 2805 thread_id: ThreadId::new(), 2806 cwd: None, 2807 history_mode: None, 2808 }), 2809 /*cwd_override*/ None, 2810 /*uses_remote_workspace*/ false, 2811 /*uses_remote_workspace_or_environment*/ false, 2812 ) 2813 .await 2814 .expect_err("session cwd should require saved metadata"); 2815 2816 assert_eq!( 2817 error.to_string(), 2818 "failed to determine the working directory recorded for the selected session" 2819 ); 2820 Ok(()) 2821 } 2822 2823 #[test] 2824 fn alternate_screen_auto_uses_alt_screen() { 2825 assert!(determine_alt_screen_mode( 2826 /*no_alt_screen*/ false, 2827 AltScreenMode::Auto, 2828 )); 2829 assert!(determine_alt_screen_mode( 2830 /*no_alt_screen*/ false, 2831 AltScreenMode::Always, 2832 )); 2833 assert!(!determine_alt_screen_mode( 2834 /*no_alt_screen*/ false, 2835 AltScreenMode::Never, 2836 )); 2837 assert!(!determine_alt_screen_mode( 2838 /*no_alt_screen*/ true, 2839 AltScreenMode::Auto, 2840 )); 2841 } 2842 2843 #[test] 2844 fn session_target_display_label_falls_back_to_thread_id() { 2845 let thread_id = ThreadId::new(); 2846 let target = crate::resume_picker::SessionTarget { 2847 path: None, 2848 thread_id, 2849 cwd: None, 2850 history_mode: None, 2851 }; 2852 2853 assert_eq!(target.display_label(), format!("thread {thread_id}")); 2854 } 2855 2856 #[test] 2857 fn resolve_remote_addr_accepts_websocket_url() { 2858 assert_eq!( 2859 resolve_remote_addr("ws://127.0.0.1:4500").expect("ws URL should normalize"), 2860 RemoteAppServerEndpoint::WebSocket { 2861 websocket_url: "ws://127.0.0.1:4500/".to_string(), 2862 auth_token: None, 2863 } 2864 ); 2865 } 2866 2867 #[test] 2868 fn resolve_remote_addr_accepts_secure_websocket_url() { 2869 assert_eq!( 2870 resolve_remote_addr("wss://example.com:443").expect("wss URL should normalize"), 2871 RemoteAppServerEndpoint::WebSocket { 2872 websocket_url: "wss://example.com/".to_string(), 2873 auth_token: None, 2874 } 2875 ); 2876 } 2877 2878 #[test] 2879 fn resolve_remote_addr_accepts_default_socket() -> color_eyre::Result<()> { 2880 let codex_home = find_codex_home().wrap_err("failed to resolve CODEX_HOME")?; 2881 assert_eq!( 2882 resolve_remote_addr("unix://")?, 2883 RemoteAppServerEndpoint::UnixSocket { 2884 socket_path: codex_app_server_client::app_server_control_socket_path(&codex_home)?, 2885 } 2886 ); 2887 Ok(()) 2888 } 2889 2890 #[test] 2891 fn resolve_remote_addr_accepts_relative_socket_path() -> color_eyre::Result<()> { 2892 assert_eq!( 2893 resolve_remote_addr("unix://codex.sock")?, 2894 RemoteAppServerEndpoint::UnixSocket { 2895 socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?, 2896 } 2897 ); 2898 Ok(()) 2899 } 2900 2901 #[test] 2902 fn resolve_remote_addr_accepts_absolute_socket_path() -> color_eyre::Result<()> { 2903 let temp_dir = TempDir::new()?; 2904 let socket_path = temp_dir.path().join("codex.sock"); 2905 assert_eq!( 2906 resolve_remote_addr(&format!("unix://{}", socket_path.display()))?, 2907 RemoteAppServerEndpoint::UnixSocket { 2908 socket_path: AbsolutePathBuf::from_absolute_path(&socket_path)?, 2909 } 2910 ); 2911 Ok(()) 2912 } 2913 2914 #[test] 2915 fn resolve_remote_addr_rejects_invalid_remote_addresses() { 2916 for addr in [ 2917 "ws://127.0.0.1", 2918 "wss://example.com", 2919 "127.0.0.1:4500", 2920 "https://127.0.0.1:4500", 2921 ] { 2922 let err = resolve_remote_addr(addr).expect_err("invalid remote addresses should fail"); 2923 assert!(err.to_string().contains( 2924 "expected `ws://host:port`, `wss://host:port`, `unix://`, or `unix://PATH`" 2925 )); 2926 } 2927 } 2928 2929 #[tokio::test] 2930 async fn default_daemon_auto_connect_skips_missing_socket() -> color_eyre::Result<()> { 2931 let codex_home = TempDir::new()?; 2932 assert!( 2933 maybe_probe_default_daemon_socket(codex_home.path()) 2934 .await 2935 .is_none() 2936 ); 2937 Ok(()) 2938 } 2939 2940 #[tokio::test] 2941 async fn default_daemon_auto_connect_probes_socket_only() -> color_eyre::Result<()> { 2942 let codex_home = TempDir::new()?; 2943 let socket_path = 2944 codex_app_server_client::app_server_control_socket_path(codex_home.path())?; 2945 #[cfg(windows)] 2946 { 2947 let parent = socket_path.as_path().parent().expect("socket parent"); 2948 std::fs::create_dir_all(parent)?; 2949 let listener = codex_uds::UnixListener::bind(socket_path.as_path()).await?; 2950 assert!( 2951 maybe_probe_default_daemon_socket(codex_home.path()) 2952 .await 2953 .is_none() 2954 ); 2955 drop(listener); 2956 std::fs::remove_dir_all(parent)?; 2957 } 2958 codex_uds::prepare_private_socket_directory( 2959 socket_path.as_path().parent().expect("socket parent"), 2960 ) 2961 .await?; 2962 let _listener = codex_uds::UnixListener::bind(socket_path.as_path()).await?; 2963 2964 let expected = Some(socket_path); 2965 #[cfg(windows)] 2966 let expected = { 2967 // Existing Windows CI may run elevated; an elevated listener must 2968 // never be selected implicitly, even when its directory is private. 2969 let output = std::process::Command::new("powershell.exe") 2970 .args(["-NoProfile", "-NonInteractive", "-Command", "([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)"]) 2971 .output()?; 2972 assert!(output.status.success()); 2973 match String::from_utf8(output.stdout)?.trim() { 2974 "True" => None, 2975 "False" => expected, 2976 other => panic!("unexpected elevation result: {other}"), 2977 } 2978 }; 2979 assert_eq!( 2980 maybe_probe_default_daemon_socket(codex_home.path()).await, 2981 expected 2982 ); 2983 Ok(()) 2984 } 2985 2986 #[test] 2987 fn app_server_target_for_launch_uses_local_daemon_for_default_socket() -> color_eyre::Result<()> 2988 { 2989 let socket_path = AbsolutePathBuf::relative_to_current_dir("codex.sock")?; 2990 let target = app_server_target_for_launch( 2991 /*explicit_remote_endpoint*/ None, 2992 Some(socket_path.clone()), 2993 /*can_reuse_implicit_local_daemon*/ true, 2994 /*workload_identity_selected*/ false, 2995 /*exec_server_url*/ None, 2996 )?; 2997 2998 assert_eq!( 2999 target, 3000 AppServerTarget::LocalDaemon { 3001 allow_embedded_fallback: true, 3002 endpoint: RemoteAppServerEndpoint::UnixSocket { socket_path }, 3003 } 3004 ); 3005 assert!(!target.uses_remote_workspace()); 3006 assert_eq!(target.thread_params_mode(), ThreadParamsMode::Embedded); 3007 Ok(()) 3008 } 3009 3010 #[test] 3011 fn app_server_target_for_launch_preserves_executor_selection() -> color_eyre::Result<()> { 3012 let socket_path = AbsolutePathBuf::relative_to_current_dir("codex.sock")?; 3013 for executor in ["none", "ws://127.0.0.1:4501"] { 3014 assert_eq!( 3015 app_server_target_for_launch( 3016 /*explicit_remote_endpoint*/ None, 3017 Some(socket_path.clone()), 3018 /*can_reuse_implicit_local_daemon*/ true, 3019 /*workload_identity_selected*/ false, 3020 Some(std::ffi::OsStr::new(executor)), 3021 )?, 3022 AppServerTarget::Embedded, 3023 ); 3024 } 3025 Ok(()) 3026 } 3027 3028 #[test] 3029 fn app_server_target_for_launch_prefers_explicit_remote_endpoint() -> color_eyre::Result<()> { 3030 let explicit_endpoint = RemoteAppServerEndpoint::UnixSocket { 3031 socket_path: AbsolutePathBuf::relative_to_current_dir("explicit.sock")?, 3032 }; 3033 let target = app_server_target_for_launch( 3034 Some(explicit_endpoint.clone()), 3035 Some(AbsolutePathBuf::relative_to_current_dir("default.sock")?), 3036 /*can_reuse_implicit_local_daemon*/ false, 3037 /*workload_identity_selected*/ false, 3038 Some(std::ffi::OsStr::new("none")), 3039 )?; 3040 3041 assert_eq!( 3042 target, 3043 AppServerTarget::Remote { 3044 endpoint: explicit_endpoint, 3045 } 3046 ); 3047 assert!(target.uses_remote_workspace()); 3048 assert_eq!(target.thread_params_mode(), ThreadParamsMode::Remote); 3049 Ok(()) 3050 } 3051 3052 #[test] 3053 fn app_server_target_for_launch_skips_local_daemon_when_launch_config_is_not_replayable() 3054 -> color_eyre::Result<()> { 3055 let socket_path = AbsolutePathBuf::relative_to_current_dir("codex.sock")?; 3056 let target = app_server_target_for_launch( 3057 /*explicit_remote_endpoint*/ None, 3058 Some(socket_path), 3059 /*can_reuse_implicit_local_daemon*/ false, 3060 /*workload_identity_selected*/ false, 3061 /*exec_server_url*/ None, 3062 )?; 3063 3064 assert_eq!(target, AppServerTarget::Embedded); 3065 Ok(()) 3066 } 3067 3068 #[test] 3069 fn workload_identity_requires_an_embedded_app_server() -> color_eyre::Result<()> { 3070 let default_socket = AbsolutePathBuf::relative_to_current_dir("default.sock")?; 3071 assert_eq!( 3072 app_server_target_for_launch( 3073 /*explicit_remote_endpoint*/ None, 3074 Some(default_socket), 3075 /*can_reuse_implicit_local_daemon*/ true, 3076 /*workload_identity_selected*/ true, 3077 /*exec_server_url*/ None, 3078 )?, 3079 AppServerTarget::Embedded 3080 ); 3081 3082 let explicit_endpoint = RemoteAppServerEndpoint::UnixSocket { 3083 socket_path: AbsolutePathBuf::relative_to_current_dir("explicit.sock")?, 3084 }; 3085 let error = app_server_target_for_launch( 3086 Some(explicit_endpoint), 3087 /*default_daemon_socket*/ None, 3088 /*can_reuse_implicit_local_daemon*/ false, 3089 /*workload_identity_selected*/ true, 3090 /*exec_server_url*/ None, 3091 ) 3092 .expect_err("remote hosts must own workload identity"); 3093 assert_eq!( 3094 error.to_string(), 3095 "workload identity must be configured on the remote app-server host" 3096 ); 3097 Ok(()) 3098 } 3099 3100 #[test] 3101 fn should_load_configured_environments_for_local_daemon() -> color_eyre::Result<()> { 3102 let target = AppServerTarget::LocalDaemon { 3103 allow_embedded_fallback: true, 3104 endpoint: RemoteAppServerEndpoint::UnixSocket { 3105 socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?, 3106 }, 3107 }; 3108 3109 assert!(should_load_configured_environments( 3110 &LoaderOverrides::default(), 3111 &target, 3112 )); 3113 Ok(()) 3114 } 3115 3116 #[tokio::test] 3117 async fn latest_session_lookup_params_keep_local_filters_for_embedded_sessions() 3118 -> std::io::Result<()> { 3119 let temp_dir = TempDir::new()?; 3120 let config = build_config(&temp_dir).await?; 3121 let cwd = temp_dir.path().join("project"); 3122 3123 let params = latest_session_lookup_params( 3124 /*uses_remote_filesystem*/ false, 3125 /*uses_remote_workspace*/ false, 3126 &config, 3127 Some(cwd.as_path()), 3128 /*include_non_interactive*/ false, 3129 LatestSessionLookupMode::StateDbOnly, 3130 ); 3131 3132 assert_eq!( 3133 params.model_providers, 3134 Some(vec![config.model_provider_id.clone()]) 3135 ); 3136 assert_eq!( 3137 params.cwd, 3138 Some(ThreadListCwdFilter::One(cwd.to_string_lossy().to_string())) 3139 ); 3140 assert!(params.use_state_db_only); 3141 3142 let scan_params = latest_session_lookup_params( 3143 /*uses_remote_filesystem*/ false, 3144 /*uses_remote_workspace*/ false, 3145 &config, 3146 Some(cwd.as_path()), 3147 /*include_non_interactive*/ false, 3148 LatestSessionLookupMode::ScanAndRepair, 3149 ); 3150 assert!(!scan_params.use_state_db_only); 3151 Ok(()) 3152 } 3153 3154 #[tokio::test] 3155 async fn latest_session_lookup_params_keep_local_filters_for_local_daemon_sessions() 3156 -> color_eyre::Result<()> { 3157 let temp_dir = TempDir::new()?; 3158 let config = build_config(&temp_dir).await?; 3159 let cwd = temp_dir.path().join("project"); 3160 let target = AppServerTarget::LocalDaemon { 3161 allow_embedded_fallback: true, 3162 endpoint: RemoteAppServerEndpoint::UnixSocket { 3163 socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?, 3164 }, 3165 }; 3166 3167 let params = latest_session_lookup_params( 3168 /*uses_remote_filesystem*/ false, 3169 target.uses_remote_workspace(), 3170 &config, 3171 Some(cwd.as_path()), 3172 /*include_non_interactive*/ false, 3173 LatestSessionLookupMode::StateDbOnly, 3174 ); 3175 3176 assert_eq!(params.model_providers, Some(vec![config.model_provider_id])); 3177 assert_eq!( 3178 params.cwd, 3179 Some(ThreadListCwdFilter::One(cwd.to_string_lossy().to_string())) 3180 ); 3181 Ok(()) 3182 } 3183 3184 #[tokio::test] 3185 async fn latest_session_lookup_params_omit_local_filters_for_remote_sessions() 3186 -> std::io::Result<()> { 3187 let temp_dir = TempDir::new()?; 3188 let config = build_config(&temp_dir).await?; 3189 3190 let params = latest_session_lookup_params( 3191 /*uses_remote_filesystem*/ true, 3192 /*uses_remote_workspace*/ true, 3193 &config, 3194 /*cwd_filter*/ None, 3195 /*include_non_interactive*/ false, 3196 LatestSessionLookupMode::StateDbOnly, 3197 ); 3198 3199 assert_eq!(params.model_providers, None); 3200 assert_eq!(params.cwd, None); 3201 Ok(()) 3202 } 3203 3204 #[tokio::test] 3205 async fn latest_session_lookup_params_can_include_non_interactive_sources() 3206 -> std::io::Result<()> { 3207 let temp_dir = TempDir::new()?; 3208 let config = build_config(&temp_dir).await?; 3209 3210 let params = latest_session_lookup_params( 3211 /*uses_remote_filesystem*/ true, 3212 /*uses_remote_workspace*/ true, 3213 &config, 3214 /*cwd_filter*/ None, 3215 /*include_non_interactive*/ true, 3216 LatestSessionLookupMode::StateDbOnly, 3217 ); 3218 3219 assert_eq!( 3220 params.source_kinds, 3221 Some(vec![ 3222 ThreadSourceKind::Cli, 3223 ThreadSourceKind::VsCode, 3224 ThreadSourceKind::Exec, 3225 ThreadSourceKind::AppServer, 3226 ]) 3227 ); 3228 Ok(()) 3229 } 3230 3231 #[tokio::test] 3232 async fn latest_session_lookup_params_keep_explicit_cwd_filter_for_remote_sessions() 3233 -> std::io::Result<()> { 3234 let temp_dir = TempDir::new()?; 3235 let config = build_config(&temp_dir).await?; 3236 let cwd = Path::new("repo/on/server"); 3237 3238 let params = latest_session_lookup_params( 3239 /*uses_remote_filesystem*/ true, 3240 /*uses_remote_workspace*/ true, 3241 &config, 3242 Some(cwd), 3243 /*include_non_interactive*/ false, 3244 LatestSessionLookupMode::StateDbOnly, 3245 ); 3246 3247 assert_eq!(params.model_providers, None); 3248 assert_eq!( 3249 params.cwd, 3250 Some(ThreadListCwdFilter::One(String::from("repo/on/server"))) 3251 ); 3252 Ok(()) 3253 } 3254 3255 #[tokio::test] 3256 async fn latest_session_cwd_filter_respects_scope_options() -> std::io::Result<()> { 3257 let temp_dir = TempDir::new()?; 3258 let config = build_config(&temp_dir).await?; 3259 let remote_cwd = Path::new("repo/on/server"); 3260 3261 let local_filter = latest_session_cwd_filter( 3262 /*uses_remote_workspace*/ false, /*remote_cwd_override*/ None, &config, 3263 /*show_all*/ false, 3264 ); 3265 let show_all_filter = latest_session_cwd_filter( 3266 /*uses_remote_workspace*/ false, /*remote_cwd_override*/ None, &config, 3267 /*show_all*/ true, 3268 ); 3269 let remote_filter = latest_session_cwd_filter( 3270 /*uses_remote_workspace*/ true, 3271 Some(remote_cwd), 3272 &config, 3273 /*show_all*/ false, 3274 ); 3275 3276 assert_eq!(local_filter, Some(config.cwd.as_path())); 3277 assert_eq!(show_all_filter, None); 3278 assert_eq!(remote_filter, Some(remote_cwd)); 3279 Ok(()) 3280 } 3281 3282 #[tokio::test] 3283 async fn fork_last_filters_latest_session_by_cwd_unless_show_all() -> color_eyre::Result<()> { 3284 let temp_dir = TempDir::new()?; 3285 let project_cwd = temp_dir.path().join("project"); 3286 let linked_cwd = temp_dir.path().join("linked-project"); 3287 let other_cwd = temp_dir.path().join("other-project"); 3288 let admin = project_cwd.join(".git/worktrees/linked"); 3289 std::fs::create_dir_all(&admin)?; 3290 std::fs::create_dir_all(&linked_cwd)?; 3291 std::fs::create_dir_all(&other_cwd)?; 3292 std::fs::write(project_cwd.join(".git/HEAD"), "ref: refs/heads/main\n")?; 3293 std::fs::write(admin.join("commondir"), "../..\n")?; 3294 std::fs::write( 3295 admin.join("gitdir"), 3296 linked_cwd.join(".git").display().to_string(), 3297 )?; 3298 std::fs::write( 3299 linked_cwd.join(".git"), 3300 format!("gitdir: {}", admin.display()), 3301 )?; 3302 3303 let mut config = ConfigBuilder::default() 3304 .codex_home(temp_dir.path().to_path_buf()) 3305 .harness_overrides(ConfigOverrides { 3306 cwd: Some(project_cwd.clone()), 3307 ..Default::default() 3308 }) 3309 .build() 3310 .await?; 3311 config 3312 .features 3313 .set_enabled(codex_features::Feature::Worktrees, /*enabled*/ false)?; 3314 let model_provider = config.model_provider_id.as_str(); 3315 let project_thread_id = write_session_rollout( 3316 temp_dir.path(), 3317 "2025-01-02T10-00-00", 3318 "2025-01-02T10:00:00Z", 3319 "older project session", 3320 model_provider, 3321 &project_cwd, 3322 )?; 3323 let linked_thread_id = write_session_rollout( 3324 temp_dir.path(), 3325 "2025-01-02T11-00-00", 3326 "2025-01-02T11:00:00Z", 3327 "newer linked-worktree session", 3328 model_provider, 3329 &linked_cwd, 3330 )?; 3331 let other_thread_id = write_session_rollout( 3332 temp_dir.path(), 3333 "2025-01-02T12-00-00", 3334 "2025-01-02T12:00:00Z", 3335 "newer other project session", 3336 model_provider, 3337 &other_cwd, 3338 )?; 3339 3340 let mut app_server = AppServerSession::new( 3341 codex_app_server_client::AppServerClient::InProcess( 3342 start_test_embedded_app_server(config.clone()).await?, 3343 ), 3344 ThreadParamsMode::Embedded, 3345 ); 3346 let filter_cwd = latest_session_cwd_filter( 3347 /*uses_remote_workspace*/ false, /*remote_cwd_override*/ None, &config, 3348 /*show_all*/ false, 3349 ); 3350 let disabled_target = lookup_latest_session_target_with_app_server( 3351 /*uses_remote_filesystem*/ false, 3352 &mut app_server, 3353 &config, 3354 filter_cwd, 3355 /*include_non_interactive*/ false, 3356 ) 3357 .await? 3358 .expect("expected current-checkout target with worktrees disabled"); 3359 config 3360 .features 3361 .set_enabled(codex_features::Feature::Worktrees, /*enabled*/ true)?; 3362 let filter_cwd = latest_session_cwd_filter( 3363 /*uses_remote_workspace*/ false, /*remote_cwd_override*/ None, &config, 3364 /*show_all*/ false, 3365 ); 3366 let scoped_target = lookup_latest_session_target_with_app_server( 3367 /*uses_remote_filesystem*/ false, 3368 &mut app_server, 3369 &config, 3370 filter_cwd, 3371 /*include_non_interactive*/ false, 3372 ) 3373 .await? 3374 .expect("expected project-scoped fork --last target"); 3375 let show_all_filter_cwd = latest_session_cwd_filter( 3376 /*uses_remote_workspace*/ false, /*remote_cwd_override*/ None, &config, 3377 /*show_all*/ true, 3378 ); 3379 let show_all_target = lookup_latest_session_target_with_app_server( 3380 /*uses_remote_filesystem*/ false, 3381 &mut app_server, 3382 &config, 3383 show_all_filter_cwd, 3384 /*include_non_interactive*/ false, 3385 ) 3386 .await? 3387 .expect("expected global fork --last target"); 3388 app_server.shutdown().await?; 3389 3390 assert_eq!(disabled_target.thread_id, project_thread_id); 3391 assert_eq!(scoped_target.thread_id, linked_thread_id); 3392 assert_eq!(show_all_target.thread_id, other_thread_id); 3393 Ok(()) 3394 } 3395 3396 #[tokio::test] 3397 async fn latest_session_lookup_falls_back_for_rollout_missing_from_state_db() 3398 -> color_eyre::Result<()> { 3399 let temp_dir = TempDir::new()?; 3400 let project_cwd = temp_dir.path().join("project"); 3401 std::fs::create_dir_all(&project_cwd)?; 3402 let config = ConfigBuilder::default() 3403 .codex_home(temp_dir.path().to_path_buf()) 3404 .harness_overrides(ConfigOverrides { 3405 cwd: Some(project_cwd.clone()), 3406 ..Default::default() 3407 }) 3408 .build() 3409 .await?; 3410 let mut app_server = AppServerSession::new( 3411 codex_app_server_client::AppServerClient::InProcess( 3412 start_test_embedded_app_server(config.clone()).await?, 3413 ), 3414 ThreadParamsMode::Embedded, 3415 ); 3416 3417 // Simulate a legacy writer creating a rollout after the state DB backfill completed. 3418 let thread_id = write_session_rollout( 3419 temp_dir.path(), 3420 "2025-01-02T10-00-00", 3421 "2025-01-02T10:00:00Z", 3422 "legacy writer session", 3423 config.model_provider_id.as_str(), 3424 &project_cwd, 3425 )?; 3426 3427 let target = lookup_latest_session_target_with_app_server( 3428 /*uses_remote_filesystem*/ false, 3429 &mut app_server, 3430 &config, 3431 Some(project_cwd.as_path()), 3432 /*include_non_interactive*/ false, 3433 ) 3434 .await? 3435 .expect("expected scan-and-repair fallback to find the rollout"); 3436 app_server.shutdown().await?; 3437 3438 assert_eq!(target.thread_id, thread_id); 3439 Ok(()) 3440 } 3441 3442 #[tokio::test] 3443 async fn config_cwd_for_app_server_target_omits_cwd_for_remote_sessions() -> std::io::Result<()> 3444 { 3445 let remote_only_cwd = if cfg!(windows) { 3446 Path::new(r"C:\definitely\not\local\to\this\test") 3447 } else { 3448 Path::new("/definitely/not/local/to/this/test") 3449 }; 3450 let target = AppServerTarget::Remote { 3451 endpoint: RemoteAppServerEndpoint::UnixSocket { 3452 socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?, 3453 }, 3454 }; 3455 let environment_manager = EnvironmentManager::default_for_tests(); 3456 3457 let config_cwd = config_cwd_for_app_server_target( 3458 Some(remote_only_cwd), 3459 &target, 3460 environment_manager 3461 .default_environment() 3462 .is_some_and(|environment| environment.is_remote()), 3463 )?; 3464 3465 assert_eq!(config_cwd, None); 3466 Ok(()) 3467 } 3468 3469 #[tokio::test] 3470 async fn config_cwd_for_app_server_target_canonicalizes_embedded_cli_cwd() -> std::io::Result<()> 3471 { 3472 let temp_dir = TempDir::new()?; 3473 let target = AppServerTarget::Embedded; 3474 let environment_manager = EnvironmentManager::default_for_tests(); 3475 3476 let config_cwd = config_cwd_for_app_server_target( 3477 Some(temp_dir.path()), 3478 &target, 3479 environment_manager 3480 .default_environment() 3481 .is_some_and(|environment| environment.is_remote()), 3482 )?; 3483 3484 assert_eq!( 3485 config_cwd, 3486 Some(AbsolutePathBuf::from_absolute_path(dunce::canonicalize( 3487 temp_dir.path() 3488 )?)?) 3489 ); 3490 Ok(()) 3491 } 3492 3493 #[tokio::test] 3494 async fn config_cwd_for_app_server_target_canonicalizes_local_daemon_cli_cwd() 3495 -> std::io::Result<()> { 3496 let temp_dir = TempDir::new()?; 3497 let target = AppServerTarget::LocalDaemon { 3498 allow_embedded_fallback: true, 3499 endpoint: RemoteAppServerEndpoint::UnixSocket { 3500 socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?, 3501 }, 3502 }; 3503 let environment_manager = EnvironmentManager::default_for_tests(); 3504 3505 let config_cwd = config_cwd_for_app_server_target( 3506 Some(temp_dir.path()), 3507 &target, 3508 environment_manager 3509 .default_environment() 3510 .is_some_and(|environment| environment.is_remote()), 3511 )?; 3512 3513 assert_eq!( 3514 config_cwd, 3515 Some(AbsolutePathBuf::from_absolute_path(dunce::canonicalize( 3516 temp_dir.path() 3517 )?)?) 3518 ); 3519 Ok(()) 3520 } 3521 3522 #[tokio::test] 3523 async fn config_cwd_for_app_server_target_errors_for_missing_embedded_cli_cwd() 3524 -> std::io::Result<()> { 3525 let temp_dir = TempDir::new()?; 3526 let missing = temp_dir.path().join("missing"); 3527 let target = AppServerTarget::Embedded; 3528 let environment_manager = EnvironmentManager::default_for_tests(); 3529 3530 let err = config_cwd_for_app_server_target( 3531 Some(&missing), 3532 &target, 3533 environment_manager 3534 .default_environment() 3535 .is_some_and(|environment| environment.is_remote()), 3536 ) 3537 .expect_err("missing embedded cwd should fail"); 3538 3539 assert_eq!(err.kind(), std::io::ErrorKind::NotFound); 3540 Ok(()) 3541 } 3542 3543 #[tokio::test] 3544 async fn config_cwd_for_app_server_target_omits_cwd_for_remote_exec_server() 3545 -> std::io::Result<()> { 3546 let remote_only_cwd = if cfg!(windows) { 3547 Path::new(r"C:\definitely\not\local\to\this\test") 3548 } else { 3549 Path::new("/definitely/not/local/to/this/test") 3550 }; 3551 let target = AppServerTarget::Embedded; 3552 let environment_manager = EnvironmentManager::create_for_tests( 3553 Some("ws://127.0.0.1:8765".to_string()), 3554 Some(ExecServerRuntimePaths::new( 3555 std::env::current_exe().expect("current exe"), 3556 /*codex_linux_sandbox_exe*/ None, 3557 )?), 3558 ) 3559 .await; 3560 3561 let config_cwd = config_cwd_for_app_server_target( 3562 Some(remote_only_cwd), 3563 &target, 3564 environment_manager 3565 .default_environment() 3566 .is_some_and(|environment| environment.is_remote()), 3567 )?; 3568 3569 assert_eq!(config_cwd, None); 3570 assert!(uses_remote_workspace_or_environment( 3571 &target, 3572 &environment_manager 3573 )); 3574 let local_daemon = AppServerTarget::LocalDaemon { 3575 allow_embedded_fallback: true, 3576 endpoint: RemoteAppServerEndpoint::UnixSocket { 3577 socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?, 3578 }, 3579 }; 3580 assert!(uses_remote_workspace_or_environment( 3581 &local_daemon, 3582 &environment_manager 3583 )); 3584 Ok(()) 3585 } 3586 3587 #[tokio::test] 3588 #[serial] 3589 async fn windows_shows_trust_prompt_without_sandbox() -> std::io::Result<()> { 3590 let temp_dir = TempDir::new()?; 3591 let mut config = build_config(&temp_dir).await?; 3592 config.active_project = ProjectConfig { trust_level: None }; 3593 config.set_windows_sandbox_enabled(/*value*/ false); 3594 3595 let should_show = should_show_trust_screen(&config); 3596 assert!( 3597 should_show, 3598 "Trust prompt should be shown when project trust is undecided" 3599 ); 3600 Ok(()) 3601 } 3602 3603 #[tokio::test] 3604 async fn embedded_app_server_supports_thread_start_rpc() -> color_eyre::Result<()> { 3605 let temp_dir = TempDir::new()?; 3606 let config = build_config(&temp_dir).await?; 3607 let app_server = start_test_embedded_app_server(config).await?; 3608 let response: ThreadStartResponse = app_server 3609 .request_typed(ClientRequest::ThreadStart { 3610 request_id: RequestId::Integer(1), 3611 params: ThreadStartParams { 3612 ephemeral: Some(true), 3613 ..ThreadStartParams::default() 3614 }, 3615 }) 3616 .await 3617 .expect("thread/start should succeed"); 3618 assert!(!response.thread.id.is_empty()); 3619 3620 app_server.shutdown().await?; 3621 Ok(()) 3622 } 3623 3624 #[tokio::test] 3625 async fn resume_picker_loads_complete_paginated_and_legacy_transcripts() 3626 -> color_eyre::Result<()> { 3627 let temp_dir = TempDir::new()?; 3628 let mut config = build_config(&temp_dir).await?; 3629 config.terminal_resize_reflow.max_rows = 3630 crate::legacy_core::config::TerminalResizeReflowMaxRows::Limit(2); 3631 let mut app_server = AppServerSession::new( 3632 AppServerClient::InProcess(start_test_embedded_app_server(config.clone()).await?), 3633 ThreadParamsMode::Embedded, 3634 ); 3635 let filename_ts = "2025-01-05T12-00-00"; 3636 let rollout_line = |ordinal: usize, payload: serde_json::Value| { 3637 serde_json::json!({ 3638 "timestamp": "2025-01-05T12:00:00Z", 3639 "type": "event_msg", 3640 "payload": payload, 3641 "ordinal": ordinal, 3642 }) 3643 }; 3644 for (history_mode, create_rollout) in [ 3645 app_test_support::create_fake_rollout, 3646 app_test_support::create_fake_paginated_rollout, 3647 ] 3648 .into_iter() 3649 .enumerate() 3650 { 3651 let thread_id = create_rollout( 3652 temp_dir.path(), 3653 filename_ts, 3654 "2025-01-05T12:00:00Z", 3655 "message 0", 3656 Some(config.model_provider_id.as_str()), 3657 /*git_info*/ None, 3658 ) 3659 .expect("create session rollout"); 3660 let path = app_test_support::rollout_path(temp_dir.path(), filename_ts, &thread_id); 3661 let mut contents = std::fs::read_to_string(&path)?; 3662 let started = rollout_line( 3663 /*ordinal*/ 3, 3664 serde_json::json!({ "type": "task_started", "turn_id": "history-turn", "model_context_window": null }), 3665 ); 3666 contents.push_str(&format!("{started}\n")); 3667 for index in 0..=100 { 3668 let message = format!("message {index}"); 3669 let payload = if history_mode == 1 { 3670 serde_json::json!({ 3671 "type": "item_completed", 3672 "thread_id": thread_id, 3673 "turn_id": "history-turn", 3674 "item": { "type": "UserMessage", "id": format!("user-{index}"), 3675 "content": [{ "type": "text", "text": message }] }, 3676 }) 3677 } else { 3678 serde_json::json!({ "type": "user_message", "message": message }) 3679 }; 3680 let item = rollout_line(index + 4, payload); 3681 contents.push_str(&format!("{item}\n")); 3682 } 3683 if history_mode == 1 { 3684 for index in 0..125 { 3685 let item = rollout_line( 3686 index + 105, 3687 serde_json::json!({ 3688 "type": "item_completed", 3689 "thread_id": thread_id, 3690 "turn_id": "history-turn", 3691 "item": { "type": "Reasoning", "id": format!("hidden-{index}"), 3692 "summary_text": [], "raw_content": [] }, 3693 }), 3694 ); 3695 contents.push_str(&format!("{item}\n")); 3696 } 3697 } 3698 std::fs::write(path, contents)?; 3699 let thread_id = ThreadId::from_string(&thread_id)?; 3700 let started = app_server 3701 .resume_thread( 3702 &crate::local_settings::LocalSettings::from(&config), 3703 config.clone(), 3704 thread_id, 3705 app_server_session::ResumeModelSettings::RestoreFromThread, 3706 ) 3707 .await?; 3708 if history_mode == 1 { 3709 assert!( 3710 started 3711 .turns 3712 .iter() 3713 .flat_map(|turn| &turn.items) 3714 .any(|item| { 3715 matches!( 3716 item, 3717 codex_app_server_protocol::ThreadItem::UserMessage { .. } 3718 ) 3719 }) 3720 ); 3721 let preview = crate::resume_picker::load_transcript_preview( 3722 &mut app_server, 3723 thread_id, 3724 /*config*/ None, 3725 ) 3726 .await?; 3727 assert!(!preview.is_empty()); 3728 } 3729 let cells = crate::thread_transcript::load_session_transcript( 3730 &mut app_server, 3731 thread_id, 3732 crate::thread_transcript::RawReasoningVisibility::Hidden, 3733 /*config*/ None, 3734 ) 3735 .await?; 3736 assert!(cells.len() > 100); 3737 } 3738 app_server.shutdown().await?; 3739 Ok(()) 3740 } 3741 3742 #[tokio::test] 3743 async fn embedded_app_server_start_failure_is_returned() -> color_eyre::Result<()> { 3744 let temp_dir = TempDir::new()?; 3745 let config = build_config(&temp_dir).await?; 3746 let result = start_embedded_app_server_with( 3747 Arg0DispatchPaths::default(), 3748 config, 3749 Vec::new(), 3750 LoaderOverrides::default(), 3751 /*strict_config*/ false, 3752 CloudConfigBundleLoader::default(), 3753 codex_feedback::CodexFeedback::new(), 3754 /*log_db*/ None, 3755 /*state_db*/ None, 3756 Arc::new(EnvironmentManager::default_for_tests()), 3757 |_args| async { Err(std::io::Error::other("boom")) }, 3758 ) 3759 .await; 3760 let err = match result { 3761 Ok(_) => panic!("startup failure should be returned"), 3762 Err(err) => err, 3763 }; 3764 3765 assert!( 3766 err.to_string() 3767 .contains("failed to start embedded app server"), 3768 "error should preserve the embedded app server startup context" 3769 ); 3770 Ok(()) 3771 } 3772 3773 #[tokio::test] 3774 async fn embedded_state_db_failure_is_typed_for_cli_recovery() -> color_eyre::Result<()> { 3775 let temp_dir = TempDir::new()?; 3776 let mut config = build_config(&temp_dir).await?; 3777 let occupied_sqlite_home = temp_dir.path().join("sqlite-home"); 3778 std::fs::write(&occupied_sqlite_home, "occupied")?; 3779 let sqlite = 3780 codex_state::SqliteConfig::new_for_testing(occupied_sqlite_home.as_path().abs()); 3781 config.sqlite = sqlite.clone(); 3782 3783 let err = 3784 match init_state_db_for_app_server_target(&config, &AppServerTarget::Embedded).await { 3785 Ok(_) => panic!("embedded startup should surface state db init failures"), 3786 Err(err) => err, 3787 }; 3788 let startup_error = err 3789 .get_ref() 3790 .and_then(|err| err.downcast_ref::<LocalStateDbStartupError>()) 3791 .expect("state db startup failure should retain its typed context"); 3792 3793 assert_eq!( 3794 startup_error.state_db_path(), 3795 sqlite.state_db_path().as_path() 3796 ); 3797 assert!( 3798 startup_error 3799 .detail() 3800 .contains("failed to initialize state runtime"), 3801 "startup error should preserve the underlying state db failure" 3802 ); 3803 Ok(()) 3804 } 3805 3806 #[tokio::test] 3807 async fn embedded_state_db_corruption_preserves_failed_database_for_cli_recovery() 3808 -> color_eyre::Result<()> { 3809 let temp_dir = TempDir::new()?; 3810 let mut config = build_config(&temp_dir).await?; 3811 let sqlite_home = temp_dir.path().join("sqlite-home"); 3812 std::fs::create_dir_all(&sqlite_home)?; 3813 let sqlite = codex_state::SqliteConfig::new_for_testing(sqlite_home.as_path().abs()); 3814 let logs_db_path = sqlite.logs_db_path(); 3815 std::fs::write(&logs_db_path, "not a sqlite database")?; 3816 config.sqlite = sqlite; 3817 3818 let err = 3819 match init_state_db_for_app_server_target(&config, &AppServerTarget::Embedded).await { 3820 Ok(_) => panic!("embedded startup should surface state db init failures"), 3821 Err(err) => err, 3822 }; 3823 let startup_error = err 3824 .get_ref() 3825 .and_then(|err| err.downcast_ref::<LocalStateDbStartupError>()) 3826 .expect("state db startup failure should retain its typed context"); 3827 3828 assert_eq!(startup_error.database_path(), logs_db_path.as_path()); 3829 assert!( 3830 codex_state::sqlite_error_detail_is_corruption(startup_error.detail()), 3831 "startup error should preserve the SQLite corruption cause, got: {}", 3832 startup_error.detail() 3833 ); 3834 Ok(()) 3835 } 3836 3837 #[tokio::test] 3838 #[serial] 3839 async fn windows_shows_trust_prompt_with_sandbox() -> std::io::Result<()> { 3840 let temp_dir = TempDir::new()?; 3841 let mut config = build_config(&temp_dir).await?; 3842 config.active_project = ProjectConfig { trust_level: None }; 3843 config.set_windows_sandbox_enabled(/*value*/ true); 3844 3845 let should_show = should_show_trust_screen(&config); 3846 if cfg!(target_os = "windows") { 3847 assert!( 3848 should_show, 3849 "Windows trust prompt should be shown on native Windows with sandbox enabled" 3850 ); 3851 } else { 3852 assert!( 3853 should_show, 3854 "Non-Windows should still show trust prompt when project is untrusted" 3855 ); 3856 } 3857 Ok(()) 3858 } 3859 #[tokio::test] 3860 async fn untrusted_project_skips_trust_prompt() -> std::io::Result<()> { 3861 use codex_protocol::config_types::TrustLevel; 3862 let temp_dir = TempDir::new()?; 3863 let mut config = build_config(&temp_dir).await?; 3864 config.active_project = ProjectConfig { 3865 trust_level: Some(TrustLevel::Untrusted), 3866 }; 3867 3868 let should_show = should_show_trust_screen(&config); 3869 assert!( 3870 !should_show, 3871 "Trust prompt should not be shown for projects explicitly marked as untrusted" 3872 ); 3873 Ok(()) 3874 } 3875 3876 #[tokio::test] 3877 async fn config_rebuild_changes_trust_defaults_with_cwd() -> std::io::Result<()> { 3878 let temp_dir = TempDir::new()?; 3879 let codex_home = temp_dir.path().to_path_buf(); 3880 let trusted = temp_dir.path().join("trusted"); 3881 let untrusted = temp_dir.path().join("untrusted"); 3882 std::fs::create_dir_all(&trusted)?; 3883 std::fs::create_dir_all(&untrusted)?; 3884 3885 // TOML keys need escaped backslashes on Windows paths. 3886 let trusted_display = trusted.display().to_string().replace('\\', "\\\\"); 3887 let untrusted_display = untrusted.display().to_string().replace('\\', "\\\\"); 3888 let config_toml = format!( 3889 r#"[projects."{trusted_display}"] 3890 trust_level = "trusted" 3891 3892 [projects."{untrusted_display}"] 3893 trust_level = "untrusted" 3894 "# 3895 ); 3896 std::fs::write(temp_dir.path().join("config.toml"), config_toml)?; 3897 3898 let trusted_overrides = ConfigOverrides { 3899 cwd: Some(trusted.clone()), 3900 ..Default::default() 3901 }; 3902 let trusted_config = ConfigBuilder::default() 3903 .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) 3904 .codex_home(codex_home.clone()) 3905 .harness_overrides(trusted_overrides.clone()) 3906 .build() 3907 .await?; 3908 assert_eq!( 3909 AskForApproval::from(trusted_config.permissions.approval_policy.value()), 3910 AskForApproval::OnRequest 3911 ); 3912 3913 let untrusted_overrides = ConfigOverrides { 3914 cwd: Some(untrusted), 3915 ..trusted_overrides 3916 }; 3917 let untrusted_config = ConfigBuilder::default() 3918 .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) 3919 .codex_home(codex_home) 3920 .harness_overrides(untrusted_overrides) 3921 .build() 3922 .await?; 3923 assert_eq!( 3924 AskForApproval::from(untrusted_config.permissions.approval_policy.value()), 3925 AskForApproval::UnlessTrusted 3926 ); 3927 Ok(()) 3928 } 3929 3930 /// Regression: theme must be configured from the *final* config. 3931 /// 3932 /// `run_ratatui_app` can reload config during onboarding and again 3933 /// during session resume/fork. The syntax theme override (stored in 3934 /// a `OnceLock`) must use the final config's `tui_theme`, not the 3935 /// initial one — otherwise users resuming a thread in a project with 3936 /// a different theme get the wrong highlighting. 3937 /// 3938 /// We verify the invariant indirectly: `validate_theme_name` (the 3939 /// pure validation core of `set_theme_override`) must be called with 3940 /// the *final* config's theme, and its warning must land in the 3941 /// final config's `startup_warnings`. 3942 #[tokio::test] 3943 async fn theme_warning_uses_final_config() -> std::io::Result<()> { 3944 use crate::render::highlight::validate_theme_name; 3945 3946 let temp_dir = TempDir::new()?; 3947 3948 // initial_config has a valid theme — no warning. 3949 let initial_config = build_config(&temp_dir).await?; 3950 assert!(initial_config.tui_theme.is_none()); 3951 3952 // Simulate resume/fork reload: the final config has an invalid theme. 3953 let mut config = build_config(&temp_dir).await?; 3954 config.tui_theme = Some("bogus-theme".into()); 3955 3956 // Theme override must use the final config (not initial_config). 3957 // This mirrors the real call site in run_ratatui_app. 3958 if let Some(w) = validate_theme_name(config.tui_theme.as_deref(), Some(temp_dir.path())) { 3959 config.startup_warnings.push(w); 3960 } 3961 3962 assert_eq!( 3963 config.startup_warnings.len(), 3964 1, 3965 "warning from final config's invalid theme should be present" 3966 ); 3967 assert!( 3968 config.startup_warnings[0].contains("bogus-theme"), 3969 "warning should reference the final config's theme name" 3970 ); 3971 Ok(()) 3972 } 3973 }