[codex] Use background agent task auth for backend calls (#18094)

## Summary

Introduces a single background/control-plane agent task for ChatGPT
backend requests that do not have a thread-scoped task, with
`AuthManager` owning the default ChatGPT backend authorization decision.

Callers now ask `AuthManager` for the default ChatGPT backend
authorization header. `AuthManager` decides whether that is bearer or
background AgentAssertion based on config/internal state, while
low-level bootstrap paths can explicitly request bearer-only auth.

This PR is stacked on PR4 and focuses on the shared background task auth
plumbing plus the first tranche of backend/control-plane consumers. The
remaining callsite wiring is split into PR4.2 to keep review size down.

## Stack

- PR1: https://github.com/openai/codex/pull/17385 - add
`features.use_agent_identity`
- PR2: https://github.com/openai/codex/pull/17386 - register agent
identities when enabled
- PR3: https://github.com/openai/codex/pull/17387 - register agent tasks
when enabled
- PR3.1: https://github.com/openai/codex/pull/17978 - persist and
prewarm registered tasks per thread
- PR4: https://github.com/openai/codex/pull/17980 - use task-scoped
`AgentAssertion` for downstream calls
- PR4.1: this PR - introduce AuthManager-owned background/control-plane
`AgentAssertion` auth
- PR4.2: https://github.com/openai/codex/pull/18260 - use background
task auth for additional backend/control-plane calls

## What Changed

- add background task registration and assertion minting inside
`codex-login`
- persist `agent_identity.background_task_id` separately from
per-session task state
- make `BackgroundAgentTaskManager` private to `codex-login`; call sites
do not instantiate or pass it around
- teach `AuthManager` the ChatGPT backend base URL and feature-derived
background auth mode from resolved config
- expose bearer-only helpers for bootstrap/registration/refresh-style
paths that must not use AgentAssertion
- wire `AuthManager` default ChatGPT authorization through app listing,
connector directory listing, remote plugins, MCP status/listing,
analytics, and core-skills remote calls
- preserve bearer fallback when the feature is disabled, the backend
host is unsupported, or background task registration is not available

## Validation

- `just fmt`
- `cargo check -p codex-core -p codex-login -p codex-analytics -p
codex-app-server -p codex-cloud-requirements -p codex-cloud-tasks -p
codex-models-manager -p codex-chatgpt -p codex-model-provider -p
codex-mcp -p codex-core-skills`
- `cargo test -p codex-login agent_identity`
- `cargo test -p codex-model-provider bearer_auth_provider`
- `cargo test -p codex-core agent_assertion`
- `cargo test -p codex-app-server remote_control`
- `cargo test -p codex-cloud-requirements fetch_cloud_requirements`
- `cargo test -p codex-models-manager manager::tests`
- `cargo test -p codex-chatgpt`
- `cargo test -p codex-cloud-tasks`
- `just fix -p codex-core -p codex-login -p codex-analytics -p
codex-app-server -p codex-cloud-requirements -p codex-cloud-tasks -p
codex-models-manager -p codex-chatgpt -p codex-model-provider -p
codex-mcp -p codex-core-skills`
- `just fix -p codex-app-server`
- `git diff --check`
This commit is contained in:
Adrian authored and GitHub committed 2026-04-20 06:50:28 -07:00
1 parent e1c289e11b
commit 904c751a40
23 files changed
+1297 -149

No files matched your search

@@ -167,6 +167,7 @@ mod tests {
agent_runtime_id: agent_runtime_id.to_string(),
agent_private_key: BASE64_STANDARD.encode(private_key.as_bytes()),
registered_at: "2026-03-23T12:00:00Z".to_string(),
background_task_id: None,
}
}
}
+1
View File
@@ -211,6 +211,7 @@ fn chatgpt_auth_persists_agent_identity_for_workspace() {
agent_runtime_id: "agent_123".to_string(),
agent_private_key: "pkcs8-base64".to_string(),
registered_at: "2026-04-13T12:00:00Z".to_string(),
background_task_id: None,
};
auth.set_agent_identity(record.clone())
+119 -1
View File
@@ -41,9 +41,12 @@ use codex_protocol::auth::KnownPlan as InternalKnownPlan;
use codex_protocol::auth::PlanType as InternalPlanType;
use codex_protocol::auth::RefreshTokenFailedError;
use codex_protocol::auth::RefreshTokenFailedReason;
use codex_protocol::protocol::SessionSource;
use serde_json::Value;
use thiserror::Error;
use crate::agent_identity::BackgroundAgentTaskAuthMode;
use crate::agent_identity::BackgroundAgentTaskManager;
/// Authentication mechanism used by the current user.
#[derive(Debug, Clone)]
pub enum CodexAuth {
@@ -372,7 +375,7 @@ impl CodexAuth {
.filter(|identity| identity.workspace_id == workspace_id)
}
pub fn set_agent_identity(&self, record: AgentIdentityAuthRecord) -> std::io::Result<()> {
pub fn set_agent_identity(&self, mut record: AgentIdentityAuthRecord) -> std::io::Result<()> {
let (state, storage) = match self {
Self::Chatgpt(auth) => (&auth.state, &auth.storage),
Self::ChatgptAuthTokens(auth) => (&auth.state, &auth.storage),
@@ -385,6 +388,13 @@ impl CodexAuth {
let mut auth = guard
.clone()
.ok_or_else(|| std::io::Error::other("auth data is not available"))?;
if record.background_task_id.is_none()
&& let Some(existing) = auth.agent_identity.as_ref()
&& existing.workspace_id == record.workspace_id
&& existing.agent_runtime_id == record.agent_runtime_id
{
record.background_task_id = existing.background_task_id.clone();
}
auth.agent_identity = Some(record);
storage.save(&auth)?;
*guard = Some(auth);
@@ -1181,6 +1191,8 @@ pub struct AuthManager {
enable_codex_api_key_env: bool,
auth_credentials_store_mode: AuthCredentialsStoreMode,
forced_chatgpt_workspace_id: RwLock<Option<String>>,
chatgpt_base_url: RwLock<Option<String>>,
background_agent_task_auth_mode: RwLock<BackgroundAgentTaskAuthMode>,
refresh_lock: AsyncMutex<()>,
external_auth: RwLock<Option<Arc<dyn ExternalAuth>>>,
auth_state_tx: watch::Sender<()>,
@@ -1201,6 +1213,16 @@ pub trait AuthManagerConfig {
/// Returns the workspace ID that ChatGPT auth should be restricted to, if any.
fn forced_chatgpt_workspace_id(&self) -> Option<String>;
/// Returns the ChatGPT backend base URL used for first-party backend authorization.
fn chatgpt_base_url(&self) -> Option<String> {
None
}
/// Returns whether default ChatGPT backend authorization may use background AgentAssertion.
fn background_agent_task_auth_mode(&self) -> BackgroundAgentTaskAuthMode {
BackgroundAgentTaskAuthMode::Disabled
}
}
impl Debug for AuthManager {
@@ -1217,6 +1239,11 @@ impl Debug for AuthManager {
"forced_chatgpt_workspace_id",
&self.forced_chatgpt_workspace_id,
)
.field("chatgpt_base_url", &self.chatgpt_base_url)
.field(
"background_agent_task_auth_mode",
&self.background_agent_task_auth_mode,
)
.field("has_external_auth", &self.has_external_auth())
.finish_non_exhaustive()
}
@@ -1249,6 +1276,8 @@ impl AuthManager {
enable_codex_api_key_env,
auth_credentials_store_mode,
forced_chatgpt_workspace_id: RwLock::new(None),
chatgpt_base_url: RwLock::new(None),
background_agent_task_auth_mode: RwLock::new(BackgroundAgentTaskAuthMode::Disabled),
refresh_lock: AsyncMutex::new(()),
external_auth: RwLock::new(None),
auth_state_tx,
@@ -1269,6 +1298,8 @@ impl AuthManager {
enable_codex_api_key_env: false,
auth_credentials_store_mode: AuthCredentialsStoreMode::File,
forced_chatgpt_workspace_id: RwLock::new(None),
chatgpt_base_url: RwLock::new(None),
background_agent_task_auth_mode: RwLock::new(BackgroundAgentTaskAuthMode::Disabled),
refresh_lock: AsyncMutex::new(()),
external_auth: RwLock::new(None),
auth_state_tx,
@@ -1288,6 +1319,8 @@ impl AuthManager {
enable_codex_api_key_env: false,
auth_credentials_store_mode: AuthCredentialsStoreMode::File,
forced_chatgpt_workspace_id: RwLock::new(None),
chatgpt_base_url: RwLock::new(None),
background_agent_task_auth_mode: RwLock::new(BackgroundAgentTaskAuthMode::Disabled),
refresh_lock: AsyncMutex::new(()),
external_auth: RwLock::new(None),
auth_state_tx,
@@ -1305,6 +1338,8 @@ impl AuthManager {
enable_codex_api_key_env: false,
auth_credentials_store_mode: AuthCredentialsStoreMode::File,
forced_chatgpt_workspace_id: RwLock::new(None),
chatgpt_base_url: RwLock::new(None),
background_agent_task_auth_mode: RwLock::new(BackgroundAgentTaskAuthMode::Disabled),
refresh_lock: AsyncMutex::new(()),
external_auth: RwLock::new(Some(
Arc::new(BearerTokenRefresher::new(config)) as Arc<dyn ExternalAuth>
@@ -1523,6 +1558,85 @@ impl AuthManager {
Ok(Some(record))
}
pub fn set_chatgpt_backend_auth_config(
&self,
chatgpt_base_url: Option<String>,
background_agent_task_auth_mode: BackgroundAgentTaskAuthMode,
) {
let mut changed = false;
if let Ok(mut guard) = self.chatgpt_base_url.write()
&& *guard != chatgpt_base_url
{
*guard = chatgpt_base_url;
changed = true;
}
if let Ok(mut guard) = self.background_agent_task_auth_mode.write()
&& *guard != background_agent_task_auth_mode
{
*guard = background_agent_task_auth_mode;
changed = true;
}
if changed {
self.auth_state_tx.send_replace(());
}
}
fn chatgpt_backend_auth_config(&self) -> (Option<String>, BackgroundAgentTaskAuthMode) {
let chatgpt_base_url = self
.chatgpt_base_url
.read()
.ok()
.and_then(|guard| guard.clone());
let auth_mode = self
.background_agent_task_auth_mode
.read()
.ok()
.map(|guard| *guard)
.unwrap_or_default();
(chatgpt_base_url, auth_mode)
}
/// Returns the default authorization header for ChatGPT backend requests.
///
/// This uses background AgentAssertion when configured and available, otherwise it falls back
/// to the ChatGPT bearer token. Low-level bootstrap calls that must never use AgentAssertion
/// should use [`Self::chatgpt_bearer_authorization_header_for_auth`] instead.
pub async fn chatgpt_authorization_header(self: &Arc<Self>) -> Option<String> {
let auth = self.auth().await?;
self.chatgpt_authorization_header_for_auth(&auth).await
}
pub async fn chatgpt_authorization_header_for_auth(
self: &Arc<Self>,
auth: &CodexAuth,
) -> Option<String> {
if !auth.is_chatgpt_auth() {
return None;
}
let (chatgpt_base_url, auth_mode) = self.chatgpt_backend_auth_config();
let Some(chatgpt_base_url) = chatgpt_base_url else {
return Self::chatgpt_bearer_authorization_header_for_auth(auth);
};
BackgroundAgentTaskManager::new_with_auth_mode(
Arc::clone(self),
chatgpt_base_url,
SessionSource::Cli,
auth_mode,
)
.authorization_header_value_or_bearer(auth)
.await
}
pub fn chatgpt_bearer_token_for_auth(auth: &CodexAuth) -> Option<String> {
auth.get_token().ok().filter(|token| !token.is_empty())
}
pub fn chatgpt_bearer_authorization_header_for_auth(auth: &CodexAuth) -> Option<String> {
Self::chatgpt_bearer_token_for_auth(auth).map(|token| format!("Bearer {token}"))
}
pub fn subscribe_auth_state(&self) -> watch::Receiver<()> {
self.auth_state_tx.subscribe()
}
@@ -1565,6 +1679,10 @@ impl AuthManager {
config.cli_auth_credentials_store_mode(),
);
auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id());
auth_manager.set_chatgpt_backend_auth_config(
config.chatgpt_base_url(),
config.background_agent_task_auth_mode(),
);
auth_manager
}
+2
View File
@@ -52,6 +52,8 @@ pub struct AgentIdentityAuthRecord {
pub agent_runtime_id: String,
pub agent_private_key: String,
pub registered_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background_task_id: Option<String>,
}
pub(super) fn get_auth_file(codex_home: &Path) -> PathBuf {
+1
View File
@@ -69,6 +69,7 @@ async fn file_storage_persists_agent_identity() -> anyhow::Result<()> {
agent_runtime_id: "agent_123".to_string(),
agent_private_key: "pkcs8-base64".to_string(),
registered_at: "2026-04-13T12:00:00Z".to_string(),
background_task_id: None,
}),
};