docs/audio-source-snapshots/codex-rs/tui/src/lib.rs

Текстовый файл из учебного комплекта, не свидетельство установленного 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 }