diff --git a/codex-rs/tui/src/app/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs index 5fb53fdc8c..3613314e65 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -306,6 +306,7 @@ impl App { self.agents_overview.usage_disabled = false; self.repaint_agents_overview(); self.chat_widget.cyber_policy_notice = Default::default(); + self.chat_widget.invalidate_security_setup(); if let Some(crate::pager_overlay::Overlay::Analytics(view)) = &mut self.overlay { view.refresh(); } @@ -340,6 +341,12 @@ impl App { has_codex_backend_auth, ); if self.chat_widget.has_chatgpt_account() { + crate::security_setup::prefetch( + &self.config, + app_server_client, + self.app_event_tx.clone(), + self.chat_widget.security_setup_request_id, + ); crate::daybreak::prefetch_notice( &self.config, app_server_client, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 4a97366d41..ae51fc91be 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1236,6 +1236,12 @@ impl App { elicitation_target: None, }); } + AppEvent::SecuritySetupLoaded { request_id, identity, notice } => { + tracing::debug!(current = request_id == self.chat_widget.security_setup_request_id, "handling security setup notice"); + if request_id == self.chat_widget.security_setup_request_id { + self.chat_widget.show_security_setup(identity, notice); + } + } AppEvent::OpenUrlInBrowser { url } => { self.open_url_in_browser(url); } diff --git a/codex-rs/tui/src/app/reconnect.rs b/codex-rs/tui/src/app/reconnect.rs index bf07b73720..513d01fb2b 100644 --- a/codex-rs/tui/src/app/reconnect.rs +++ b/codex-rs/tui/src/app/reconnect.rs @@ -478,6 +478,12 @@ impl App { matches!(bootstrap.auth_mode, Some(TelemetryAuthMode::Chatgpt)), ); if self.chat_widget.has_chatgpt_account() { + crate::security_setup::prefetch( + &self.config, + app_server, + self.app_event_tx.clone(), + self.chat_widget.security_setup_request_id, + ); crate::daybreak::prefetch_notice( &self.config, app_server, diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 6e73c002e4..ea02b08dfe 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -519,6 +519,7 @@ impl App { AppServerTarget::LocalDaemon { .. } )); chat_widget.inherit_backend_banner_state(&mut self.chat_widget); + chat_widget.inherit_security_setup(&mut self.chat_widget); for (thread_id, entry) in self.agent_navigation.ordered_threads() { chat_widget.set_collab_agent_metadata( thread_id, diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index f9da39c26c..5d2ad0c09f 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -985,6 +985,12 @@ See the Codex keymap documentation for supported actions and examples." // already has data and available reset credits can be surfaced, without // delaying the initial frame render. if requires_openai_auth && has_chatgpt_account { + crate::security_setup::prefetch( + &app.config, + &app_server, + app.app_event_tx.clone(), + app.chat_widget.security_setup_request_id, + ); crate::daybreak::prefetch_notice( &app.config, &app_server, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index a25fcabd69..2d4031e67b 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -4,6 +4,8 @@ mod daybreak_tests; #[path = "tests/math_interruption_tests.rs"] mod math_interruption_tests; +#[path = "tests/security_setup_tests.rs"] +mod security_setup_tests; #[path = "tests/advanced_reasoning_tests.rs"] mod advanced_reasoning_tests; diff --git a/codex-rs/tui/src/app/tests/security_setup_tests.rs b/codex-rs/tui/src/app/tests/security_setup_tests.rs new file mode 100644 index 0000000000..b2e8bf8f1d --- /dev/null +++ b/codex-rs/tui/src/app/tests/security_setup_tests.rs @@ -0,0 +1,242 @@ +//! Exercises the authenticated reminder transport against a local HTTP fixture. +use super::disconnect::serve_reconnect_requests; +use super::*; +use crate::app_server_session::ThreadParamsMode; +use crate::security_setup::Identity; +use app_test_support::ChatGptAuthFixture; +use app_test_support::write_chatgpt_auth; +use codex_app_server_client::AppServerEvent; +use codex_app_server_protocol::AccountUpdatedNotification; +use codex_app_server_protocol::AuthMode; +use codex_login::AuthCredentialsStoreMode; +use pretty_assertions::assert_eq; +use serde_json::json; +use tokio::net::TcpListener; + +#[tokio::test] +async fn security_setup_fetch_with_default_features_uses_authenticated_codex_endpoint() -> Result<()> +{ + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + let backend = wiremock::MockServer::start().await; + app.config.chatgpt_base_url = backend.uri(); + app.config.cli_auth_credentials_store_mode = AuthCredentialsStoreMode::File; + std::fs::write( + app.config.codex_home.join("config.toml"), + format!("chatgpt_base_url = {:?}\n", backend.uri()), + )?; + write_chatgpt_auth( + &app.config.codex_home, + ChatGptAuthFixture::new("test-token") + .account_id("account") + .chatgpt_user_id("user"), + AuthCredentialsStoreMode::File, + ) + .expect("write synthetic auth"); + app_test_support::mount_workspace_routing(&backend).await; + let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + wiremock::Mock::given(wiremock::matchers::path("/wham/security-setup")) + .and(wiremock::matchers::header( + "authorization", + "Bearer test-token", + )) + .and(wiremock::matchers::header("ChatGPT-Account-ID", "account")) + .and(wiremock::matchers::header( + "User-Agent", + codex_login::default_client::get_codex_user_agent(), + )) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!({ + "notice": { + "title": "Keep using Daybreak mode", "description": "Set up security.", + "action": {"label": "Set up security", "url": "https://chatgpt.com/cyber"} + } + }))) + .expect(3) + .mount(&backend) + .await; + let request_id = app.chat_widget.security_setup_request_id; + let mut tui = crate::tui::test_support::make_test_tui()?; + let init = app.chatwidget_init_for_forked_or_resumed_thread( + &mut tui, + app.config.clone(), + /*initial_user_message*/ None, + ); + app.replace_chat_widget(ChatWidget::new_with_app_event(init)); + assert_eq!(app.chat_widget.security_setup_request_id, request_id); + crate::security_setup::prefetch(&app.config, &server, app.app_event_tx.clone(), request_id); + let notice = tokio::time::timeout(Duration::from_secs(5), async { + loop { + if let AppEvent::SecuritySetupLoaded { + request_id: actual, + identity, + notice, + } = events.recv().await.unwrap() + { + assert_eq!(actual, request_id); + assert_eq!( + identity, + Identity { + account: "account".into(), + user: "user".into() + } + ); + break notice; + } + } + }) + .await?; + assert!(notice.valid()); + let identity = Identity { + account: "account".into(), + user: "user".into(), + }; + app.handle_event( + &mut tui, + &mut server, + AppEvent::SecuritySetupLoaded { + request_id, + identity: identity.clone(), + notice: notice.clone(), + }, + ) + .await?; + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 70).contains("Set up security")); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 70).contains("Set up security")); + + // Reconnect can emit AccountUpdated for the same identity more than once. + for _ in 0..2 { + let stale_request_id = app.chat_widget.security_setup_request_id; + app.handle_app_server_event( + &server, + AppServerEvent::ServerNotification(Box::new(ServerNotification::AccountUpdated( + AccountUpdatedNotification { + auth_mode: Some(AuthMode::Chatgpt), + plan_type: None, + }, + ))), + ) + .await; + let request_id = app.chat_widget.security_setup_request_id; + assert_ne!(request_id, stale_request_id); + app.handle_event( + &mut tui, + &mut server, + AppEvent::SecuritySetupLoaded { + request_id: stale_request_id, + identity: Identity { + account: "stale-account".into(), + ..identity.clone() + }, + notice: notice.clone(), + }, + ) + .await?; + assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 70).contains("Set up security")); + + let init = app.chatwidget_init_for_forked_or_resumed_thread( + &mut tui, + app.config.clone(), + /*initial_user_message*/ None, + ); + app.replace_chat_widget(ChatWidget::new_with_app_event(init)); + let event = tokio::time::timeout(Duration::from_secs(5), async { + loop { + let event = events.recv().await.unwrap(); + if let AppEvent::SecuritySetupLoaded { + request_id: actual, + identity: actual_identity, + .. + } = &event + { + assert_eq!(*actual, request_id); + assert_eq!(actual_identity, &identity); + break event; + } + } + }) + .await?; + app.handle_event(&mut tui, &mut server, event).await?; + assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 70).contains("Set up security")); + } + backend.verify().await; + server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn security_setup_skips_fetch_when_server_auth_does_not_match_saved_login() -> Result<()> { + for (auth_method, auth_token) in [ + ( + Some(AuthMode::ChatgptAuthTokens), + Some("other-account-token"), + ), + (Some(AuthMode::ChatgptAuthTokens), Some("saved-token")), + (Some(AuthMode::Chatgpt), Some("other-account-token")), + (Some(AuthMode::Chatgpt), None), + (None, None), + ] { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + let backend = wiremock::MockServer::start().await; + app.config.chatgpt_base_url = backend.uri(); + app.config.cli_auth_credentials_store_mode = AuthCredentialsStoreMode::File; + write_chatgpt_auth( + &app.config.codex_home, + ChatGptAuthFixture::new("saved-token") + .account_id("saved-account") + .chatgpt_user_id("saved-user"), + AuthCredentialsStoreMode::File, + ) + .expect("write synthetic auth"); + wiremock::Mock::given(wiremock::matchers::path("/wham/security-setup")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!({ + "notice": { + "title": "Keep using Daybreak mode", "description": "Set up security.", + "action": {"label": "Set up security", "url": "https://chatgpt.com/cyber"} + } + }))) + .expect(0) + .mount(&backend) + .await; + + let listener = TcpListener::bind("127.0.0.1:0").await?; + let endpoint = crate::resolve_remote_addr(&format!("ws://{}", listener.local_addr()?))?; + let daemon = tokio::spawn(async move { + let (stream, _) = listener.accept().await?; + serve_reconnect_requests(tokio_tungstenite::accept_async(stream).await?, |request| { + assert_eq!(request.method, "getAuthStatus"); + assert_eq!( + request.params, + Some(json!({"includeToken": true, "refreshToken": false})) + ); + std::future::ready(Some(json!({"result": { + "authMethod": auth_method, "authToken": auth_token, + "requiresOpenaiAuth": true + }}))) + }) + .await + }); + let server = AppServerSession::new( + crate::connect_remote_app_server(endpoint).await?, + ThreadParamsMode::Embedded, + ); + let (tx, mut events) = mpsc::unbounded_channel(); + crate::security_setup::prefetch( + &app.config, + &server, + AppEventSender::new(tx), + app.chat_widget.security_setup_request_id, + ); + // The fetch owns the only sender, so channel closure proves it completed. + assert!( + tokio::time::timeout(Duration::from_secs(5), events.recv()) + .await? + .is_none() + ); + backend.verify().await; + server.shutdown().await?; + let methods = daemon.await??; + assert!(methods.iter().any(|method| method == "getAuthStatus")); + } + Ok(()) +} diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 982c3ecb5a..0d160fd8f5 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -277,6 +277,11 @@ pub(crate) struct AgentsOverviewThreadRefresh { #[allow(clippy::large_enum_variant)] #[derive(Debug, IntoStaticStr)] pub(crate) enum AppEvent { + SecuritySetupLoaded { + request_id: uuid::Uuid, + identity: crate::security_setup::Identity, + notice: crate::security_setup::Notice, + }, OpenDaemonMenu, ConfirmDaemonUpdate(crate::update_action::DaemonUpdateSource), RunDaemonUpdate(crate::update_action::DaemonUpdateSource), diff --git a/codex-rs/tui/src/bottom_pane/actionable_banner.rs b/codex-rs/tui/src/bottom_pane/actionable_banner.rs index 5bcdbfd8f7..a7d7c327da 100644 --- a/codex-rs/tui/src/bottom_pane/actionable_banner.rs +++ b/codex-rs/tui/src/bottom_pane/actionable_banner.rs @@ -159,6 +159,16 @@ impl From for SelectionViewParams { } impl BottomPane { + pub(crate) fn transfer_inline_banner_from(&mut self, previous: &mut Self) { + self.inline_banner = previous.inline_banner.take(); + if let Some(banner) = &mut self.inline_banner + && let InlineBannerContent::Actions(view) = &mut banner.content + { + view.app_event_tx = self.app_event_tx.clone(); + } + self.request_redraw(); + } + pub(crate) fn show_actionable_banner(&mut self, banner: ActionableBanner) { self.show_selection_view(banner.into()); } diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 575a7ea332..59603894b4 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -304,7 +304,7 @@ pub(crate) struct ListSelectionView { state: ScrollState, completion: Option, pub(super) dismiss_after_child_accept: bool, - app_event_tx: AppEventSender, + pub(super) app_event_tx: AppEventSender, is_searchable: bool, search_query: String, search_placeholder: Option, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index eb9c9cdfa2..15778d90e5 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -336,6 +336,7 @@ mod backend_banners; mod compaction; mod luna_reserve_model; mod luna_reserve_return; +mod security_setup; pub(crate) use backend_banners::AutomaticModelSwitchReason; mod protocol; mod protocol_requests; @@ -573,6 +574,10 @@ pub(crate) struct ChatWidget { clock_format: crate::clock_format::ClockFormat, usage_notice_state: usage_notice::UsageNoticeState, backend_banner_state: backend_banners::BackendBannerState, + pub(crate) security_setup_request_id: uuid::Uuid, + security_setup_presented: bool, + security_setup_identity: Option, + security_setup_dismissed: bool, automatic_model_switch_state: backend_banners::AutomaticModelSwitchState, backend_banner_notice_model: Option, // Remember the account's Reserve entry notice across chats and transient banner refreshes. diff --git a/codex-rs/tui/src/chatwidget/backend_banners.rs b/codex-rs/tui/src/chatwidget/backend_banners.rs index 757d2f9ff9..e769319de7 100644 --- a/codex-rs/tui/src/chatwidget/backend_banners.rs +++ b/codex-rs/tui/src/chatwidget/backend_banners.rs @@ -376,9 +376,11 @@ impl ChatWidget { } fn observe_backend_banner_view(&mut self) { - let (shown, dismissed) = self.bottom_pane.inline_banner_lifecycle(); - self.backend_banner_state.shown |= shown; - self.backend_banner_state.dismissed |= dismissed; + if self.backend_banner_state.presented.is_some() { + let (shown, dismissed) = self.bottom_pane.inline_banner_lifecycle(); + self.backend_banner_state.shown |= shown; + self.backend_banner_state.dismissed |= dismissed; + } self.backend_banner_state.dismissed |= self .backend_banner_state .picker_dismissed @@ -441,6 +443,7 @@ impl ChatWidget { } self.bottom_pane .dismiss_view_by_id(LUNA_RESERVE_RECOVERY_VIEW_ID); + self.clear_security_setup_banner(); match (is_reserve, content) { (true, Some(content)) => { self.bottom_pane.set_inline_banner(/*banner*/ None); @@ -510,6 +513,7 @@ impl ChatWidget { } pub(crate) fn clear_backend_banner(&mut self) { + self.clear_security_setup_banner(); self.backend_banner_state = BackendBannerState::default(); self.backend_banner_notice_model = None; self.bottom_pane diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index fb4575ab0d..755da1e626 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -164,6 +164,10 @@ impl ChatWidget { clock_format: crate::clock_format::ClockFormat::system(), usage_notice_state: usage_notice::UsageNoticeState::default(), backend_banner_state: backend_banners::BackendBannerState::default(), + security_setup_request_id: uuid::Uuid::new_v4(), + security_setup_presented: false, + security_setup_identity: None, + security_setup_dismissed: false, automatic_model_switch_state: backend_banners::AutomaticModelSwitchState::default(), backend_banner_notice_model: None, luna_reserve_notice_account_id: None, diff --git a/codex-rs/tui/src/chatwidget/security_setup.rs b/codex-rs/tui/src/chatwidget/security_setup.rs new file mode 100644 index 0000000000..e001f07e1b --- /dev/null +++ b/codex-rs/tui/src/chatwidget/security_setup.rs @@ -0,0 +1,80 @@ +//! Presents the optional setup reminder without displacing account usage controls. +//! Dismissal survives banner replacement and account notifications until a different identity is verified. +use super::ChatWidget; +use crate::app_event::AppEvent; +use crate::bottom_pane::ActionableBanner; +use crate::bottom_pane::SelectionItem; +use crate::security_setup::Identity; +use crate::security_setup::Notice; + +impl ChatWidget { + pub(crate) fn inherit_security_setup(&mut self, previous: &mut Self) { + // Startup and thread navigation replace the widget while the request is in flight. + // Preserve the occurrence, including its rendered/dismissed state. + self.security_setup_request_id = previous.security_setup_request_id; + self.security_setup_identity = previous.security_setup_identity.clone(); + self.security_setup_dismissed = previous.security_setup_dismissed + || (previous.security_setup_presented + && previous.bottom_pane.inline_banner_lifecycle().1); + if previous.security_setup_presented && !self.has_applicable_backend_banner() { + self.bottom_pane + .transfer_inline_banner_from(&mut previous.bottom_pane); + self.security_setup_presented = true; + previous.security_setup_presented = false; + } + } + + pub(crate) fn invalidate_security_setup(&mut self) { + // AccountUpdated can announce the same account after reconnect. Hide stale content + // and reject pending results, but retain dismissal until we verify the identity. + self.security_setup_request_id = uuid::Uuid::new_v4(); + self.clear_security_setup_banner(); + } + + pub(super) fn clear_security_setup_banner(&mut self) { + // Capture this notice's dismissal before the shared slot is cleared or replaced. + // Once another banner owns the slot, its lifecycle must not affect this notice. + if self.security_setup_presented { + self.security_setup_dismissed |= self.bottom_pane.inline_banner_lifecycle().1; + self.bottom_pane.set_inline_banner(/*banner*/ None); + self.security_setup_presented = false; + } + } + + pub(crate) fn show_security_setup(&mut self, identity: Identity, notice: Notice) { + if !notice.valid() { + return; + } + if self.security_setup_identity.as_ref() != Some(&identity) { + self.invalidate_security_setup(); + self.security_setup_identity = Some(identity); + self.security_setup_dismissed = false; + } + // Reconnect fetches must preserve the current occurrence, including Esc dismissal. + if self.security_setup_presented || self.security_setup_dismissed { + return; + } + tracing::debug!( + has_backend_banner = self.has_applicable_backend_banner(), + "presenting security setup notice" + ); + if self.has_applicable_backend_banner() { + return; + } + self.security_setup_presented = true; + self.bottom_pane.set_inline_banner(Some(ActionableBanner { + title: notice.title, + description: notice.description, + actions: vec![SelectionItem { + name: notice.action.label, + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenUrlInBrowser { + url: notice.action.url.clone(), + }) + })], + ..Default::default() + }], + ..Default::default() + })); + } +} diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 3dabfdc058..6d42ca5e5d 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -321,3 +321,6 @@ mod list_spacing_tests; #[path = "tests/question_notifications_tests.rs"] mod question_notifications_tests; + +#[path = "tests/security_setup_tests.rs"] +mod security_setup_tests; diff --git a/codex-rs/tui/src/chatwidget/tests/security_setup_tests.rs b/codex-rs/tui/src/chatwidget/tests/security_setup_tests.rs new file mode 100644 index 0000000000..b6fadbf144 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/security_setup_tests.rs @@ -0,0 +1,190 @@ +use super::*; +use crate::security_setup::Identity; +use crate::security_setup::Notice; +use serde_json::json; + +fn setup_notice() -> Notice { + serde_json::from_value(json!({ + "title":"Keep using Daybreak mode", + "description":"Set up Advanced Account Security with a hardware security key by October 1. Already Persona-verified? Add your key before October 15 to skip re-verification.", + "action":{"label":"Set up security","url":"https://chatgpt.com/cyber"} + })) + .unwrap() +} + +fn setup_identity() -> Identity { + Identity { + account: "account".into(), + user: "user".into(), + } +} + +#[tokio::test] +async fn security_setup_preserves_draft_action_and_dismissal_across_widgets() { + let (mut chat, _rx, _ops) = make_chatwidget_manual(Some("test-model-a")).await; + chat.show_security_setup(setup_identity(), setup_notice()); + let rendered = normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 70)); + insta::assert_snapshot!("security_setup_banner", rendered); + let (mut replacement, mut events, _ops) = make_chatwidget_manual(Some("test-model-a")).await; + chat.app_event_tx + .voice_only + .store(/*val*/ true, std::sync::atomic::Ordering::Relaxed); + replacement.inherit_security_setup(&mut chat); + chat = replacement; + assert!(render_bottom_popup(&chat, /*width*/ 70).contains("Set up security")); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); + assert!( + matches!(events.try_recv(), Ok(AppEvent::OpenUrlInBrowser { url }) if url == "https://chatgpt.com/cyber") + ); + assert!(render_bottom_popup(&chat, /*width*/ 70).contains("Set up security")); + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(!render_bottom_popup(&chat, /*width*/ 70).contains("Set up security")); + + let (mut replacement, _events, _ops) = make_chatwidget_manual(Some("test-model-a")).await; + replacement.inherit_security_setup(&mut chat); + replacement.show_security_setup(setup_identity(), setup_notice()); + assert_eq!( + replacement.bottom_pane.inline_banner_lifecycle(), + (true, true) + ); + assert!(!render_bottom_popup(&replacement, /*width*/ 70).contains("Set up security")); + + replacement.invalidate_security_setup(); + replacement.show_security_setup(setup_identity(), setup_notice()); + assert!(!render_bottom_popup(&replacement, /*width*/ 70).contains("Set up security")); + + // A verified change of account starts a new notice occurrence. + replacement.invalidate_security_setup(); + replacement.show_security_setup( + Identity { + account: "other-account".into(), + ..setup_identity() + }, + setup_notice(), + ); + assert!(render_bottom_popup(&replacement, /*width*/ 70).contains("Set up security")); +} + +#[tokio::test] +async fn security_setup_dismissal_survives_usage_banner_replacement() { + for dismiss_security in [true, false] { + let (mut chat, _events, _ops) = make_chatwidget_manual(Some("test-model-a")).await; + chat.show_security_setup(setup_identity(), setup_notice()); + assert!(render_bottom_popup(&chat, /*width*/ 70).contains("Set up security")); + if dismiss_security { + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + } + + let response = codex_app_server_protocol::GetAccountRateLimitsResponse { + ordinary_usage_allowed: None, + account_id: Some("account".into()), + rate_limit_upsell: Some(json!({ + "banner_type": "selected_model_limit", "model_slug": "test-model-a", + "title": "Selected model usage exhausted", "description": "Switch models.", + "presentation": "dismissible", "ctas": [], + })), + rate_limits: snapshot(/*percent*/ 100.0), + rate_limits_by_limit_id: None, + rate_limit_reset_credits: None, + }; + chat.update_backend_banner(&response); + assert!( + render_bottom_popup(&chat, /*width*/ 70).contains("Selected model usage exhausted") + ); + if !dismiss_security { + // Dismissing usage must not dismiss the security reminder instead. + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + } + + // Account refresh must not read the replacement banner's dismissal state. + chat.invalidate_security_setup(); + chat.clear_backend_banner(); + chat.show_security_setup(setup_identity(), setup_notice()); + let rendered = normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 70)); + assert_eq!(rendered.contains("Set up security"), !dismiss_security); + if dismiss_security { + insta::assert_snapshot!("security_setup_dismissed_after_usage", rendered); + } + + let (mut replacement, _events, _ops) = make_chatwidget_manual(Some("test-model-a")).await; + replacement.inherit_security_setup(&mut chat); + replacement.invalidate_security_setup(); + replacement.show_security_setup(setup_identity(), setup_notice()); + assert_eq!( + render_bottom_popup(&replacement, /*width*/ 70).contains("Set up security"), + !dismiss_security + ); + + // A fresh CLI run has no inherited dismissal and reminds the same eligible user. + let (mut fresh, _events, _ops) = make_chatwidget_manual(Some("test-model-a")).await; + fresh.show_security_setup(setup_identity(), setup_notice()); + assert!(render_bottom_popup(&fresh, /*width*/ 70).contains("Set up security")); + } +} + +#[tokio::test] +async fn security_setup_after_cutoff_wraps_in_narrow_terminal() { + let (mut chat, _events, _ops) = make_chatwidget_manual(Some("test-model-a")).await; + let mut notice = setup_notice(); + notice.title = "Set up security for Daybreak mode".into(); + notice.description = "Set up Advanced Account Security with a hardware security key. Already Persona-verified? Add your key before October 15 to skip re-verification. You can keep using Codex while you finish setup.".into(); + chat.show_security_setup(setup_identity(), notice); + insta::assert_snapshot!( + "security_setup_after_cutoff", + normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 48)) + ); +} + +#[tokio::test] +async fn security_setup_after_persona_grace_wraps_in_narrow_terminal() { + let (mut chat, _events, _ops) = make_chatwidget_manual(Some("test-model-a")).await; + let mut notice = setup_notice(); + notice.title = "Set up security for Daybreak mode".into(); + notice.description = "Set up Advanced Account Security with a hardware security key. Verify each new hardware security key with Persona. You can keep using Codex while you finish setup.".into(); + chat.show_security_setup(setup_identity(), notice); + insta::assert_snapshot!( + "security_setup_after_persona_grace", + normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 48)) + ); +} + +#[tokio::test] +async fn security_setup_respects_applicable_backend_banner() { + for state in ["visible", "dismissed", "another-model"] { + let (mut chat, _events, _ops) = make_chatwidget_manual(Some("test-model-a")).await; + let response = codex_app_server_protocol::GetAccountRateLimitsResponse { + ordinary_usage_allowed: None, + account_id: Some("workspace-a".into()), + rate_limit_upsell: Some(json!({ + "banner_type": "selected_model_limit", "model_slug": "test-model-a", + "title": "Selected model usage exhausted", "description": "Switch models.", + "presentation": "dismissible", "ctas": [], + })), + rate_limits: snapshot(/*percent*/ 25.0), + rate_limits_by_limit_id: None, + rate_limit_reset_credits: None, + }; + chat.update_backend_banner(&response); + render_bottom_popup(&chat, /*width*/ 70); + match state { + "dismissed" => { + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + } + "another-model" => chat.set_model("test-model-b"), + "visible" => {} + _ => unreachable!(), + } + chat.show_security_setup(setup_identity(), setup_notice()); + assert_eq!( + render_bottom_popup(&chat, /*width*/ 70).contains("Set up security"), + state != "visible" + ); + if state == "another-model" { + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + chat.set_model("test-model-a"); + assert!( + render_bottom_popup(&chat, /*width*/ 70).contains("Selected model usage exhausted") + ); + } + } +} diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__security_setup_tests__security_setup_after_cutoff.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__security_setup_tests__security_setup_after_cutoff.snap new file mode 100644 index 0000000000..00a6396097 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__security_setup_tests__security_setup_after_cutoff.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/chatwidget/tests/security_setup_tests.rs +assertion_line: 43 +expression: "normalize_snapshot_paths(render_bottom_popup(&chat, 48))" +--- + Set up security for Daybreak mode + Set up Advanced Account Security with a + hardware security key. Already Persona- + verified? Add your key before October 15 to + skip re-verification. You can keep using + Codex while you finish setup. + +› 1. Set up security + + Press a number to choose · esc to dismiss · + type to continue + +› Ask Codex to do anything + + test-model-a default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__security_setup_tests__security_setup_after_persona_grace.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__security_setup_tests__security_setup_after_persona_grace.snap new file mode 100644 index 0000000000..849c8a09a3 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__security_setup_tests__security_setup_after_persona_grace.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/chatwidget/tests/security_setup_tests.rs +assertion_line: 56 +expression: "normalize_snapshot_paths(render_bottom_popup(&chat, 48))" +--- + Set up security for Daybreak mode + Set up Advanced Account Security with a + hardware security key. Verify each new + hardware security key with Persona. You can + keep using Codex while you finish setup. + +› 1. Set up security + + Press a number to choose · esc to dismiss · + type to continue + +› Ask Codex to do anything + + test-model-a default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__security_setup_tests__security_setup_banner.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__security_setup_tests__security_setup_banner.snap new file mode 100644 index 0000000000..bff2ed1194 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__security_setup_tests__security_setup_banner.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/chatwidget/tests/security_setup_tests.rs +assertion_line: 19 +expression: rendered +--- + Keep using Daybreak mode + Set up Advanced Account Security with a hardware security key by + October 1. Already Persona-verified? Add your key before October + 15 to skip re-verification. + +› 1. Set up security + + Press a number to choose · esc to dismiss · type to continue + +› Ask Codex to do anything + + test-model-a default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__security_setup_tests__security_setup_dismissed_after_usage.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__security_setup_tests__security_setup_dismissed_after_usage.snap new file mode 100644 index 0000000000..77b7fbd67f --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__security_setup_tests__security_setup_dismissed_after_usage.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests/security_setup_tests.rs +expression: rendered +--- +› Ask Codex to do anything + + test-model-a default · /tmp/project diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 1e04ab8575..41802083c8 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -131,6 +131,7 @@ mod experimental_features; mod markdown_copy; mod permission_discovery; mod pets; +mod security_setup; mod worktree_browser; pub use custom_terminal::Terminal; mod assistant_directives; diff --git a/codex-rs/tui/src/security_setup.rs b/codex-rs/tui/src/security_setup.rs new file mode 100644 index 0000000000..9e48eda944 --- /dev/null +++ b/codex-rs/tui/src/security_setup.rs @@ -0,0 +1,164 @@ +//! Optional account-security reminders for the local, ChatGPT-authenticated CLI. +//! The server owns eligibility, rollout and cutoff copy. Reads never block a turn; +//! no reminder analytics are sent. Unknown or unavailable state produces no notice. + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::app_server_session::AppServerSession; +use crate::legacy_core::config::Config; +use codex_app_server_protocol::AuthMode; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::GetAuthStatusParams; +use codex_app_server_protocol::GetAuthStatusResponse; +use codex_app_server_protocol::RequestId; +use codex_http_client::ClientRouteClass; +use codex_http_client::RouteAwareClientPool; +use codex_login::CodexAuth; +use serde::Deserialize; +use std::time::Duration; +use uuid::Uuid; + +#[derive(Clone, Debug, Deserialize)] +pub(crate) struct Notice { + pub title: String, + pub description: String, + pub action: Action, +} + +#[derive(Clone, Debug, Deserialize)] +pub(crate) struct Action { + pub label: String, + pub url: String, +} + +#[derive(Deserialize)] +struct Response { + notice: Option, +} +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Identity { + pub account: String, + pub user: String, +} +impl Identity { + fn from_auth(auth: &CodexAuth) -> Option { + Some(Self { + account: auth.get_account_id()?, + user: auth.get_chatgpt_user_id()?, + }) + } +} +impl Notice { + pub(crate) fn valid(&self) -> bool { + !self.title.trim().is_empty() + && self.title.len() <= 256 + && self.description.len() <= 2048 + && !self.title.chars().any(char::is_control) + && !self.description.chars().any(char::is_control) + && !self.action.label.trim().is_empty() + && self.action.label.len() <= 128 + && !self.action.label.chars().any(char::is_control) + && url::Url::parse(&self.action.url).is_ok_and(|url| { + url.scheme() == "https" + && url.host_str() == Some("chatgpt.com") + && url.username().is_empty() + && url.password().is_none() + && url.port().is_none() + }) + } +} + +pub(crate) fn prefetch( + config: &Config, + server: &AppServerSession, + tx: AppEventSender, + request_id: Uuid, +) { + if config.model_provider_id != "openai" || server.uses_remote_workspace() { + return; + } + let config = config.clone(); + let request_handle = server.request_handle(); + tokio::spawn(async move { + let result = tokio::time::timeout(Duration::from_secs(3), async { + // Saved credentials must match the connected server, which may use external auth. + let status: GetAuthStatusResponse = request_handle + .request_typed(ClientRequest::GetAuthStatus { + request_id: RequestId::String(Uuid::new_v4().to_string()), + params: GetAuthStatusParams { + include_token: Some(true), + refresh_token: Some(false), + }, + }) + .await + .ok()?; + let auth = config + .auth_config() + .load_auth(/*enable_codex_api_key_env*/ false) + .await + .ok() + .flatten()?; + if !matches!(auth, CodexAuth::Chatgpt(_)) || auth.is_fedramp_account() { + return None; + } + let saved_token = auth.get_token().ok()?; + if status.auth_method != Some(AuthMode::Chatgpt) + || status.auth_token.as_deref() != Some(saved_token.as_str()) + { + return None; + } + let identity = Identity::from_auth(&auth)?; + let client = RouteAwareClientPool::new_without_redirects( + config.http_client_factory(), + ClientRouteClass::Api, + ); + let response = client + .get(format!( + "{}/wham/security-setup", + config.chatgpt_base_url.trim_end_matches('/') + )) + .headers(codex_model_provider::auth_provider_from_auth(&auth).to_auth_headers()) + .header( + "User-Agent", + codex_login::default_client::get_codex_user_agent(), + ) + .send() + .await + .ok()?; + if !response.status().is_success() { + return None; + } + let notice = response.json::().await.ok()?.notice?; + tracing::debug!(valid = notice.valid(), "received security setup notice"); + if !notice.valid() { + return None; + } + let current = config + .auth_config() + .load_auth(/*enable_codex_api_key_env*/ false) + .await + .ok() + .flatten()?; + if Identity::from_auth(¤t).as_ref() != Some(&identity) { + tracing::debug!("security setup identity changed during fetch"); + return None; + } + Some((identity, notice)) + }) + .await + .ok() + .flatten(); + if let Some((identity, notice)) = result { + tracing::debug!("security setup notice ready"); + tx.send(AppEvent::SecuritySetupLoaded { + request_id, + identity, + notice, + }); + } + }); +} + +#[cfg(test)] +#[path = "security_setup_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/security_setup_tests.rs b/codex-rs/tui/src/security_setup_tests.rs new file mode 100644 index 0000000000..701bd4a5e0 --- /dev/null +++ b/codex-rs/tui/src/security_setup_tests.rs @@ -0,0 +1,29 @@ +use super::*; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[test] +fn security_setup_rejects_unsafe_actions_and_terminal_controls() { + let value = json!({"title":"Keep using Daybreak mode","description":"Set up security.", + "action":{"label":"Set up security","url":"https://chatgpt.com/cyber"}}); + let notice: Notice = serde_json::from_value(value.clone()).unwrap(); + assert!(notice.valid()); + for url in [ + "http://chatgpt.com/cyber", + "https://chatgpt.com.evil.test/cyber", + "https://evil.test/", + "https://user@chatgpt.com/cyber", + "https://chatgpt.com:8080/cyber", + "javascript:alert(1)", + ] { + let mut invalid = value.clone(); + invalid["action"]["url"] = json!(url); + assert!( + !serde_json::from_value::(invalid).unwrap().valid(), + "{url}" + ); + } + let mut invalid = notice; + invalid.title = "untrusted\u{1b}[31m".into(); + assert_eq!(invalid.valid(), false); +}