Skip to content

Commit 688aad3

Browse files
committed
Use AgentAssertion downstream behind use_agent_identity
1 parent 1f624ed commit 688aad3

File tree

10 files changed

+696
-73
lines changed

10 files changed

+696
-73
lines changed

codex-rs/codex-api/src/api_bridge.rs

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,13 +178,33 @@ struct UsageErrorBody {
178178
pub struct CoreAuthProvider {
179179
pub token: Option<String>,
180180
pub account_id: Option<String>,
181+
authorization_header_override: Option<String>,
181182
}
182183

183184
impl CoreAuthProvider {
185+
pub fn from_bearer_token(token: Option<String>, account_id: Option<String>) -> Self {
186+
Self {
187+
token,
188+
account_id,
189+
authorization_header_override: None,
190+
}
191+
}
192+
193+
pub fn from_authorization_header_value(
194+
authorization_header_value: Option<String>,
195+
account_id: Option<String>,
196+
) -> Self {
197+
Self {
198+
token: None,
199+
account_id,
200+
authorization_header_override: authorization_header_value,
201+
}
202+
}
203+
184204
pub fn auth_header_attached(&self) -> bool {
185-
self.token
205+
self.authorization_header_value()
186206
.as_ref()
187-
.is_some_and(|token| http::HeaderValue::from_str(&format!("Bearer {token}")).is_ok())
207+
.is_some_and(|value| http::HeaderValue::from_str(value).is_ok())
188208
}
189209

190210
pub fn auth_header_name(&self) -> Option<&'static str> {
@@ -195,15 +215,33 @@ impl CoreAuthProvider {
195215
Self {
196216
token: token.map(str::to_string),
197217
account_id: account_id.map(str::to_string),
218+
authorization_header_override: None,
198219
}
199220
}
221+
222+
#[cfg(test)]
223+
pub fn for_test_authorization_header(
224+
authorization_header_value: Option<&str>,
225+
account_id: Option<&str>,
226+
) -> Self {
227+
Self::from_authorization_header_value(
228+
authorization_header_value.map(str::to_string),
229+
account_id.map(str::to_string),
230+
)
231+
}
200232
}
201233

202234
impl ApiAuthProvider for CoreAuthProvider {
203235
fn bearer_token(&self) -> Option<String> {
204236
self.token.clone()
205237
}
206238

239+
fn authorization_header_value(&self) -> Option<String> {
240+
self.authorization_header_override
241+
.clone()
242+
.or_else(|| self.bearer_token().map(|token| format!("Bearer {token}")))
243+
}
244+
207245
fn account_id(&self) -> Option<String> {
208246
self.account_id.clone()
209247
}

codex-rs/codex-api/src/api_bridge_tests.rs

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -133,11 +133,27 @@ fn map_api_error_extracts_identity_auth_details_from_headers() {
133133

134134
#[test]
135135
fn core_auth_provider_reports_when_auth_header_will_attach() {
136-
let auth = CoreAuthProvider {
137-
token: Some("access-token".to_string()),
138-
account_id: None,
139-
};
136+
let auth = CoreAuthProvider::from_bearer_token(
137+
Some("access-token".to_string()),
138+
/*account_id*/ None,
139+
);
140140

141141
assert!(auth.auth_header_attached());
142142
assert_eq!(auth.auth_header_name(), Some("authorization"));
143143
}
144+
145+
#[test]
146+
fn core_auth_provider_supports_non_bearer_authorization_headers() {
147+
let auth = CoreAuthProvider::for_test_authorization_header(
148+
Some("AgentAssertion opaque-token"),
149+
/*account_id*/ None,
150+
);
151+
152+
assert!(auth.auth_header_attached());
153+
assert_eq!(auth.auth_header_name(), Some("authorization"));
154+
assert_eq!(auth.bearer_token(), None);
155+
assert_eq!(
156+
auth.authorization_header_value(),
157+
Some("AgentAssertion opaque-token".to_string())
158+
);
159+
}

codex-rs/codex-api/src/auth.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,17 @@ use http::HeaderValue;
99
/// reach this interface.
1010
pub trait AuthProvider: Send + Sync {
1111
fn bearer_token(&self) -> Option<String>;
12+
fn authorization_header_value(&self) -> Option<String> {
13+
self.bearer_token().map(|token| format!("Bearer {token}"))
14+
}
1215
fn account_id(&self) -> Option<String> {
1316
None
1417
}
1518
}
1619

1720
pub(crate) fn add_auth_headers_to_header_map<A: AuthProvider>(auth: &A, headers: &mut HeaderMap) {
18-
if let Some(token) = auth.bearer_token()
19-
&& let Ok(header) = HeaderValue::from_str(&format!("Bearer {token}"))
21+
if let Some(authorization) = auth.authorization_header_value()
22+
&& let Ok(header) = HeaderValue::from_str(&authorization)
2023
{
2124
let _ = headers.insert(http::header::AUTHORIZATION, header);
2225
}

codex-rs/core/src/agent_identity.rs

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,15 @@ use tracing::debug;
2727
use tracing::info;
2828
use tracing::warn;
2929

30+
use crate::config::Config;
31+
32+
mod assertion;
3033
mod task_registration;
3134

35+
#[cfg(test)]
36+
pub(crate) use assertion::AgentAssertionEnvelope;
3237
pub(crate) use task_registration::RegisteredAgentTask;
3338

34-
use crate::config::Config;
35-
3639
const AGENT_REGISTRATION_TIMEOUT: Duration = Duration::from_secs(15);
3740
const AGENT_IDENTITY_BISCUIT_TIMEOUT: Duration = Duration::from_secs(15);
3841

@@ -335,7 +338,7 @@ impl AgentIdentityManager {
335338
}
336339

337340
#[cfg(test)]
338-
fn new_for_tests(
341+
pub(crate) fn new_for_tests(
339342
auth_manager: Arc<AuthManager>,
340343
feature_enabled: bool,
341344
chatgpt_base_url: String,
@@ -349,6 +352,30 @@ impl AgentIdentityManager {
349352
ensure_lock: Arc::new(Mutex::new(())),
350353
}
351354
}
355+
356+
#[cfg(test)]
357+
pub(crate) async fn seed_generated_identity_for_tests(
358+
&self,
359+
agent_runtime_id: &str,
360+
) -> Result<StoredAgentIdentity> {
361+
let (auth, binding) = self
362+
.current_auth_binding()
363+
.await
364+
.context("test agent identity requires ChatGPT auth")?;
365+
let key_material = generate_agent_key_material()?;
366+
let stored_identity = StoredAgentIdentity {
367+
binding_id: binding.binding_id.clone(),
368+
chatgpt_account_id: binding.chatgpt_account_id.clone(),
369+
chatgpt_user_id: binding.chatgpt_user_id,
370+
agent_runtime_id: agent_runtime_id.to_string(),
371+
private_key_pkcs8_base64: key_material.private_key_pkcs8_base64,
372+
public_key_ssh: key_material.public_key_ssh,
373+
registered_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
374+
abom: self.abom.clone(),
375+
};
376+
self.store_identity(&auth, &stored_identity)?;
377+
Ok(stored_identity)
378+
}
352379
}
353380

354381
impl StoredAgentIdentity {
@@ -579,7 +606,7 @@ mod tests {
579606
.and(path("/v1/agent/register"))
580607
.and(header("x-openai-authorization", "human-biscuit"))
581608
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
582-
"agent_runtime_id": "agent_123",
609+
"agent_runtime_id": "agent-123",
583610
})))
584611
.expect(1)
585612
.mount(&server)
@@ -605,7 +632,7 @@ mod tests {
605632
.unwrap()
606633
.expect("identity should be reused");
607634

608-
assert_eq!(first.agent_runtime_id, "agent_123");
635+
assert_eq!(first.agent_runtime_id, "agent-123");
609636
assert_eq!(first, second);
610637
assert_eq!(first.abom.agent_harness_id, "codex-cli");
611638
assert_eq!(first.chatgpt_account_id, "account-123");
@@ -621,7 +648,7 @@ mod tests {
621648
.and(path("/v1/agent/register"))
622649
.and(header("x-openai-authorization", "human-biscuit"))
623650
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
624-
"agent_runtime_id": "agent_456",
651+
"agent_runtime_id": "agent-456",
625652
})))
626653
.expect(1)
627654
.mount(&server)
@@ -653,11 +680,11 @@ mod tests {
653680
.unwrap()
654681
.expect("identity should be registered");
655682

656-
assert_eq!(stored.agent_runtime_id, "agent_456");
683+
assert_eq!(stored.agent_runtime_id, "agent-456");
657684
let persisted = auth
658685
.get_agent_identity(&binding.chatgpt_account_id)
659686
.expect("stored identity");
660-
assert_eq!(persisted.agent_runtime_id, "agent_456");
687+
assert_eq!(persisted.agent_runtime_id, "agent-456");
661688
}
662689

