Delay the startup composer until first-login onboarding (#38643)

## Why

On a pristine default installation, the provisional composer can appear before
first-login onboarding takes over the terminal.

## What changed

- Detect pristine local installations conservatively, accounting for existing
  Codex state, custom homes, access tokens, system configuration, and managed
  configuration sources, including macOS managed preferences.
- Keep the composer hidden and ignore draft input until onboarding completes,
  while still allowing startup cancellation with `Ctrl-C` or `Ctrl-D`.
- Reveal the configured composer after onboarding for normal new-session
  launches; resume and fork pickers retain their existing startup flow.

## Testing

- Cover pristine and existing home layouts, environment credentials, system and
  managed configuration, inaccessible state, onboarding input suppression,
  cancellation, and the transition to the composer.

GitOrigin-RevId: c1a47ea3ba7c1ff1dd229895c0362b6dd9bfd713
This commit is contained in:
Charlie Marsh
2026-08-14 20:35:11 +00:00
committed by copyberry
parent a0bed4be21
commit 6d97d4c102
9 changed files with 436 additions and 12 deletions
+7
View File
@@ -34,6 +34,13 @@ pub(super) fn managed_preferences_requirements_source() -> RequirementSource {
}
}
pub(super) fn has_managed_preferences() -> io::Result<bool> {
Ok(
load_managed_preference(MANAGED_PREFERENCES_CONFIG_KEY)?.is_some()
|| load_managed_preference(MANAGED_PREFERENCES_REQUIREMENTS_KEY)?.is_some(),
)
}
pub(crate) async fn load_managed_admin_config_layer(
override_base64: Option<&str>,
strict_config: bool,
+19
View File
@@ -730,6 +730,25 @@ fn system_requirements_toml_file_with_overrides(
}
}
/// Check local managed configuration sources without loading or parsing configuration.
///
/// Filesystem or managed-preference errors are returned so callers can conservatively avoid
/// assuming that administrator-controlled configuration is absent.
pub fn has_local_managed_configuration(codex_home: &Path) -> io::Result<bool> {
if layer_io::managed_config_default_path(codex_home).try_exists()?
|| system_requirements_toml_file()?.as_path().try_exists()?
{
return Ok(true);
}
#[cfg(target_os = "macos")]
if macos::has_managed_preferences()? {
return Ok(true);
}
Ok(false)
}
#[cfg(unix)]
pub fn system_config_toml_file() -> io::Result<AbsolutePathBuf> {
AbsolutePathBuf::from_absolute_path(Path::new(SYSTEM_CONFIG_TOML_FILE_UNIX))
+7
View File
@@ -176,6 +176,7 @@ mod startup_draft;
mod startup_error;
mod startup_hooks_review;
mod startup_orchestration;
mod startup_preflight;
mod status;
mod status_indicator_widget;
mod streaming;
@@ -1194,6 +1195,12 @@ async fn run_ratatui_app(
initial_config
};
startup_draft.apply_config(&config);
if !(cli.resume_picker || cli.fork_picker)
&& let Err(err) = startup_draft.show(&mut tui)
{
shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await;
return Err(err.into());
}
let missing_session_exit =
|id_str: &str,
@@ -0,0 +1,18 @@
---
source: tui/src/startup_draft_tests.rs
expression: frames
---
before onboarding: hidden (0x0 viewport)
---
after onboarding:
╭───────────────────────────────────────╮
│ >_ OpenAI Codex (v<VERSION>) │
│ │
│ model: loading /model to change │
│ directory: loading │
╰───────────────────────────────────────╯
› Ask Codex to do anything
? for shortcuts
+12 -5
View File
@@ -51,6 +51,7 @@ const STARTUP_PASTE_NEWLINE_TIMEOUT: Duration = Duration::from_millis(120);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum StartupDraftInitialScreen {
Composer,
Onboarding,
SessionPicker,
}
@@ -115,9 +116,7 @@ impl StartupDraft {
pending_paste_newline: None,
},
};
if initial_screen == StartupDraftInitialScreen::Composer {
draft.pump.show(&mut draft.tui)?;
}
draft.pump.show_initial_screen(&mut draft.tui)?;
Ok(draft)
}
@@ -230,7 +229,15 @@ impl StartupDraftPump {
self.bottom_pane.composer_draft_snapshot()
}
/// Reveal the editable composer once a requested session picker has finished.
/// Draw the initial composer only when no protected startup screen must appear first.
fn show_initial_screen(&mut self, tui: &mut Tui) -> io::Result<()> {
if self.initial_screen == StartupDraftInitialScreen::Composer {
self.show(tui)?;
}
Ok(())
}
/// Reveal the editable composer once an expected protected screen has finished.
pub(crate) fn show(&mut self, tui: &mut Tui) -> io::Result<()> {
self.initial_screen = StartupDraftInitialScreen::Composer;
self.draw(tui, tui.terminal.last_known_screen_size)
@@ -274,7 +281,7 @@ impl StartupDraftPump {
}
match event {
TuiEvent::Key(key) => {
if self.initial_screen == StartupDraftInitialScreen::SessionPicker
if self.initial_screen != StartupDraftInitialScreen::Composer
&& !key_hint::ctrl(KeyCode::Char('c')).is_press(key)
&& !key_hint::ctrl(KeyCode::Char('d')).is_press(key)
{
+84
View File
@@ -499,6 +499,90 @@ async fn startup_draft_applies_editor_keymap_without_enabling_vim() {
assert_eq!(pump.bottom_pane.composer_cursor(), 0);
}
#[tokio::test]
async fn startup_draft_waits_for_onboarding_before_accepting_input() {
let mut composer_pump = startup_test_pump(std::iter::empty());
let mut composer_tui =
crate::tui::test_support::make_test_tui().expect("create composer test terminal");
composer_pump
.show_initial_screen(&mut composer_tui)
.expect("draw the composer when no protected screen is expected");
assert!(!composer_tui.terminal.viewport_area.is_empty());
drop(composer_tui);
let mut pump = startup_test_pump(
[
TuiEvent::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)),
TuiEvent::Paste("not a draft".to_string()),
TuiEvent::Draw,
]
.into_iter(),
);
pump.initial_screen = StartupDraftInitialScreen::Onboarding;
let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal");
pump.show_initial_screen(&mut tui)
.expect("keep the composer hidden until onboarding finishes");
pump.flush_pending_events(&mut tui)
.await
.expect("ignore input before onboarding owns the screen");
assert!(pump.bottom_pane.composer_is_empty());
assert!(tui.terminal.viewport_area.is_empty());
let hidden_area = tui.terminal.viewport_area;
let mut frames = format!(
"before onboarding: hidden ({}x{} viewport)",
hidden_area.width, hidden_area.height
);
pump.show(&mut tui)
.expect("show the composer after onboarding finishes");
assert!(!tui.terminal.viewport_area.is_empty());
let area = tui.terminal.viewport_area;
let renderable = startup_draft_renderable(&pump.header, &pump.bottom_pane);
let mut buffer = Buffer::empty(area);
renderable.render(area, &mut buffer);
let visible_frame = (area.top()..area.bottom())
.map(|row| {
(area.left()..area.right())
.map(|column| buffer[(column, row)].symbol())
.collect::<String>()
.trim_end()
.to_string()
})
.collect::<Vec<_>>()
.join("\n")
.replace(crate::version::CODEX_CLI_VERSION, "<VERSION>");
drop(renderable);
frames.push_str(&format!("\n---\nafter onboarding:\n{visible_frame}"));
insta::assert_snapshot!("startup_draft_onboarding_transition", frames);
pump.handle_event(
&mut tui,
TuiEvent::Key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)),
)
.expect("edit the composer after onboarding finishes");
pump.bottom_pane.flush_composer_paste_burst();
assert_eq!(pump.bottom_pane.composer_text(), "y");
}
#[tokio::test]
async fn startup_draft_allows_cancellation_before_onboarding_appears() {
for character in ['c', 'd'] {
let mut pump = startup_test_pump(std::iter::once(TuiEvent::Key(KeyEvent::new(
KeyCode::Char(character),
KeyModifiers::CONTROL,
))));
pump.initial_screen = StartupDraftInitialScreen::Onboarding;
let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal");
let error = pump
.flush_pending_events(&mut tui)
.await
.expect_err("cancel startup before onboarding appears");
assert!(super::StartupCancelled::matches(&error));
}
}
#[tokio::test]
async fn startup_draft_waits_for_session_picker_before_accepting_input() {
let mut pump = startup_test_pump(
+19 -7
View File
@@ -135,13 +135,6 @@ pub(super) async fn run_main_inner(
.await;
}
let initial_screen = if cli.resume_picker || cli.fork_picker {
startup_draft::StartupDraftInitialScreen::SessionPicker
} else {
startup_draft::StartupDraftInitialScreen::Composer
};
let mut startup_draft = startup_draft::StartupDraft::new(initial_screen)?;
let reuse_implicit_local_daemon = !workload_identity_selected
&& can_reuse_implicit_local_daemon(
&cli_kv_overrides,
@@ -149,6 +142,25 @@ pub(super) async fn run_main_inner(
strict_config,
cli.bypass_hook_trust,
);
let initial_screen = if cli.resume_picker || cli.fork_picker {
startup_draft::StartupDraftInitialScreen::SessionPicker
} else if !cli.oss
&& explicit_remote_endpoint.is_none()
&& reuse_implicit_local_daemon
&& launch_loader_overrides.packaged_defaults_path.is_none()
&& startup_preflight::should_delay_startup_composer_for_first_login(
&codex_home,
codex_config::loader::system_config_toml_file(),
|| codex_config::loader::has_local_managed_configuration(&codex_home),
|name| std::env::var(name).ok(),
)
{
startup_draft::StartupDraftInitialScreen::Onboarding
} else {
startup_draft::StartupDraftInitialScreen::Composer
};
let mut startup_draft = startup_draft::StartupDraft::new(initial_screen)?;
let default_daemon = if explicit_remote_endpoint.is_none() && reuse_implicit_local_daemon {
startup_draft
.run_until(maybe_probe_default_daemon_socket(&codex_home))
+69
View File
@@ -0,0 +1,69 @@
//! Conservative first-install checks that run before the provisional composer appears.
//!
//! Any existing user, system, daemon, or authentication state keeps the composer visible.
use std::io;
use std::path::Path;
use codex_utils_absolute_path::AbsolutePathBuf;
/// Hide the composer only on a first installation without user or machine-wide configuration.
pub(super) fn should_delay_startup_composer_for_first_login(
codex_home: &Path,
system_config_path: io::Result<AbsolutePathBuf>,
managed_configuration: impl FnOnce() -> io::Result<bool>,
environment_variable: impl Fn(&str) -> Option<String>,
) -> bool {
if environment_variable("CODEX_HOME").is_some_and(|value| !value.is_empty())
|| environment_variable(codex_login::CODEX_ACCESS_TOKEN_ENV_VAR)
.is_some_and(|credential| !credential.trim().is_empty())
{
return false;
}
let Ok(system_config_path) = system_config_path else {
return false;
};
if !matches!(system_config_path.as_path().try_exists(), Ok(false)) {
return false;
}
let pristine_home = match codex_home.try_exists() {
Ok(false) => true,
Err(_) => false,
Ok(true) => {
let Ok(mut entries) = std::fs::read_dir(codex_home) else {
return false;
};
let Some(Ok(temporary_root)) = entries.next() else {
return false;
};
if temporary_root.file_name() != "tmp"
|| !temporary_root
.file_type()
.is_ok_and(|file_type| file_type.is_dir())
|| entries.next().is_some()
{
return false;
}
let Ok(mut entries) = std::fs::read_dir(temporary_root.path()) else {
return false;
};
let Some(Ok(arg0_root)) = entries.next() else {
return false;
};
arg0_root.file_name() == "arg0"
&& arg0_root
.file_type()
.is_ok_and(|file_type| file_type.is_dir())
&& entries.next().is_none()
}
};
pristine_home && matches!(managed_configuration(), Ok(false))
}
#[cfg(test)]
#[path = "startup_preflight_tests.rs"]
mod tests;
+201
View File
@@ -0,0 +1,201 @@
use codex_utils_absolute_path::AbsolutePathBuf;
use tempfile::TempDir;
use super::should_delay_startup_composer_for_first_login;
#[test]
fn startup_delays_composer_only_for_pristine_default_homes() -> std::io::Result<()> {
let temporary_directory = TempDir::new()?;
let codex_home = temporary_directory.path().join("codex-home");
let system_config_path =
AbsolutePathBuf::from_absolute_path(temporary_directory.path().join("system.toml"))?;
assert!(should_delay_startup_composer_for_first_login(
&codex_home,
Ok(system_config_path.clone()),
|| Ok(false),
|_| None,
));
assert!(should_delay_startup_composer_for_first_login(
&codex_home,
Ok(system_config_path.clone()),
|| Ok(false),
|name| (name == codex_login::CODEX_ACCESS_TOKEN_ENV_VAR).then(|| " ".to_string()),
));
std::fs::create_dir_all(codex_home.join("tmp").join("arg0"))?;
assert!(should_delay_startup_composer_for_first_login(
&codex_home,
Ok(system_config_path.clone()),
|| Ok(false),
|_| None,
));
let helper_directory = codex_home
.join("tmp")
.join("arg0")
.join("codex-arg0-session");
std::fs::create_dir(&helper_directory)?;
std::fs::write(helper_directory.join("apply_patch"), "")?;
assert!(should_delay_startup_composer_for_first_login(
&codex_home,
Ok(system_config_path.clone()),
|| Ok(false),
|_| None,
));
assert!(!should_delay_startup_composer_for_first_login(
&codex_home,
Ok(system_config_path.clone()),
|| Ok(false),
|name| (name == "CODEX_HOME").then(|| "/custom/home".to_string()),
));
assert!(!should_delay_startup_composer_for_first_login(
&codex_home,
Ok(system_config_path.clone()),
|| Ok(false),
|name| {
(name == codex_login::CODEX_ACCESS_TOKEN_ENV_VAR).then(|| "access-token".to_string())
},
));
for disabled_credential in [
codex_login::OPENAI_API_KEY_ENV_VAR,
codex_login::CODEX_API_KEY_ENV_VAR,
] {
assert!(should_delay_startup_composer_for_first_login(
&codex_home,
Ok(system_config_path.clone()),
|| Ok(false),
|name| (name == disabled_credential).then(|| "disabled-key".to_string()),
));
}
for existing_state in ["auth.json", "config.toml", "history.jsonl", "sessions"] {
let state_path = codex_home.join(existing_state);
std::fs::write(&state_path, "")?;
assert!(!should_delay_startup_composer_for_first_login(
&codex_home,
Ok(system_config_path.clone()),
|| Ok(false),
|_| None,
));
std::fs::remove_file(state_path)?;
}
let daemon_directory = codex_home.join("app-server-control");
std::fs::create_dir(&daemon_directory)?;
std::fs::write(daemon_directory.join("app-server-control.sock"), "")?;
assert!(!should_delay_startup_composer_for_first_login(
&codex_home,
Ok(system_config_path.clone()),
|| panic!("existing homes should not probe managed configuration"),
|_| None,
));
std::fs::remove_file(daemon_directory.join("app-server-control.sock"))?;
std::fs::remove_dir(daemon_directory)?;
let additional_temporary_state = codex_home.join("tmp").join("other");
std::fs::write(&additional_temporary_state, "")?;
assert!(!should_delay_startup_composer_for_first_login(
&codex_home,
Ok(system_config_path.clone()),
|| Ok(false),
|_| None,
));
let invalid_home = temporary_directory.path().join("invalid-home");
std::fs::write(&invalid_home, "not a directory")?;
assert!(!should_delay_startup_composer_for_first_login(
&invalid_home,
Ok(system_config_path),
|| Ok(false),
|_| None,
));
Ok(())
}
#[test]
fn startup_keeps_composer_when_home_state_cannot_be_confirmed() -> std::io::Result<()> {
let temporary_directory = TempDir::new()?;
let codex_home = temporary_directory.path().join("codex-home");
let system_config_path =
AbsolutePathBuf::from_absolute_path(temporary_directory.path().join("system.toml"))?;
std::fs::create_dir(&codex_home)?;
assert!(!should_delay_startup_composer_for_first_login(
&codex_home,
Ok(system_config_path.clone()),
|| Ok(false),
|_| None,
));
std::fs::write(codex_home.join("tmp"), "not a directory")?;
assert!(!should_delay_startup_composer_for_first_login(
&codex_home,
Ok(system_config_path),
|| Ok(false),
|_| None,
));
Ok(())
}
#[test]
fn startup_keeps_composer_when_system_configuration_is_possible() -> std::io::Result<()> {
let temporary_directory = TempDir::new()?;
let codex_home = temporary_directory.path().join("codex-home");
let system_config_path =
AbsolutePathBuf::from_absolute_path(temporary_directory.path().join("system.toml"))?;
assert!(!should_delay_startup_composer_for_first_login(
&codex_home,
Err(std::io::Error::other(
"system configuration path is unavailable"
)),
|| Ok(false),
|_| None,
));
#[cfg(unix)]
{
let blocking_parent = temporary_directory.path().join("blocking-parent");
std::fs::write(&blocking_parent, "not a directory")?;
assert!(!should_delay_startup_composer_for_first_login(
&codex_home,
AbsolutePathBuf::from_absolute_path(blocking_parent.join("system.toml")),
|| Ok(false),
|_| None,
));
}
std::fs::write(system_config_path.as_path(), "model_provider = 'local'")?;
assert!(!should_delay_startup_composer_for_first_login(
&codex_home,
Ok(system_config_path),
|| Ok(false),
|_| None,
));
Ok(())
}
#[test]
fn startup_keeps_composer_when_managed_configuration_is_possible() -> std::io::Result<()> {
let temporary_directory = TempDir::new()?;
let codex_home = temporary_directory.path().join("codex-home");
let system_config_path =
AbsolutePathBuf::from_absolute_path(temporary_directory.path().join("system.toml"))?;
assert!(!should_delay_startup_composer_for_first_login(
&codex_home,
Ok(system_config_path.clone()),
|| Ok(true),
|_| None,
));
assert!(!should_delay_startup_composer_for_first_login(
&codex_home,
Ok(system_config_path),
|| Err(std::io::Error::other(
"managed configuration is inaccessible"
)),
|_| None,
));
Ok(())
}