diff --git a/codex-rs/tui/src/app/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs index a40e44ea5f..ee2c798545 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -325,7 +325,6 @@ impl App { self.agents_overview.pending_usage = None; 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(); @@ -367,11 +366,6 @@ impl App { self.app_event_tx.clone(), self.chat_widget.security_setup_request_id, ); - crate::daybreak::prefetch_notice( - &self.config, - app_server_client, - self.chat_widget.cyber_policy_notice.clone(), - ); } return; } diff --git a/codex-rs/tui/src/app/reconnect.rs b/codex-rs/tui/src/app/reconnect.rs index bdb643db84..a18292f6b0 100644 --- a/codex-rs/tui/src/app/reconnect.rs +++ b/codex-rs/tui/src/app/reconnect.rs @@ -313,7 +313,6 @@ impl App { !self.app_server_target.uses_remote_workspace() && app_server.app_server_platform_os() == Some("windows"); self.chat_widget.windows_sandbox_host = WindowsSandboxHost::Unknown; - self.chat_widget.cyber_policy_notice = Default::default(); self.chat_widget.requires_openai_auth = bootstrap.requires_openai_auth; self.chat_widget.remote_connection = crate::status::remote_connection::remote_connection_status_value( @@ -495,11 +494,6 @@ impl App { self.app_event_tx.clone(), self.chat_widget.security_setup_request_id, ); - crate::daybreak::prefetch_notice( - &self.config, - app_server, - self.chat_widget.cyber_policy_notice.clone(), - ); } self.feedback_audience = bootstrap.feedback_audience; if displayed.is_some_and(|id| !self.thread_unavailable(id)) { diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index c1bdc28b26..e6f6c39cee 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -495,7 +495,6 @@ impl App { if !self.chat_widget.realtime_conversation_is_running() { self.retain_realtime_replay_state_before_replace(); } - chat_widget.cyber_policy_notice = self.chat_widget.cyber_policy_notice.clone(); self.commit_animation = None; // Transfer the last-written terminal title to the replacement widget // so it knows what OSC title is currently displayed. Without this, the diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index 0714428e9d..9ff641e115 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -1064,11 +1064,6 @@ See the Codex keymap documentation for supported actions and examples." app.app_event_tx.clone(), app.chat_widget.security_setup_request_id, ); - crate::daybreak::prefetch_notice( - &app.config, - &app_server, - app.chat_widget.cyber_policy_notice.clone(), - ); let reset_hint_request_id = app.chat_widget.start_rate_limit_reset_startup_check(); app.refresh_rate_limits( &app_server, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index ceccc39bea..413f805c4b 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3,8 +3,6 @@ #[path = "tests/mcp_login_tests.rs"] mod mcp_login_tests; -#[path = "tests/daybreak_tests.rs"] -mod daybreak_tests; #[path = "tests/math_interruption_tests.rs"] mod math_interruption_tests; #[path = "tests/security_setup_tests.rs"] diff --git a/codex-rs/tui/src/app/tests/daybreak_tests.rs b/codex-rs/tui/src/app/tests/daybreak_tests.rs deleted file mode 100644 index f7b96297da..0000000000 --- a/codex-rs/tui/src/app/tests/daybreak_tests.rs +++ /dev/null @@ -1,153 +0,0 @@ -use super::*; -use crate::daybreak::Notice; -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_app_server_protocol::CodexErrorInfo; -use codex_app_server_protocol::ErrorNotification; -use codex_login::AuthCredentialsStoreMode; -use pretty_assertions::assert_eq; -use serde_json::json; - -#[tokio::test] -async fn cyber_refusal_reads_eligibility_without_changing_the_model() -> 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(); - // Embedded account discovery reloads the bootstrap URL from disk. - std::fs::write( - app.config.codex_home.join("config.toml"), - format!("chatgpt_base_url = {:?}\n", backend.uri()), - )?; - app_test_support::mount_workspace_routing(&backend).await; - app.config.cli_auth_credentials_store_mode = AuthCredentialsStoreMode::File; - write_chatgpt_auth( - &app.config.codex_home, - ChatGptAuthFixture::new("test-token") - .account_id("account") - .chatgpt_user_id("user"), - AuthCredentialsStoreMode::File, - ) - .expect("write synthetic auth"); - let server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; - let thread_id = ThreadId::new(); - let mut session = test_thread_session(thread_id, app.config.cwd.to_path_buf()); - session.model_provider_id = "openai".to_string(); - app.enqueue_primary_thread_session(session, Vec::new()) - .await?; - for (status, enrolled, model, replace_account, expected) in [ - (200, true, "gpt-5.6-sol", false, Notice::Limited), - (200, false, "gpt-5.6-sol", false, Notice::Apply), - (503, false, "gpt-5.6-sol", false, Notice::Limited), - (200, true, "gpt-6-astra", false, Notice::Astra), - (200, false, "gpt-6-astra", false, Notice::Astra), - (503, false, "gpt-6-astra", false, Notice::Astra), - (200, true, "gpt-6-astra-wm", false, Notice::Astra), - (200, true, "unmapped-model", false, Notice::Limited), - (200, false, "gpt-5.6-sol", true, Notice::Limited), - ] { - backend.reset().await; - app_test_support::mount_workspace_routing(&backend).await; - let codex_home = app.config.codex_home.clone(); - let response = wiremock::ResponseTemplate::new(status) - .set_delay(if model == "gpt-5.6-sol" && enrolled { Duration::from_secs(1) } else { Duration::ZERO }) - .set_body_json(json!({"programs": [{"program":"cyber", "state": if enrolled { "active" } else { "inactive" }, "grants": if enrolled { json!([{"level":"tac2"}]) } else { json!([]) }}]})); - wiremock::Mock::given(wiremock::matchers::path("/accounts/verified_access")) - .and(wiremock::matchers::header( - "authorization", - "Bearer test-token", - )) - .respond_with(move |_: &wiremock::Request| { - if replace_account { - write_chatgpt_auth( - &codex_home, - ChatGptAuthFixture::new("test-token").account_id("other-account"), - AuthCredentialsStoreMode::File, - ) - .unwrap(); - } - response.clone() - }) - .expect(1) - .mount(&backend) - .await; - // Account discovery starts before a subsequent model switch to Sol. - app.chat_widget.set_model("gpt-6-astra"); - app.handle_app_server_event( - &server, - AppServerEvent::ServerNotification(Box::new(ServerNotification::AccountUpdated( - AccountUpdatedNotification { - auth_mode: Some(AuthMode::Chatgpt), - plan_type: None, - }, - ))), - ) - .await; - if model != "gpt-5.6-sol" || !enrolled { - wait_for_notice(&app.chat_widget.cyber_policy_notice).await; - } - app.active_thread_id = Some(thread_id); - app.chat_widget.set_model(model); - while events.try_recv().is_ok() {} - // In the slow Sol case, discovery is still pending: do not block input on it. - tokio::time::timeout( - Duration::from_millis(250), - app.handle_app_server_event( - &server, - AppServerEvent::ServerNotification(Box::new(ServerNotification::Error( - ErrorNotification { - error: AppServerTurnError { - misalignment: None, - message: "server fallback".into(), - codex_error_info: Some(CodexErrorInfo::CyberPolicy), - additional_details: None, - }, - will_retry: false, - thread_id: thread_id.to_string(), - turn_id: "turn".into(), - }, - ))), - ), - ) - .await - .expect("refusal must not wait for eligibility"); - let event = app.active_thread_rx.as_mut().unwrap().recv().await.unwrap(); - app.handle_thread_event_now(event); - let cell = std::iter::from_fn(|| events.try_recv().ok()) - .find_map(|event| match event { - AppEvent::InsertHistoryCell(cell) => Some(cell), - _ => None, - }) - .expect("cyber refusal cell"); - assert_eq!( - cell.display_lines(/*width*/ 80), - history_cell::new_cyber_policy_error_event(expected).display_lines(/*width*/ 80), - "{model}" - ); - assert_eq!( - wait_for_notice(&app.chat_widget.cyber_policy_notice) - .await - .for_model(model), - expected - ); - backend.verify().await; - assert_eq!(app.chat_widget.current_model(), model); - } - server.shutdown().await?; - Ok(()) -} - -async fn wait_for_notice(cache: &crate::daybreak::NoticeCache) -> Notice { - tokio::time::timeout(Duration::from_secs(3), async { - loop { - if let Some(notice) = cache.get() { - return *notice; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("eligibility should finish") -} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 7fafd6dd45..7d30c1f9e8 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -515,7 +515,6 @@ pub(crate) enum ExternalEditorState { pub(crate) struct ChatWidget { pub(crate) empty_state_animation: std::cell::RefCell, - pub(crate) cyber_policy_notice: crate::daybreak::NoticeCache, app_event_tx: AppEventSender, codex_op_target: CodexOpTarget, bottom_pane: BottomPane, diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 175fccf51a..4636a3ec96 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -97,7 +97,6 @@ impl ChatWidget { ); let mut widget = Self { empty_state_animation: std::cell::RefCell::new(empty_state_animation), - cyber_policy_notice: Default::default(), app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), codex_op_target, diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index abe4b6667f..6300ba9ebe 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -123,9 +123,13 @@ async fn resumed_session_hides_unknown_token_usage_until_an_update_arrives() { #[tokio::test] async fn app_server_cyber_policy_error_renders_dedicated_notice() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(Some("gpt-5.6-sol")).await; - chat.cyber_policy_notice - .set(crate::daybreak::Notice::Apply) - .unwrap(); + chat.has_chatgpt_account = true; + let mut model = crate::test_support::TEST_MODEL_PRESETS[0].clone(); + model.model = "gpt-5.6-sol".into(); + model.available_access_programs = Some(codex_protocol::openai_models::ModelAccessPrograms { + cyber: vec![codex_protocol::turn_input::CyberAccessProgram::Standard], + }); + chat.model_catalog = std::sync::Arc::new(ModelCatalog::new(vec![model])); handle_error( &mut chat, diff --git a/codex-rs/tui/src/chatwidget/turn_runtime.rs b/codex-rs/tui/src/chatwidget/turn_runtime.rs index 137220567c..778588ea37 100644 --- a/codex-rs/tui/src/chatwidget/turn_runtime.rs +++ b/codex-rs/tui/src/chatwidget/turn_runtime.rs @@ -394,14 +394,10 @@ impl ChatWidget { pub(super) fn on_cyber_policy_error(&mut self) { self.input_queue.submit_pending_steers_after_interrupt = false; self.finalize_turn(); - let notice = if self.config.model_provider_id == "openai" { - self.cyber_policy_notice - .get() - .copied() - .unwrap_or_default() - .for_model(self.current_model()) + let notice = if self.has_chatgpt_account && self.config.model_provider_id == "openai" { + crate::daybreak::notice_for_model(&self.model_catalog.models, self.current_model()) } else { - crate::daybreak::Notice::Limited + crate::daybreak::Notice::Apply }; self.add_to_history(history_cell::new_cyber_policy_error_event(notice)); self.request_redraw(); diff --git a/codex-rs/tui/src/daybreak.rs b/codex-rs/tui/src/daybreak.rs index 9dfcfbebba..a211507711 100644 --- a/codex-rs/tui/src/daybreak.rs +++ b/codex-rs/tui/src/daybreak.rs @@ -1,24 +1,8 @@ -//! Read-only Daybreak eligibility used to choose cyber refusal copy. -//! Account-scoped discovery runs in the background; pending/failed reads use neutral copy. -//! Astra takes precedence; the TUI does not configure the app’s Daybreak access program. +//! Choose cyber refusal guidance from the connected host's model catalog. -use crate::app_server_session::AppServerSession; -use crate::legacy_core::config::Config; -use codex_app_server_client::AppServerRequestHandle; -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::sync::Arc; -use std::time::Duration; -use tokio::sync::OnceCell; - -pub(crate) type NoticeCache = Arc>; +use codex_protocol::openai_models::ModelAccessPrograms; +use codex_protocol::openai_models::ModelPreset; +use codex_protocol::turn_input::CyberAccessProgram; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) enum Notice { @@ -28,123 +12,22 @@ pub(crate) enum Notice { Limited, } -pub(crate) fn prefetch_notice(config: &Config, server: &AppServerSession, cache: NoticeCache) { - if config.model_provider_id != "openai" - || server.uses_remote_workspace() - || cache.get().is_some() +pub(crate) fn notice_for_model(models: &[ModelPreset], model: &str) -> Notice { + let programs = models + .iter() + .find(|entry| entry.model == model) + .and_then(|entry| entry.available_access_programs.as_ref()); + if matches!(model, "gpt-6-astra" | "gpt-6-astra-wm") + && programs.is_some_and(|programs| { + programs.cyber.contains(&CyberAccessProgram::Standard) && programs.daybreak().is_none() + }) { - return; + return Notice::Astra; } - let config = config.clone(); - let request_handle = server.request_handle(); - tokio::spawn(async move { - cache - .get_or_init(|| read_notice(&config, &request_handle)) - .await; - }); -} - -async fn read_notice(config: &Config, request_handle: &AppServerRequestHandle) -> Notice { - tokio::time::timeout(Duration::from_secs(3), async { - // Allow the server to refresh its token before reading local credentials. - let status: GetAuthStatusResponse = request_handle - .request_typed(ClientRequest::GetAuthStatus { - request_id: RequestId::String(uuid::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()?; - let CodexAuth::Chatgpt(_) = &auth else { - return None; - }; - if status.auth_method != Some(AuthMode::Chatgpt) - || status.auth_token.as_deref() != Some(auth.get_token().ok()?.as_str()) - { - return None; - } - let client = RouteAwareClientPool::new_without_redirects( - config.http_client_factory(), - ClientRouteClass::Api, - ); - let url = format!( - "{}/accounts/verified_access", - config.chatgpt_base_url.trim_end_matches('/') - ); - let response = client - .get(url) - .headers(codex_model_provider::auth_provider_from_auth(&auth).to_auth_headers()) - .send() - .await - .ok()?; - if !response.status().is_success() { - return None; - } - let access = response.json::().await.ok()?; - let current_auth = config - .auth_config() - .load_auth(/*enable_codex_api_key_env*/ false) - .await - .ok() - .flatten()?; - if current_auth.get_token().ok()? != auth.get_token().ok()? - || current_auth.get_account_id() != auth.get_account_id() - { - return None; - } - Some(access.notice()) - }) - .await - .ok() - .flatten() - .unwrap_or_default() -} - -#[derive(Deserialize)] -struct VerifiedAccess { - programs: Vec, -} -#[derive(Deserialize)] -#[serde(tag = "program", rename_all = "snake_case")] -enum Program { - Cyber { - state: String, - grants: Vec, - }, - #[serde(other)] - Other, -} -impl VerifiedAccess { - fn notice(&self) -> Notice { - let absent = self.programs.iter().all(|program| match program { - Program::Cyber { state, grants } => state == "inactive" && grants.is_empty(), - Program::Other => true, - }); - if absent { - Notice::Apply - } else { - Notice::Limited - } - } -} - -impl Notice { - pub(crate) fn for_model(self, model: &str) -> Self { - match model { - // Same identifiers as the app's isDaybreakUnavailableModel. - "gpt-6-astra" | "gpt-6-astra-wm" => Self::Astra, - "gpt-5.6-sol" => self, - // Other model/access-program mappings are not established for the TUI. - _ => Self::Limited, - } + if programs.and_then(ModelAccessPrograms::daybreak).is_some() { + Notice::Limited + } else { + Notice::Apply } } diff --git a/codex-rs/tui/src/daybreak_tests.rs b/codex-rs/tui/src/daybreak_tests.rs index eb011e6360..28e25c2651 100644 --- a/codex-rs/tui/src/daybreak_tests.rs +++ b/codex-rs/tui/src/daybreak_tests.rs @@ -1,37 +1,45 @@ use super::*; +use codex_protocol::openai_models::ModelAccessPrograms; use pretty_assertions::assert_eq; -use serde_json::json; #[test] -fn refusal_copy_uses_verified_access() { - for (programs, expected) in [ - (json!([]), Notice::Apply), - ( - json!([{"program":"cyber","state":"inactive","grants":[]}]), - Notice::Apply, - ), - ( - json!([{"program":"cyber","state":"unavailable","grants":[]}]), - Notice::Limited, - ), - ( - json!([{"program":"cyber","state":"active","grants":[{"level":"tac2"}]}]), - Notice::Limited, - ), - ( - json!([{"program":"cyber","state":"active","grants":[]}]), - Notice::Limited, - ), - ( - json!([{"program":"cyber","state":"inactive","grants":[{"level":"tac2"}]}]), - Notice::Limited, - ), - ( - json!([{"program":"cyber","state":"unknown","grants":[]}]), - Notice::Limited, - ), +fn refusal_guidance_uses_the_model_catalog() { + let mut model = crate::test_support::TEST_MODEL_PRESETS[0].clone(); + model.available_access_programs = Some(ModelAccessPrograms { + cyber: vec![CyberAccessProgram::Standard], + }); + for (name, expected) in [ + ("gpt-5.6-sol", Notice::Apply), + ("gpt-6-astra", Notice::Astra), + ("gpt-6-astra-wm", Notice::Astra), ] { - let access: VerifiedAccess = serde_json::from_value(json!({"programs": programs})).unwrap(); - assert_eq!(access.notice(), expected); + model.model = name.into(); + assert_eq!( + notice_for_model(std::slice::from_ref(&model), name), + expected + ); } + model + .available_access_programs + .as_mut() + .unwrap() + .cyber + .push(CyberAccessProgram::DaybreakBlue); + assert_eq!( + notice_for_model(std::slice::from_ref(&model), &model.model), + Notice::Limited + ); + + let mut other = model.clone(); + model.model = "gpt-5.6-sol".into(); + other.model = "another-model".into(); + model.available_access_programs.as_mut().unwrap().cyber = vec![CyberAccessProgram::Standard]; + for programs in [None, other.available_access_programs.clone()] { + other.available_access_programs = programs; + assert_eq!( + notice_for_model(&[model.clone(), other.clone()], &model.model), + Notice::Apply + ); + } + assert_eq!(notice_for_model(&[], "gpt-6-astra"), Notice::Apply); }