mirror of
https://github.com/openai/codex.git
synced 2026-10-19 08:16:48 +00:00
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:
@@ -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]
|
||||
");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}"`);
|
||||
}
|
||||
|
||||
@@ -37,4 +37,4 @@ export type {
|
||||
ModelReasoningEffort,
|
||||
WebSearchMode,
|
||||
} from "./threadOptions";
|
||||
export type { TurnOptions } from "./turnOptions";
|
||||
export type { CyberAccessProgram, TurnOptions } from "./turnOptions";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user