mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
Add account security setup reminders to the TUI (#49715)
## What changed Fetch optional security setup notices asynchronously for local ChatGPT sessions using the OpenAI provider. Verify that saved credentials match the connected app server, ignore stale account responses, and validate notice text and HTTPS actions on `chatgpt.com`. Show notices as dismissible inline banners with a browser action, while giving applicable usage banners priority. Preserve notice state and dismissal across thread changes, reconnects, and banner replacement for the same account and user. ## Testing Add authenticated HTTP fixture tests, notice validation tests, and TUI tests covering stale responses, dismissal persistence, usage banner priority, browser actions, and wrapping in narrow terminals. GitOrigin-RevId: b8635fcf6a26b42b6a06640601c8517d8d5e47e8
This commit is contained in:
@@ -326,6 +326,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();
|
||||
}
|
||||
@@ -360,6 +361,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,
|
||||
|
||||
@@ -1257,6 +1257,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);
|
||||
}
|
||||
|
||||
@@ -484,6 +484,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,
|
||||
|
||||
@@ -520,6 +520,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,
|
||||
|
||||
@@ -1021,6 +1021,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,
|
||||
|
||||
@@ -7,6 +7,8 @@ mod mcp_login_tests;
|
||||
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(())
|
||||
}
|
||||
@@ -279,6 +279,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;
|
||||
@@ -574,6 +575,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
|
||||
|
||||
@@ -163,6 +163,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