mirror of
https://github.com/openai/codex.git
synced 2026-10-23 18:08:45 +00:00
Add managed configuration loaders for custom config servers (#56314)
## What changed - Add loaders that select `config_server` when configured and otherwise use the existing ChatGPT bundle source. Fetch custom bundles from `<base_url>/codex/config/bundle` without redirects or request logging. - Support optional OAuth sessions without interactive login during bundle fetches, recover credentials after a `401`, and scope cached bundles to the endpoint and authorization identity. - Restrict bootstrap network access to configuration and OAuth endpoints using local administrator rules when available. - Return generic bundle validation errors so invalid TOML, requirements, and provider settings do not expose configuration contents. ## Testing Add tests for missing configuration-server sign-in, replacement OAuth credentials, endpoint-specific cache reuse, and validation errors that omit sensitive values while preserving cached bundles. GitOrigin-RevId: 56590a9e66340540666ca21590bef46c495e0e33
This commit is contained in:
Generated
+2
@@ -2650,6 +2650,7 @@ dependencies = [
|
||||
"codex-config",
|
||||
"codex-core",
|
||||
"codex-http-client",
|
||||
"codex-keyring-store",
|
||||
"codex-login",
|
||||
"codex-otel",
|
||||
"codex-protocol",
|
||||
@@ -2662,6 +2663,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -165,6 +165,7 @@ pub struct Client {
|
||||
chatgpt_account_id: Option<String>,
|
||||
chatgpt_account_is_fedramp: bool,
|
||||
path_style: PathStyle,
|
||||
config_server_base_url: Option<String>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for Client {
|
||||
@@ -205,6 +206,22 @@ impl Client {
|
||||
Self::with_http(base_url.into(), http)
|
||||
}
|
||||
|
||||
pub fn for_config_endpoint(
|
||||
base_url: impl Into<String>,
|
||||
http_client_factory: HttpClientFactory,
|
||||
) -> Self {
|
||||
let base_url = base_url.into();
|
||||
let mut client = Self::with_http(
|
||||
base_url.clone(),
|
||||
RouteAwareClientPool::new_without_redirects_or_request_logging(
|
||||
http_client_factory,
|
||||
ClientRouteClass::Api,
|
||||
),
|
||||
);
|
||||
client.config_server_base_url = Some(base_url);
|
||||
client
|
||||
}
|
||||
|
||||
fn with_http(mut base_url: String, http: RouteAwareClientPool) -> Self {
|
||||
// Normalize common ChatGPT hostnames to include /backend-api so we hit the WHAM paths.
|
||||
// Also trim trailing slashes for consistent URL building.
|
||||
@@ -226,6 +243,7 @@ impl Client {
|
||||
chatgpt_account_id: None,
|
||||
chatgpt_account_is_fedramp: false,
|
||||
path_style,
|
||||
config_server_base_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,6 +262,12 @@ impl Client {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_bearer_token(self, token: String) -> Self {
|
||||
self.with_auth_provider(std::sync::Arc::new(
|
||||
codex_model_provider::BearerAuthProvider::new(token),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn with_user_agent(mut self, ua: impl Into<String>) -> Self {
|
||||
if let Ok(hv) = HeaderValue::from_str(&ua.into()) {
|
||||
self.user_agent = Some(hv);
|
||||
@@ -523,10 +547,9 @@ impl Client {
|
||||
self.decode_json::<TurnAttemptsSiblingTurnsResponse>(&url, &ct, &body)
|
||||
}
|
||||
|
||||
/// Fetch the selected cloud-managed config bundle from codex-backend.
|
||||
///
|
||||
/// `GET /api/codex/config/bundle` (Codex API style) or
|
||||
/// `GET /wham/config/bundle` (ChatGPT backend-api style).
|
||||
/// `GET /wham/config/bundle` (ChatGPT backend-api style), or
|
||||
/// `GET /codex/config/bundle` beneath a custom config server's base URL.
|
||||
pub async fn get_config_bundle(
|
||||
&self,
|
||||
) -> std::result::Result<ConfigBundleResponse, RequestError> {
|
||||
@@ -538,6 +561,10 @@ impl Client {
|
||||
|
||||
/// Returns the normalized discovery endpoint used by this client.
|
||||
pub fn config_bundle_url(&self) -> String {
|
||||
if let Some(base_url) = &self.config_server_base_url {
|
||||
let base_url = base_url.strip_suffix('/').unwrap_or(base_url);
|
||||
return format!("{base_url}/codex/config/bundle");
|
||||
}
|
||||
match self.path_style {
|
||||
PathStyle::CodexApi => format!("{}/api/codex/config/bundle", self.base_url),
|
||||
PathStyle::ChatGptApi => format!("{}/wham/config/bundle", self.base_url),
|
||||
@@ -1282,6 +1309,7 @@ mod tests {
|
||||
chatgpt_account_id: None,
|
||||
chatgpt_account_is_fedramp: false,
|
||||
path_style,
|
||||
config_server_base_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,5 +183,6 @@ fn test_client(base_url: &str, path_style: PathStyle) -> Client {
|
||||
chatgpt_account_id: None,
|
||||
chatgpt_account_is_fedramp: false,
|
||||
path_style,
|
||||
config_server_base_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ codex-async-utils = { workspace = true }
|
||||
codex-backend-client = { workspace = true }
|
||||
codex-config = { workspace = true }
|
||||
codex-http-client = { workspace = true }
|
||||
codex-keyring-store = { workspace = true }
|
||||
codex-login = { workspace = true }
|
||||
codex-otel = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
@@ -27,6 +28,7 @@ tokio = { workspace = true, features = ["fs", "rt", "sync", "time"] }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
wiremock = { workspace = true }
|
||||
codex-agent-identity = { workspace = true }
|
||||
codex-core = { workspace = true }
|
||||
pretty_assertions = { workspace = true }
|
||||
|
||||
@@ -98,9 +98,6 @@ pub(crate) async fn fetch_bundle(
|
||||
client
|
||||
.get_config_bundle()
|
||||
.await
|
||||
.inspect_err(|err| {
|
||||
tracing::warn!(error = %err, "Failed to fetch cloud config bundle");
|
||||
})
|
||||
.map(bundle_from_response)
|
||||
.map_err(bundle_request_error)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
use crate::backend::BackendBundleClient;
|
||||
use crate::chatgpt::ChatGptSource;
|
||||
use crate::config_server::config_server_http_client_factory;
|
||||
use crate::config_source::BundleSource;
|
||||
use crate::service::CLOUD_CONFIG_BUNDLE_TIMEOUT;
|
||||
use crate::service::CloudConfigBundleService;
|
||||
use codex_config::CloudConfigBundleLoader;
|
||||
use codex_config::ConfigRequirementsToml;
|
||||
use codex_http_client::HttpClientFactory;
|
||||
use codex_login::AuthConfig;
|
||||
use codex_login::AuthManager;
|
||||
@@ -12,6 +14,64 @@ use std::sync::Arc;
|
||||
use tokio::task::AbortHandle;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
pub fn managed_config_auth_manager(
|
||||
auth: AuthConfig,
|
||||
requirements: &ConfigRequirementsToml,
|
||||
) -> std::io::Result<Option<codex_login::OAuthSessionManager>> {
|
||||
let Some(service_config) = requirements.config_server.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let factory = config_server_http_client_factory(service_config, &auth.auth_route_config);
|
||||
crate::config_server_auth_manager(service_config, auth.codex_home, &factory)
|
||||
}
|
||||
|
||||
pub fn managed_config_bundle_loader(
|
||||
auth_manager: Arc<AuthManager>,
|
||||
chatgpt_base_url: String,
|
||||
codex_home: PathBuf,
|
||||
factory: HttpClientFactory,
|
||||
requirements: &ConfigRequirementsToml,
|
||||
) -> CloudConfigBundleLoader {
|
||||
if let Some(service_config) = requirements.config_server.as_ref() {
|
||||
let factory = config_server_http_client_factory(
|
||||
service_config,
|
||||
&codex_login::AuthRouteConfig::from_http_client_factory(factory),
|
||||
);
|
||||
return crate::config_server_bundle_loader(service_config.clone(), codex_home, factory);
|
||||
}
|
||||
let endpoint = codex_backend_client::Client::new(chatgpt_base_url.clone(), factory.clone())
|
||||
.config_bundle_url();
|
||||
let policy = factory
|
||||
.network_policy()
|
||||
.clone()
|
||||
.restrict_to_endpoints(endpoint.parse().into_iter().collect());
|
||||
cloud_config_bundle_loader(
|
||||
auth_manager,
|
||||
chatgpt_base_url,
|
||||
codex_home,
|
||||
factory.with_network_policy(policy),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn managed_config_bundle_loader_for_storage(
|
||||
auth_config: AuthConfig,
|
||||
requirements: &ConfigRequirementsToml,
|
||||
enable_codex_api_key_env: bool,
|
||||
) -> std::io::Result<CloudConfigBundleLoader> {
|
||||
if let Some(service_config) = requirements.config_server.as_ref() {
|
||||
let factory =
|
||||
config_server_http_client_factory(service_config, &auth_config.auth_route_config);
|
||||
return Ok(crate::config_server_bundle_loader(
|
||||
service_config.clone(),
|
||||
auth_config.codex_home,
|
||||
factory,
|
||||
));
|
||||
}
|
||||
let service =
|
||||
cloud_config_bundle_service_for_storage(auth_config, enable_codex_api_key_env).await?;
|
||||
Ok(cloud_config_bundle_loader_for_service(service).0)
|
||||
}
|
||||
|
||||
struct CloudConfigBundleLoaderLifetime<C: BundleSource> {
|
||||
service: Arc<CloudConfigBundleService<C>>,
|
||||
refresh_task: AbortHandle,
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
//! Fetch managed configuration from a custom config server.
|
||||
//! Cache entries are scoped to the endpoint and the current OAuth sign-in.
|
||||
|
||||
use crate::backend::BundleRequestError;
|
||||
use crate::backend::RetryableFailureKind;
|
||||
use crate::backend::fetch_bundle;
|
||||
use crate::config_source::BundleSource;
|
||||
use crate::service::CLOUD_CONFIG_BUNDLE_TIMEOUT;
|
||||
use crate::service::CloudConfigBundleService;
|
||||
use codex_backend_client::Client as BackendClient;
|
||||
use codex_config::CloudConfigBundle;
|
||||
use codex_config::CloudConfigBundleLoadError;
|
||||
use codex_config::CloudConfigBundleLoadErrorCode;
|
||||
use codex_config::CloudConfigBundleLoader;
|
||||
use codex_config::ConfigServer;
|
||||
use codex_http_client::HttpClientFactory;
|
||||
use codex_keyring_store::DefaultKeyringStore;
|
||||
use codex_login::AuthRouteConfig;
|
||||
use codex_login::OAuthSessionConfig;
|
||||
use codex_login::OAuthSessionError;
|
||||
use codex_login::OAuthSessionManager;
|
||||
use codex_login::OAuthSessionRequest;
|
||||
use sha2::Digest;
|
||||
use sha2::Sha256;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub fn config_server_bundle_loader(
|
||||
config: ConfigServer,
|
||||
codex_home: PathBuf,
|
||||
factory: HttpClientFactory,
|
||||
) -> CloudConfigBundleLoader {
|
||||
match ConfigServerSource::from_config_server(config.clone(), codex_home.clone(), factory) {
|
||||
Ok(source) => {
|
||||
let service = CloudConfigBundleService::from_source(
|
||||
source,
|
||||
codex_home,
|
||||
CLOUD_CONFIG_BUNDLE_TIMEOUT,
|
||||
);
|
||||
crate::bundle_loader::cloud_config_bundle_loader_for_service(service).0
|
||||
}
|
||||
Err(error) => CloudConfigBundleLoader::new(std::future::ready(Err(error))),
|
||||
}
|
||||
.with_config_server(config)
|
||||
}
|
||||
|
||||
pub fn config_server_auth_manager(
|
||||
service_config: &ConfigServer,
|
||||
codex_home: PathBuf,
|
||||
factory: &HttpClientFactory,
|
||||
) -> std::io::Result<Option<OAuthSessionManager>> {
|
||||
service_config
|
||||
.validated_base_url()
|
||||
.map_err(std::io::Error::other)?;
|
||||
oauth_session_manager(service_config.oauth.as_deref(), codex_home, factory)
|
||||
}
|
||||
|
||||
pub(crate) fn config_server_http_client_factory(
|
||||
config: &ConfigServer,
|
||||
routes: &AuthRouteConfig,
|
||||
) -> HttpClientFactory {
|
||||
let endpoint = config.validated_base_url().ok().map(|base_url| {
|
||||
BackendClient::for_config_endpoint(base_url, routes.http_client_factory().clone())
|
||||
.config_bundle_url()
|
||||
});
|
||||
let endpoints = endpoint
|
||||
.as_deref()
|
||||
.into_iter()
|
||||
.chain(config.oauth.as_ref().map(|oauth| oauth.token_url.as_str()))
|
||||
.chain(
|
||||
config
|
||||
.oauth
|
||||
.as_ref()
|
||||
.and_then(|oauth| oauth.device_authorization_url.as_deref()),
|
||||
)
|
||||
.filter_map(|endpoint| endpoint.parse().ok());
|
||||
routes.http_client_factory_for_bootstrap(endpoints)
|
||||
}
|
||||
|
||||
struct ConfigServerSource {
|
||||
cache_namespace: String,
|
||||
client: BackendClient,
|
||||
auth: Option<OAuthSessionManager>,
|
||||
}
|
||||
|
||||
fn oauth_session_manager(
|
||||
oauth: Option<&OAuthSessionConfig>,
|
||||
codex_home: PathBuf,
|
||||
factory: &HttpClientFactory,
|
||||
) -> std::io::Result<Option<OAuthSessionManager>> {
|
||||
oauth
|
||||
.map(|oauth| {
|
||||
OAuthSessionManager::new(
|
||||
oauth.clone(),
|
||||
codex_home,
|
||||
factory,
|
||||
Arc::new(DefaultKeyringStore),
|
||||
)
|
||||
.map(OAuthSessionManager::without_interactive_login)
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
impl ConfigServerSource {
|
||||
fn from_config_server(
|
||||
config: ConfigServer,
|
||||
codex_home: PathBuf,
|
||||
factory: HttpClientFactory,
|
||||
) -> Result<Self, CloudConfigBundleLoadError> {
|
||||
let base_url = config.validated_base_url().map_err(|message| {
|
||||
CloudConfigBundleLoadError::new(
|
||||
CloudConfigBundleLoadErrorCode::Internal,
|
||||
/*status_code*/ None,
|
||||
message,
|
||||
)
|
||||
})?;
|
||||
Self::new(
|
||||
base_url,
|
||||
config.oauth.map(|oauth| *oauth),
|
||||
codex_home,
|
||||
factory,
|
||||
)
|
||||
}
|
||||
|
||||
fn new(
|
||||
base_url: String,
|
||||
oauth: Option<OAuthSessionConfig>,
|
||||
codex_home: PathBuf,
|
||||
factory: HttpClientFactory,
|
||||
) -> Result<Self, CloudConfigBundleLoadError> {
|
||||
let auth =
|
||||
oauth_session_manager(oauth.as_ref(), codex_home, &factory).map_err(auth_io_error)?;
|
||||
let client = BackendClient::for_config_endpoint(base_url, factory);
|
||||
// Reuse the OAuth session's issuer/client/audience/scope identity; the endpoint
|
||||
// additionally separates policy authorities that share one OAuth grant.
|
||||
let namespace = serde_json::to_vec(&(
|
||||
client.config_bundle_url(),
|
||||
auth.as_ref().map(OAuthSessionManager::credential_id),
|
||||
))
|
||||
.map_err(|_| auth_failure())?;
|
||||
Ok(Self {
|
||||
client,
|
||||
cache_namespace: format!("{:x}", Sha256::digest(namespace)),
|
||||
auth,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl BundleSource for ConfigServerSource {
|
||||
type Auth = Option<String>;
|
||||
type FetchContext = Option<OAuthSessionRequest>;
|
||||
fn begin_fetch(&self) -> Self::FetchContext {
|
||||
self.auth.as_ref().map(OAuthSessionManager::begin_request)
|
||||
}
|
||||
|
||||
async fn auth(&self) -> Result<Option<Self::Auth>, CloudConfigBundleLoadError> {
|
||||
// Cache reads need the grant identity, not a refreshed access token.
|
||||
Ok(Some(match &self.auth {
|
||||
Some(auth) => Some(auth.authorization_id().await.map_err(auth_io_error)?),
|
||||
None => None,
|
||||
}))
|
||||
}
|
||||
|
||||
fn cache_namespace(&self) -> Option<String> {
|
||||
Some(self.cache_namespace.clone())
|
||||
}
|
||||
|
||||
fn identity(&self, auth: &Self::Auth) -> (Option<String>, Option<String>) {
|
||||
(
|
||||
Some(auth.clone().unwrap_or_else(|| "anonymous".into())),
|
||||
self.cache_namespace(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn get_bundle(
|
||||
&self,
|
||||
identity: &mut Self::Auth,
|
||||
context: &mut Self::FetchContext,
|
||||
) -> Result<CloudConfigBundle, BundleRequestError> {
|
||||
let Some(request) = context else {
|
||||
return fetch_bundle(&self.client).await;
|
||||
};
|
||||
let credentials = request
|
||||
.credentials()
|
||||
.await
|
||||
.map_err(|error| auth_request_error(error, request.is_recovering()))?;
|
||||
if credentials.authorization_id.is_none() {
|
||||
return Err(BundleRequestError::Fatal {
|
||||
error: auth_failure(),
|
||||
metric_reason: Some("auth"),
|
||||
});
|
||||
}
|
||||
*identity = credentials.authorization_id;
|
||||
fetch_bundle(
|
||||
&self
|
||||
.client
|
||||
.clone()
|
||||
.with_bearer_token(credentials.access_token),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn recover_auth(
|
||||
&self,
|
||||
_identity: &mut Self::Auth,
|
||||
context: &mut Self::FetchContext,
|
||||
status_code: Option<u16>,
|
||||
recovery_attempt: usize,
|
||||
) -> Result<bool, BundleRequestError> {
|
||||
let Some(auth) = context else {
|
||||
return Ok(false);
|
||||
};
|
||||
if status_code != Some(401) || recovery_attempt != 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
Ok(auth.reject_credentials())
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_request_error(error: std::io::Error, recovering: bool) -> BundleRequestError {
|
||||
if error
|
||||
.get_ref()
|
||||
.is_some_and(<dyn std::error::Error + Send + Sync>::is::<OAuthSessionError>)
|
||||
|| matches!(
|
||||
error.kind(),
|
||||
std::io::ErrorKind::PermissionDenied
|
||||
| std::io::ErrorKind::InvalidInput
|
||||
| std::io::ErrorKind::InvalidData
|
||||
)
|
||||
{
|
||||
BundleRequestError::Fatal {
|
||||
error: auth_io_error(error),
|
||||
metric_reason: Some("auth"),
|
||||
}
|
||||
} else {
|
||||
BundleRequestError::Retryable(if recovering {
|
||||
RetryableFailureKind::AuthRecovery {
|
||||
status_code: Some(401),
|
||||
}
|
||||
} else {
|
||||
RetryableFailureKind::Request { status_code: None }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_io_error(error: std::io::Error) -> CloudConfigBundleLoadError {
|
||||
if error
|
||||
.get_ref()
|
||||
.and_then(|source| source.downcast_ref::<OAuthSessionError>())
|
||||
== Some(&OAuthSessionError::LoginRequired)
|
||||
{
|
||||
CloudConfigBundleLoadError::new(
|
||||
CloudConfigBundleLoadErrorCode::Auth,
|
||||
/*status_code*/ None,
|
||||
"Sign in to the configuration server to load your organization's settings",
|
||||
)
|
||||
} else {
|
||||
auth_failure()
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_failure() -> CloudConfigBundleLoadError {
|
||||
CloudConfigBundleLoadError::new(
|
||||
CloudConfigBundleLoadErrorCode::Auth,
|
||||
/*status_code*/ None,
|
||||
"Could not authenticate with the configuration server",
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "config_server_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,195 @@
|
||||
#![cfg(test)]
|
||||
|
||||
use super::*;
|
||||
use crate::bundle_loader::cloud_config_bundle_loader_for_service;
|
||||
use crate::service::CLOUD_CONFIG_BUNDLE_TIMEOUT;
|
||||
use crate::service::CloudConfigBundleService;
|
||||
use codex_config::CloudConfigBundleLoader;
|
||||
use codex_config::ConfigServer;
|
||||
use codex_config::test_support::CloudConfigBundleFixture;
|
||||
use codex_keyring_store::tests::MockKeyringStore;
|
||||
use codex_login::test_support::transport_default_auth_route_config;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
use tempfile::tempdir;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::header;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
// HTTP fixtures exercise the same service while production enforces HTTPS at construction.
|
||||
fn remote_loader(url: String, codex_home: PathBuf) -> CloudConfigBundleLoader {
|
||||
let factory = transport_default_auth_route_config()
|
||||
.http_client_factory()
|
||||
.clone();
|
||||
let source = ConfigServerSource::new(url, /*oauth*/ None, codex_home.clone(), factory).unwrap();
|
||||
cloud_config_bundle_loader_for_service(CloudConfigBundleService::from_source(
|
||||
source,
|
||||
codex_home,
|
||||
CLOUD_CONFIG_BUNDLE_TIMEOUT,
|
||||
))
|
||||
.0
|
||||
}
|
||||
|
||||
fn oauth_config(base_url: &str) -> OAuthSessionConfig {
|
||||
OAuthSessionConfig {
|
||||
authorization_url: format!("{base_url}/authorize"),
|
||||
token_url: format!("{base_url}/token"),
|
||||
device_authorization_url: None,
|
||||
client_id: "company-client".into(),
|
||||
resource: None,
|
||||
scopes: vec![],
|
||||
redirect_port: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn policy_fetch_requires_config_server_sign_in() {
|
||||
let server = MockServer::start().await;
|
||||
let home = tempdir().unwrap();
|
||||
let endpoint = format!("https://{}", server.address());
|
||||
let factory = transport_default_auth_route_config()
|
||||
.http_client_factory()
|
||||
.clone();
|
||||
let loader = config_server_bundle_loader(
|
||||
ConfigServer {
|
||||
base_url: format!("{endpoint}/settings"),
|
||||
oauth: Some(Box::new(oauth_config(&endpoint))),
|
||||
},
|
||||
home.path().to_path_buf(),
|
||||
factory,
|
||||
);
|
||||
let error = loader.get().await.unwrap_err();
|
||||
assert_eq!(error.code(), CloudConfigBundleLoadErrorCode::Auth);
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"Sign in to the configuration server to load your organization's settings"
|
||||
);
|
||||
assert!(server.received_requests().await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn policy_fetch_uses_replacement_config_server_credentials() {
|
||||
let server = MockServer::start().await;
|
||||
let sign_in = AtomicUsize::new(0);
|
||||
Mock::given(path("/token"))
|
||||
.respond_with(move |_: &wiremock::Request| {
|
||||
ResponseTemplate::new(200).set_body_json(json!({
|
||||
"access_token": format!("account-{}", sign_in.fetch_add(1, Ordering::Relaxed)),
|
||||
"token_type": "Bearer", "expires_in": 3600,
|
||||
}))
|
||||
})
|
||||
.mount(&server)
|
||||
.await;
|
||||
let policies = ["never", "on-request"].map(|approval| {
|
||||
CloudConfigBundleFixture::enterprise_requirement(format!(
|
||||
"allowed_approval_policies = ['{approval}']"
|
||||
))
|
||||
.into_bundle()
|
||||
});
|
||||
for (account, policy) in policies.iter().enumerate() {
|
||||
Mock::given(path("/settings/codex/config/bundle"))
|
||||
.and(header("authorization", format!("Bearer account-{account}")))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(policy))
|
||||
.mount(&server)
|
||||
.await;
|
||||
}
|
||||
let home = tempdir().unwrap();
|
||||
let factory = transport_default_auth_route_config()
|
||||
.http_client_factory()
|
||||
.clone();
|
||||
let oauth = oauth_config(&server.uri());
|
||||
let keyring = Arc::new(MockKeyringStore::default());
|
||||
let manager = || {
|
||||
OAuthSessionManager::new(
|
||||
oauth.clone(),
|
||||
home.path().to_path_buf(),
|
||||
&factory,
|
||||
keyring.clone(),
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
let gateway = manager();
|
||||
gateway
|
||||
.login_with_browser(std::future::pending(), complete_browser_authorization)
|
||||
.await
|
||||
.unwrap();
|
||||
let source = ConfigServerSource {
|
||||
cache_namespace: "test".into(),
|
||||
client: BackendClient::for_config_endpoint(
|
||||
format!("{}/settings", server.uri()),
|
||||
factory.clone(),
|
||||
),
|
||||
auth: Some(manager().without_interactive_login()),
|
||||
};
|
||||
let service = CloudConfigBundleService::from_source(
|
||||
source,
|
||||
home.path().to_path_buf(),
|
||||
CLOUD_CONFIG_BUNDLE_TIMEOUT,
|
||||
);
|
||||
assert_eq!(
|
||||
service.load_startup_bundle_with_timeout().await.unwrap(),
|
||||
Some(policies[0].clone())
|
||||
);
|
||||
|
||||
gateway
|
||||
.login_with_browser(std::future::pending(), complete_browser_authorization)
|
||||
.await
|
||||
.unwrap();
|
||||
// The old access token is still valid, so this must detect the changed grant.
|
||||
assert_eq!(
|
||||
service.load_startup_bundle_with_timeout().await.unwrap(),
|
||||
Some(policies[1].clone())
|
||||
);
|
||||
}
|
||||
|
||||
fn complete_browser_authorization(url: &wiremock::http::Url) {
|
||||
let query = url
|
||||
.query_pairs()
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
let callback = format!(
|
||||
"{}?code=browser-authorization-code&state={}",
|
||||
query["redirect_uri"], query["state"]
|
||||
);
|
||||
tokio::spawn(async move {
|
||||
codex_login::auth::default_client::create_client_without_request_logging()
|
||||
.get(callback)
|
||||
.send()
|
||||
.await
|
||||
.expect("OAuth callback");
|
||||
});
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cached_policy_is_reused_only_for_the_same_endpoint() {
|
||||
let server = MockServer::start().await;
|
||||
let home = tempdir().unwrap();
|
||||
let endpoint = format!("{}/settings", server.uri());
|
||||
let loader = || remote_loader(endpoint.clone(), home.path().to_path_buf());
|
||||
let bundle =
|
||||
CloudConfigBundleFixture::enterprise_requirement("allowed_approval_policies = ['never']")
|
||||
.into_bundle();
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/settings/codex/config/bundle"))
|
||||
.respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(&bundle))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let first = loader();
|
||||
assert_eq!(first.get().await.unwrap(), Some(bundle.clone()));
|
||||
drop(first);
|
||||
server.reset().await;
|
||||
Mock::given(method("GET"))
|
||||
.respond_with(ResponseTemplate::new(/*s*/ 403))
|
||||
.mount(&server)
|
||||
.await;
|
||||
assert_eq!(loader().get().await.unwrap(), Some(bundle));
|
||||
let different = remote_loader(
|
||||
format!("{}/different", server.uri()),
|
||||
home.path().to_path_buf(),
|
||||
);
|
||||
assert_eq!(different.get().await.unwrap_err().status_code(), Some(403));
|
||||
}
|
||||
@@ -7,6 +7,7 @@ mod backend;
|
||||
mod bundle_loader;
|
||||
mod cache;
|
||||
mod chatgpt;
|
||||
mod config_server;
|
||||
mod config_source;
|
||||
mod metrics;
|
||||
mod service;
|
||||
@@ -15,3 +16,8 @@ mod validation;
|
||||
pub use bundle_loader::cloud_config_bundle_loader;
|
||||
pub use bundle_loader::cloud_config_bundle_loader_for_storage;
|
||||
pub use bundle_loader::cloud_config_bundle_loader_for_storage_without_cache;
|
||||
pub use bundle_loader::managed_config_auth_manager;
|
||||
pub use bundle_loader::managed_config_bundle_loader;
|
||||
pub use bundle_loader::managed_config_bundle_loader_for_storage;
|
||||
pub use config_server::config_server_auth_manager;
|
||||
pub use config_server::config_server_bundle_loader;
|
||||
|
||||
@@ -666,7 +666,10 @@ async fn get_bundle_skips_team_like_business_plans() {
|
||||
#[tokio::test]
|
||||
async fn get_bundle_rejects_invalid_remote_bundle_before_cache_write() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let fetcher = Arc::new(StaticBundleClient::new(invalid_config_bundle()));
|
||||
let mut invalid = invalid_config_bundle();
|
||||
invalid.config_toml.enterprise_managed[0].contents =
|
||||
"secret_credential = [secret-token".to_string();
|
||||
let fetcher = Arc::new(StaticBundleClient::new(invalid));
|
||||
let service = CloudConfigBundleService::new(
|
||||
auth_manager_with_plan("business").await,
|
||||
fetcher.clone(),
|
||||
@@ -681,6 +684,8 @@ async fn get_bundle_rejects_invalid_remote_bundle_before_cache_write() {
|
||||
|
||||
assert_eq!(err.code(), CloudConfigBundleLoadErrorCode::InvalidBundle);
|
||||
assert!(err.to_string().contains("invalid cloud config bundle"));
|
||||
assert!(!err.to_string().contains("secret"));
|
||||
assert!(!format!("{err:?}").contains("secret"));
|
||||
assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1);
|
||||
assert!(
|
||||
!codex_home
|
||||
@@ -691,15 +696,16 @@ async fn get_bundle_rejects_invalid_remote_bundle_before_cache_write() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_cloud_provider_does_not_replace_cached_bundle() {
|
||||
async fn invalid_requirements_do_not_replace_cached_bundle() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let cache = create_test_cache(codex_home.path());
|
||||
let previous = test_bundle();
|
||||
write_test_cache(&cache, previous.clone()).await;
|
||||
|
||||
for contents in [
|
||||
"allowed_approval_policies = ['secret-policy']",
|
||||
"[model_providers.openai]\nname = 'Reserved'",
|
||||
"[model_providers.gateway]\nname = ' '",
|
||||
"[model_providers.secret-provider]\nname = ' '",
|
||||
"[model_providers.gateway]\nbase_url = 'https://gateway.example/v1'",
|
||||
"[model_providers.amazon-bedrock]\nname = 'Managed Bedrock'",
|
||||
"[model_providers.amazon-bedrock-runtime]\nname = 'Managed Bedrock Runtime'",
|
||||
@@ -719,8 +725,10 @@ async fn invalid_cloud_provider_does_not_replace_cached_bundle() {
|
||||
let error = service
|
||||
.validate_and_cache_remote_bundle(&auth, "refresh", /*attempt*/ 1, invalid)
|
||||
.await
|
||||
.expect_err("invalid provider must fail before cache write");
|
||||
.expect_err("invalid requirements must fail before cache write");
|
||||
assert_eq!(error.code(), CloudConfigBundleLoadErrorCode::InvalidBundle);
|
||||
assert!(!error.to_string().contains("secret"));
|
||||
assert!(!format!("{error:?}").contains("secret"));
|
||||
assert_eq!(
|
||||
cache
|
||||
.load(Some("user-12345"), Some("account-12345"))
|
||||
|
||||
@@ -11,11 +11,11 @@ pub(crate) fn validate_bundle(
|
||||
base_dir: &AbsolutePathBuf,
|
||||
) -> Result<(), CloudConfigBundleLoadError> {
|
||||
let bundle_layers =
|
||||
CloudConfigBundleLayers::from_bundle(bundle.clone(), base_dir).map_err(|err| {
|
||||
CloudConfigBundleLayers::from_bundle(bundle.clone(), base_dir).map_err(|_| {
|
||||
CloudConfigBundleLoadError::new(
|
||||
CloudConfigBundleLoadErrorCode::InvalidBundle,
|
||||
/*status_code*/ None,
|
||||
format!("invalid cloud config bundle: {err}"),
|
||||
"invalid cloud config bundle: invalid TOML",
|
||||
)
|
||||
})?;
|
||||
let CloudConfigBundleLayers {
|
||||
@@ -23,19 +23,19 @@ pub(crate) fn validate_bundle(
|
||||
enterprise_managed_requirements,
|
||||
} = bundle_layers;
|
||||
|
||||
let requirements = compose_requirements(enterprise_managed_requirements).map_err(|err| {
|
||||
let requirements = compose_requirements(enterprise_managed_requirements).map_err(|_| {
|
||||
CloudConfigBundleLoadError::new(
|
||||
CloudConfigBundleLoadErrorCode::InvalidBundle,
|
||||
/*status_code*/ None,
|
||||
format!("invalid cloud config bundle: {err}"),
|
||||
"invalid cloud config bundle: invalid requirements",
|
||||
)
|
||||
})?;
|
||||
if let Some(providers) = requirements.and_then(|requirements| requirements.model_providers) {
|
||||
validate_model_providers(&providers).map_err(|err| {
|
||||
validate_model_providers(&providers).map_err(|_| {
|
||||
CloudConfigBundleLoadError::new(
|
||||
CloudConfigBundleLoadErrorCode::InvalidBundle,
|
||||
/*status_code*/ None,
|
||||
format!("invalid cloud config bundle: {err}"),
|
||||
"invalid cloud config bundle: invalid model provider configuration",
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
@@ -7,9 +7,6 @@ use std::fmt;
|
||||
|
||||
pub use codex_model_provider_info::OAuthSessionConfig as ConfigServerOAuth;
|
||||
|
||||
// Deployment-specific prefixes belong in base_url.
|
||||
const BUNDLE_PATH: &str = "codex/config/bundle";
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(try_from = "ConfigServerInput")]
|
||||
pub struct ConfigServer {
|
||||
@@ -33,29 +30,24 @@ impl TryFrom<ConfigServerInput> for ConfigServer {
|
||||
base_url: input.base_url,
|
||||
oauth: input.oauth,
|
||||
};
|
||||
service.endpoint_url()?;
|
||||
service.validated_base_url()?;
|
||||
Ok(service)
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigServer {
|
||||
/// Validates the trusted settings and appends the fixed bundle route to the base URL.
|
||||
pub fn endpoint_url(&self) -> Result<String, String> {
|
||||
/// Validates the trusted settings and returns the normalized base URL.
|
||||
pub fn validated_base_url(&self) -> Result<String, String> {
|
||||
let validate_https_url = |value: &str| {
|
||||
codex_http_client::validate_endpoint_url(value, "config_server")
|
||||
.ok()
|
||||
.filter(|url| url.scheme() == "https")
|
||||
.ok_or("config_server URLs must use HTTPS without userinfo or fragments")
|
||||
};
|
||||
let mut endpoint = validate_https_url(&self.base_url)?;
|
||||
let endpoint = validate_https_url(&self.base_url)?;
|
||||
if endpoint.query().is_some() {
|
||||
return Err("config_server.base_url must not contain a query".into());
|
||||
}
|
||||
endpoint
|
||||
.path_segments_mut()
|
||||
.map_err(|_| "config_server.base_url cannot be used as a base URL")?
|
||||
.pop_if_empty()
|
||||
.extend(BUNDLE_PATH.split('/'));
|
||||
if let Some(oauth) = &self.oauth {
|
||||
validate_https_url(&oauth.authorization_url)?;
|
||||
validate_https_url(&oauth.token_url)?;
|
||||
|
||||
@@ -30,22 +30,34 @@ impl AuthRouteConfig {
|
||||
}
|
||||
|
||||
/// Installs the configuration owner's local-only policy for auth discovery.
|
||||
/// Only auth-owned endpoint constructors can access this factory.
|
||||
/// Endpoint constructors access it through destination-restricted factories.
|
||||
pub fn with_local_bootstrap_factory(mut self, factory: HttpClientFactory) -> Self {
|
||||
self.local_bootstrap_factory = Some(factory);
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn authentication_factory(&self, endpoint: &str) -> HttpClientFactory {
|
||||
let Some(factory) = &self.local_bootstrap_factory else {
|
||||
if self.local_bootstrap_factory.is_none() {
|
||||
return self.http_client_factory.clone();
|
||||
};
|
||||
let endpoints = endpoint.parse().into_iter().collect();
|
||||
}
|
||||
self.http_client_factory_for_bootstrap(endpoint.parse::<url::Url>())
|
||||
}
|
||||
|
||||
/// Uses local administrator network rules when available, limited to the supplied endpoints.
|
||||
/// Fetched policy must not block the sign-in and configuration requests needed to refresh it.
|
||||
pub fn http_client_factory_for_bootstrap(
|
||||
&self,
|
||||
endpoints: impl IntoIterator<Item = url::Url>,
|
||||
) -> HttpClientFactory {
|
||||
let factory = self
|
||||
.local_bootstrap_factory
|
||||
.as_ref()
|
||||
.unwrap_or(&self.http_client_factory);
|
||||
factory.clone().with_network_policy(
|
||||
factory
|
||||
.network_policy()
|
||||
.clone()
|
||||
.restrict_to_endpoints(endpoints),
|
||||
.restrict_to_endpoints(endpoints.into_iter().collect()),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user