Add runtime assertion authentication modes and an auth provider (#55636)

## What changed

Add `RuntimeAuthProvider` to resolve request authentication through the runtime auth manager. Support `dual_header` mode, which sends a bearer credential alongside `x-openai-agent-assertion`, and `assertion_only` mode, which uses `Authorization: AgentAssertion …`.

Replace stale authentication headers and sign a fresh assertion for each request attempt. Propagate registration failures without falling back to credential-only authentication, preserving policy errors and classifying transport failures, rate limits, and server errors as transient.

Add the disabled-by-default `features.runtime_agent_instances` configuration with independent `enabled` and `mode` settings, schema support, and structured override merging. Selecting a mode alone does not enable the feature.

## Testing

Add coverage for both modes with API key and ChatGPT credentials, stale header replacement, fresh assertions, credential refresh without runtime replacement, registration failures, and configuration parsing and merging.

GitOrigin-RevId: 9e93d25762600f0ff592c5248fe4c8c2c8182c8d
This commit is contained in:
cooper-oai
2026-10-20 17:29:40 +00:00
committed by copyberry
parent a13ab7e4ed
commit cb00a67569
12 changed files with 444 additions and 39 deletions
+132 -1
View File
@@ -1,11 +1,27 @@
//! Credentials and transport selected with the runtime for one request.
//! Apply runtime auth from the selected request credentials. No credential-only fallback is allowed.
use crate::ActorInfo;
use crate::RegistrationError;
use crate::RuntimeAgentInstance;
use crate::RuntimeAuthError;
use crate::RuntimeAuthManager;
use codex_api::AuthError;
use codex_api::AuthHeadersFuture;
use codex_api::AuthProvider;
use codex_api::AuthProviderFuture;
use codex_features::RuntimeAuthMode;
use codex_http_client::HttpClientFactory;
use codex_http_client::Request;
use codex_login::AuthChangeState;
use codex_login::CodexAuth;
use http::HeaderMap;
use http::HeaderValue;
use http::StatusCode;
use http::header::AUTHORIZATION;
use std::sync::Arc;
const AGENT_ASSERTION: &str = "x-openai-agent-assertion";
/// Request-scoped credentials and transport selected with their matching runtime.
#[derive(Clone)]
pub struct RuntimeAuth {
@@ -25,6 +41,50 @@ impl std::fmt::Debug for RuntimeAuth {
}
}
/// Auth adapter for OpenAI clients. Share its manager across requests and threads;
/// never select this provider for a third-party endpoint.
pub struct RuntimeAuthProvider {
manager: Arc<RuntimeAuthManager>,
mode: RuntimeAuthMode,
}
impl RuntimeAuthProvider {
pub fn new(manager: Arc<RuntimeAuthManager>, mode: RuntimeAuthMode) -> Self {
Self { manager, mode }
}
}
impl AuthProvider for RuntimeAuthProvider {
fn add_auth_headers(&self, _headers: &mut HeaderMap) -> Result<(), AuthError> {
Err(AuthError::Build(
"authentication requires async header resolution".to_string(),
))
}
fn resolve_auth_headers(&self) -> AuthHeadersFuture<'_> {
Box::pin(async move {
let auth = self.manager.auth().await?;
auth.auth_headers(self.mode)
})
}
fn apply_auth(&self, mut request: Request) -> AuthProviderFuture<'_> {
Box::pin(async move {
let headers = self.resolve_auth_headers().await?;
for name in [
AUTHORIZATION.as_str(),
AGENT_ASSERTION,
"chatgpt-account-id",
"x-openai-fedramp",
] {
request.headers.remove(name);
}
request.headers.extend(headers);
Ok(request)
})
}
}
impl RuntimeAuth {
pub fn runtime(&self) -> &Arc<RuntimeAgentInstance> {
&self.runtime
@@ -41,4 +101,75 @@ impl RuntimeAuth {
pub fn change_state(&self) -> AuthChangeState {
self.change_state
}
fn auth_headers(&self, mode: RuntimeAuthMode) -> Result<HeaderMap, AuthError> {
let assertion = self
.runtime()
.assertion()
.map_err(|_| AuthError::Build("failed to sign runtime assertion".to_string()))?;
let mut headers = HeaderMap::new();
match mode {
RuntimeAuthMode::DualHeader => {
let token = self.auth().get_token().map_err(|_| {
AuthError::Build("runtime actor credential is unavailable".to_string())
})?;
headers.insert(AUTHORIZATION, secret_header(&format!("Bearer {token}"))?);
headers.insert(AGENT_ASSERTION, secret_header(assertion.as_str())?);
}
RuntimeAuthMode::AssertionOnly => {
headers.insert(
AUTHORIZATION,
secret_header(&format!("AgentAssertion {}", assertion.as_str()))?,
);
}
}
if let ActorInfo::Chatgpt { account_id, .. } = self.runtime().actor().info() {
headers.insert(
"chatgpt-account-id",
HeaderValue::from_str(account_id)
.map_err(|_| AuthError::Build("invalid runtime account header".to_string()))?,
);
}
if self.auth().is_fedramp_account() {
headers.insert("x-openai-fedramp", HeaderValue::from_static("true"));
}
Ok(headers)
}
}
fn secret_header(value: &str) -> Result<HeaderValue, AuthError> {
let mut header = HeaderValue::from_str(value)
.map_err(|_| AuthError::Build("invalid runtime authentication header".to_string()))?;
header.set_sensitive(true);
Ok(header)
}
impl From<RuntimeAuthError> for AuthError {
fn from(error: RuntimeAuthError) -> Self {
let transient = match &error {
RuntimeAuthError::Policy(denied)
| RuntimeAuthError::Registration(RegistrationError::Policy(denied)) => {
return Self::Policy(*denied);
}
RuntimeAuthError::Registration(RegistrationError::Transport) => true,
RuntimeAuthError::Registration(RegistrationError::Rejected { status, .. }) => {
status.is_server_error() || *status == StatusCode::TOO_MANY_REQUESTS
}
RuntimeAuthError::Unavailable
| RuntimeAuthError::Unsupported
| RuntimeAuthError::Changed
| RuntimeAuthError::RefreshFailed
| RuntimeAuthError::KeyGeneration
| RuntimeAuthError::Registration(
RegistrationError::InvalidEndpoint
| RegistrationError::InvalidRequest
| RegistrationError::InvalidResponse,
) => false,
};
if transient {
Self::Transient(error.to_string())
} else {
Self::Build(error.to_string())
}
}
}
@@ -9,6 +9,7 @@ mod runtime;
pub use actor::AgentHost;
pub use actor::AuthenticatedActor;
pub use auth::RuntimeAuthProvider;
pub use bootstrap::ActorInfo;
pub use bootstrap::AgentHostAuthorization;
pub use bootstrap::AuthenticatedActorAuthorization;
@@ -1,8 +1,12 @@
//! Exercise shared bootstrap against HTTP and the existing external credential-provider boundary.
use super::*;
use crate::RuntimeAuthProvider;
use crate::RuntimeRole;
use base64::Engine;
use codex_api::AuthError;
use codex_api::AuthProvider;
use codex_features::RuntimeAuthMode;
use codex_http_client::DestinationPolicy;
use codex_http_client::HttpClientFactory;
use codex_http_client::NetworkPolicyController;
@@ -13,6 +17,10 @@ use codex_login::AuthRouteConfig;
use codex_login::ExternalAuth;
use codex_login::ExternalAuthFuture;
use codex_login::ExternalAuthRefreshContext;
use http::HeaderMap;
use http::HeaderValue;
use http::Method;
use http::header::AUTHORIZATION;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
@@ -177,7 +185,7 @@ async fn policy_recovery_preserves_pending_registration() {
.await
.unwrap();
assert!(controller.publish(policy.revision(), DestinationPolicy::Unrestricted));
let manager = RuntimeAuthManager::new(
let manager = Arc::new(RuntimeAuthManager::new(
credentials,
AgentHostAuthorization::InstallationId {
installation_id: Uuid::nil(),
@@ -188,25 +196,24 @@ async fn policy_recovery_preserves_pending_registration() {
role: RuntimeRole::OrchestratorAndExecutor,
},
format!("{}/agent/register", server.uri()).parse().unwrap(),
);
));
let provider = RuntimeAuthProvider::new(manager.clone(), RuntimeAuthMode::DualHeader);
assert!(matches!(
manager.auth().await,
Err(RuntimeAuthError::Registration(
RegistrationError::Rejected {
status: StatusCode::SERVICE_UNAVAILABLE,
..
}
))
provider.resolve_auth_headers().await,
Err(AuthError::Transient(_))
));
policy.invalidate();
assert!(matches!(
manager.auth().await,
Err(RuntimeAuthError::Policy(NetworkPolicyDenied::Unavailable))
provider.resolve_auth_headers().await,
Err(AuthError::Policy(NetworkPolicyDenied::Unavailable))
));
assert_eq!(server.received_requests().await.unwrap().len(), 1);
assert!(controller.publish(policy.revision(), DestinationPolicy::Unrestricted));
let headers = provider.resolve_auth_headers().await.unwrap();
assert_eq!(headers["authorization"], "Bearer key-a");
assert!(headers.contains_key("x-openai-agent-assertion"));
let selected = manager.auth().await.unwrap();
let requests = server.received_requests().await.unwrap();
assert_eq!(requests.len(), 2);
@@ -321,3 +328,128 @@ async fn a_late_registration_cannot_publish_the_previous_actor() {
requests[1].headers["idempotency-key"]
);
}
#[tokio::test]
async fn request_auth_replaces_stale_headers_and_signs_each_attempt() {
for mode in [RuntimeAuthMode::DualHeader, RuntimeAuthMode::AssertionOnly] {
for initial in [
CodexAuth::from_api_key("key-a"),
oauth(Some("login-a"), "token-a"),
] {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(registered)
.mount(&server)
.await;
let (manager, _, _) = setup(&server, initial.clone(), initial.clone()).await;
let provider = RuntimeAuthProvider::new(manager.clone(), mode);
let mut previous_assertion = None;
for _ in 0..2 {
let mut request = codex_http_client::Request::new(
Method::POST,
format!("{}/responses", server.uri()),
);
for name in [
"authorization",
"x-openai-agent-assertion",
"chatgpt-account-id",
"x-openai-fedramp",
] {
request
.headers
.insert(name, HeaderValue::from_static("stale"));
}
request
.headers
.insert("openai-project", HeaderValue::from_static("project-a"));
let mut headers = provider.apply_auth(request).await.unwrap().headers;
let mut expected = HeaderMap::new();
expected.insert("openai-project", HeaderValue::from_static("project-a"));
if initial.is_chatgpt_auth() {
expected.insert(
"chatgpt-account-id",
HeaderValue::from_static("workspace-a"),
);
}
let assertion = match mode {
RuntimeAuthMode::DualHeader => {
let bearer = headers.remove(AUTHORIZATION).unwrap();
assert!(bearer.is_sensitive());
assert_eq!(bearer, format!("Bearer {}", initial.get_token().unwrap()));
headers.remove("x-openai-agent-assertion").unwrap()
}
RuntimeAuthMode::AssertionOnly => headers.remove(AUTHORIZATION).unwrap(),
};
assert!(assertion.is_sensitive());
assert_eq!(headers, expected);
assert_ne!(previous_assertion.as_ref(), Some(&assertion));
let assertion_text = assertion.to_str().unwrap();
let assertion_text = match mode {
RuntimeAuthMode::DualHeader => assertion_text,
RuntimeAuthMode::AssertionOnly => {
assertion_text.strip_prefix("AgentAssertion ").unwrap()
}
};
let segments: Vec<_> = assertion_text.split('.').collect();
let claims: Value = serde_json::from_slice(
&base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(segments[2])
.unwrap(),
)
.unwrap();
assert_eq!(
claims["runtime_agent_instance"],
manager.auth().await.unwrap().runtime().id()
);
previous_assertion = Some(assertion);
}
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}
}
}
#[tokio::test]
async fn header_auth_uses_refreshed_credentials_without_replacing_the_runtime() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(registered)
.mount(&server)
.await;
let initial = oauth(Some("login-a"), "old");
let (manager, source, _) = setup(&server, initial.clone(), initial).await;
let provider = RuntimeAuthProvider::new(manager.clone(), RuntimeAuthMode::DualHeader);
let first = provider.resolve_auth_headers().await.unwrap();
let runtime = manager.auth().await.unwrap();
let refreshed = oauth(Some("login-a"), "new");
*source.current.write().unwrap() = refreshed.clone();
let next = provider.resolve_auth_headers().await.unwrap();
assert_eq!(
next[AUTHORIZATION],
format!("Bearer {}", refreshed.get_token().unwrap())
);
assert_ne!(first[AUTHORIZATION], next[AUTHORIZATION]);
assert!(Arc::ptr_eq(
runtime.runtime(),
manager.auth().await.unwrap().runtime()
));
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}
#[tokio::test]
async fn registration_failure_never_returns_credential_only_auth() {
for status in [403, 404, 429, 503] {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(status))
.mount(&server)
.await;
let initial = CodexAuth::from_api_key("key-a");
let (manager, _, _) = setup(&server, initial.clone(), initial).await;
let provider = RuntimeAuthProvider::new(manager, RuntimeAuthMode::DualHeader);
let error = provider.resolve_auth_headers().await.unwrap_err();
assert_eq!(
matches!(error, AuthError::Transient(_)),
status == 429 || status == 503
);
}
}