mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
## 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`
337 lines
11 KiB
Rust
337 lines
11 KiB
Rust
use chrono::DateTime;
|
|
use chrono::Utc;
|
|
use serde::Deserialize;
|
|
use serde::Serialize;
|
|
use sha2::Digest;
|
|
use sha2::Sha256;
|
|
use std::collections::HashMap;
|
|
use std::fmt::Debug;
|
|
use std::fs::File;
|
|
use std::fs::OpenOptions;
|
|
use std::io::Read;
|
|
use std::io::Write;
|
|
#[cfg(unix)]
|
|
use std::os::unix::fs::OpenOptionsExt;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::sync::Mutex;
|
|
use tracing::warn;
|
|
|
|
use crate::token_data::TokenData;
|
|
use codex_app_server_protocol::AuthMode;
|
|
use codex_config::types::AuthCredentialsStoreMode;
|
|
use codex_keyring_store::DefaultKeyringStore;
|
|
use codex_keyring_store::KeyringStore;
|
|
use once_cell::sync::Lazy;
|
|
|
|
/// Expected structure for $CODEX_HOME/auth.json.
|
|
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
|
|
pub struct AuthDotJson {
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub auth_mode: Option<AuthMode>,
|
|
|
|
#[serde(rename = "OPENAI_API_KEY")]
|
|
pub openai_api_key: Option<String>,
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub tokens: Option<TokenData>,
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub last_refresh: Option<DateTime<Utc>>,
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub agent_identity: Option<AgentIdentityAuthRecord>,
|
|
}
|
|
|
|
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)]
|
|
pub struct AgentIdentityAuthRecord {
|
|
pub workspace_id: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub chatgpt_user_id: Option<String>,
|
|
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 {
|
|
codex_home.join("auth.json")
|
|
}
|
|
|
|
pub(super) fn delete_file_if_exists(codex_home: &Path) -> std::io::Result<bool> {
|
|
let auth_file = get_auth_file(codex_home);
|
|
match std::fs::remove_file(&auth_file) {
|
|
Ok(()) => Ok(true),
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
|
Err(err) => Err(err),
|
|
}
|
|
}
|
|
|
|
pub(super) trait AuthStorageBackend: Debug + Send + Sync {
|
|
fn load(&self) -> std::io::Result<Option<AuthDotJson>>;
|
|
fn save(&self, auth: &AuthDotJson) -> std::io::Result<()>;
|
|
fn delete(&self) -> std::io::Result<bool>;
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub(super) struct FileAuthStorage {
|
|
codex_home: PathBuf,
|
|
}
|
|
|
|
impl FileAuthStorage {
|
|
pub(super) fn new(codex_home: PathBuf) -> Self {
|
|
Self { codex_home }
|
|
}
|
|
|
|
/// Attempt to read and parse the `auth.json` file in the given `CODEX_HOME` directory.
|
|
/// Returns the full AuthDotJson structure.
|
|
pub(super) fn try_read_auth_json(&self, auth_file: &Path) -> std::io::Result<AuthDotJson> {
|
|
let mut file = File::open(auth_file)?;
|
|
let mut contents = String::new();
|
|
file.read_to_string(&mut contents)?;
|
|
let auth_dot_json: AuthDotJson = serde_json::from_str(&contents)?;
|
|
|
|
Ok(auth_dot_json)
|
|
}
|
|
}
|
|
|
|
impl AuthStorageBackend for FileAuthStorage {
|
|
fn load(&self) -> std::io::Result<Option<AuthDotJson>> {
|
|
let auth_file = get_auth_file(&self.codex_home);
|
|
let auth_dot_json = match self.try_read_auth_json(&auth_file) {
|
|
Ok(auth) => auth,
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
|
Err(err) => return Err(err),
|
|
};
|
|
Ok(Some(auth_dot_json))
|
|
}
|
|
|
|
fn save(&self, auth_dot_json: &AuthDotJson) -> std::io::Result<()> {
|
|
let auth_file = get_auth_file(&self.codex_home);
|
|
|
|
if let Some(parent) = auth_file.parent() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
let json_data = serde_json::to_string_pretty(auth_dot_json)?;
|
|
let mut options = OpenOptions::new();
|
|
options.truncate(true).write(true).create(true);
|
|
#[cfg(unix)]
|
|
{
|
|
options.mode(0o600);
|
|
}
|
|
let mut file = options.open(auth_file)?;
|
|
file.write_all(json_data.as_bytes())?;
|
|
file.flush()?;
|
|
Ok(())
|
|
}
|
|
|
|
fn delete(&self) -> std::io::Result<bool> {
|
|
delete_file_if_exists(&self.codex_home)
|
|
}
|
|
}
|
|
|
|
const KEYRING_SERVICE: &str = "Codex Auth";
|
|
|
|
// turns codex_home path into a stable, short key string
|
|
fn compute_store_key(codex_home: &Path) -> std::io::Result<String> {
|
|
let canonical = codex_home
|
|
.canonicalize()
|
|
.unwrap_or_else(|_| codex_home.to_path_buf());
|
|
let path_str = canonical.to_string_lossy();
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(path_str.as_bytes());
|
|
let digest = hasher.finalize();
|
|
let hex = format!("{digest:x}");
|
|
let truncated = hex.get(..16).unwrap_or(&hex);
|
|
Ok(format!("cli|{truncated}"))
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct KeyringAuthStorage {
|
|
codex_home: PathBuf,
|
|
keyring_store: Arc<dyn KeyringStore>,
|
|
}
|
|
|
|
impl KeyringAuthStorage {
|
|
fn new(codex_home: PathBuf, keyring_store: Arc<dyn KeyringStore>) -> Self {
|
|
Self {
|
|
codex_home,
|
|
keyring_store,
|
|
}
|
|
}
|
|
|
|
fn load_from_keyring(&self, key: &str) -> std::io::Result<Option<AuthDotJson>> {
|
|
match self.keyring_store.load(KEYRING_SERVICE, key) {
|
|
Ok(Some(serialized)) => serde_json::from_str(&serialized).map(Some).map_err(|err| {
|
|
std::io::Error::other(format!(
|
|
"failed to deserialize CLI auth from keyring: {err}"
|
|
))
|
|
}),
|
|
Ok(None) => Ok(None),
|
|
Err(error) => Err(std::io::Error::other(format!(
|
|
"failed to load CLI auth from keyring: {}",
|
|
error.message()
|
|
))),
|
|
}
|
|
}
|
|
|
|
fn save_to_keyring(&self, key: &str, value: &str) -> std::io::Result<()> {
|
|
match self.keyring_store.save(KEYRING_SERVICE, key, value) {
|
|
Ok(()) => Ok(()),
|
|
Err(error) => {
|
|
let message = format!(
|
|
"failed to write OAuth tokens to keyring: {}",
|
|
error.message()
|
|
);
|
|
warn!("{message}");
|
|
Err(std::io::Error::other(message))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl AuthStorageBackend for KeyringAuthStorage {
|
|
fn load(&self) -> std::io::Result<Option<AuthDotJson>> {
|
|
let key = compute_store_key(&self.codex_home)?;
|
|
self.load_from_keyring(&key)
|
|
}
|
|
|
|
fn save(&self, auth: &AuthDotJson) -> std::io::Result<()> {
|
|
let key = compute_store_key(&self.codex_home)?;
|
|
// Simpler error mapping per style: prefer method reference over closure
|
|
let serialized = serde_json::to_string(auth).map_err(std::io::Error::other)?;
|
|
self.save_to_keyring(&key, &serialized)?;
|
|
if let Err(err) = delete_file_if_exists(&self.codex_home) {
|
|
warn!("failed to remove CLI auth fallback file: {err}");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn delete(&self) -> std::io::Result<bool> {
|
|
let key = compute_store_key(&self.codex_home)?;
|
|
let keyring_removed = self
|
|
.keyring_store
|
|
.delete(KEYRING_SERVICE, &key)
|
|
.map_err(|err| {
|
|
std::io::Error::other(format!("failed to delete auth from keyring: {err}"))
|
|
})?;
|
|
let file_removed = delete_file_if_exists(&self.codex_home)?;
|
|
Ok(keyring_removed || file_removed)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct AutoAuthStorage {
|
|
keyring_storage: Arc<KeyringAuthStorage>,
|
|
file_storage: Arc<FileAuthStorage>,
|
|
}
|
|
|
|
impl AutoAuthStorage {
|
|
fn new(codex_home: PathBuf, keyring_store: Arc<dyn KeyringStore>) -> Self {
|
|
Self {
|
|
keyring_storage: Arc::new(KeyringAuthStorage::new(codex_home.clone(), keyring_store)),
|
|
file_storage: Arc::new(FileAuthStorage::new(codex_home)),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl AuthStorageBackend for AutoAuthStorage {
|
|
fn load(&self) -> std::io::Result<Option<AuthDotJson>> {
|
|
match self.keyring_storage.load() {
|
|
Ok(Some(auth)) => Ok(Some(auth)),
|
|
Ok(None) => self.file_storage.load(),
|
|
Err(err) => {
|
|
warn!("failed to load CLI auth from keyring, falling back to file storage: {err}");
|
|
self.file_storage.load()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn save(&self, auth: &AuthDotJson) -> std::io::Result<()> {
|
|
match self.keyring_storage.save(auth) {
|
|
Ok(()) => Ok(()),
|
|
Err(err) => {
|
|
warn!("failed to save auth to keyring, falling back to file storage: {err}");
|
|
self.file_storage.save(auth)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn delete(&self) -> std::io::Result<bool> {
|
|
// Keyring storage will delete from disk as well
|
|
self.keyring_storage.delete()
|
|
}
|
|
}
|
|
|
|
// A global in-memory store for mapping codex_home -> AuthDotJson.
|
|
static EPHEMERAL_AUTH_STORE: Lazy<Mutex<HashMap<String, AuthDotJson>>> =
|
|
Lazy::new(|| Mutex::new(HashMap::new()));
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct EphemeralAuthStorage {
|
|
codex_home: PathBuf,
|
|
}
|
|
|
|
impl EphemeralAuthStorage {
|
|
fn new(codex_home: PathBuf) -> Self {
|
|
Self { codex_home }
|
|
}
|
|
|
|
fn with_store<F, T>(&self, action: F) -> std::io::Result<T>
|
|
where
|
|
F: FnOnce(&mut HashMap<String, AuthDotJson>, String) -> std::io::Result<T>,
|
|
{
|
|
let key = compute_store_key(&self.codex_home)?;
|
|
let mut store = EPHEMERAL_AUTH_STORE
|
|
.lock()
|
|
.map_err(|_| std::io::Error::other("failed to lock ephemeral auth storage"))?;
|
|
action(&mut store, key)
|
|
}
|
|
}
|
|
|
|
impl AuthStorageBackend for EphemeralAuthStorage {
|
|
fn load(&self) -> std::io::Result<Option<AuthDotJson>> {
|
|
self.with_store(|store, key| Ok(store.get(&key).cloned()))
|
|
}
|
|
|
|
fn save(&self, auth: &AuthDotJson) -> std::io::Result<()> {
|
|
self.with_store(|store, key| {
|
|
store.insert(key, auth.clone());
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
fn delete(&self) -> std::io::Result<bool> {
|
|
self.with_store(|store, key| Ok(store.remove(&key).is_some()))
|
|
}
|
|
}
|
|
|
|
pub(super) fn create_auth_storage(
|
|
codex_home: PathBuf,
|
|
mode: AuthCredentialsStoreMode,
|
|
) -> Arc<dyn AuthStorageBackend> {
|
|
let keyring_store: Arc<dyn KeyringStore> = Arc::new(DefaultKeyringStore);
|
|
create_auth_storage_with_keyring_store(codex_home, mode, keyring_store)
|
|
}
|
|
|
|
fn create_auth_storage_with_keyring_store(
|
|
codex_home: PathBuf,
|
|
mode: AuthCredentialsStoreMode,
|
|
keyring_store: Arc<dyn KeyringStore>,
|
|
) -> Arc<dyn AuthStorageBackend> {
|
|
match mode {
|
|
AuthCredentialsStoreMode::File => Arc::new(FileAuthStorage::new(codex_home)),
|
|
AuthCredentialsStoreMode::Keyring => {
|
|
Arc::new(KeyringAuthStorage::new(codex_home, keyring_store))
|
|
}
|
|
AuthCredentialsStoreMode::Auto => Arc::new(AutoAuthStorage::new(codex_home, keyring_store)),
|
|
AuthCredentialsStoreMode::Ephemeral => Arc::new(EphemeralAuthStorage::new(codex_home)),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "storage_tests.rs"]
|
|
mod tests;
|