mirror of
https://github.com/openai/codex.git
synced 2026-11-04 00:24:43 +00:00
[codex] Use AgentAssertion downstream behind use_agent_identity (#17980)
## Summary This is the AgentAssertion downstream slice for feature-gated agent identity support, replacing the oversized AgentAssertion slice from PR #17807. It isolates task-scoped downstream AgentAssertion wiring on top of the merged PR3.1 work without re-carrying the earlier agent registration, task registration, or task-state history. This PR includes the task-scoped bug-fix call sites from the review: generic file upload auth, MCP OpenAI file upload auth, and ARC monitor auth. Broader user/control-plane calls move to PR4.1 and PR4.2. ## 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: this PR - use task-scoped `AgentAssertion` downstream when enabled - PR4.1: https://github.com/openai/codex/pull/18094 - 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 AgentAssertion envelope generation in `codex-core` - route downstream HTTP and websocket auth through AgentAssertion when an agent task is present - extend the model-provider auth provider so non-bearer authorization schemes can be passed through cleanly - make generic file uploads attach the full authorization header value - make MCP OpenAI file uploads use the cached thread agent task assertion when present - make ARC monitor calls use the cached thread agent task assertion when present ## Why The original PR had drifted ancestry and showed a much larger diff than the semantic change actually required. Restacking it onto PR3.1 keeps the reviewable surface down to the downstream assertion slice. ## 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-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` - `cargo test -p codex-login agent_identity` - `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:
1 parent
3c75f9b4dd
commit
b44d2851cf
19 files changed
+1206
-65
No files matched your search
@@ -0,0 +1,172 @@
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use anyhow::Context;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use reqwest::StatusCode;
|
||||
@@ -20,6 +21,8 @@ use codex_app_server_protocol::AuthMode as ApiAuthMode;
|
||||
use codex_protocol::config_types::ForcedLoginMethod;
|
||||
use codex_protocol::config_types::ModelProviderAuthInfo;
|
||||
|
||||
use super::agent_assertion;
|
||||
use super::agent_assertion::AgentTaskAuthorizationTarget;
|
||||
use super::external_bearer::BearerTokenRefresher;
|
||||
use super::revoke::revoke_auth_tokens;
|
||||
pub use crate::auth::storage::AgentIdentityAuthRecord;
|
||||
@@ -1483,6 +1486,43 @@ impl AuthManager {
|
||||
.and_then(|guard| guard.clone())
|
||||
}
|
||||
|
||||
pub fn chatgpt_agent_task_authorization_header_for_auth(
|
||||
&self,
|
||||
auth: &CodexAuth,
|
||||
target: AgentTaskAuthorizationTarget<'_>,
|
||||
) -> anyhow::Result<Option<String>> {
|
||||
let Some(record) = self.agent_identity_for_chatgpt_auth(auth)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
agent_assertion::authorization_header_for_agent_task(&record, target).map(Some)
|
||||
}
|
||||
|
||||
fn agent_identity_for_chatgpt_auth(
|
||||
&self,
|
||||
auth: &CodexAuth,
|
||||
) -> anyhow::Result<Option<AgentIdentityAuthRecord>> {
|
||||
if !auth.is_chatgpt_auth() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let token_data = auth
|
||||
.get_token_data()
|
||||
.context("ChatGPT token data is not available")?;
|
||||
let workspace_id = self
|
||||
.forced_chatgpt_workspace_id()
|
||||
.filter(|value| !value.is_empty())
|
||||
.or(token_data.account_id.filter(|value| !value.is_empty()));
|
||||
|
||||
let Some(workspace_id) = workspace_id else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(record) = auth.get_agent_identity(&workspace_id) else {
|
||||
anyhow::bail!("agent identity is not available for workspace {workspace_id}");
|
||||
};
|
||||
|
||||
Ok(Some(record))
|
||||
}
|
||||
|
||||
pub fn subscribe_auth_state(&self) -> watch::Receiver<()> {
|
||||
self.auth_state_tx.subscribe()
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod agent_assertion;
|
||||
pub mod default_client;
|
||||
pub mod error;
|
||||
mod storage;
|
||||
@@ -7,6 +8,7 @@ mod external_bearer;
|
||||
mod manager;
|
||||
mod revoke;
|
||||
|
||||
pub use agent_assertion::AgentTaskAuthorizationTarget;
|
||||
pub use error::RefreshTokenFailedError;
|
||||
pub use error::RefreshTokenFailedReason;
|
||||
pub use manager::*;
|
||||
Reference in new issue
Block a user