Align agent identity registration auth

This commit is contained in:
adrian
2026-04-11 09:52:42 -07:00
parent ea8ec876fd
commit ca85b94016
5 changed files with 170 additions and 19 deletions
+9
View File
@@ -271,6 +271,15 @@ pub struct ConfigToml {
/// Base URL for requests to ChatGPT (as opposed to the OpenAI API).
pub chatgpt_base_url: Option<String>,
/// Base URL for agent identity registration requests.
pub agent_identity_base_url: Option<String>,
/// Base URL for minting human biscuits used by agent identity registration requests.
pub agent_identity_biscuit_base_url: Option<String>,
/// Target URL used when minting the human biscuit for agent identity registration.
pub agent_identity_biscuit_target_url: Option<String>,
/// Base URL override for the built-in `openai` model provider.
pub openai_base_url: Option<String>,
+12
View File
@@ -2049,6 +2049,18 @@
},
"description": "Base config deserialized from ~/.codex/config.toml.",
"properties": {
"agent_identity_base_url": {
"description": "Base URL for agent identity registration requests.",
"type": "string"
},
"agent_identity_biscuit_base_url": {
"description": "Base URL for minting human biscuits used by agent identity registration requests.",
"type": "string"
},
"agent_identity_biscuit_target_url": {
"description": "Target URL used when minting the human biscuit for agent identity registration.",
"type": "string"
},
"agents": {
"allOf": [
{
+119 -19
View File
@@ -5,11 +5,13 @@ use anyhow::Context;
use anyhow::Result;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::SecondsFormat;
use chrono::Utc;
use codex_features::Feature;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_login::default_client::create_client;
use codex_protocol::protocol::SessionSource;
use codex_secrets::SecretName;
use codex_secrets::SecretScope;
@@ -29,21 +31,43 @@ use tracing::info;
use tracing::warn;
use crate::config::Config;
use crate::default_client::create_client;
const AGENT_IDENTITY_SECRET_NAME: &str = "AGENT_IDENTITY";
const AGENT_REGISTRATION_TIMEOUT: Duration = Duration::from_secs(15);
const AGENT_IDENTITY_BISCUIT_TIMEOUT: Duration = Duration::from_secs(15);
#[cfg(test)]
const AGENT_IDENTITY_BISCUIT_TARGET_URL: &str = "https://api.openai.com/v1/responses";
#[derive(Clone)]
pub(crate) struct AgentIdentityManager {
auth_manager: Arc<AuthManager>,
secrets_manager: SecretsManager,
chatgpt_base_url: String,
agent_identity_base_url: String,
agent_identity_biscuit_base_url: String,
agent_identity_biscuit_target_url: String,
feature_enabled: bool,
abom: AgentBillOfMaterials,
ensure_lock: Arc<Mutex<()>>,
}
impl std::fmt::Debug for AgentIdentityManager {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AgentIdentityManager")
.field("agent_identity_base_url", &self.agent_identity_base_url)
.field(
"agent_identity_biscuit_base_url",
&self.agent_identity_biscuit_base_url,
)
.field(
"agent_identity_biscuit_target_url",
&self.agent_identity_biscuit_target_url,
)
.field("feature_enabled", &self.feature_enabled)
.field("abom", &self.abom)
.finish_non_exhaustive()
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) struct StoredAgentIdentity {
pub(crate) binding_id: String,
@@ -100,7 +124,9 @@ impl AgentIdentityManager {
config.codex_home.clone(),
SecretsBackendKind::Local,
),
chatgpt_base_url: config.chatgpt_base_url.clone(),
agent_identity_base_url: config.agent_identity_base_url.clone(),
agent_identity_biscuit_base_url: config.agent_identity_biscuit_base_url.clone(),
agent_identity_biscuit_target_url: config.agent_identity_biscuit_target_url.clone(),
feature_enabled: config.features.enabled(Feature::UseAgentIdentity),
abom: build_abom(session_source),
ensure_lock: Arc::new(Mutex::new(())),
@@ -155,12 +181,12 @@ impl AgentIdentityManager {
capabilities: Vec::new(),
};
let human_biscuit = self.mint_human_biscuit(binding).await?;
let client = create_client();
let url = agent_registration_url(&self.chatgpt_base_url);
let url = agent_registration_url(&self.agent_identity_base_url);
let response = client
.post(&url)
.bearer_auth(&binding.access_token)
.header("chatgpt-account-id", &binding.chatgpt_account_id)
.header("X-OpenAI-Authorization", human_biscuit)
.json(&request_body)
.timeout(AGENT_REGISTRATION_TIMEOUT)
.send()
@@ -197,6 +223,47 @@ impl AgentIdentityManager {
anyhow::bail!("agent identity registration failed with status {status} from {url}: {body}")
}
async fn mint_human_biscuit(&self, binding: &AgentIdentityBinding) -> Result<String> {
anyhow::ensure!(
!self.agent_identity_biscuit_base_url.trim().is_empty(),
"`agent_identity_biscuit_base_url` must be configured when `features.use_agent_identity` is enabled"
);
let url = agent_identity_biscuit_url(&self.agent_identity_biscuit_base_url);
let request_id = agent_identity_request_id()?;
let client = create_client();
let response = client
.get(&url)
.bearer_auth(&binding.access_token)
.header("X-Request-Id", request_id.clone())
.header("X-Original-Method", "GET")
.header("X-Original-Url", &self.agent_identity_biscuit_target_url)
.timeout(AGENT_IDENTITY_BISCUIT_TIMEOUT)
.send()
.await
.with_context(|| format!("failed to send agent identity biscuit request to {url}"))?;
if response.status().is_success() {
let human_biscuit = response
.headers()
.get("x-openai-authorization")
.context("agent identity biscuit response did not include x-openai-authorization")?
.to_str()
.context("agent identity biscuit response header was not valid UTF-8")?
.to_string();
info!(
request_id = %request_id,
"minted human biscuit for agent identity registration"
);
return Ok(human_biscuit);
}
let status = response.status();
let body = response.text().await.unwrap_or_default();
anyhow::bail!(
"agent identity biscuit minting failed with status {status} from {url}: {body}"
)
}
fn load_stored_identity(
&self,
binding: &AgentIdentityBinding,
@@ -264,14 +331,16 @@ impl AgentIdentityManager {
fn new_for_tests(
auth_manager: Arc<AuthManager>,
feature_enabled: bool,
chatgpt_base_url: String,
agent_identity_base_url: String,
session_source: SessionSource,
secrets_manager: SecretsManager,
) -> Self {
Self {
auth_manager,
secrets_manager,
chatgpt_base_url,
agent_identity_biscuit_base_url: agent_identity_base_url.clone(),
agent_identity_base_url,
agent_identity_biscuit_target_url: AGENT_IDENTITY_BISCUIT_TARGET_URL.to_string(),
feature_enabled,
abom: build_abom(session_source),
ensure_lock: Arc::new(Mutex::new(())),
@@ -389,14 +458,27 @@ fn secret_scope(binding: &AgentIdentityBinding) -> Result<SecretScope> {
.context("agent identity binding must be a valid secrets scope")
}
fn agent_registration_url(chatgpt_base_url: &str) -> String {
let trimmed = chatgpt_base_url.trim_end_matches('/');
if let Some(root) = trimmed.strip_suffix("/backend-api") {
return format!("{root}/v1/agent/register");
}
fn agent_registration_url(agent_identity_base_url: &str) -> String {
let trimmed = agent_identity_base_url.trim_end_matches('/');
format!("{trimmed}/v1/agent/register")
}
fn agent_identity_biscuit_url(agent_identity_biscuit_base_url: &str) -> String {
let trimmed = agent_identity_biscuit_base_url.trim_end_matches('/');
format!("{trimmed}/authenticate_app_v2")
}
fn agent_identity_request_id() -> Result<String> {
let mut request_id_bytes = [0u8; 16];
OsRng
.try_fill_bytes(&mut request_id_bytes)
.context("failed to generate agent identity request id")?;
Ok(format!(
"codex-agent-identity-{}",
URL_SAFE_NO_PAD.encode(request_id_bytes)
))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -463,10 +545,10 @@ mod tests {
#[tokio::test]
async fn ensure_registered_identity_registers_and_reuses_cached_identity() {
let server = MockServer::start().await;
mount_human_biscuit(&server).await;
Mock::given(method("POST"))
.and(path("/v1/agent/register"))
.and(header("authorization", "Bearer access-token-account-123"))
.and(header("chatgpt-account-id", "account-123"))
.and(header("x-openai-authorization", "human-biscuit"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"agent_runtime_id": "agent_123",
})))
@@ -486,7 +568,7 @@ mod tests {
let manager = AgentIdentityManager::new_for_tests(
auth_manager,
/*feature_enabled*/ true,
format!("{}/backend-api/", server.uri()),
server.uri(),
SessionSource::Cli,
secrets_manager,
);
@@ -512,8 +594,10 @@ mod tests {
#[tokio::test]
async fn ensure_registered_identity_deletes_invalid_cached_identity_and_reregisters() {
let server = MockServer::start().await;
mount_human_biscuit(&server).await;
Mock::given(method("POST"))
.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",
})))
@@ -533,7 +617,7 @@ mod tests {
let manager = AgentIdentityManager::new_for_tests(
auth_manager,
/*feature_enabled*/ true,
format!("{}/backend-api/", server.uri()),
server.uri(),
SessionSource::Cli,
secrets_manager.clone(),
);
@@ -565,10 +649,12 @@ mod tests {
}
#[tokio::test]
async fn ensure_registered_identity_uses_canonical_agent_registration_url() {
async fn ensure_registered_identity_uses_agent_identity_base_url() {
let server = MockServer::start().await;
mount_human_biscuit(&server).await;
Mock::given(method("POST"))
.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_canonical",
})))
@@ -588,7 +674,7 @@ mod tests {
let manager = AgentIdentityManager::new_for_tests(
auth_manager,
/*feature_enabled*/ true,
format!("{}/backend-api/", server.uri()),
server.uri(),
SessionSource::Cli,
secrets_manager,
);
@@ -601,6 +687,20 @@ mod tests {
assert_eq!(stored.agent_runtime_id, "agent_canonical");
}
async fn mount_human_biscuit(server: &MockServer) {
Mock::given(method("GET"))
.and(path("/authenticate_app_v2"))
.and(header("authorization", "Bearer access-token-account-123"))
.and(header("x-original-method", "GET"))
.and(header("x-original-url", AGENT_IDENTITY_BISCUIT_TARGET_URL))
.respond_with(
ResponseTemplate::new(200).insert_header("x-openai-authorization", "human-biscuit"),
)
.expect(1)
.mount(server)
.await;
}
#[test]
fn encode_ssh_ed25519_public_key_matches_expected_wire_shape() {
let key_material = generate_agent_key_material().expect("key material");
+12
View File
@@ -4550,6 +4550,9 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
model_verbosity: None,
personality: Some(Personality::Pragmatic),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
agent_identity_base_url: "https://auth.openai.com/api/accounts".to_string(),
agent_identity_biscuit_base_url: String::new(),
agent_identity_biscuit_target_url: "https://api.openai.com/v1/responses".to_string(),
realtime_audio: RealtimeAudioConfig::default(),
experimental_realtime_start_instructions: None,
experimental_realtime_ws_base_url: None,
@@ -4696,6 +4699,9 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
model_verbosity: None,
personality: Some(Personality::Pragmatic),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
agent_identity_base_url: "https://auth.openai.com/api/accounts".to_string(),
agent_identity_biscuit_base_url: String::new(),
agent_identity_biscuit_target_url: "https://api.openai.com/v1/responses".to_string(),
realtime_audio: RealtimeAudioConfig::default(),
experimental_realtime_start_instructions: None,
experimental_realtime_ws_base_url: None,
@@ -4840,6 +4846,9 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
model_verbosity: None,
personality: Some(Personality::Pragmatic),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
agent_identity_base_url: "https://auth.openai.com/api/accounts".to_string(),
agent_identity_biscuit_base_url: String::new(),
agent_identity_biscuit_target_url: "https://api.openai.com/v1/responses".to_string(),
realtime_audio: RealtimeAudioConfig::default(),
experimental_realtime_start_instructions: None,
experimental_realtime_ws_base_url: None,
@@ -4970,6 +4979,9 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
model_verbosity: Some(Verbosity::High),
personality: Some(Personality::Pragmatic),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
agent_identity_base_url: "https://auth.openai.com/api/accounts".to_string(),
agent_identity_biscuit_base_url: String::new(),
agent_identity_biscuit_target_url: "https://api.openai.com/v1/responses".to_string(),
realtime_audio: RealtimeAudioConfig::default(),
experimental_realtime_start_instructions: None,
experimental_realtime_ws_base_url: None,
+18
View File
@@ -471,6 +471,15 @@ pub struct Config {
/// Base URL for requests to ChatGPT (as opposed to the OpenAI API).
pub chatgpt_base_url: String,
/// Base URL for agent identity registration requests.
pub agent_identity_base_url: String,
/// Base URL for minting human biscuits used by agent identity registration requests.
pub agent_identity_biscuit_base_url: String,
/// Target URL used when minting the human biscuit for agent identity registration.
pub agent_identity_biscuit_target_url: String,
/// Machine-local realtime audio device preferences used by realtime voice.
pub realtime_audio: RealtimeAudioConfig,
@@ -2079,6 +2088,15 @@ impl Config {
.chatgpt_base_url
.or(cfg.chatgpt_base_url)
.unwrap_or("https://chatgpt.com/backend-api/".to_string()),
agent_identity_base_url: cfg
.agent_identity_base_url
.unwrap_or("https://auth.openai.com/api/accounts".to_string()),
agent_identity_biscuit_base_url: cfg
.agent_identity_biscuit_base_url
.unwrap_or_default(),
agent_identity_biscuit_target_url: cfg
.agent_identity_biscuit_target_url
.unwrap_or("https://api.openai.com/v1/responses".to_string()),
realtime_audio: cfg
.audio
.map_or_else(RealtimeAudioConfig::default, |audio| RealtimeAudioConfig {