Add ephemeral runtime registration and signed assertions (#55402)

## What changed

Extend `codex-runtime-agent-instance` with a registration client and an in-memory assertion signer. Pending registrations retain their signing key and idempotency key across caller-driven retries; registered runtimes generate fresh versioned assertions with a timestamp and unique ID.

Bound registration requests and responses, reject unsafe endpoints, disable redirects and request logging, and preserve network policy errors. Report server rejections using only the status and recognized error codes.

## Testing

Add HTTP-backed tests for retry stability, assertion signatures and freshness, policy revocation, endpoint and response validation, redirect refusal, and error redaction.

GitOrigin-RevId: a6dab0900459dfb75f72652a0a2ee1e4b65ca322
This commit is contained in:
cooper-oai
2026-10-19 23:01:54 +00:00
committed by copyberry
parent 431185b78b
commit 79a19cbb30
6 changed files with 564 additions and 3 deletions
+11
View File
@@ -4578,11 +4578,22 @@ dependencies = [
name = "codex-runtime-agent-instance"
version = "0.0.0"
dependencies = [
"base64 0.22.1",
"codex-agent-identity",
"codex-http-client",
"codex-utils-redacted-string",
"ed25519-dalek",
"http 1.4.0",
"pretty_assertions",
"rand 0.9.3",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"url",
"uuid",
"wiremock",
"zeroize",
]
[[package]]
+13 -2
View File
@@ -11,10 +11,21 @@ doctest = false
workspace = true
[dependencies]
base64 = { workspace = true }
codex-agent-identity = { workspace = true }
codex-http-client = { workspace = true }
codex-utils-redacted-string = { workspace = true }
ed25519-dalek = { workspace = true }
http = { workspace = true }
rand = { workspace = true }
serde = { workspace = true, features = ["derive"] }
uuid = { workspace = true, features = ["serde"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
url = { workspace = true }
uuid = { workspace = true, features = ["serde", "v4"] }
zeroize = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
wiremock = { workspace = true }
+10 -1
View File
@@ -1,6 +1,8 @@
//! OpenAI runtime registration types, independent of credential storage and model providers.
//! Ephemeral OpenAI runtimes: composite registration and per-request assertions.
mod bootstrap;
mod registration;
mod runtime;
pub use bootstrap::ActorInfo;
pub use bootstrap::AgentHostAuthorization;
@@ -9,3 +11,10 @@ pub use bootstrap::RuntimeRegistrationMetadata;
pub use bootstrap::RuntimeRegistrationRequest;
pub use bootstrap::RuntimeRegistrationResponse;
pub use bootstrap::RuntimeRole;
pub use registration::DEFAULT_REGISTRATION_URL;
pub use registration::RegistrationError;
pub use registration::RegistrationErrorCode;
pub use registration::RuntimeRegistrationClient;
pub use runtime::RuntimeAgentInstance;
pub use runtime::RuntimeRegistration;
@@ -0,0 +1,160 @@
//! Direct composite bootstrap. Credentials are sent only in the body, never through redirects.
use crate::RuntimeAgentInstance;
use crate::RuntimeRegistration;
use codex_http_client::ClientRouteClass;
use codex_http_client::HttpClientFactory;
use codex_http_client::HttpError;
use codex_http_client::NetworkPolicyDenied;
use codex_http_client::RouteAwareClientPool;
use codex_http_client::RouteAwareRequestError;
use http::StatusCode;
use serde::Deserialize;
use std::time::Duration;
use url::Url;
pub const DEFAULT_REGISTRATION_URL: &str = "https://auth.openai.com/agent/register";
const MAX_BODY_BYTES: usize = 64 * 1024;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
/// Makes one bounded attempt. Callers reuse the pending registration when retrying.
pub struct RuntimeRegistrationClient {
http: RouteAwareClientPool,
url: Url,
}
impl RuntimeRegistrationClient {
pub fn new(factory: HttpClientFactory, url: Url) -> Result<Self, RegistrationError> {
let loopback = url.host_str().is_some_and(|host| {
host == "localhost"
|| host == "[::1]"
|| host
.parse::<std::net::IpAddr>()
.is_ok_and(|ip| ip.is_loopback())
});
if !(url.scheme() == "https" || (url.scheme() == "http" && loopback))
|| !url.username().is_empty()
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
{
return Err(RegistrationError::InvalidEndpoint);
}
Ok(Self {
http: RouteAwareClientPool::with_chatgpt_cloudflare_cookies_without_redirects_or_request_logging(
factory,
ClientRouteClass::Auth,
),
url,
})
}
pub async fn register(
&self,
registration: &RuntimeRegistration,
) -> Result<RuntimeAgentInstance, RegistrationError> {
let body = serde_json::to_vec(&registration.request)
.map_err(|_| RegistrationError::InvalidRequest)?;
if body.len() > MAX_BODY_BYTES {
return Err(RegistrationError::InvalidRequest);
}
let mut response = self
.http
.post(self.url.as_str())
.header("Content-Type", "application/json")
.header(
"Idempotency-Key",
registration.initialization_id.to_string(),
)
.timeout(REQUEST_TIMEOUT)
.body(body)
.send()
.await
.map_err(|error| match error {
RouteAwareRequestError::Policy(denied) => RegistrationError::Policy(denied),
_ => RegistrationError::Transport,
})?;
let status = response.status();
let mut body = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(|error| match error {
HttpError::Policy(denied) => RegistrationError::Policy(denied),
_ => RegistrationError::Transport,
})? {
if chunk.len() > MAX_BODY_BYTES - body.len() {
return Err(RegistrationError::InvalidResponse);
}
body.extend_from_slice(&chunk);
}
if status != StatusCode::OK {
let code = serde_json::from_slice::<ErrorEnvelope>(&body)
.ok()
.map(|e| e.error.code);
return Err(RegistrationError::Rejected { status, code });
}
let info: crate::RuntimeRegistrationResponse =
serde_json::from_slice(&body).map_err(|_| RegistrationError::InvalidResponse)?;
if info.runtime_agent_instance_id.is_empty() || info.agent_host_id.is_empty() {
return Err(RegistrationError::InvalidResponse);
}
Ok(RuntimeAgentInstance {
info,
signing_key: registration.signing_key.clone(),
})
}
}
/// Errors intentionally omit raw transport details and server messages, which can contain secrets.
#[derive(Debug, thiserror::Error)]
pub enum RegistrationError {
#[error(
"runtime registration requires a secure endpoint without credentials or query parameters"
)]
InvalidEndpoint,
#[error("invalid runtime registration request")]
InvalidRequest,
#[error(transparent)]
Policy(#[from] NetworkPolicyDenied),
#[error("runtime registration transport failed")]
Transport,
#[error("invalid runtime registration response")]
InvalidResponse,
#[error("runtime registration rejected ({status}, {code:?})")]
Rejected {
status: StatusCode,
code: Option<RegistrationErrorCode>,
},
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RegistrationErrorCode {
TokenExpired,
RuntimeActorInvalid,
RuntimeInstanceRevoked,
RuntimeActorSourceInactive,
RuntimeInputInvalid,
RuntimeIdempotencyKeyInvalid,
RuntimeActorConflict,
RuntimeHostConflict,
RuntimeRegistrationConflict,
RuntimeInputTooLarge,
RuntimeRateLimited,
RuntimeVerificationUnavailable,
NotFound,
#[serde(other)]
Unknown,
}
#[derive(Deserialize)]
struct ErrorEnvelope {
error: ErrorDetail,
}
#[derive(Deserialize)]
struct ErrorDetail {
code: RegistrationErrorCode,
}
#[cfg(test)]
#[path = "registration_tests.rs"]
mod tests;
@@ -0,0 +1,268 @@
//! Exercise bootstrap and signing through a real HTTP server without changing process auth.
use super::*;
use crate::AgentHostAuthorization;
use crate::AuthenticatedActorAuthorization;
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 {
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 runtime = client.register(&pending).await.unwrap();
let retry = client.register(&pending).await.unwrap();
assert_eq!(runtime.info(), 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)
));
}
}
@@ -0,0 +1,102 @@
//! A pending registration keeps its key and retry ID; a registered runtime signs v1 assertions.
use crate::RuntimeRegistrationRequest;
use crate::RuntimeRegistrationResponse;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use codex_agent_identity::encode_ssh_ed25519_public_key;
use codex_utils_redacted_string::RedactedString;
use ed25519_dalek::Signer;
use ed25519_dalek::SigningKey;
use rand::TryRngCore;
use rand::rngs::OsRng;
use serde_json::json;
use std::fmt;
use std::io;
use std::sync::Arc;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
use uuid::Uuid;
use zeroize::Zeroizing;
/// One initialization, including retries. Neither this key nor the resulting runtime is persisted.
pub struct RuntimeRegistration {
pub(crate) initialization_id: Uuid,
pub(crate) request: RuntimeRegistrationRequest,
pub(crate) signing_key: Arc<SigningKey>,
}
impl RuntimeRegistration {
pub fn new(
actor: crate::AuthenticatedActorAuthorization,
host: crate::AgentHostAuthorization,
metadata: crate::RuntimeRegistrationMetadata,
) -> io::Result<Self> {
let mut seed = Zeroizing::new([0u8; 32]);
OsRng
.try_fill_bytes(seed.as_mut())
.map_err(|_| io::Error::other("failed to generate runtime signing key"))?;
let signing_key = SigningKey::from_bytes(&seed);
Ok(Self {
initialization_id: Uuid::new_v4(),
request: RuntimeRegistrationRequest {
actor_auth: actor,
agent_host_auth: host,
public_key: encode_ssh_ed25519_public_key(&signing_key.verifying_key()),
metadata,
},
signing_key: Arc::new(signing_key),
})
}
}
/// A server-registered identity and its memory-only signer, shared for this runtime's lifetime.
pub struct RuntimeAgentInstance {
pub(crate) info: RuntimeRegistrationResponse,
pub(crate) signing_key: Arc<SigningKey>,
}
impl RuntimeAgentInstance {
pub fn info(&self) -> &RuntimeRegistrationResponse {
&self.info
}
/// Make a fresh assertion for each request attempt or connection handshake.
pub fn assertion(&self) -> io::Result<RedactedString> {
let issued_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| io::Error::other("system clock is before the Unix epoch"))?
.as_secs();
let protected =
URL_SAFE_NO_PAD.encode(br#"{"alg":"EdDSA","typ":"openai-agent-assertion+jws"}"#);
let payload = URL_SAFE_NO_PAD.encode(
json!({
"version": 1,
"runtime_agent_instance": self.info.runtime_agent_instance_id,
"iat": issued_at,
"id": Uuid::new_v4(),
})
.to_string(),
);
let signing_input = format!("{protected}.{payload}");
let signature =
URL_SAFE_NO_PAD.encode(self.signing_key.sign(signing_input.as_bytes()).to_bytes());
// The outer version routes the assertion; it is not part of the JWS signing input.
let assertion = format!("1.{signing_input}.{signature}");
if assertion.len() > 8192 {
return Err(io::Error::other("runtime assertion exceeds the size limit"));
}
Ok(assertion.into())
}
}
impl fmt::Debug for RuntimeAgentInstance {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RuntimeAgentInstance")
.field(
"runtime_agent_instance_id",
&self.info.runtime_agent_instance_id,
)
.finish_non_exhaustive()
}
}