diff --git a/codex-rs/core/src/agent_identity.rs b/codex-rs/core/src/agent_identity.rs index 515bbc7fef..1061f45fba 100644 --- a/codex-rs/core/src/agent_identity.rs +++ b/codex-rs/core/src/agent_identity.rs @@ -365,7 +365,7 @@ impl AgentIdentityManager { let stored_identity = StoredAgentIdentity { binding_id: binding.binding_id.clone(), chatgpt_account_id: binding.chatgpt_account_id.clone(), - chatgpt_user_id: binding.chatgpt_user_id.clone(), + chatgpt_user_id: binding.chatgpt_user_id, agent_runtime_id: agent_runtime_id.to_string(), private_key_pkcs8_base64: key_material.private_key_pkcs8_base64, public_key_ssh: key_material.public_key_ssh, diff --git a/codex-rs/core/src/agent_identity/assertion.rs b/codex-rs/core/src/agent_identity/assertion.rs index 5943ae5346..bbb993d106 100644 --- a/codex-rs/core/src/agent_identity/assertion.rs +++ b/codex-rs/core/src/agent_identity/assertion.rs @@ -90,8 +90,9 @@ mod tests { #[tokio::test] async fn authorization_header_for_task_skips_when_feature_is_disabled() { - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let codex_home = tempfile::tempdir().expect("tempdir"); + let auth = make_chatgpt_auth(codex_home.path(), "account-123", Some("user-123")); + let auth_manager = AuthManager::from_auth_for_testing(auth); let manager = AgentIdentityManager::new_for_tests( auth_manager, /*feature_enabled*/ false, @@ -99,9 +100,9 @@ mod tests { SessionSource::Cli, ); let agent_task = RegisteredAgentTask { - binding_id: "chatgpt-account-account_id".to_string(), - chatgpt_account_id: "account_id".to_string(), - chatgpt_user_id: None, + binding_id: "chatgpt-account-account-123".to_string(), + chatgpt_account_id: "account-123".to_string(), + chatgpt_user_id: Some("user-123".to_string()), agent_runtime_id: "agent-123".to_string(), task_id: "task-123".to_string(), registered_at: "2026-03-23T12:00:00Z".to_string(), @@ -118,8 +119,9 @@ mod tests { #[tokio::test] async fn authorization_header_for_task_serializes_signed_agent_assertion() { - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let codex_home = tempfile::tempdir().expect("tempdir"); + let auth = make_chatgpt_auth(codex_home.path(), "account-123", Some("user-123")); + let auth_manager = AuthManager::from_auth_for_testing(auth); let manager = AgentIdentityManager::new_for_tests( auth_manager, /*feature_enabled*/ true, @@ -131,9 +133,9 @@ mod tests { .await .expect("seed test identity"); let agent_task = RegisteredAgentTask { - binding_id: "chatgpt-account-account_id".to_string(), - chatgpt_account_id: "account_id".to_string(), - chatgpt_user_id: None, + binding_id: "chatgpt-account-account-123".to_string(), + chatgpt_account_id: "account-123".to_string(), + chatgpt_user_id: Some("user-123".to_string()), agent_runtime_id: "agent-123".to_string(), task_id: "task-123".to_string(), registered_at: "2026-03-23T12:00:00Z".to_string(), @@ -179,4 +181,50 @@ mod tests { ) .expect("signature should verify"); } + + fn make_chatgpt_auth( + codex_home: &std::path::Path, + account_id: &str, + user_id: Option<&str>, + ) -> CodexAuth { + let auth_json = codex_login::AuthDotJson { + auth_mode: Some(codex_app_server_protocol::AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(codex_login::token_data::TokenData { + id_token: codex_login::token_data::IdTokenInfo { + email: None, + chatgpt_plan_type: None, + chatgpt_user_id: user_id.map(ToOwned::to_owned), + chatgpt_account_id: Some(account_id.to_string()), + raw_jwt: fake_id_token(account_id, user_id), + }, + access_token: format!("access-token-{account_id}"), + refresh_token: "refresh-token".to_string(), + account_id: Some(account_id.to_string()), + }), + last_refresh: Some(chrono::Utc::now()), + agent_identity: None, + }; + codex_login::save_auth( + codex_home, + &auth_json, + codex_login::AuthCredentialsStoreMode::File, + ) + .expect("save auth"); + CodexAuth::from_auth_storage(codex_home, codex_login::AuthCredentialsStoreMode::File) + .expect("load auth") + .expect("auth") + } + + fn fake_id_token(account_id: &str, user_id: Option<&str>) -> String { + let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none","typ":"JWT"}"#); + let payload = serde_json::json!({ + "https://api.openai.com/auth": { + "chatgpt_user_id": user_id, + "chatgpt_account_id": account_id, + } + }); + let payload = URL_SAFE_NO_PAD.encode(payload.to_string()); + format!("{header}.{payload}.signature") + } } diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index 4a370f57ae..2d96e2c5fa 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -17,11 +17,16 @@ use crate::agent_identity::RegisteredAgentTask; use crate::agent_identity::StoredAgentIdentity; use base64::Engine as _; use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use chrono::Utc; use codex_api::CoreAuthProvider; use codex_app_server_protocol::AuthMode; -use codex_keyring_store::tests::MockKeyringStore; +use codex_login::AuthCredentialsStoreMode; +use codex_login::AuthDotJson; use codex_login::AuthManager; use codex_login::CodexAuth; +use codex_login::save_auth; +use codex_login::token_data::IdTokenInfo; +use codex_login::token_data::TokenData; use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::WireApi; use codex_model_provider_info::create_oss_provider_with_base_url; @@ -33,8 +38,6 @@ use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelInfo; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; -use codex_secrets::SecretsBackendKind; -use codex_secrets::SecretsManager; use core_test_support::responses; use ed25519_dalek::Signature; use ed25519_dalek::Verifier as _; @@ -136,20 +139,13 @@ async fn model_client_with_agent_task( StoredAgentIdentity, ) { let codex_home = tempfile::tempdir().expect("tempdir"); - let keyring_store = Arc::new(MockKeyringStore::default()); - let secrets_manager = SecretsManager::new_with_keyring_store( - codex_home.path().to_path_buf(), - SecretsBackendKind::Local, - keyring_store, - ); - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let auth = make_chatgpt_auth(codex_home.path(), "account-123", Some("user-123")); + let auth_manager = AuthManager::from_auth_for_testing(auth); let agent_identity_manager = Arc::new(AgentIdentityManager::new_for_tests( Arc::clone(&auth_manager), /*feature_enabled*/ true, "https://chatgpt.com/backend-api/".to_string(), SessionSource::Cli, - secrets_manager, )); let stored_identity = agent_identity_manager .seed_generated_identity_for_tests("agent-123") @@ -178,6 +174,47 @@ async fn model_client_with_agent_task( (codex_home, client, agent_task, stored_identity) } +fn make_chatgpt_auth( + codex_home: &std::path::Path, + account_id: &str, + user_id: Option<&str>, +) -> CodexAuth { + let auth_json = AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(TokenData { + id_token: IdTokenInfo { + email: None, + chatgpt_plan_type: None, + chatgpt_user_id: user_id.map(ToOwned::to_owned), + chatgpt_account_id: Some(account_id.to_string()), + raw_jwt: fake_id_token(account_id, user_id), + }, + access_token: format!("access-token-{account_id}"), + refresh_token: "refresh-token".to_string(), + account_id: Some(account_id.to_string()), + }), + last_refresh: Some(Utc::now()), + agent_identity: None, + }; + save_auth(codex_home, &auth_json, AuthCredentialsStoreMode::File).expect("save auth"); + CodexAuth::from_auth_storage(codex_home, AuthCredentialsStoreMode::File) + .expect("load auth") + .expect("auth") +} + +fn fake_id_token(account_id: &str, user_id: Option<&str>) -> String { + let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none","typ":"JWT"}"#); + let payload = serde_json::json!({ + "https://api.openai.com/auth": { + "chatgpt_user_id": user_id, + "chatgpt_account_id": account_id, + } + }); + let payload = URL_SAFE_NO_PAD.encode(payload.to_string()); + format!("{header}.{payload}.signature") +} + fn assert_agent_assertion_header( authorization_header: &str, stored_identity: &StoredAgentIdentity, @@ -420,7 +457,7 @@ async fn websocket_agent_task_bypasses_cached_bearer_prewarm() { assert_eq!(handshakes.len(), 2); assert_eq!( handshakes[0].header("authorization"), - Some("Bearer Access Token".to_string()) + Some("Bearer access-token-account-123".to_string()) ); let agent_authorization = handshakes[1] .header("authorization") diff --git a/codex-rs/core/tests/suite/responses_api_proxy_headers.rs b/codex-rs/core/tests/suite/responses_api_proxy_headers.rs index 6bfb8826fb..a4d3d8d6ee 100644 --- a/codex-rs/core/tests/suite/responses_api_proxy_headers.rs +++ b/codex-rs/core/tests/suite/responses_api_proxy_headers.rs @@ -138,6 +138,10 @@ async fn responses_api_proxy_dumps_parent_and_subagent_identity_headers() -> Res let proxy_base_url = proxy.base_url(); let mut builder = test_codex().with_config(move |config| { config.model_provider.base_url = Some(proxy_base_url); + config + .features + .enable(Feature::Collab) + .expect("test config should allow feature update"); config .features .disable(Feature::EnableRequestCompression) @@ -179,7 +183,23 @@ async fn responses_api_proxy_dumps_parent_and_subagent_identity_headers() -> Res } fn request_body_contains(req: &wiremock::Request, text: &str) -> bool { - std::str::from_utf8(&req.body).is_ok_and(|body| body.contains(text)) + let is_zstd = req + .headers + .get("content-encoding") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| { + value + .split(',') + .any(|entry| entry.trim().eq_ignore_ascii_case("zstd")) + }); + let bytes = if is_zstd { + zstd::stream::decode_all(std::io::Cursor::new(&req.body)).ok() + } else { + Some(req.body.clone()) + }; + bytes + .and_then(|body| String::from_utf8(body).ok()) + .is_some_and(|body| body.contains(text)) } fn wait_for_proxy_request_dumps(dump_dir: &Path) -> Result> {