Skip to content

Commit b9a0661

Browse files
committed
Load cloud requirements for agent identity
1 parent 273c2e2 commit b9a0661

7 files changed

Lines changed: 237 additions & 48 deletions

File tree

codex-rs/agent-identity/src/lib.rs

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
88
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
99
use chrono::SecondsFormat;
1010
use chrono::Utc;
11-
use codex_protocol::account::PlanType as AccountPlanType;
11+
use codex_protocol::auth::PlanType as AuthPlanType;
1212
use codex_protocol::protocol::SessionSource;
1313
use crypto_box::SecretKey as Curve25519SecretKey;
1414
use ed25519_dalek::Signer as _;
@@ -73,7 +73,7 @@ pub struct AgentIdentityJwtClaims {
7373
pub account_id: String,
7474
pub chatgpt_user_id: String,
7575
pub email: String,
76-
pub plan_type: AccountPlanType,
76+
pub plan_type: AuthPlanType,
7777
pub chatgpt_account_is_fedramp: bool,
7878
}
7979

@@ -408,6 +408,8 @@ mod tests {
408408
use jsonwebtoken::Header;
409409
use pretty_assertions::assert_eq;
410410

411+
use codex_protocol::auth::KnownPlan;
412+
411413
use super::*;
412414

413415
#[test]
@@ -517,12 +519,33 @@ mod tests {
517519
account_id: "account-id".to_string(),
518520
chatgpt_user_id: "user-id".to_string(),
519521
email: "user@example.com".to_string(),
520-
plan_type: AccountPlanType::Pro,
522+
plan_type: AuthPlanType::Known(KnownPlan::Pro),
521523
chatgpt_account_is_fedramp: false,
522524
}
523525
);
524526
}
525527

