mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
[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:
committed by
Andrew Gu
co-authored by
dennyku
parent
94cd1062e9
commit
f2908e170c
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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()
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
@@ -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
|
||||
+19
@@ -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
|
||||
+17
@@ -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
|
||||
+7
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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(¤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;
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user