Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions codex-rs/tui/src/app/app_server_events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions codex-rs/tui/src/app/event_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
6 changes: 6 additions & 0 deletions codex-rs/tui/src/app/reconnect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions codex-rs/tui/src/app/session_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions codex-rs/tui/src/app/startup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions codex-rs/tui/src/app/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
242 changes: 242 additions & 0 deletions codex-rs/tui/src/app/tests/security_setup_tests.rs
Original file line number Diff line number Diff line change
@@ -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 changes: 5 additions & 0 deletions codex-rs/tui/src/app_event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
10 changes: 10 additions & 0 deletions codex-rs/tui/src/bottom_pane/actionable_banner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Expand Down
2 changes: 1 addition & 1 deletion codex-rs/tui/src/bottom_pane/list_selection_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
Expand Down
5 changes: 5 additions & 0 deletions codex-rs/tui/src/chatwidget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading