diff --git a/codex-rs/core/tests/suite/compact_program_tests.rs b/codex-rs/core/tests/suite/compact_program_tests.rs index 67d42b69e8..f1986403a7 100644 --- a/codex-rs/core/tests/suite/compact_program_tests.rs +++ b/codex-rs/core/tests/suite/compact_program_tests.rs @@ -245,6 +245,11 @@ async fn model_switch_program_pair( .features .enable(Feature::ApiKeyCyberAccessPrograms) .expect("enable API-key Cyber access programs"); + } else if matches!(history, History::ApiKeyResume) { + config + .features + .disable(Feature::ApiKeyCyberAccessPrograms) + .expect("disable API-key Cyber access programs"); } if matches!(history, History::ApiKeyCustomProviderResume) { config.model_provider_id = "custom".to_owned(); @@ -258,7 +263,7 @@ async fn model_switch_program_pair( } }; if matches!(history, History::ApiKeyResume) { - // Resuming a ChatGPT turn does not enable the API-key rollout features. + // Resuming a ChatGPT turn cannot bypass an explicit API-key opt-out. let error = submit_pair(&thread, next_model, Some(CyberAccessProgram::DaybreakRed)) .await .expect_err("API-key selections require the rollout features"); diff --git a/codex-rs/exec/src/daybreak.rs b/codex-rs/exec/src/daybreak.rs index 823a0437b8..e3fdf41bf4 100644 --- a/codex-rs/exec/src/daybreak.rs +++ b/codex-rs/exec/src/daybreak.rs @@ -9,9 +9,12 @@ use codex_app_server_protocol::GetAccountParams; use codex_app_server_protocol::GetAccountResponse; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; +use codex_features::Feature; +use codex_features::Features; use codex_protocol::openai_models::ModelAccessPrograms; pub(crate) async fn program_for_turn( + features: &Features, client: &InProcessAppServerClient, request_ids: &mut RequestIdSequencer, model: &str, @@ -25,8 +28,11 @@ pub(crate) async fn program_for_turn( if enabled { anyhow::ensure!( - has_chatgpt_account(client, request_ids).await?, - "Daybreak requires a signed-in ChatGPT account" + matches!( + read_account(client, request_ids).await?, + Some(Account::Chatgpt { .. } | Account::ApiKey { .. }) + ), + "Daybreak requires a signed-in ChatGPT account or an OpenAI API key" ); } @@ -43,44 +49,48 @@ pub(crate) async fn program_for_turn( }) .await; let Ok(result) = result else { - anyhow::ensure!(!enabled, "Daybreak availability could not be determined"); return Ok(None); }; if let Some(selected) = result.data.into_iter().find(|entry| entry.model == model) { let programs = selected .available_access_programs - .map(ModelAccessPrograms::from); + .map(ModelAccessPrograms::from) + .filter(|programs| !programs.cyber.is_empty()); + let Some(programs) = programs else { + return Ok(None); + }; if enabled { return programs - .and_then(|programs| programs.daybreak()) + .daybreak() .map(|program| Some(program.into())) .ok_or_else(|| anyhow::anyhow!("Daybreak is unavailable for model {model}; turn it off or choose a compatible model")); } - let Some(standard) = programs.and_then(|programs| programs.standard()) else { + let Some(standard) = programs.standard() else { return Ok(None); }; - return Ok(has_chatgpt_account(client, request_ids) - .await - .unwrap_or(false) - .then_some(standard.into())); + let eligible = match read_account(client, request_ids).await.unwrap_or(None) { + Some(Account::Chatgpt { .. }) => true, + Some(Account::ApiKey { .. }) => { + features.enabled(Feature::ApiKeyCyberAccessPrograms) + } + Some(Account::AmazonBedrock { .. }) | None => false, + }; + return Ok(eligible.then_some(standard.into())); } match result.next_cursor { Some(next) => cursor = Some(next), None => { - anyhow::ensure!( - !enabled, - "Daybreak availability could not be determined for model {model}" - ); + // Preserve automatic treatment for explicit model IDs absent from the catalog. return Ok(None); } } } } -async fn has_chatgpt_account( +async fn read_account( client: &InProcessAppServerClient, request_ids: &mut RequestIdSequencer, -) -> anyhow::Result { +) -> anyhow::Result> { let account = client .request_typed::(ClientRequest::GetAccount { request_id: request_ids.next(), @@ -92,5 +102,5 @@ async fn has_chatgpt_account( .map_err(|error| { anyhow::anyhow!("Daybreak account availability could not be determined: {error}") })?; - Ok(matches!(account.account, Some(Account::Chatgpt { .. }))) + Ok(account.account) } diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index a8d40a31d7..f97ce42737 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -1215,6 +1215,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { Some(program) => Some(program), None if config.features.enabled(Feature::CliDaybreak) => { daybreak::program_for_turn( + &config.features, &client, &mut request_ids, &session_configured.model, diff --git a/codex-rs/exec/tests/suite/daybreak.rs b/codex-rs/exec/tests/suite/daybreak.rs index f9db9888da..d134a77c05 100644 --- a/codex-rs/exec/tests/suite/daybreak.rs +++ b/codex-rs/exec/tests/suite/daybreak.rs @@ -21,34 +21,52 @@ fn started_thread(output: std::process::Output) -> anyhow::Result { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn exec_rejects_daybreak_for_api_keys_and_allows_an_override() -> anyhow::Result<()> { +async fn exec_resolves_daybreak_for_api_keys_and_allows_an_off_override() -> anyhow::Result<()> { let (test, server) = configured_daybreak_exec().await?; std::fs::remove_file(test.home_path().join("auth.json"))?; - let response = responses::mount_sse_once(&server, responses::sse_completed("response")).await; - + for (args, expected) in [ + (vec![], Some("daybreak_blue")), + (vec!["-c", "daybreak=false"], Some("standard")), + (vec!["-m", "unlisted-model"], None), + ( + vec![ + "-c", + "daybreak=false", + "-c", + "features.api_key_cyber_access_programs=false", + ], + None, + ), + ] { + let response = + responses::mount_sse_once(&server, responses::sse_completed("response")).await; + test.cmd_with_server(&server) + .env_remove("CODEX_ACCESS_TOKEN") + .arg("--skip-git-repo-check") + .args(args) + .arg("hello") + .assert() + .success(); + assert_eq!( + response.single_request().body_json().get("access_programs"), + expected.map(|program| json!({"cyber": program})).as_ref() + ); + } + let response = responses::mount_sse_once(&server, responses::sse_completed("unexpected")).await; test.cmd_with_server(&server) .env_remove("CODEX_ACCESS_TOKEN") - .arg("--skip-git-repo-check") - .arg("hello") + .args([ + "--skip-git-repo-check", + "-c", + "features.api_key_cyber_access_programs=false", + "hello", + ]) .assert() .failure() .stderr(predicate::str::contains( - "Daybreak requires a signed-in ChatGPT account", + "Cyber access programs are disabled for this API-key session.", )); assert!(response.requests().is_empty()); - - test.cmd_with_server(&server) - .env_remove("CODEX_ACCESS_TOKEN") - .arg("--skip-git-repo-check") - .arg("-c") - .arg("daybreak=false") - .arg("hello") - .assert() - .success(); - assert_eq!( - response.single_request().body_json().get("access_programs"), - None - ); Ok(()) } diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 15f06f3fd8..13ff893c1c 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -1418,7 +1418,7 @@ pub const FEATURES: &[FeatureSpec] = &[ id: Feature::ApiKeyCyberAccessPrograms, key: "api_key_cyber_access_programs", stage: Stage::Stable, - default_enabled: false, + default_enabled: true, }, FeatureSpec { id: Feature::EnableRequestCompression, diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index b6b60787a5..062efa383d 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -203,6 +203,8 @@ async fn daybreak_command_persists_and_confirms_each_selection() -> Result<()> { app.chat_widget .set_feature_enabled(Feature::CliDaybreak, /*enabled*/ true); app.chat_widget.set_daybreak_enabled(/*enabled*/ true); + app.chat_widget + .set_feature_enabled(Feature::ApiKeyCyberAccessPrograms, /*enabled*/ false); let models = app.chat_widget.model_catalog().models.clone(); app.chat_widget.update_account_state( Some(crate::status::StatusAccountDisplay::ApiKey), @@ -1899,10 +1901,6 @@ async fn check_dynamic_tool_requests( app.config .features .set_enabled(Feature::CliDaybreak, rollout_enabled)?; - app.config.features.set_enabled( - Feature::ApiKeyCyberAccessPrograms, - api_key_cyber_access_programs, - )?; let codex_home = tempdir()?; let backend = wiremock::MockServer::start().await; let backend_url = format!("{}/backend-api", backend.uri()); @@ -1919,10 +1917,11 @@ async fn check_dynamic_tool_requests( } let catalog_path = codex_home.path().join("models.json"); std::fs::write(&catalog_path, serde_json::to_vec(&catalog)?)?; + // The server's target-thread config may differ from the launching TUI's defaults. std::fs::write( codex_home.path().join("config.toml"), format!( - "daybreak = true\nmodel_catalog_json = {:?}\nchatgpt_base_url = {backend_url:?}\ncli_auth_credentials_store = \"file\"\nopenai_base_url = {api_url:?}\n", + "daybreak = true\nmodel_catalog_json = {:?}\nchatgpt_base_url = {backend_url:?}\ncli_auth_credentials_store = \"file\"\nopenai_base_url = {api_url:?}\nfeatures.cli_daybreak = {rollout_enabled}\nfeatures.api_key_cyber_access_programs = {api_key_cyber_access_programs}\n", catalog_path.display().to_string() ), )?; diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index c6917df3c2..93662ffac4 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -56,6 +56,7 @@ async fn resumed_initial_messages_render_history() { // A saved On preference cannot bypass the API-key gate. chat.status_account_display = Some(StatusAccountDisplay::ApiKey); + chat.set_feature_enabled(Feature::ApiKeyCyberAccessPrograms, /*enabled*/ false); chat.set_feature_enabled(Feature::CliDaybreak, /*enabled*/ true); configured.thread_id = ThreadId::new(); chat.handle_thread_session(configured.clone()); diff --git a/codex-rs/tui/src/daybreak.rs b/codex-rs/tui/src/daybreak.rs index 687a1e8c66..a47a837b33 100644 --- a/codex-rs/tui/src/daybreak.rs +++ b/codex-rs/tui/src/daybreak.rs @@ -21,14 +21,19 @@ pub(crate) fn program_for_turn( let programs = models .iter() .find(|entry| entry.model == model) - .and_then(|entry| entry.available_access_programs.as_ref()); + .and_then(|entry| entry.available_access_programs.as_ref()) + .filter(|programs| !programs.cyber.is_empty()); + let Some(programs) = programs else { + // Missing discovery metadata preserves the server's automatic treatment. + return Ok(None); + }; if enabled { programs - .and_then(codex_protocol::openai_models::ModelAccessPrograms::daybreak) + .daybreak() .map(Some) .ok_or_else(|| format!("Daybreak support for model {model} could not be confirmed by the connected server. Use /daybreak to turn it off, or choose a compatible model and server.")) } else { - Ok(programs.and_then(codex_protocol::openai_models::ModelAccessPrograms::standard)) + Ok(programs.standard()) } } diff --git a/codex-rs/tui/src/daybreak_tests.rs b/codex-rs/tui/src/daybreak_tests.rs index ee86b68d8c..1d1b7bf458 100644 --- a/codex-rs/tui/src/daybreak_tests.rs +++ b/codex-rs/tui/src/daybreak_tests.rs @@ -24,14 +24,39 @@ fn turn_program_uses_catalog_and_preserves_legacy_models() { Some(CyberAccessProgram::Standard), ), ("unlisted-model", false, None), + ("unlisted-model", true, None), ] { assert_eq!( program_for_turn(&models, name, /*eligible_account*/ true, enabled), Ok(expected) ); } - for (name, eligible_account) in [("unlisted-model", true), (model.model.as_str(), false)] { - assert!(program_for_turn(&models, name, eligible_account, /*enabled*/ true).is_err()); + assert!( + program_for_turn( + &models, + &model.model, + /*eligible_account*/ false, + /*enabled*/ true + ) + .is_err() + ); + for (programs, rejects) in [ + (None, false), + (Some(vec![]), false), + (Some(vec![CyberAccessProgram::Standard]), true), + ] { + model.available_access_programs = programs.map(|cyber| ModelAccessPrograms { cyber }); + let result = program_for_turn( + std::slice::from_ref(&model), + &model.model, + /*eligible_account*/ true, + /*enabled*/ true, + ); + if rejects { + assert!(result.is_err()); + } else { + assert_eq!(result, Ok(None)); + } } } diff --git a/codex-rs/tui/src/dynamic_tools.rs b/codex-rs/tui/src/dynamic_tools.rs index e4db48b6a9..dd1897bf17 100644 --- a/codex-rs/tui/src/dynamic_tools.rs +++ b/codex-rs/tui/src/dynamic_tools.rs @@ -566,6 +566,7 @@ async fn execute_inner( let cyber_access_program = background_turn_program( &features, &handle, + &thread_id, &started.model, &started.model_provider, daybreak_enabled, @@ -717,6 +718,7 @@ async fn execute_inner( let cyber_access_program = background_turn_program( &features, &handle, + &arguments.thread_id, arguments.model.as_deref().unwrap_or(&resumed.model), &resumed.model_provider, daybreak_enabled, @@ -1262,6 +1264,7 @@ async fn start_turn( async fn background_turn_program( features: &Features, handle: &AppServerRequestHandle, + thread_id: &str, model: &str, provider: &str, enabled: bool, @@ -1294,12 +1297,30 @@ async fn background_turn_program( } Err(_) => None, }; - let has_chatgpt_account = matches!(account, Some(Account::Chatgpt { .. })); let eligible = provider == "openai" - && (has_chatgpt_account - || enabled - && matches!(account, Some(Account::ApiKey { .. })) - && features.enabled(Feature::ApiKeyCyberAccessPrograms)); + && match account { + Some(Account::Chatgpt { .. }) => true, + Some(Account::ApiKey { .. }) => { + // Only the target thread can establish its forwarding policy, including + // project opt-outs. Older servers that cannot report it keep omission. + let (tx, rx) = tokio::sync::oneshot::channel(); + crate::experimental_features::fetch( + handle.clone(), + Some(ThreadId::from_string(thread_id).map_err(|error| error.to_string())?), + format!("tui-dynamic-{}", Uuid::new_v4()), + tx, + ); + if !rx.await.ok().and_then(Result::ok).is_some_and(|features| { + features.into_iter().any(|feature| { + feature.name == Feature::ApiKeyCyberAccessPrograms.key() && feature.enabled + }) + }) { + return Ok(None); + } + true + } + Some(Account::AmazonBedrock { .. }) | None => false, + }; // Retain saved metadata while suppressing it for accounts that cannot use Daybreak. let enabled = enabled && eligible; let models = models diff --git a/codex-rs/tui/src/experimental_features.rs b/codex-rs/tui/src/experimental_features.rs index b3c360a036..270a24c4f8 100644 --- a/codex-rs/tui/src/experimental_features.rs +++ b/codex-rs/tui/src/experimental_features.rs @@ -19,9 +19,10 @@ use tokio::sync::oneshot; pub(crate) fn fetch( request_handle: AppServerRequestHandle, thread_id: Option, - request_id: &'static str, + request_id: impl Into, mut response_tx: oneshot::Sender, String>>, ) { + let request_id = request_id.into(); tokio::spawn(async move { let discovery = async { let mut features = Vec::new();