Enforce host-required cyber access programs per thread (#59754)

## What changed

- Expose `RequiredCyberAccessPrograms` so hosts can require an explicit allowed program on a thread. Snapshot the requirement before extension callbacks; hosts must reapply it on cold resume.
- Validate resolved `access_programs` before HTTP and WebSocket model requests, rejecting omitted or disallowed programs. Explicit `Standard` remains distinct from omission. Reject memory summarization and realtime requests when a requirement is present.
- Keep required programs scoped to the parent thread by preventing Guardian reviewers from inheriting its program, including Guardian v2 reviews.

## Testing

Add HTTP and WebSocket coverage for allowed programs, omission, disallowed programs, unsupported providers, and connection reuse. Extend Guardian tests to verify parent-only program scope and that removing the public attachment does not erase the startup snapshot.

GitOrigin-RevId: 9306a9af0e03e7a1728dfeb610cd7ec7517367fd
This commit is contained in:
jamy-OAI
2026-11-05 22:49:46 +00:00
committed by copyberry
parent 09358089b0
commit b313cca1cf
14 changed files with 472 additions and 16 deletions
+1
View File
@@ -52,6 +52,7 @@ pub use codex_core::NotSubmittedReason;
pub use codex_core::PromptCacheKey;
pub use codex_core::PromptCacheTtl;
pub use codex_core::RecoverTurnRequest;
pub use codex_core::RequiredCyberAccessPrograms;
pub use codex_core::StartIfIdleSubmission;
pub use codex_core::StartThreadOptions;
pub use codex_core::StartupModels;
+29
View File
@@ -310,6 +310,7 @@ pub struct ModelClient {
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,
required_cyber_access_programs: Option<Arc<cyber_access_program::RequiredCyberAccessPrograms>>,
}
/// A turn-scoped streaming session created from a [`ModelClient`].
@@ -656,6 +657,7 @@ impl ModelClient {
executed_tool_calls: None,
api_key_cyber_access_programs:
cyber_access_program::ApiKeyCyberAccessPrograms::UnsupportedProvider,
required_cyber_access_programs: None,
}
}
@@ -682,15 +684,36 @@ impl ModelClient {
event_sender: Sender<ProtocolEvent>,
codex_responses_headers: Option<Arc<CodexResponsesHeaders>>,
api_key_cyber_access_programs: cyber_access_program::ApiKeyCyberAccessPrograms,
required_cyber_access_programs: Option<
Arc<cyber_access_program::RequiredCyberAccessPrograms>,
>,
) -> Self {
self.prompt_cache_key_policy = prompt_cache_key;
self.session_id_override = session_id_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.required_cyber_access_programs = required_cyber_access_programs;
self
}
pub(crate) fn validate_required_cyber_access_program(
&self,
programs: Option<codex_api::AccessPrograms>,
) -> Result<()> {
if let Some(required) = &self.required_cyber_access_programs
&& !required
.allowed
.iter()
.any(|program| programs == Some((*program).into()))
{
return Err(CodexErr::InvalidRequest(
"this thread requires an allowed explicit access program".into(),
));
}
Ok(())
}
pub(crate) fn prompt_cache_key_policy(&self) -> PromptCacheKey {
self.prompt_cache_key_policy.clone()
}
@@ -863,6 +886,7 @@ impl ModelClient {
if raw_memories.is_empty() {
return Ok(Vec::new());
}
self.validate_required_cyber_access_program(/*programs*/ None)?;
let client_setup = self
.current_client_setup(ClientRouting::ConfiguredProvider)
@@ -1200,6 +1224,7 @@ impl ModelClient {
// Realtime keeps provider auth until its runtime integration lands later in the stack.
async fn realtime_client_setup(&self) -> Result<CurrentClientSetup> {
self.validate_required_cyber_access_program(/*programs*/ None)?;
let provider = if self.state.provider.runtime_auth().is_some() {
create_model_provider(
self.state.provider.info().clone(),
@@ -1916,6 +1941,8 @@ impl ResponsesHttpExecutor<'_> {
prompt.cyber_access_program,
self.client.api_key_cyber_access_programs,
)?;
self.client
.validate_required_cyber_access_program(request.access_programs)?;
self.client
.prepare_response_items_for_request(&mut request.input);
let request_session_telemetry =
@@ -2076,6 +2103,8 @@ impl ModelClientSession {
prompt.cyber_access_program,
self.client.api_key_cyber_access_programs,
)?;
self.client
.validate_required_cyber_access_program(request.access_programs)?;
let mut websocket_metadata = responses_metadata.clone();
websocket_metadata.routing_hint = self.client.build_routing_hint_header(
client_setup.auth.as_ref(),
+21
View File
@@ -6,6 +6,27 @@ use codex_login::CodexAuth;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result;
use codex_protocol::turn_input::CyberAccessProgram;
use std::sync::Arc;
/// Host requirement for every model request issued by this thread.
///
/// Install in thread extension data before startup and reapply on cold resume.
/// Core snapshots it before extension callbacks; it is not inherited or persisted.
/// Every request must resolve to one listed program; backend authorization remains authoritative.
#[derive(Clone, Debug)]
pub struct RequiredCyberAccessPrograms {
pub allowed: Vec<CyberAccessProgram>,
}
pub(crate) struct RequiredCyberAccessProgramsSnapshot(pub Arc<RequiredCyberAccessPrograms>);
impl RequiredCyberAccessPrograms {
/// Read Core's startup snapshot, independent of later edits to the public attachment.
pub fn for_thread(data: &codex_extension_api::ExtensionData) -> Option<Arc<Self>> {
data.get::<RequiredCyberAccessProgramsSnapshot>()
.map(|snapshot| Arc::clone(&snapshot.0))
}
}
/// Configured support for explicit Cyber programs on API-key requests.
#[derive(Clone, Copy, Debug)]
+6 -1
View File
@@ -588,7 +588,12 @@ async fn run_review_on_session(
personality: params.personality,
model: review_model.model.clone(),
reasoning_effort: review_model.reasoning_effort.clone(),
cyber_access_program: parent_turn.cyber_access_program,
cyber_access_program: parent_turn.cyber_access_program.filter(|_| {
crate::RequiredCyberAccessPrograms::for_thread(
&params.parent_session.services.thread_extension_data,
)
.is_none()
}),
parent_response_id: params
.parent_context
.parent_response
+1
View File
@@ -93,6 +93,7 @@ pub mod context;
mod context_manager;
mod current_time;
pub mod cyber_access_program;
pub use cyber_access_program::RequiredCyberAccessPrograms;
mod elicitation;
mod environment_selection;
pub mod exec;
@@ -1396,6 +1396,9 @@ async fn prepare_realtime_start(
sess: &Arc<Session>,
params: ConversationStartParams,
) -> CodexResult<PreparedRealtimeConversationStart> {
sess.services
.model_client
.validate_required_cyber_access_program(/*programs*/ None)?;
let provider = sess.provider().await;
let auth_manager = sess
.services
+10
View File
@@ -1012,6 +1012,8 @@ impl Session {
));
}
};
let required_cyber_access_programs =
thread_extension_init.get::<crate::cyber_access_program::RequiredCyberAccessPrograms>();
let inherited_session_id = thread_extension_init
.remove::<InheritedSessionId>()
.map(|inherited| inherited.0);
@@ -1150,6 +1152,13 @@ impl Session {
thread_id.to_string(),
thread_extension_init,
);
if let Some(required) = &required_cyber_access_programs {
thread_extension_data.insert(
crate::cyber_access_program::RequiredCyberAccessProgramsSnapshot(Arc::clone(
required,
)),
);
}
thread_extension_data.insert(crate::context::GuardianReviewEvidence::default());
crate::model_search::SearchOwner::restore(&thread_extension_data, &initial_history);
// Kick off independent async setup tasks in parallel to reduce startup latency.
@@ -1924,6 +1933,7 @@ impl Session {
tx_event.clone(),
codex_responses_headers,
crate::cyber_access_program::ApiKeyCyberAccessPrograms::from_config(&config),
required_cyber_access_programs,
);
// Once tracing starts, startup cleanup must own the session before the next await.
let rollout_thread_trace = if matches!(
@@ -1,4 +1,4 @@
//! Verifies that reused Guardian reviewers use Blue for Daybreak without changing parent programs.
//! Verifies reviewer program selection and parent-only enrollment across reviewer reuse.
use anyhow::Result;
use codex_core::TurnInputRequest;
@@ -21,11 +21,14 @@ use pretty_assertions::assert_eq;
use serde_json::json;
use test_case::test_case;
#[test_case(CodexAuth::from_api_key("test-api-key"); "api_key")]
#[test_case(CodexAuth::create_dummy_chatgpt_auth_for_testing(); "chatgpt")]
#[test_case(CodexAuth::from_api_key("test-api-key"), false; "api_key")]
#[test_case(CodexAuth::create_dummy_chatgpt_auth_for_testing(), false; "chatgpt")]
#[test_case(CodexAuth::from_api_key("test-api-key"), true; "api_key_parent_only")]
#[test_case(CodexAuth::create_dummy_chatgpt_auth_for_testing(), true; "chatgpt_parent_only")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn guardian_reused_reviewer_uses_blue_without_changing_parent_cyber_access_program(
async fn guardian_reused_reviewer_respects_parent_program_scope(
auth: CodexAuth,
parent_only: bool,
) -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_wine_exec!(
@@ -34,7 +37,7 @@ async fn guardian_reused_reviewer_uses_blue_without_changing_parent_cyber_access
);
let server = responses::start_mock_server().await;
let test = test_codex()
let mut test = test_codex()
.with_auth(auth)
.with_model_info_override("gpt-6.1-sol", |_| {})
.with_model_info_override("gpt-5.5", |model| {
@@ -54,8 +57,25 @@ async fn guardian_reused_reviewer_uses_blue_without_changing_parent_cyber_access
})
.build_with_auto_env(&server)
.await?;
if parent_only {
let mut options = test.start_thread_options().await;
options
.thread_extension_init
.insert(codex_core::RequiredCyberAccessPrograms {
allowed: vec![
CyberAccessProgram::DaybreakBlue,
CyberAccessProgram::DaybreakRed,
CyberAccessProgram::Standard,
],
});
test.codex.shutdown_and_wait().await?;
test.codex = test.thread_manager.start_thread(options).await?.thread;
test.codex
.thread_extension_data()
.remove::<codex_core::RequiredCyberAccessPrograms>();
}
let cases = [
let mut cases = vec![
(
Some(CyberAccessProgram::DaybreakBlue),
Some("daybreak_blue"),
@@ -64,6 +84,9 @@ async fn guardian_reused_reviewer_uses_blue_without_changing_parent_cyber_access
(Some(CyberAccessProgram::Standard), Some("standard")),
(None, None),
];
if parent_only {
cases.pop();
}
let mut events = Vec::new();
for (index, _) in cases.iter().enumerate() {
events.push(responses::sse(vec![
@@ -131,12 +154,16 @@ async fn guardian_reused_reviewer_uses_blue_without_changing_parent_cyber_access
.iter()
.map(|(_, name)| name.map(|name| json!({ "cyber": name })))
.collect::<Vec<_>>();
let expected_guardian_programs = [
Some(json!({ "cyber": "daybreak_blue" })),
Some(json!({ "cyber": "daybreak_blue" })),
Some(json!({ "cyber": "standard" })),
None,
];
let expected_guardian_programs = if parent_only {
vec![None; cases.len()]
} else {
vec![
Some(json!({ "cyber": "daybreak_blue" })),
Some(json!({ "cyber": "daybreak_blue" })),
Some(json!({ "cyber": "standard" })),
None,
]
};
assert_eq!(
guardian_requests
.iter()
+2
View File
@@ -199,6 +199,8 @@ mod request_permissions_tool;
mod request_plugin_install;
mod request_user_input;
mod request_user_input_async;
#[path = "required_access_program_tests.rs"]
mod required_access_program;
mod responses_api_proxy_headers;
mod responses_lite;
#[cfg(target_os = "linux")]
@@ -0,0 +1,334 @@
//! Selected threads require an allowed program on every HTTP and WebSocket model request.
use anyhow::Result;
use codex_core::RequiredCyberAccessPrograms;
use codex_core::TurnInputRequest;
use codex_core::TurnStartOptions;
use codex_login::AuthHeaders;
use codex_login::CodexAuth;
use codex_protocol::protocol::EventMsg;
use codex_protocol::turn_input::CyberAccessProgram;
use codex_protocol::user_input::UserInput;
use core_test_support::responses;
use core_test_support::test_codex::TestCodex;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use http::HeaderMap;
use http::HeaderValue;
use pretty_assertions::assert_eq;
use serde_json::json;
use test_case::test_case;
const ACTOR: &str = "x-openai-actor-authorization";
const ACCOUNT: &str = "chatgpt-account-id";
fn actor_headers() -> HeaderMap {
HeaderMap::from_iter([
(
http::HeaderName::from_static(ACTOR),
HeaderValue::from_static("verified-actor"),
),
(
http::HeaderName::from_static(ACCOUNT),
HeaderValue::from_static("workspace-1"),
),
])
}
fn actor_auth() -> CodexAuth {
CodexAuth::Headers(AuthHeaders::new(actor_headers()))
}
async fn submit(test: &TestCodex, program: Option<CyberAccessProgram>) -> Result<()> {
test.codex
.start_or_steer_turn(
TurnInputRequest::user_input(vec![UserInput::Text {
text: "hello".to_owned(),
text_elements: Vec::new(),
}])
.on_start(TurnStartOptions {
cyber_access_program: program,
..Default::default()
}),
)
.await?;
let event = wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_) | EventMsg::Error(_))
})
.await;
if let EventMsg::Error(error) = event {
anyhow::bail!("actor program request failed: {error:?}");
}
Ok(())
}
/// A Blue/Red startup requirement rejects Standard, omission, and unsupported providers.
#[test_case(None, true, None; "omitted")]
#[test_case(Some(CyberAccessProgram::Standard), true, None; "standard_rejected")]
#[test_case(Some(CyberAccessProgram::DaybreakBlue), true, Some("daybreak_blue"); "blue")]
#[test_case(Some(CyberAccessProgram::DaybreakRed), true, Some("daybreak_red"); "red")]
#[test_case(Some(CyberAccessProgram::DaybreakBlue), false, None; "unsupported_provider")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn required_actor_program_http(
program: Option<CyberAccessProgram>,
provider_enabled: bool,
expected_wire_program: Option<&str>,
) -> Result<()> {
core_test_support::skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let requests = responses::mount_sse_once(&server, responses::sse_completed("resp-1")).await;
let test = test_codex()
.with_auth(actor_auth())
.with_config(move |config| {
config.model_provider_id = "local".to_owned();
config
.model_provider
.capabilities
.get_or_insert_default()
.cyber_access_programs = Some(provider_enabled);
})
.build_with_auto_env(&server)
.await?;
let mut options = test.start_thread_options().await;
options
.thread_extension_init
.insert(RequiredCyberAccessPrograms {
allowed: vec![
CyberAccessProgram::DaybreakBlue,
CyberAccessProgram::DaybreakRed,
],
});
let thread = test.thread_manager.start_thread(options).await?.thread;
thread
.thread_extension_data()
.remove::<RequiredCyberAccessPrograms>();
thread
.start_or_steer_turn(
TurnInputRequest::user_input(vec![UserInput::Text {
text: "hello".into(),
text_elements: Vec::new(),
}])
.on_start(TurnStartOptions {
cyber_access_program: program,
..Default::default()
}),
)
.await?;
let event = wait_for_event(&thread, |event| {
matches!(event, EventMsg::TurnComplete(_) | EventMsg::Error(_))
})
.await;
if let Some(expected_wire_program) = expected_wire_program {
assert!(matches!(event, EventMsg::TurnComplete(_)));
assert_eq!(
requests
.single_request()
.body_json()
.get("access_programs")
.cloned(),
Some(json!({"cyber": expected_wire_program}))
);
} else {
let EventMsg::Error(error) = event else {
panic!("host requirement must refuse request")
};
assert!(
error
.message
.contains("this thread requires an allowed explicit access program")
);
assert!(requests.requests().is_empty());
}
thread.shutdown_and_wait().await?;
test.codex.shutdown_and_wait().await?;
Ok(())
}
/// A Blue/Red requirement survives WebSocket reuse and rejects Standard or omission.
#[test_case(CyberAccessProgram::DaybreakBlue, "daybreak_blue", Some(CyberAccessProgram::Standard); "blue_then_standard")]
#[test_case(CyberAccessProgram::DaybreakRed, "daybreak_red", Some(CyberAccessProgram::Standard); "red_then_standard")]
#[test_case(CyberAccessProgram::DaybreakBlue, "daybreak_blue", None; "blue_then_omitted")]
#[test_case(CyberAccessProgram::DaybreakRed, "daybreak_red", None; "red_then_omitted")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn required_actor_program_websocket(
program: CyberAccessProgram,
expected_wire_program: &str,
rejected_program: Option<CyberAccessProgram>,
) -> Result<()> {
core_test_support::skip_if_no_network!(Ok(()));
let server = responses::start_websocket_server(vec![
vec![vec![responses::ev_completed("resp-warmup")]],
vec![vec![responses::ev_completed("resp-allowed")]],
])
.await;
let base_url = format!("{}/v1", server.uri().replacen("ws://", "http://", 1));
let mut test = test_codex()
.with_auth(actor_auth())
.with_config(move |config| {
config.model_provider_id = "local".to_owned();
config
.model_provider
.capabilities
.get_or_insert_default()
.cyber_access_programs = Some(true);
config.model_provider.base_url = Some(base_url);
})
.build_with_websocket_server(&server)
.await?;
server
.wait_for_request(/*connection_index*/ 0, /*request_index*/ 0)
.await;
let mut options = test.start_thread_options().await;
options
.thread_extension_init
.insert(RequiredCyberAccessPrograms {
allowed: vec![
CyberAccessProgram::DaybreakBlue,
CyberAccessProgram::DaybreakRed,
],
});
test.codex.shutdown_and_wait().await?;
test.codex = test.thread_manager.start_thread(options).await?.thread;
submit(&test, Some(program)).await?;
let error = submit(&test, rejected_program)
.await
.expect_err("Standard or omission must not satisfy a Blue/Red requirement");
assert!(
error
.to_string()
.contains("this thread requires an allowed explicit access program")
);
assert_eq!(
server
.connections()
.into_iter()
.flatten()
.map(|request| request.body_json().get("access_programs").cloned())
.collect::<Vec<_>>(),
vec![None, Some(json!({"cyber": expected_wire_program}))]
);
test.codex.shutdown_and_wait().await?;
server.shutdown().await;
Ok(())
}
/// A Standard-only host requirement accepts explicit Standard, never omission.
#[test_case(Some(CyberAccessProgram::Standard), Some("standard"); "standard")]
#[test_case(None, None; "omitted")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn required_actor_program_http_standard_only(
program: Option<CyberAccessProgram>,
expected_wire_program: Option<&str>,
) -> Result<()> {
core_test_support::skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let requests = responses::mount_sse_once(&server, responses::sse_completed("resp-1")).await;
let mut test = test_codex()
.with_auth(actor_auth())
.with_config(|config| {
config.model_provider_id = "local".to_owned();
config
.model_provider
.capabilities
.get_or_insert_default()
.cyber_access_programs = Some(true);
})
.build_with_auto_env(&server)
.await?;
let mut options = test.start_thread_options().await;
options
.thread_extension_init
.insert(RequiredCyberAccessPrograms {
allowed: vec![CyberAccessProgram::Standard],
});
test.codex.shutdown_and_wait().await?;
test.codex = test.thread_manager.start_thread(options).await?.thread;
let result = submit(&test, program).await;
if let Some(expected_wire_program) = expected_wire_program {
result?;
assert_eq!(
requests
.single_request()
.body_json()
.get("access_programs")
.cloned(),
Some(json!({"cyber": expected_wire_program}))
);
} else {
let error = result.expect_err("omission must not silently select Standard");
assert!(
error
.to_string()
.contains("this thread requires an allowed explicit access program")
);
assert!(requests.requests().is_empty());
}
test.codex.shutdown_and_wait().await?;
Ok(())
}
/// A Standard-only WebSocket requirement also distinguishes Standard from omission.
#[test_case(Some(CyberAccessProgram::Standard), Some("standard"); "standard")]
#[test_case(None, None; "omitted")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn required_actor_program_websocket_standard_only(
program: Option<CyberAccessProgram>,
expected_wire_program: Option<&str>,
) -> Result<()> {
core_test_support::skip_if_no_network!(Ok(()));
let server = responses::start_websocket_server(vec![
vec![vec![responses::ev_completed("resp-warmup")]],
vec![vec![responses::ev_completed("resp-allowed")]],
])
.await;
let base_url = format!("{}/v1", server.uri().replacen("ws://", "http://", 1));
let mut test = test_codex()
.with_auth(actor_auth())
.with_config(move |config| {
config.model_provider_id = "local".to_owned();
config
.model_provider
.capabilities
.get_or_insert_default()
.cyber_access_programs = Some(true);
config.model_provider.base_url = Some(base_url);
})
.build_with_websocket_server(&server)
.await?;
server
.wait_for_request(/*connection_index*/ 0, /*request_index*/ 0)
.await;
let mut options = test.start_thread_options().await;
options
.thread_extension_init
.insert(RequiredCyberAccessPrograms {
allowed: vec![CyberAccessProgram::Standard],
});
test.codex.shutdown_and_wait().await?;
test.codex = test.thread_manager.start_thread(options).await?.thread;
let result = submit(&test, program).await;
let mut expected_programs = vec![None];
if let Some(expected_wire_program) = expected_wire_program {
result?;
expected_programs.push(Some(json!({"cyber": expected_wire_program})));
} else {
let error = result.expect_err("omission must not silently select Standard");
assert!(
error
.to_string()
.contains("this thread requires an allowed explicit access program")
);
}
assert_eq!(
server
.connections()
.into_iter()
.flatten()
.map(|request| request.body_json().get("access_programs").cloned())
.collect::<Vec<_>>(),
expected_programs
);
test.codex.shutdown_and_wait().await?;
server.shutdown().await;
Ok(())
}
@@ -264,7 +264,8 @@ async fn installed_extension_warms_connections_without_blocking_thread_start() -
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn installed_extension_uses_http_after_warm_socket_auth_expires() -> Result<()> {
async fn installed_extension_keeps_parent_program_out_of_websocket_and_http_reviews() -> Result<()>
{
skip_if_no_network!(Ok(()));
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("original"));
@@ -272,11 +273,19 @@ async fn installed_extension_uses_http_after_warm_socket_auth_expires() -> Resul
.set_external_auth(Arc::new(RefreshableAuth(std::sync::Mutex::new("original"))))
.await?;
let thread_server = responses::start_mock_server().await;
let test = test_codex()
let mut test = test_codex()
.with_auth_manager(Arc::clone(&auth_manager))
.with_config(|config| config.approvals_reviewer = ApprovalsReviewer::AutoReview)
.build_with_auto_env(&thread_server)
.await?;
let mut options = test.start_thread_options().await;
options
.thread_extension_init
.insert(codex_core::RequiredCyberAccessPrograms {
allowed: vec![codex_protocol::turn_input::CyberAccessProgram::DaybreakRed],
});
test.codex.shutdown_and_wait().await?;
test.codex = test.thread_manager.start_thread(options).await?.thread;
let events = vec![
ev_assistant_message("sample", "low"),
ev_completed("response-1"),
@@ -307,6 +316,8 @@ async fn installed_extension_uses_http_after_warm_socket_auth_expires() -> Resul
let registry = builder.build();
let session_store = ExtensionData::new("session-1");
let thread_store = test.codex.thread_extension_data();
// An earlier extension cannot erase Core's captured enrollment policy.
thread_store.remove::<codex_core::RequiredCyberAccessPrograms>();
let mut model = test
.thread_manager
.get_models_manager()
@@ -337,6 +348,7 @@ async fn installed_extension_uses_http_after_warm_socket_auth_expires() -> Resul
.expect("Guardian v2 should initialize");
let turn_store = ExtensionData::new("turn-1");
turn_store.insert_arc(thread_store.get::<Config>().expect("captured test config"));
turn_store.insert(codex_protocol::turn_input::CyberAccessProgram::DaybreakRed);
let tool_name = ToolName::namespaced("mcp__node_repl__", "js");
let payload = ToolPayload::Function {
arguments: r#"{"path":"README.md"}"#.to_owned(),
@@ -406,6 +418,7 @@ async fn installed_extension_uses_http_after_warm_socket_auth_expires() -> Resul
assert_eq!(requests.len(), 1);
requests.push(http_request.body_json());
for request in &requests {
assert!(request.get("access_programs").is_none());
responses::assert_parent_turn(request, Some("turn-1"))?;
responses::assert_root_turn(request, /*expected*/ None)?;
}
@@ -48,6 +48,8 @@ pub struct LunaSamplerConfig {
pub provider: SharedModelProvider,
/// Provider and feature policy shared with ordinary model requests.
pub api_key_cyber_access_programs: ApiKeyCyberAccessPrograms,
/// False when the parent host confines its explicit program to that thread.
pub inherit_parent_cyber_access_program: bool,
/// Routing scope and retained configuration layers for the owning thread.
pub workspace_routing: WorkspaceRoutingContext,
/// Effective proxy, custom-CA, and cookie configuration.
@@ -253,7 +255,9 @@ impl LunaSampler {
execution::SamplingExecution {
auth_owner_generation,
cyber_access_program: codex_guardian_reviewer::guardian_cyber_access_program(
request.cyber_access_program,
request
.cyber_access_program
.filter(|_| self.config.inherit_parent_cyber_access_program),
),
config: Arc::clone(&self.config),
connections: Arc::clone(&self.connections),
@@ -242,6 +242,7 @@ pub(in crate::async_scorer) async fn proxy_websocket_servers_with_http(
pub(in crate::async_scorer) fn sampler_config(base_url: String) -> LunaSamplerConfig {
LunaSamplerConfig {
api_key_cyber_access_programs: ApiKeyCyberAccessPrograms::Disabled,
inherit_parent_cyber_access_program: true,
workspace_routing: codex_model_provider::WorkspaceRoutingContext::new(
"https://chatgpt.com/backend-api".into(),
),
@@ -658,6 +659,7 @@ async fn preconnected_sampler_reuses_authenticated_websocket_for_classifications
let sampler = connect_sampler(LunaSamplerConfig {
api_key_cyber_access_programs: ApiKeyCyberAccessPrograms::Disabled,
inherit_parent_cyber_access_program: true,
workspace_routing: codex_model_provider::WorkspaceRoutingContext::new(
"https://chatgpt.com/backend-api".into(),
),
@@ -840,6 +842,7 @@ async fn sampler_returns_classification_token_before_terminal_response_events()
);
let sampler = connect_sampler(LunaSamplerConfig {
api_key_cyber_access_programs: ApiKeyCyberAccessPrograms::Disabled,
inherit_parent_cyber_access_program: true,
workspace_routing: codex_model_provider::WorkspaceRoutingContext::new(
"https://chatgpt.com/backend-api".into(),
),
@@ -79,6 +79,8 @@ pub(super) async fn sampler_config(
input: &ThreadStartInput<'_, Config>,
manager: Option<Arc<ThreadManager>>,
) -> LunaSamplerConfig {
let inherit_parent_cyber_access_program =
codex_core::RequiredCyberAccessPrograms::for_thread(input.thread_store).is_none();
let luna_model = if let Some(thread_manager) = manager {
let mut model_config = input.config.to_models_manager_config();
model_config.model_context_window = None;
@@ -107,6 +109,7 @@ pub(super) async fn sampler_config(
.expect("Core installs provider auth before starting Guardian");
LunaSamplerConfig {
api_key_cyber_access_programs: ApiKeyCyberAccessPrograms::from_config(input.config),
inherit_parent_cyber_access_program,
workspace_routing: input
.thread_store
.get_or_init(|| input.config.workspace_routing_context())