663690
#[tokio::test]
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
use std::collections::BTreeMap;
2+
3+
use anyhow::Context;
4+
use anyhow::Result;
5+
use base64::Engine as _;
6+
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
7+
use ed25519_dalek::Signer as _;
8+
use serde::Deserialize;
9+
use serde::Serialize;
10+
use tracing::debug;
11+
12+
use super::*;
13+
14+
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
15+
pub(crate) struct AgentAssertionEnvelope {
16+
pub(crate) agent_runtime_id: String,
17+
pub(crate) task_id: String,
18+
pub(crate) timestamp: String,
19+
pub(crate) signature: String,
20+
}
21+
22+
impl AgentIdentityManager {
23+
pub(crate) async fn authorization_header_for_task(
24+
&self,
25+
agent_task: &RegisteredAgentTask,
26+
) -> Result<Option<String>> {
27+
if !self.feature_enabled {
28+
return Ok(None);
29+
}
30+
31+
let Some(stored_identity) = self.ensure_registered_identity().await? else {
32+
return Ok(None);
33+
};
34+
anyhow::ensure!(
35+
stored_identity.agent_runtime_id == agent_task.agent_runtime_id,
36+
"agent task runtime {} does not match stored agent identity {}",
37+
agent_task.agent_runtime_id,
38+
stored_identity.agent_runtime_id
39+
);
40+
41+
let timestamp = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
42+
let envelope = AgentAssertionEnvelope {
43+
agent_runtime_id: agent_task.agent_runtime_id.clone(),
44+
task_id: agent_task.task_id.clone(),
45+
timestamp: timestamp.clone(),
46+
signature: sign_agent_assertion_payload(&stored_identity, agent_task, &timestamp)?,
47+
};
48+
let serialized_assertion = serialize_agent_assertion(&envelope)?;
49+
debug!(
50+
agent_runtime_id = %envelope.agent_runtime_id,
51+
task_id = %envelope.task_id,
52+
"attaching agent assertion authorization to downstream request"
53+
);
54+
Ok(Some(format!("AgentAssertion {serialized_assertion}")))
55+
}
56+
}
57+
58+
fn sign_agent_assertion_payload(
59+
stored_identity: &StoredAgentIdentity,
60+
agent_task: &RegisteredAgentTask,
61+
timestamp: &str,
62+
) -> Result<String> {
63+
let signing_key = stored_identity.signing_key()?;
64+
let payload = format!(
65+
"{}:{}:{timestamp}",
66+
agent_task.agent_runtime_id, agent_task.task_id
67+
);
68+
Ok(BASE64_STANDARD.encode(signing_key.sign(payload.as_bytes()).to_bytes()))
69+
}
70+
71+
fn serialize_agent_assertion(envelope: &AgentAssertionEnvelope) -> Result<String> {
72+
let payload = serde_json::to_vec(&BTreeMap::from([
73+
("agent_runtime_id", envelope.agent_runtime_id.as_str()),
74+
("signature", envelope.signature.as_str()),
75+
("task_id", envelope.task_id.as_str()),
76+
("timestamp", envelope.timestamp.as_str()),
77+
]))
78+
.context("failed to serialize agent assertion envelope")?;
79+
Ok(URL_SAFE_NO_PAD.encode(payload))
80+
}
81+
82+
#[cfg(test)]
83+
mod tests {
84+
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
85+
use ed25519_dalek::Signature;
86+
use ed25519_dalek::Verifier as _;
87+
use pretty_assertions::assert_eq;
88+
89+
use super::*;
90+
91+
#[tokio::test]
92+
async fn authorization_header_for_task_skips_when_feature_is_disabled() {
93+
let auth_manager =
94+
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
95+
let manager = AgentIdentityManager::new_for_tests(
96+
auth_manager,
97+
/*feature_enabled*/ false,
98+
"https://chatgpt.com/backend-api/".to_string(),
99+
SessionSource::Cli,
100+
);
101+
let agent_task = RegisteredAgentTask {
102+
agent_runtime_id: "agent-123".to_string(),
103+
task_id: "task-123".to_string(),
104+
registered_at: "2026-03-23T12:00:00Z".to_string(),
105+
};
106+
107+
assert_eq!(
108+
manager
109+
.authorization_header_for_task(&agent_task)
110+
.await
111+
.unwrap(),
112+
None
113+
);
114+
}
115+
116+
#[tokio::test]
117+
async fn authorization_header_for_task_serializes_signed_agent_assertion() {
118+
let auth_manager =
119+
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
120+
let manager = AgentIdentityManager::new_for_tests(
121+
auth_manager,
122+
/*feature_enabled*/ true,
123+
"https://chatgpt.com/backend-api/".to_string(),
124+
SessionSource::Cli,
125+
);
126+
let stored_identity = manager
127+
.seed_generated_identity_for_tests("agent-123")
128+
.await
129+
.expect("seed test identity");
130+
let agent_task = RegisteredAgentTask {
131+
agent_runtime_id: "agent-123".to_string(),
132+
task_id: "task-123".to_string(),
133+
registered_at: "2026-03-23T12:00:00Z".to_string(),
134+
};
135+
136+
let header = manager
137+
.authorization_header_for_task(&agent_task)
138+
.await
139+
.expect("build agent assertion")
140+
.expect("header should exist");
141+
let token = header
142+
.strip_prefix("AgentAssertion ")
143+
.expect("agent assertion scheme");
144+
let payload = URL_SAFE_NO_PAD
145+
.decode(token)
146+
.expect("valid base64url payload");
147+
let envelope: AgentAssertionEnvelope =
148+
serde_json::from_slice(&payload).expect("valid assertion envelope");
149+
150+
assert_eq!(
151+
envelope,
152+
AgentAssertionEnvelope {
153+
agent_runtime_id: "agent-123".to_string(),
154+
task_id: "task-123".to_string(),
155+
timestamp: envelope.timestamp.clone(),
156+
signature: envelope.signature.clone(),
157+
}
158+
);
159+
let signature_bytes = BASE64_STANDARD
160+
.decode(&envelope.signature)
161+
.expect("valid base64 signature");
162+
let signature = Signature::from_slice(&signature_bytes).expect("valid signature bytes");
163+
let signing_key = stored_identity.signing_key().expect("signing key");
164+
signing_key
165+
.verifying_key()
166+
.verify(
167+
format!(
168+
"{}:{}:{}",
169+
envelope.agent_runtime_id, envelope.task_id, envelope.timestamp
170+
)
171+
.as_bytes(),
172+
&signature,
173+
)
174+
.expect("signature should verify");
175+
}
176+
}

0 commit comments

Comments
 (0)