Support explicit cyber access programs with OpenAI API keys (#49406)

## What changed

Forward explicit `cyberAccessProgram` selections for the built-in OpenAI provider when both `api_key_cyber_access_programs` and `api_key_model_discovery` are enabled. The new feature defaults to disabled and can be toggled through the app server's experimental feature enablement API.

Reject explicit selections in OpenAI API-key sessions when either feature is disabled, instead of silently omitting the program. Leave entitlement and model restrictions to the server, and preserve the requested program when a server rejection triggers transport retries.

## Testing

Add app-server coverage for both feature gates, forwarding all three program values, and server rejection without fallback. Update core coverage for API-key resumes with disabled features and custom-provider omission.

GitOrigin-RevId: aa639afb841aa651b28f6f3f61198299372baa2d
This commit is contained in:
Brian Lee
2026-09-30 00:08:55 +00:00
committed by copyberry
parent 17a9df60e4
commit 7c35e1551f
20 changed files with 333 additions and 29 deletions
+1 -1
View File
@@ -762,7 +762,7 @@
"type": "string"
},
"CyberAccessProgram": {
"description": "Requested cyber treatment for a ChatGPT-authenticated Codex turn. Authorization and model-tier restrictions remain server-owned.",
"description": "Requested cyber treatment for an OpenAI model turn. Authorization and model-tier restrictions remain server-owned.",
"enum": [
"standard",
"daybreakBlue",
@@ -10712,7 +10712,7 @@
"type": "object"
},
"CyberAccessProgram": {
"description": "Requested cyber treatment for a ChatGPT-authenticated Codex turn. Authorization and model-tier restrictions remain server-owned.",
"description": "Requested cyber treatment for an OpenAI model turn. Authorization and model-tier restrictions remain server-owned.",
"enum": [
"standard",
"daybreakBlue",
@@ -6568,7 +6568,7 @@
"type": "object"
},
"CyberAccessProgram": {
"description": "Requested cyber treatment for a ChatGPT-authenticated Codex turn. Authorization and model-tier restrictions remain server-owned.",
"description": "Requested cyber treatment for an OpenAI model turn. Authorization and model-tier restrictions remain server-owned.",
"enum": [
"standard",
"daybreakBlue",
@@ -2,7 +2,7 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"CyberAccessProgram": {
"description": "Requested cyber treatment for a ChatGPT-authenticated Codex turn. Authorization and model-tier restrictions remain server-owned.",
"description": "Requested cyber treatment for an OpenAI model turn. Authorization and model-tier restrictions remain server-owned.",
"enum": [
"standard",
"daybreakBlue",
@@ -121,7 +121,7 @@
"type": "object"
},
"CyberAccessProgram": {
"description": "Requested cyber treatment for a ChatGPT-authenticated Codex turn. Authorization and model-tier restrictions remain server-owned.",
"description": "Requested cyber treatment for an OpenAI model turn. Authorization and model-tier restrictions remain server-owned.",
"enum": [
"standard",
"daybreakBlue",
@@ -3,7 +3,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Requested cyber treatment for a ChatGPT-authenticated Codex turn.
* Requested cyber treatment for an OpenAI model turn.
* Authorization and model-tier restrictions remain server-owned.
*/
export type CyberAccessProgram = "standard" | "daybreakBlue" | "daybreakRed";
@@ -118,7 +118,7 @@ pub struct AdditionalContextEntry {
pub kind: AdditionalContextKind,
}
/// Requested cyber treatment for a ChatGPT-authenticated Codex turn.
/// Requested cyber treatment for an OpenAI model turn.
/// Authorization and model-tier restrictions remain server-owned.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
@@ -271,7 +271,7 @@ pub struct TurnStartParams {
#[ts(optional = nullable)]
pub multi_agent_mode: Option<MultiAgentMode>,
/// EXPERIMENTAL - Request a workspace-authorized cyber program for this
/// EXPERIMENTAL - Request an authorized cyber program for this
/// turn. Omission preserves automatic behavior. This does not grant access.
#[experimental("turn/start.cyberAccessProgram")]
#[ts(optional = nullable)]
@@ -64,6 +64,7 @@ const BACKGROUND_PAGINATED_ROLLOUT_MIGRATION_FEATURE: &str =
"background_paginated_rollout_migration";
const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[
"api_key_cyber_access_programs",
"api_key_model_discovery",
"auth_elicitation",
BACKGROUND_PAGINATED_ROLLOUT_MIGRATION_FEATURE,
@@ -3,7 +3,10 @@ use app_test_support::ChatGptAuthFixture;
use app_test_support::TestAppServer;
use app_test_support::write_chatgpt_auth;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::CodexErrorInfo;
use codex_app_server_protocol::CyberAccessProgram;
use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams;
use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadForkParams;
@@ -16,13 +19,18 @@ use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartedNotification;
use codex_app_server_protocol::TurnError;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::UserInput;
use codex_login::AuthCredentialsStoreMode;
use codex_login::AuthKeyringBackendKind;
use codex_login::login_with_api_key;
use core_test_support::responses;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::path::Path;
use tempfile::TempDir;
use wiremock::Mock;
@@ -192,6 +200,167 @@ async fn turn_start_forwards_explicit_cyber_access_program() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn api_key_cyber_access_program_requires_both_features() -> Result<()> {
core_test_support::skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let expected_programs = [
None,
None,
None,
Some(json!({"cyber": "daybreak_blue"})),
Some(json!({"cyber": "daybreak_red"})),
Some(json!({"cyber": "standard"})),
];
let requests = responses::mount_sse_sequence(
&server,
expected_programs
.iter()
.enumerate()
.map(|(index, _)| responses::sse_completed(&format!("resp-{index}")))
.collect(),
)
.await;
let home = TempDir::new()?;
let mut app = start_api_key_app(home.path(), &server).await?;
set_api_key_cyber_access_programs(&mut app, /*enabled*/ true).await?;
for disabled_feature in [
Some("api_key_cyber_access_programs"),
Some("api_key_model_discovery"),
None,
] {
let thread = app
.start_thread(ThreadStartParams {
config: disabled_feature
.map(|feature| HashMap::from([(format!("features.{feature}"), json!(false))])),
..Default::default()
})
.await?
.thread;
for program in [
None,
Some(CyberAccessProgram::DaybreakBlue),
Some(CyberAccessProgram::DaybreakRed),
Some(CyberAccessProgram::Standard),
] {
let completed = app
.start_turn_and_wait_for_completion(TurnStartParams {
thread_id: thread.id.clone(),
cyber_access_program: program,
input: vec![UserInput::Text {
text: "hello".to_owned(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
if program.is_some() && disabled_feature.is_some() {
assert_eq!(completed.turn.status, TurnStatus::Failed);
assert_eq!(
completed.turn.error,
Some(TurnError {
message: "Cyber access programs are disabled for this API-key session."
.to_owned(),
codex_error_info: Some(CodexErrorInfo::Other),
additional_details: None,
misalignment: None,
})
);
} else {
assert_eq!(completed.turn.status, TurnStatus::Completed);
}
}
}
assert_eq!(
requests
.requests()
.iter()
.map(|request| (
request.header("authorization"),
request.body_json()["model"].clone(),
request.body_json().get("access_programs").cloned(),
))
.collect::<Vec<_>>(),
expected_programs
.into_iter()
.map(|program| (
Some("Bearer test-key".to_owned()),
json!("gpt-6-sol"),
program
))
.collect::<Vec<_>>()
);
app.shutdown_gracefully().await?;
Ok(())
}
#[tokio::test]
async fn api_key_cyber_access_program_server_rejection_does_not_fall_back() -> Result<()> {
core_test_support::skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
Mock::given(method("POST"))
.and(path("/v1/responses"))
.respond_with(ResponseTemplate::new(/*s*/ 403).set_body_json(json!({
"error": {
"type": "invalid_request_error",
"code": "access_program_not_enabled",
"message": "Daybreak Blue is not enabled for this project."
}
})))
.expect(/*r*/ 1..)
.mount(&server)
.await;
let home = TempDir::new()?;
let mut app = start_api_key_app(home.path(), &server).await?;
set_api_key_cyber_access_programs(&mut app, /*enabled*/ true).await?;
let thread = app.start_thread(ThreadStartParams::default()).await?.thread;
let completed = app
.start_turn_and_wait_for_completion(TurnStartParams {
thread_id: thread.id,
cyber_access_program: Some(CyberAccessProgram::DaybreakBlue),
input: vec![UserInput::Text {
text: "hello".to_owned(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
assert_eq!(completed.turn.status, TurnStatus::Failed);
let error = completed.turn.error.expect("server rejection");
assert_eq!(
error.codex_error_info,
Some(CodexErrorInfo::HttpConnectionFailed {
http_status_code: Some(403),
})
);
assert!(
error
.message
.contains("Daybreak Blue is not enabled for this project.")
);
let requests = responses::received_responses_requests(&server).await;
assert!(!requests.is_empty());
// Existing transport retries may repeat the request; none may drop the program.
assert_eq!(
requests
.iter()
.map(|request| (
request.header("authorization"),
request.body_json()["access_programs"].clone()
))
.collect::<Vec<_>>(),
vec![
(
Some("Bearer test-key".to_owned()),
json!({"cyber": "daybreak_blue"})
);
requests.len()
]
);
app.shutdown_gracefully().await?;
Ok(())
}
#[tokio::test]
async fn daybreak_thread_metadata_persists_independently_across_restart_and_fork() -> Result<()> {
core_test_support::skip_if_no_network!(Ok(()));
@@ -461,3 +630,55 @@ async fn start_chatgpt_app(home: &Path, server: &MockServer) -> Result<TestAppSe
.build_initialized()
.await
}
async fn start_api_key_app(home: &Path, server: &MockServer) -> Result<TestAppServer> {
Mock::given(method("GET"))
.and(path("/v1/responses"))
.respond_with(ResponseTemplate::new(/*s*/ 426))
.mount(server)
.await;
// The existing inference override disables remote catalog discovery. Forwarding
// must therefore work without granting programs through a test catalog.
std::fs::write(
home.join("config.toml"),
format!(
"model = \"gpt-6-sol\"\nopenai_base_url = \"{}/v1\"\n[features]\napi_key_model_discovery = true\n",
server.uri()
),
)?;
login_with_api_key(
home,
"test-key",
AuthCredentialsStoreMode::File,
AuthKeyringBackendKind::default(),
)?;
TestAppServer::builder()
.with_codex_home(home)
.without_managed_config()
.with_env_overrides(&[
("OPENAI_API_KEY", None),
("CODEX_API_KEY", None),
("OPENAI_BASE_URL", None),
])
.build_initialized()
.await
}
async fn set_api_key_cyber_access_programs(app: &mut TestAppServer, enabled: bool) -> Result<()> {
let enablement = BTreeMap::from([("api_key_cyber_access_programs".to_owned(), enabled)]);
let response: ExperimentalFeatureEnablementSetResponse = app
.request(
|request_id| ClientRequest::ExperimentalFeatureEnablementSet {
request_id,
params: ExperimentalFeatureEnablementSetParams {
enablement: enablement.clone(),
},
},
)
.await?;
assert_eq!(
response,
ExperimentalFeatureEnablementSetResponse { enablement }
);
Ok(())
}
+6
View File
@@ -713,6 +713,9 @@
"analytics_plan_history": {
"type": "boolean"
},
"api_key_cyber_access_programs": {
"type": "boolean"
},
"api_key_model_discovery": {
"type": "boolean"
},
@@ -6740,6 +6743,9 @@
"analytics_plan_history": {
"type": "boolean"
},
"api_key_cyber_access_programs": {
"type": "boolean"
},
"api_key_model_discovery": {
"type": "boolean"
},
+10 -2
View File
@@ -272,6 +272,8 @@ pub struct ModelClient {
restored_history: bool,
request_contributors: Vec<Arc<dyn codex_extension_api::ModelRequestContributor>>,
executed_tool_calls: Option<ExecutedToolCalls>,
// Resolved once when the session is created, like other session feature flags.
api_key_cyber_access_programs: cyber_access_program::ApiKeyCyberAccessPrograms,
}
/// A turn-scoped streaming session created from a [`ModelClient`].
@@ -541,6 +543,8 @@ impl ModelClient {
restored_history: false,
request_contributors,
executed_tool_calls: None,
api_key_cyber_access_programs:
cyber_access_program::ApiKeyCyberAccessPrograms::UnsupportedProvider,
}
}
@@ -565,10 +569,12 @@ impl ModelClient {
prompt_cache_key_override: Option<String>,
event_sender: Sender<ProtocolEvent>,
codex_responses_headers: Option<Arc<CodexResponsesHeaders>>,
api_key_cyber_access_programs: cyber_access_program::ApiKeyCyberAccessPrograms,
) -> Self {
self.prompt_cache_key_override = prompt_cache_key_override;
self.event_sender = Some(event_sender);
self.codex_responses_headers = codex_responses_headers;
self.api_key_cyber_access_programs = api_key_cyber_access_programs;
self
}
@@ -1730,7 +1736,8 @@ impl ModelClientSession {
request.access_programs = cyber_access_program::for_auth(
client_setup.auth.as_ref(),
prompt.cyber_access_program,
);
self.client.api_key_cyber_access_programs,
)?;
self.client
.prepare_response_items_for_request(&mut request.input);
if crate::guardian::is_basic_session_source(&self.client.state.session_source) {
@@ -1889,7 +1896,8 @@ impl ModelClientSession {
request.access_programs = cyber_access_program::for_auth(
client_setup.auth.as_ref(),
prompt.cyber_access_program,
);
self.client.api_key_cyber_access_programs,
)?;
let mut websocket_metadata = responses_metadata.clone();
websocket_metadata.routing_hint = self.client.build_routing_hint_header(
client_setup.auth.as_ref(),
+47 -4
View File
@@ -1,12 +1,55 @@
//! Forward explicit programs while leaving entitlement and model policy to the server.
use codex_api::AccessPrograms;
use codex_features::Feature;
use codex_login::CodexAuth;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result;
use codex_protocol::turn_input::CyberAccessProgram;
#[derive(Clone, Copy, Debug)]
pub(crate) enum ApiKeyCyberAccessPrograms {
UnsupportedProvider,
Disabled,
Enabled,
}
impl ApiKeyCyberAccessPrograms {
pub(crate) fn from_config(config: &crate::config::Config) -> Self {
if config.model_provider_id != codex_model_provider_info::OPENAI_PROVIDER_ID {
Self::UnsupportedProvider
} else if config.features.enabled(Feature::ApiKeyCyberAccessPrograms)
&& config.features.enabled(Feature::ApiKeyModelDiscovery)
{
Self::Enabled
} else {
Self::Disabled
}
}
}
pub(crate) fn for_auth(
auth: Option<&CodexAuth>,
program: Option<CyberAccessProgram>,
) -> Option<AccessPrograms> {
program
.filter(|_| auth.is_some_and(CodexAuth::is_chatgpt_auth))
.map(AccessPrograms::from)
policy: ApiKeyCyberAccessPrograms,
) -> Result<Option<AccessPrograms>> {
let Some(program) = program else {
return Ok(None);
};
let Some(auth) = auth else {
return Ok(None);
};
if auth.is_chatgpt_auth() {
return Ok(Some(program.into()));
}
if !auth.is_api_key_auth() {
return Ok(None);
}
match policy {
ApiKeyCyberAccessPrograms::UnsupportedProvider => Ok(None),
ApiKeyCyberAccessPrograms::Disabled => Err(CodexErr::InvalidRequest(
"Cyber access programs are disabled for this API-key session.".to_owned(),
)),
ApiKeyCyberAccessPrograms::Enabled => Ok(Some(program.into())),
}
}
+1
View File
@@ -1763,6 +1763,7 @@ impl Session {
.or(fork_cache_key),
tx_event.clone(),
codex_responses_headers,
crate::cyber_access_program::ApiKeyCyberAccessPrograms::from_config(&config),
),
executed_tool_calls: executed_tool_calls.clone(),
code_mode_service: crate::tools::code_mode::CodeModeService::new(
@@ -534,6 +534,8 @@ async fn recorder_refreshes_without_changing_execution_features_or_claiming_miss
crate::ConfigRefreshOutcome::Published
);
let mut replay = output(&call.call_id);
// Replay the same history item across the configuration refresh.
replay.set_id(direct_output.id().cloned());
calls.attach_direct_call_to_output(&mut replay, prepared);
assert_eq!(replay, direct_output);
}
@@ -254,6 +254,30 @@ async fn model_switch_program_pair(
Arc::clone(&resumed.codex)
}
};
if matches!(history, History::ApiKeyResume) {
// Resuming a ChatGPT turn does not enable the API-key rollout features.
let error = submit_pair(&thread, next_model, Some(CyberAccessProgram::DaybreakRed))
.await
.expect_err("API-key selections require the rollout features");
assert!(
error
.to_string()
.contains("Cyber access programs are disabled for this API-key session.")
);
thread.shutdown_and_wait().await?;
assert_eq!(
server
.received_requests()
.await
.expect("recorded fixture requests")
.iter()
.filter(|request| request.method == "POST" && request.url.path() == "/v1/responses")
.count(),
1,
"only the original ChatGPT turn should reach inference"
);
return Ok(());
}
let summary = if local {
ev_assistant_message("summary", "summary of surviving history")
} else {
@@ -277,17 +301,7 @@ 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 authorized = !matches!(history, History::ApiKeyResume);
let old_program = if authorized {
expected_program
} else {
Value::Null
};
let new_program = if authorized {
json!({"cyber": "daybreak_red"})
} else {
Value::Null
};
let new_program = json!({"cyber": "daybreak_red"});
let actual = requests
.iter()
.map(|request| {
@@ -302,7 +316,7 @@ 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, old_program]));
assert_eq!(actual[0], json!([previous_model, expected_program]));
if !local {
assert_eq!(requests[0].inputs_of_type("compaction_trigger").len(), 1);
}
@@ -159,10 +159,10 @@ 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_api_key_and_spoofed_custom_provider() -> Result<()> {
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"), "openai"),
(CodexAuth::from_api_key("test-key"), "custom"),
(CodexAuth::create_dummy_chatgpt_auth_for_testing(), "custom"),
] {
let server = responses::start_mock_server().await;
+8
View File
@@ -96,6 +96,8 @@ pub enum Feature {
AnalyticsPlanHistory,
/// Discover model catalogs for OpenAI API-key authentication.
ApiKeyModelDiscovery,
/// Forward explicit programs with builtin OpenAI API keys.
ApiKeyCyberAccessPrograms,
/// Deprecated no-op; use `tui.fullscreen_transcript` instead.
TranscriptV2,
// Stable.
@@ -1300,6 +1302,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::ApiKeyCyberAccessPrograms,
key: "api_key_cyber_access_programs",
stage: Stage::Stable,
default_enabled: false,
},
FeatureSpec {
id: Feature::EnableRequestCompression,
key: "enable_request_compression",
+1 -1
View File
@@ -142,7 +142,7 @@ pub enum TurnInputMode {
Steer { expected_turn_id: String },
}
/// Requested cyber treatment for a ChatGPT-authenticated Codex turn.
/// Requested cyber treatment for an OpenAI model turn.
/// Authorization and model-tier restrictions remain server-owned.
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]