diff --git a/codex-rs/config/src/loader/macos.rs b/codex-rs/config/src/loader/macos.rs index 974cb27604..53c8a6d20a 100644 --- a/codex-rs/config/src/loader/macos.rs +++ b/codex-rs/config/src/loader/macos.rs @@ -34,6 +34,13 @@ pub(super) fn managed_preferences_requirements_source() -> RequirementSource { } } +pub(super) fn has_managed_preferences() -> io::Result { + 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, diff --git a/codex-rs/config/src/loader/mod.rs b/codex-rs/config/src/loader/mod.rs index 244f7df02f..87412119e9 100644 --- a/codex-rs/config/src/loader/mod.rs +++ b/codex-rs/config/src/loader/mod.rs @@ -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 { + 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::from_absolute_path(Path::new(SYSTEM_CONFIG_TOML_FILE_UNIX)) diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 15677259d2..6b110fa64b 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -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, diff --git a/codex-rs/tui/src/snapshots/codex_tui__startup_draft__tests__startup_draft_onboarding_transition.snap b/codex-rs/tui/src/snapshots/codex_tui__startup_draft__tests__startup_draft_onboarding_transition.snap new file mode 100644 index 0000000000..6284b0715e --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__startup_draft__tests__startup_draft_onboarding_transition.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/startup_draft_tests.rs +expression: frames +--- +before onboarding: hidden (0x0 viewport) +--- +after onboarding: +╭───────────────────────────────────────╮ +│ >_ OpenAI Codex (v) │ +│ │ +│ model: loading /model to change │ +│ directory: loading │ +╰───────────────────────────────────────╯ + + +› Ask Codex to do anything + + ? for shortcuts diff --git a/codex-rs/tui/src/startup_draft.rs b/codex-rs/tui/src/startup_draft.rs index 399634e98a..c094adcca5 100644 --- a/codex-rs/tui/src/startup_draft.rs +++ b/codex-rs/tui/src/startup_draft.rs @@ -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) { diff --git a/codex-rs/tui/src/startup_draft_tests.rs b/codex-rs/tui/src/startup_draft_tests.rs index 828ef8572d..9350db9de9 100644 --- a/codex-rs/tui/src/startup_draft_tests.rs +++ b/codex-rs/tui/src/startup_draft_tests.rs @@ -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::() + .trim_end() + .to_string() + }) + .collect::>() + .join("\n") + .replace(crate::version::CODEX_CLI_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( diff --git a/codex-rs/tui/src/startup_orchestration.rs b/codex-rs/tui/src/startup_orchestration.rs index 616851afd1..800d9da8f6 100644 --- a/codex-rs/tui/src/startup_orchestration.rs +++ b/codex-rs/tui/src/startup_orchestration.rs @@ -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)) diff --git a/codex-rs/tui/src/startup_preflight.rs b/codex-rs/tui/src/startup_preflight.rs new file mode 100644 index 0000000000..be6f7508b4 --- /dev/null +++ b/codex-rs/tui/src/startup_preflight.rs @@ -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, + managed_configuration: impl FnOnce() -> io::Result, + environment_variable: impl Fn(&str) -> Option, +) -> 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; diff --git a/codex-rs/tui/src/startup_preflight_tests.rs b/codex-rs/tui/src/startup_preflight_tests.rs new file mode 100644 index 0000000000..100d10f67a --- /dev/null +++ b/codex-rs/tui/src/startup_preflight_tests.rs @@ -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(()) +}