Wire shared runtime authentication through threads and API clients (#59177)

## What changed

- Share a lazily registered runtime across threads, model discovery, and extension clients when `runtime_agent_instances` is enabled, while honoring each thread's authentication mode and credential owner.
- Bind model catalogs to the effective provider and authentication context while preserving cache policy and live API-key discovery settings.
- Resolve credentials, routing, and transport policy together for each request. Preserve proxy and TLS settings, validate runtime account selectors, and prevent pooled connections from crossing runtime identities.
- Accept supported ChatGPT subdomains when selecting the registration environment.

## Testing

Add integration coverage for shared runtime registration, dual-header and assertion-only modes, disabled and third-party providers, credential replacement, subagents, and delegated review. Add coverage for catalog cache policy and runtime-aware connection reuse.

GitOrigin-RevId: 99dd9d987c29c93597a648f6586f98ac6a932c3c
This commit is contained in:
cooper-oai
2026-11-03 15:02:57 +00:00
committed by copyberry
parent f7167ac895
commit 079e17a422
71 changed files with 2636 additions and 820 deletions
+4
View File
@@ -3031,6 +3031,7 @@ dependencies = [
"codex-utils-process",
"codex-utils-pty",
"codex-utils-redacted-string",
"codex-utils-rustls-provider",
"codex-utils-stream-parser",
"codex-utils-string",
"codex-web-search-extension",
@@ -3057,6 +3058,7 @@ dependencies = [
"predicates",
"pretty_assertions",
"rand 0.9.3",
"rcgen",
"regex-lite",
"rmcp",
"serde",
@@ -3070,6 +3072,7 @@ dependencies = [
"test-log",
"thiserror 2.0.18",
"tokio",
"tokio-rustls",
"tokio-tungstenite",
"tokio-util",
"toml 0.9.11+spec-1.1.0",
@@ -3106,6 +3109,7 @@ dependencies = [
"codex-home",
"codex-image-generation-extension",
"codex-login",
"codex-model-provider",
"codex-model-provider-info",
"codex-models-manager",
"codex-protocol",
+40 -17
View File
@@ -54,22 +54,23 @@ pub enum ChatGptEnvironment {
impl ChatGptEnvironment {
pub fn from_chatgpt_base_url(chatgpt_base_url: &str) -> Result<Self> {
match chatgpt_base_url.trim_end_matches('/') {
"https://chatgpt.com"
| "https://chatgpt.com/backend-api"
| "https://chatgpt.com/codex"
| "https://chatgpt.com/backend-api/codex"
| "https://chat.openai.com"
| "https://chat.openai.com/backend-api"
| "https://chat.openai.com/codex"
| "https://chat.openai.com/backend-api/codex" => Ok(Self::Production),
"https://chatgpt-staging.com"
| "https://chatgpt-staging.com/backend-api"
| "https://chatgpt-staging.com/codex"
| "https://chatgpt-staging.com/backend-api/codex" => Ok(Self::Staging),
_ => anyhow::bail!(
"Agent Identity only supports production and staging ChatGPT environments"
),
let url = codex_http_client::validate_endpoint_url(chatgpt_base_url, "chatgpt_base_url")
.map_err(anyhow::Error::msg)?;
let host = url.host_str().unwrap_or_default();
anyhow::ensure!(
codex_http_client::is_allowed_chatgpt_host(host)
&& url.port_or_known_default() == Some(443)
&& url.query().is_none()
&& matches!(
url.path().trim_end_matches('/'),
"" | "/backend-api" | "/codex" | "/backend-api/codex"
),
"Agent Identity only supports production and staging ChatGPT environments"
);
if host == "chatgpt-staging.com" || host.ends_with(".chatgpt-staging.com") {
Ok(Self::Staging)
} else {
Ok(Self::Production)
}
}
@@ -893,12 +894,34 @@ J1bwkqKZTB5dHolX9A58e/xXnfZ5P8f3Z83+Izap3FwqQulk7b1WO1MQcHuVg2NN
ChatGptEnvironment::Staging.agent_identity_authapi_base_url(),
"https://auth.api.openai.org/api/accounts"
);
assert_eq!(
ChatGptEnvironment::from_chatgpt_base_url("https://us.chatgpt.com/backend-api")?,
ChatGptEnvironment::Production
);
assert_eq!(
ChatGptEnvironment::from_chatgpt_base_url(
"https://us.chatgpt-staging.com/backend-api"
)?,
ChatGptEnvironment::Staging
);
Ok(())
}
#[test]
fn chatgpt_environment_rejects_custom_urls() {
assert!(ChatGptEnvironment::from_chatgpt_base_url("http://localhost:8080").is_err(),);
for base in [
"http://localhost:8080",
"http://us.chatgpt.com",
"https://chatgpt.com.evil.example",
"https://us.chatgpt.com:8443",
"https://u@us.chatgpt.com",
"https://chatgpt.com/custom",
] {
assert!(
ChatGptEnvironment::from_chatgpt_base_url(base).is_err(),
"{base}"
);
}
}
#[test]
+1
View File
@@ -393,6 +393,7 @@ pub async fn start(mut args: InProcessStartArgs) -> IoResult<InProcessClientHand
});
}
let initialize = args.initialize.clone();
// Keep embedded startup state off callers' stacks, which are smaller on Windows.
let client = Box::pin(start_uninitialized(args)).await?;
let initialize_response = client
+9 -3
View File
@@ -186,6 +186,7 @@ mod tests {
use codex_home::CodexHomeUserInstructionsProvider;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_models_manager::manager::ModelCatalogSettings;
use codex_protocol::protocol::SessionSource;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
@@ -534,8 +535,14 @@ enabled = false
let thread_manager = Arc::new_cyclic(|thread_manager| {
ThreadManager::new(
&good_config,
auth_manager.clone(),
codex_core::build_models_manager(&good_config, auth_manager.clone()),
codex_core::build_startup_models(
&good_config,
auth_manager.clone(),
"11111111-1111-4111-8111-111111111111".to_string(),
Arc::new(ModelCatalogSettings::disk(
good_config.codex_home.to_path_buf(),
)),
),
codex_core::CodexAppsToolsCache::default(),
SessionSource::Exec,
Arc::clone(&environment_manager),
@@ -567,7 +574,6 @@ enabled = false
codex_core::passthrough_image_store(),
Arc::clone(&thread_store),
codex_core::local_agent_graph_store_from_state_db(Some(&state_db)),
"11111111-1111-4111-8111-111111111111".to_string(),
/*attestation_provider*/ None,
/*external_time_provider*/ None,
)
+9 -5
View File
@@ -90,6 +90,7 @@ use codex_feedback::CodexFeedback;
use codex_goal_extension::GoalService;
use codex_home::CodexHomeUserInstructionsProvider;
use codex_login::AuthManager;
use codex_models_manager::manager::ModelCatalogSettings;
use codex_otel::auth_storage::AuthStorageOriginator;
use codex_protocol::ThreadId;
use codex_protocol::mcp::ClientMcpExtensions;
@@ -372,10 +373,15 @@ impl MessageProcessor {
Arc::clone(&extension_event_sink),
))
});
let manager = ThreadManager::new(
let startup = codex_core::build_startup_models(
config.as_ref(),
auth_manager.clone(),
codex_core::build_models_manager(config.as_ref(), auth_manager.clone()),
installation_id,
Arc::new(ModelCatalogSettings::disk(config.codex_home.to_path_buf())),
);
let manager = ThreadManager::new(
config.as_ref(),
startup,
codex_core::CodexAppsToolsCache::default(),
session_source,
environment_manager,
@@ -405,7 +411,6 @@ impl MessageProcessor {
codex_core::passthrough_image_store(),
Arc::clone(&thread_store),
codex_core::local_agent_graph_store_from_state_db(state_db.as_ref()),
installation_id,
Some(app_server_attestation_provider(
outgoing.clone(),
thread_state_manager.clone(),
@@ -425,8 +430,7 @@ impl MessageProcessor {
config_manager.clone(),
startup_cwd,
Arc::clone(&config),
thread_manager.get_models_manager(),
Arc::clone(&auth_manager),
Arc::clone(&thread_manager),
));
let models_refresh_worker = crate::models_refresh_worker::spawn(&model_catalog);
let turn_cost_worker =
+13 -16
View File
@@ -4,9 +4,9 @@ use std::path::PathBuf;
use std::sync::Arc;
use codex_config::ConfigLayerStack;
use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_features::Feature;
use codex_login::AuthManager;
use codex_models_manager::manager::RefreshStrategy;
use codex_models_manager::manager::SharedModelsManager;
use codex_protocol::openai_models::ModelPreset;
@@ -19,7 +19,7 @@ use crate::config_manager::StartupConfig;
/// Refreshes catalog routing without changing the routes retained by existing threads.
pub(crate) struct ModelCatalog {
config_manager: ConfigManager,
auth_manager: Arc<AuthManager>,
thread_manager: Arc<ThreadManager>,
startup_cwd: Option<PathBuf>,
state: Mutex<CatalogState>,
}
@@ -35,12 +35,12 @@ impl ModelCatalog {
config_manager: ConfigManager,
startup_cwd: Option<PathBuf>,
config: Arc<Config>,
models_manager: SharedModelsManager,
auth_manager: Arc<AuthManager>,
thread_manager: Arc<ThreadManager>,
) -> Self {
let models_manager = thread_manager.get_models_manager();
Self {
config_manager,
auth_manager,
thread_manager,
startup_cwd,
state: Mutex::new(CatalogState {
needs_reload: false,
@@ -72,14 +72,6 @@ impl ModelCatalog {
}
}
pub(crate) async fn set_api_key_model_discovery_enabled(&self, enabled: bool) {
self.state
.lock()
.await
.models_manager
.set_api_key_model_discovery_enabled(enabled);
}
#[expect(
clippy::await_holding_invalid_type,
reason = "catalog reloads are serialized with account invalidation; model fetching holds no lock"
@@ -100,11 +92,16 @@ impl ModelCatalog {
if config.model_provider_id != state.config.model_provider_id
|| config.model_provider != state.config.model_provider
|| config.model_catalog != state.config.model_catalog
|| config.features.enabled(Feature::RuntimeAgentInstances)
!= state
.config
.features
.enabled(Feature::RuntimeAgentInstances)
|| config.runtime_auth_mode != state.config.runtime_auth_mode
{
state.models_manager =
codex_core::build_models_manager(&config, Arc::clone(&self.auth_manager));
state.models_manager = self.thread_manager.models_manager_for_config(&config);
}
state.models_manager.set_api_key_model_discovery_enabled(
self.thread_manager.set_api_key_model_discovery_enabled(
config.features.enabled(Feature::ApiKeyModelDiscovery),
);
state.config = Arc::new(config);
@@ -11,6 +11,7 @@ use codex_core::config::ConfigBuilder;
use codex_core::config::ConfigOverrides;
use codex_feedback::CodexFeedback;
use codex_http_client::HttpClientFactory;
use codex_models_manager::manager::ModelCatalogSettings;
use codex_models_manager::manager::ModelsEndpointClient;
use codex_models_manager::manager::ModelsEndpointFuture;
use codex_models_manager::manager::ModelsEndpointResponse;
@@ -28,6 +29,38 @@ use tracing_subscriber::layer::SubscriberExt;
use super::*;
fn test_thread_manager(
config: &codex_core::config::Config,
models_manager: Option<SharedModelsManager>,
auth_manager: Arc<codex_login::AuthManager>,
) -> Arc<codex_core::ThreadManager> {
let factory = match models_manager {
Some(models) => codex_core::test_support::fixed_model_catalog(models),
None => Arc::new(ModelCatalogSettings::disk(config.codex_home.to_path_buf())),
};
let startup = codex_core::build_startup_models(
config,
auth_manager,
uuid::Uuid::new_v4().to_string(),
factory,
);
Arc::new(codex_core::ThreadManager::new(
config,
startup,
codex_core::CodexAppsToolsCache::default(),
codex_protocol::protocol::SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
codex_extension_api::empty_extension_registry(),
Arc::new(codex_core::test_support::EmptyUserInstructionsProvider),
/*analytics_events_client*/ None,
codex_core::passthrough_image_store(),
codex_core::ThreadStoreBuilder::from_config(config, /*state_db*/ None).build(),
/*agent_graph_store*/ None,
/*attestation_provider*/ None,
/*external_time_provider*/ None,
))
}
#[derive(Debug)]
struct TestModelsEndpoint {
fetch_count: AtomicUsize,
@@ -148,9 +181,8 @@ async fn refreshes_immediately_periodically_and_stops_when_dropped() {
let catalog = Arc::new(ModelCatalog::new(
config_manager,
Some(config.cwd.to_path_buf()),
config,
Arc::clone(&models_manager),
auth_manager,
Arc::clone(&config),
test_thread_manager(&config, Some(Arc::clone(&models_manager)), auth_manager),
));
let worker = spawn_with_interval(&catalog, Duration::from_millis(/*millis*/ 10));
@@ -232,9 +264,8 @@ async fn background_refresh_blocks_invalid_requirements_then_recovers() -> anyho
let catalog = Arc::new(ModelCatalog::new(
config_manager,
Some(config.cwd.to_path_buf()),
config,
models_manager,
auth_manager,
Arc::clone(&config),
test_thread_manager(&config, Some(models_manager), auth_manager),
));
let feedback = CodexFeedback::new();
// This test uses Tokio's current-thread runtime, so spawned refreshes share the subscriber.
@@ -319,9 +350,11 @@ async fn catalog_reload_recovers_after_cloud_failure() -> anyhow::Result<()> {
/*enable_codex_api_key_env*/ false,
)
.await?;
let models = codex_core::build_models_manager(&config, Arc::clone(&auth));
let catalog = Arc::new(ModelCatalog::new(
manager, /*startup_cwd*/ None, config, models, auth,
manager,
/*startup_cwd*/ None,
Arc::clone(&config),
test_thread_manager(&config, /*models_manager*/ None, auth),
));
failed.store(true, Ordering::SeqCst);
catalog.invalidate().await;
@@ -370,8 +403,12 @@ async fn catalog_reload_recovers_original_project_after_startup_fallback() -> an
/*enable_codex_api_key_env*/ false,
)
.await?;
let models = codex_core::build_models_manager(&config, Arc::clone(&auth));
let catalog = ModelCatalog::new(manager, Some(project_path.clone()), config, models, auth);
let catalog = ModelCatalog::new(
manager,
Some(project_path.clone()),
Arc::clone(&config),
test_thread_manager(&config, /*models_manager*/ None, auth),
);
let mut model = codex_models_manager::bundled_models_response()?
.models
.remove(0);
@@ -102,8 +102,7 @@ async fn background_routing_skips_config_until_chatgpt_sign_in(
manager.clone(),
Some(config.cwd.to_path_buf()),
Arc::clone(&config),
threads.get_models_manager(),
Arc::clone(&auth_manager),
Arc::clone(&threads),
));
let processor = AccountRequestProcessor::new(
Arc::clone(&auth_manager),
@@ -357,16 +357,9 @@ impl ConfigRequestProcessor {
.map_err(|_| internal_error("failed to update feature enablement"))?;
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
self.thread_manager
.get_models_manager()
.set_api_key_model_discovery_enabled(
config.features.enabled(Feature::ApiKeyModelDiscovery),
);
self.model_catalog
.set_api_key_model_discovery_enabled(
config.features.enabled(Feature::ApiKeyModelDiscovery),
)
.await;
self.thread_manager.set_api_key_model_discovery_enabled(
config.features.enabled(Feature::ApiKeyModelDiscovery),
);
if should_start_background_rollout_migration && config.features.enabled(feature) {
self.thread_manager.start_background_rollout_migration();
}
+40 -10
View File
@@ -41,6 +41,7 @@ use codex_core::config::ConfigOverrides;
use codex_core::config::LoaderOverrides;
use codex_core::config::find_codex_home;
use codex_features::FEATURES;
use codex_features::Feature;
use codex_http_client::ClientRouteClass;
use codex_http_client::HttpClientFactory;
use codex_http_client::OutboundProxyPolicy;
@@ -2343,14 +2344,38 @@ async fn websocket_reachability_check(
provider.websocket_connect_timeout().as_millis()
));
let runtime_provider = create_model_provider(provider.clone(), auth_manager);
let auth = runtime_provider.auth().await;
details.push(format!(
"auth mode: {}",
auth.as_ref().map(auth_mode_name).unwrap_or("none")
));
let (api_provider, api_auth) = match runtime_provider.api_provider_and_auth().await {
let setup = async {
let runtime_auth = if config.features.enabled(Feature::RuntimeAgentInstances)
&& let Some(credentials) = &auth_manager
{
let installation_id = codex_core::resolve_installation_id(&config.codex_home).await?;
codex_core::build_runtime_auth_manager(
config,
Arc::clone(credentials),
&installation_id,
)
.map(|runtime| (runtime, config.runtime_auth_mode))
} else {
None
};
let runtime_provider = match auth_manager {
Some(credentials) => codex_model_provider::ProviderAuthContext {
credentials,
runtime_auth,
}
.model_provider(provider.clone()),
None => create_model_provider(provider.clone(), /*auth_manager*/ None),
};
codex_model_provider::resolve_request(
runtime_provider.as_ref(),
/*scope*/ None,
Some(&config.workspace_routing_context()),
config.http_client_factory(),
)
.await
}
.await;
let setup = match setup {
Ok(setup) => setup,
Err(err) => {
return websocket_probe_warning(
@@ -2360,6 +2385,11 @@ async fn websocket_reachability_check(
);
}
};
details.push(format!(
"auth mode: {}",
setup.auth.as_ref().map(auth_mode_name).unwrap_or("none")
));
let api_provider = setup.api_provider;
match api_provider.websocket_url_for_path("responses") {
Ok(url) => {
details.push(format!("endpoint: {url}"));
@@ -2383,8 +2413,8 @@ async fn websocket_reachability_check(
OPENAI_BETA_HEADER,
HeaderValue::from_static(RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE),
);
let client = ResponsesWebsocketClient::new(api_provider, api_auth);
let http_client_factory = config.http_client_factory();
let client = ResponsesWebsocketClient::new(api_provider, setup.api_auth);
let http_client_factory = setup.http_client_factory;
match tokio::time::timeout(
provider.websocket_connect_timeout(),
client.probe_handshake(
+14 -1
View File
@@ -2189,7 +2189,20 @@ async fn run_debug_models_command(
.await?;
let auth_manager =
AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ true).await?;
let models_manager = build_models_manager(&config, auth_manager);
let runtime_auth = if config
.features
.enabled(codex_features::Feature::RuntimeAgentInstances)
{
let installation_id = codex_core::resolve_installation_id(&config.codex_home).await?;
codex_core::build_runtime_auth_manager(
&config,
Arc::clone(&auth_manager),
&installation_id,
)
} else {
None
};
let models_manager = build_models_manager(&config, auth_manager, runtime_auth);
models_manager
.raw_model_catalog(
RefreshStrategy::OnlineIfUncached,
+1
View File
@@ -26,6 +26,7 @@ codex-image-generation-extension = { workspace = true }
codex-exec-server = { workspace = true }
codex-features = { workspace = true }
codex-login = { workspace = true }
codex-model-provider = { workspace = true }
codex-model-provider-info = { workspace = true }
codex-models-manager = { workspace = true }
codex-protocol = { workspace = true }
+7
View File
@@ -53,6 +53,7 @@ pub use codex_core::PromptCacheKey;
pub use codex_core::RecoverTurnRequest;
pub use codex_core::StartIfIdleSubmission;
pub use codex_core::StartThreadOptions;
pub use codex_core::StartupModels;
pub use codex_core::StateDbHandle;
pub use codex_core::SteerSubmission;
pub use codex_core::SuspendTurnOutcome;
@@ -66,6 +67,8 @@ pub use codex_core::TurnStartOptions;
pub use codex_core::WaitForEnvironmentToolConfig;
pub use codex_core::WithTurnExtensionData;
pub use codex_core::build_models_manager;
pub use codex_core::build_runtime_auth_manager;
pub use codex_core::build_startup_models;
pub use codex_core::config::Config;
pub use codex_core::config::Constrained;
pub use codex_core::config::ExtraConfig;
@@ -117,8 +120,11 @@ pub use codex_login::ExternalAuthFuture;
pub use codex_login::ExternalAuthRefreshContext;
pub use codex_login::ExternalAuthRefreshReason;
pub use codex_login::default_client::set_default_originator;
pub use codex_model_provider::ModelCatalogFactory;
pub use codex_model_provider::SharedModelProvider;
pub use codex_model_provider_info::OPENAI_PROVIDER_ID;
pub use codex_model_provider_info::built_in_model_providers;
pub use codex_models_manager::manager::ModelCatalogSettings;
pub use codex_models_manager::manager::RefreshStrategy;
pub use codex_models_manager::manager::SharedModelsManager;
pub use codex_protocol::ThreadId;
@@ -138,6 +144,7 @@ pub use codex_protocol::error::Result as CodexResult;
pub use codex_protocol::models::PermissionProfile;
pub use codex_protocol::models::PermissionProfileSnapshot;
pub use codex_protocol::openai_models::ModelPreset;
pub use codex_protocol::openai_models::ModelsResponse;
pub use codex_protocol::protocol::AskForApproval;
pub use codex_protocol::protocol::EnvironmentConfigState;
pub use codex_protocol::protocol::EventMsg;
+3
View File
@@ -157,6 +157,7 @@ codex-protocol-test-support = { workspace = true }
codex-test-binary-support = { workspace = true }
codex-utils-cargo-bin = { workspace = true }
codex-utils-redacted-string = { workspace = true }
codex-utils-rustls-provider = { workspace = true }
codex-web-search-extension = { workspace = true }
codex-windows-sandbox-test-support = { workspace = true }
core_test_support = { workspace = true }
@@ -166,6 +167,7 @@ maplit = { workspace = true }
opentelemetry = { workspace = true }
predicates = { workspace = true }
pretty_assertions = { workspace = true }
rcgen = { workspace = true }
test-case = "3.3.1"
opentelemetry_sdk = { workspace = true, features = [
"experimental_metrics_custom_reader",
@@ -174,6 +176,7 @@ opentelemetry_sdk = { workspace = true, features = [
serial_test = { workspace = true }
tempfile = { workspace = true }
test-log = { workspace = true }
tokio-rustls = { workspace = true }
tracing-opentelemetry = { workspace = true }
tracing-subscriber = { workspace = true }
tracing-test = { workspace = true, features = ["no-env-filter"] }
@@ -113,8 +113,12 @@ async fn publication_harness(
});
harness.manager = ThreadManager::new(
&harness.config,
harness.manager.auth_manager(),
harness.manager.get_models_manager(),
crate::build_startup_models(
&harness.config,
harness.manager.auth_manager(),
"spawn-publication-cleanup-test".to_string(),
crate::test_support::fixed_model_catalog(harness.manager.get_models_manager()),
),
crate::CodexAppsToolsCache::default(),
SessionSource::Exec,
harness.manager.environment_manager(),
@@ -124,7 +128,6 @@ async fn publication_harness(
harness.manager.image_store(),
crate::ThreadStoreBuilder::from_config(&harness.config, harness.state_db.clone()).build(),
Some(graph.clone()),
"spawn-publication-cleanup-test".to_string(),
/*attestation_provider*/ None,
/*external_time_provider*/ None,
)
+15 -6
View File
@@ -49,6 +49,7 @@ use codex_history::ResumedHistory;
use codex_history::RolloutItem;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_models_manager::manager::ModelCatalogSettings;
use codex_models_manager::model_info::model_info_from_slug;
use codex_protocol::AgentPath;
use codex_protocol::ResponseItemId;
@@ -3785,8 +3786,14 @@ async fn spawn_agent_full_fork_legacy_compaction_rebuilds_child_instructions_onc
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy"));
harness.manager = ThreadManager::new(
&parent_config,
auth_manager.clone(),
crate::thread_manager::build_models_manager(&parent_config, auth_manager),
crate::build_startup_models(
&parent_config,
auth_manager.clone(),
uuid::Uuid::new_v4().to_string(),
Arc::new(ModelCatalogSettings::disk(
parent_config.codex_home.to_path_buf(),
)),
),
crate::CodexAppsToolsCache::default(),
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
@@ -3797,7 +3804,6 @@ async fn spawn_agent_full_fork_legacy_compaction_rebuilds_child_instructions_onc
crate::ThreadStoreBuilder::from_config(&parent_config, harness.state_db.clone())
.build(),
crate::thread_manager::local_agent_graph_store_from_state_db(harness.state_db.as_ref()),
uuid::Uuid::new_v4().to_string(),
/*attestation_provider*/ None,
/*external_time_provider*/ None,
);
@@ -4941,8 +4947,12 @@ async fn resume_thread_subagent_restores_stored_metadata() {
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy"));
let manager = ThreadManager::new(
&config,
auth_manager.clone(),
crate::thread_manager::build_models_manager(&config, auth_manager),
crate::build_startup_models(
&config,
auth_manager.clone(),
uuid::Uuid::new_v4().to_string(),
Arc::new(ModelCatalogSettings::disk(config.codex_home.to_path_buf())),
),
crate::CodexAppsToolsCache::default(),
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
@@ -4952,7 +4962,6 @@ async fn resume_thread_subagent_restores_stored_metadata() {
crate::thread_manager::passthrough_image_store(),
thread_store.clone(),
/*agent_graph_store*/ None,
uuid::Uuid::new_v4().to_string(),
/*attestation_provider*/ None,
/*external_time_provider*/ None,
);
+17 -166
View File
@@ -54,7 +54,6 @@ use codex_api::RealtimeSessionConfig as ApiRealtimeSessionConfig;
use codex_api::Reasoning;
use codex_api::ReasoningContext;
use codex_api::RequestTelemetry;
use codex_api::ReqwestTransport;
use codex_api::ResponseCreateWsRequest;
use codex_api::ResponsesApiRequest;
use codex_api::ResponsesClient as ApiResponsesClient;
@@ -71,22 +70,17 @@ use codex_api::auth_header_telemetry;
use codex_api::build_session_headers;
use codex_api::create_text_param_for_request;
use codex_api::response_create_client_metadata;
use codex_features::RuntimeAuthMode;
use codex_http_client::ClientRouteClass;
use codex_http_client::HttpClientFactory;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_login::RefreshTokenError;
use codex_login::UnauthorizedRecovery;
use codex_login::default_client::ClientRedirectPolicy;
use codex_login::default_client::add_originator_header;
use codex_login::default_client::create_client_for_route;
use codex_otel::SessionTelemetry;
use codex_otel::WEBSOCKET_CONTINUATION_COUNT_METRIC;
use codex_otel::current_span_w3c_trace_context;
use codex_protocol::ResponseItemId;
use codex_protocol::auth::AuthMode;
use codex_runtime_agent_instance::RuntimeAuthManager;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
@@ -147,14 +141,13 @@ use codex_login::auth_env_telemetry::collect_auth_env_telemetry;
use codex_model_provider::AgentIdentitySessionFallback;
use codex_model_provider::ProviderAuthScope;
use codex_model_provider::ProviderUnauthorizedRecovery;
use codex_model_provider::ResolvedProviderAuth;
use codex_model_provider::ResolvedProviderRequest as CurrentClientSetup;
use codex_model_provider::ResponsesConnectionKey;
use codex_model_provider::SharedModelProvider;
use codex_model_provider::WorkspaceRoutingContext;
use codex_model_provider::create_model_provider;
#[cfg(test)]
use codex_model_provider_info::DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS;
use codex_model_provider_info::ModelProviderInfo;
use codex_model_provider_info::WireApi;
use codex_protocol::error::CodexErr;
use codex_protocol::error::CodexErrorDetails;
@@ -235,44 +228,6 @@ enum ClientRouting {
ConfiguredProvider,
}
/// Resolved API client setup for a single request attempt.
///
/// Keeping this as a single bundle ensures prewarm and normal request paths
/// share the same auth/provider setup flow.
struct CurrentClientSetup {
auth: Option<CodexAuth>,
auth_owner_generation: Option<u64>,
auth_revision: Option<u64>,
api_provider: ApiProvider,
http_client_factory: HttpClientFactory,
redirect_policy: ClientRedirectPolicy,
api_auth: SharedAuthProvider,
agent_identity_telemetry: Option<AgentIdentityTelemetry>,
runtime_id: Option<String>,
}
impl CurrentClientSetup {
fn build_api_transport(&self, endpoint: &str) -> Result<ReqwestTransport> {
let api_provider = &self.api_provider;
let redirect_policy = if api_provider
.headers
.contains_key(codex_model_provider::ACCOUNT_ROUTING_HEADER)
{
ClientRedirectPolicy::Reject
} else {
self.redirect_policy
};
let client = create_client_for_route(
&self.http_client_factory,
&api_provider.url_for_path(endpoint),
ClientRouteClass::Api,
redirect_policy,
)
.map_err(std::io::Error::from)?;
Ok(ReqwestTransport::from_http_client(client))
}
}
#[derive(Clone, Copy)]
struct RequestRouteTelemetry {
endpoint: &'static str,
@@ -309,7 +264,6 @@ 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,
runtime_auth: Option<(Arc<RuntimeAuthManager>, RuntimeAuthMode)>,
}
/// A turn-scoped streaming session created from a [`ModelClient`].
@@ -581,10 +535,9 @@ impl ModelClient {
/// HTTP client factory must come from the effective session configuration so every transport
/// observes the resolved outbound proxy policy.
pub fn new(
auth_manager: Option<Arc<AuthManager>>,
model_provider: SharedModelProvider,
agent_identity_policy: AgentIdentityAuthPolicy,
thread_id: ThreadId,
provider_info: ModelProviderInfo,
session_source: SessionSource,
originator: String,
model_verbosity: Option<VerbosityConfig>,
@@ -600,7 +553,6 @@ impl ModelClient {
workspace_routing: WorkspaceRoutingContext,
request_contributors: Vec<Arc<dyn codex_extension_api::ModelRequestContributor>>,
) -> Self {
let model_provider = create_model_provider(provider_info, auth_manager);
let codex_api_key_env_enabled = model_provider
.auth_manager()
.as_ref()
@@ -647,7 +599,6 @@ impl ModelClient {
codex_responses_headers: None,
event_sender: None,
http_client_factory,
runtime_auth: None,
restored_history: false,
request_contributors,
executed_tool_calls: None,
@@ -672,24 +623,6 @@ impl ModelClient {
self
}
/// Use the harness-owned runtime for this first-party provider. The owner must share this
/// client's credential manager. Provider-specific overrides need their own runtime owner.
pub fn with_runtime_auth(
mut self,
manager: Arc<RuntimeAuthManager>,
mode: RuntimeAuthMode,
) -> Result<Self> {
let info = self.state.provider.info();
codex_model_provider::validate_runtime_destination(info.base_url.as_deref())?;
codex_model_provider::validate_runtime_provider(
info,
self.auth_manager().as_ref(),
&manager,
)?;
self.runtime_auth = Some((manager, mode));
Ok(self)
}
pub(crate) fn with_session_context(
mut self,
prompt_cache_key: PromptCacheKey,
@@ -834,7 +767,7 @@ impl ModelClient {
if let Some(api_provider) = api_provider_override {
client_setup.api_provider = api_provider;
}
let transport = client_setup.build_api_transport(REALTIME_CALLS_ENDPOINT)?;
let transport = client_setup.http_transport(REALTIME_CALLS_ENDPOINT)?;
let response =
ApiRealtimeCallClient::new(transport, client_setup.api_provider, client_setup.api_auth)
.create_with_session_and_headers(sdp, session_config, extra_headers)
@@ -882,7 +815,7 @@ impl ModelClient {
let client_setup = self
.current_client_setup(ClientRouting::ConfiguredProvider)
.await?;
let transport = client_setup.build_api_transport(MEMORIES_SUMMARIZE_ENDPOINT)?;
let transport = client_setup.http_transport(MEMORIES_SUMMARIZE_ENDPOINT)?;
let request_telemetry = Self::build_request_telemetry(
session_telemetry,
AuthRequestTelemetryContext::new(
@@ -1206,61 +1139,6 @@ impl ModelClient {
/// This centralizes setup used by both prewarm and normal request paths so they stay in
/// lockstep when auth/provider resolution changes.
async fn current_client_setup(&self, routing: ClientRouting) -> Result<CurrentClientSetup> {
if let Some((manager, mode)) = &self.runtime_auth {
let selected = codex_model_provider::select_runtime_auth(
manager,
&self.state.provider.info().build_header_map()?,
)
.await
.map_err(|error| self.state.provider.map_api_error(error.into()))?;
let auth = selected.auth().clone();
let auth_revision = Some(selected.change_state().generation);
let auth_owner_generation = Some(selected.change_state().owner_generation);
let api_provider = self.state.provider.api_provider_for_auth(Some(&auth))?;
let (api_provider, redirect_policy) = match routing {
ClientRouting::Workspace => {
let resolved = self
.state
.provider
.route_responses_api_provider(
api_provider,
Some(&auth),
&self.state.workspace_routing,
)
.await?;
(resolved.provider, resolved.redirect_policy)
}
ClientRouting::ConfiguredProvider => (api_provider, ClientRedirectPolicy::Default),
};
let runtime = selected.runtime();
codex_model_provider::validate_runtime_selectors(
&api_provider.headers,
runtime.actor().info(),
)?;
let runtime_id = Some(runtime.id().to_string());
let http_client_factory = selected.http_client_factory().clone();
let resolved_auth = self
.state
.provider
.compose_auth(ResolvedProviderAuth {
auth: selected.clone().into_auth_provider(*mode),
agent_identity_telemetry: None,
runtime: Some(selected),
})
.await?;
tracing::debug!(runtime_auth_mode = ?mode, "selected runtime authentication");
return Ok(CurrentClientSetup {
auth: Some(auth),
auth_owner_generation,
auth_revision,
api_provider,
http_client_factory,
redirect_policy,
api_auth: resolved_auth.auth,
agent_identity_telemetry: None,
runtime_id,
});
}
self.provider_client_setup(routing, &self.state.provider)
.await
}
@@ -1284,46 +1162,19 @@ impl ModelClient {
routing: ClientRouting,
provider: &SharedModelProvider,
) -> Result<CurrentClientSetup> {
let routing = &routing;
codex_model_provider::prepare_provider_request(
let routing = match routing {
ClientRouting::Workspace => Some(&self.state.workspace_routing),
ClientRouting::ConfiguredProvider => None,
};
codex_model_provider::resolve_request(
provider.as_ref(),
provider.info().auth.is_none(),
|state| async move {
let auth = provider.auth().await;
let (api_provider, redirect_policy) = match routing {
ClientRouting::Workspace => {
let resolved = provider
.responses_api_provider(&self.state.workspace_routing)
.await?;
(resolved.provider, resolved.redirect_policy)
}
ClientRouting::ConfiguredProvider => (
provider.api_provider().await?,
ClientRedirectPolicy::Default,
),
};
let resolved_auth = provider
.api_auth_for_scope(ProviderAuthScope {
agent_identity_policy: self.agent_identity_policy,
session_source: self.state.session_source.clone(),
agent_identity_session_fallback: self
.state
.agent_identity_session_fallback
.clone(),
})
.await?;
Ok(CurrentClientSetup {
auth,
auth_owner_generation: state.map(|state| state.owner_generation),
auth_revision: state.map(|state| state.generation),
api_provider,
http_client_factory: self.http_client_factory.clone(),
redirect_policy,
api_auth: resolved_auth.auth,
agent_identity_telemetry: resolved_auth.agent_identity_telemetry,
runtime_id: None,
})
},
Some(ProviderAuthScope {
agent_identity_policy: self.agent_identity_policy,
session_source: self.state.session_source.clone(),
agent_identity_session_fallback: self.state.agent_identity_session_fallback.clone(),
}),
routing,
self.http_client_factory.clone(),
)
.await
}
@@ -1953,7 +1804,7 @@ impl ResponsesHttpExecutor<'_> {
.client
.responses_headers(client_setup.auth.as_ref(), &model_info.slug);
tracing::Span::current().record("api.path", "/responses");
let transport = client_setup.build_api_transport("/responses")?;
let transport = client_setup.http_transport("/responses")?;
let request_auth_context = AuthRequestTelemetryContext::new(
client_setup.auth.as_ref().map(CodexAuth::auth_mode),
client_setup.api_auth.as_ref(),
+19 -24
View File
@@ -70,16 +70,19 @@ async fn selected_runtime_transport_stays_revoked(mode: RuntimeAuthMode) {
));
let mut client = test_model_client(SessionSource::Exec);
client.http_client_factory = factory;
Arc::get_mut(&mut client.state).unwrap().provider = create_model_provider(
ModelProviderInfo::create_openai_provider(Some(format!("{}/v1", server.uri()))),
Some(manager),
);
let client = client.with_runtime_auth(runtime, mode).unwrap();
let setup = client
Arc::get_mut(&mut client.state).unwrap().provider = codex_model_provider::ProviderAuthContext {
credentials: manager,
runtime_auth: Some((runtime, mode)),
}
.model_provider(ModelProviderInfo::create_openai_provider(
/*base_url*/ None,
));
let mut setup = client
.current_client_setup(ClientRouting::Workspace)
.await
.unwrap();
let transport = setup.build_api_transport("/responses").unwrap();
setup.api_provider.base_url = format!("{}/v1", server.uri());
let transport = setup.http_transport("/responses").unwrap();
let request = setup
.api_provider
.build_request(http::Method::POST, "/responses");
@@ -130,15 +133,10 @@ async fn selected_runtime_transport_stays_revoked(mode: RuntimeAuthMode) {
server.verify().await;
}
#[test_case::test_case(RuntimeAuthMode::DualHeader, false; "dual_header")]
#[test_case::test_case(RuntimeAuthMode::AssertionOnly, false; "assertion_only")]
#[test_case::test_case(RuntimeAuthMode::DualHeader, true; "runtime_provider_dual_header")]
#[test_case::test_case(RuntimeAuthMode::AssertionOnly, true; "runtime_provider_assertion_only")]
#[test_case::test_case(RuntimeAuthMode::DualHeader; "dual_header")]
#[test_case::test_case(RuntimeAuthMode::AssertionOnly; "assertion_only")]
#[tokio::test]
async fn realtime_keeps_provider_auth_until_runtime_integration(
mode: RuntimeAuthMode,
runtime_provider: bool,
) {
async fn realtime_keeps_provider_auth_until_runtime_integration(mode: RuntimeAuthMode) {
let (mut client, _) = model_client_with_counting_attestation(/*include_attestation*/ true);
let registration = MockServer::start().await;
let manager = client.auth_manager().unwrap();
@@ -158,16 +156,13 @@ async fn realtime_keeps_provider_auth_until_runtime_integration(
},
format!("{}/register", registration.uri()).parse().unwrap(),
));
if runtime_provider {
let info = client.state.provider.info().clone();
Arc::get_mut(&mut client.state).unwrap().provider =
codex_model_provider::ProviderAuthContext {
credentials: manager,
runtime_auth: Some((Arc::clone(&runtime), mode)),
}
.model_provider(info);
let info = client.state.provider.info().clone();
Arc::get_mut(&mut client.state).unwrap().provider = codex_model_provider::ProviderAuthContext {
credentials: manager,
runtime_auth: Some((runtime, mode)),
}
let client = client.with_runtime_auth(runtime, mode).unwrap();
.model_provider(info);
assert!(client.state.provider.runtime_auth().is_some());
let sideband = client
.realtime_sideband_headers(http::HeaderMap::new())
.await
+11 -12
View File
@@ -125,10 +125,9 @@ fn test_model_client_with_thread_id(
) -> ModelClient {
let provider = create_oss_provider_with_base_url("https://example.com/v1", WireApi::Responses);
ModelClient::new(
/*auth_manager*/ None,
codex_model_provider::create_model_provider(provider, /*auth_manager*/ None),
AgentIdentityAuthPolicy::JwtOnly,
thread_id,
provider,
session_source,
"test_originator".to_string(),
/*model_verbosity*/ None,
@@ -227,13 +226,14 @@ async fn workspace_routed_http_rejects_redirects_without_a_routing_header(
format!("{}/register", registration.uri()).parse().unwrap(),
));
let mut client = test_model_client(SessionSource::Exec);
Arc::get_mut(&mut client.state).unwrap().provider = create_model_provider(
ModelProviderInfo::create_openai_provider(/*base_url*/ None),
Some(manager),
);
if let Some(mode) = runtime_mode {
client = client.with_runtime_auth(runtime, mode).unwrap();
}
Arc::get_mut(&mut client.state).unwrap().provider =
codex_model_provider::ProviderAuthContext {
credentials: manager,
runtime_auth: runtime_mode.map(|mode| (runtime, mode)),
}
.model_provider(ModelProviderInfo::create_openai_provider(
/*base_url*/ None,
));
let mut setup = client
.current_client_setup(super::ClientRouting::Workspace)
.await
@@ -246,7 +246,7 @@ async fn workspace_routed_http_rejects_redirects_without_a_routing_header(
}
// Exercise the resolved route's redirect policy against loopback HTTP servers.
setup.api_provider.base_url = origin.uri();
let transport = setup.build_api_transport("/responses").unwrap();
let transport = setup.http_transport("/responses").unwrap();
let request = setup
.api_provider
.build_request(http::Method::POST, "/responses")
@@ -2147,10 +2147,9 @@ fn model_client_with_counting_attestation(
)
};
let model_client = ModelClient::new(
auth_manager,
codex_model_provider::create_model_provider(provider, auth_manager),
AgentIdentityAuthPolicy::JwtOnly,
ThreadId::new(),
provider,
SessionSource::Exec,
"test_originator".to_string(),
/*model_verbosity*/ None,
+1
View File
@@ -114,6 +114,7 @@ pub(crate) async fn run_codex_thread_interactive(
instructions,
installation_id: parent_session.installation_id.clone(),
auth_manager,
runtime_auth: parent_session.services.runtime_auth.clone(),
models_manager,
git_root_discovery: Arc::clone(&parent_session.services.git_root_discovery),
environment_manager: parent_session
+13
View File
@@ -76,6 +76,7 @@ use codex_features::Features;
use codex_features::FeaturesToml;
use codex_features::MultiAgentV2ConfigToml;
use codex_features::NetworkProxyConfigToml;
use codex_features::RuntimeAuthMode;
use codex_features::SleepToolMode;
use codex_features::TokenBudgetConfigToml;
use codex_git_utils::resolve_root_git_project_for_trust;
@@ -1166,6 +1167,8 @@ pub struct Config {
pub current_time_reminder: Option<CurrentTimeReminderConfig>,
/// How the sleep tool is selected when its feature gate is enabled.
pub sleep_tool_mode: SleepToolMode,
/// Authentication mode used when runtime agent instances are enabled.
pub runtime_auth_mode: RuntimeAuthMode,
/// Centralized feature flags; source of truth for feature gating.
pub features: ManagedFeatures,
@@ -4033,6 +4036,15 @@ impl Config {
FeatureToml::Config(config) => config.mode,
})
.unwrap_or_default();
let runtime_auth_mode = cfg
.features
.as_ref()
.and_then(|features| features.runtime_agent_instances.as_ref())
.and_then(|feature| match feature {
FeatureToml::Enabled(_) => None,
FeatureToml::Config(config) => config.mode,
})
.unwrap_or_default();
let terminal_resize_reflow = resolve_terminal_resize_reflow_config(&cfg);
let agent_roles =
@@ -4754,6 +4766,7 @@ impl Config {
rollout_budget,
current_time_reminder,
sleep_tool_mode,
runtime_auth_mode,
features,
runtime_feature_defaults: BTreeMap::new(),
suppress_unstable_features_warning: cfg
+6 -3
View File
@@ -27,8 +27,12 @@ pub(crate) fn install(session: &Session, config: &Config) {
);
let manager = Arc::new(crate::ThreadManager::new(
config,
Arc::clone(&session.services.auth_manager),
Arc::clone(&session.services.models_manager),
crate::build_startup_models(
config,
Arc::clone(&session.services.auth_manager),
session.installation_id.clone(),
crate::test_support::fixed_model_catalog(Arc::clone(&session.services.models_manager)),
),
crate::CodexAppsToolsCache::default(),
SessionSource::Exec,
session.services.turn_environments.environment_manager(),
@@ -40,7 +44,6 @@ pub(crate) fn install(session: &Session, config: &Config) {
crate::passthrough_image_store(),
Arc::clone(&session.services.thread_store),
/*agent_graph_store*/ None,
session.installation_id.clone(),
/*attestation_provider*/ None,
/*external_time_provider*/ None,
));
+5
View File
@@ -183,10 +183,12 @@ pub use thread_manager::InternalSessionParent;
pub use thread_manager::NewThread;
pub use thread_manager::PromptCacheKey;
pub use thread_manager::StartThreadOptions;
pub use thread_manager::StartupModels;
pub use thread_manager::ThreadManager;
pub use thread_manager::ThreadRegistration;
pub use thread_manager::ThreadShutdownReport;
pub use thread_manager::build_models_manager;
pub use thread_manager::build_startup_models;
pub use thread_manager::local_agent_graph_store_from_state_db;
pub use thread_manager::passthrough_image_store;
pub use thread_store_builder::ThreadStoreBuilder;
@@ -281,3 +283,6 @@ pub mod otel_init;
// Captured environment bindings can be passed back to ThreadManager by internal reviewers.
pub use environment_selection::TurnEnvironmentSnapshot;
pub use environment_selection::validate_environment_ids_and_cwds;
mod runtime_auth;
pub use runtime_auth::build_runtime_auth_manager;
+8 -3
View File
@@ -5,6 +5,7 @@ use codex_exec_server::ExecServerRuntimeOptions;
use codex_extension_api::ExtensionRegistry;
use codex_extension_api::UserInstructionsProvider;
use codex_login::AuthManager;
use codex_models_manager::manager::ModelCatalogSettings;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
use codex_protocol::models::ResponseItem;
@@ -44,10 +45,15 @@ pub async fn build_prompt_input(
let thread_store = ThreadStoreBuilder::from_config(&config, state_db.clone()).build();
let installation_id = resolve_installation_id(&config.codex_home).await?;
let thread_manager = ThreadManager::new(
let startup = crate::build_startup_models(
&config,
Arc::clone(&auth_manager),
crate::thread_manager::build_models_manager(&config, Arc::clone(&auth_manager)),
installation_id,
Arc::new(ModelCatalogSettings::disk(config.codex_home.to_path_buf())),
);
let thread_manager = ThreadManager::new(
&config,
startup,
crate::CodexAppsToolsCache::default(),
SessionSource::Exec,
Arc::new(
@@ -65,7 +71,6 @@ pub async fn build_prompt_input(
crate::thread_manager::passthrough_image_store(),
thread_store,
crate::local_agent_graph_store_from_state_db(state_db.as_ref()),
installation_id,
/*attestation_provider*/ None,
/*external_time_provider*/ None,
);
+53
View File
@@ -0,0 +1,53 @@
//! Construct and share the harness-owned runtime credential context.
use crate::config::Config;
use codex_features::Feature;
use codex_login::AuthManager;
use codex_runtime_agent_instance::RuntimeAuthManager;
use codex_runtime_agent_instance::RuntimeRegistrationMetadata;
use codex_runtime_agent_instance::RuntimeRole;
use std::sync::Arc;
use std::sync::OnceLock;
// A later thread may enable runtime auth even when harness startup left it disabled.
pub(crate) type RuntimeAuthSlot = Arc<OnceLock<Arc<RuntimeAuthManager>>>;
/// Share this owner between thread sessions and first-party API clients.
/// Construction does not register a runtime or generate its key.
pub fn build_runtime_auth_manager(
config: &Config,
credentials: Arc<AuthManager>,
installation_id: &str,
) -> Option<Arc<RuntimeAuthManager>> {
runtime_for_config(
&RuntimeAuthSlot::default(),
config,
credentials,
installation_id,
)
}
pub(crate) fn runtime_for_config(
slot: &RuntimeAuthSlot,
config: &Config,
credentials: Arc<AuthManager>,
installation_id: &str,
) -> Option<Arc<RuntimeAuthManager>> {
config
.features
.enabled(Feature::RuntimeAgentInstances)
.then(|| {
Arc::clone(slot.get_or_init(|| {
Arc::new(RuntimeAuthManager::for_chatgpt(
credentials,
installation_id.to_owned(),
config.chatgpt_base_url.clone(),
RuntimeRegistrationMetadata {
harness: Some("codex".to_string()),
harness_version: Some(env!("CARGO_PKG_VERSION").to_string()),
role: RuntimeRole::Orchestrator,
},
))
}))
})
}
+4
View File
@@ -218,6 +218,10 @@ impl Session {
published: false,
};
let auth = self.services.auth_manager.auth().await;
// Capture after claiming the refresh so a new invalidation cannot be consumed
// with the environment snapshot taken before its permissions changed.
let environments = self.services.turn_environments.snapshot().await;
let environment_selections = environments.all_selections();
let desired = self.latest_mcp_desired_state(auth, environments).await;
let selected_capability_roots = self
.resolve_selected_capability_roots_for_step(&desired.environments)
+19 -6
View File
@@ -2,6 +2,7 @@ pub(crate) mod startup;
use crate::PromptCacheKey;
use crate::WithTurnExtensionData;
use crate::runtime_auth::RuntimeAuthSlot;
use std::borrow::Cow;
use std::collections::HashMap;
use std::collections::HashSet;
@@ -235,7 +236,6 @@ use crate::thread_rollout_truncation::initial_history_has_prior_user_turns;
use codex_config::CONFIG_TOML_FILE;
use codex_config::ConfigLayerSource;
use codex_config::types::McpServerConfig;
use codex_model_provider::create_model_provider;
use codex_model_provider_info::ModelProviderInfo;
use codex_protocol::error::CodexErr;
use codex_protocol::error::CodexErrorDetails;
@@ -509,6 +509,7 @@ pub(crate) struct SessionSpawnArgs {
pub(crate) instructions: SessionInstructions,
pub(crate) installation_id: String,
pub(crate) auth_manager: Arc<AuthManager>,
pub(crate) runtime_auth: RuntimeAuthSlot,
pub(crate) models_manager: SharedModelsManager,
pub(crate) git_root_discovery: Arc<GitRootDiscovery>,
pub(crate) environment_manager: Arc<EnvironmentManager>,
@@ -630,6 +631,7 @@ impl Session {
instructions,
installation_id,
auth_manager,
runtime_auth,
models_manager,
git_root_discovery,
environment_manager,
@@ -758,12 +760,19 @@ impl Session {
};
let mut config = Arc::new(config);
let configured_runtime = crate::runtime_auth::runtime_for_config(
&runtime_auth,
config.as_ref(),
Arc::clone(&auth_manager),
&installation_id,
);
let refresh_strategy = if session_source.is_non_root_agent() {
codex_models_manager::manager::RefreshStrategy::Offline
} else {
codex_models_manager::manager::RefreshStrategy::OnlineIfUncached
};
if config.model.is_none()
if configured_runtime.is_some()
|| config.model.is_none()
|| !matches!(
refresh_strategy,
codex_models_manager::manager::RefreshStrategy::Offline
@@ -951,12 +960,15 @@ impl Session {
let enable_full_access_form_input = !root_capabilities_restricted
&& matches!(thread_source.as_ref(), Some(ThreadSource::User))
&& client_mcp_extensions.contains(OPENAI_STANDARD_FORM_INPUT_EXTENSION_ID);
let provider_auth = codex_model_provider::ProviderAuthContext {
credentials: Arc::clone(&auth_manager),
runtime_auth: configured_runtime.map(|runtime| (runtime, config.runtime_auth_mode)),
};
// Replace any inherited context before startup hooks see this thread's effective auth.
thread_extension_init.insert(provider_auth.clone());
let session_configuration = SessionConfiguration {
turn_extension_init,
provider: create_model_provider(
config.model_provider.clone(),
Some(Arc::clone(&auth_manager)),
),
provider: provider_auth.model_provider(config.model_provider.clone()),
step_settings: Arc::new(StepSettings {
collaboration_mode,
reasoning_summary: config.model_reasoning_summary,
@@ -1014,6 +1026,7 @@ impl Session {
instructions,
installation_id,
auth_manager.clone(),
runtime_auth,
models_manager.clone(),
git_root_discovery,
model_info,
+46 -43
View File
@@ -20,6 +20,7 @@ use crate::mcp_tool_call::McpToolApprovalContext;
use crate::responses_metadata::CodexResponsesMetadata;
use crate::responses_metadata::CodexResponsesRequestKind;
use crate::responses_metadata::CompactionTurnMetadata;
use crate::runtime_auth::RuntimeAuthSlot;
use crate::shell_snapshot::ShellSnapshot;
use crate::shell_snapshot::SnapshotCredentialBrokerState;
use crate::state::ActiveTurn;
@@ -833,6 +834,7 @@ impl Session {
mut instructions: SessionInstructions,
installation_id: String,
auth_manager: Arc<AuthManager>,
runtime_auth: RuntimeAuthSlot,
models_manager: SharedModelsManager,
git_root_discovery: Arc<GitRootDiscovery>,
model_info: CatalogModel,
@@ -1794,6 +1796,48 @@ impl Session {
);
let reasoning_effort_override_enabled =
config.features.enabled(Feature::ReasoningEffortOverride) && !title_request;
let model_client = ModelClient::new(
Arc::clone(&session_configuration.provider),
if config.features.enabled(Feature::UseAgentIdentity) {
AgentIdentityAuthPolicy::ChatGptAuth
} else {
AgentIdentityAuthPolicy::JwtOnly
},
thread_id,
session_configuration.session_source.clone(),
session_configuration.originator.clone(),
config.model_verbosity,
config.features.enabled(Feature::ContentItemKinds),
reasoning_effort_override_enabled,
config.features.enabled(Feature::EnableRequestCompression),
config.features.enabled(Feature::RuntimeMetrics),
Self::build_model_client_beta_features_header(config.as_ref()),
/*concurrent_reasoning_summaries_enabled*/
config
.features
.enabled(Feature::ConcurrentReasoningSummaries),
config.features.enabled(Feature::OutputTokenReplay),
attestation_provider.clone(),
config.http_client_factory(),
workspace_routing.as_ref().clone(),
extensions.model_request_contributors().to_vec(),
)
.with_executed_tool_calls(executed_tool_calls.clone())
.with_restored_history(matches!(
&initial_history,
InitialHistory::Resumed(_) | InitialHistory::Forked(_)
))
.with_session_context(
prompt_cache_key,
crate::guardian::prompt_cache_key_override_for_review_session(
&session_configuration.session_source,
session_configuration.parent_thread_id,
)
.or(fork_cache_key),
tx_event.clone(),
codex_responses_headers,
crate::cyber_access_program::ApiKeyCyberAccessPrograms::from_config(&config),
);
// Once tracing starts, startup cleanup must own the session before the next await.
let rollout_thread_trace = if matches!(
session_configuration.session_source,
@@ -1832,6 +1876,7 @@ impl Session {
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
exec_policy,
auth_manager: Arc::clone(&auth_manager),
runtime_auth,
openai_file_upload_client_pool: RouteAwareClientPool::new_without_request_logging(
config.http_client_factory(),
ClientRouteClass::Api,
@@ -1866,49 +1911,7 @@ impl Session {
thread_store: Arc::clone(&thread_store),
attestation_provider: attestation_provider.clone(),
time_provider,
model_client: ModelClient::new(
Some(Arc::clone(&auth_manager)),
if config.features.enabled(Feature::UseAgentIdentity) {
AgentIdentityAuthPolicy::ChatGptAuth
} else {
AgentIdentityAuthPolicy::JwtOnly
},
thread_id,
session_configuration.provider.info().clone(),
session_configuration.session_source.clone(),
session_configuration.originator.clone(),
config.model_verbosity,
config.features.enabled(Feature::ContentItemKinds),
reasoning_effort_override_enabled,
config.features.enabled(Feature::EnableRequestCompression),
config.features.enabled(Feature::RuntimeMetrics),
Self::build_model_client_beta_features_header(config.as_ref()),
/*concurrent_reasoning_summaries_enabled*/
config
.features
.enabled(Feature::ConcurrentReasoningSummaries),
config.features.enabled(Feature::OutputTokenReplay),
attestation_provider,
config.http_client_factory(),
workspace_routing.as_ref().clone(),
extensions.model_request_contributors().to_vec(),
)
.with_executed_tool_calls(executed_tool_calls.clone())
.with_restored_history(matches!(
&initial_history,
InitialHistory::Resumed(_) | InitialHistory::Forked(_)
))
.with_session_context(
prompt_cache_key,
crate::guardian::prompt_cache_key_override_for_review_session(
&session_configuration.session_source,
session_configuration.parent_thread_id,
)
.or(fork_cache_key),
tx_event.clone(),
codex_responses_headers,
crate::cyber_access_program::ApiKeyCyberAccessPrograms::from_config(&config),
),
model_client,
executed_tool_calls: executed_tool_calls.clone(),
code_mode_service: crate::tools::code_mode::CodeModeService::new(
thread_id,
+16 -6
View File
@@ -895,10 +895,12 @@ fn test_model_client_session() -> crate::client::ModelClientSession {
let thread_id = ThreadId::try_from("00000000-0000-4000-8000-000000000001")
.expect("test thread id should be valid");
crate::client::ModelClient::new(
/*auth_manager*/ None,
codex_model_provider::create_model_provider(
ModelProviderInfo::create_openai_provider(/*base_url*/ None),
/*auth_manager*/ None,
),
AgentIdentityAuthPolicy::JwtOnly,
thread_id,
ModelProviderInfo::create_openai_provider(/* base_url */ /*base_url*/ None),
codex_protocol::protocol::SessionSource::Exec,
"test_originator".to_string(),
/*model_verbosity*/ None,
@@ -7077,6 +7079,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
exec_policy,
auth_manager: auth_manager.clone(),
runtime_auth: Default::default(),
openai_file_upload_client_pool: RouteAwareClientPool::new_without_request_logging(
config.http_client_factory(),
ClientRouteClass::Api,
@@ -7116,10 +7119,12 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
attestation_provider: None,
time_provider: Arc::new(crate::current_time::SystemTimeProvider),
model_client: ModelClient::new(
Some(auth_manager.clone()),
codex_model_provider::create_model_provider(
session_configuration.provider.info().clone(),
Some(auth_manager.clone()),
),
AgentIdentityAuthPolicy::JwtOnly,
thread_id,
session_configuration.provider.info().clone(),
session_configuration.session_source.clone(),
session_configuration.originator.clone(),
config.model_verbosity,
@@ -7350,6 +7355,7 @@ async fn make_session_with_config_and_rx(
SessionInstructions::default(),
"11111111-1111-4111-8111-111111111111".to_string(),
auth_manager,
/*runtime_auth*/ Default::default(),
models_manager,
Arc::default(),
model_info,
@@ -7489,6 +7495,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx(
SessionInstructions::default(),
"11111111-1111-4111-8111-111111111111".to_string(),
auth_manager,
/*runtime_auth*/ Default::default(),
models_manager,
Arc::default(),
model_info,
@@ -9407,6 +9414,7 @@ where
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
exec_policy,
auth_manager: Arc::clone(&auth_manager),
runtime_auth: Default::default(),
openai_file_upload_client_pool: RouteAwareClientPool::new_without_request_logging(
config.http_client_factory(),
ClientRouteClass::Api,
@@ -9446,10 +9454,12 @@ where
attestation_provider: None,
time_provider: Arc::new(crate::current_time::SystemTimeProvider),
model_client: ModelClient::new(
Some(Arc::clone(&auth_manager)),
codex_model_provider::create_model_provider(
session_configuration.provider.info().clone(),
Some(Arc::clone(&auth_manager)),
),
AgentIdentityAuthPolicy::JwtOnly,
thread_id,
session_configuration.provider.info().clone(),
session_configuration.session_source.clone(),
session_configuration.originator.clone(),
config.model_verbosity,
@@ -1338,6 +1338,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
instructions: Default::default(),
installation_id: "11111111-1111-4111-8111-111111111111".to_string(),
auth_manager,
runtime_auth: Default::default(),
models_manager,
git_root_discovery: Arc::default(),
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
+2
View File
@@ -15,6 +15,7 @@ use crate::environment_selection::ThreadEnvironments;
use crate::exec_policy::ExecPolicyManager;
use crate::mcp::McpManager;
use crate::mcp_tool_exposure::McpHandlerCache;
use crate::runtime_auth::RuntimeAuthSlot;
use crate::tools::ExecutedToolCalls;
use crate::tools::code_mode::CodeModeService;
use crate::tools::handlers::ToolSearchHandlerCache;
@@ -62,6 +63,7 @@ pub(crate) struct SessionServices {
pub(crate) show_raw_agent_reasoning: bool,
pub(crate) exec_policy: Arc<ExecPolicyManager>,
pub(crate) auth_manager: Arc<AuthManager>,
pub(crate) runtime_auth: RuntimeAuthSlot,
/// Upload-only clients shared across turns without logging signed blob URLs.
pub(crate) openai_file_upload_client_pool: RouteAwareClientPool,
pub(crate) models_manager: SharedModelsManager,
+24
View File
@@ -41,6 +41,30 @@ use crate::responses_metadata::subagent_metadata_kind;
use crate::thread_manager;
use crate::unified_exec;
/// Supply fixed model metadata to tests without implementing a provider.
pub fn fixed_model_catalog(
manager: SharedModelsManager,
) -> Arc<dyn codex_model_provider::ModelCatalogFactory> {
struct FixedCatalog(SharedModelsManager);
impl codex_model_provider::ModelCatalogFactory for FixedCatalog {
fn build(
&self,
_provider: codex_model_provider::SharedModelProvider,
_config_model_catalog: Option<codex_protocol::openai_models::ModelsResponse>,
_api_key_model_discovery_default: bool,
) -> SharedModelsManager {
Arc::clone(&self.0)
}
fn set_api_key_model_discovery_enabled(&self, enabled: bool) {
self.0.set_api_key_model_discovery_enabled(enabled);
}
}
Arc::new(FixedCatalog(manager))
}
static TEST_MODEL_PRESETS: Lazy<Vec<ModelPreset>> = Lazy::new(|| {
let mut response = bundled_models_response()
.unwrap_or_else(|err| panic!("bundled models.json should parse: {err}"));
+110 -37
View File
@@ -1,4 +1,5 @@
mod managed;
mod model_catalog;
mod shared_instructions;
mod shutdown;
@@ -72,7 +73,6 @@ use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_login::default_client::CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR;
use codex_login::default_client::originator;
use codex_model_provider::create_model_provider;
use codex_model_provider_info::ModelProviderInfo;
use codex_model_provider_info::OPENAI_PROVIDER_ID;
use codex_models_manager::manager::RefreshStrategy;
@@ -429,6 +429,7 @@ struct ThreadSpawnRequest {
startup: Option<Arc<crate::session::startup::SessionStartup>>,
options: StartThreadOptions,
auth_manager: Arc<AuthManager>,
inherited_runtime_auth: Option<crate::runtime_auth::RuntimeAuthSlot>,
agent_control: AgentControlInit,
parent_thread_id: Option<ThreadId>,
parent_originator: Option<String>,
@@ -452,6 +453,7 @@ impl ThreadSpawnRequest {
startup: None,
options,
auth_manager,
inherited_runtime_auth: None,
agent_control: agent_control.into(),
parent_thread_id: None,
parent_originator: None,
@@ -521,8 +523,8 @@ pub(crate) struct ThreadManagerState {
thread_id_generator: ThreadIdGenerator,
agent_control_factory: Option<AgentControlFactory>,
auth_manager: Arc<AuthManager>,
models_manager: SharedModelsManager,
models_manager_provider: ModelProviderInfo,
runtime_auth: crate::runtime_auth::RuntimeAuthSlot,
model_catalog: model_catalog::ThreadModelCatalog,
git_root_discovery: Arc<GitRootDiscovery>,
environment_manager: Arc<EnvironmentManager>,
starting_mcp_runtimes: std::sync::Mutex<Vec<std::sync::Weak<AtomicBool>>>,
@@ -544,20 +546,9 @@ pub(crate) struct ThreadManagerState {
ops_log: Option<SharedCapturedOps>,
}
pub fn build_models_manager(
config: &Config,
auth_manager: Arc<AuthManager>,
) -> SharedModelsManager {
let provider = create_model_provider(config.model_provider.clone(), Some(auth_manager));
let manager = provider.models_manager(
config.codex_home.to_path_buf(),
config.model_catalog.clone(),
);
manager.set_api_key_model_discovery_enabled(
config.features.enabled(Feature::ApiKeyModelDiscovery),
);
manager
}
pub use model_catalog::StartupModels;
pub use model_catalog::build_models_manager;
pub use model_catalog::build_startup_models;
/// Constructs the default image store that preserves inline images.
pub fn passthrough_image_store() -> Arc<dyn AttachmentStore> {
@@ -577,8 +568,7 @@ impl ThreadManager {
#[allow(clippy::too_many_arguments)]
pub fn new(
config: &Config,
auth_manager: Arc<AuthManager>,
models_manager: SharedModelsManager,
startup: StartupModels,
codex_apps_tools_cache: CodexAppsToolsCache,
session_source: SessionSource,
environment_manager: Arc<EnvironmentManager>,
@@ -588,10 +578,15 @@ impl ThreadManager {
image_store: Arc<dyn AttachmentStore>,
thread_store: Arc<dyn ThreadStore>,
agent_graph_store: Option<Arc<dyn AgentGraphStore>>,
installation_id: String,
attestation_provider: Option<Arc<dyn AttestationProvider>>,
external_time_provider: Option<Arc<dyn TimeProvider>>,
) -> Self {
let StartupModels {
credentials: auth_manager,
runtime_auth,
installation_id,
catalog: model_catalog,
} = startup;
let codex_home = config.codex_home.clone();
let restriction_product = session_source.restriction_product();
let (thread_created_tx, _) = broadcast::channel(THREAD_CREATED_CHANNEL_CAPACITY);
@@ -629,8 +624,7 @@ impl ThreadManager {
thread_created_tx,
thread_id_generator: default_thread_id_generator(),
agent_control_factory: None,
models_manager,
models_manager_provider: config.model_provider.clone(),
model_catalog,
git_root_discovery: Arc::default(),
environment_manager,
starting_mcp_runtimes: std::sync::Mutex::new(Vec::new()),
@@ -646,6 +640,7 @@ impl ThreadManager {
attestation_provider,
external_time_provider,
auth_manager,
runtime_auth,
session_source,
installation_id,
analytics_events_client,
@@ -834,14 +829,24 @@ impl ThreadManager {
let agent_graph_store = local_agent_graph_store_from_state_db(state_db.as_ref());
Self {
state: Arc::new(ThreadManagerState {
runtime_auth: Default::default(),
threads: Arc::new(RwLock::new(HashMap::new())),
shared_thread_instructions: Default::default(),
thread_created_tx,
thread_id_generator: default_thread_id_generator(),
agent_control_factory: None,
models_manager_provider: provider.clone(),
models_manager: create_model_provider(provider, Some(auth_manager.clone()))
.models_manager(codex_home, /*config_model_catalog*/ None),
model_catalog: model_catalog::ThreadModelCatalog::new(
provider,
/*catalog*/ None,
Arc::new(codex_models_manager::manager::ModelCatalogSettings::disk(
codex_home,
)),
/*api_key_model_discovery_default*/ false,
codex_model_provider::ProviderAuthContext {
credentials: Arc::clone(&auth_manager),
runtime_auth: None,
},
),
git_root_discovery: Arc::default(),
environment_manager,
starting_mcp_runtimes: std::sync::Mutex::new(Vec::new()),
@@ -982,7 +987,31 @@ impl ThreadManager {
}
pub fn get_models_manager(&self) -> SharedModelsManager {
self.state.models_manager.clone()
self.state.model_catalog.manager.clone()
}
/// Select the app-server catalog using the same owner as thread requests.
pub fn models_manager_for_config(&self, config: &Config) -> SharedModelsManager {
let credentials = self.auth_manager();
let runtime = crate::runtime_auth::runtime_for_config(
&self.state.runtime_auth,
config,
Arc::clone(&credentials),
&self.state.installation_id,
);
self.state.model_catalog.for_config(
config,
config.model_catalog.as_ref(),
credentials,
runtime,
/*same_owner*/ true,
)
}
pub fn set_api_key_model_discovery_enabled(&self, enabled: bool) {
self.state
.model_catalog
.set_api_key_model_discovery_enabled(enabled);
}
pub async fn list_models(
@@ -991,13 +1020,14 @@ impl ThreadManager {
http_client_factory: codex_http_client::HttpClientFactory,
) -> Vec<ModelPreset> {
self.state
.models_manager
.model_catalog
.manager
.list_models(refresh_strategy, http_client_factory)
.await
}
pub fn list_collaboration_modes(&self) -> Vec<CollaborationModeMask> {
self.state.models_manager.list_collaboration_modes()
self.state.model_catalog.manager.list_collaboration_modes()
}
pub async fn list_thread_ids(&self) -> Vec<ThreadId> {
@@ -2081,8 +2111,9 @@ impl ThreadManagerState {
(options.client_mcp_extensions, options.prompt_cache_key) =
self.inherited_child_settings(parent_thread_id).await;
let forked = matches!(options.initial_history, InitialHistory::Forked(_));
let mut request =
ThreadSpawnRequest::new(options, Arc::clone(&self.auth_manager), agent_control);
let (auth_manager, runtime_auth) = self.inherited_child_auth(parent_thread_id).await;
let mut request = ThreadSpawnRequest::new(options, auth_manager, agent_control);
request.inherited_runtime_auth = Some(runtime_auth);
request.parent_thread_id = parent_thread_id;
request.inherited_exec_policy = inherited_exec_policy;
if forked {
@@ -2103,8 +2134,9 @@ impl ThreadManagerState {
options.reserved_thread_id.ok_or_else(|| {
CodexErr::InvalidRequest("prepared child startup requires a reserved thread ID".into())
})?;
let mut request =
ThreadSpawnRequest::new(options, Arc::clone(&self.auth_manager), agent_control);
let (auth_manager, runtime_auth) = self.inherited_child_auth(Some(parent_thread_id)).await;
let mut request = ThreadSpawnRequest::new(options, auth_manager, agent_control);
request.inherited_runtime_auth = Some(runtime_auth);
request.ready_notification = ThreadReadyNotification::Defer;
request.parent_thread_id = Some(parent_thread_id);
let history_initialization = match &request.options.initial_history {
@@ -2175,6 +2207,23 @@ impl ThreadManagerState {
Box::pin(self.spawn_thread(request)).await
}
async fn inherited_child_auth(
&self,
parent_thread_id: Option<ThreadId>,
) -> (Arc<AuthManager>, crate::runtime_auth::RuntimeAuthSlot) {
let threads = self.threads.read().await;
match parent_thread_id.and_then(|id| threads.get(&id)) {
Some(parent) => (
Arc::clone(&parent.session.services.auth_manager),
Arc::clone(&parent.session.services.runtime_auth),
),
None => (
Arc::clone(&self.auth_manager),
Arc::clone(&self.runtime_auth),
),
}
}
async fn inherited_child_settings(
&self,
parent_thread_id: Option<ThreadId>,
@@ -2226,6 +2275,7 @@ impl ThreadManagerState {
startup,
options,
auth_manager,
inherited_runtime_auth,
agent_control,
parent_thread_id,
parent_originator,
@@ -2527,14 +2577,36 @@ impl ThreadManagerState {
} else {
codex_sandboxing::WindowsSandboxProxySettingsMode::Reconcile
};
let models_manager = if config.model_provider == self.models_manager_provider {
Arc::clone(&self.models_manager)
// Resume may supply a different credential owner. Keep its runtime separate from the
// harness default, and let sessions using that parent's credentials inherit its owner.
let runtime_auth = if let Some(runtime_auth) = inherited_runtime_auth {
runtime_auth
} else if Arc::ptr_eq(&auth_manager, &self.auth_manager) {
Arc::clone(&self.runtime_auth)
} else {
build_models_manager(&config, Arc::clone(&auth_manager))
let threads = self.threads.read().await;
parent_thread_id
.or(forked_from_thread_id)
.and_then(|id| threads.get(&id))
.filter(|parent| Arc::ptr_eq(&parent.session.services.auth_manager, &auth_manager))
.map(|parent| Arc::clone(&parent.session.services.runtime_auth))
.unwrap_or_default()
};
let models_manager = models_manager
let runtime = crate::runtime_auth::runtime_for_config(
&runtime_auth,
&config,
Arc::clone(&auth_manager),
&self.installation_id,
);
let manager = self.model_catalog.for_thread(
&config,
Arc::clone(&auth_manager),
runtime,
Arc::ptr_eq(&auth_manager, &self.auth_manager),
);
let models_manager = manager
.for_originator(&startup_context.originator)
.unwrap_or(models_manager);
.unwrap_or(manager);
let spawn_result = Session::spawn(SessionSpawnArgs {
attached_thread,
startup,
@@ -2544,6 +2616,7 @@ impl ThreadManagerState {
instructions: startup_context.instructions,
installation_id: self.installation_id.clone(),
auth_manager,
runtime_auth,
models_manager,
git_root_discovery: Arc::clone(&self.git_root_discovery),
environment_manager: Arc::clone(&self.environment_manager),
@@ -0,0 +1,166 @@
//! Keep each catalog bound to its provider and auth owner while preserving host cache policy.
use crate::config::Config;
use crate::runtime_auth::RuntimeAuthSlot;
use crate::runtime_auth::runtime_for_config;
use codex_features::Feature;
use codex_features::RuntimeAuthMode;
use codex_login::AuthManager;
use codex_model_provider::ModelCatalogFactory;
use codex_model_provider::ProviderAuthContext;
use codex_model_provider_info::ModelProviderInfo;
use codex_models_manager::manager::ModelCatalogSettings;
use codex_models_manager::manager::SharedModelsManager;
use codex_protocol::openai_models::ModelsResponse;
use codex_runtime_agent_instance::RuntimeAuthManager;
use std::sync::Arc;
/// The startup catalog and the credential/runtime owner used to construct it.
pub struct StartupModels {
pub(super) credentials: Arc<AuthManager>,
pub(super) runtime_auth: RuntimeAuthSlot,
pub(super) installation_id: String,
pub(super) catalog: ThreadModelCatalog,
}
impl StartupModels {
/// Share the startup catalog with host discovery and prefetch callers.
pub fn models_manager(&self) -> SharedModelsManager {
Arc::clone(&self.catalog.manager)
}
}
/// Construct the catalog and runtime owner together using the host's catalog factory.
/// Use `ModelCatalogSettings` for standard provider catalogs with a disk, custom, or no cache.
/// Runtime registration remains lazy; this does not register an instance or generate a key.
pub fn build_startup_models(
config: &Config,
credentials: Arc<AuthManager>,
installation_id: String,
factory: Arc<dyn ModelCatalogFactory>,
) -> StartupModels {
let runtime_auth = RuntimeAuthSlot::default();
let runtime = runtime_for_config(
&runtime_auth,
config,
Arc::clone(&credentials),
&installation_id,
);
let catalog = ThreadModelCatalog::new(
config.model_provider.clone(),
config.model_catalog.clone(),
factory,
config.features.enabled(Feature::ApiKeyModelDiscovery),
ProviderAuthContext {
credentials: Arc::clone(&credentials),
runtime_auth: runtime.map(|runtime| (runtime, config.runtime_auth_mode)),
},
);
StartupModels {
credentials,
runtime_auth,
installation_id,
catalog,
}
}
pub(super) struct ThreadModelCatalog {
pub(super) manager: SharedModelsManager,
provider: ModelProviderInfo,
catalog: Option<ModelsResponse>,
factory: Arc<dyn ModelCatalogFactory>,
api_key_model_discovery_default: bool,
runtime_mode: Option<RuntimeAuthMode>,
}
pub fn build_models_manager(
config: &Config,
credentials: Arc<AuthManager>,
runtime: Option<Arc<RuntimeAuthManager>>,
) -> SharedModelsManager {
let auth = ProviderAuthContext {
credentials,
runtime_auth: runtime
.filter(|_| config.features.enabled(Feature::RuntimeAgentInstances))
.map(|runtime| (runtime, config.runtime_auth_mode)),
};
ModelCatalogSettings::disk(config.codex_home.to_path_buf()).build(
auth.model_provider(config.model_provider.clone()),
config.model_catalog.clone(),
config.features.enabled(Feature::ApiKeyModelDiscovery),
)
}
impl ThreadModelCatalog {
pub(super) fn new(
provider: ModelProviderInfo,
catalog: Option<ModelsResponse>,
factory: Arc<dyn ModelCatalogFactory>,
api_key_model_discovery_default: bool,
auth: ProviderAuthContext,
) -> Self {
let runtime_mode = auth.runtime_auth.as_ref().map(|(_, mode)| *mode);
let manager = factory.build(
auth.model_provider(provider.clone()),
catalog.clone(),
api_key_model_discovery_default,
);
Self {
manager,
provider,
catalog,
factory,
api_key_model_discovery_default,
runtime_mode,
}
}
pub(super) fn for_thread(
&self,
config: &Config,
credentials: Arc<AuthManager>,
runtime: Option<Arc<RuntimeAuthManager>>,
same_owner: bool,
) -> SharedModelsManager {
let models = config.model_catalog.as_ref().or_else(|| {
self.catalog
.as_ref()
.filter(|_| self.provider == config.model_provider)
});
self.for_config(config, models, credentials, runtime, same_owner)
}
pub(super) fn for_config(
&self,
config: &Config,
models: Option<&ModelsResponse>,
credentials: Arc<AuthManager>,
runtime: Option<Arc<RuntimeAuthManager>>,
same_owner: bool,
) -> SharedModelsManager {
let runtime = runtime.map(|runtime| (runtime, config.runtime_auth_mode));
let api_key_model_discovery_default =
config.features.enabled(Feature::ApiKeyModelDiscovery);
if same_owner
&& self.runtime_mode == runtime.as_ref().map(|(_, mode)| *mode)
&& self.provider == config.model_provider
&& self.catalog.as_ref() == models
&& self.api_key_model_discovery_default == api_key_model_discovery_default
{
return Arc::clone(&self.manager);
}
let auth = ProviderAuthContext {
credentials,
runtime_auth: runtime,
};
self.factory.build(
auth.model_provider(config.model_provider.clone()),
models.cloned(),
api_key_model_discovery_default,
)
}
pub(super) fn set_api_key_model_discovery_enabled(&self, enabled: bool) {
self.factory.set_api_key_model_discovery_enabled(enabled);
}
}
+214 -51
View File
@@ -25,6 +25,7 @@ use codex_extension_api::SessionIsolation;
use codex_extension_api::empty_extension_registry;
use codex_history::InitialHistory;
use codex_history::ResumedHistory;
use codex_models_manager::manager::ModelCatalogSettings;
use codex_models_manager::manager::RefreshStrategy;
use codex_protocol::ResponseItemId;
use codex_protocol::approvals::ExecPolicyAmendment;
@@ -678,6 +679,128 @@ async fn thread_id_generator_does_not_replace_resumed_thread_id() {
.expect("shut down resumed thread");
}
#[test_case::test_case(false; "local_child")]
#[test_case::test_case(true; "host_child")]
#[tokio::test]
async fn child_session_inherits_resumed_parent_auth(host_child: bool) {
let home = tempdir().expect("temporary home");
let mut config = test_config().await;
config.codex_home = home.path().join("codex-home").abs();
config.cwd = config.codex_home.abs();
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
let manager = ThreadManager::with_models_provider_and_home_for_tests(
CodexAuth::from_api_key("key-a"),
config.model_provider.clone(),
config.codex_home.to_path_buf(),
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
);
let original = manager
.start_thread(StartThreadOptions {
history_mode: Some(ThreadHistoryMode::Legacy),
..StartThreadOptions::new(config.clone())
})
.await
.expect("start original thread");
original.thread.ensure_rollout_materialized().await;
original
.thread
.flush_rollout()
.await
.expect("flush original history");
original
.thread
.shutdown_and_wait()
.await
.expect("stop original thread");
manager.remove_thread(&original.thread_id).await;
let replacement = AuthManager::from_auth_for_testing_with_home(
CodexAuth::from_api_key("key-b"),
config.codex_home.to_path_buf(),
);
let parent = manager
.resume_thread_with_history(
config.clone(),
InitialHistory::Resumed(ResumedHistory {
history_revision: None,
conversation_id: original.thread_id,
history: Arc::new(Vec::new()),
rollout_path: original.thread.rollout_path(),
}),
Arc::clone(&replacement),
/*parent_trace*/ None,
ClientMcpExtensions::default(),
)
.await
.expect("resume with replacement credentials");
let options = StartThreadOptions {
reserved_thread_id: host_child.then(|| manager.reserve_thread_id()),
session_source: Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: parent.thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
})),
..StartThreadOptions::new(config)
};
let control = parent
.thread
.session
.services
.local_agent_runtime
.control(parent.thread.session.session_id());
let child = if host_child {
manager
.state
.spawn_host_child_thread(
options,
control.into(),
parent.thread_id,
crate::agent::host::PreparedChildInheritance {
instructions: parent.thread.session.inherited_instructions().await,
exec_policy: None,
originator: parent.thread.session.originator().await,
attachment_source: None,
},
)
.await
} else {
manager
.state
.spawn_child_thread(
options,
control,
Some(parent.thread_id),
/*inherited_exec_policy*/ None,
)
.await
}
.expect("spawn child under the resumed parent");
assert!(
Arc::ptr_eq(&child.thread.session.services.auth_manager, &replacement),
"child must use the resumed parent's credentials",
);
assert!(
Arc::ptr_eq(
&child.thread.session.services.runtime_auth,
&parent.thread.session.services.runtime_auth,
),
"child must share the resumed parent's runtime owner",
);
assert!(
!Arc::ptr_eq(
&child.thread.session.services.runtime_auth,
&manager.state.runtime_auth
),
"replacement-auth descendants must stay separate from the harness runtime",
);
let report = manager
.shutdown_all_threads_bounded(Duration::from_secs(10))
.await;
assert_eq!(report.completed.len(), 2);
}
#[tokio::test]
async fn child_session_inherits_client_mcp_extensions() {
let temp_dir = tempdir().expect("tempdir");
@@ -1250,8 +1373,12 @@ async fn mcp_invalidation_refreshes_threads_that_are_still_starting() {
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy"));
let manager = Arc::new(ThreadManager::new(
&config,
Arc::clone(&auth_manager),
build_models_manager(&config, auth_manager),
crate::build_startup_models(
&config,
Arc::clone(&auth_manager),
TEST_INSTALLATION_ID.to_string(),
Arc::new(ModelCatalogSettings::disk(config.codex_home.to_path_buf())),
),
crate::CodexAppsToolsCache::default(),
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
@@ -1261,7 +1388,6 @@ async fn mcp_invalidation_refreshes_threads_that_are_still_starting() {
passthrough_image_store(),
ThreadStoreBuilder::from_config(&config, /*state_db*/ None).build(),
/*agent_graph_store*/ None,
TEST_INSTALLATION_ID.to_string(),
/*attestation_provider*/ None,
/*external_time_provider*/ None,
));
@@ -1918,8 +2044,12 @@ async fn start_thread_seeds_extension_data_for_mcp_and_lifecycle_contributors()
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let manager = ThreadManager::new(
&config,
auth_manager.clone(),
build_models_manager(&config, auth_manager),
crate::build_startup_models(
&config,
auth_manager.clone(),
TEST_INSTALLATION_ID.to_string(),
Arc::new(ModelCatalogSettings::disk(config.codex_home.to_path_buf())),
),
crate::CodexAppsToolsCache::default(),
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
@@ -1929,7 +2059,6 @@ async fn start_thread_seeds_extension_data_for_mcp_and_lifecycle_contributors()
passthrough_image_store(),
ThreadStoreBuilder::from_config(&config, /*state_db*/ None).build(),
/*agent_graph_store*/ None,
TEST_INSTALLATION_ID.to_string(),
/*attestation_provider*/ None,
/*external_time_provider*/ None,
);
@@ -2365,8 +2494,12 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() {
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let manager = ThreadManager::new(
&config,
auth_manager.clone(),
build_models_manager(&config, auth_manager.clone()),
crate::build_startup_models(
&config,
auth_manager.clone(),
TEST_INSTALLATION_ID.to_string(),
Arc::new(ModelCatalogSettings::disk(config.codex_home.to_path_buf())),
),
crate::CodexAppsToolsCache::default(),
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
@@ -2376,7 +2509,6 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() {
passthrough_image_store(),
ThreadStoreBuilder::from_config(&config, /*state_db*/ None).build(),
/*agent_graph_store*/ None,
TEST_INSTALLATION_ID.to_string(),
/*attestation_provider*/ None,
/*external_time_provider*/ None,
);
@@ -2525,8 +2657,12 @@ async fn explicit_installation_id_skips_codex_home_file() {
let thread_store = ThreadStoreBuilder::from_config(&config, state_db.clone()).build();
let manager = ThreadManager::new(
&config,
auth_manager.clone(),
build_models_manager(&config, auth_manager),
crate::build_startup_models(
&config,
auth_manager.clone(),
installation_id.clone(),
Arc::new(ModelCatalogSettings::disk(config.codex_home.to_path_buf())),
),
crate::CodexAppsToolsCache::default(),
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
@@ -2536,7 +2672,6 @@ async fn explicit_installation_id_skips_codex_home_file() {
passthrough_image_store(),
thread_store,
local_agent_graph_store_from_state_db(state_db.as_ref()),
installation_id.clone(),
/*attestation_provider*/ None,
/*external_time_provider*/ None,
);
@@ -2569,8 +2704,12 @@ async fn resume_active_thread_from_rollout_returns_running_thread() {
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let manager = ThreadManager::new(
&config,
auth_manager.clone(),
build_models_manager(&config, auth_manager.clone()),
crate::build_startup_models(
&config,
auth_manager.clone(),
TEST_INSTALLATION_ID.to_string(),
Arc::new(ModelCatalogSettings::disk(config.codex_home.to_path_buf())),
),
crate::CodexAppsToolsCache::default(),
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
@@ -2580,7 +2719,6 @@ async fn resume_active_thread_from_rollout_returns_running_thread() {
passthrough_image_store(),
ThreadStoreBuilder::from_config(&config, /*state_db*/ None).build(),
/*agent_graph_store*/ None,
TEST_INSTALLATION_ID.to_string(),
/*attestation_provider*/ None,
/*external_time_provider*/ None,
);
@@ -2635,8 +2773,12 @@ async fn resume_stopped_thread_from_rollout_spawns_new_thread() {
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let manager = ThreadManager::new(
&config,
auth_manager.clone(),
build_models_manager(&config, auth_manager.clone()),
crate::build_startup_models(
&config,
auth_manager.clone(),
TEST_INSTALLATION_ID.to_string(),
Arc::new(ModelCatalogSettings::disk(config.codex_home.to_path_buf())),
),
crate::CodexAppsToolsCache::default(),
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
@@ -2646,7 +2788,6 @@ async fn resume_stopped_thread_from_rollout_spawns_new_thread() {
passthrough_image_store(),
ThreadStoreBuilder::from_config(&config, /*state_db*/ None).build(),
/*agent_graph_store*/ None,
TEST_INSTALLATION_ID.to_string(),
/*attestation_provider*/ None,
/*external_time_provider*/ None,
);
@@ -2708,8 +2849,12 @@ async fn resume_stopped_thread_from_rollout_preserves_thread_source() {
let thread_store = ThreadStoreBuilder::from_config(&config, state_db.clone()).build();
let manager = ThreadManager::new(
&config,
auth_manager.clone(),
build_models_manager(&config, auth_manager.clone()),
crate::build_startup_models(
&config,
auth_manager.clone(),
TEST_INSTALLATION_ID.to_string(),
Arc::new(ModelCatalogSettings::disk(config.codex_home.to_path_buf())),
),
crate::CodexAppsToolsCache::default(),
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
@@ -2719,7 +2864,6 @@ async fn resume_stopped_thread_from_rollout_preserves_thread_source() {
passthrough_image_store(),
thread_store,
local_agent_graph_store_from_state_db(state_db.as_ref()),
TEST_INSTALLATION_ID.to_string(),
/*attestation_provider*/ None,
/*external_time_provider*/ None,
);
@@ -2795,8 +2939,12 @@ async fn subtree_listing_uses_injected_graph_store_without_state_db() {
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let manager = ThreadManager::new(
&config,
auth_manager.clone(),
build_models_manager(&config, auth_manager),
crate::build_startup_models(
&config,
auth_manager.clone(),
TEST_INSTALLATION_ID.to_string(),
Arc::new(ModelCatalogSettings::disk(config.codex_home.to_path_buf())),
),
crate::CodexAppsToolsCache::default(),
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
@@ -2806,7 +2954,6 @@ async fn subtree_listing_uses_injected_graph_store_without_state_db() {
passthrough_image_store(),
ThreadStoreBuilder::from_config(&config, /*state_db*/ None).build(),
Some(agent_graph_store),
TEST_INSTALLATION_ID.to_string(),
/*attestation_provider*/ None,
/*external_time_provider*/ None,
);
@@ -2843,8 +2990,12 @@ async fn rollout_path_resume_and_fork_read_history_through_thread_store() {
.expect("configured in-memory store");
let manager = ThreadManager::new(
&config,
auth_manager.clone(),
build_models_manager(&config, auth_manager.clone()),
crate::build_startup_models(
&config,
auth_manager.clone(),
TEST_INSTALLATION_ID.to_string(),
Arc::new(ModelCatalogSettings::disk(config.codex_home.to_path_buf())),
),
crate::CodexAppsToolsCache::default(),
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
@@ -2854,7 +3005,6 @@ async fn rollout_path_resume_and_fork_read_history_through_thread_store() {
passthrough_image_store(),
thread_store.clone(),
local_agent_graph_store_from_state_db(state_db.as_ref()),
TEST_INSTALLATION_ID.to_string(),
/*attestation_provider*/ None,
/*external_time_provider*/ None,
);
@@ -2953,8 +3103,12 @@ async fn metadata_update_without_result_reads_only_when_the_caller_needs_the_thr
.expect("configured in-memory store");
let manager = ThreadManager::new(
&config,
auth_manager.clone(),
build_models_manager(&config, auth_manager),
crate::build_startup_models(
&config,
auth_manager.clone(),
TEST_INSTALLATION_ID.to_string(),
Arc::new(ModelCatalogSettings::disk(config.codex_home.to_path_buf())),
),
crate::CodexAppsToolsCache::default(),
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
@@ -2964,7 +3118,6 @@ async fn metadata_update_without_result_reads_only_when_the_caller_needs_the_thr
passthrough_image_store(),
thread_store.clone(),
/*agent_graph_store*/ None,
TEST_INSTALLATION_ID.to_string(),
/*attestation_provider*/ None,
/*external_time_provider*/ None,
);
@@ -3080,8 +3233,12 @@ async fn new_uses_active_provider_for_model_refresh() {
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let manager = ThreadManager::new(
&config,
auth_manager.clone(),
build_models_manager(&config, auth_manager),
crate::build_startup_models(
&config,
auth_manager.clone(),
TEST_INSTALLATION_ID.to_string(),
Arc::new(ModelCatalogSettings::disk(config.codex_home.to_path_buf())),
),
crate::CodexAppsToolsCache::default(),
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
@@ -3091,7 +3248,6 @@ async fn new_uses_active_provider_for_model_refresh() {
passthrough_image_store(),
ThreadStoreBuilder::from_config(&config, /*state_db*/ None).build(),
/*agent_graph_store*/ None,
TEST_INSTALLATION_ID.to_string(),
/*attestation_provider*/ None,
/*external_time_provider*/ None,
);
@@ -3157,7 +3313,7 @@ async fn new_uses_active_provider_for_model_refresh() {
}
#[tokio::test]
async fn injected_models_manager_controls_refresh_policy() {
async fn startup_catalog_cache_policy_controls_refresh() {
let server = MockServer::start().await;
let _ = mount_models_once(&server, ModelsResponse { models: vec![] }).await;
let _ = mount_models_once(&server, ModelsResponse { models: vec![] }).await;
@@ -3172,15 +3328,14 @@ async fn injected_models_manager_controls_refresh_policy() {
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let provider = create_model_provider(
config.model_provider.clone(),
Some(Arc::clone(&auth_manager)),
);
let models_manager = provider.models_manager_without_cache(config.model_catalog.clone());
let manager = ThreadManager::new(
&config,
auth_manager,
models_manager,
crate::build_startup_models(
&config,
auth_manager,
TEST_INSTALLATION_ID.to_string(),
Arc::new(ModelCatalogSettings::default()),
),
crate::CodexAppsToolsCache::default(),
SessionSource::Custom("test-embedder".to_string()),
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
@@ -3190,7 +3345,6 @@ async fn injected_models_manager_controls_refresh_policy() {
passthrough_image_store(),
ThreadStoreBuilder::from_config(&config, /*state_db*/ None).build(),
/*agent_graph_store*/ None,
TEST_INSTALLATION_ID.to_string(),
/*attestation_provider*/ None,
/*external_time_provider*/ None,
);
@@ -3460,8 +3614,12 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor
let state_db = init_state_db(&config).await;
let manager = ThreadManager::new(
&config,
auth_manager.clone(),
build_models_manager(&config, auth_manager.clone()),
crate::build_startup_models(
&config,
auth_manager.clone(),
TEST_INSTALLATION_ID.to_string(),
Arc::new(ModelCatalogSettings::disk(config.codex_home.to_path_buf())),
),
crate::CodexAppsToolsCache::default(),
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
@@ -3471,7 +3629,6 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor
passthrough_image_store(),
ThreadStoreBuilder::from_config(&config, state_db.clone()).build(),
local_agent_graph_store_from_state_db(state_db.as_ref()),
TEST_INSTALLATION_ID.to_string(),
/*attestation_provider*/ None,
/*external_time_provider*/ None,
);
@@ -3572,8 +3729,12 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() {
let state_db = init_state_db(&config).await;
let manager = ThreadManager::new(
&config,
auth_manager.clone(),
build_models_manager(&config, auth_manager.clone()),
crate::build_startup_models(
&config,
auth_manager.clone(),
TEST_INSTALLATION_ID.to_string(),
Arc::new(ModelCatalogSettings::disk(config.codex_home.to_path_buf())),
),
crate::CodexAppsToolsCache::default(),
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
@@ -3583,7 +3744,6 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() {
passthrough_image_store(),
ThreadStoreBuilder::from_config(&config, state_db.clone()).build(),
local_agent_graph_store_from_state_db(state_db.as_ref()),
TEST_INSTALLATION_ID.to_string(),
/*attestation_provider*/ None,
/*external_time_provider*/ None,
);
@@ -3677,8 +3837,12 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_
let state_db = init_state_db(&config).await;
let manager = ThreadManager::new(
&config,
auth_manager.clone(),
build_models_manager(&config, auth_manager.clone()),
crate::build_startup_models(
&config,
auth_manager.clone(),
TEST_INSTALLATION_ID.to_string(),
Arc::new(ModelCatalogSettings::disk(config.codex_home.to_path_buf())),
),
crate::CodexAppsToolsCache::default(),
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
@@ -3688,7 +3852,6 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_
passthrough_image_store(),
ThreadStoreBuilder::from_config(&config, state_db.clone()).build(),
local_agent_graph_store_from_state_db(state_db.as_ref()),
TEST_INSTALLATION_ID.to_string(),
/*attestation_provider*/ None,
/*external_time_provider*/ None,
);
@@ -37,6 +37,7 @@ use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_model_provider::create_model_provider;
use codex_model_provider_info::built_in_model_providers;
use codex_models_manager::manager::ModelCatalogSettings;
use codex_models_manager::manager::StaticModelsManager;
use codex_otel::MULTI_AGENT_SPAWN_FAILURE_METRIC;
use codex_otel::MetricsClient;
@@ -4319,8 +4320,12 @@ async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtr
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy"));
let manager = ThreadManager::new(
&config,
auth_manager.clone(),
crate::thread_manager::build_models_manager(&config, auth_manager),
crate::build_startup_models(
&config,
auth_manager.clone(),
"11111111-1111-4111-8111-111111111111".to_string(),
Arc::new(ModelCatalogSettings::disk(config.codex_home.to_path_buf())),
),
crate::CodexAppsToolsCache::default(),
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
@@ -4330,7 +4335,6 @@ async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtr
crate::thread_manager::passthrough_image_store(),
ThreadStoreBuilder::from_config(&config, state_db.clone()).build(),
local_agent_graph_store_from_state_db(state_db.as_ref()),
"11111111-1111-4111-8111-111111111111".to_string(),
/*attestation_provider*/ None,
/*external_time_provider*/ None,
);
+22 -7
View File
@@ -44,6 +44,7 @@ use codex_login::CodexAuth;
use codex_model_provider_info::ModelProviderInfo;
use codex_model_provider_info::built_in_model_providers;
use codex_models_manager::bundled_models_response;
use codex_models_manager::manager::ModelCatalogSettings;
use codex_models_manager::manager::SharedModelsManager;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ModeKind;
@@ -424,6 +425,7 @@ pub struct TestCodexBuilder {
code_mode_host_program: Option<PathBuf>,
history_mode: Option<ThreadHistoryMode>,
models_manager: Option<SharedModelsManager>,
model_catalog_settings: Option<ModelCatalogSettings>,
thread_store: Option<Arc<dyn ThreadStore>>,
agent_graph_store: Option<Arc<dyn AgentGraphStore>>,
image_store: Arc<dyn AttachmentStore>,
@@ -479,6 +481,11 @@ impl TestCodexBuilder {
self
}
pub fn with_model_catalog_settings(mut self, settings: ModelCatalogSettings) -> Self {
self.model_catalog_settings = Some(settings);
self
}
pub fn with_models_manager(mut self, models_manager: SharedModelsManager) -> Self {
self.models_manager = Some(models_manager);
self
@@ -863,14 +870,23 @@ impl TestCodexBuilder {
))
});
let auth_manager = self.auth.manager_for_home(config.codex_home.as_path());
let models_manager = self
.models_manager
.clone()
.unwrap_or_else(|| codex_core::build_models_manager(&config, auth_manager.clone()));
let code_mode_host_program = self
.code_mode_host_program
.take()
.or_else(|| codex_utils_cargo_bin::cargo_bin("codex-code-mode-host").ok());
let factory =
match &self.models_manager {
Some(models) => codex_core::test_support::fixed_model_catalog(Arc::clone(models)),
None => Arc::new(self.model_catalog_settings.clone().unwrap_or_else(|| {
ModelCatalogSettings::disk(config.codex_home.to_path_buf())
})),
};
let startup = codex_core::build_startup_models(
&config,
Arc::clone(&auth_manager),
installation_id,
factory,
);
let thread_manager = Arc::new_cyclic(|manager| {
let mut extensions = self.extensions.to_builder();
codex_app_server::install_agent_message_board(&mut extensions, manager.clone());
@@ -881,8 +897,7 @@ impl TestCodexBuilder {
}
let thread_manager = ThreadManager::new(
&config,
auth_manager.clone(),
models_manager,
startup,
self.apps_tools_cache.clone(),
SessionSource::Exec,
Arc::clone(&environment_manager),
@@ -894,7 +909,6 @@ impl TestCodexBuilder {
self.agent_graph_store.clone().or_else(|| {
codex_core::local_agent_graph_store_from_state_db(state_db.as_ref())
}),
installation_id,
/*attestation_provider*/ None,
/*external_time_provider*/ self.external_time_provider.clone(),
);
@@ -1573,6 +1587,7 @@ pub fn test_codex() -> TestCodexBuilder {
// These fixtures exercise legacy-only resume/fork helpers; store-default tests opt out.
history_mode: Some(ThreadHistoryMode::Legacy),
models_manager: None,
model_catalog_settings: None,
thread_store: None,
agent_graph_store: None,
image_store: codex_core::passthrough_image_store(),
+3 -6
View File
@@ -129,10 +129,9 @@ async fn responses_stream_includes_subagent_header_on_review() {
);
let client = ModelClient::new(
/*auth_manager*/ None,
codex_model_provider::create_model_provider(provider.clone(), /*auth_manager*/ None),
AgentIdentityAuthPolicy::JwtOnly,
thread_id,
provider.clone(),
session_source.clone(),
"test_originator".to_string(),
config.model_verbosity,
@@ -279,10 +278,9 @@ async fn responses_stream_includes_subagent_header_on_other() {
);
let client = ModelClient::new(
/*auth_manager*/ None,
codex_model_provider::create_model_provider(provider.clone(), /*auth_manager*/ None),
AgentIdentityAuthPolicy::JwtOnly,
thread_id,
provider.clone(),
session_source.clone(),
"test_originator".to_string(),
config.model_verbosity,
@@ -414,10 +412,9 @@ async fn responses_respects_model_info_overrides_from_config() {
);
let client = ModelClient::new(
/*auth_manager*/ None,
codex_model_provider::create_model_provider(provider.clone(), /*auth_manager*/ None),
AgentIdentityAuthPolicy::JwtOnly,
thread_id,
provider.clone(),
session_source.clone(),
"test_originator".to_string(),
config.model_verbosity,
+14 -9
View File
@@ -26,6 +26,7 @@ use codex_model_provider_info::ModelProviderInfo;
use codex_model_provider_info::WireApi;
use codex_model_provider_info::built_in_model_providers;
use codex_models_manager::bundled_models_response;
use codex_models_manager::manager::ModelCatalogSettings;
use codex_otel::SessionTelemetry;
use codex_otel::TelemetryAuthMode;
use codex_protocol::ResponseItemId;
@@ -1823,12 +1824,14 @@ async fn send_request_with_provider(provider: ModelProviderInfo) {
SessionSource::Exec,
);
let client = ModelClient::new(
Some(AuthManager::from_auth_for_testing(CodexAuth::from_api_key(
"unused-api-key",
))),
codex_model_provider::create_model_provider(
provider,
Some(AuthManager::from_auth_for_testing(CodexAuth::from_api_key(
"unused-api-key",
))),
),
AgentIdentityAuthPolicy::JwtOnly,
thread_id,
provider,
SessionSource::Exec,
"test_originator".to_string(),
config.model_verbosity,
@@ -2074,8 +2077,12 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() {
.expect("resolve installation id");
let thread_manager = ThreadManager::new(
&config,
auth_manager.clone(),
codex_core::build_models_manager(&config, auth_manager),
codex_core::build_startup_models(
&config,
auth_manager.clone(),
installation_id,
Arc::new(ModelCatalogSettings::disk(config.codex_home.to_path_buf())),
),
codex_core::CodexAppsToolsCache::default(),
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
@@ -2085,7 +2092,6 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() {
codex_core::passthrough_image_store(),
ThreadStoreBuilder::from_config(&config, /*state_db*/ None).build(),
/*agent_graph_store*/ None,
installation_id,
/*attestation_provider*/ None,
/*external_time_provider*/ None,
);
@@ -3329,10 +3335,9 @@ async fn azure_responses_request_does_not_store_and_preserves_prefixed_item_ids(
);
let client = ModelClient::new(
/*auth_manager*/ None,
codex_model_provider::create_model_provider(provider.clone(), /*auth_manager*/ None),
AgentIdentityAuthPolicy::JwtOnly,
thread_id,
provider.clone(),
SessionSource::Exec,
"test_originator".to_string(),
config.model_verbosity,
+23 -2
View File
@@ -2827,6 +2827,28 @@ async fn websocket_harness_with_provider_options_and_auth(
concurrent_reasoning_summaries_enabled: bool,
enabled_features: &[Feature],
auth: Option<CodexAuth>,
) -> WebsocketTestHarness {
websocket_harness_with_provider_factory(
provider,
runtime_metrics_enabled,
concurrent_reasoning_summaries_enabled,
enabled_features,
auth,
|provider, auth| codex_model_provider::create_model_provider(provider, auth),
)
.await
}
async fn websocket_harness_with_provider_factory(
provider: ModelProviderInfo,
runtime_metrics_enabled: bool,
concurrent_reasoning_summaries_enabled: bool,
enabled_features: &[Feature],
auth: Option<CodexAuth>,
provider_factory: impl FnOnce(
ModelProviderInfo,
Option<Arc<AuthManager>>,
) -> codex_model_provider::SharedModelProvider,
) -> WebsocketTestHarness {
let codex_home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&codex_home).await;
@@ -2898,10 +2920,9 @@ async fn websocket_harness_with_provider_options_and_auth(
)
});
let client = ModelClient::new(
client_auth_manager,
provider_factory(provider.clone(), client_auth_manager),
AgentIdentityAuthPolicy::JwtOnly,
thread_id,
provider.clone(),
SessionSource::Exec,
"test_originator".to_string(),
config.model_verbosity,
@@ -58,12 +58,33 @@ async fn runtime_harness(
) -> (WebsocketTestHarness, Arc<Credentials>) {
provider.requires_openai_auth = true;
let initial = CodexAuth::from_api_key("key-a");
let mut harness = websocket_harness_with_provider_options_and_auth(
let registration_url = format!("{}/register", registration.uri()).parse().unwrap();
let harness = websocket_harness_with_provider_factory(
provider,
/*runtime_metrics_enabled*/ false,
/*concurrent_reasoning_summaries_enabled*/ false,
&[],
Some(initial.clone()),
move |provider, credentials| {
let credentials = credentials.expect("runtime fixture requires credentials");
let runtime = Arc::new(RuntimeAuthManager::new(
Arc::clone(&credentials),
AgentHostAuthorization::InstallationId {
installation_id: Uuid::nil(),
},
RuntimeRegistrationMetadata {
harness: Some("codex".into()),
harness_version: None,
role: RuntimeRole::OrchestratorAndExecutor,
},
registration_url,
));
codex_model_provider::test_support::runtime_model_provider(
provider,
credentials,
(runtime, mode),
)
},
)
.await;
let credentials = Arc::new(Credentials(RwLock::new(initial)));
@@ -72,19 +93,6 @@ async fn runtime_harness(
.set_external_auth(credentials.clone())
.await
.unwrap();
let runtime = Arc::new(RuntimeAuthManager::new(
harness.auth_manager.clone(),
AgentHostAuthorization::InstallationId {
installation_id: Uuid::nil(),
},
RuntimeRegistrationMetadata {
harness: Some("codex".into()),
harness_version: None,
role: RuntimeRole::OrchestratorAndExecutor,
},
format!("{}/register", registration.uri()).parse().unwrap(),
));
harness.client = harness.client.with_runtime_auth(runtime, mode).unwrap();
(harness, credentials)
}
@@ -1,5 +1,6 @@
//! Integration coverage for model catalogs supplied through the public cache interface.
use codex_core::StartThreadOptions;
use codex_core::TurnInputRequest;
use std::sync::Arc;
use std::sync::Mutex;
@@ -12,12 +13,12 @@ use chrono::Utc;
use codex_http_client::HttpClientFactory;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_model_provider::create_model_provider;
use codex_model_provider_info::built_in_model_providers;
use codex_models_manager::cache::ModelsCache;
use codex_models_manager::cache::ModelsCacheEntry;
use codex_models_manager::cache::ModelsCacheError;
use codex_models_manager::cache::ModelsCacheFuture;
use codex_models_manager::manager::ModelCatalogSettings;
use codex_models_manager::manager::ModelsEndpointClient;
use codex_models_manager::manager::ModelsEndpointFuture;
use codex_models_manager::manager::ModelsEndpointResponse;
@@ -44,6 +45,7 @@ use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use test_case::test_case;
use tokio::sync::oneshot;
use tokio::time::Duration;
use tokio::time::timeout;
@@ -202,7 +204,15 @@ async fn run_agent_with_model(models_manager: SharedModelsManager, model_slug: &
.await;
let mut builder = test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_models_manager(models_manager);
.with_models_manager(models_manager)
.with_config(|config| {
config
.features
.enable(codex_features::Feature::RuntimeAgentInstances)
.expect("test config should allow enabling runtime auth");
// Model inference uses a third-party fixture; a custom ChatGPT base must not block startup.
config.chatgpt_base_url = "https://custom.example.test/backend-api".to_string();
});
let test = builder.build(&server).await?;
let available_models = test
.thread_manager
@@ -245,6 +255,7 @@ async fn run_agent_with_model(models_manager: SharedModelsManager, model_slug: &
response_mock.single_request().body_json()["model"],
model_slug
);
assert!(!test.codex_home_path().join("models_cache.json").exists());
Ok(())
}
@@ -295,6 +306,124 @@ async fn injected_cache_error_falls_back_for_agent_model_selection() -> Result<(
Ok(())
}
#[test_case(false; "without_cache")]
#[test_case(true; "with_host_cache")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn threads_follow_provider_changes_and_preserve_host_cache_policy(
use_cache: bool,
) -> Result<()> {
let host = responses::start_mock_server().await;
let inference = responses::start_mock_server().await;
let model = "host-catalog-model";
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/host-models"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(ModelsResponse {
models: vec![remote_model(model)],
}),
)
.expect(1..)
.mount(&host)
.await;
let auth = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("host-catalog-key"));
let mut info =
built_in_model_providers(Some(format!("{}/v1", inference.uri())))["openai"].clone();
info.model_catalog_url = Some(format!("{}/host-models", host.uri()).into());
let cache = Arc::new(TestModelsCache {
entry: None,
load_error: false,
stored_entries: Mutex::default(),
store_gate: Mutex::default(),
});
let settings = if use_cache {
ModelCatalogSettings::with_cache(cache.clone())
} else {
ModelCatalogSettings::default()
};
settings.set_api_key_model_discovery_enabled(/*enabled*/ true);
let test = test_codex()
.with_auth_manager(auth)
.with_model_catalog_settings(settings)
.with_config(move |config| {
config.model_provider = info;
config
.features
.enable(codex_features::Feature::RuntimeAgentInstances)
.expect("runtime identity feature should be available");
config.model = None;
})
.build_with_auto_env(&inference)
.await?;
assert_eq!(test.session_configured.model, model);
let mut disabled = test.config.clone();
disabled
.features
.disable(codex_features::Feature::RuntimeAgentInstances)?;
let sibling = test
.thread_manager
.start_thread(StartThreadOptions::new(disabled))
.await?;
assert_eq!(sibling.session_configured.model, model);
let second_host = responses::start_mock_server().await;
let second_inference = responses::start_mock_server().await;
let second_model = "second-provider-model";
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/second-models"))
.and(wiremock::matchers::header(
"authorization",
"Bearer host-catalog-key",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(ModelsResponse {
models: vec![remote_model(second_model)],
}),
)
.expect(1..)
.mount(&second_host)
.await;
let mut changed = test.config.clone();
changed.model_provider.base_url = Some(format!("{}/v1", second_inference.uri()));
changed.model_provider.model_catalog_url =
Some(format!("{}/second-models", second_host.uri()).into());
let changed_thread = test
.thread_manager
.start_thread(StartThreadOptions::new(changed))
.await?;
assert_eq!(changed_thread.session_configured.model, second_model);
if use_cache {
assert_eq!(
cache
.stored_entries
.lock()
.expect("stored entries lock should not be poisoned")
.last()
.expect("provider catalog should be cached")
.models,
vec![remote_model(second_model)],
);
}
for (server, path) in [(&host, "/host-models"), (&second_host, "/second-models")] {
for request in server
.received_requests()
.await
.expect("mock requests should be recorded")
{
assert_eq!(request.url.path(), path);
assert_eq!(request.headers["authorization"], "Bearer host-catalog-key");
}
}
assert_eq!(
!cache
.stored_entries
.lock()
.expect("stored entries lock should not be poisoned")
.is_empty(),
use_cache
);
assert!(!test.codex_home_path().join("models_cache.json").exists());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn account_switch_during_cache_store_preserves_new_catalog_for_next_turn() -> Result<()> {
let server = wiremock::MockServer::start().await;
@@ -313,8 +442,6 @@ async fn account_switch_during_cache_store_preserves_new_catalog_for_next_turn()
stored_entries: Mutex::new(Vec::new()),
store_gate: Mutex::new(None),
});
let manager = create_model_provider(provider_info.clone(), Some(auth.clone()))
.models_manager_with_cache(/*config_model_catalog*/ None, cache.clone());
let mut old_model = remote_model("gpt-5.4");
old_model.default_reasoning_summary = ReasoningSummary::Concise;
let initial = responses::mount_models_once(
@@ -326,15 +453,16 @@ async fn account_switch_during_cache_store_preserves_new_catalog_for_next_turn()
.await;
let test = test_codex()
.with_home(home.clone())
.with_auth(initial_auth.clone())
.with_auth_manager(Arc::clone(&auth))
.with_model("gpt-5.4")
.with_models_manager(manager.clone())
.with_model_catalog_settings(ModelCatalogSettings::with_cache(cache.clone()))
.with_config(move |config| {
config.model_provider = provider_info;
config.model_reasoning_summary = None;
})
.build_with_auto_env(&server)
.await?;
let manager = test.thread_manager.get_models_manager();
assert_eq!(initial.requests().len(), 1);
// Pause after the old catalog has been stored, before its refresh can publish it.
@@ -379,7 +507,6 @@ async fn account_switch_during_cache_store_preserves_new_catalog_for_next_turn()
}))?,
)?;
auth.reload().await;
test.thread_manager.auth_manager().reload().await;
let mut new_model = remote_model("gpt-5.4");
new_model.default_reasoning_summary = ReasoningSummary::Detailed;
+1
View File
@@ -208,6 +208,7 @@ mod rmcp_client;
mod rollout_budget;
mod rollout_compression;
mod rollout_list_find;
mod runtime_auth;
mod safety_buffering;
mod safety_check_downgrade;
mod scenarios;
+650
View File
@@ -0,0 +1,650 @@
//! Exercise harness-owned auth through real sessions, subagents, and delegated review.
use anyhow::Context;
use anyhow::Result;
use base64::Engine;
use codex_core::CodexThread;
use codex_core::StartThreadOptions;
use codex_core::TurnInputRequest;
use codex_features::Feature;
use codex_features::RuntimeAuthMode;
use codex_history::InitialHistory;
use codex_history::ResumedHistory;
use codex_login::CodexAuth;
use codex_model_provider::ProviderAuthContext;
use codex_models_manager::manager::RefreshStrategy;
use codex_protocol::mcp::ClientMcpExtensions;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::InternalSessionSource;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::protocol::ReviewTarget;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::user_input::UserInput;
use core_test_support::responses;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use std::collections::BTreeSet;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpListener;
use tokio::net::TcpStream;
use tokio::process::Command;
use tokio::task::JoinSet;
use tokio_rustls::TlsAcceptor;
use tokio_rustls::rustls;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::body_string_contains;
use wiremock::matchers::method;
use wiremock::matchers::path;
const PROBE_SERVER: &str = "CODEX_RUNTIME_AUTH_TEST_SERVER";
const TEST_NAME: &str = "suite::runtime_auth::harness_shares_runtime_and_honors_thread_auth_config";
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn harness_shares_runtime_and_honors_thread_auth_config() -> Result<()> {
skip_if_no_network!(Ok(()));
if let Ok(server_uri) = std::env::var(PROBE_SERVER) {
return exercise_harness(server_uri).await;
}
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/agent/register"))
.respond_with(|request: &wiremock::Request| {
let body: Value = request.body_json().expect("registration body should parse");
let replacement = body["actor_auth"]["credential"] == "key-b";
ResponseTemplate::new(200).set_body_json(json!({
"runtime_agent_instance_id": if replacement { "runtime-replacement" } else { "runtime-shared" },
"agent_host_id": "host-a",
"actor": {"kind": "platform", "user_id": if replacement { "user-b" } else { "user-a" }, "organization_id": "org-a", "project_id": null},
}))
})
.expect(2)
.mount(&server)
.await;
// Both runtime modes retain the normal same-origin redirect behavior.
for label in ["parent-dual", "child-assertion"] {
Mock::given(method("POST"))
.and(path("/v1/responses"))
.and(body_string_contains(label))
.respond_with(
ResponseTemplate::new(307)
.insert_header("Location", "https://api.openai.com/redirected/responses"),
)
.up_to_n_times(1)
.expect(1)
.with_priority(1)
.mount(&server)
.await;
}
let responses = responses::mount_response_sequence(
&server,
(0..14)
.map(|i| {
let response =
responses::sse_response(responses::sse(vec![responses::ev_completed(
&format!("resp-{i}"),
)]));
if (1..=4).contains(&i) {
response.insert_header("X-Models-Etag", format!("catalog-refreshed-{i}"))
} else {
response
}
})
.collect(),
)
.await;
Mock::given(method("GET"))
.and(path("/catalog-isolation"))
.respond_with(ResponseTemplate::new(200).set_body_json(catalog_fixture()))
.expect(2)
.mount(&server)
.await;
for mode in ["dual", "assertion", "disabled"] {
Mock::given(method("GET"))
.and(path(format!("/catalog-reload/{mode}")))
.respond_with(
ResponseTemplate::new(200)
.insert_header("ETag", "catalog-initial")
.set_body_json(catalog_fixture()),
)
.expect(2)
.mount(&server)
.await;
}
Mock::given(method("GET"))
.and(path("/resumed-off-models"))
.respond_with(ResponseTemplate::new(200).set_body_json(catalog_fixture()))
.mount(&server)
.await;
// The production destination check stays intact. A test-only CA and CONNECT proxy route
// the two real host names to wiremock, only inside this isolated test subprocess.
codex_utils_rustls_provider::ensure_rustls_crypto_provider();
let rcgen::CertifiedKey { cert, signing_key } = rcgen::generate_simple_self_signed(vec![
"api.openai.com".into(),
"auth.openai.com".into(),
])?;
let ca_dir = tempfile::tempdir()?;
let ca_path = ca_dir.path().join("ca.pem");
std::fs::write(&ca_path, cert.pem())?;
let acceptor = TlsAcceptor::from(Arc::new(
rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(vec![cert.der().clone()], signing_key.into())?,
));
let proxy = TcpListener::bind("127.0.0.1:0").await?;
let proxy_url = format!("http://{}", proxy.local_addr()?);
let target = *server.address();
let proxy_task = tokio::spawn(async move {
let mut connections = JoinSet::new();
loop {
let (mut stream, _) = proxy.accept().await?;
let acceptor = acceptor.clone();
connections.spawn(async move {
let mut headers = Vec::new();
while !headers.ends_with(b"\r\n\r\n") && headers.len() < 8192 {
headers.push(stream.read_u8().await?);
}
let headers = std::str::from_utf8(&headers)?;
anyhow::ensure!(
matches!(
headers.lines().next(),
Some(
"CONNECT api.openai.com:443 HTTP/1.1"
| "CONNECT auth.openai.com:443 HTTP/1.1"
)
),
"unexpected proxy destination"
);
stream
.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")
.await?;
let mut stream = acceptor.accept(stream).await?;
let mut target = TcpStream::connect(target).await?;
tokio::io::copy_bidirectional(&mut stream, &mut target).await?;
Ok::<_, anyhow::Error>(())
});
}
#[allow(unreachable_code)]
Ok::<_, anyhow::Error>(())
});
let mut command = Command::new(std::env::current_exe()?);
command
.args(["--exact", TEST_NAME, "--nocapture"])
.kill_on_drop(true);
for &key in codex_network_proxy::PROXY_ENV_KEYS {
command.env_remove(key);
}
command
.env(PROBE_SERVER, server.uri())
.env("HTTPS_PROXY", proxy_url)
.env("NO_PROXY", codex_network_proxy::DEFAULT_NO_PROXY_VALUE)
.env("CODEX_CA_CERTIFICATE", ca_path);
let output = tokio::time::timeout(Duration::from_secs(90), command.output()).await;
proxy_task.abort();
let output = output??;
assert!(
output.status.success(),
"harness subprocess failed\n{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let requests = responses.requests();
let expected = [
(Some("startup-disabled"), "Bearer key-a", None),
(
Some("discovery-off"),
"Bearer key-a",
Some("runtime-shared"),
),
(Some("catalog-dual"), "Bearer key-a", Some("runtime-shared")),
(
Some("catalog-assertion"),
"AgentAssertion ",
Some("runtime-shared"),
),
(Some("catalog-disabled"), "Bearer key-a", None),
(Some("parent-dual"), "Bearer key-a", Some("runtime-shared")),
// Explicit first-party inference with a third-party catalog.
(None, "Bearer key-a", Some("runtime-shared")),
(
Some("child-assertion"),
"AgentAssertion ",
Some("runtime-shared"),
),
(None, "AgentAssertion ", Some("runtime-shared")),
// The inline review delegate inherits the parent's dual-header mode.
(None, "Bearer key-a", Some("runtime-shared")),
(Some("thread-disabled"), "Bearer key-a", None),
(Some("third-party"), "Bearer key-a", None),
(
Some("replacement-root"),
"Bearer key-b",
Some("runtime-replacement"),
),
(
Some("replacement-child"),
"Bearer key-b",
Some("runtime-replacement"),
),
];
assert_eq!(requests.len(), expected.len());
let mut proof_ids = BTreeSet::new();
for (request, (label, expected_auth, expected_runtime)) in requests.iter().zip(expected) {
if let Some(label) = label {
assert_eq!(
request
.message_input_texts("user")
.last()
.map(String::as_str),
Some(label)
);
}
let auth = request.header("authorization").unwrap();
let proof = if expected_auth == "AgentAssertion " {
assert!(request.header("x-openai-agent-assertion").is_none());
Some(
auth.strip_prefix(expected_auth)
.context("assertion-only request")?
.to_owned(),
)
} else {
assert_eq!(auth, expected_auth);
request.header("x-openai-agent-assertion")
};
assert_eq!(proof.is_some(), expected_runtime.is_some(), "{label:?}");
if let Some(proof) = proof {
let payload: Value = serde_json::from_slice(
&base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(proof.split('.').nth(2).unwrap())?,
)?;
assert_eq!(payload["runtime_agent_instance"], expected_runtime.unwrap());
assert!(proof_ids.insert(payload["id"].as_str().unwrap().to_owned()));
}
}
let resumed_catalog: Vec<_> = server
.received_requests()
.await
.unwrap()
.into_iter()
.filter(|request| request.url.path() == "/resumed-off-models")
.collect();
assert!(
!resumed_catalog.is_empty(),
"resumed thread must fetch its catalog"
);
for request in resumed_catalog {
assert_eq!(request.headers["authorization"], "Bearer key-b");
assert!(!request.headers.contains_key("openai-agent-assertion"));
}
let catalogs: Vec<_> = server
.received_requests()
.await
.unwrap()
.into_iter()
.filter(|request| request.url.path() == "/catalog-isolation")
.collect();
assert_eq!(catalogs.len(), 2);
for request in catalogs {
assert!(
request.headers.contains_key("x-openai-agent-assertion")
|| request.headers["authorization"]
.to_str()?
.starts_with("AgentAssertion ")
);
}
let requests = server.received_requests().await.unwrap();
let reloaded_catalogs: Vec<_> = requests
.iter()
.filter(|request| request.url.path().starts_with("/catalog-reload/"))
.collect();
assert_eq!(reloaded_catalogs.len(), 6);
for request in reloaded_catalogs {
assert_eq!(request.headers["originator"], "codex_work_cca");
let authorization = request.headers["authorization"].to_str()?;
if request.url.path().ends_with("/disabled") {
assert_eq!(authorization, "Bearer key-a");
assert!(!request.headers.contains_key("x-openai-agent-assertion"));
continue;
}
let proof = if request.url.path().ends_with("/dual") {
assert_eq!(authorization, "Bearer key-a");
request.headers["x-openai-agent-assertion"].to_str()?
} else {
assert!(!request.headers.contains_key("x-openai-agent-assertion"));
authorization
.strip_prefix("AgentAssertion ")
.context("catalog assertion")?
};
let payload: Value = serde_json::from_slice(
&base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(proof.split('.').nth(2).unwrap())?,
)?;
assert_eq!(payload["runtime_agent_instance"], "runtime-shared");
assert!(proof_ids.insert(payload["id"].as_str().unwrap().to_owned()));
}
server.verify().await;
Ok(())
}
async fn exercise_harness(server_uri: String) -> Result<()> {
let server = MockServer::start().await;
let test = test_codex()
.with_auth(CodexAuth::from_api_key("key-a"))
.with_config(|config| {
config.model_provider.base_url = Some("https://api.openai.com/v1".into());
config.chatgpt_base_url = "https://us.chatgpt.com/backend-api".to_string();
config.model_provider.env_http_headers = None;
config
.features
.disable(Feature::RuntimeAgentInstances)
.expect("test config should allow disabling runtime auth");
config.model_catalog = Some(
codex_models_manager::bundled_models_response()
.expect("bundled models should parse"),
);
})
.build_with_auto_env(&server)
.await?;
turn(&test.codex, "startup-disabled").await?;
// Enable after startup, then share one lazy registration with a child and an inline delegate.
let mut enabled = test.config.clone();
enabled.features.enable(Feature::RuntimeAgentInstances)?;
// Initial discovery and response-triggered ETag refresh use each thread's effective auth.
for (mode, runtime_enabled) in [
(RuntimeAuthMode::DualHeader, true),
(RuntimeAuthMode::AssertionOnly, true),
(RuntimeAuthMode::DualHeader, false),
] {
let mut catalog_config = enabled.clone();
catalog_config.model_catalog = None;
catalog_config
.features
.enable(Feature::ApiKeyModelDiscovery)?;
catalog_config.runtime_auth_mode = mode;
if !runtime_enabled {
catalog_config
.features
.disable(Feature::RuntimeAgentInstances)?;
}
catalog_config.model = None;
let label = if !runtime_enabled {
"disabled"
} else if mode == RuntimeAuthMode::AssertionOnly {
"assertion"
} else {
"dual"
};
catalog_config.model_provider.model_catalog_url =
Some(format!("https://api.openai.com/catalog-reload/{label}").into());
let catalog_thread = test
.thread_manager
.start_thread(StartThreadOptions {
metrics_service_name: Some("codex_work_cca".into()),
..StartThreadOptions::new(catalog_config)
})
.await?;
assert_eq!(
catalog_thread.session_configured.model,
"catalog-isolation-model"
);
if label == "dual" {
// The first new ETag must not fetch while discovery is disabled. A second,
// distinct ETag on the same thread must fetch once discovery is enabled again.
test.thread_manager
.set_api_key_model_discovery_enabled(/*enabled*/ false);
turn(&catalog_thread.thread, "discovery-off").await?;
test.thread_manager
.set_api_key_model_discovery_enabled(/*enabled*/ true);
}
turn(&catalog_thread.thread, &format!("catalog-{label}")).await?;
}
let parent = test
.thread_manager
.start_thread(StartThreadOptions::new(enabled.clone()))
.await?;
turn(&parent.thread, "parent-dual").await?;
exercise_provider_destinations(&parent.thread, &enabled).await?;
let mut child_config = enabled.clone();
child_config.runtime_auth_mode = RuntimeAuthMode::AssertionOnly;
parent.thread.ensure_rollout_materialized().await;
parent.thread.flush_rollout().await?;
let prepared = test
.thread_store
.prepare_fork(codex_thread_store::PrepareForkParams {
thread_id: parent.thread_id,
boundary: codex_thread_store::ForkBoundary::Latest,
})
.await?;
let child = test
.thread_manager
.fork_prepared_thread(
StartThreadOptions {
session_source: Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: parent.thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
})),
..StartThreadOptions::new(child_config)
},
prepared,
)
.await?;
turn(&child.thread, "child-assertion").await?;
exercise_provider_destinations(&child.thread, &enabled).await?;
parent
.thread
.submit(Op::Review {
review_request: ReviewRequest {
target: ReviewTarget::Custom {
instructions: "Review this change".into(),
},
user_facing_hint: None,
},
})
.await?;
wait_for_event(&parent.thread, |e| matches!(e, EventMsg::TurnComplete(_))).await;
let disabled = test
.thread_manager
.start_thread(StartThreadOptions::new(test.config.clone()))
.await?;
turn(&disabled.thread, "thread-disabled").await?;
// A third-party OpenAI-compatible endpoint still uses its existing provider authentication.
enabled.model_provider.base_url = Some(format!("{server_uri}/v1"));
enabled.chatgpt_base_url = "https://custom.example.test/backend-api".to_string();
let third_party = test
.thread_manager
.start_thread(StartThreadOptions::new(enabled))
.await?;
turn(&third_party.thread, "third-party").await?;
// Resume a stopped thread under a separately supplied credential context. An internal child
// must inherit the resumed parent's runtime, not the ThreadManager's original default.
let rollout = parent
.thread
.rollout_path()
.context("parent rollout should exist")?;
parent.thread.submit(Op::Shutdown).await?;
wait_for_event(&parent.thread, |e| matches!(e, EventMsg::ShutdownComplete)).await;
test.thread_manager.remove_thread(&parent.thread_id).await;
let context = test
.thread_store
.load_latest_model_context(codex_thread_store::LoadThreadHistoryParams {
thread_id: parent.thread_id,
include_archived: false,
})
.await?;
let replacement = codex_core::test_support::auth_manager_from_auth_with_home(
CodexAuth::from_api_key("key-b"),
test.codex_home_path().to_path_buf(),
);
let history = InitialHistory::Resumed(ResumedHistory {
history_revision: None,
conversation_id: context.thread_id,
history: Arc::new(context.items),
rollout_path: Some(rollout),
});
let mut disabled = test.config.clone();
disabled.model_catalog = None;
disabled.model = None;
disabled.model_provider.model_catalog_url =
Some("https://api.openai.com/resumed-off-models".into());
let resumed_off = test
.thread_manager
.resume_thread_with_history(
disabled,
history.clone(),
Arc::clone(&replacement),
/*parent_trace*/ None,
ClientMcpExtensions::default(),
)
.await?;
assert_eq!(
resumed_off.session_configured.model,
"catalog-isolation-model"
);
resumed_off.thread.submit(Op::Shutdown).await?;
wait_for_event(&resumed_off.thread, |e| {
matches!(e, EventMsg::ShutdownComplete)
})
.await;
test.thread_manager
.remove_thread(&resumed_off.thread_id)
.await;
let mut resumed_config = test.config.clone();
resumed_config
.features
.enable(Feature::RuntimeAgentInstances)?;
let resumed = test
.thread_manager
.resume_thread_with_history(
resumed_config.clone(),
history,
replacement,
/*parent_trace*/ None,
ClientMcpExtensions::default(),
)
.await?;
turn(&resumed.thread, "replacement-root").await?;
let internal = test
.thread_manager
.spawn_internal_session(
resumed.thread_id,
StartThreadOptions {
session_source: Some(SessionSource::Internal(
InternalSessionSource::MemoryConsolidation,
)),
..StartThreadOptions::new(resumed_config)
},
)
.await?;
turn(&internal.thread, "replacement-child").await?;
Ok(())
}
fn catalog_fixture() -> codex_protocol::openai_models::ModelsResponse {
let mut models = codex_models_manager::bundled_models_response()
.expect("bundled catalog should provide a valid model fixture");
models.models.truncate(1);
models.models[0].slug = "catalog-isolation-model".into();
models
}
async fn exercise_provider_destinations(
thread: &CodexThread,
config: &codex_core::config::Config,
) -> Result<()> {
let external = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/catalog-isolation"))
.respond_with(ResponseTemplate::new(200).set_body_json(catalog_fixture()))
.expect(1)
.mount(&external)
.await;
let external_responses = responses::mount_sse_once(
&external,
responses::sse(vec![responses::ev_completed("external-response")]),
)
.await;
let auth = thread
.thread_extension_data()
.get::<ProviderAuthContext>()
.context("thread should publish its provider auth context")?;
for (inference, catalog) in [
("https://api.openai.com/v1".to_string(), external.uri()),
(external.uri(), "https://api.openai.com".to_string()),
] {
let mut info = config.model_provider.clone();
info.base_url = Some(inference);
info.model_catalog_url = Some(format!("{catalog}/catalog-isolation").into());
let provider = auth.model_provider(info);
let setup = codex_model_provider::resolve_request(
provider.as_ref(),
/*scope*/ None,
/*routing*/ None,
config.http_client_factory(),
)
.await?;
let client = codex_api::ResponsesClient::new(
setup.http_transport("responses")?,
setup.api_provider,
setup.api_auth,
);
let _stream = client
.stream(
json!({"model": "test", "input": []}),
http::HeaderMap::new(),
codex_api::Compression::None,
/*turn_state*/ None,
)
.await?;
let models = provider.models_manager_without_cache(/*config_model_catalog*/ None);
models.set_api_key_model_discovery_enabled(/*enabled*/ true);
models
.raw_model_catalog(RefreshStrategy::Online, config.http_client_factory())
.await;
assert_eq!(models.get_remote_models().await, catalog_fixture().models);
}
assert_eq!(external_responses.requests().len(), 1);
let requests = external
.received_requests()
.await
.context("external destination should record requests")?;
assert_eq!(requests.len(), 2);
for request in requests {
assert_eq!(request.headers["authorization"], "Bearer key-a");
assert!(!request.headers.contains_key("x-openai-agent-assertion"));
}
external.verify().await;
Ok(())
}
async fn turn(thread: &Arc<CodexThread>, label: &str) -> Result<()> {
thread
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: label.into(),
text_elements: Vec::new(),
}]))
.await?;
wait_for_event(thread, |e| matches!(e, EventMsg::TurnComplete(_))).await;
Ok(())
}
@@ -72,12 +72,8 @@ impl ThreadLifecycleContributor<Config> for GuardianV2Extension {
.constrain_guardian_policy(&mut policy, &model.slug);
}
let scoring_enabled = policy.scoring_enabled();
let sampler_config = super::startup::sampler_config(
&input,
Arc::clone(&self.auth_manager),
self.thread_manager.upgrade(),
)
.await;
let sampler_config =
super::startup::sampler_config(&input, self.thread_manager.upgrade()).await;
if scoring_enabled && guardian_config.transcript.include_images {
input
@@ -265,8 +265,13 @@ async fn installed_extension_warms_connections_without_blocking_thread_start() -
async fn installed_extension_uses_http_after_warm_socket_auth_expires() -> Result<()> {
skip_if_no_network!(Ok(()));
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("original"));
auth_manager
.set_external_auth(Arc::new(RefreshableAuth(std::sync::Mutex::new("original"))))
.await?;
let thread_server = responses::start_mock_server().await;
let 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?;
@@ -281,10 +286,6 @@ async fn installed_extension_uses_http_after_warm_socket_auth_expires() -> Resul
let server = responses::start_websocket_server(connections).await;
let http = responses::start_mock_server().await;
let http_mock = responses::mount_sse_once(&http, responses::sse(events)).await;
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("original"));
auth_manager
.set_external_auth(Arc::new(RefreshableAuth(std::sync::Mutex::new("original"))))
.await?;
let mut config = test.config.clone();
config.model_provider = ModelProviderInfo::create_openai_provider(Some(
proxy_websocket_servers_with_http(
@@ -25,6 +25,9 @@ use codex_api::SharedAuthProvider;
use codex_api::TransportError;
use codex_api::build_session_headers;
use codex_http_client::ClientRouteClass;
use codex_http_client::HttpClientFactory;
use codex_http_client::NetworkPolicy;
use codex_login::AuthChangeState;
use codex_login::CodexAuth;
use codex_login::default_client::ClientRedirectPolicy;
use codex_login::default_client::add_originator_header;
@@ -33,7 +36,6 @@ use codex_login::default_client::default_headers;
use codex_model_provider::ACCOUNT_ROUTING_HEADER;
use codex_model_provider::AgentIdentitySessionFallback;
use codex_model_provider::ProviderAuthScope;
use codex_model_provider::ResolvedResponsesProvider;
use codex_model_provider::ResponsesConnectionKey;
use codex_protocol::ThreadId;
use codex_websocket_client::WebSocketConnector;
@@ -76,6 +78,7 @@ pub(super) struct ConnectionPool {
struct HttpTransport {
url: String,
network_policy: NetworkPolicy,
redirect_policy: ClientRedirectPolicy,
transport: OnceCell<ReqwestTransport>,
}
@@ -86,17 +89,21 @@ pub(super) struct PooledConnection {
// The bridge routes by thread ID, so each socket needs its own identity.
pub(super) thread_id: String,
pub(super) expires_at: Instant,
auth_changes: Option<tokio::sync::watch::Receiver<u64>>,
auth_changes: Option<tokio::sync::watch::Receiver<AuthChangeState>>,
key: ResponsesConnectionKey,
runtime_id: Option<String>,
_permit: OwnedSemaphorePermit,
}
struct ClientSetup {
provider: Provider,
http_client_factory: HttpClientFactory,
runtime_id: Option<String>,
request_kind: RequestMode,
auth: SharedAuthProvider,
redirect_policy: ClientRedirectPolicy,
key: ResponsesConnectionKey,
auth_changes: Option<tokio::sync::watch::Receiver<u64>>,
auth_changes: Option<tokio::sync::watch::Receiver<AuthChangeState>>,
}
enum Connection {
@@ -218,6 +225,9 @@ impl ConnectionPool {
let ClientSetup {
mut provider,
auth,
http_client_factory,
runtime_id,
request_kind,
redirect_policy,
key,
auth_changes,
@@ -232,6 +242,7 @@ impl ConnectionPool {
match idle {
Some(connection)
if connection.key == key
&& connection.runtime_id == runtime_id
&& connection
.auth_changes
.as_ref()
@@ -264,7 +275,6 @@ impl ConnectionPool {
self.replenish();
// Sampling owns the retry budget across both transports.
provider.retry.max_attempts = 0;
let request_kind = self.responses_request_kind().await;
let url = provider.url_for_path("/responses");
let redirect_policy = if provider.headers.contains_key(ACCOUNT_ROUTING_HEADER) {
ClientRedirectPolicy::Reject
@@ -279,11 +289,13 @@ impl ConnectionPool {
if let Some(transport) = cached.as_ref()
&& transport.url == url
&& transport.redirect_policy == redirect_policy
&& transport.network_policy == *http_client_factory.network_policy()
{
Arc::clone(transport)
} else {
let transport = Arc::new(HttpTransport {
url: url.clone(),
network_policy: http_client_factory.network_policy().clone(),
redirect_policy,
transport: OnceCell::new(),
});
@@ -295,7 +307,7 @@ impl ConnectionPool {
.transport
.get_or_try_init(|| async {
let client = create_client_for_route_async(
self.config.http_client_factory.clone(),
http_client_factory.clone(),
url,
ClientRouteClass::Api,
redirect_policy,
@@ -353,45 +365,41 @@ impl ConnectionPool {
}
async fn client_setup(&self) -> Result<ClientSetup, LunaSamplerError> {
let setup = codex_model_provider::resolve_request(
self.config.provider.as_ref(),
Some(ProviderAuthScope {
agent_identity_policy: self.config.agent_identity_policy,
session_source: self.config.session_source.clone(),
agent_identity_session_fallback: AgentIdentitySessionFallback::default(),
}),
Some(&self.config.workspace_routing),
self.config.http_client_factory.clone(),
)
.await
.map_err(LunaSamplerError::Provider)?;
let mut auth_changes = self
.config
.provider
.auth_manager()
.map(|manager| manager.auth_change_receiver());
let revision = auth_changes.as_mut().map(|auth| *auth.borrow_and_update());
let ResolvedResponsesProvider {
provider,
redirect_policy,
} = self
.config
.provider
.responses_api_provider(&self.config.workspace_routing)
.await
.map_err(LunaSamplerError::Provider)?;
let auth = self
.config
.provider
.api_auth_for_scope(ProviderAuthScope {
agent_identity_policy: self.config.agent_identity_policy,
session_source: self.config.session_source.clone(),
agent_identity_session_fallback: AgentIdentitySessionFallback::default(),
})
.await
.map_err(LunaSamplerError::Provider)?
.auth;
.map(|manager| manager.auth_change_state_receiver());
if auth_changes
.as_ref()
.is_some_and(|auth| auth.has_changed().unwrap_or(true))
.as_mut()
.map(|auth| auth.borrow_and_update().generation)
!= setup.auth_revision
{
return Err(LunaSamplerError::Api(ApiError::Stream(
"authentication changed while resolving routing".into(),
)));
}
let key = ResponsesConnectionKey::new(&provider, revision);
let key = ResponsesConnectionKey::new(&setup.api_provider, setup.auth_revision);
let request_kind = self.responses_request_kind(setup.auth.as_ref());
Ok(ClientSetup {
provider,
auth,
redirect_policy,
provider: setup.api_provider,
auth: setup.api_auth,
redirect_policy: setup.redirect_policy,
http_client_factory: setup.http_client_factory,
runtime_id: setup.runtime_id,
request_kind,
key,
auth_changes,
})
@@ -430,15 +438,9 @@ impl ConnectionPool {
}
Ok(headers)
}
async fn responses_request_kind(&self) -> RequestMode {
fn responses_request_kind(&self, auth: Option<&CodexAuth>) -> RequestMode {
let provider = self.config.provider.info();
if self
.config
.provider
.auth()
.await
.as_ref()
.is_some_and(CodexAuth::uses_codex_backend)
if auth.is_some_and(CodexAuth::uses_codex_backend)
&& provider.is_openai()
&& provider.requires_openai_auth
&& provider.env_key.is_none()
@@ -459,12 +461,14 @@ impl ConnectionPool {
let ClientSetup {
provider,
auth,
http_client_factory,
runtime_id,
request_kind,
redirect_policy: _,
key,
auth_changes,
} = self.client_setup().await?;
let thread_id = ThreadId::new().to_string();
let request_kind = self.responses_request_kind().await;
let mut headers = self.headers(&thread_id, request_kind)?;
headers.insert(
"openai-beta",
@@ -488,9 +492,17 @@ impl ConnectionPool {
})
})
.await?;
let connector = connector
.with_account_binding(http_client_factory.network_policy())
.ok_or_else(|| {
ApiError::Transport(TransportError::Build(
"websocket credentials and transport have different account policies"
.into(),
))
})?;
client
.connect_with_connector(
connector,
&connector,
headers,
default_headers(),
/*turn_state*/ None,
@@ -532,6 +544,7 @@ impl ConnectionPool {
expires_at: Instant::now() + MAX_WEBSOCKET_AGE,
auth_changes,
key,
runtime_id,
_permit: permit,
})
}
@@ -513,6 +513,71 @@ impl WorkspaceRoutingResolver for RoutingPolicy {
}
}
#[tokio::test]
async fn sampling_uses_new_credentials_after_logout_and_login() -> Result<()> {
skip_if_no_network!(Ok(()));
let home = tempfile::tempdir()?;
let auth = AuthManager::from_auth_for_testing_with_home(
CodexAuth::from_api_key("initial-key"),
home.path().to_path_buf(),
);
let server = responses::start_mock_server().await;
let requests = responses::mount_sse_sequence(
&server,
vec![
responses::sse(vec![
responses::ev_output_text_delta("low"),
responses::ev_completed("before-logout"),
]),
responses::sse(vec![
responses::ev_output_text_delta("low"),
responses::ev_completed("after-login"),
]),
],
)
.await;
let mut config = sampler_config(format!("{}/v1", server.uri()));
config.provider = create_model_provider(config.provider.info().clone(), Some(auth.clone()));
let sampler = LunaSampler::new(config);
// Keep both requests on HTTP without starting background WebSocket opens.
*sampler.connections.retry_after.lock().unwrap() = Some(Instant::now() + CONNECT_COOLDOWN);
assert_eq!(
sampler
.sample(sample_request("before-logout"))
.await
.expect("sample before logout"),
"low"
);
auth.logout().await?;
codex_login::login_with_api_key(
home.path(),
"replacement-key",
codex_login::AuthCredentialsStoreMode::File,
codex_login::AuthKeyringBackendKind::default(),
)?;
auth.reload().await;
assert_eq!(
sampler
.sample(sample_request("after-login"))
.await
.expect("sample after signing in again"),
"low"
);
let requests = requests.requests();
assert_eq!(requests.len(), 2);
assert_eq!(
requests[0].header("authorization").as_deref(),
Some("Bearer initial-key")
);
assert_eq!(
requests[1].header("authorization").as_deref(),
Some("Bearer replacement-key")
);
Ok(())
}
#[tokio::test]
async fn http_cache_preserves_no_constraint_redirect_policy_and_configured_headers() -> Result<()> {
let base_url = "https://localhost:9/backend-api/codex".to_owned();
@@ -184,12 +184,14 @@ impl WorkspaceRoutingResolver for ChangedBootstrap {
#[tokio::test]
async fn idle_classifier_retains_the_owning_threads_workspace_binding() -> Result<()> {
core_test_support::skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let test = core_test_support::test_codex::test_codex()
.build_with_auto_env(&server)
.await?;
let auth =
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let thread_server = responses::start_mock_server().await;
let test = core_test_support::test_codex::test_codex()
.with_auth_manager(Arc::clone(&auth))
.build_with_auto_env(&thread_server)
.await?;
let server = responses::start_mock_server().await;
let resolver: Arc<dyn WorkspaceRoutingResolver> =
Arc::new(ChangedBootstrap(AtomicBool::new(/*v*/ true)));
auth.set_workspace_routing_resolver(Arc::downgrade(&resolver));
@@ -213,8 +215,7 @@ async fn idle_classifier_retains_the_owning_threads_workspace_binding() -> Resul
.thread_store
.get::<codex_model_provider::WorkspaceRoutingContext>()
.expect("Core seeds the owning thread's routing context before extensions start");
let classifier =
crate::async_scorer::startup::sampler_config(&input, auth.clone(), /*manager*/ None).await;
let classifier = crate::async_scorer::startup::sampler_config(&input, /*manager*/ None).await;
classifier
.provider
.responses_api_provider(&owning_context)
@@ -12,7 +12,6 @@ use codex_features::Feature;
use codex_login::AgentIdentityAuthPolicy;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_model_provider::create_model_provider;
use super::decisions::DecisionsError;
use super::decisions::DecisionsSampler;
@@ -78,7 +77,6 @@ pub(super) fn decisions_api_key(
pub(super) async fn sampler_config(
input: &ThreadStartInput<'_, Config>,
auth_manager: Arc<AuthManager>,
manager: Option<Arc<ThreadManager>>,
) -> LunaSamplerConfig {
let luna_model = if let Some(thread_manager) = manager {
@@ -102,6 +100,11 @@ pub(super) async fn sampler_config(
)
});
let luna_compaction_hash = luna_model.and_then(|model| model.comp_hash.clone());
#[expect(clippy::expect_used, reason = "Required thread startup invariant")]
let auth = input
.thread_store
.get::<codex_model_provider::ProviderAuthContext>()
.expect("Core installs provider auth before starting Guardian");
LunaSamplerConfig {
api_key_cyber_access_programs: ApiKeyCyberAccessPrograms::from_config(input.config),
workspace_routing: input
@@ -109,7 +112,7 @@ pub(super) async fn sampler_config(
.get_or_init(|| input.config.workspace_routing_context())
.as_ref()
.clone(),
provider: create_model_provider(input.config.model_provider.clone(), Some(auth_manager)),
provider: auth.model_provider(input.config.model_provider.clone()),
http_client_factory: input.config.http_client_factory(),
agent_identity_policy: if input.config.features.enabled(Feature::UseAgentIdentity) {
AgentIdentityAuthPolicy::ChatGptAuth
+12 -6
View File
@@ -53,11 +53,16 @@ impl HistoryNotesBackend {
}),
);
let (provider, auth) = self.provider.api_provider_and_auth().await.map_err(|_| {
format!("{OPERATION_ERROR_PREFIX} Could not prepare the backend request.")
})?;
let setup = codex_model_provider::resolve_request(
self.provider.as_ref(),
/*scope*/ None,
/*routing*/ None,
self.http_client_factory.clone(),
)
.await
.map_err(|_| format!("{OPERATION_ERROR_PREFIX} Could not prepare the backend request."))?;
let mut request = provider.build_request(Method::POST, path);
let mut request = setup.api_provider.build_request(Method::POST, path);
let encoded_truncation_policy =
serde_json::to_string(&truncation_policy).map_err(|_| {
format!("{OPERATION_ERROR_PREFIX} Could not encode the output truncation policy.")
@@ -84,12 +89,13 @@ impl HistoryNotesBackend {
}
request.body = Some(RequestBody::Json(arguments));
request.timeout = Some(HISTORY_NOTES_BACKEND_TIMEOUT);
let request = auth.apply_auth(request).await.map_err(|_| {
let request = setup.api_auth.apply_auth(request).await.map_err(|_| {
format!("{OPERATION_ERROR_PREFIX} Could not apply backend authentication.")
})?;
let transport = create_transport_for_routes_async(
self.http_client_factory.clone(),
setup.http_client_factory,
ClientRouteClass::Api,
setup.redirect_policy,
)
.await
.map_err(|_| format!("{OPERATION_ERROR_PREFIX} The backend request failed."))?;
+10 -6
View File
@@ -18,7 +18,6 @@ use codex_extension_api::ToolCall;
use codex_extension_api::ToolContributor;
use codex_extension_api::ToolExecutor;
use codex_login::AuthManager;
use codex_model_provider::create_model_provider;
use codex_protocol::AgentPath;
use codex_protocol::openai_models::HistoryToolMessages;
use codex_protocol::openai_models::NotesToolMessages;
@@ -46,19 +45,24 @@ struct HistoryNotesAgentIdentity {
impl HistoryNotesExtension {
fn update_config(&self, thread_store: &ExtensionData, config: &Config) {
let auth = thread_store
.get::<codex_model_provider::ProviderAuthContext>()
.unwrap_or_else(|| {
Arc::new(codex_model_provider::ProviderAuthContext {
credentials: Arc::clone(&self.auth_manager),
runtime_auth: None,
})
});
if config
.token_budget
.as_ref()
.is_some_and(|token_budget| token_budget.use_history_notes_extension)
&& config.model_provider.is_openai()
&& self.auth_manager.current_auth_uses_codex_backend()
&& auth.credentials.current_auth_uses_codex_backend()
{
thread_store.insert(HistoryNotesExtensionConfig {
backend: HistoryNotesBackend::new(
create_model_provider(
config.model_provider.clone(),
Some(self.auth_manager.clone()),
),
auth.model_provider(config.model_provider.clone()),
config.http_client_factory(),
),
});
+16 -8
View File
@@ -78,18 +78,26 @@ impl CodexImagesBackend {
/// Resolves the provider and auth required for the current image API request.
async fn client(&self) -> Result<ImagesClient<ReqwestTransport>, ImageBackendError> {
let (provider, auth) = self
.provider
.api_provider_and_auth()
.await
.map_err(|err| ImageBackendError::from_message(err.to_string()))?;
let transport = create_transport_for_routes_async(
let setup = codex_model_provider::resolve_request(
self.provider.as_ref(),
/*scope*/ None,
/*routing*/ None,
self.http_client_factory.clone(),
ClientRouteClass::Api,
)
.await
.map_err(|err| ImageBackendError::from_message(err.to_string()))?;
Ok(ImagesClient::new(transport, provider, auth))
let transport = create_transport_for_routes_async(
setup.http_client_factory,
ClientRouteClass::Api,
setup.redirect_policy,
)
.await
.map_err(|err| ImageBackendError::from_message(err.to_string()))?;
Ok(ImagesClient::new(
transport,
setup.api_provider,
setup.api_auth,
))
}
/// Sends a standalone image generation request through the configured Images client.
@@ -13,7 +13,6 @@ use codex_extension_api::ToolContributor;
use codex_extension_api::ToolExecutor;
use codex_http_client::HttpClientFactory;
use codex_login::AuthManager;
use codex_model_provider::create_model_provider;
use codex_model_provider_info::ModelProviderInfo;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -98,10 +97,18 @@ impl ToolContributor for ImageGenerationExtension {
if !config.available {
return Vec::new();
}
let auth = thread_store
.get::<codex_model_provider::ProviderAuthContext>()
.unwrap_or_else(|| {
Arc::new(codex_model_provider::ProviderAuthContext {
credentials: Arc::clone(&self.auth_manager),
runtime_auth: None,
})
});
vec![Arc::new(ImageGenerationTool::new(
CodexImagesBackend::new(
create_model_provider(config.provider.clone(), Some(self.auth_manager.clone())),
auth.model_provider(config.provider.clone()),
config.http_client_factory.clone(),
thread_store
.get::<ThreadOriginator>()
+9 -5
View File
@@ -20,7 +20,6 @@ use codex_extension_api::ToolContributor;
use codex_http_client::HttpClientFactory;
use codex_login::AuthManager;
use codex_model_provider::WorkspaceRoutingContext;
use codex_model_provider::create_model_provider;
use codex_model_provider_info::ModelProviderInfo;
use codex_protocol::config_types::WebSearchContextSize;
use codex_protocol::config_types::WebSearchMode;
@@ -137,14 +136,19 @@ impl ToolContributor for WebSearchExtension {
if !config.provider_supports_search || *mode == WebSearchMode::Disabled {
return Vec::new();
}
let auth = thread_store
.get::<codex_model_provider::ProviderAuthContext>()
.unwrap_or_else(|| {
Arc::new(codex_model_provider::ProviderAuthContext {
credentials: Arc::clone(&self.auth_manager),
runtime_auth: None,
})
});
vec![Arc::new(WebSearchTool {
session_id: session_store.level_id().to_string(),
http_client_factory: config.http_client_factory.clone(),
provider: create_model_provider(
config.provider.clone(),
Some(self.auth_manager.clone()),
),
provider: auth.model_provider(config.provider.clone()),
workspace_routing,
settings: SearchSettings {
external_web_access: Some(external_web_access_for_mode(*mode)),
+30 -39
View File
@@ -29,8 +29,6 @@ use codex_login::default_client::create_transport_for_routes_async;
use codex_model_provider::ACCOUNT_ROUTING_HEADER;
use codex_model_provider::SharedModelProvider;
use codex_model_provider::WorkspaceRoutingContext;
use codex_model_provider::prepare_provider_request;
use codex_model_provider::provider_uses_first_party_auth_path;
use codex_protocol::models::WebSearchAction as CoreWebSearchAction;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::WebSearchBeginEvent;
@@ -113,45 +111,38 @@ impl WebSearchTool {
) -> Result<Box<dyn ToolOutput>, FunctionCallError> {
let commands = parse_commands(call.function_arguments()?, self.supports_literature_search)?;
let command_action = command_action(&commands);
let provider = self.provider.as_ref();
let first_party = provider_uses_first_party_auth_path(provider.info());
let client = prepare_provider_request(provider, first_party, |_| async {
let routing_auth = if first_party {
provider.auth().await
} else {
None
};
let (endpoint, api_auth) = provider.api_provider_and_auth().await?;
let resolved = provider
.route_responses_api_provider(
endpoint,
routing_auth.as_ref(),
&self.workspace_routing,
)
.await?;
let endpoint = resolved.provider;
let transport = if resolved.redirect_policy == ClientRedirectPolicy::Reject
|| endpoint.headers.contains_key(ACCOUNT_ROUTING_HEADER)
{
create_client_for_route_async(
self.http_client_factory.clone(),
endpoint.url_for_path("alpha/search"),
ClientRouteClass::Api,
ClientRedirectPolicy::Reject,
)
.await
.map(ReqwestTransport::from_http_client)
} else {
create_transport_for_routes_async(
self.http_client_factory.clone(),
ClientRouteClass::Api,
)
.await
}?;
Ok(SearchClient::new(transport, endpoint, api_auth))
})
let setup = codex_model_provider::resolve_request(
self.provider.as_ref(),
/*scope*/ None,
Some(&self.workspace_routing),
self.http_client_factory.clone(),
)
.await
.map_err(|err| FunctionCallError::RespondToModel(search_error_message(err)))?;
let transport = if setup.redirect_policy == ClientRedirectPolicy::Reject
|| setup
.api_provider
.headers
.contains_key(ACCOUNT_ROUTING_HEADER)
{
create_client_for_route_async(
setup.http_client_factory,
setup.api_provider.url_for_path("alpha/search"),
ClientRouteClass::Api,
ClientRedirectPolicy::Reject,
)
.await
.map(ReqwestTransport::from_http_client)
} else {
create_transport_for_routes_async(
setup.http_client_factory,
ClientRouteClass::Api,
setup.redirect_policy,
)
.await
}
.map_err(|err| FunctionCallError::RespondToModel(search_error_message(err)))?;
let client = SearchClient::new(transport, setup.api_provider, setup.api_auth);
let request = SearchRequest {
id: self.session_id.clone(),
model: call.model.clone(),
+7 -2
View File
@@ -383,6 +383,7 @@ async fn create_client_for_route_async_with_builder(
pub async fn create_transport_for_routes_async(
http_client_factory: HttpClientFactory,
route_class: ClientRouteClass,
redirect_policy: ClientRedirectPolicy,
) -> std::io::Result<ReqwestTransport> {
let permit = ROUTE_AWARE_CLIENT_BUILD_PERMIT
.acquire()
@@ -395,7 +396,8 @@ pub async fn create_transport_for_routes_async(
http_client_factory.outbound_proxy_policy(),
OutboundProxyPolicy::ReqwestDefault
);
if !http_client_factory.network_policy().is_managed()
if redirect_policy == ClientRedirectPolicy::Default
&& !http_client_factory.network_policy().is_managed()
&& !http_client_factory.protects_sensitive_headers()
&& (transport_default_proxy || sandboxed)
{
@@ -405,7 +407,10 @@ pub async fn create_transport_for_routes_async(
let mut pool = RouteAwareClientPool::with_builder(
http_client_factory,
route_class,
default_http_client_builder(),
match redirect_policy {
ClientRedirectPolicy::Default => default_http_client_builder(),
ClientRedirectPolicy::Reject => default_http_client_builder().without_redirects(),
},
);
if sandboxed {
pool = pool.with_legacy_direct_proxy_and_custom_ca_fallback();
+13 -8
View File
@@ -21,8 +21,8 @@ use codex_login::auth::AgentIdentityAuthPolicy;
use codex_login::auth_env_telemetry::collect_auth_env_telemetry;
use codex_login::default_client::originator;
use codex_model_provider::ModelProvider;
use codex_model_provider::ProviderAuthContext;
use codex_model_provider::SharedModelProvider;
use codex_model_provider::create_model_provider;
use codex_otel::SessionTelemetry;
use codex_otel::TelemetryAuthMode;
use codex_protocol::MemoryVersion;
@@ -147,13 +147,19 @@ impl MemoryStartupContext {
config: &Config,
source: SessionSource,
) -> Self {
let provider = create_model_provider(
config.model_provider.clone(),
Some(Arc::clone(&auth_manager)),
);
let auth = thread
.thread_extension_data()
.get::<ProviderAuthContext>()
.unwrap_or_else(|| {
Arc::new(ProviderAuthContext {
credentials: auth_manager,
runtime_auth: None,
})
});
let provider = auth.model_provider(config.model_provider.clone());
Self::new_with_provider(
thread_manager,
auth_manager,
Arc::clone(&auth.credentials),
thread_id,
thread,
config,
@@ -327,10 +333,9 @@ impl MemoryStartupContext {
let session_id = SessionId::from(self.thread_id);
let session_id_string = session_id.to_string();
let model_client = ModelClient::new(
Some(Arc::clone(&self.auth_manager)),
Arc::clone(&self.provider),
AgentIdentityAuthPolicy::JwtOnly,
self.thread_id,
config.model_provider.clone(),
session_source.clone(),
config_snapshot.originator,
config.model_verbosity,
+33
View File
@@ -12,6 +12,7 @@ use codex_api::AuthProvider;
use codex_api::SharedAuthProvider;
use codex_features::RuntimeAuthMode;
use codex_http_client::CHATGPT_ACCOUNT_ID_HEADER;
use codex_http_client::HttpClientFactory;
use codex_login::AuthHeaders;
use codex_login::AuthManager;
use codex_login::CodexAuth;
@@ -74,6 +75,8 @@ impl AgentIdentitySessionFallback {
/// Provider auth resolved for a request, plus metadata describing the effective auth.
#[derive(Clone)]
pub struct ResolvedProviderAuth {
/// Login selected with these headers; routing must use the same credential.
pub credential: Option<CodexAuth>,
pub auth: SharedAuthProvider,
pub agent_identity_telemetry: Option<AgentIdentityTelemetry>,
pub runtime: Option<RuntimeAuth>,
@@ -82,15 +85,45 @@ pub struct ResolvedProviderAuth {
impl ResolvedProviderAuth {
pub(crate) fn new(auth: SharedAuthProvider) -> Self {
Self {
credential: None,
auth,
agent_identity_telemetry: None,
runtime: None,
}
}
pub(crate) fn for_runtime(selected: RuntimeAuth, mode: RuntimeAuthMode) -> Self {
Self {
credential: Some(selected.auth().clone()),
auth: selected.clone().into_auth_provider(mode),
agent_identity_telemetry: None,
runtime: Some(selected),
}
}
/// Preserve the caller's transport configuration and bind it to the selected login.
pub fn bind_http_client_factory(
&self,
factory: HttpClientFactory,
) -> Result<HttpClientFactory, AuthError> {
match &self.runtime {
Some(runtime) => runtime.bind_http_client_factory(factory),
None => Ok(factory),
}
}
/// Reject account selectors that disagree with the selected runtime.
pub fn validate_headers(&self, headers: &HeaderMap) -> codex_protocol::error::Result<()> {
if let Some(runtime) = &self.runtime {
crate::validate_runtime_selectors(headers, runtime.runtime().actor().info())?;
}
Ok(())
}
fn for_agent_identity(auth: AgentIdentityAuth) -> Self {
let agent_identity_telemetry = agent_identity_telemetry(&auth);
Self {
credential: None,
auth: Arc::new(AgentIdentityAuthProvider { auth }),
agent_identity_telemetry: Some(agent_identity_telemetry),
runtime: None,
@@ -0,0 +1,39 @@
//! Host catalog construction from the provider and credentials selected for a thread.
use crate::SharedModelProvider;
use codex_models_manager::manager::ModelCatalogSettings;
use codex_models_manager::manager::SharedModelsManager;
use codex_protocol::openai_models::ModelsResponse;
/// Builds catalogs for the selected provider while retaining the host's cache policy.
/// Provider-backed implementations must use the supplied provider, which carries the
/// current credential owner and runtime mode. Hosts may also supply independent metadata.
pub trait ModelCatalogFactory: Send + Sync {
fn build(
&self,
provider: SharedModelProvider,
config_model_catalog: Option<ModelsResponse>,
api_key_model_discovery_default: bool,
) -> SharedModelsManager;
/// Publish a live discovery setting to existing and future catalogs.
/// Sources that do not perform API-key discovery may ignore it.
fn set_api_key_model_discovery_enabled(&self, _enabled: bool) {}
}
impl ModelCatalogFactory for ModelCatalogSettings {
fn build(
&self,
provider: SharedModelProvider,
config_model_catalog: Option<ModelsResponse>,
api_key_model_discovery_default: bool,
) -> SharedModelsManager {
let mut settings = self.clone();
settings.api_key_model_discovery_default = api_key_model_discovery_default;
provider.models_manager_with_settings(config_model_catalog, settings)
}
fn set_api_key_model_discovery_enabled(&self, enabled: bool) {
ModelCatalogSettings::set_api_key_model_discovery_enabled(self, enabled);
}
}
+5 -1
View File
@@ -2,6 +2,7 @@ mod amazon_bedrock;
mod auth;
mod bearer_auth_provider;
mod capabilities;
mod catalog_factory;
mod chatgpt_base_url;
mod combined_auth;
mod gcp;
@@ -9,6 +10,7 @@ mod models_endpoint;
mod models_identity;
mod provider;
mod request_preparation;
mod request_setup;
mod runtime_auth;
mod shared_state;
pub mod test_support;
@@ -31,6 +33,7 @@ pub use auth::unauthenticated_auth_provider;
pub use bearer_auth_provider::BearerAuthProvider;
pub use bearer_auth_provider::BearerAuthProvider as CoreAuthProvider;
pub use capabilities::ProviderCapabilities;
pub use catalog_factory::ModelCatalogFactory;
pub use chatgpt_base_url::ChatgptBaseUrl;
pub use chatgpt_base_url::DEFAULT_CHATGPT_BASE_URL;
pub use codex_model_provider_info::AMAZON_BEDROCK_PROVIDER_ID;
@@ -52,7 +55,8 @@ pub use provider::provider_uses_first_party_auth_path;
pub use provider::resolve_api_provider;
pub use request_preparation::prepare_provider_request;
pub use runtime_auth::select_runtime_auth;
pub use request_setup::ResolvedProviderRequest;
pub use request_setup::resolve_request;
pub use runtime_auth::validate_runtime_destination;
pub use runtime_auth::validate_runtime_provider;
pub use runtime_auth::validate_runtime_selectors;
+29 -63
View File
@@ -39,12 +39,11 @@ use codex_runtime_agent_instance::RuntimeAuthManager;
use http::HeaderMap;
use tokio::time::timeout;
use crate::auth::ResolvedProviderAuth;
use crate::auth::agent_identity_telemetry;
use crate::auth::resolve_provider_auth;
use crate::auth::uses_fedramp_api_key;
use crate::combined_auth::compose_auth;
use crate::provider::resolve_api_provider;
use crate::request_setup::resolve_request_auth;
const MODELS_REFRESH_TIMEOUT: Duration = Duration::from_secs(5);
const MODELS_ENDPOINT: &str = "/models";
@@ -90,9 +89,15 @@ impl OpenAiModelsEndpoint {
}
async fn auth(&self) -> Option<CodexAuth> {
match self.auth_manager.as_ref() {
Some(auth_manager) => auth_manager.auth().await,
None => None,
let auth_manager = self.auth_manager.clone()?;
// Catalog startup has its own deadline. Let rotating credentials finish
// saving even when that caller stops waiting.
match tokio::spawn(async move { auth_manager.auth().await }).await {
Ok(auth) => auth,
Err(error) => {
tracing::warn!(?error, "model catalog authentication task failed");
None
}
}
}
@@ -121,48 +126,20 @@ impl OpenAiModelsEndpoint {
client_version: &str,
http_client_factory: HttpClientFactory,
) -> CoreResult<ModelsEndpointResponse> {
let provider_info = self.provider_info.clone();
let auth_manager = self.auth_manager.clone();
let runtime_auth = self.runtime_auth.clone();
// A timed-out catalog fetch must not discard a rotated refresh token before
// it is saved. Keep credential refresh and registration owned until they finish.
let (auth, selected_runtime_auth) = tokio::spawn(async move {
let selected_runtime_auth = match runtime_auth {
Some((runtime, mode)) => {
crate::validate_runtime_destination(
provider_info
.model_catalog_url
.as_deref()
.map(String::as_str)
.or(provider_info.base_url.as_deref()),
)?;
crate::validate_runtime_provider(
&provider_info,
auth_manager.as_ref(),
&runtime,
)?;
Some((
crate::runtime_auth::select_runtime_auth(
&runtime,
&provider_info.build_header_map()?,
)
.await
.map_err(|error| map_api_error(error.into()))?,
mode,
))
}
None => None,
};
let auth = match &selected_runtime_auth {
Some((selected_runtime_auth, _)) => Some(selected_runtime_auth.auth().clone()),
None => match auth_manager {
Some(auth_manager) => auth_manager.auth().await,
None => None,
},
};
Ok::<_, CodexErr>((auth, selected_runtime_auth))
})
.await??;
let resolved = resolve_request_auth(
self.provider_info.clone(),
self.auth_manager.clone(),
self.runtime_auth.clone(),
/*scope*/ None,
self.provider_info
.model_catalog_url
.as_deref()
.map(String::as_str)
.or(self.provider_info.base_url.as_deref())
.map(str::to_owned),
)
.await?;
let auth = resolved.credential.clone();
let metric_auth_mode = if self.has_provider_api_key()
|| auth.as_ref().is_some_and(CodexAuth::is_api_key_auth)
{
@@ -192,25 +169,14 @@ impl OpenAiModelsEndpoint {
}
.to_string();
}
let http_client_factory = match &selected_runtime_auth {
Some((selected_runtime_auth, _)) => selected_runtime_auth
.bind_http_client_factory(http_client_factory)
.map_err(|error| map_api_error(error.into()))?,
None => http_client_factory,
};
let primary = if let Some((selected_runtime_auth, mode)) = selected_runtime_auth {
crate::validate_runtime_selectors(
&api_provider.headers,
selected_runtime_auth.runtime().actor().info(),
)?;
selected_runtime_auth.into_auth_provider(mode)
} else {
resolve_provider_auth(auth.as_ref(), &self.provider_info)?
};
let http_client_factory = resolved
.bind_http_client_factory(http_client_factory)
.map_err(|error| map_api_error(error.into()))?;
resolved.validate_headers(&api_provider.headers)?;
let resolved = compose_auth(
&self.provider_info,
self.gateway_auth_manager.as_ref(),
ResolvedProviderAuth::new(primary),
resolved,
)
.await?;
let api_auth = resolved.auth;
@@ -6,6 +6,8 @@ use codex_http_client::OutboundProxyPolicy;
use codex_login::AuthCredentialsStoreMode;
use codex_login::AuthKeyringBackendKind;
use codex_login::AuthRouteConfig;
use codex_models_manager::manager::ModelsManager;
use codex_models_manager::manager::OpenAiModelsManager;
use codex_runtime_agent_instance::AgentHostAuthorization;
use codex_runtime_agent_instance::RuntimeRegistrationMetadata;
use codex_runtime_agent_instance::RuntimeRole;
@@ -20,6 +22,15 @@ use wiremock::matchers::path;
#[tokio::test]
async fn catalog_timeout_preserves_refreshed_tokens_with_and_without_runtime_auth() {
check_catalog_timeout_preserves_refreshed_tokens(/*refresh_after_auth_change*/ false).await;
}
#[tokio::test]
async fn catalog_startup_timeout_preserves_refreshed_tokens() {
check_catalog_timeout_preserves_refreshed_tokens(/*refresh_after_auth_change*/ true).await;
}
async fn check_catalog_timeout_preserves_refreshed_tokens(refresh_after_auth_change: bool) {
for mode in [
None,
Some(RuntimeAuthMode::DualHeader),
@@ -138,20 +149,40 @@ async fn catalog_timeout_preserves_refreshed_tokens_with_and_without_runtime_aut
});
let mut info = ModelProviderInfo::create_openai_provider(Some(server.uri()));
info.env_http_headers = None;
let endpoint = OpenAiModelsEndpoint::new(
info,
Some(Arc::clone(&credentials)),
/*gateway_auth_manager*/ None,
)
.with_runtime_auth(runtime_auth);
let error = endpoint
.list_models("test", factory.clone())
.await
.unwrap_err();
assert!(matches!(
error.details(),
codex_protocol::error::CodexErrorDetails::RequestTimeout
));
let endpoint = Arc::new(
OpenAiModelsEndpoint::new(
info,
Some(Arc::clone(&credentials)),
/*gateway_auth_manager*/ None,
)
.with_runtime_auth(runtime_auth),
);
if refresh_after_auth_change {
let manager = OpenAiModelsManager::new_without_cache(
endpoint.clone(),
Some(Arc::clone(&credentials)),
);
manager.refresh_after_auth_change(factory.clone()).await;
} else {
let error = endpoint
.list_models("test", factory.clone())
.await
.unwrap_err();
assert!(matches!(
error.details(),
codex_protocol::error::CodexErrorDetails::RequestTimeout
));
}
assert_eq!(
server
.received_requests()
.await
.unwrap()
.iter()
.filter(|request| request.url.path() == "/oauth/token")
.count(),
1,
);
// Wait through the existing refresh lock; this must reuse the completed refresh.
let current = timeout(Duration::from_secs(5), credentials.auth())
@@ -129,13 +129,28 @@ async fn model_requests_keep_the_selected_project_while_reusing_one_runtime() {
headers.insert("openai-project".into(), project.into());
}
info.http_headers = Some(headers);
let auth = context
.model_provider(info.clone())
.api_auth()
.await
.unwrap();
let provider = context.model_provider(info.clone());
let request = crate::resolve_request(
provider.as_ref(),
/*scope*/ None,
/*routing*/ None,
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
)
.await
.unwrap();
assert_eq!(request.runtime_id.as_deref(), Some("runtime-a"));
assert_eq!(
request
.api_provider
.headers
.get("openai-project")
.map(|value| value.to_str().unwrap()),
project,
);
assert!(
auth.to_auth_headers()
request
.api_auth
.to_auth_headers()
.unwrap()
.contains_key("authorization")
);
+77 -75
View File
@@ -16,6 +16,7 @@ use codex_login::WorkspaceRoutingRequest;
use codex_login::default_client::ClientRedirectPolicy;
use codex_model_provider_info::ModelProviderInfo;
use codex_models_manager::cache::ModelsCache;
use codex_models_manager::manager::ModelCatalogSettings;
use codex_models_manager::manager::OpenAiModelsManager;
use codex_models_manager::manager::SharedModelsManager;
use codex_models_manager::manager::StaticModelsManager;
@@ -37,6 +38,7 @@ use crate::auth::uses_fedramp_api_key;
use crate::combined_auth::compose_auth;
use crate::gcp::GcpModelProvider;
use crate::models_endpoint::OpenAiModelsEndpoint;
use crate::request_setup::resolve_request_auth;
use crate::workspace_routing::WorkspaceRoutingContext;
/// Current app-visible account state for a model provider.
@@ -353,6 +355,15 @@ pub trait ModelProvider: fmt::Debug + Send + Sync {
}
}
/// Resolve the endpoint before headers for providers whose setup may refresh credentials.
fn request_provider_and_auth(
&self,
scope: Option<ProviderAuthScope>,
) -> ModelProviderFuture<'_, codex_protocol::error::Result<(Provider, ResolvedProviderAuth)>>
{
Box::pin(async move { Ok((self.api_provider().await?, self.request_auth(scope).await?)) })
}
/// Creates the model manager implementation appropriate for this provider.
fn models_manager(
&self,
@@ -360,6 +371,15 @@ pub trait ModelProvider: fmt::Debug + Send + Sync {
config_model_catalog: Option<ModelsResponse>,
) -> SharedModelsManager;
/// Build a thread catalog with the same cache and live discovery policy as its harness.
fn models_manager_with_settings(
&self,
config_model_catalog: Option<ModelsResponse>,
_settings: ModelCatalogSettings,
) -> SharedModelsManager {
self.models_manager_without_cache(config_model_catalog)
}
/// Creates a model manager with caching disabled.
///
/// Providers that fetch model catalogs should override this method. The default uses an
@@ -462,12 +482,6 @@ struct ConfiguredModelProvider {
catalog_runtime_auth: Option<(Arc<RuntimeAuthManager>, RuntimeAuthMode)>,
}
enum ModelsCacheConfig {
Disk { codex_home: PathBuf },
Disabled,
Custom(Arc<dyn ModelsCache>),
}
impl ConfiguredModelProvider {
fn new(
info: ModelProviderInfo,
@@ -513,74 +527,16 @@ impl ConfiguredModelProvider {
&self,
scope: Option<ProviderAuthScope>,
) -> codex_protocol::error::Result<ResolvedProviderAuth> {
let resolved = if let Some((runtime, mode)) = &self.runtime_auth {
crate::validate_runtime_destination(self.info.base_url.as_deref())?;
crate::validate_runtime_provider(&self.info, self.auth_manager.as_ref(), runtime)?;
let headers = self.info.build_header_map()?;
let selected = crate::runtime_auth::select_runtime_auth(runtime, &headers)
.await
.map_err(|error| self.map_api_error(error.into()))?;
crate::validate_runtime_selectors(&headers, selected.runtime().actor().info())?;
ResolvedProviderAuth {
auth: selected.clone().into_auth_provider(*mode),
agent_identity_telemetry: None,
runtime: Some(selected),
}
} else {
let auth = self.auth().await;
match scope {
Some(scope) if provider_uses_first_party_auth_path(&self.info) => {
resolve_provider_auth_for_scope(
self.auth_manager.clone(),
auth.as_ref(),
&self.info,
scope,
)
.await?
}
_ => ResolvedProviderAuth::new(resolve_provider_auth(auth.as_ref(), &self.info)?),
}
};
let resolved = resolve_request_auth(
self.info.clone(),
self.auth_manager.clone(),
self.runtime_auth.clone(),
scope,
self.info.base_url.clone(),
)
.await?;
self.compose_auth(resolved).await
}
fn create_models_manager(
&self,
config_model_catalog: Option<ModelsResponse>,
cache: ModelsCacheConfig,
) -> SharedModelsManager {
if let Some(model_catalog) = config_model_catalog {
return Arc::new(StaticModelsManager::new(
self.auth_manager.clone(),
model_catalog,
codex_models_manager::ModelOrigin::ExplicitConfig,
));
}
let endpoint = Arc::new(
OpenAiModelsEndpoint::new(
self.info.clone(),
self.auth_manager.clone(),
self.gateway_auth_manager.clone(),
)
.with_runtime_auth(self.catalog_runtime_auth.clone()),
);
let auth_manager = self.auth_manager.clone();
let manager = match cache {
ModelsCacheConfig::Disk { codex_home } => {
OpenAiModelsManager::new(codex_home, endpoint, auth_manager)
}
ModelsCacheConfig::Disabled => {
OpenAiModelsManager::new_without_cache(endpoint, auth_manager)
}
ModelsCacheConfig::Custom(cache) => {
OpenAiModelsManager::new_with_cache(cache, endpoint, auth_manager)
}
};
match &self.info.model_catalog_url {
Some(_) => Arc::new(manager.with_provider_catalog()),
None => Arc::new(manager),
}
}
}
impl ModelProvider for ConfiguredModelProvider {
@@ -643,6 +599,18 @@ impl ModelProvider for ConfiguredModelProvider {
Box::pin(self.resolve_auth(scope))
}
fn request_provider_and_auth(
&self,
scope: Option<ProviderAuthScope>,
) -> ModelProviderFuture<'_, codex_protocol::error::Result<(Provider, ResolvedProviderAuth)>>
{
Box::pin(async move {
let resolved = self.resolve_auth(scope).await?;
let provider = self.api_provider_for_auth(resolved.credential.as_ref())?;
Ok((provider, resolved))
})
}
fn api_auth(
&self,
) -> ModelProviderFuture<'_, codex_protocol::error::Result<SharedAuthProvider>> {
@@ -733,14 +701,45 @@ impl ModelProvider for ConfiguredModelProvider {
codex_home: PathBuf,
config_model_catalog: Option<ModelsResponse>,
) -> SharedModelsManager {
self.create_models_manager(config_model_catalog, ModelsCacheConfig::Disk { codex_home })
self.models_manager_with_settings(
config_model_catalog,
ModelCatalogSettings::disk(codex_home),
)
}
fn models_manager_with_settings(
&self,
config_model_catalog: Option<ModelsResponse>,
settings: ModelCatalogSettings,
) -> SharedModelsManager {
if let Some(model_catalog) = config_model_catalog {
return Arc::new(StaticModelsManager::new(
self.auth_manager.clone(),
model_catalog,
codex_models_manager::ModelOrigin::ExplicitConfig,
));
}
let endpoint = Arc::new(
OpenAiModelsEndpoint::new(
self.info.clone(),
self.auth_manager.clone(),
self.gateway_auth_manager.clone(),
)
.with_runtime_auth(self.catalog_runtime_auth.clone()),
);
let auth_manager = self.auth_manager.clone();
let manager = OpenAiModelsManager::with_settings(settings, endpoint, auth_manager);
match &self.info.model_catalog_url {
Some(_) => Arc::new(manager.with_provider_catalog()),
None => Arc::new(manager),
}
}
fn models_manager_without_cache(
&self,
config_model_catalog: Option<ModelsResponse>,
) -> SharedModelsManager {
self.create_models_manager(config_model_catalog, ModelsCacheConfig::Disabled)
self.models_manager_with_settings(config_model_catalog, ModelCatalogSettings::default())
}
fn models_manager_with_cache(
@@ -748,7 +747,10 @@ impl ModelProvider for ConfiguredModelProvider {
config_model_catalog: Option<ModelsResponse>,
cache: Arc<dyn ModelsCache>,
) -> SharedModelsManager {
self.create_models_manager(config_model_catalog, ModelsCacheConfig::Custom(cache))
self.models_manager_with_settings(
config_model_catalog,
ModelCatalogSettings::with_cache(cache),
)
}
}
@@ -0,0 +1,158 @@
//! Resolve credentials, routing, and transport policy as one request context.
use crate::ModelProvider;
use crate::ProviderAuthScope;
use crate::ResolvedProviderAuth;
use crate::WorkspaceRoutingContext;
use crate::auth::resolve_provider_auth;
use crate::auth::resolve_provider_auth_for_scope;
use codex_api::AgentIdentityTelemetry;
use codex_api::Provider;
use codex_api::ReqwestTransport;
use codex_api::SharedAuthProvider;
use codex_features::RuntimeAuthMode;
use codex_http_client::ClientRouteClass;
use codex_http_client::HttpClientFactory;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_login::default_client::ClientRedirectPolicy;
use codex_login::default_client::create_client_for_route;
use codex_model_provider_info::ModelProviderInfo;
use codex_protocol::error::Result;
use codex_runtime_agent_instance::RuntimeAuth;
use codex_runtime_agent_instance::RuntimeAuthManager;
use std::sync::Arc;
/// Select auth for inference and discovery, letting rotating credentials finish saving
/// if the requesting operation is cancelled.
pub(crate) async fn resolve_request_auth(
info: ModelProviderInfo,
manager: Option<Arc<AuthManager>>,
runtime: Option<(Arc<RuntimeAuthManager>, RuntimeAuthMode)>,
scope: Option<ProviderAuthScope>,
destination: Option<String>,
) -> codex_protocol::error::Result<ResolvedProviderAuth> {
tokio::spawn(async move {
if let Some((runtime, mode)) = runtime {
crate::validate_runtime_destination(destination.as_deref())?;
crate::validate_runtime_provider(&info, manager.as_ref(), &runtime)?;
let headers = info.build_header_map()?;
let selected = crate::runtime_auth::select_runtime_auth(&runtime, &headers)
.await
.map_err(|error| codex_api::map_api_error(error.into()))?;
let resolved = ResolvedProviderAuth::for_runtime(selected, mode);
resolved.validate_headers(&headers)?;
tracing::debug!(runtime_auth_mode = ?mode, "selected runtime authentication");
return Ok(resolved);
}
let credential = match &manager {
Some(manager) => manager.auth().await,
None => None,
};
let mut resolved = match scope {
Some(scope) if crate::provider_uses_first_party_auth_path(&info) => {
resolve_provider_auth_for_scope(manager, credential.as_ref(), &info, scope).await?
}
_ => ResolvedProviderAuth::new(resolve_provider_auth(credential.as_ref(), &info)?),
};
resolved.credential = credential;
Ok(resolved)
})
.await?
}
pub struct ResolvedProviderRequest {
pub auth: Option<CodexAuth>,
pub auth_owner_generation: Option<u64>,
pub auth_revision: Option<u64>,
pub api_provider: Provider,
pub redirect_policy: ClientRedirectPolicy,
pub api_auth: SharedAuthProvider,
pub agent_identity_telemetry: Option<AgentIdentityTelemetry>,
pub runtime_id: Option<String>,
pub http_client_factory: HttpClientFactory,
}
impl ResolvedProviderRequest {
pub fn http_transport(&self, endpoint: &str) -> Result<ReqwestTransport> {
let redirect_policy = if self
.api_provider
.headers
.contains_key(crate::ACCOUNT_ROUTING_HEADER)
{
ClientRedirectPolicy::Reject
} else {
self.redirect_policy
};
let client = create_client_for_route(
&self.http_client_factory,
&self.api_provider.url_for_path(endpoint),
ClientRouteClass::Api,
redirect_policy,
)
.map_err(std::io::Error::from)?;
Ok(ReqwestTransport::from_http_client(client))
}
}
/// Resolve one attempt, including any workspace route, without mixing credential revisions.
/// The supplied factory retains the caller's proxy/TLS settings; runtime requests bind its
/// application policy to the same selected account used to sign the request.
pub async fn resolve_request(
provider: &dyn ModelProvider,
scope: Option<ProviderAuthScope>,
routing: Option<&WorkspaceRoutingContext>,
http_client_factory: HttpClientFactory,
) -> Result<ResolvedProviderRequest> {
let check_auth = provider.runtime_auth().is_none()
&& crate::provider_uses_first_party_auth_path(provider.info());
crate::prepare_provider_request(provider, check_auth, |state| {
let scope = scope.clone();
let http_client_factory = http_client_factory.clone();
async move {
let (api_provider, resolved_auth) = provider.request_provider_and_auth(scope).await?;
let auth = match &resolved_auth.credential {
Some(auth) => Some(auth.clone()),
None => provider.auth().await,
};
let (api_provider, redirect_policy) = match routing {
Some(routing) => {
let resolved = provider
.route_responses_api_provider(api_provider, auth.as_ref(), routing)
.await?;
(resolved.provider, resolved.redirect_policy)
}
None => (api_provider, ClientRedirectPolicy::Default),
};
resolved_auth.validate_headers(&api_provider.headers)?;
let runtime_state = resolved_auth
.runtime
.as_ref()
.map(RuntimeAuth::change_state);
let http_client_factory = resolved_auth
.bind_http_client_factory(http_client_factory)
.map_err(|error| provider.map_api_error(error.into()))?;
let runtime_id = resolved_auth
.runtime
.as_ref()
.map(|selected| selected.runtime().id().to_owned());
Ok(ResolvedProviderRequest {
auth,
auth_owner_generation: runtime_state
.map(|state| state.owner_generation)
.or(state.map(|state| state.owner_generation)),
auth_revision: runtime_state
.map(|state| state.generation)
.or(state.map(|state| state.generation)),
api_provider,
redirect_policy,
api_auth: resolved_auth.auth,
agent_identity_telemetry: resolved_auth.agent_identity_telemetry,
runtime_id,
http_client_factory,
})
}
})
.await
}
+1 -1
View File
@@ -18,7 +18,7 @@ use http::HeaderMap;
use std::sync::Arc;
/// Select the runtime for the resource scope carried by the model request.
pub async fn select_runtime_auth(
pub(crate) async fn select_runtime_auth(
manager: &RuntimeAuthManager,
headers: &HeaderMap,
) -> Result<RuntimeAuth, AuthError> {
+66 -27
View File
@@ -28,8 +28,6 @@ use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use std::time::Duration;
use tokio::sync::Mutex;
use tokio::sync::MutexGuard;
@@ -165,7 +163,7 @@ pub trait ModelsManager: fmt::Debug + Send + Sync {
None
}
/// Supply startup API-key discovery policy; live changes require a new session.
/// Update API-key discovery for this catalog and its derived catalogs.
/// Static catalogs ignore this setting.
fn set_api_key_model_discovery_enabled(&self, _enabled: bool) {}
@@ -344,16 +342,54 @@ impl CatalogSource {
}
}
/// Cache policy and live feature state shared by catalogs with different request credentials.
#[derive(Clone, Debug, Default)]
pub struct ModelCatalogSettings {
cache: Option<Arc<dyn ModelsCache>>,
pub api_key_model_discovery_default: bool,
// Before the host publishes a live change, each thread uses its configured default.
live_discovery: Arc<std::sync::Mutex<Option<bool>>>,
}
impl ModelCatalogSettings {
pub fn disk(codex_home: PathBuf) -> Self {
Self::with_cache(Arc::new(FileModelsCache::new(
codex_home.join(MODEL_CACHE_FILE),
DEFAULT_MODEL_CACHE_TTL,
)))
}
pub fn with_cache(cache: Arc<dyn ModelsCache>) -> Self {
Self {
cache: Some(cache),
..Self::default()
}
}
pub fn set_api_key_model_discovery_enabled(&self, enabled: bool) {
*self
.live_discovery
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(enabled);
}
fn api_key_model_discovery_enabled(&self) -> bool {
self.live_discovery
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.unwrap_or(self.api_key_model_discovery_default)
}
}
/// OpenAI-compatible model manager backed by bundled models, cache, and `/models`.
#[derive(Debug)]
pub struct OpenAiModelsManager {
originator_managers: std::sync::Mutex<HashMap<ModelCatalogOriginator, SharedModelsManager>>,
remote_models: RwLock<CatalogState>,
catalog_updates: watch::Sender<Option<Arc<RefreshedModelCatalog>>>,
cache: Option<Arc<dyn ModelsCache>>,
settings: ModelCatalogSettings,
endpoint_client: SharedModelsEndpointClient,
catalog_source: CatalogSource,
api_key_model_discovery_enabled: Arc<AtomicBool>,
auth_manager: Option<Arc<AuthManager>>,
}
@@ -372,12 +408,8 @@ impl OpenAiModelsManager {
endpoint_client: Arc<dyn ModelsEndpointClient>,
auth_manager: Option<Arc<AuthManager>>,
) -> Self {
let cache_path = codex_home.join(MODEL_CACHE_FILE);
Self::new_with_optional_cache(
Some(Arc::new(FileModelsCache::new(
cache_path,
DEFAULT_MODEL_CACHE_TTL,
))),
Self::with_settings(
ModelCatalogSettings::disk(codex_home),
endpoint_client,
auth_manager,
)
@@ -388,7 +420,11 @@ impl OpenAiModelsManager {
endpoint_client: Arc<dyn ModelsEndpointClient>,
auth_manager: Option<Arc<AuthManager>>,
) -> Self {
Self::new_with_optional_cache(/*cache*/ None, endpoint_client, auth_manager)
Self::with_settings(
ModelCatalogSettings::default(),
endpoint_client,
auth_manager,
)
}
/// Constructs an OpenAI-compatible model manager with a caller-provided cache.
@@ -400,11 +436,15 @@ impl OpenAiModelsManager {
endpoint_client: Arc<dyn ModelsEndpointClient>,
auth_manager: Option<Arc<AuthManager>>,
) -> Self {
Self::new_with_optional_cache(Some(cache), endpoint_client, auth_manager)
Self::with_settings(
ModelCatalogSettings::with_cache(cache),
endpoint_client,
auth_manager,
)
}
fn new_with_optional_cache(
cache: Option<Arc<dyn ModelsCache>>,
pub fn with_settings(
settings: ModelCatalogSettings,
endpoint_client: Arc<dyn ModelsEndpointClient>,
auth_manager: Option<Arc<AuthManager>>,
) -> Self {
@@ -418,8 +458,7 @@ impl OpenAiModelsManager {
identity: endpoint_client.identity(),
models: load_remote_models_from_file().unwrap_or_default(),
})),
cache,
api_key_model_discovery_enabled: Arc::new(AtomicBool::new(false)),
settings,
endpoint_client,
catalog_source: CatalogSource::Default,
auth_manager,
@@ -503,6 +542,7 @@ impl ModelsManager for OpenAiModelsManager {
state.model_catalog(self.models_for_entry(&state.entry))
})
}
fn for_originator(&self, originator: &str) -> Option<SharedModelsManager> {
let originator = ModelCatalogOriginator::from_originator(originator)?;
let mut managers = self
@@ -514,23 +554,22 @@ impl ModelsManager for OpenAiModelsManager {
}
let endpoint = self.endpoint_client.for_originator(originator)?;
// A backend that cannot partition must not overwrite the default catalog.
let cache = self
let mut settings = self.settings.clone();
settings.cache = settings
.cache
.as_ref()
.and_then(|cache| cache.for_originator(originator));
let mut manager = Self::new_with_optional_cache(cache, endpoint, self.auth_manager.clone());
let mut manager = Self::with_settings(settings, endpoint, self.auth_manager.clone());
if matches!(self.catalog_source, CatalogSource::ExplicitProvider(_)) {
manager = manager.with_provider_catalog();
}
manager.api_key_model_discovery_enabled = Arc::clone(&self.api_key_model_discovery_enabled);
let manager: SharedModelsManager = Arc::new(manager);
managers.insert(originator, Arc::clone(&manager));
Some(manager)
}
fn set_api_key_model_discovery_enabled(&self, enabled: bool) {
self.api_key_model_discovery_enabled
.store(enabled, Ordering::SeqCst);
self.settings.set_api_key_model_discovery_enabled(enabled);
}
fn refresh_after_auth_change(
@@ -657,7 +696,7 @@ impl OpenAiModelsManager {
&& Some(&identity) == self.endpoint_client.identity().as_ref()
&& current_etag.as_deref() == Some(etag.as_str())
{
if let Some(cache) = self.cache.as_ref()
if let Some(cache) = self.settings.cache.as_ref()
&& let Err(err) = cache
.refresh_ttl(&crate::client_version_to_whole(), &identity, &etag)
.await
@@ -761,7 +800,7 @@ impl OpenAiModelsManager {
self.catalog_updates.send_replace(None);
}
if let Some(identity) = request_identity
&& let Some(cache) = self.cache.as_ref()
&& let Some(cache) = self.settings.cache.as_ref()
{
let invalidation = async {
if let Some(mut entry) = cache.load(&client_version).await?
@@ -798,7 +837,7 @@ impl OpenAiModelsManager {
identity: Some(identity),
models,
};
if let Some(cache) = self.cache.as_ref()
if let Some(cache) = self.settings.cache.as_ref()
&& let Err(err) = cache.store(&entry).await
{
error!("failed to write models cache: {err}");
@@ -821,7 +860,7 @@ impl OpenAiModelsManager {
self.uses_api_key_auth()
&& !self.endpoint_client.has_command_auth()
&& (!self.endpoint_client.supports_api_key_models()
|| !self.api_key_model_discovery_enabled.load(Ordering::SeqCst))
|| !self.settings.api_key_model_discovery_enabled())
}
fn uses_api_key_auth(&self) -> bool {
@@ -874,7 +913,7 @@ impl OpenAiModelsManager {
/// Attempt to satisfy the refresh from the cache when it matches the provider and TTL.
async fn try_load_cache(&self) -> bool {
let Some(cache) = self.cache.as_ref() else {
let Some(cache) = self.settings.cache.as_ref() else {
return false;
};
let _timer =
+68 -9
View File
@@ -9,6 +9,7 @@ use crate::RuntimeAuth;
use crate::RuntimeRegistration;
use crate::RuntimeRegistrationClient;
use crate::RuntimeRegistrationMetadata;
use codex_agent_identity::ChatGptEnvironment;
use codex_http_client::HttpClientFactory;
use codex_http_client::NetworkPolicyDenied;
use codex_login::AuthChangeState;
@@ -23,12 +24,52 @@ use url::Url;
/// Owned alongside an AuthManager, not created for every thread or request.
pub struct RuntimeAuthManager {
credentials: Arc<AuthManager>,
host: AgentHostAuthorization,
registration: RegistrationTarget,
metadata: RuntimeRegistrationMetadata,
registration_url: Url,
state: Mutex<Option<CachedRuntime>>,
}
enum RegistrationTarget {
Explicit {
host: AgentHostAuthorization,
url: Url,
},
ChatGpt {
installation_id: String,
base_url: String,
},
}
impl RegistrationTarget {
fn host(&self) -> Result<AgentHostAuthorization, RegistrationError> {
match self {
Self::Explicit { host, .. } => Ok(host.clone()),
Self::ChatGpt {
installation_id, ..
} => Ok(AgentHostAuthorization::InstallationId {
installation_id: installation_id
.parse()
.map_err(|_| RegistrationError::InvalidRequest)?,
}),
}
}
fn url(&self) -> Result<Url, RegistrationError> {
match self {
Self::Explicit { url, .. } => Ok(url.clone()),
Self::ChatGpt { base_url, .. } => {
let url = match ChatGptEnvironment::from_chatgpt_base_url(base_url)
.map_err(|_| RegistrationError::InvalidEndpoint)?
{
ChatGptEnvironment::Production => crate::DEFAULT_REGISTRATION_URL,
ChatGptEnvironment::Staging => "https://auth.api.openai.org/agent/register",
};
url.parse().map_err(|_| RegistrationError::InvalidEndpoint)
}
}
}
}
struct CachedRuntime {
source_generation: u64,
selection: PlatformResourceSelection,
@@ -61,9 +102,29 @@ impl RuntimeAuthManager {
) -> Self {
Self {
credentials,
host,
registration: RegistrationTarget::Explicit {
host,
url: registration_url,
},
metadata,
state: Mutex::new(None),
}
}
/// Resolve the ChatGPT registration origin only when a first-party request needs a runtime.
pub fn for_chatgpt(
credentials: Arc<AuthManager>,
installation_id: String,
base_url: String,
metadata: RuntimeRegistrationMetadata,
) -> Self {
Self {
credentials,
registration: RegistrationTarget::ChatGpt {
installation_id,
base_url,
},
metadata,
registration_url,
state: Mutex::new(None),
}
}
@@ -149,7 +210,7 @@ impl RuntimeAuthManager {
state: RuntimeState::Pending {
registration: RuntimeRegistration::new(
actor_auth.clone(),
self.host.clone(),
self.registration.host()?,
self.metadata.clone(),
)
.map_err(|_| RuntimeAuthError::KeyGeneration)?,
@@ -172,10 +233,8 @@ impl RuntimeAuthManager {
registration
}
};
let client = RuntimeRegistrationClient::new(
http_client_factory.clone(),
self.registration_url.clone(),
)?;
let client =
RuntimeRegistrationClient::new(http_client_factory.clone(), self.registration.url()?)?;
let result = client.register(registration).await;
if self.auth_state(&auth)?.source_generation != change_state.source_generation {
// A late response must not publish an old identity after a credential switch.
+9 -4
View File
@@ -35,6 +35,7 @@ use codex_core_api::GhostSnapshotConfig;
use codex_core_api::History;
use codex_core_api::MemoriesConfig;
use codex_core_api::ModelAvailabilityNuxConfig;
use codex_core_api::ModelCatalogSettings;
use codex_core_api::MultiAgentV2Config;
use codex_core_api::NewThread;
use codex_core_api::Notice;
@@ -64,7 +65,6 @@ use codex_core_api::UserInput;
use codex_core_api::WebSearchMode;
use codex_core_api::arg0_dispatch_or_else;
use codex_core_api::bootstrap_auth_config;
use codex_core_api::build_models_manager;
use codex_core_api::built_in_model_providers;
use codex_core_api::find_codex_home;
use codex_core_api::init_state_db;
@@ -146,10 +146,15 @@ async fn run_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
install_image_generation_extension(&mut extensions, auth_manager.clone(), |config: &Config| {
Some(config.codex_home.clone())
});
let thread_manager = ThreadManager::new(
let startup = codex_core_api::build_startup_models(
&config,
Arc::clone(&auth_manager),
build_models_manager(&config, auth_manager),
installation_id,
Arc::new(ModelCatalogSettings::disk(config.codex_home.to_path_buf())),
);
let thread_manager = ThreadManager::new(
&config,
startup,
CodexAppsToolsCache::default(),
SessionSource::Exec,
environment_manager,
@@ -159,7 +164,6 @@ async fn run_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
passthrough_image_store(),
Arc::clone(&thread_store),
local_agent_graph_store_from_state_db(state_db.as_ref()),
installation_id,
/*attestation_provider*/ None,
/*external_time_provider*/ None,
);
@@ -394,6 +398,7 @@ async fn new_config(
rollout_budget: None,
current_time_reminder: None,
sleep_tool_mode: Default::default(),
runtime_auth_mode: Default::default(),
features: Default::default(),
prefer_mxc: false,
+14
View File
@@ -112,6 +112,20 @@ impl WebSocketConnector {
self
}
/// Reuse the TLS/proxy configuration with a captured credential's account policy.
pub fn with_account_binding(&self, policy: &codex_http_client::NetworkPolicy) -> Option<Self> {
let policy = self
.http_client_factory
.network_policy()
.clone()
.with_account_binding(policy)?;
Some(Self {
http_client_factory: self.http_client_factory.clone().with_network_policy(policy),
tls_config: self.tls_config.clone(),
tcp_nodelay: self.tcp_nodelay,
})
}
/// Connects a WebSocket after resolving the request destination through the configured proxy
/// policy.
pub async fn connect(