diff --git a/codex-rs/cli/src/exec_args_tests.rs b/codex-rs/cli/src/exec_args_tests.rs new file mode 100644 index 0000000000..c828bc4f4c --- /dev/null +++ b/codex-rs/cli/src/exec_args_tests.rs @@ -0,0 +1,30 @@ +//! Snapshot coverage for the public exec command's argument help. + +use super::MultitoolCli; +use clap::CommandFactory; +use pretty_assertions::assert_eq; + +#[test] +fn exec_help_documents_cyber_access_program() { + let help = MultitoolCli::command() + .term_width(80) + .try_get_matches_from(["codex", "exec", "--help"]) + .expect_err("help should short-circuit"); + assert_eq!(help.kind(), clap::error::ErrorKind::DisplayHelp); + let program_help = help + .to_string() + .lines() + .skip_while(|line| !line.contains("--cyber-access-program")) + .take_while(|line| !line.is_empty()) + .map(str::trim_end) + .collect::>() + .join("\n"); + insta::assert_snapshot!(program_help.trim(), @r" + --cyber-access-program + Request an experimental Cyber access program for this turn (OpenAI + provider only). Omit to use server defaults. Not supported with + review; fork requires a prompt + + [possible values: standard, daybreak_blue, daybreak_red] + "); +} diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index bdd7771b3a..ac06a665e0 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -59,6 +59,9 @@ mod daemon_telemetry; mod desktop_app; mod doctor; #[cfg(test)] +#[path = "exec_args_tests.rs"] +mod exec_args_tests; +#[cfg(test)] #[path = "exec_server_args_tests.rs"] mod exec_server_args_tests; mod exec_server_auth; diff --git a/codex-rs/core/src/cyber_access_program.rs b/codex-rs/core/src/cyber_access_program.rs index f71fee3e73..0b7803b7b1 100644 --- a/codex-rs/core/src/cyber_access_program.rs +++ b/codex-rs/core/src/cyber_access_program.rs @@ -3,6 +3,7 @@ use codex_api::AccessPrograms; use codex_features::Feature; use codex_login::CodexAuth; +use codex_model_provider_info::OPENAI_PROVIDER_ID; use codex_protocol::error::CodexErr; use codex_protocol::error::Result; use codex_protocol::turn_input::CyberAccessProgram; @@ -16,7 +17,7 @@ pub(crate) enum ApiKeyCyberAccessPrograms { impl ApiKeyCyberAccessPrograms { pub(crate) fn from_config(config: &crate::config::Config) -> Self { - if config.model_provider_id != codex_model_provider_info::OPENAI_PROVIDER_ID { + if config.model_provider_id != OPENAI_PROVIDER_ID { Self::UnsupportedProvider } else if config.features.enabled(Feature::ApiKeyCyberAccessPrograms) { Self::Enabled @@ -26,6 +27,13 @@ impl ApiKeyCyberAccessPrograms { } } +pub(crate) fn for_provider( + provider_id: &str, + program: Option, +) -> Option { + program.filter(|_| provider_id == OPENAI_PROVIDER_ID) +} + pub(crate) fn for_auth( auth: Option<&CodexAuth>, program: Option, diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 106e2c4dd4..f13e98ccb0 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -12,6 +12,7 @@ use crate::compact_remote_v2::run_inline_remote_auto_compact_task as run_inline_ use crate::connectors; use crate::context::ContextualUserFragment; use crate::context::UserVerificationNotice; +use crate::cyber_access_program; use crate::environment_selection::TurnEnvironmentSnapshot; use crate::feedback_tags; use crate::hook_runtime::drain_async_hook_results; @@ -1335,21 +1336,23 @@ fn comp_hash_changed(previous: Option<&str>, current: Option<&str>) -> bool { /// Captures the current model's request-scoped state for retrying previous-model compaction. /// -/// Returns `None` when the active authentication does not use the Codex backend, the provider is -/// not OpenAI, or the previous and current model are the same. +/// Returns `None` when auth uses neither the Codex backend nor an API key, the provider is +/// not OpenAI, or the previous and current model/program pairs are the same. async fn capture_current_model_fallback_step_context( sess: &Arc, turn_context: &Arc, - previous_model: &str, + previous_turn_context: &TurnContext, cancellation_token: &CancellationToken, ) -> CodexResult>> { - let uses_codex_backend = turn_context + let supports_fallback = turn_context .auth_manager .as_deref() - .is_some_and(codex_login::AuthManager::current_auth_uses_codex_backend); - if !uses_codex_backend + .and_then(codex_login::AuthManager::auth_cached) + .is_some_and(|auth| auth.uses_codex_backend() || auth.is_api_key_auth()); + if !supports_fallback || !turn_context.provider.info().is_openai() - || previous_model == turn_context.model_info().slug + || (previous_turn_context.model_info().slug == turn_context.model_info().slug + && previous_turn_context.cyber_access_program == turn_context.cyber_access_program) { return Ok(None); } @@ -1386,7 +1389,10 @@ async fn maybe_run_previous_model_inline_compact( // turn's program so compaction uses the same model/cyber_access_program pair as that turn. // Combining the previous model with the current turn's program can produce a pair // that the server rejects. - previous_model_turn_context.cyber_access_program = previous_turn_settings.cyber_access_program; + previous_model_turn_context.cyber_access_program = cyber_access_program::for_provider( + &previous_model_turn_context.config.model_provider_id, + previous_turn_settings.cyber_access_program, + ); let previous_model_turn_context = Arc::new(previous_model_turn_context); if should_compact_for_comp_hash_change { @@ -1396,7 +1402,7 @@ async fn maybe_run_previous_model_inline_compact( let fallback_step_context = capture_current_model_fallback_step_context( sess, turn_context, - previous_model.as_str(), + &previous_model_turn_context, cancellation_token, ) .await?; @@ -1444,7 +1450,7 @@ async fn maybe_run_previous_model_inline_compact( let fallback_step_context = capture_current_model_fallback_step_context( sess, turn_context, - previous_model.as_str(), + &previous_model_turn_context, cancellation_token, ) .await?; diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 8f94f0b81b..4db28491b2 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -3,6 +3,7 @@ use super::token_budget::has_explicit_settings; use super::token_budget::resolve_token_budget; use super::*; use crate::config::TokenBudgetConfig; +use crate::cyber_access_program; use crate::environment_selection::EnvironmentConfigOrigin; use crate::environment_selection::TurnEnvironmentSnapshot; use crate::exec_policy::AllowPrefixRules; @@ -1439,9 +1440,10 @@ impl Session { turn_context.realtime_active = self.conversation.running_state().await.is_some(); turn_context.final_output_json_schema = options.final_output_json_schema; - if turn_context.config.model_provider_id == codex_model_provider_info::OPENAI_PROVIDER_ID { - turn_context.cyber_access_program = options.cyber_access_program; - } + turn_context.cyber_access_program = cyber_access_program::for_provider( + &turn_context.config.model_provider_id, + options.cyber_access_program, + ); let turn_context = Arc::new(turn_context); if git_enrichment_policy == GitEnrichmentPolicy::Fresh && turn_context diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 6549a92c8f..08a9df5d9b 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -2853,8 +2853,12 @@ async fn pre_sampling_compact_falls_back_when_previous_model_is_not_found() { ); } +#[test_case::test_case("openai"; "built in provider")] +#[test_case::test_case("local"; "cloud agent provider alias")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn pre_sampling_compact_falls_back_after_previous_model_invalid_request_on_downshift() { +async fn pre_sampling_compact_falls_back_after_previous_model_invalid_request_on_downshift( + provider_id: &'static str, +) { skip_if_no_network!(); let server = MockServer::start().await; @@ -2909,11 +2913,15 @@ async fn pre_sampling_compact_falls_back_after_previous_model_invalid_request_on .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_model(retired_model) .with_config(move |config| { + config.model_provider_id = provider_id.to_owned(); config.model_provider = model_provider; config.tool_registry.turn_metadata_includes_tool_info = true; set_test_compact_prompt(config); }); - let test = builder.build(&server).await.expect("build test codex"); + let test = builder + .build_with_auto_env(&server) + .await + .expect("build test codex"); test.codex .start_or_steer_turn(disabled_permission_user_turn( diff --git a/codex-rs/core/tests/suite/compact_program_tests.rs b/codex-rs/core/tests/suite/compact_program_tests.rs index 3a357df410..808fffe145 100644 --- a/codex-rs/core/tests/suite/compact_program_tests.rs +++ b/codex-rs/core/tests/suite/compact_program_tests.rs @@ -8,6 +8,7 @@ use codex_core::TurnStartOptions; use codex_protocol::turn_input::CyberAccessProgram; use pretty_assertions::assert_eq; use test_case::test_case; +use wiremock::ResponseTemplate; #[derive(Clone, Copy, Debug)] enum History { @@ -18,6 +19,8 @@ enum History { IncompleteRollback, Checkpoint, ApiKeyResume, + ApiKeyEnabledResume, + ApiKeyCustomProviderResume, } #[derive(Clone, Copy, Debug)] @@ -73,7 +76,11 @@ async fn submit_pair( #[test_case(Compaction::RemoteHash, History::Resume, Some(CyberAccessProgram::Standard); "standard")] #[test_case(Compaction::LocalHash, History::Live, None; "local missing program")] #[test_case(Compaction::Fallback, History::Live, Some(CyberAccessProgram::DaybreakBlue); "fallback")] -#[test_case(Compaction::LocalHash, History::ApiKeyResume, Some(CyberAccessProgram::DaybreakBlue); "api key cannot inherit authorization")] +#[test_case(Compaction::LocalHash, History::ApiKeyResume, Some(CyberAccessProgram::DaybreakBlue); "api key requires rollout features")] +#[test_case(Compaction::LocalHash, History::ApiKeyEnabledResume, Some(CyberAccessProgram::DaybreakBlue); "api key local resume")] +#[test_case(Compaction::RemoteHash, History::ApiKeyEnabledResume, Some(CyberAccessProgram::DaybreakBlue); "api key remote resume")] +#[test_case(Compaction::LocalHash, History::ApiKeyCustomProviderResume, Some(CyberAccessProgram::DaybreakBlue); "api key custom provider local resume")] +#[test_case(Compaction::RemoteHash, History::ApiKeyCustomProviderResume, Some(CyberAccessProgram::DaybreakBlue); "api key custom provider remote resume")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn model_switch_program_pair( compaction: Compaction, @@ -204,7 +211,9 @@ async fn model_switch_program_pair( | History::Rollback | History::IncompleteRollback | History::Checkpoint - | History::ApiKeyResume => { + | History::ApiKeyResume + | History::ApiKeyEnabledResume + | History::ApiKeyCustomProviderResume => { initial.codex.shutdown_and_wait().await?; let rollout_path = initial.codex.rollout_path().expect("rollout"); if matches!(history, History::Rollback | History::IncompleteRollback) { @@ -239,13 +248,34 @@ async fn model_switch_program_pair( + "\n", )?; } - if matches!(history, History::ApiKeyResume) { + if matches!( + history, + History::ApiKeyResume + | History::ApiKeyEnabledResume + | History::ApiKeyCustomProviderResume + ) { builder = builder.with_auth(CodexAuth::from_api_key("test-key")); } let resume_config = initial.config.clone(); builder = builder.with_config(move |config| { config.model_catalog = resume_config.model_catalog; config.model_provider = resume_config.model_provider; + if matches!( + history, + History::ApiKeyEnabledResume | History::ApiKeyCustomProviderResume + ) { + config + .features + .enable(Feature::ApiKeyModelDiscovery) + .expect("enable API-key model discovery"); + config + .features + .enable(Feature::ApiKeyCyberAccessPrograms) + .expect("enable API-key Cyber access programs"); + } + if matches!(history, History::ApiKeyCustomProviderResume) { + config.model_provider_id = "custom".to_owned(); + } set_test_compact_prompt(config); }); resumed = builder @@ -301,7 +331,25 @@ async fn model_switch_program_pair( submit_pair(&thread, next_model, Some(CyberAccessProgram::DaybreakRed)).await?; thread.shutdown_and_wait().await?; let requests = requests.requests(); - let new_program = json!({"cyber": "daybreak_red"}); + if matches!( + history, + History::ApiKeyEnabledResume | History::ApiKeyCustomProviderResume + ) { + for request in &requests { + assert_eq!( + request.header("authorization").as_deref(), + Some("Bearer test-key") + ); + } + } + let (old_program, new_program) = if matches!(history, History::ApiKeyCustomProviderResume) { + for request in &requests { + assert_eq!(request.body_json().get("access_programs"), None); + } + (Value::Null, Value::Null) + } else { + (expected_program, json!({"cyber": "daybreak_red"})) + }; let actual = requests .iter() .map(|request| { @@ -316,9 +364,133 @@ async fn model_switch_program_pair( } following.push(json!([next_model, new_program])); assert_eq!(&actual[1..], following.as_slice()); - assert_eq!(actual[0], json!([previous_model, expected_program])); + assert_eq!(actual[0], json!([previous_model, old_program])); if !local { assert_eq!(requests[0].inputs_of_type("compaction_trigger").len(), 1); } Ok(()) } + +#[test_case("next-model", Some(CyberAccessProgram::Standard); "standard after model switch")] +#[test_case("gpt-5.5", None; "omitted after same model hash change")] +#[test_case("next-model", Some(CyberAccessProgram::DaybreakBlue); "explicit blue remains denied")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn api_key_resume_compaction_retries_current_program( + current_model: &str, + current_program: Option, +) -> Result<()> { + skip_if_no_network!(Ok(())); + let server = start_mock_server().await; + let previous_model = "gpt-5.5"; + let mut previous = + model_info_with_context_window(previous_model, /*context_window*/ 273_000); + previous.comp_hash = Some("previous-hash".to_owned()); + let mut current = previous.clone(); + current.slug = current_model.to_owned(); + current.comp_hash = Some("current-hash".to_owned()); + let initial_models = ModelsResponse { + models: vec![previous.clone()], + }; + let mut provider = openai_model_provider(&server); + provider.stream_max_retries = Some(0); + let mut builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_model(previous_model) + .with_config(move |config| { + config.model_catalog = Some(initial_models.clone()); + config.model_provider = provider; + }); + let initial = builder.build_with_auto_env(&server).await?; + mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("history", "previous history"), + ev_completed_with_tokens("history", /*total_tokens*/ 100), + ]), + ) + .await; + submit_pair( + &initial.codex, + previous_model, + Some(CyberAccessProgram::DaybreakBlue), + ) + .await?; + initial.codex.shutdown_and_wait().await?; + server.reset().await; + + // The resumed API key lacks Blue access, including when Blue is explicitly requested again. + let denied = ResponseTemplate::new(/*s*/ 403).set_body_json(json!({"error": { + "message": "API key is not entitled to daybreak_blue" + }})); + let mut replies = vec![denied.clone()]; + if current_program == Some(CyberAccessProgram::DaybreakBlue) { + replies.push(denied); + } else { + replies.push(sse_response(sse(vec![ + json!({"type": "response.output_item.done", "item": { + "type": "compaction", "encrypted_content": "AUTHORIZED_SUMMARY" + }}), + ev_completed_with_tokens("compact", /*total_tokens*/ 10), + ]))); + replies.push(sse_response(sse(vec![ev_completed("current")]))); + } + let requests = mount_response_sequence(&server, replies).await; + let models = if current_model == previous_model { + vec![current] + } else { + vec![previous, current] + }; + let provider = initial.config.model_provider.clone(); + builder = builder + .with_auth(CodexAuth::from_api_key("test-key")) + .with_config(move |config| { + config.model_catalog = Some(ModelsResponse { models }); + config.model_provider = provider; + config + .features + .enable(Feature::ApiKeyModelDiscovery) + .expect("enable API-key model discovery"); + config + .features + .enable(Feature::ApiKeyCyberAccessPrograms) + .expect("enable API-key Cyber access programs"); + }); + let resumed = builder + .resume( + &server, + Arc::clone(&initial.home), + initial.codex.rollout_path().expect("rollout"), + ) + .await?; + let result = submit_pair(&resumed.codex, current_model, current_program).await; + resumed.codex.shutdown_and_wait().await?; + let requests = requests.requests(); + for request in &requests { + assert_eq!( + request.header("authorization").as_deref(), + Some("Bearer test-key") + ); + } + let requests = requests + .iter() + .map(|request| { + let body = request.body_json(); + json!([body["model"], body["access_programs"]]) + }) + .collect::>(); + let program = current_program + .map(|program| json!({"cyber": program})) + .unwrap_or(Value::Null); + let mut expected = vec![ + json!([previous_model, {"cyber": "daybreak_blue"}]), + json!([current_model, program]), + ]; + if current_program == Some(CyberAccessProgram::DaybreakBlue) { + assert!(result.is_err(), "explicit Blue unexpectedly succeeded"); + } else { + assert!(result.is_ok(), "{result:?}; requests: {requests:?}"); + expected.push(json!([current_model, program])); + } + assert_eq!(requests, expected); + Ok(()) +} diff --git a/codex-rs/core/tests/suite/cyber_access_program.rs b/codex-rs/core/tests/suite/cyber_access_program.rs index 1d7ee9adae..dbb709aa2b 100644 --- a/codex-rs/core/tests/suite/cyber_access_program.rs +++ b/codex-rs/core/tests/suite/cyber_access_program.rs @@ -20,6 +20,7 @@ use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; use serde_json::json; +use test_case::test_case; use tokio::sync::oneshot; use wiremock::Mock; use wiremock::matchers::header; @@ -161,9 +162,9 @@ async fn recover_turn_restores_cyber_access_program_without_making_it_sticky() - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn cyber_access_program_omits_spoofed_custom_providers() -> Result<()> { core_test_support::skip_if_no_network!(Ok(())); - for (auth, provider_id) in [ - (CodexAuth::from_api_key("test-key"), "custom"), - (CodexAuth::create_dummy_chatgpt_auth_for_testing(), "custom"), + for auth in [ + CodexAuth::from_api_key("test-key"), + CodexAuth::create_dummy_chatgpt_auth_for_testing(), ] { let server = responses::start_mock_server().await; let request = responses::mount_sse_once( @@ -173,9 +174,9 @@ async fn cyber_access_program_omits_spoofed_custom_providers() -> Result<()> { .await; let test = test_codex() .with_auth(auth) - .with_config(move |config| { + .with_config(|config| { // Keep the display name "OpenAI": provider identity must not use it. - config.model_provider_id = provider_id.to_owned(); + config.model_provider_id = "custom".to_owned(); }) .build_with_auto_env(&server) .await?; @@ -188,8 +189,12 @@ async fn cyber_access_program_omits_spoofed_custom_providers() -> Result<()> { Ok(()) } +#[test_case(CodexAuth::create_dummy_chatgpt_auth_for_testing(); "chatgpt")] +#[test_case(CodexAuth::from_api_key("test-api-key"); "api_key")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn cyber_access_program_survives_mid_turn_remote_compaction_v2() -> Result<()> { +async fn cyber_access_program_survives_mid_turn_remote_compaction_v2( + auth: CodexAuth, +) -> Result<()> { core_test_support::skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; let requests = responses::mount_sse_sequence( @@ -216,8 +221,18 @@ async fn cyber_access_program_survives_mid_turn_remote_compaction_v2() -> Result ) .await; let test = test_codex() - .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) - .with_config(|config| config.model_auto_compact_token_limit = Some(200)) + .with_auth(auth) + .with_config(|config| { + config.model_auto_compact_token_limit = Some(200); + config + .features + .enable(Feature::ApiKeyModelDiscovery) + .expect("enable API-key model discovery"); + config + .features + .enable(Feature::ApiKeyCyberAccessPrograms) + .expect("enable API-key Cyber access programs"); + }) .build_with_auto_env(&server) .await?; @@ -391,8 +406,12 @@ async fn cyber_access_program_is_inherited_by_child_turns() -> Result<()> { Ok(()) } +#[test_case(CodexAuth::create_dummy_chatgpt_auth_for_testing(); "chatgpt")] +#[test_case(CodexAuth::from_api_key("test-api-key"); "api_key")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn cyber_access_program_changes_on_one_websocket_with_response_reuse() -> Result<()> { +async fn cyber_access_program_changes_on_one_websocket_with_response_reuse( + auth: CodexAuth, +) -> Result<()> { core_test_support::skip_if_no_network!(Ok(())); let response_ids = [ "prewarm", "first", "second", "blue", "red", "off", "omitted", @@ -405,7 +424,17 @@ async fn cyber_access_program_changes_on_one_websocket_with_response_reuse() -> ]) .await; let test = test_codex() - .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_auth(auth) + .with_config(|config| { + config + .features + .enable(Feature::ApiKeyModelDiscovery) + .expect("enable API-key model discovery"); + config + .features + .enable(Feature::ApiKeyCyberAccessPrograms) + .expect("enable API-key Cyber access programs"); + }) .build_with_websocket_server(&server) .await?; for program in [ diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 0ef0be6af9..6a268da298 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -28,6 +28,11 @@ pub struct Cli { #[arg(long = "thread-source", value_name = "SOURCE", global = true)] pub thread_source: Option, + /// Request an experimental Cyber access program for this turn (OpenAI provider only). + /// Omit to use server defaults. Not supported with review; fork requires a prompt. + #[arg(long, value_enum, value_name = "PROGRAM", global = true)] + pub cyber_access_program: Option, + /// Allow running Codex outside a Git repository. #[arg(long = "skip-git-repo-check", global = true, default_value_t = false)] pub skip_git_repo_check: bool, @@ -80,6 +85,24 @@ pub struct Cli { pub prompt: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +#[value(rename_all = "snake_case")] +pub enum CyberAccessProgramCliArg { + Standard, + DaybreakBlue, + DaybreakRed, +} + +impl From for codex_app_server_protocol::CyberAccessProgram { + fn from(value: CyberAccessProgramCliArg) -> Self { + match value { + CyberAccessProgramCliArg::Standard => Self::Standard, + CyberAccessProgramCliArg::DaybreakBlue => Self::DaybreakBlue, + CyberAccessProgramCliArg::DaybreakRed => Self::DaybreakRed, + } + } +} + impl std::ops::Deref for Cli { type Target = SharedCliOptions; diff --git a/codex-rs/exec/src/cli_tests.rs b/codex-rs/exec/src/cli_tests.rs index 423935904a..b6770e1d44 100644 --- a/codex-rs/exec/src/cli_tests.rs +++ b/codex-rs/exec/src/cli_tests.rs @@ -11,6 +11,8 @@ fn resume_parses_prompt_after_global_flags() { "--json", "--model", "gpt-5.2-codex", + "--cyber-access-program", + "daybreak_blue", "--dangerously-bypass-approvals-and-sandbox", "--skip-git-repo-check", "--ephemeral", @@ -22,6 +24,10 @@ fn resume_parses_prompt_after_global_flags() { assert!(cli.ephemeral); assert!(cli.ignore_user_config); assert!(cli.ignore_rules); + assert_eq!( + cli.cyber_access_program, + Some(CyberAccessProgramCliArg::DaybreakBlue) + ); let Some(Command::Resume(args)) = cli.command else { panic!("expected resume command"); }; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 6eddf37370..22e481a890 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -233,6 +233,7 @@ struct ExecRunArgs { skip_git_repo_check: bool, stderr_with_ansi: bool, thread_source: ThreadSource, + cyber_access_program: Option, } struct ManagedExecWorktree { @@ -268,6 +269,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result strict_config, shared, thread_source, + cyber_access_program, skip_git_repo_check, ephemeral, ignore_user_config, @@ -279,6 +281,17 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result output_schema: output_schema_path, mut config_overrides, } = cli; + if cyber_access_program.is_some() { + match command.as_ref() { + Some(ExecCommand::Review(_)) => { + anyhow::bail!("--cyber-access-program is not supported with `codex exec review`"); + } + Some(ExecCommand::Fork(args)) if args.prompt.is_none() && prompt.is_none() => { + anyhow::bail!("Forking with --cyber-access-program requires a prompt"); + } + Some(ExecCommand::Resume(_) | ExecCommand::Fork(_)) | None => {} + } + } let mut shared = shared.into_inner(); shared.take_auto_review_config_overrides(&mut config_overrides); let SharedCliOptions { @@ -757,6 +770,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result skip_git_repo_check, stderr_with_ansi, thread_source: thread_source.map(Into::into).unwrap_or(ThreadSource::User), + cyber_access_program: cyber_access_program.map(Into::into), }) .instrument(exec_span) .await @@ -858,6 +872,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { skip_git_repo_check, stderr_with_ansi, thread_source, + cyber_access_program, } = args; if config.daybreak_enabled && !matches!(&command, Some(ExecCommand::Review(_))) { @@ -1191,14 +1206,19 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { items, output_schema, } => { - let cyber_access_program = daybreak::program_for_turn( - &client, - &mut request_ids, - &session_configured.model, - &session_configured.model_provider_id, - daybreak_enabled, - ) - .await?; + let cyber_access_program = match cyber_access_program { + Some(program) => Some(program), + None => { + daybreak::program_for_turn( + &client, + &mut request_ids, + &session_configured.model, + &session_configured.model_provider_id, + daybreak_enabled, + ) + .await? + } + }; let response: TurnStartResponse = send_request_with_response( &client, ClientRequest::TurnStart { diff --git a/codex-rs/exec/tests/suite/cyber_access_program_tests.rs b/codex-rs/exec/tests/suite/cyber_access_program_tests.rs new file mode 100644 index 0000000000..f942da8d50 --- /dev/null +++ b/codex-rs/exec/tests/suite/cyber_access_program_tests.rs @@ -0,0 +1,102 @@ +//! Verify exec forwards Cyber selection per turn and rejects unsupported commands. + +use core_test_support::responses; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex_exec::test_codex_exec; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn cyber_access_program_is_selected_per_turn() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let test = test_codex_exec(); + std::fs::write( + test.home_path().join("config.toml"), + "[features]\napi_key_cyber_access_programs = true\napi_key_model_discovery = true\n", + )?; + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_sequence( + &server, + (0..4) + .map(|index| { + responses::sse(vec![responses::ev_completed(&format!( + "resp-cyber-{index}" + ))]) + }) + .collect(), + ) + .await; + + let seed = test + .cmd_with_server(&server) + .args([ + "--skip-git-repo-check", + "--json", + "--cyber-access-program", + "daybreak_blue", + "Respond only OK.", + ]) + .assert() + .success(); + let events = std::str::from_utf8(&seed.get_output().stdout)? + .lines() + .map(serde_json::from_str::) + .collect::, _>>()?; + let thread_id = events + .iter() + .find(|event| event["type"] == "thread.started") + .and_then(|event| event["thread_id"].as_str()) + .expect("new turn should emit a thread id"); + + for (command, prompt, program) in [ + ("resume", "Respond only OK.", Some("daybreak_red")), + ("fork", "-", Some("standard")), + ("fork", "Respond only OK.", None), + ] { + let mut cmd = test.cmd_with_server(&server); + cmd.args(["--skip-git-repo-check", command, thread_id]); + if let Some(program) = program { + cmd.args(["--cyber-access-program", program]); + } + if prompt == "-" { + cmd.write_stdin("Respond only OK."); + } + cmd.arg(prompt).assert().success(); + } + + assert_eq!( + response_mock + .requests() + .iter() + .map(|request| request.body_json().get("access_programs").cloned()) + .collect::>(), + vec![ + Some(json!({"cyber": "daybreak_blue"})), + Some(json!({"cyber": "daybreak_red"})), + Some(json!({"cyber": "standard"})), + None, + ] + ); + Ok(()) +} + +#[test] +fn cyber_access_program_requires_a_supported_turn() { + let test = test_codex_exec(); + for (args, message) in [ + ( + vec!["review", "--uncommitted"], + "is not supported with `codex exec review`", + ), + (vec!["fork", "synthetic-session"], "requires a prompt"), + ] { + test.cmd() + .args(["--cyber-access-program", "daybreak_blue"]) + .args(args) + .assert() + .failure() + .stderr(predicates::str::contains(message)); + } +} diff --git a/codex-rs/exec/tests/suite/daybreak.rs b/codex-rs/exec/tests/suite/daybreak.rs index 0b03c8dabc..2f021e4b9f 100644 --- a/codex-rs/exec/tests/suite/daybreak.rs +++ b/codex-rs/exec/tests/suite/daybreak.rs @@ -212,6 +212,46 @@ async fn exec_resumes_and_forks_the_saved_daybreak_choice() -> anyhow::Result<() Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_explicit_cyber_program_overrides_daybreak_for_one_turn() -> anyhow::Result<()> { + let (test, server) = configured_daybreak_exec().await?; + let response = + responses::mount_sse_once(&server, responses::sse_completed("explicit-program")).await; + let output = test + .cmd_with_server(&server) + .env_remove("CODEX_ACCESS_TOKEN") + .env_remove("CODEX_API_KEY") + .env_remove("OPENAI_API_KEY") + .args([ + "--skip-git-repo-check", + "--json", + "--cyber-access-program", + "daybreak_red", + "hello", + ]) + .output()?; + let thread_id = started_thread(output)?; + assert_eq!( + response.single_request().body_json()["access_programs"], + json!({"cyber": "daybreak_red"}) + ); + + let response = + responses::mount_sse_once(&server, responses::sse_completed("saved-daybreak")).await; + test.cmd_with_server(&server) + .env_remove("CODEX_ACCESS_TOKEN") + .env_remove("CODEX_API_KEY") + .env_remove("OPENAI_API_KEY") + .args(["--skip-git-repo-check", "resume", &thread_id, "continue"]) + .assert() + .success(); + assert_eq!( + response.single_request().body_json()["access_programs"], + json!({"cyber": "daybreak_blue"}) + ); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn exec_resume_override_does_not_change_a_standard_thread() -> anyhow::Result<()> { let (test, server) = configured_daybreak_exec().await?; diff --git a/codex-rs/exec/tests/suite/mod.rs b/codex-rs/exec/tests/suite/mod.rs index de152560c9..baf2957b82 100644 --- a/codex-rs/exec/tests/suite/mod.rs +++ b/codex-rs/exec/tests/suite/mod.rs @@ -6,6 +6,8 @@ mod approval_policy; mod auth_env; #[path = "completion_backfill_tests.rs"] mod completion_backfill; +#[path = "cyber_access_program_tests.rs"] +mod cyber_access_program; mod daybreak; mod ephemeral; mod hooks; diff --git a/sdk/typescript/src/exec.ts b/sdk/typescript/src/exec.ts index 853caff471..e1735d2c76 100644 --- a/sdk/typescript/src/exec.ts +++ b/sdk/typescript/src/exec.ts @@ -6,6 +6,7 @@ import { createRequire } from "node:module"; import type { CodexConfigObject, CodexConfigValue } from "./codexOptions"; import { SandboxMode, ModelReasoningEffort, ApprovalMode, WebSearchMode } from "./threadOptions"; +import type { CyberAccessProgram } from "./turnOptions"; export type CodexExecArgs = { input: string; @@ -28,6 +29,8 @@ export type CodexExecArgs = { skipGitRepoCheck?: boolean; // --output-schema outputSchemaFile?: string; + // --cyber-access-program + cyberAccessProgram?: CyberAccessProgram; // --config model_reasoning_effort modelReasoningEffort?: ModelReasoningEffort; // AbortSignal to cancel the execution @@ -140,6 +143,10 @@ export class CodexExec { commandArgs.push("--output-schema", args.outputSchemaFile); } + if (args.cyberAccessProgram !== undefined) { + commandArgs.push("--cyber-access-program", args.cyberAccessProgram); + } + if (args.modelReasoningEffort) { commandArgs.push("--config", `model_reasoning_effort="${args.modelReasoningEffort}"`); } diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index a74f391a74..cfda99024c 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -37,4 +37,4 @@ export type { ModelReasoningEffort, WebSearchMode, } from "./threadOptions"; -export type { TurnOptions } from "./turnOptions"; +export type { CyberAccessProgram, TurnOptions } from "./turnOptions"; diff --git a/sdk/typescript/src/thread.ts b/sdk/typescript/src/thread.ts index b34463e570..ead1767a94 100644 --- a/sdk/typescript/src/thread.ts +++ b/sdk/typescript/src/thread.ts @@ -86,6 +86,7 @@ export class Thread { workingDirectory: options?.workingDirectory, skipGitRepoCheck: options?.skipGitRepoCheck, outputSchemaFile: schemaPath, + cyberAccessProgram: turnOptions.cyberAccessProgram, modelReasoningEffort: options?.modelReasoningEffort, signal: turnOptions.signal, networkAccessEnabled: options?.networkAccessEnabled, diff --git a/sdk/typescript/src/turnOptions.ts b/sdk/typescript/src/turnOptions.ts index 1eda806694..f63d0ebe0a 100644 --- a/sdk/typescript/src/turnOptions.ts +++ b/sdk/typescript/src/turnOptions.ts @@ -1,4 +1,11 @@ +export type CyberAccessProgram = "standard" | "daybreak_blue" | "daybreak_red"; + export type TurnOptions = { + /** + * Experimental Cyber selection for this turn with the built-in OpenAI provider. + * Omit to use server defaults. + */ + cyberAccessProgram?: CyberAccessProgram; /** JSON schema describing the expected agent output. */ outputSchema?: unknown; /** AbortSignal to cancel the turn. */ diff --git a/sdk/typescript/tests/run.test.ts b/sdk/typescript/tests/run.test.ts index 9729c597b7..34a2742d1d 100644 --- a/sdk/typescript/tests/run.test.ts +++ b/sdk/typescript/tests/run.test.ts @@ -229,6 +229,48 @@ describe("Codex", () => { } }); + it("passes cyber access program selections only to the turns that specify them", async () => { + const { url, close } = await startResponsesTestProxy({ + responseBodies: [ + sse(responseCompleted("response_1")), + sse(responseCompleted("response_2")), + sse(responseCompleted("response_3")), + sse(responseCompleted("response_4")), + ], + }); + const { args: spawnArgs, restore } = codexExecSpy(); + const { client, cleanup } = createMockClient(url); + + try { + const thread = client.startThread(); + await thread.run("select blue", { cyberAccessProgram: "daybreak_blue" }); + const threadId = thread.id; + expect(threadId).toEqual(expect.any(String)); + + const streamed = await thread.runStreamed("select red", { + cyberAccessProgram: "daybreak_red", + }); + for await (const _ of streamed.events) { + // Consume the streamed turn before resuming the thread. + } + await thread.run("select standard", { cyberAccessProgram: "standard" }); + await thread.run("use automatic selection"); + + expect(spawnArgs).toHaveLength(4); + expectPair(spawnArgs[0], ["--cyber-access-program", "daybreak_blue"]); + expectPair(spawnArgs[1], ["--cyber-access-program", "daybreak_red"]); + expectPair(spawnArgs[2], ["--cyber-access-program", "standard"]); + expect(spawnArgs[3]).not.toContain("--cyber-access-program"); + for (const args of spawnArgs.slice(1)) { + expectPair(args, ["resume", threadId!]); + } + } finally { + cleanup(); + restore(); + await close(); + } + }, 15_000); + it.each(["high", "persistent"] as const)( "passes %s modelReasoningEffort to exec", async (effort) => {