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`
174 lines
6.2 KiB
Rust
174 lines
6.2 KiB
Rust
use std::collections::BTreeMap;
|
|
|
|
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 ed25519_dalek::Signer as _;
|
|
use ed25519_dalek::SigningKey;
|
|
use ed25519_dalek::pkcs8::DecodePrivateKey;
|
|
use serde::Deserialize;
|
|
use serde::Serialize;
|
|
|
|
use super::storage::AgentIdentityAuthRecord;
|
|
|
|
/// Task binding to use when constructing a task-scoped AgentAssertion.
|
|
///
|
|
/// The caller owns the task lifecycle. `AuthManager` only uses this target to
|
|
/// sign an authorization header with the stored agent identity key material.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub struct AgentTaskAuthorizationTarget<'a> {
|
|
pub agent_runtime_id: &'a str,
|
|
pub task_id: &'a str,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
|
struct AgentAssertionEnvelope {
|
|
agent_runtime_id: String,
|
|
task_id: String,
|
|
timestamp: String,
|
|
signature: String,
|
|
}
|
|
|
|
pub(super) fn authorization_header_for_agent_task(
|
|
record: &AgentIdentityAuthRecord,
|
|
target: AgentTaskAuthorizationTarget<'_>,
|
|
) -> Result<String> {
|
|
anyhow::ensure!(
|
|
record.agent_runtime_id == target.agent_runtime_id,
|
|
"agent task runtime {} does not match stored agent identity {}",
|
|
target.agent_runtime_id,
|
|
record.agent_runtime_id
|
|
);
|
|
|
|
let timestamp = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
|
|
let envelope = AgentAssertionEnvelope {
|
|
agent_runtime_id: target.agent_runtime_id.to_string(),
|
|
task_id: target.task_id.to_string(),
|
|
timestamp: timestamp.clone(),
|
|
signature: sign_agent_assertion_payload(record, target, ×tamp)?,
|
|
};
|
|
let serialized_assertion = serialize_agent_assertion(&envelope)?;
|
|
Ok(format!("AgentAssertion {serialized_assertion}"))
|
|
}
|
|
|
|
fn sign_agent_assertion_payload(
|
|
record: &AgentIdentityAuthRecord,
|
|
target: AgentTaskAuthorizationTarget<'_>,
|
|
timestamp: &str,
|
|
) -> Result<String> {
|
|
let signing_key = signing_key_from_agent_private_key(&record.agent_private_key)?;
|
|
let payload = format!("{}:{}:{timestamp}", target.agent_runtime_id, target.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))
|
|
}
|
|
|
|
fn signing_key_from_agent_private_key(agent_private_key: &str) -> Result<SigningKey> {
|
|
let private_key = BASE64_STANDARD
|
|
.decode(agent_private_key)
|
|
.context("stored agent identity private key is not valid base64")?;
|
|
SigningKey::from_pkcs8_der(&private_key)
|
|
.context("stored agent identity private key is not valid PKCS#8")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use ed25519_dalek::Signature;
|
|
use ed25519_dalek::Verifier as _;
|
|
use ed25519_dalek::pkcs8::EncodePrivateKey;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn authorization_header_for_agent_task_serializes_signed_agent_assertion() {
|
|
let record = test_agent_identity_record("agent-123");
|
|
let target = AgentTaskAuthorizationTarget {
|
|
agent_runtime_id: "agent-123",
|
|
task_id: "task-123",
|
|
};
|
|
|
|
let header = authorization_header_for_agent_task(&record, target)
|
|
.expect("build agent assertion header");
|
|
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");
|
|
signing_key_from_agent_private_key(&record.agent_private_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 authorization_header_for_agent_task_rejects_mismatched_runtime() {
|
|
let record = test_agent_identity_record("agent-123");
|
|
let target = AgentTaskAuthorizationTarget {
|
|
agent_runtime_id: "agent-456",
|
|
task_id: "task-123",
|
|
};
|
|
|
|
let error = authorization_header_for_agent_task(&record, target)
|
|
.expect_err("runtime mismatch should fail");
|
|
|
|
assert_eq!(
|
|
error.to_string(),
|
|
"agent task runtime agent-456 does not match stored agent identity agent-123"
|
|
);
|
|
}
|
|
|
|
fn test_agent_identity_record(agent_runtime_id: &str) -> AgentIdentityAuthRecord {
|
|
let signing_key = SigningKey::from_bytes(&[7u8; 32]);
|
|
let private_key = signing_key
|
|
.to_pkcs8_der()
|
|
.expect("encode test key material");
|
|
AgentIdentityAuthRecord {
|
|
workspace_id: "account-123".to_string(),
|
|
chatgpt_user_id: Some("user-123".to_string()),
|
|
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,
|
|
}
|
|
}
|
|
}
|