528+
#[test]
529+
fn decode_agent_identity_jwt_maps_raw_plan_aliases() {
530+
let jwt = jwt_with_payload(serde_json::json!({
531+
"iss": AGENT_IDENTITY_JWT_ISSUER,
532+
"aud": AGENT_IDENTITY_JWT_AUDIENCE,
533+
"iat": 1_700_000_000usize,
534+
"exp": 4_000_000_000usize,
535+
"agent_runtime_id": "agent-runtime-id",
536+
"agent_private_key": "private-key",
537+
"account_id": "account-id",
538+
"chatgpt_user_id": "user-id",
539+
"email": "user@example.com",
540+
"plan_type": "hc",
541+
"chatgpt_account_is_fedramp": false,
542+
}));
543+
544+
let claims = decode_agent_identity_jwt(&jwt, /*jwks*/ None).expect("JWT should decode");
545+
546+
assert_eq!(claims.plan_type, AuthPlanType::Known(KnownPlan::Enterprise));
547+
}
548+
526549
#[test]
527550
fn decode_agent_identity_jwt_verifies_when_jwks_is_present() {
528551
let jwks = test_jwks("test-key");
@@ -536,7 +559,7 @@ mod tests {
536559
account_id: "account-id".to_string(),
537560
chatgpt_user_id: "user-id".to_string(),
538561
email: "user@example.com".to_string(),
539-
plan_type: AccountPlanType::Pro,
562+
plan_type: AuthPlanType::Known(KnownPlan::Pro),
540563
chatgpt_account_is_fedramp: false,
541564
};
542565
let jwt = jsonwebtoken::encode(
@@ -568,7 +591,7 @@ mod tests {
568591
account_id: "account-id".to_string(),
569592
chatgpt_user_id: "user-id".to_string(),
570593
email: "user@example.com".to_string(),
571-
plan_type: AccountPlanType::Pro,
594+
plan_type: AuthPlanType::Known(KnownPlan::Pro),
572595
chatgpt_account_is_fedramp: false,
573596
};
574597
assert_eq!(

codex-rs/cloud-requirements/src/lib.rs

Lines changed: 82 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,14 @@ fn auth_identity(auth: &CodexAuth) -> (Option<String>, Option<String>) {
179179
(auth.get_chatgpt_user_id(), auth.get_account_id())
180180
}
181181

182+
fn cloud_requirements_eligible_auth(auth: &CodexAuth) -> bool {
183+
let Some(plan_type) = auth.account_plan_type() else {
184+
return false;
185+
};
186+
auth.uses_codex_backend()
187+
&& (plan_type.is_business_like() || matches!(plan_type, PlanType::Enterprise))
188+
}
189+
182190
fn cache_payload_bytes(payload: &CloudRequirementsCacheSignedPayload) -> Option<Vec<u8>> {
183191
serde_json::to_vec(&payload).ok()
184192
}
@@ -329,17 +337,7 @@ impl CloudRequirementsService {
329337
let Some(auth) = self.auth_manager.auth().await else {
330338
return Ok(None);
331339
};
332-
if matches!(auth, CodexAuth::AgentIdentity(_)) {
333-
// AgentIdentity does not carry a human bearer token, and identity-edge
334-
// only allowlists task-scoped AgentAssertion calls for the Codex runtime.
335-
return Ok(None);
336-
}
337-
let Some(plan_type) = auth.account_plan_type() else {
338-
return Ok(None);
339-
};
340-
if !auth.uses_codex_backend()
341-
|| !(plan_type.is_business_like() || matches!(plan_type, PlanType::Enterprise))
342-
{
340+
if !cloud_requirements_eligible_auth(&auth) {
343341
return Ok(None);
344342
}
345343
let (chatgpt_user_id, account_id) = auth_identity(&auth);
@@ -554,12 +552,7 @@ impl CloudRequirementsService {
554552
let Some(auth) = self.auth_manager.auth().await else {
555553
return false;
556554
};
557-
let Some(plan_type) = auth.account_plan_type() else {
558-
return false;
559-
};
560-
if !auth.uses_codex_backend()
561-
|| !(plan_type.is_business_like() || matches!(plan_type, PlanType::Enterprise))
562-
{
555+
if !cloud_requirements_eligible_auth(&auth) {
563556
return false;
564557
}
565558

@@ -837,18 +830,41 @@ mod tests {
837830
use base64::Engine;
838831
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
839832
use codex_config::types::AuthCredentialsStoreMode;
833+
use codex_login::auth::AgentIdentityAuth;
834+
use codex_login::auth::AgentIdentityAuthRecord;
840835
use codex_protocol::protocol::AskForApproval;
841836
use pretty_assertions::assert_eq;
842837
use serde_json::json;
843838
use std::collections::BTreeMap;
844839
use std::collections::VecDeque;
840+
use std::ffi::OsString;
845841
use std::future::pending;
842+
use std::io::Read;
843+
use std::io::Write;
844+
use std::net::TcpListener;
846845
use std::path::Path;
847846
use std::sync::atomic::AtomicUsize;
848847
use std::sync::atomic::Ordering;
848+
use std::thread;
849849
use tempfile::TempDir;
850850
use tempfile::tempdir;
851851

852+
struct EnvVarGuard {
853+
key: &'static str,
854+
original: Option<OsString>,
855+
}
856+
857+
impl Drop for EnvVarGuard {
858+
fn drop(&mut self) {
859+
unsafe {
860+
match &self.original {
861+
Some(value) => std::env::set_var(self.key, value),
862+
None => std::env::remove_var(self.key),
863+
}
864+
}
865+
}
866+
}
867+
852868
fn write_auth_json(codex_home: &Path, value: serde_json::Value) -> std::io::Result<()> {
853869
std::fs::write(codex_home.join("auth.json"), serde_json::to_string(&value)?)?;
854870
Ok(())
@@ -1200,6 +1216,55 @@ mod tests {
12001216
);
12011217
}
12021218

1219+
#[tokio::test]
1220+
async fn cloud_requirements_eligible_auth_allows_agent_identity_business_plan() {
1221+
let listener = TcpListener::bind("127.0.0.1:0").expect("bind task registration server");
1222+
let addr = listener
1223+
.local_addr()
1224+
.expect("task registration server addr");
1225+
let server = thread::spawn(move || {
1226+
let (mut stream, _) = listener.accept().expect("accept task registration request");
1227+
let mut request = [0; 4096];
1228+
let _ = stream
1229+
.read(&mut request)
1230+
.expect("read task registration request");
1231+
let body = r#"{"task_id":"task-123"}"#;
1232+
write!(
1233+
stream,
1234+
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
1235+
body.len(),
1236+
body
1237+
)
1238+
.expect("write task registration response");
1239+
});
1240+
let record = AgentIdentityAuthRecord {
1241+
agent_runtime_id: "agent-runtime-123".to_string(),
1242+
agent_private_key: "MC4CAQAwBQYDK2VwBCIEIDQg14jybCLydjHQwXeBzsDM7oB6BSAenodx6oCovQ/D"
1243+
.to_string(),
1244+
account_id: "account-12345".to_string(),
1245+
chatgpt_user_id: "user-12345".to_string(),
1246+
email: "user@example.com".to_string(),
1247+
plan_type: PlanType::Business,
1248+
chatgpt_account_is_fedramp: false,
1249+
};
1250+
let authapi_base_url = format!("http://{addr}/backend-api");
1251+
let original_authapi_base_url = std::env::var_os("CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL");
1252+
unsafe {
1253+
std::env::set_var("CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL", &authapi_base_url);
1254+
}
1255+
let _authapi_guard = EnvVarGuard {
1256+
key: "CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL",
1257+
original: original_authapi_base_url,
1258+
};
1259+
let auth = AgentIdentityAuth::load(record)
1260+
.await
1261+
.map(CodexAuth::AgentIdentity)
1262+
.expect("agent identity auth");
1263+
server.join().expect("task registration server joined");
1264+
1265+
assert!(cloud_requirements_eligible_auth(&auth));
1266+
}
1267+
12031268
#[tokio::test]
12041269
async fn fetch_cloud_requirements_allows_business_like_usage_based_plan() {
12051270
let codex_home = tempdir().expect("tempdir");

codex-rs/login/src/auth/auth_tests.rs

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,8 @@ async fn login_with_agent_identity_writes_only_token() {
8888
let dir = tempdir().unwrap();
8989
let auth_path = dir.path().join("auth.json");
9090
let record = agent_identity_record("account-123");
91-
let agent_identity = signed_agent_identity_jwt(&record).expect("signed agent identity");
91+
let agent_identity =
92+
signed_agent_identity_jwt(&record, json!(record.plan_type)).expect("signed agent identity");
9293
let server = MockServer::start().await;
9394
Mock::given(method("GET"))
9495
.and(path("/backend-api/wham/agent-identities/jwks"))
@@ -709,7 +710,8 @@ async fn load_auth_reads_agent_identity_from_env() {
709710
let codex_home = tempdir().unwrap();
710711
let expected_record = agent_identity_record("account-123");
711712
let agent_identity =
712-
signed_agent_identity_jwt(&expected_record).expect("signed agent identity");
713+
signed_agent_identity_jwt(&expected_record, json!(expected_record.plan_type))
714+
.expect("signed agent identity");
713715
let server = MockServer::start().await;
714716
Mock::given(method("GET"))
715717
.and(path("/backend-api/wham/agent-identities/jwks"))
@@ -925,6 +927,13 @@ fn agent_identity_record(account_id: &str) -> AgentIdentityAuthRecord {
925927
}
926928

927929
fn fake_agent_identity_jwt(record: &AgentIdentityAuthRecord) -> std::io::Result<String> {
930+
fake_agent_identity_jwt_with_plan_type(record, serde_json::to_value(record.plan_type)?)
931+
}
932+
933+
fn fake_agent_identity_jwt_with_plan_type(
934+
record: &AgentIdentityAuthRecord,
935+
plan_type: serde_json::Value,
936+
) -> std::io::Result<String> {
928937
let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
929938
let header_b64 = encode(br#"{"alg":"EdDSA","typ":"JWT"}"#);
930939
let payload = json!({
@@ -937,7 +946,7 @@ fn fake_agent_identity_jwt(record: &AgentIdentityAuthRecord) -> std::io::Result<
937946
"account_id": record.account_id,
938947
"chatgpt_user_id": record.chatgpt_user_id,
939948
"email": record.email,
940-
"plan_type": record.plan_type,
949+
"plan_type": plan_type,
941950
"chatgpt_account_is_fedramp": record.chatgpt_account_is_fedramp,
942951
});
943952
let payload_b64 = encode(&serde_json::to_vec(&payload)?);
@@ -947,6 +956,7 @@ fn fake_agent_identity_jwt(record: &AgentIdentityAuthRecord) -> std::io::Result<
947956

948957
fn signed_agent_identity_jwt(
949958
record: &AgentIdentityAuthRecord,
959+
plan_type: serde_json::Value,
950960
) -> jsonwebtoken::errors::Result<String> {
951961
let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256);
952962
header.kid = Some("test-key".to_string());
@@ -962,7 +972,7 @@ fn signed_agent_identity_jwt(
962972
"account_id": record.account_id,
963973
"chatgpt_user_id": record.chatgpt_user_id,
964974
"email": record.email,
965-
"plan_type": record.plan_type,
975+
"plan_type": plan_type,
966976
"chatgpt_account_is_fedramp": record.chatgpt_account_is_fedramp,
967977
}),
968978
&jsonwebtoken::EncodingKey::from_rsa_pem(TEST_AGENT_IDENTITY_RSA_PRIVATE_KEY_PEM)?,
@@ -1011,6 +1021,48 @@ J1bwkqKZTB5dHolX9A58e/xXnfZ5P8f3Z83+Izap3FwqQulk7b1WO1MQcHuVg2NN
10111021
8U4M2TSWCKUY/A6sT4W8+mT9
10121022
-----END PRIVATE KEY-----"#;
10131023

1024+
#[tokio::test]
1025+
#[serial(codex_auth_env)]
1026+
async fn agent_identity_plan_type_maps_raw_enterprise_alias() {
1027+
assert_agent_identity_plan_alias(json!("hc"), AccountPlanType::Enterprise).await;
1028+
}
1029+
1030+
#[tokio::test]
1031+
#[serial(codex_auth_env)]
1032+
async fn agent_identity_plan_type_maps_raw_education_alias() {
1033+
assert_agent_identity_plan_alias(json!("education"), AccountPlanType::Edu).await;
1034+
}
1035+
1036+
async fn assert_agent_identity_plan_alias(
1037+
plan_type: serde_json::Value,
1038+
expected_plan_type: AccountPlanType,
1039+
) {
1040+
let record = agent_identity_record("account-id");
1041+
let jwt = signed_agent_identity_jwt(&record, plan_type).expect("agent identity jwt");
1042+
let server = MockServer::start().await;
1043+
Mock::given(method("GET"))
1044+
.and(path("/backend-api/wham/agent-identities/jwks"))
1045+
.respond_with(ResponseTemplate::new(200).set_body_json(test_jwks_body()))
1046+
.expect(1)
1047+
.mount(&server)
1048+
.await;
1049+
Mock::given(method("POST"))
1050+
.and(path("/backend-api/v1/agent/agent-runtime-id/task/register"))
1051+
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
1052+
"task_id": "task-123",
1053+
})))
1054+
.expect(1)
1055+
.mount(&server)
1056+
.await;
1057+
let chatgpt_base_url = format!("{}/backend-api", server.uri());
1058+
let auth = CodexAuth::from_agent_identity_jwt(&jwt, Some(&chatgpt_base_url))
1059+
.await
1060+
.expect("agent identity auth");
1061+
1062+
pretty_assertions::assert_eq!(auth.account_plan_type(), Some(expected_plan_type));
1063+
server.verify().await;
1064+
}
1065+
10141066
#[tokio::test]
10151067
#[serial(codex_auth_env)]
10161068
async fn plan_type_maps_known_plan() {

codex-rs/login/src/auth/manager.rs

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@ use crate::token_data::parse_jwt_expiration;
3939
use codex_client::CodexHttpClient;
4040
use codex_config::types::AuthCredentialsStoreMode;
4141
use codex_protocol::account::PlanType as AccountPlanType;
42-
use codex_protocol::auth::KnownPlan as InternalKnownPlan;
4342
use codex_protocol::auth::PlanType as InternalPlanType;
4443
use codex_protocol::auth::RefreshTokenFailedError;
4544
use codex_protocol::auth::RefreshTokenFailedReason;
@@ -380,29 +379,10 @@ impl CodexAuth {
380379
return Some(auth.plan_type());
381380
}
382381

383-
let map_known = |kp: &InternalKnownPlan| match kp {
384-
InternalKnownPlan::Free => AccountPlanType::Free,
385-
InternalKnownPlan::Go => AccountPlanType::Go,
386-
InternalKnownPlan::Plus => AccountPlanType::Plus,
387-
InternalKnownPlan::Pro => AccountPlanType::Pro,
388-
InternalKnownPlan::ProLite => AccountPlanType::ProLite,
389-
InternalKnownPlan::Team => AccountPlanType::Team,
390-
InternalKnownPlan::SelfServeBusinessUsageBased => {
391-
AccountPlanType::SelfServeBusinessUsageBased
392-
}
393-
InternalKnownPlan::Business => AccountPlanType::Business,
394-
InternalKnownPlan::EnterpriseCbpUsageBased => AccountPlanType::EnterpriseCbpUsageBased,
395-
InternalKnownPlan::Enterprise => AccountPlanType::Enterprise,
396-
InternalKnownPlan::Edu => AccountPlanType::Edu,
397-
};
398-
399382
self.get_current_token_data().map(|t| {
400383
t.id_token
401384
.chatgpt_plan_type
402-
.map(|pt| match pt {
403-
InternalPlanType::Known(k) => map_known(&k),
404-
InternalPlanType::Unknown(_) => AccountPlanType::Unknown,
405-
})
385+
.map(AccountPlanType::from)
406386
.unwrap_or(AccountPlanType::Unknown)
407387
})
408388
}

codex-rs/login/src/auth/storage.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ impl From<AgentIdentityJwtClaims> for AgentIdentityAuthRecord {
7575
account_id: claims.account_id,
7676
chatgpt_user_id: claims.chatgpt_user_id,
7777
email: claims.email,
78-
plan_type: claims.plan_type,
78+
plan_type: claims.plan_type.into(),
7979
chatgpt_account_is_fedramp: claims.chatgpt_account_is_fedramp,
8080
}
8181
}

0 commit comments

Comments
 (0)