mirror of
https://github.com/openai/codex.git
synced 2026-10-29 19:08:43 +00:00
## Why Model catalog refreshes need to use the same registered actor and credential owner as model requests when runtime authentication is enabled. ## What changed - Add `ProviderAuthContext` to carry credentials and optional runtime authentication into model providers and catalog clients. - Select runtime authentication independently for inference and catalog destinations. Reject unsupported OpenAI origins while preserving provider authentication for other destinations. - Include API organization and project selectors in runtime registration. Reuse registered runtimes across projects within an organization and replace them when the organization changes. - Preserve transport restrictions and account bindings, and strip sensitive headers on cross-origin redirects. - Apply the catalog timeout to authentication and fetching while allowing credential refresh and registration to finish safely. ## Testing Add coverage for runtime-authenticated catalogs, provider isolation, resource selection, gateway authentication, cross-origin redirects, and timeout recovery that preserves registration and refreshed tokens. GitOrigin-RevId: c14f1a02159031ea0af1f9b26ea890f8dca7d6ad
306 lines
11 KiB
Rust
306 lines
11 KiB
Rust
//! Exercise catalog auth through the public provider factory and real HTTPS transport.
|
|
#![cfg(test)]
|
|
|
|
use base64::Engine;
|
|
use codex_features::RuntimeAuthMode;
|
|
use codex_http_client::HttpClientFactory;
|
|
use codex_http_client::OutboundProxyPolicy;
|
|
use codex_login::AuthManager;
|
|
use codex_login::CodexAuth;
|
|
use codex_model_provider::ProviderAuthContext;
|
|
use codex_model_provider_info::ModelProviderInfo;
|
|
use codex_models_manager::manager::RefreshStrategy;
|
|
use codex_models_manager::model_info::model_info_from_slug;
|
|
use codex_protocol::openai_models::ModelsResponse;
|
|
use codex_runtime_agent_instance::AgentHostAuthorization;
|
|
use codex_runtime_agent_instance::RuntimeAuthManager;
|
|
use codex_runtime_agent_instance::RuntimeRegistrationMetadata;
|
|
use codex_runtime_agent_instance::RuntimeRole;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::Value;
|
|
use serde_json::json;
|
|
use std::io::Read;
|
|
use std::io::Write;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
use tokio::net::TcpListener;
|
|
use tokio::process::Command;
|
|
use wiremock::Mock;
|
|
use wiremock::MockServer;
|
|
use wiremock::ResponseTemplate;
|
|
use wiremock::matchers::method;
|
|
use wiremock::matchers::path;
|
|
|
|
const CHILD_SERVER: &str = "CODEX_RUNTIME_CATALOG_TEST_SERVER";
|
|
const TEST_NAME: &str = "production_catalog_uses_runtime_auth_and_preserves_provider_isolation";
|
|
|
|
#[tokio::test]
|
|
async fn production_catalog_uses_runtime_auth_and_preserves_provider_isolation() {
|
|
if let Ok(server) = std::env::var(CHILD_SERVER) {
|
|
check_catalog(&server).await;
|
|
return;
|
|
}
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/register"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"runtime_agent_instance_id": "runtime-a", "agent_host_id": "host-a",
|
|
"actor": {"kind":"platform", "user_id":"user-a", "organization_id":"org-a", "project_id":null},
|
|
})))
|
|
.expect(2).mount(&server).await;
|
|
let catalog = ModelsResponse {
|
|
models: vec![model_info_from_slug("runtime-catalog-marker")],
|
|
};
|
|
for (url, count) in [("/backend-api/codex/models", 4), ("/models", 2)] {
|
|
Mock::given(method("GET"))
|
|
.and(path(url))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(&catalog))
|
|
.expect(count)
|
|
.mount(&server)
|
|
.await;
|
|
}
|
|
|
|
// Scope proxy and CA configuration to a child process, preserving the real OpenAI
|
|
// URL classification without changing environment variables used by other tests.
|
|
let (ca, tls) = tls_config();
|
|
let home = tempfile::tempdir().unwrap();
|
|
let ca_path = home.path().join("catalog-ca.pem");
|
|
std::fs::write(&ca_path, ca).unwrap();
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let proxy_url = format!("http://{}", listener.local_addr().unwrap());
|
|
let upstream = *server.address();
|
|
let proxy = tokio::spawn(async move {
|
|
for _ in 0..4 {
|
|
let (stream, _) = tokio::time::timeout(Duration::from_secs(30), listener.accept())
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let stream = stream.into_std().unwrap();
|
|
let tls = Arc::clone(&tls);
|
|
tokio::task::spawn_blocking(move || proxy_catalog(stream, tls, upstream))
|
|
.await
|
|
.unwrap();
|
|
}
|
|
});
|
|
let mut command = Command::new(std::env::current_exe().unwrap());
|
|
command
|
|
.arg("--exact")
|
|
.arg(TEST_NAME)
|
|
.arg("--nocapture")
|
|
.kill_on_drop(/*kill_on_drop*/ true);
|
|
for key in [
|
|
"HTTP_PROXY",
|
|
"http_proxy",
|
|
"HTTPS_PROXY",
|
|
"https_proxy",
|
|
"ALL_PROXY",
|
|
"all_proxy",
|
|
"NO_PROXY",
|
|
"no_proxy",
|
|
"OPENAI_API_KEY",
|
|
"OPENAI_PROJECT",
|
|
"OPENAI_ORGANIZATION",
|
|
"SSL_CERT_FILE",
|
|
"SSL_CERT_DIR",
|
|
] {
|
|
command.env_remove(key);
|
|
}
|
|
let output = tokio::time::timeout(
|
|
Duration::from_secs(30),
|
|
command
|
|
.env(CHILD_SERVER, server.uri())
|
|
.env("HTTPS_PROXY", proxy_url)
|
|
.env("NO_PROXY", "127.0.0.1,localhost")
|
|
.env("SSL_CERT_FILE", ca_path)
|
|
.output(),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
if !output.status.success() {
|
|
proxy.abort();
|
|
}
|
|
assert!(
|
|
output.status.success(),
|
|
"catalog child failed:\n{}\n{}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
proxy.await.unwrap();
|
|
|
|
let requests = server.received_requests().await.unwrap();
|
|
let catalogs: Vec<_> = requests
|
|
.iter()
|
|
.filter(|request| request.url.path() == "/backend-api/codex/models")
|
|
.collect();
|
|
assert_eq!(catalogs.len(), 4);
|
|
let mut proofs = std::collections::HashSet::new();
|
|
for request in catalogs {
|
|
let authorization = request.headers["authorization"].to_str().unwrap();
|
|
let proof = if let Some(proof) = request.headers.get("x-openai-agent-assertion") {
|
|
assert_eq!(authorization, "Bearer catalog-key");
|
|
proof.to_str().unwrap()
|
|
} else {
|
|
authorization.strip_prefix("AgentAssertion ").unwrap()
|
|
};
|
|
let payload: Value = serde_json::from_slice(
|
|
&base64::engine::general_purpose::URL_SAFE_NO_PAD
|
|
.decode(proof.split('.').nth(2).unwrap())
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(payload["runtime_agent_instance"], "runtime-a");
|
|
assert!(proofs.insert(payload["id"].as_str().unwrap().to_owned()));
|
|
}
|
|
assert_eq!(
|
|
requests
|
|
.iter()
|
|
.filter(|request| request.url.path() == "/backend-api/codex/models"
|
|
&& request.headers["originator"] == "codex_work_cca")
|
|
.count(),
|
|
2
|
|
);
|
|
for request in requests
|
|
.iter()
|
|
.filter(|request| request.url.path() == "/models")
|
|
{
|
|
assert_eq!(request.headers["authorization"], "Bearer catalog-key");
|
|
assert!(!request.headers.contains_key("x-openai-agent-assertion"));
|
|
}
|
|
server.verify().await;
|
|
}
|
|
|
|
async fn check_catalog(server: &str) {
|
|
for mode in [RuntimeAuthMode::DualHeader, RuntimeAuthMode::AssertionOnly] {
|
|
let credentials =
|
|
AuthManager::from_auth_for_testing(CodexAuth::from_api_key("catalog-key"));
|
|
let runtime = Arc::new(RuntimeAuthManager::new(
|
|
Arc::clone(&credentials),
|
|
AgentHostAuthorization::InstallationId {
|
|
installation_id: "00000000-0000-0000-0000-000000000000".parse().unwrap(),
|
|
},
|
|
RuntimeRegistrationMetadata {
|
|
harness: None,
|
|
harness_version: None,
|
|
role: RuntimeRole::Orchestrator,
|
|
},
|
|
format!("{server}/register").parse().unwrap(),
|
|
));
|
|
let context = ProviderAuthContext {
|
|
credentials,
|
|
runtime_auth: Some((runtime, mode)),
|
|
};
|
|
let info = ModelProviderInfo::create_openai_provider(/*base_url*/ None);
|
|
let provider = context.model_provider(info);
|
|
let selected = provider
|
|
.request_auth(/*scope*/ None)
|
|
.await
|
|
.unwrap()
|
|
.runtime
|
|
.unwrap();
|
|
assert_eq!(selected.runtime().id(), "runtime-a");
|
|
let models = provider.models_manager_without_cache(/*config_model_catalog*/ None);
|
|
models.set_api_key_model_discovery_enabled(/*enabled*/ true);
|
|
let work = models.for_originator("codex_work_cca").unwrap();
|
|
for manager in [models, work] {
|
|
let result = manager
|
|
.raw_model_catalog(
|
|
RefreshStrategy::Online,
|
|
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
|
|
)
|
|
.await;
|
|
assert!(
|
|
result
|
|
.models
|
|
.iter()
|
|
.any(|model| model.slug == "runtime-catalog-marker"),
|
|
"the returned catalog must contain the remote model"
|
|
);
|
|
}
|
|
let mut other_info = ModelProviderInfo::create_openai_provider(Some(server.to_owned()));
|
|
other_info.model_catalog_url = Some(format!("{server}/models").into());
|
|
let other = context.model_provider(other_info);
|
|
let models = other.models_manager_without_cache(/*config_model_catalog*/ None);
|
|
models.set_api_key_model_discovery_enabled(/*enabled*/ true);
|
|
let result = models
|
|
.raw_model_catalog(
|
|
RefreshStrategy::Online,
|
|
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
|
|
)
|
|
.await;
|
|
assert!(
|
|
result
|
|
.models
|
|
.iter()
|
|
.any(|model| model.slug == "runtime-catalog-marker"),
|
|
"the other provider must also receive its remote catalog"
|
|
);
|
|
}
|
|
}
|
|
|
|
fn tls_config() -> (String, Arc<rustls::ServerConfig>) {
|
|
codex_utils_rustls_provider::ensure_rustls_crypto_provider();
|
|
let mut ca_params = rcgen::CertificateParams::default();
|
|
ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
|
|
ca_params.key_usages = vec![rcgen::KeyUsagePurpose::KeyCertSign];
|
|
let ca = rcgen::CertifiedIssuer::self_signed(ca_params, rcgen::KeyPair::generate().unwrap())
|
|
.unwrap();
|
|
let mut leaf = rcgen::CertificateParams::new(vec!["chatgpt.com".into()]).unwrap();
|
|
leaf.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ServerAuth];
|
|
let key = rcgen::KeyPair::generate().unwrap();
|
|
let cert = leaf.signed_by(&key, &ca).unwrap();
|
|
let config = rustls::ServerConfig::builder()
|
|
.with_no_client_auth()
|
|
.with_single_cert(
|
|
vec![cert.der().clone()],
|
|
rustls::pki_types::PrivateKeyDer::from(key),
|
|
)
|
|
.unwrap();
|
|
(ca.pem(), Arc::new(config))
|
|
}
|
|
|
|
fn proxy_catalog(
|
|
mut stream: std::net::TcpStream,
|
|
config: Arc<rustls::ServerConfig>,
|
|
upstream: std::net::SocketAddr,
|
|
) {
|
|
stream.set_nonblocking(/*nonblocking*/ false).unwrap();
|
|
stream
|
|
.set_read_timeout(Some(Duration::from_secs(10)))
|
|
.unwrap();
|
|
stream
|
|
.set_write_timeout(Some(Duration::from_secs(10)))
|
|
.unwrap();
|
|
let connect = read_headers(&mut stream);
|
|
assert!(connect.starts_with(b"CONNECT chatgpt.com:443 "));
|
|
stream
|
|
.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")
|
|
.unwrap();
|
|
let mut tls = rustls::StreamOwned::new(rustls::ServerConnection::new(config).unwrap(), stream);
|
|
let mut headers = read_headers(&mut tls);
|
|
assert!(headers.starts_with(b"GET /backend-api/codex/models?"));
|
|
headers.truncate(headers.len() - 2);
|
|
headers.extend_from_slice(b"connection: close\r\n\r\n");
|
|
let mut origin = std::net::TcpStream::connect(upstream).unwrap();
|
|
origin
|
|
.set_read_timeout(Some(Duration::from_secs(10)))
|
|
.unwrap();
|
|
origin.write_all(&headers).unwrap();
|
|
let mut response = Vec::new();
|
|
origin.read_to_end(&mut response).unwrap();
|
|
tls.write_all(&response).unwrap();
|
|
tls.flush().unwrap();
|
|
}
|
|
|
|
fn read_headers(reader: &mut impl Read) -> Vec<u8> {
|
|
let mut headers = Vec::new();
|
|
while !headers.ends_with(b"\r\n\r\n") {
|
|
let mut byte = [0_u8];
|
|
reader.read_exact(&mut byte).unwrap();
|
|
headers.push(byte[0]);
|
|
assert!(headers.len() < 64 * 1024);
|
|
}
|
|
headers
|
|
}
|