mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +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
273 lines
9.4 KiB
Rust
273 lines
9.4 KiB
Rust
//! Exercise bootstrap and signing through a real HTTP server without changing process auth.
|
|
|
|
use super::*;
|
|
use crate::AgentHostAuthorization;
|
|
use crate::AuthenticatedActorAuthorization;
|
|
use crate::RuntimeAgentInstance;
|
|
use crate::RuntimeRegistrationMetadata;
|
|
use crate::RuntimeRole;
|
|
use base64::Engine;
|
|
use base64::engine::general_purpose::STANDARD;
|
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
|
use codex_http_client::DestinationPolicy;
|
|
use codex_http_client::NetworkPolicyController;
|
|
use codex_http_client::OutboundProxyPolicy;
|
|
use ed25519_dalek::Signature;
|
|
use ed25519_dalek::VerifyingKey;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::Value;
|
|
use serde_json::json;
|
|
use uuid::Uuid;
|
|
use wiremock::Mock;
|
|
use wiremock::MockServer;
|
|
use wiremock::ResponseTemplate;
|
|
use wiremock::matchers::method;
|
|
use wiremock::matchers::path;
|
|
|
|
fn pending() -> RuntimeRegistration {
|
|
RuntimeRegistration::new(
|
|
AuthenticatedActorAuthorization::PlatformApiKey {
|
|
selection: Default::default(),
|
|
credential: "actor-secret".into(),
|
|
},
|
|
AgentHostAuthorization::InstallationId {
|
|
installation_id: Uuid::nil(),
|
|
},
|
|
RuntimeRegistrationMetadata {
|
|
harness: Some("codex".into()),
|
|
harness_version: Some("1.0.0".into()),
|
|
role: RuntimeRole::OrchestratorAndExecutor,
|
|
},
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
fn client(server: &MockServer) -> RuntimeRegistrationClient {
|
|
let registration_url = Url::parse(DEFAULT_REGISTRATION_URL).unwrap();
|
|
let endpoint = Url::parse(&server.uri())
|
|
.unwrap()
|
|
.join(registration_url.path())
|
|
.unwrap();
|
|
RuntimeRegistrationClient::new(
|
|
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
|
|
endpoint,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
fn registered() -> Value {
|
|
json!({
|
|
"runtime_agent_instance_id": "agent-instance-test",
|
|
"agent_host_id": "host-test",
|
|
"actor": {
|
|
"kind": "platform",
|
|
"user_id": "user-test",
|
|
"organization_id": "org-test",
|
|
"project_id": "proj-test"
|
|
}
|
|
})
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn registration_retries_keep_the_key_and_sign_fresh_assertions() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/agent/register"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(registered()))
|
|
.expect(2)
|
|
.mount(&server)
|
|
.await;
|
|
let pending = pending();
|
|
let client = client(&server);
|
|
let info = client.register(&pending).await.unwrap();
|
|
let retry_info = client.register(&pending).await.unwrap();
|
|
assert_eq!(info, retry_info);
|
|
let runtime = pending.finish(info);
|
|
let retry = pending.finish(retry_info);
|
|
let requests = server.received_requests().await.unwrap();
|
|
assert_eq!(requests.len(), 2);
|
|
assert_eq!(requests[0].body, requests[1].body);
|
|
for request in &requests {
|
|
assert!(!request.headers.contains_key("authorization"));
|
|
assert!(!request.headers.contains_key("x-openai-agent-assertion"));
|
|
assert_eq!(
|
|
request.headers["idempotency-key"],
|
|
pending.initialization_id.to_string()
|
|
);
|
|
}
|
|
let request: Value = requests[0].body_json().unwrap();
|
|
assert_eq!(request, serde_json::to_value(&pending.request).unwrap());
|
|
let key = STANDARD
|
|
.decode(
|
|
request["public_key"]
|
|
.as_str()
|
|
.unwrap()
|
|
.split_once(' ')
|
|
.unwrap()
|
|
.1,
|
|
)
|
|
.unwrap();
|
|
let key = VerifyingKey::from_bytes(key[19..].try_into().unwrap()).unwrap();
|
|
let before = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_secs();
|
|
let first = verify_assertion(&runtime, &key);
|
|
let second = verify_assertion(&retry, &key);
|
|
let after = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_secs();
|
|
assert_ne!(first["id"], second["id"]);
|
|
for payload in [first, second] {
|
|
assert!(Uuid::parse_str(payload["id"].as_str().unwrap()).is_ok());
|
|
assert!((before..=after).contains(&payload["iat"].as_u64().unwrap()));
|
|
assert_eq!(
|
|
payload,
|
|
json!({
|
|
"version": 1, "runtime_agent_instance": "agent-instance-test",
|
|
"iat": payload["iat"], "id": payload["id"]
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
fn verify_assertion(runtime: &RuntimeAgentInstance, key: &VerifyingKey) -> Value {
|
|
let assertion = runtime.assertion().unwrap();
|
|
let parts: Vec<_> = assertion.split('.').collect();
|
|
assert_eq!(parts.len(), 4);
|
|
assert_eq!(parts[0], "1");
|
|
assert!(!assertion.contains('='));
|
|
let protected: Value =
|
|
serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[1]).unwrap()).unwrap();
|
|
assert_eq!(
|
|
protected,
|
|
json!({"alg": "EdDSA", "typ": "openai-agent-assertion+jws"})
|
|
);
|
|
let signature = Signature::from_slice(&URL_SAFE_NO_PAD.decode(parts[3]).unwrap()).unwrap();
|
|
key.verify_strict(format!("{}.{}", parts[1], parts[2]).as_bytes(), &signature)
|
|
.unwrap();
|
|
serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[2]).unwrap()).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn new_initializations_use_new_keys_and_retry_ids() {
|
|
let first = pending();
|
|
let second = pending();
|
|
assert_ne!(first.initialization_id, second.initialization_id);
|
|
assert_ne!(first.request.public_key, second.request.public_key);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rejected_registration_preserves_only_status_and_known_error_codes() {
|
|
for (status, code, expected) in [
|
|
(401, "token_expired", RegistrationErrorCode::TokenExpired),
|
|
(
|
|
403,
|
|
"runtime_instance_revoked",
|
|
RegistrationErrorCode::RuntimeInstanceRevoked,
|
|
),
|
|
(500, "secret-from-server", RegistrationErrorCode::Unknown),
|
|
] {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.respond_with(ResponseTemplate::new(status).set_body_json(json!({
|
|
"error": {"code": code, "message": "secret-from-server"}
|
|
})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
let error = client(&server).register(&pending()).await.unwrap_err();
|
|
assert!(!format!("{error} {error:?}").contains("secret-from-server"));
|
|
assert!(
|
|
matches!(error, RegistrationError::Rejected { status: actual, code: Some(actual_code) }
|
|
if actual.as_u16() == status && actual_code == expected)
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn registration_preserves_policy_revocation() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/agent/register"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(registered()))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
let controller = NetworkPolicyController::default();
|
|
let policy = controller.policy();
|
|
assert!(controller.publish(policy.revision(), DestinationPolicy::Unrestricted));
|
|
let client = RuntimeRegistrationClient::new(
|
|
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault)
|
|
.with_network_policy(policy.clone().for_current_account()),
|
|
format!("{}/agent/register", server.uri()).parse().unwrap(),
|
|
)
|
|
.unwrap();
|
|
let pending = pending();
|
|
client.register(&pending).await.unwrap();
|
|
|
|
policy.invalidate();
|
|
assert!(controller.publish(policy.revision(), DestinationPolicy::Unrestricted));
|
|
assert!(matches!(
|
|
client.register(&pending).await,
|
|
Err(RegistrationError::Policy(NetworkPolicyDenied::Revoked))
|
|
));
|
|
assert_eq!(server.received_requests().await.unwrap().len(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn registration_rejects_incomplete_or_oversized_responses() {
|
|
for body in [
|
|
json!({"runtime_agent_instance_id": "old-response"}).to_string(),
|
|
"x".repeat(MAX_BODY_BYTES + 1),
|
|
] {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(body))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
assert!(matches!(
|
|
client(&server).register(&pending()).await,
|
|
Err(RegistrationError::InvalidResponse)
|
|
));
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn registration_never_forwards_credentials_through_a_redirect() {
|
|
let server = MockServer::start().await;
|
|
let destination = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.respond_with(ResponseTemplate::new(307).insert_header("Location", destination.uri()))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
assert!(matches!(
|
|
client(&server).register(&pending()).await,
|
|
Err(RegistrationError::Rejected {
|
|
status: StatusCode::TEMPORARY_REDIRECT,
|
|
..
|
|
})
|
|
));
|
|
assert!(destination.received_requests().await.unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn registration_rejects_unsafe_endpoints() {
|
|
for endpoint in [
|
|
"http://auth.example.com/register",
|
|
"https://user:secret@auth.example.com/register",
|
|
"https://auth.example.com/register?token=secret",
|
|
] {
|
|
assert!(matches!(
|
|
RuntimeRegistrationClient::new(
|
|
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
|
|
endpoint.parse().unwrap(),
|
|
),
|
|
Err(RegistrationError::InvalidEndpoint)
|
|
));
|
|
}
|
|
}
|