Add per-turn Cyber access program selection to exec and the SDK (#49939)

## Why

`codex exec` and the TypeScript SDK need a way to request a Cyber access program for an individual turn. Resuming with an API key can also leave compaction using a saved program that the current credentials cannot access.

## What changed

- Add `--cyber-access-program` with `standard`, `daybreak_blue`, and `daybreak_red`, and expose the equivalent `cyberAccessProgram` turn option and `CyberAccessProgram` type in the TypeScript SDK.
- Apply explicit selections to new, resumed, and prompted fork turns. Override automatic Daybreak selection for that turn without changing the saved choice. Reject the flag for review and forks without a prompt.
- Filter Cyber programs by the built-in OpenAI provider when starting turns and restoring previous-turn settings for compaction.
- Allow previous-model compaction fallback with API-key authentication, comparing both model and program so a program change can trigger a retry with the current pair even when the model is unchanged.

## Testing

Add CLI help, argument parsing, exec, and SDK coverage for per-turn selection and unsupported commands. Extend core tests for API-key compaction retries, denied explicit selections, custom-provider filtering, remote compaction, and WebSocket response reuse.

GitOrigin-RevId: 586f25b621924f3bc509c9a39b39fe2020ff9cd2
This commit is contained in:
mldangelo-oai
2026-10-01 08:57:53 +00:00
committed by copyberry
parent 444da310e1
commit 6b4daafdb4
19 changed files with 548 additions and 40 deletions
+30
View File
@@ -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::<Vec<_>>()
.join("\n");
insta::assert_snapshot!(program_help.trim(), @r"
--cyber-access-program <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]
");
}
+3
View File
@@ -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;
+9 -1
View File
@@ -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<CyberAccessProgram>,
) -> Option<CyberAccessProgram> {
program.filter(|_| provider_id == OPENAI_PROVIDER_ID)
}
pub(crate) fn for_auth(
auth: Option<&CodexAuth>,
program: Option<CyberAccessProgram>,
+16 -10
View File
@@ -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<Session>,
turn_context: &Arc<TurnContext>,
previous_model: &str,
previous_turn_context: &TurnContext,
cancellation_token: &CancellationToken,
) -> CodexResult<Option<Arc<StepContext>>> {
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?;
+5 -3
View File
@@ -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
+10 -2
View File
@@ -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(
@@ -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<CyberAccessProgram>,
) -> 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::<Vec<_>>();
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(())
}
@@ -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 [
+23
View File
@@ -28,6 +28,11 @@ pub struct Cli {
#[arg(long = "thread-source", value_name = "SOURCE", global = true)]
pub thread_source: Option<ThreadSource>,
/// 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<CyberAccessProgramCliArg>,
/// 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<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
#[value(rename_all = "snake_case")]
pub enum CyberAccessProgramCliArg {
Standard,
DaybreakBlue,
DaybreakRed,
}
impl From<CyberAccessProgramCliArg> 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;
+6
View File
@@ -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");
};
+28 -8
View File
@@ -233,6 +233,7 @@ struct ExecRunArgs {
skip_git_repo_check: bool,
stderr_with_ansi: bool,
thread_source: ThreadSource,
cyber_access_program: Option<codex_app_server_protocol::CyberAccessProgram>,
}
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 {
@@ -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::<Value>)
.collect::<Result<Vec<_>, _>>()?;
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<_>>(),
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));
}
}
+40
View File
@@ -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?;
+2
View File
@@ -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;
+7
View File
@@ -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}"`);
}
+1 -1
View File
@@ -37,4 +37,4 @@ export type {
ModelReasoningEffort,
WebSearchMode,
} from "./threadOptions";
export type { TurnOptions } from "./turnOptions";
export type { CyberAccessProgram, TurnOptions } from "./turnOptions";
+1
View File
@@ -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,
+7
View File
@@ -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. */
+42
View File
@@ -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) => {