[0.159] Backport account security setup reminders for 0.159.3 (#49744)

Backport #49715 to the 0.159 maintenance line for 0.159.3 so local
ChatGPT sessions can display optional account-security setup reminders.

This is an unchanged cherry-pick of the upstream patch: 23 files,
including authenticated-request, stale-account, notice-validation and
TUI regression tests. The server continues to own eligibility and
rollout; unavailable notices produce no banner.

Validation: clean cherry-pick onto release/0.159 at
2851358eaeffa9f1567d0bec3d5a2df1dfcb9215; stable patch ID matches
upstream; whitespace checks pass. Exact-backport CI and production build
validation are pending.

Co-authored-by: dennyku <dennyku@openai.com>
This commit is contained in:
andrewgu-oai
2026-10-02 19:04:40 +00:00
committed by Andrew Gu
co-authored by dennyku
parent 94cd1062e9
commit f2908e170c
23 changed files with 832 additions and 4 deletions
@@ -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,
+6
View File
@@ -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);
}
+6
View File
@@ -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,
@@ -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,
+6
View File
@@ -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,
+2
View File
@@ -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;
@@ -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(())
}
+5
View File
@@ -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),
@@ -159,6 +159,16 @@ impl From<ActionableBanner> 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());
}
@@ -304,7 +304,7 @@ pub(crate) struct ListSelectionView {
state: ScrollState,
completion: Option<ViewCompletion>,
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<String>,
+5
View File
@@ -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<crate::security_setup::Identity>,
security_setup_dismissed: bool,
automatic_model_switch_state: backend_banners::AutomaticModelSwitchState,
backend_banner_notice_model: Option<String>,
// Remember the account's Reserve entry notice across chats and transient banner refreshes.
@@ -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
@@ -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,
@@ -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()
}));
}
}
+3
View File
@@ -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;
@@ -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")
);
}
}
}
@@ -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
@@ -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
@@ -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
@@ -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
+1
View File
@@ -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;
+164
View File
@@ -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<Notice>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct Identity {
pub account: String,
pub user: String,
}
impl Identity {
fn from_auth(auth: &CodexAuth) -> Option<Self> {
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::<Response>().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(&current).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;
+29
View File
@@ -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::<Notice>(invalid).unwrap().valid(),
"{url}"
);
}
let mut invalid = notice;
invalid.title = "untrusted\u{1b}[31m".into();
assert_eq!(invalid.valid(), false);
}