feat: use run agent task auth for inference (#19051)

## Stack

This is PR 3 of the simplified HAI single-run-task stack:

- [#19047](https://github.com/openai/codex/pull/19047) Agent Identity
assertion and task-registration primitives, including the shared
run-task helper used by existing Agent Identity JWT auth.
- [#19049](https://github.com/openai/codex/pull/19049)
Disabled-by-default ChatGPT auth opt-in that provisions/reuses persisted
Agent Identity runtime auth and its single run task.
- [#19051](https://github.com/openai/codex/pull/19051) Run-scoped
provider auth that uses one backend-owned task id for first-party
inference and compaction requests.

[#19054](https://github.com/openai/codex/pull/19054) collapsed out of
the active stack because the simplified design no longer needs a
separate background/control-plane task helper.

## Summary

This PR moves Agent Identity usage into provider auth resolution. That
keeps `AgentAssertion` auth tied to first-party OpenAI provider requests
instead of applying a late session-wide override that could affect
local, custom, Bedrock, API-key, or external-bearer providers.

What changed:

- adds a small `ProviderAuthScope` struct carrying the run auth policy
and session source needed by provider-scoped auth resolution
- lets `Session` opt the existing `ModelClient` into `ChatGptAuth`
policy when `use_agent_identity` is enabled, without adding a second
model-client constructor
- resolves Agent Identity only for first-party OpenAI provider auth
paths
- uses the persisted run task id from the `AgentIdentityAuth` record to
build `AgentAssertion` auth for Responses requests
- routes shared request setup through scoped provider auth so unary
compact requests use the same run-task assertion path as inference turns
- keeps local/custom/Bedrock/env-key/external-bearer provider auth
unchanged
- lets missing run-task state surface through the existing model-request
error path instead of silently falling back to bearer auth

This PR intentionally does not create thread-scoped, target-scoped, or
background-scoped task identities. The run task is the only task Codex
registers in this POC shape.

## Testing

- `just test -p codex-model-provider`
- `just test -p codex-core client::tests::provider_auth_scope_uses`
- `just test -p codex-core remote_compact_uses_agent_identity_assertion`
This commit is contained in:
Adrian authored and GitHub committed 2026-06-24 22:31:41 -07:00
1 parent f66d793a2d
commit 51864b0b4b
22 files changed
+1020 -20

No files matched your search

+9 -8
View File
@@ -46,7 +46,7 @@ pub(super) fn require_agent_identity_authapi_base_url(
})
}
#[derive(Debug, Error)]
#[derive(Clone, Debug, Error)]
pub enum AgentIdentityAuthError {
#[error(
"agent identity bootstrap unavailable after {attempts} attempts during {operation}: {message}"
@@ -59,13 +59,14 @@ pub enum AgentIdentityAuthError {
}
impl AgentIdentityAuthError {
pub fn is_bootstrap_unavailable(error: &std::io::Error) -> bool {
matches!(
error
.get_ref()
.and_then(|source| source.downcast_ref::<Self>()),
Some(Self::BootstrapUnavailable { .. })
)
pub(super) fn bootstrap_unavailable(error: &std::io::Error) -> Option<&Self> {
match error
.get_ref()
.and_then(|source| source.downcast_ref::<Self>())
{
Some(error @ Self::BootstrapUnavailable { .. }) => Some(error),
None => None,
}
}
}
+4 -4
View File
@@ -584,7 +584,7 @@ async fn chatgpt_auth_registration_retry_exhaustion_is_fallback_eligible() -> an
.await
.expect_err("retry exhaustion should return an error");
assert!(AgentIdentityAuthError::is_bootstrap_unavailable(&err));
assert!(AgentIdentityAuthError::bootstrap_unavailable(&err).is_some());
assert!(
auth.stored_managed_chatgpt_agent_identity_record("account-123")
.is_none()
@@ -648,7 +648,7 @@ async fn chatgpt_auth_task_registration_retry_exhaustion_is_fallback_eligible()
.await
.expect_err("task retry exhaustion should return an error");
assert!(AgentIdentityAuthError::is_bootstrap_unavailable(&err));
assert!(AgentIdentityAuthError::bootstrap_unavailable(&err).is_some());
record.task_id = None;
assert_eq!(
auth.stored_managed_chatgpt_agent_identity_record("account-123"),
@@ -701,7 +701,7 @@ async fn chatgpt_auth_non_retryable_registration_error_is_hard_failure() -> anyh
.await
.expect_err("hard registration failure should return an error");
assert!(!AgentIdentityAuthError::is_bootstrap_unavailable(&err));
assert!(AgentIdentityAuthError::bootstrap_unavailable(&err).is_none());
assert!(
auth.stored_managed_chatgpt_agent_identity_record("account-123")
.is_none()
@@ -742,7 +742,7 @@ async fn agent_identity_jwt_task_registration_retry_exhaustion_is_strict() -> an
.await
.expect_err("agent identity jwt task retry exhaustion should fail");
assert!(!AgentIdentityAuthError::is_bootstrap_unavailable(&err));
assert!(AgentIdentityAuthError::bootstrap_unavailable(&err).is_none());
Ok(())
}
+96 -2
View File
@@ -15,6 +15,8 @@ use std::sync::Mutex;
use std::sync::RwLock;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
use std::time::Duration;
use std::time::Instant;
use tokio::sync::Semaphore;
use tokio::sync::watch;
use tracing::instrument;
@@ -83,6 +85,63 @@ pub enum AgentIdentityAuthPolicy {
ChatGptAuth,
}
const AGENT_IDENTITY_BOOTSTRAP_FAILURE_COOLDOWN: Duration = Duration::from_secs(60 * 60);
#[derive(Debug)]
struct CachedAgentIdentityBootstrapFailure {
account_id: String,
authapi_base_url: String,
retry_at: Instant,
error: AgentIdentityAuthError,
}
#[derive(Debug, Default)]
struct AgentIdentityBootstrapCooldown {
failure: Option<CachedAgentIdentityBootstrapFailure>,
}
impl AgentIdentityBootstrapCooldown {
fn error_for(
&mut self,
account_id: &str,
authapi_base_url: &str,
now: Instant,
) -> Option<AgentIdentityAuthError> {
let error = self
.failure
.as_ref()
.filter(|failure| {
failure.account_id == account_id
&& failure.authapi_base_url == authapi_base_url
&& failure.retry_at > now
})
.map(|failure| failure.error.clone());
if error.is_none() {
self.clear();
}
error
}
fn record_failure(
&mut self,
account_id: String,
authapi_base_url: String,
error: AgentIdentityAuthError,
now: Instant,
) {
self.failure = Some(CachedAgentIdentityBootstrapFailure {
account_id,
authapi_base_url,
retry_at: now + AGENT_IDENTITY_BOOTSTRAP_FAILURE_COOLDOWN,
error,
});
}
fn clear(&mut self) {
self.failure = None;
}
}
impl PartialEq for CodexAuth {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
@@ -1742,6 +1801,7 @@ pub struct AuthManager {
agent_identity_authapi_base_url: Option<String>,
refresh_lock: Semaphore,
agent_identity_lock: Semaphore,
agent_identity_bootstrap_cooldown: Mutex<AgentIdentityBootstrapCooldown>,
external_auth: RwLock<Option<Arc<dyn ExternalAuth>>>,
auth_route_config: Option<AuthRouteConfig>,
}
@@ -1843,6 +1903,7 @@ impl AuthManager {
agent_identity_authapi_base_url,
refresh_lock: Semaphore::new(/*permits*/ 1),
agent_identity_lock: Semaphore::new(/*permits*/ 1),
agent_identity_bootstrap_cooldown: Mutex::default(),
external_auth: RwLock::new(None),
auth_route_config,
}
@@ -1868,6 +1929,7 @@ impl AuthManager {
agent_identity_authapi_base_url: default_agent_identity_authapi_base_url(),
refresh_lock: Semaphore::new(/*permits*/ 1),
agent_identity_lock: Semaphore::new(/*permits*/ 1),
agent_identity_bootstrap_cooldown: Mutex::default(),
external_auth: RwLock::new(None),
auth_route_config: None,
})
@@ -1892,6 +1954,7 @@ impl AuthManager {
agent_identity_authapi_base_url: default_agent_identity_authapi_base_url(),
refresh_lock: Semaphore::new(/*permits*/ 1),
agent_identity_lock: Semaphore::new(/*permits*/ 1),
agent_identity_bootstrap_cooldown: Mutex::default(),
external_auth: RwLock::new(None),
auth_route_config: None,
})
@@ -1924,6 +1987,7 @@ impl AuthManager {
),
refresh_lock: Semaphore::new(/*permits*/ 1),
agent_identity_lock: Semaphore::new(/*permits*/ 1),
agent_identity_bootstrap_cooldown: Mutex::default(),
external_auth: RwLock::new(None),
auth_route_config: None,
})
@@ -1946,6 +2010,7 @@ impl AuthManager {
agent_identity_authapi_base_url: default_agent_identity_authapi_base_url(),
refresh_lock: Semaphore::new(/*permits*/ 1),
agent_identity_lock: Semaphore::new(/*permits*/ 1),
agent_identity_bootstrap_cooldown: Mutex::default(),
external_auth: RwLock::new(Some(
Arc::new(BearerTokenRefresher::new(config)) as Arc<dyn ExternalAuth>
)),
@@ -2006,15 +2071,44 @@ impl AuthManager {
.acquire()
.await
.map_err(std::io::Error::other)?;
return auth
let forced_chatgpt_workspace_id = self.forced_chatgpt_workspace_id();
let cooldown_key = ManagedChatGptAgentIdentityBinding::from_auth(
&auth,
forced_chatgpt_workspace_id.clone(),
)
.and_then(|binding| {
self.agent_identity_authapi_base_url
.as_ref()
.map(|base_url| (binding.account_id, base_url.clone()))
});
if let Some((account_id, authapi_base_url)) = cooldown_key.as_ref()
&& let Ok(mut cooldown) = self.agent_identity_bootstrap_cooldown.lock()
&& let Some(error) =
cooldown.error_for(account_id, authapi_base_url, Instant::now())
{
tracing::warn!("agent identity bootstrap retry suppressed during shared cooldown");
return Err(std::io::Error::other(error));
}
let result = auth
.agent_identity_auth(
policy,
self.agent_identity_authapi_base_url.as_deref(),
self.forced_chatgpt_workspace_id(),
forced_chatgpt_workspace_id,
self.auth_route_config.as_ref(),
session_source,
)
.await;
if let Ok(mut cooldown) = self.agent_identity_bootstrap_cooldown.lock() {
if let (Err(err), Some((account_id, authapi_base_url))) = (&result, cooldown_key)
&& let Some(error) = AgentIdentityAuthError::bootstrap_unavailable(err).cloned()
{
cooldown.record_failure(account_id, authapi_base_url, error, Instant::now());
} else {
cooldown.clear();
}
}
return result;
}
auth.agent_identity_auth(
policy,