Use AgentAssertion downstream behind use_agent_identity

This commit is contained in:
adrian
2026-04-14 10:23:23 -07:00
parent 1176180367
commit 1991c2b300
10 changed files with 690 additions and 64 deletions
+40 -2
View File
@@ -178,13 +178,33 @@ struct UsageErrorBody {
pub struct CoreAuthProvider {
pub token: Option<String>,
pub account_id: Option<String>,
authorization_header_override: Option<String>,
}
impl CoreAuthProvider {
pub fn from_bearer_token(token: Option<String>, account_id: Option<String>) -> Self {
Self {
token,
account_id,
authorization_header_override: None,
}
}
pub fn from_authorization_header_value(
authorization_header_value: Option<String>,
account_id: Option<String>,
) -> Self {
Self {
token: None,
account_id,
authorization_header_override: authorization_header_value,
}
}
pub fn auth_header_attached(&self) -> bool {
self.token
self.authorization_header_value()
.as_ref()
.is_some_and(|token| http::HeaderValue::from_str(&format!("Bearer {token}")).is_ok())
.is_some_and(|value| http::HeaderValue::from_str(value).is_ok())
}
pub fn auth_header_name(&self) -> Option<&'static str> {
@@ -195,8 +215,20 @@ impl CoreAuthProvider {
Self {
token: token.map(str::to_string),
account_id: account_id.map(str::to_string),
authorization_header_override: None,
}
}
#[cfg(test)]
pub fn for_test_authorization_header(
authorization_header_value: Option<&str>,
account_id: Option<&str>,
) -> Self {
Self::from_authorization_header_value(
authorization_header_value.map(str::to_string),
account_id.map(str::to_string),
)
}
}
impl ApiAuthProvider for CoreAuthProvider {
@@ -204,6 +236,12 @@ impl ApiAuthProvider for CoreAuthProvider {
self.token.clone()
}
fn authorization_header_value(&self) -> Option<String> {
self.authorization_header_override
.clone()
.or_else(|| self.bearer_token().map(|token| format!("Bearer {token}")))
}
fn account_id(&self) -> Option<String> {
self.account_id.clone()
}
+15 -4
View File
@@ -133,11 +133,22 @@ fn map_api_error_extracts_identity_auth_details_from_headers() {
#[test]
fn core_auth_provider_reports_when_auth_header_will_attach() {
let auth = CoreAuthProvider {
token: Some("access-token".to_string()),
account_id: None,
};
let auth = CoreAuthProvider::from_bearer_token(Some("access-token".to_string()), None);
assert!(auth.auth_header_attached());
assert_eq!(auth.auth_header_name(), Some("authorization"));
}
#[test]
fn core_auth_provider_supports_non_bearer_authorization_headers() {
let auth =
CoreAuthProvider::for_test_authorization_header(Some("AgentAssertion opaque-token"), None);
assert!(auth.auth_header_attached());
assert_eq!(auth.auth_header_name(), Some("authorization"));
assert_eq!(auth.bearer_token(), None);
assert_eq!(
auth.authorization_header_value(),
Some("AgentAssertion opaque-token".to_string())
);
}
+5 -2
View File
@@ -9,14 +9,17 @@ use http::HeaderValue;
/// reach this interface.
pub trait AuthProvider: Send + Sync {
fn bearer_token(&self) -> Option<String>;
fn authorization_header_value(&self) -> Option<String> {
self.bearer_token().map(|token| format!("Bearer {token}"))
}
fn account_id(&self) -> Option<String> {
None
}
}
pub(crate) fn add_auth_headers_to_header_map<A: AuthProvider>(auth: &A, headers: &mut HeaderMap) {
if let Some(token) = auth.bearer_token()
&& let Ok(header) = HeaderValue::from_str(&format!("Bearer {token}"))
if let Some(authorization) = auth.authorization_header_value()
&& let Ok(header) = HeaderValue::from_str(&authorization)
{
let _ = headers.insert(http::header::AUTHORIZATION, header);
}
+35 -8
View File
@@ -27,12 +27,15 @@ use tracing::debug;
use tracing::info;
use tracing::warn;
use crate::config::Config;
mod assertion;
mod task_registration;
#[cfg(test)]
pub(crate) use assertion::AgentAssertionEnvelope;
pub(crate) use task_registration::RegisteredAgentTask;
use crate::config::Config;
const AGENT_REGISTRATION_TIMEOUT: Duration = Duration::from_secs(15);
const AGENT_IDENTITY_BISCUIT_TIMEOUT: Duration = Duration::from_secs(15);
@@ -334,7 +337,7 @@ impl AgentIdentityManager {
}
#[cfg(test)]
fn new_for_tests(
pub(crate) fn new_for_tests(
auth_manager: Arc<AuthManager>,
feature_enabled: bool,
chatgpt_base_url: String,
@@ -348,6 +351,30 @@ impl AgentIdentityManager {
ensure_lock: Arc::new(Mutex::new(())),
}
}
#[cfg(test)]
pub(crate) async fn seed_generated_identity_for_tests(
&self,
agent_runtime_id: &str,
) -> Result<StoredAgentIdentity> {
let (auth, binding) = self
.current_auth_binding()
.await
.context("test agent identity requires ChatGPT auth")?;
let key_material = generate_agent_key_material()?;
let stored_identity = StoredAgentIdentity {
binding_id: binding.binding_id.clone(),
chatgpt_account_id: binding.chatgpt_account_id.clone(),
chatgpt_user_id: binding.chatgpt_user_id.clone(),
agent_runtime_id: agent_runtime_id.to_string(),
private_key_pkcs8_base64: key_material.private_key_pkcs8_base64,
public_key_ssh: key_material.public_key_ssh,
registered_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
abom: self.abom.clone(),
};
self.store_identity(&auth, &stored_identity)?;
Ok(stored_identity)
}
}
impl StoredAgentIdentity {
@@ -577,7 +604,7 @@ mod tests {
.and(path("/v1/agent/register"))
.and(header("x-openai-authorization", "human-biscuit"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"agent_runtime_id": "agent_123",
"agent_runtime_id": "agent-123",
})))
.expect(1)
.mount(&server)
@@ -603,7 +630,7 @@ mod tests {
.unwrap()
.expect("identity should be reused");
assert_eq!(first.agent_runtime_id, "agent_123");
assert_eq!(first.agent_runtime_id, "agent-123");
assert_eq!(first, second);
assert_eq!(first.abom.agent_harness_id, "codex-cli");
assert_eq!(first.chatgpt_account_id, "account-123");
@@ -619,7 +646,7 @@ mod tests {
.and(path("/v1/agent/register"))
.and(header("x-openai-authorization", "human-biscuit"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"agent_runtime_id": "agent_456",
"agent_runtime_id": "agent-456",
})))
.expect(1)
.mount(&server)
@@ -650,11 +677,11 @@ mod tests {
.unwrap()
.expect("identity should be registered");
assert_eq!(stored.agent_runtime_id, "agent_456");
assert_eq!(stored.agent_runtime_id, "agent-456");
let persisted = auth
.get_agent_identity(&binding.chatgpt_account_id)
.expect("stored identity");
assert_eq!(persisted.agent_runtime_id, "agent_456");
assert_eq!(persisted.agent_runtime_id, "agent-456");
}
#[tokio::test]
@@ -0,0 +1,182 @@
use std::collections::BTreeMap;
use anyhow::Context;
use anyhow::Result;
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use ed25519_dalek::Signer as _;
use serde::Deserialize;
use serde::Serialize;
use tracing::debug;
use super::*;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) struct AgentAssertionEnvelope {
pub(crate) agent_runtime_id: String,
pub(crate) task_id: String,
pub(crate) timestamp: String,
pub(crate) signature: String,
}
impl AgentIdentityManager {
pub(crate) async fn authorization_header_for_task(
&self,
agent_task: &RegisteredAgentTask,
) -> Result<Option<String>> {
if !self.feature_enabled {
return Ok(None);
}
let Some(stored_identity) = self.ensure_registered_identity().await? else {
return Ok(None);
};
anyhow::ensure!(
stored_identity.agent_runtime_id == agent_task.agent_runtime_id,
"agent task runtime {} does not match stored agent identity {}",
agent_task.agent_runtime_id,
stored_identity.agent_runtime_id
);
let timestamp = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
let envelope = AgentAssertionEnvelope {
agent_runtime_id: agent_task.agent_runtime_id.clone(),
task_id: agent_task.task_id.clone(),
timestamp: timestamp.clone(),
signature: sign_agent_assertion_payload(&stored_identity, agent_task, &timestamp)?,
};
let serialized_assertion = serialize_agent_assertion(&envelope)?;
debug!(
agent_runtime_id = %envelope.agent_runtime_id,
task_id = %envelope.task_id,
"attaching agent assertion authorization to downstream request"
);
Ok(Some(format!("AgentAssertion {serialized_assertion}")))
}
}
fn sign_agent_assertion_payload(
stored_identity: &StoredAgentIdentity,
agent_task: &RegisteredAgentTask,
timestamp: &str,
) -> Result<String> {
let signing_key = stored_identity.signing_key()?;
let payload = format!(
"{}:{}:{timestamp}",
agent_task.agent_runtime_id, agent_task.task_id
);
Ok(BASE64_STANDARD.encode(signing_key.sign(payload.as_bytes()).to_bytes()))
}
fn serialize_agent_assertion(envelope: &AgentAssertionEnvelope) -> Result<String> {
let payload = serde_json::to_vec(&BTreeMap::from([
("agent_runtime_id", envelope.agent_runtime_id.as_str()),
("signature", envelope.signature.as_str()),
("task_id", envelope.task_id.as_str()),
("timestamp", envelope.timestamp.as_str()),
]))
.context("failed to serialize agent assertion envelope")?;
Ok(URL_SAFE_NO_PAD.encode(payload))
}
#[cfg(test)]
mod tests {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use ed25519_dalek::Signature;
use ed25519_dalek::Verifier as _;
use pretty_assertions::assert_eq;
use super::*;
#[tokio::test]
async fn authorization_header_for_task_skips_when_feature_is_disabled() {
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let manager = AgentIdentityManager::new_for_tests(
auth_manager,
/*feature_enabled*/ false,
"https://chatgpt.com/backend-api/".to_string(),
SessionSource::Cli,
);
let agent_task = RegisteredAgentTask {
binding_id: "chatgpt-account-account_id".to_string(),
chatgpt_account_id: "account_id".to_string(),
chatgpt_user_id: None,
agent_runtime_id: "agent-123".to_string(),
task_id: "task-123".to_string(),
registered_at: "2026-03-23T12:00:00Z".to_string(),
};
assert_eq!(
manager
.authorization_header_for_task(&agent_task)
.await
.unwrap(),
None
);
}
#[tokio::test]
async fn authorization_header_for_task_serializes_signed_agent_assertion() {
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let manager = AgentIdentityManager::new_for_tests(
auth_manager,
/*feature_enabled*/ true,
"https://chatgpt.com/backend-api/".to_string(),
SessionSource::Cli,
);
let stored_identity = manager
.seed_generated_identity_for_tests("agent-123")
.await
.expect("seed test identity");
let agent_task = RegisteredAgentTask {
binding_id: "chatgpt-account-account_id".to_string(),
chatgpt_account_id: "account_id".to_string(),
chatgpt_user_id: None,
agent_runtime_id: "agent-123".to_string(),
task_id: "task-123".to_string(),
registered_at: "2026-03-23T12:00:00Z".to_string(),
};
let header = manager
.authorization_header_for_task(&agent_task)
.await
.expect("build agent assertion")
.expect("header should exist");
let token = header
.strip_prefix("AgentAssertion ")
.expect("agent assertion scheme");
let payload = URL_SAFE_NO_PAD
.decode(token)
.expect("valid base64url payload");
let envelope: AgentAssertionEnvelope =
serde_json::from_slice(&payload).expect("valid assertion envelope");
assert_eq!(
envelope,
AgentAssertionEnvelope {
agent_runtime_id: "agent-123".to_string(),
task_id: "task-123".to_string(),
timestamp: envelope.timestamp.clone(),
signature: envelope.signature.clone(),
}
);
let signature_bytes = BASE64_STANDARD
.decode(&envelope.signature)
.expect("valid base64 signature");
let signature = Signature::from_slice(&signature_bytes).expect("valid signature bytes");
let signing_key = stored_identity.signing_key().expect("signing key");
signing_key
.verifying_key()
.verify(
format!(
"{}:{}:{}",
envelope.agent_runtime_id, envelope.task_id, envelope.timestamp
)
.as_bytes(),
&signature,
)
.expect("signature should verify");
}
}
+110 -15
View File
@@ -31,6 +31,8 @@ use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
use crate::agent_identity::AgentIdentityManager;
use crate::agent_identity::RegisteredAgentTask;
use codex_api::ApiError;
use codex_api::CompactClient as ApiCompactClient;
use codex_api::CompactionInput as ApiCompactionInput;
@@ -92,6 +94,7 @@ use tokio::sync::oneshot;
use tokio::sync::oneshot::error::TryRecvError;
use tokio_tungstenite::tungstenite::Error;
use tokio_tungstenite::tungstenite::Message;
use tracing::debug;
use tracing::instrument;
use tracing::trace;
use tracing::warn;
@@ -144,6 +147,7 @@ pub(crate) const WEBSOCKET_CONNECT_TIMEOUT: Duration =
#[derive(Debug)]
struct ModelClientState {
auth_manager: Option<Arc<AuthManager>>,
agent_identity_manager: Option<Arc<AgentIdentityManager>>,
conversation_id: ThreadId,
window_generation: AtomicU64,
installation_id: String,
@@ -211,6 +215,8 @@ pub struct ModelClient {
pub struct ModelClientSession {
client: ModelClient,
websocket_session: WebsocketSession,
agent_task: Option<RegisteredAgentTask>,
cache_websocket_session_on_drop: bool,
/// Turn state for sticky routing.
///
/// This is an `OnceLock` that stores the turn state value received from the server
@@ -306,6 +312,33 @@ impl ModelClient {
enable_request_compression: bool,
include_timing_metrics: bool,
beta_features_header: Option<String>,
) -> Self {
Self::new_with_agent_identity_manager(
auth_manager,
/*agent_identity_manager*/ None,
conversation_id,
installation_id,
provider,
session_source,
model_verbosity,
enable_request_compression,
include_timing_metrics,
beta_features_header,
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn new_with_agent_identity_manager(
auth_manager: Option<Arc<AuthManager>>,
agent_identity_manager: Option<Arc<AgentIdentityManager>>,
conversation_id: ThreadId,
installation_id: String,
provider: ModelProviderInfo,
session_source: SessionSource,
model_verbosity: Option<VerbosityConfig>,
enable_request_compression: bool,
include_timing_metrics: bool,
beta_features_header: Option<String>,
) -> Self {
let auth_manager = auth_manager_for_provider(auth_manager, &provider);
let codex_api_key_env_enabled = auth_manager
@@ -315,6 +348,7 @@ impl ModelClient {
Self {
state: Arc::new(ModelClientState {
auth_manager,
agent_identity_manager,
conversation_id,
window_generation: AtomicU64::new(0),
installation_id,
@@ -336,9 +370,25 @@ impl ModelClient {
/// This constructor does not perform network I/O itself; the session opens a websocket lazily
/// when the first stream request is issued.
pub fn new_session(&self) -> ModelClientSession {
self.new_session_with_agent_task(None)
}
pub(crate) fn new_session_with_agent_task(
&self,
agent_task: Option<RegisteredAgentTask>,
) -> ModelClientSession {
let cache_websocket_session_on_drop = agent_task.is_none();
let websocket_session = if agent_task.is_some() {
drop(self.take_cached_websocket_session());
WebsocketSession::default()
} else {
self.take_cached_websocket_session()
};
ModelClientSession {
client: self.clone(),
websocket_session: self.take_cached_websocket_session(),
websocket_session,
agent_task,
cache_websocket_session_on_drop,
turn_state: Arc::new(OnceLock::new()),
}
}
@@ -421,7 +471,7 @@ impl ModelClient {
if prompt.input.is_empty() {
return Ok(Vec::new());
}
let client_setup = self.current_client_setup().await?;
let client_setup = self.current_client_setup(None).await?;
let transport = ReqwestTransport::new(build_reqwest_client());
let request_telemetry = Self::build_request_telemetry(
session_telemetry,
@@ -485,7 +535,7 @@ impl ModelClient {
) -> Result<RealtimeWebrtcCallStart> {
// Create the media call over HTTP first, then retain matching auth so realtime can attach
// the server-side control WebSocket to the call id from that HTTP response.
let client_setup = self.current_client_setup().await?;
let client_setup = self.current_client_setup(/*agent_task*/ None).await?;
let mut sideband_headers = extra_headers.clone();
sideband_headers.extend(sideband_websocket_auth_headers(&client_setup.api_auth));
let transport = ReqwestTransport::new(build_reqwest_client());
@@ -518,7 +568,7 @@ impl ModelClient {
return Ok(Vec::new());
}
let client_setup = self.current_client_setup().await?;
let client_setup = self.current_client_setup(None).await?;
let transport = ReqwestTransport::new(build_reqwest_client());
let request_telemetry = Self::build_request_telemetry(
session_telemetry,
@@ -659,7 +709,10 @@ impl ModelClient {
///
/// This centralizes setup used by both prewarm and normal request paths so they stay in
/// lockstep when auth/provider resolution changes.
async fn current_client_setup(&self) -> Result<CurrentClientSetup> {
async fn current_client_setup(
&self,
agent_task: Option<&RegisteredAgentTask>,
) -> Result<CurrentClientSetup> {
let auth = match self.state.auth_manager.as_ref() {
Some(manager) => manager.auth().await,
None => None,
@@ -668,7 +721,33 @@ impl ModelClient {
.state
.provider
.to_api_provider(auth.as_ref().map(CodexAuth::auth_mode))?;
let api_auth = auth_provider_from_auth(auth.clone(), &self.state.provider)?;
let api_auth = match (agent_task, self.state.agent_identity_manager.as_ref()) {
(Some(agent_task), Some(agent_identity_manager)) => {
if let Some(authorization_header_value) = agent_identity_manager
.authorization_header_for_task(agent_task)
.await
.map_err(|err| {
CodexErr::Stream(
format!("failed to build agent assertion authorization: {err}"),
None,
)
})?
{
debug!(
agent_runtime_id = %agent_task.agent_runtime_id,
task_id = %agent_task.task_id,
"using agent assertion authorization for downstream request"
);
CoreAuthProvider::from_authorization_header_value(
Some(authorization_header_value),
None,
)
} else {
auth_provider_from_auth(auth.clone(), &self.state.provider)?
}
}
_ => auth_provider_from_auth(auth.clone(), &self.state.provider)?,
};
Ok(CurrentClientSetup {
auth,
api_provider,
@@ -802,12 +881,18 @@ impl ModelClient {
impl Drop for ModelClientSession {
fn drop(&mut self) {
let websocket_session = std::mem::take(&mut self.websocket_session);
self.client
.store_cached_websocket_session(websocket_session);
if self.cache_websocket_session_on_drop {
self.client
.store_cached_websocket_session(websocket_session);
}
}
}
impl ModelClientSession {
pub(crate) fn disable_cached_websocket_session_on_drop(&mut self) {
self.cache_websocket_session_on_drop = false;
}
pub(crate) fn reset_websocket_session(&mut self) {
self.websocket_session.connection = None;
self.websocket_session.last_request = None;
@@ -1009,11 +1094,15 @@ impl ModelClientSession {
return Ok(());
}
let client_setup = self.client.current_client_setup().await.map_err(|err| {
ApiError::Stream(format!(
"failed to build websocket prewarm client setup: {err}"
))
})?;
let client_setup = self
.client
.current_client_setup(self.agent_task.as_ref())
.await
.map_err(|err| {
ApiError::Stream(format!(
"failed to build websocket prewarm client setup: {err}"
))
})?;
let auth_context = AuthRequestTelemetryContext::new(
client_setup.auth.as_ref().map(CodexAuth::auth_mode),
&client_setup.api_auth,
@@ -1167,7 +1256,10 @@ impl ModelClientSession {
.map(AuthManager::unauthorized_recovery);
let mut pending_retry = PendingUnauthorizedRetry::default();
loop {
let client_setup = self.client.current_client_setup().await?;
let client_setup = self
.client
.current_client_setup(self.agent_task.as_ref())
.await?;
let transport = ReqwestTransport::new(build_reqwest_client());
let request_auth_context = AuthRequestTelemetryContext::new(
client_setup.auth.as_ref().map(CodexAuth::auth_mode),
@@ -1256,7 +1348,10 @@ impl ModelClientSession {
.map(AuthManager::unauthorized_recovery);
let mut pending_retry = PendingUnauthorizedRetry::default();
loop {
let client_setup = self.client.current_client_setup().await?;
let client_setup = self
.client
.current_client_setup(self.agent_task.as_ref())
.await?;
let request_auth_context = AuthRequestTelemetryContext::new(
client_setup.auth.as_ref().map(CodexAuth::auth_mode),
&client_setup.api_auth,
+263
View File
@@ -1,3 +1,5 @@
use std::sync::Arc;
use super::AuthRequestTelemetryContext;
use super::ModelClient;
use super::PendingUnauthorizedRetry;
@@ -7,17 +9,39 @@ use super::X_CODEX_PARENT_THREAD_ID_HEADER;
use super::X_CODEX_TURN_METADATA_HEADER;
use super::X_CODEX_WINDOW_ID_HEADER;
use super::X_OPENAI_SUBAGENT_HEADER;
use crate::Prompt;
use crate::ResponseEvent;
use crate::agent_identity::AgentAssertionEnvelope;
use crate::agent_identity::AgentIdentityManager;
use crate::agent_identity::RegisteredAgentTask;
use crate::agent_identity::StoredAgentIdentity;
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use codex_api::CoreAuthProvider;
use codex_app_server_protocol::AuthMode;
use codex_keyring_store::tests::MockKeyringStore;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_model_provider_info::ModelProviderInfo;
use codex_model_provider_info::WireApi;
use codex_model_provider_info::create_oss_provider_with_base_url;
use codex_otel::SessionTelemetry;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_secrets::SecretsBackendKind;
use codex_secrets::SecretsManager;
use core_test_support::responses;
use ed25519_dalek::Signature;
use ed25519_dalek::Verifier as _;
use futures::StreamExt;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
fn test_model_client(session_source: SessionSource) -> ModelClient {
let provider = create_oss_provider_with_base_url("https://example.com/v1", WireApi::Responses);
@@ -79,6 +103,118 @@ fn test_session_telemetry() -> SessionTelemetry {
)
}
fn test_prompt(text: &str) -> Prompt {
Prompt {
input: vec![ResponseItem::Message {
id: None,
role: "user".into(),
content: vec![ContentItem::InputText {
text: text.to_string(),
}],
end_turn: None,
phase: None,
}],
..Prompt::default()
}
}
async fn drain_stream_to_completion(stream: &mut crate::ResponseStream) -> anyhow::Result<()> {
while let Some(event) = stream.next().await {
if matches!(event?, ResponseEvent::Completed { .. }) {
break;
}
}
Ok(())
}
async fn model_client_with_agent_task(
provider: ModelProviderInfo,
) -> (
TempDir,
ModelClient,
RegisteredAgentTask,
StoredAgentIdentity,
) {
let codex_home = tempfile::tempdir().expect("tempdir");
let keyring_store = Arc::new(MockKeyringStore::default());
let secrets_manager = SecretsManager::new_with_keyring_store(
codex_home.path().to_path_buf(),
SecretsBackendKind::Local,
keyring_store,
);
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let agent_identity_manager = Arc::new(AgentIdentityManager::new_for_tests(
Arc::clone(&auth_manager),
/*feature_enabled*/ true,
"https://chatgpt.com/backend-api/".to_string(),
SessionSource::Cli,
secrets_manager,
));
let stored_identity = agent_identity_manager
.seed_generated_identity_for_tests("agent-123")
.await
.expect("seed test identity");
let agent_task = RegisteredAgentTask {
agent_runtime_id: stored_identity.agent_runtime_id.clone(),
task_id: "task-123".to_string(),
registered_at: "2026-03-23T12:00:00Z".to_string(),
};
let client = ModelClient::new_with_agent_identity_manager(
Some(auth_manager),
Some(agent_identity_manager),
ThreadId::new(),
/*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(),
provider,
SessionSource::Cli,
None,
false,
false,
None,
);
(codex_home, client, agent_task, stored_identity)
}
fn assert_agent_assertion_header(
authorization_header: &str,
stored_identity: &StoredAgentIdentity,
expected_agent_runtime_id: &str,
expected_task_id: &str,
) {
let token = authorization_header
.strip_prefix("AgentAssertion ")
.expect("agent assertion authorization scheme");
let envelope: AgentAssertionEnvelope = serde_json::from_slice(
&URL_SAFE_NO_PAD
.decode(token)
.expect("base64url-encoded agent assertion"),
)
.expect("valid agent assertion envelope");
assert_eq!(envelope.agent_runtime_id, expected_agent_runtime_id);
assert_eq!(envelope.task_id, expected_task_id);
let signature = Signature::from_slice(
&base64::engine::general_purpose::STANDARD
.decode(&envelope.signature)
.expect("base64 signature"),
)
.expect("signature bytes");
stored_identity
.signing_key()
.expect("signing key")
.verifying_key()
.verify(
format!(
"{}:{}:{}",
envelope.agent_runtime_id, envelope.task_id, envelope.timestamp
)
.as_bytes(),
&signature,
)
.expect("signature should verify");
}
#[test]
fn build_subagent_headers_sets_other_subagent_label() {
let client = test_model_client(SessionSource::SubAgent(SubAgentSource::Other(
@@ -169,3 +305,130 @@ fn auth_request_telemetry_context_tracks_attached_auth_and_retry_phase() {
assert_eq!(auth_context.recovery_mode, Some("managed"));
assert_eq!(auth_context.recovery_phase, Some("refresh_token"));
}
#[tokio::test]
async fn responses_http_uses_agent_assertion_when_agent_task_is_present() {
core_test_support::skip_if_no_network!();
let server = responses::start_mock_server().await;
let request_recorder = responses::mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_completed("resp-1"),
]),
)
.await;
let provider =
create_oss_provider_with_base_url(&format!("{}/v1", server.uri()), WireApi::Responses);
let (_codex_home, client, agent_task, stored_identity) =
model_client_with_agent_task(provider).await;
let model_info = test_model_info();
let session_telemetry = test_session_telemetry();
let mut client_session = client.new_session_with_agent_task(Some(agent_task.clone()));
let mut stream = client_session
.stream(
&test_prompt("hello"),
&model_info,
&session_telemetry,
None,
ReasoningSummary::Auto,
None,
None,
)
.await
.expect("stream request should succeed");
drain_stream_to_completion(&mut stream)
.await
.expect("stream should complete");
let request = request_recorder.single_request();
let authorization = request
.header("authorization")
.expect("authorization header should be present");
assert_agent_assertion_header(
&authorization,
&stored_identity,
&agent_task.agent_runtime_id,
&agent_task.task_id,
);
assert_eq!(request.header("chatgpt-account-id"), None);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn websocket_agent_task_bypasses_cached_bearer_prewarm() {
core_test_support::skip_if_no_network!();
let server = responses::start_websocket_server(vec![
vec![vec![
responses::ev_response_created("resp-prewarm"),
responses::ev_completed("resp-prewarm"),
]],
vec![vec![
responses::ev_response_created("resp-1"),
responses::ev_completed("resp-1"),
]],
])
.await;
let mut provider =
create_oss_provider_with_base_url(&format!("{}/v1", server.uri()), WireApi::Responses);
provider.supports_websockets = true;
provider.websocket_connect_timeout_ms = Some(5_000);
let (_codex_home, client, agent_task, stored_identity) =
model_client_with_agent_task(provider).await;
let model_info = test_model_info();
let session_telemetry = test_session_telemetry();
let prompt = test_prompt("hello");
let mut prewarm_session = client.new_session();
prewarm_session
.prewarm_websocket(
&prompt,
&model_info,
&session_telemetry,
None,
ReasoningSummary::Auto,
None,
None,
)
.await
.expect("bearer prewarm should succeed");
drop(prewarm_session);
let mut agent_task_session = client.new_session_with_agent_task(Some(agent_task.clone()));
let mut stream = agent_task_session
.stream(
&prompt,
&model_info,
&session_telemetry,
None,
ReasoningSummary::Auto,
None,
None,
)
.await
.expect("agent task stream should succeed");
drain_stream_to_completion(&mut stream)
.await
.expect("agent task websocket stream should complete");
let handshakes = server.handshakes();
assert_eq!(handshakes.len(), 2);
assert_eq!(
handshakes[0].header("authorization"),
Some("Bearer Access Token".to_string())
);
let agent_authorization = handshakes[1]
.header("authorization")
.expect("agent handshake should include authorization");
assert_agent_assertion_header(
&agent_authorization,
&stored_identity,
&agent_task.agent_runtime_id,
&agent_task.task_id,
);
assert_eq!(handshakes[1].header("chatgpt-account-id"), None);
server.shutdown().await;
}
+31 -13
View File
@@ -2185,6 +2185,11 @@ impl Session {
config.analytics_enabled,
)
});
let agent_identity_manager = Arc::new(AgentIdentityManager::new(
config.as_ref(),
Arc::clone(&auth_manager),
session_configuration.session_source.clone(),
));
let services = SessionServices {
// Initialize the MCP connection manager with an uninitialized
// instance. It will be replaced with one created via
@@ -2207,11 +2212,7 @@ impl Session {
hooks,
rollout: Mutex::new(rollout_recorder),
user_shell: Arc::new(default_shell),
agent_identity_manager: Arc::new(AgentIdentityManager::new(
config.as_ref(),
Arc::clone(&auth_manager),
session_configuration.session_source.clone(),
)),
agent_identity_manager: Arc::clone(&agent_identity_manager),
shell_snapshot_tx,
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
exec_policy,
@@ -2228,8 +2229,9 @@ impl Session {
network_proxy,
network_approval: Arc::clone(&network_approval),
state_db: state_db_ctx.clone(),
model_client: ModelClient::new(
model_client: ModelClient::new_with_agent_identity_manager(
Some(Arc::clone(&auth_manager)),
Some(agent_identity_manager),
conversation_id,
installation_id,
session_configuration.provider.clone(),
@@ -6373,11 +6375,14 @@ pub(crate) async fn run_turn(
}))
.await;
}
if let Err(error) = sess.ensure_agent_task_registered().await {
warn!(error = %error, "agent task registration failed");
sess.fail_agent_identity_registration(error).await;
return None;
}
let agent_task = match sess.ensure_agent_task_registered().await {
Ok(agent_task) => agent_task,
Err(error) => {
warn!(error = %error, "agent task registration failed");
sess.fail_agent_identity_registration(error).await;
return None;
}
};
if !skill_items.is_empty() {
sess.record_conversation_items(&turn_context, &skill_items)
@@ -6400,8 +6405,21 @@ pub(crate) async fn run_turn(
// `ModelClientSession` is turn-scoped and caches WebSocket + sticky routing state, so we reuse
// one instance across retries within this turn.
let mut client_session =
prewarmed_client_session.unwrap_or_else(|| sess.services.model_client.new_session());
let mut prewarmed_client_session = prewarmed_client_session;
if agent_task.is_some()
&& let Some(prewarmed_client_session) = prewarmed_client_session.as_mut()
{
prewarmed_client_session.disable_cached_websocket_session_on_drop();
}
let mut client_session = if let Some(agent_task) = agent_task {
sess.services
.model_client
.new_session_with_agent_task(Some(agent_task))
} else if let Some(prewarmed_client_session) = prewarmed_client_session.take() {
prewarmed_client_session
} else {
sess.services.model_client.new_session()
};
// Pending input is drained into history before building the next model request.
// However, we defer that drain until after sampling in two cases:
// 1. At the start of a turn, so the fresh user prompt in `input` gets sampled first.
+2 -4
View File
@@ -112,10 +112,8 @@ async fn build_uploaded_local_argument_value(
let token_data = auth
.get_token_data()
.map_err(|error| format!("failed to read ChatGPT auth for file upload: {error}"))?;
let upload_auth = CoreAuthProvider {
token: Some(token_data.access_token),
account_id: token_data.account_id,
};
let upload_auth =
CoreAuthProvider::from_bearer_token(Some(token_data.access_token), token_data.account_id);
let uploaded = upload_local_file(
turn_context.config.chatgpt_base_url.trim_end_matches('/'),
&upload_auth,
+7 -16
View File
@@ -8,29 +8,20 @@ pub fn auth_provider_from_auth(
provider: &ModelProviderInfo,
) -> codex_protocol::error::Result<CoreAuthProvider> {
if let Some(api_key) = provider.api_key()? {
return Ok(CoreAuthProvider {
token: Some(api_key),
account_id: None,
});
return Ok(CoreAuthProvider::from_bearer_token(Some(api_key), None));
}
if let Some(token) = provider.experimental_bearer_token.clone() {
return Ok(CoreAuthProvider {
token: Some(token),
account_id: None,
});
return Ok(CoreAuthProvider::from_bearer_token(Some(token), None));
}
if let Some(auth) = auth {
let token = auth.get_token()?;
Ok(CoreAuthProvider {
token: Some(token),
account_id: auth.get_account_id(),
})
Ok(CoreAuthProvider::from_bearer_token(
Some(token),
auth.get_account_id(),
))
} else {
Ok(CoreAuthProvider {
token: None,
account_id: None,
})
Ok(CoreAuthProvider::from_bearer_token(None, None))
}
}