mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Allow ChatGPT accounts without email (#28991)
# Summary Codex required every ChatGPT account to have an email address. A service-account personal access token can return valid account metadata without one, so PAT login failed while decoding the metadata response. This change makes email optional in the account metadata type that owns it and preserves that absence through authentication, provider account state, the app-server API, generated clients, and TUI bootstrap. Existing accounts with email addresses keep the same behavior. ## Behavior-changing call sites | Call site | Behavior after this change | | --- | --- | | `login/src/auth/personal_access_token.rs` | PAT metadata accepts a missing or null email and retains `None`. | | `agent-identity/src/lib.rs` | Agent Identity JWT claims accept an omitted email. | | `login/src/auth/storage.rs` and `login/src/auth/agent_identity.rs` | Stored and managed Agent Identity records carry `Option<String>`. Deserialization maps the legacy empty-string sentinel to `None`. | | `login/src/auth/manager.rs` | `get_account_email` returns the stored option, and managed identity bootstrap no longer converts `None` to an empty string. | | `model-provider/src/provider.rs` and `protocol/src/account.rs` | A ChatGPT provider account requires a plan type but may carry no email. | | `app-server-protocol/src/protocol/v2/account.rs` | `account/read` keeps the `email` field on the wire and returns `null` when the account has no email. Generated TypeScript and JSON schemas describe a required, nullable field. | | `sdk/python/src/openai_codex/generated/v2_all.py` | The generated Python `ChatgptAccount` model accepts `None` for email. | | `tui/src/app_server_session.rs` | Email-less ChatGPT accounts bootstrap normally, keep external feedback routing, omit account-email telemetry, and display the plan in account status. | ## Design decisions - Missing email remains `None` at every layer. The code never uses an empty string as a substitute. - The app-server response includes `"email": null` instead of omitting the field. Clients retain a stable response shape. - Plan type remains required for provider account state. This change relaxes only the email assumption. ## Testing Tests: affected test targets compile, scoped Clippy and formatting pass, a focused TUI snapshot covers plan-only account status, real before/after PAT login smoke covers metadata without email, app-server smoke covers `account/read` with `email: null`, and a regression smoke covers an existing email-bearing PAT. Unit tests run in CI. ## Evidence Visual smoke evidence will be attached here.
This commit is contained in:
Generated
+1
@@ -4016,6 +4016,7 @@ name = "codex-tui"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"app_test_support",
|
||||
"arboard",
|
||||
"assert_matches",
|
||||
"base64 0.22.1",
|
||||
|
||||
@@ -119,7 +119,7 @@ pub struct AgentIdentityJwtClaims {
|
||||
pub agent_private_key: String,
|
||||
pub account_id: String,
|
||||
pub chatgpt_user_id: String,
|
||||
pub email: String,
|
||||
pub email: Option<String>,
|
||||
pub plan_type: AuthPlanType,
|
||||
pub chatgpt_account_is_fedramp: bool,
|
||||
}
|
||||
@@ -667,13 +667,33 @@ mod tests {
|
||||
agent_private_key: "private-key".to_string(),
|
||||
account_id: "account-id".to_string(),
|
||||
chatgpt_user_id: "user-id".to_string(),
|
||||
email: "user@example.com".to_string(),
|
||||
email: Some("user@example.com".to_string()),
|
||||
plan_type: AuthPlanType::Known(KnownPlan::Pro),
|
||||
chatgpt_account_is_fedramp: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_agent_identity_jwt_accepts_missing_email() {
|
||||
let jwt = jwt_with_payload(serde_json::json!({
|
||||
"iss": AGENT_IDENTITY_JWT_ISSUER,
|
||||
"aud": AGENT_IDENTITY_JWT_AUDIENCE,
|
||||
"iat": 1_700_000_000usize,
|
||||
"exp": 4_000_000_000usize,
|
||||
"agent_runtime_id": "agent-runtime-id",
|
||||
"agent_private_key": "private-key",
|
||||
"account_id": "account-id",
|
||||
"chatgpt_user_id": "user-id",
|
||||
"plan_type": "pro",
|
||||
"chatgpt_account_is_fedramp": false,
|
||||
}));
|
||||
|
||||
let claims = decode_agent_identity_jwt(&jwt, /*jwks*/ None).expect("JWT should decode");
|
||||
|
||||
assert_eq!(claims.email, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_agent_identity_jwt_maps_raw_plan_aliases() {
|
||||
let jwt = jwt_with_payload(serde_json::json!({
|
||||
@@ -707,7 +727,7 @@ mod tests {
|
||||
agent_private_key: "private-key".to_string(),
|
||||
account_id: "account-id".to_string(),
|
||||
chatgpt_user_id: "user-id".to_string(),
|
||||
email: "user@example.com".to_string(),
|
||||
email: Some("user@example.com".to_string()),
|
||||
plan_type: AuthPlanType::Known(KnownPlan::Pro),
|
||||
chatgpt_account_is_fedramp: false,
|
||||
};
|
||||
@@ -739,7 +759,7 @@ mod tests {
|
||||
agent_private_key: "private-key".to_string(),
|
||||
account_id: "account-id".to_string(),
|
||||
chatgpt_user_id: "user-id".to_string(),
|
||||
email: "user@example.com".to_string(),
|
||||
email: Some("user@example.com".to_string()),
|
||||
plan_type: AuthPlanType::Known(KnownPlan::Pro),
|
||||
chatgpt_account_is_fedramp: false,
|
||||
};
|
||||
|
||||
+4
-1
@@ -5900,7 +5900,10 @@
|
||||
{
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string"
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"planType": {
|
||||
"$ref": "#/definitions/v2/PlanType"
|
||||
|
||||
+4
-1
@@ -26,7 +26,10 @@
|
||||
{
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string"
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"planType": {
|
||||
"$ref": "#/definitions/PlanType"
|
||||
|
||||
@@ -22,7 +22,10 @@
|
||||
{
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string"
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"planType": {
|
||||
"$ref": "#/definitions/PlanType"
|
||||
|
||||
@@ -4,4 +4,4 @@
|
||||
import type { AmazonBedrockCredentialSource } from "../AmazonBedrockCredentialSource";
|
||||
import type { PlanType } from "../PlanType";
|
||||
|
||||
export type Account = { "type": "apiKey", } | { "type": "chatgpt", email: string, planType: PlanType, } | { "type": "amazonBedrock", credentialSource: AmazonBedrockCredentialSource, };
|
||||
export type Account = { "type": "apiKey", } | { "type": "chatgpt", email: string | null, planType: PlanType, } | { "type": "amazonBedrock", credentialSource: AmazonBedrockCredentialSource, };
|
||||
|
||||
@@ -2854,7 +2854,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let chatgpt = v2::Account::Chatgpt {
|
||||
email: "user@example.com".to_string(),
|
||||
email: Some("user@example.com".to_string()),
|
||||
plan_type: PlanType::Plus,
|
||||
};
|
||||
assert_eq!(
|
||||
@@ -2866,6 +2866,19 @@ mod tests {
|
||||
serde_json::to_value(&chatgpt)?,
|
||||
);
|
||||
|
||||
let chatgpt_without_email = v2::Account::Chatgpt {
|
||||
email: None,
|
||||
plan_type: PlanType::Pro,
|
||||
};
|
||||
assert_eq!(
|
||||
json!({
|
||||
"type": "chatgpt",
|
||||
"email": null,
|
||||
"planType": "pro",
|
||||
}),
|
||||
serde_json::to_value(&chatgpt_without_email)?,
|
||||
);
|
||||
|
||||
let codex_managed_bedrock = v2::Account::AmazonBedrock {
|
||||
credential_source: AmazonBedrockCredentialSource::CodexManaged,
|
||||
};
|
||||
|
||||
@@ -25,7 +25,11 @@ pub enum Account {
|
||||
|
||||
#[serde(rename = "chatgpt", rename_all = "camelCase")]
|
||||
#[ts(rename = "chatgpt", rename_all = "camelCase")]
|
||||
Chatgpt { email: String, plan_type: PlanType },
|
||||
Chatgpt {
|
||||
#[schemars(required, schema_with = "nullable_string_schema")]
|
||||
email: Option<String>,
|
||||
plan_type: PlanType,
|
||||
},
|
||||
|
||||
#[serde(rename = "amazonBedrock", rename_all = "camelCase")]
|
||||
#[ts(rename = "amazonBedrock", rename_all = "camelCase")]
|
||||
@@ -35,6 +39,12 @@ pub enum Account {
|
||||
},
|
||||
}
|
||||
|
||||
fn nullable_string_schema(
|
||||
generator: &mut schemars::r#gen::SchemaGenerator,
|
||||
) -> schemars::schema::Schema {
|
||||
generator.subschema_for::<Option<String>>()
|
||||
}
|
||||
|
||||
fn default_bedrock_credential_source() -> AmazonBedrockCredentialSource {
|
||||
AmazonBedrockCredentialSource::AwsManaged
|
||||
}
|
||||
|
||||
@@ -1941,6 +1941,7 @@ Response examples:
|
||||
{ "id": 1, "result": { "account": null, "requiresOpenaiAuth": true } } // OpenAI auth required (typical for OpenAI-hosted models)
|
||||
{ "id": 1, "result": { "account": { "type": "apiKey" }, "requiresOpenaiAuth": true } }
|
||||
{ "id": 1, "result": { "account": { "type": "chatgpt", "email": "user@example.com", "planType": "pro" }, "requiresOpenaiAuth": true } }
|
||||
{ "id": 1, "result": { "account": { "type": "chatgpt", "email": null, "planType": "enterprise" }, "requiresOpenaiAuth": true } }
|
||||
{ "id": 1, "result": { "account": { "type": "amazonBedrock", "credentialSource": "codexManaged" }, "requiresOpenaiAuth": false } }
|
||||
{ "id": 1, "result": { "account": { "type": "amazonBedrock", "credentialSource": "awsManaged" }, "requiresOpenaiAuth": false } }
|
||||
```
|
||||
@@ -1948,6 +1949,7 @@ Response examples:
|
||||
Field notes:
|
||||
|
||||
- `refreshToken` (bool): set `true` to force a token refresh.
|
||||
- `email` is `null` when the ChatGPT account does not have an email address.
|
||||
- `requiresOpenaiAuth` reflects the active provider; when `false`, Codex can run without OpenAI credentials.
|
||||
- Amazon Bedrock reports `credentialSource: "codexManaged"` when it uses a Bedrock API key managed by Codex. Otherwise it reports `credentialSource: "awsManaged"` for the external AWS credential path. This identifies the selected credential source; it does not validate that the AWS credential chain can resolve credentials.
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@ use app_test_support::to_response;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
use chrono::Duration;
|
||||
use chrono::Utc;
|
||||
use codex_app_server_protocol::Account;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_app_server_protocol::GetAccountParams;
|
||||
use codex_app_server_protocol::GetAccountResponse;
|
||||
use codex_app_server_protocol::GetAuthStatusParams;
|
||||
use codex_app_server_protocol::GetAuthStatusResponse;
|
||||
use codex_app_server_protocol::JSONRPCError;
|
||||
@@ -14,6 +17,7 @@ use codex_app_server_protocol::LoginAccountResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR;
|
||||
use codex_protocol::account::PlanType as AccountPlanType;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
@@ -162,7 +166,7 @@ async fn get_auth_status_with_api_key() -> Result<()> {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn get_auth_status_with_personal_access_token_omits_token() -> Result<()> {
|
||||
async fn personal_access_token_without_email_supports_auth_status_and_account_read() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path())?;
|
||||
|
||||
@@ -171,7 +175,7 @@ async fn get_auth_status_with_personal_access_token_omits_token() -> Result<()>
|
||||
.and(path("/v1/user-auth-credential/whoami"))
|
||||
.and(header("Authorization", "Bearer at-test-token"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"email": "user@example.com",
|
||||
"email": null,
|
||||
"chatgpt_user_id": "user-123",
|
||||
"chatgpt_account_id": "account-123",
|
||||
"chatgpt_plan_type": "pro",
|
||||
@@ -215,6 +219,34 @@ async fn get_auth_status_with_personal_access_token_omits_token() -> Result<()>
|
||||
}
|
||||
);
|
||||
|
||||
let request_id = mcp
|
||||
.send_get_account_request(GetAccountParams {
|
||||
refresh_token: false,
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(
|
||||
response
|
||||
.result
|
||||
.get("account")
|
||||
.and_then(|account| account.get("email")),
|
||||
Some(&serde_json::Value::Null),
|
||||
);
|
||||
assert_eq!(
|
||||
to_response::<GetAccountResponse>(response)?,
|
||||
GetAccountResponse {
|
||||
account: Some(Account::Chatgpt {
|
||||
email: None,
|
||||
plan_type: AccountPlanType::Pro,
|
||||
}),
|
||||
requires_openai_auth: true,
|
||||
}
|
||||
);
|
||||
|
||||
server.verify().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -320,7 +320,7 @@ async fn set_auth_token_updates_account_and_notifies() -> Result<()> {
|
||||
account,
|
||||
GetAccountResponse {
|
||||
account: Some(Account::Chatgpt {
|
||||
email: "embedded@example.com".to_string(),
|
||||
email: Some("embedded@example.com".to_string()),
|
||||
plan_type: AccountPlanType::Pro,
|
||||
}),
|
||||
requires_openai_auth: true,
|
||||
@@ -388,7 +388,7 @@ async fn account_read_refresh_token_is_noop_in_external_mode() -> Result<()> {
|
||||
account,
|
||||
GetAccountResponse {
|
||||
account: Some(Account::Chatgpt {
|
||||
email: "embedded@example.com".to_string(),
|
||||
email: Some("embedded@example.com".to_string()),
|
||||
plan_type: AccountPlanType::Pro,
|
||||
}),
|
||||
requires_openai_auth: true,
|
||||
@@ -1817,7 +1817,7 @@ async fn get_account_with_chatgpt() -> Result<()> {
|
||||
|
||||
let expected = GetAccountResponse {
|
||||
account: Some(Account::Chatgpt {
|
||||
email: "user@example.com".to_string(),
|
||||
email: Some("user@example.com".to_string()),
|
||||
plan_type: AccountPlanType::Pro,
|
||||
}),
|
||||
requires_openai_auth: true,
|
||||
@@ -1826,6 +1826,51 @@ async fn get_account_with_chatgpt() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_account_with_chatgpt_without_email() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(
|
||||
codex_home.path(),
|
||||
CreateConfigTomlParams {
|
||||
requires_openai_auth: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("access-chatgpt").plan_type("pro"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let mut mcp =
|
||||
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_get_account_request(GetAccountParams {
|
||||
refresh_token: false,
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let received: GetAccountResponse = to_response(response)?;
|
||||
|
||||
assert_eq!(
|
||||
received,
|
||||
GetAccountResponse {
|
||||
account: Some(Account::Chatgpt {
|
||||
email: None,
|
||||
plan_type: AccountPlanType::Pro,
|
||||
}),
|
||||
requires_openai_auth: true,
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_account_omits_chatgpt_after_permanent_refresh_failure() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -1944,7 +1989,7 @@ async fn get_account_with_chatgpt_missing_plan_claim_returns_unknown() -> Result
|
||||
|
||||
let expected = GetAccountResponse {
|
||||
account: Some(Account::Chatgpt {
|
||||
email: "user@example.com".to_string(),
|
||||
email: Some("user@example.com".to_string()),
|
||||
plan_type: AccountPlanType::Unknown,
|
||||
}),
|
||||
requires_openai_auth: true,
|
||||
|
||||
@@ -204,12 +204,11 @@ async fn consume_timeout_releases_account_auth_queue() -> Result<()> {
|
||||
"rate limit reset consume timed out"
|
||||
);
|
||||
|
||||
let account_error = read_error_response(&mut mcp, account_id).await?;
|
||||
assert_eq!(account_error.error.code, INVALID_REQUEST_ERROR_CODE);
|
||||
assert_eq!(
|
||||
account_error.error.message,
|
||||
"email and plan type are required for chatgpt authentication"
|
||||
);
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(account_id)),
|
||||
)
|
||||
.await??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ async fn auth_manager_with_agent_identity_business_plan() -> Arc<AuthManager> {
|
||||
agent_private_key: key_material.private_key_pkcs8_base64,
|
||||
account_id: "account-12345".to_string(),
|
||||
chatgpt_user_id: "user-12345".to_string(),
|
||||
email: "user@example.com".to_string(),
|
||||
email: Some("user@example.com".to_string()),
|
||||
plan_type: PlanType::Business,
|
||||
chatgpt_account_is_fedramp: false,
|
||||
task_id: Some("task-123".to_string()),
|
||||
|
||||
@@ -90,7 +90,7 @@ pub struct AgentIdentityAuth {
|
||||
pub(super) struct ManagedChatGptAgentIdentityBinding {
|
||||
pub(super) account_id: String,
|
||||
pub(super) chatgpt_user_id: String,
|
||||
pub(super) email: String,
|
||||
pub(super) email: Option<String>,
|
||||
pub(super) plan_type: AccountPlanType,
|
||||
pub(super) chatgpt_account_is_fedramp: bool,
|
||||
pub(super) access_token: String,
|
||||
@@ -156,8 +156,8 @@ impl AgentIdentityAuth {
|
||||
&self.record.chatgpt_user_id
|
||||
}
|
||||
|
||||
pub fn email(&self) -> &str {
|
||||
&self.record.email
|
||||
pub fn email(&self) -> Option<&str> {
|
||||
self.record.email.as_deref()
|
||||
}
|
||||
|
||||
pub fn plan_type(&self) -> AccountPlanType {
|
||||
@@ -363,7 +363,7 @@ mod tests {
|
||||
agent_private_key: private_key,
|
||||
account_id: "account-1".to_string(),
|
||||
chatgpt_user_id: "user-1".to_string(),
|
||||
email: "agent@example.com".to_string(),
|
||||
email: Some("agent@example.com".to_string()),
|
||||
plan_type: AccountPlanType::Plus,
|
||||
chatgpt_account_is_fedramp: false,
|
||||
task_id: None,
|
||||
|
||||
@@ -607,7 +607,7 @@ async fn chatgpt_auth_task_registration_retry_exhaustion_is_fallback_eligible()
|
||||
)?;
|
||||
let mut record = agent_identity_record("account-123");
|
||||
record.chatgpt_user_id = "user-12345".to_string();
|
||||
record.email = "user@example.com".to_string();
|
||||
record.email = Some("user@example.com".to_string());
|
||||
let storage = FileAuthStorage::new(codex_home.path().to_path_buf());
|
||||
let auth_path = get_auth_file(codex_home.path());
|
||||
let mut auth_json = storage.try_read_auth_json(&auth_path)?;
|
||||
@@ -1926,7 +1926,7 @@ fn agent_identity_record(account_id: &str) -> AgentIdentityAuthRecord {
|
||||
agent_private_key: key_material.private_key_pkcs8_base64,
|
||||
account_id: account_id.to_string(),
|
||||
chatgpt_user_id: "user-id".to_string(),
|
||||
email: "user@example.com".to_string(),
|
||||
email: Some("user@example.com".to_string()),
|
||||
plan_type: AccountPlanType::Pro,
|
||||
chatgpt_account_is_fedramp: false,
|
||||
task_id: None,
|
||||
|
||||
@@ -506,8 +506,8 @@ impl CodexAuth {
|
||||
/// Returns `None` if Codex backend auth does not expose an account email.
|
||||
pub fn get_account_email(&self) -> Option<String> {
|
||||
match self {
|
||||
Self::AgentIdentity(auth) => Some(auth.email().to_string()),
|
||||
Self::PersonalAccessToken(auth) => Some(auth.email().to_string()),
|
||||
Self::AgentIdentity(auth) => auth.email().map(str::to_string),
|
||||
Self::PersonalAccessToken(auth) => auth.email().map(str::to_string),
|
||||
_ => self.get_current_token_data().and_then(|t| t.id_token.email),
|
||||
}
|
||||
}
|
||||
@@ -723,7 +723,7 @@ impl ManagedChatGptAgentIdentityBinding {
|
||||
Some(Self {
|
||||
account_id,
|
||||
chatgpt_user_id,
|
||||
email: token_data.id_token.email.clone().unwrap_or_default(),
|
||||
email: token_data.id_token.email.clone(),
|
||||
plan_type: auth.account_plan_type().unwrap_or(AccountPlanType::Unknown),
|
||||
chatgpt_account_is_fedramp: auth.is_fedramp_account(),
|
||||
access_token: token_data.access_token,
|
||||
|
||||
@@ -14,7 +14,7 @@ const WHOAMI_PATH: &str = "/v1/user-auth-credential/whoami";
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
|
||||
struct PersonalAccessTokenMetadata {
|
||||
email: String,
|
||||
email: Option<String>,
|
||||
chatgpt_user_id: String,
|
||||
chatgpt_account_id: String,
|
||||
chatgpt_plan_type: String,
|
||||
@@ -63,8 +63,8 @@ impl PersonalAccessTokenAuth {
|
||||
&self.metadata.chatgpt_user_id
|
||||
}
|
||||
|
||||
pub fn email(&self) -> &str {
|
||||
&self.metadata.email
|
||||
pub fn email(&self) -> Option<&str> {
|
||||
self.metadata.email.as_deref()
|
||||
}
|
||||
|
||||
pub fn plan_type(&self) -> AccountPlanType {
|
||||
|
||||
@@ -40,7 +40,7 @@ async fn hydrate_sends_bearer_token_and_preserves_metadata() {
|
||||
PersonalAccessTokenAuth {
|
||||
access_token: "at-example".to_string(),
|
||||
metadata: PersonalAccessTokenMetadata {
|
||||
email: "user@example.com".to_string(),
|
||||
email: Some("user@example.com".to_string()),
|
||||
chatgpt_user_id: "user-123".to_string(),
|
||||
chatgpt_account_id: "account-123".to_string(),
|
||||
chatgpt_plan_type: "enterprise".to_string(),
|
||||
@@ -52,7 +52,7 @@ async fn hydrate_sends_bearer_token_and_preserves_metadata() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hydrate_rejects_missing_email() {
|
||||
async fn hydrate_preserves_missing_email() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(WHOAMI_PATH))
|
||||
@@ -62,13 +62,22 @@ async fn hydrate_rejects_missing_email() {
|
||||
.await;
|
||||
|
||||
let endpoint = whoami_endpoint(&server.uri());
|
||||
let err = hydrate_personal_access_token(&create_client(), &endpoint, "at-example")
|
||||
let auth = hydrate_personal_access_token(&create_client(), &endpoint, "at-example")
|
||||
.await
|
||||
.expect_err("personal access token hydration should reject missing email");
|
||||
.expect("personal access token hydration should accept missing email");
|
||||
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("failed to decode personal access token metadata")
|
||||
assert_eq!(
|
||||
auth,
|
||||
PersonalAccessTokenAuth {
|
||||
access_token: "at-example".to_string(),
|
||||
metadata: PersonalAccessTokenMetadata {
|
||||
email: None,
|
||||
chatgpt_user_id: "user-123".to_string(),
|
||||
chatgpt_account_id: "account-123".to_string(),
|
||||
chatgpt_plan_type: "enterprise".to_string(),
|
||||
chatgpt_account_is_fedramp: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
server.verify().await;
|
||||
}
|
||||
|
||||
@@ -92,13 +92,37 @@ pub struct AgentIdentityAuthRecord {
|
||||
pub agent_private_key: String,
|
||||
pub account_id: String,
|
||||
pub chatgpt_user_id: String,
|
||||
pub email: String,
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "deserialize_optional_non_empty_string",
|
||||
serialize_with = "serialize_optional_string_as_empty"
|
||||
)]
|
||||
pub email: Option<String>,
|
||||
pub plan_type: AccountPlanType,
|
||||
pub chatgpt_account_is_fedramp: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub task_id: Option<String>,
|
||||
}
|
||||
|
||||
fn deserialize_optional_non_empty_string<'de, D>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<String>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
Option::<String>::deserialize(deserializer).map(|value| value.filter(|value| !value.is_empty()))
|
||||
}
|
||||
|
||||
fn serialize_optional_string_as_empty<S>(
|
||||
value: &Option<String>,
|
||||
serializer: S,
|
||||
) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
value.as_deref().unwrap_or_default().serialize(serializer)
|
||||
}
|
||||
|
||||
impl AgentIdentityAuthRecord {
|
||||
pub(crate) fn from_agent_identity_jwt(jwt: &str) -> std::io::Result<Self> {
|
||||
let claims =
|
||||
|
||||
@@ -102,7 +102,7 @@ async fn file_storage_round_trips_registered_agent_identity_auth() -> anyhow::Re
|
||||
agent_private_key: "private-key".to_string(),
|
||||
account_id: "account-id".to_string(),
|
||||
chatgpt_user_id: "user-id".to_string(),
|
||||
email: "user@example.com".to_string(),
|
||||
email: Some("user@example.com".to_string()),
|
||||
plan_type: AccountPlanType::Pro,
|
||||
chatgpt_account_is_fedramp: false,
|
||||
task_id: Some("task-id".to_string()),
|
||||
@@ -124,6 +124,85 @@ async fn file_storage_round_trips_registered_agent_identity_auth() -> anyhow::Re
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_storage_loads_empty_agent_identity_email_as_none() -> anyhow::Result<()> {
|
||||
let codex_home = tempdir()?;
|
||||
let storage = FileAuthStorage::new(codex_home.path().to_path_buf());
|
||||
let auth_file = get_auth_file(codex_home.path());
|
||||
std::fs::write(
|
||||
&auth_file,
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"auth_mode": "chatgpt",
|
||||
"agent_identity": {
|
||||
"agent_runtime_id": "agent-runtime-id",
|
||||
"agent_private_key": "private-key",
|
||||
"account_id": "account-id",
|
||||
"chatgpt_user_id": "user-id",
|
||||
"email": "",
|
||||
"plan_type": "pro",
|
||||
"chatgpt_account_is_fedramp": false,
|
||||
},
|
||||
}))?,
|
||||
)?;
|
||||
|
||||
let loaded = storage.load()?;
|
||||
|
||||
assert_eq!(
|
||||
loaded,
|
||||
Some(AuthDotJson {
|
||||
auth_mode: Some(AuthMode::Chatgpt),
|
||||
openai_api_key: None,
|
||||
tokens: None,
|
||||
last_refresh: None,
|
||||
agent_identity: Some(AgentIdentityStorage::Record(AgentIdentityAuthRecord {
|
||||
agent_runtime_id: "agent-runtime-id".to_string(),
|
||||
agent_private_key: "private-key".to_string(),
|
||||
account_id: "account-id".to_string(),
|
||||
chatgpt_user_id: "user-id".to_string(),
|
||||
email: None,
|
||||
plan_type: AccountPlanType::Pro,
|
||||
chatgpt_account_is_fedramp: false,
|
||||
task_id: None,
|
||||
})),
|
||||
personal_access_token: None,
|
||||
bedrock_api_key: None,
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_storage_writes_missing_agent_identity_email_as_empty_string() -> anyhow::Result<()> {
|
||||
let codex_home = tempdir()?;
|
||||
let storage = FileAuthStorage::new(codex_home.path().to_path_buf());
|
||||
let auth_dot_json = AuthDotJson {
|
||||
auth_mode: Some(AuthMode::Chatgpt),
|
||||
openai_api_key: None,
|
||||
tokens: None,
|
||||
last_refresh: None,
|
||||
agent_identity: Some(AgentIdentityStorage::Record(AgentIdentityAuthRecord {
|
||||
agent_runtime_id: "agent-runtime-id".to_string(),
|
||||
agent_private_key: "private-key".to_string(),
|
||||
account_id: "account-id".to_string(),
|
||||
chatgpt_user_id: "user-id".to_string(),
|
||||
email: None,
|
||||
plan_type: AccountPlanType::Pro,
|
||||
chatgpt_account_is_fedramp: false,
|
||||
task_id: None,
|
||||
})),
|
||||
personal_access_token: None,
|
||||
bedrock_api_key: None,
|
||||
};
|
||||
|
||||
storage.save(&auth_dot_json)?;
|
||||
|
||||
let auth_file = get_auth_file(codex_home.path());
|
||||
let saved: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(auth_file)?)?;
|
||||
assert_eq!(saved["agent_identity"]["email"], "");
|
||||
assert_eq!(storage.load()?, Some(auth_dot_json));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_storage_round_trips_personal_access_token_auth() -> anyhow::Result<()> {
|
||||
let codex_home = tempdir()?;
|
||||
|
||||
@@ -60,10 +60,7 @@ impl fmt::Display for ProviderAccountError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::MissingChatgptAccountDetails => {
|
||||
write!(
|
||||
f,
|
||||
"email and plan type are required for chatgpt authentication"
|
||||
)
|
||||
write!(f, "plan type is required for chatgpt authentication")
|
||||
}
|
||||
Self::UnsupportedBedrockApiKeyAuth => {
|
||||
write!(
|
||||
@@ -261,12 +258,9 @@ impl ModelProvider for ConfiguredModelProvider {
|
||||
let email = auth.get_account_email();
|
||||
let plan_type = auth.account_plan_type();
|
||||
|
||||
match (email, plan_type) {
|
||||
(Some(email), Some(plan_type)) => {
|
||||
Ok(ProviderAccount::Chatgpt { email, plan_type })
|
||||
}
|
||||
_ => Err(ProviderAccountError::MissingChatgptAccountDetails),
|
||||
}
|
||||
plan_type
|
||||
.map(|plan_type| ProviderAccount::Chatgpt { email, plan_type })
|
||||
.ok_or(ProviderAccountError::MissingChatgptAccountDetails)
|
||||
}
|
||||
})
|
||||
.transpose()?
|
||||
@@ -313,6 +307,7 @@ mod tests {
|
||||
use codex_model_provider_info::ModelProviderAwsAuthInfo;
|
||||
use codex_model_provider_info::WireApi;
|
||||
use codex_models_manager::manager::RefreshStrategy;
|
||||
use codex_protocol::account::PlanType;
|
||||
use codex_protocol::config_types::ModelProviderAuthInfo;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
@@ -518,7 +513,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_provider_rejects_chatgpt_account_state_without_email() {
|
||||
fn openai_provider_returns_chatgpt_account_state_without_email() {
|
||||
let provider = create_model_provider(
|
||||
ModelProviderInfo::create_openai_provider(/*base_url*/ None),
|
||||
Some(AuthManager::from_auth_for_testing(
|
||||
@@ -528,7 +523,13 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
provider.account_state(),
|
||||
Err(ProviderAccountError::MissingChatgptAccountDetails)
|
||||
Ok(ProviderAccountState {
|
||||
account: Some(ProviderAccount::Chatgpt {
|
||||
email: None,
|
||||
plan_type: PlanType::Unknown,
|
||||
}),
|
||||
requires_openai_auth: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ pub enum PlanType {
|
||||
pub enum ProviderAccount {
|
||||
ApiKey,
|
||||
Chatgpt {
|
||||
email: String,
|
||||
email: Option<String>,
|
||||
plan_type: PlanType,
|
||||
},
|
||||
AmazonBedrock {
|
||||
|
||||
@@ -148,6 +148,7 @@ arboard = { workspace = true }
|
||||
|
||||
|
||||
[dev-dependencies]
|
||||
app_test_support = { workspace = true }
|
||||
codex-cli = { workspace = true }
|
||||
codex-mcp = { workspace = true }
|
||||
core_test_support = { workspace = true }
|
||||
|
||||
@@ -311,16 +311,19 @@ impl AppServerSession {
|
||||
false,
|
||||
),
|
||||
Some(Account::Chatgpt { email, plan_type }) => {
|
||||
let feedback_audience = if email.ends_with("@openai.com") {
|
||||
let feedback_audience = if email
|
||||
.as_deref()
|
||||
.is_some_and(|email| email.ends_with("@openai.com"))
|
||||
{
|
||||
FeedbackAudience::OpenAiEmployee
|
||||
} else {
|
||||
FeedbackAudience::External
|
||||
};
|
||||
(
|
||||
Some(email.clone()),
|
||||
email.clone(),
|
||||
Some(TelemetryAuthMode::Chatgpt),
|
||||
Some(StatusAccountDisplay::ChatGpt {
|
||||
email: Some(email),
|
||||
email,
|
||||
plan: Some(plan_type_display_name(plan_type)),
|
||||
}),
|
||||
Some(plan_type),
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
---
|
||||
source: tui/src/status/tests.rs
|
||||
expression: sanitized
|
||||
---
|
||||
/status
|
||||
|
||||
╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v0.0.0) │
|
||||
│ │
|
||||
│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │
|
||||
│ information on rate limits and credits │
|
||||
│ │
|
||||
│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │
|
||||
│ Directory: [[workspace]] │
|
||||
│ Permissions: Custom (workspace with network access, Ask for approval) │
|
||||
│ Agents.md: <none> │
|
||||
│ Account: Enterprise │
|
||||
│ │
|
||||
│ Limits: data not available yet │
|
||||
╰──────────────────────────────────────────────────────────────────────────╯
|
||||
@@ -17,6 +17,9 @@ use crate::test_support::PathBufExt;
|
||||
use crate::test_support::test_path_buf;
|
||||
use crate::token_usage::TokenUsage;
|
||||
use crate::token_usage::TokenUsageInfo;
|
||||
use app_test_support::ChatGptAuthFixture;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
use app_test_support::write_models_cache;
|
||||
use chrono::Duration as ChronoDuration;
|
||||
use chrono::Local;
|
||||
use chrono::TimeZone;
|
||||
@@ -27,6 +30,7 @@ use codex_app_server_protocol::RateLimitSnapshot;
|
||||
use codex_app_server_protocol::RateLimitWindow;
|
||||
use codex_app_server_protocol::SpendControlLimitSnapshot;
|
||||
use codex_config::LoaderOverrides;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use codex_model_provider_info::ModelProviderAwsAuthInfo;
|
||||
use codex_model_provider_info::ModelProviderInfo;
|
||||
use codex_models_manager::test_support::construct_model_info_offline_for_tests;
|
||||
@@ -318,6 +322,67 @@ async fn status_snapshot_includes_reasoning_details() {
|
||||
assert_snapshot!(sanitized);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_snapshot_shows_chatgpt_plan_without_email() {
|
||||
let temp_home = TempDir::new().expect("temp home");
|
||||
write_models_cache(temp_home.path()).expect("write models cache");
|
||||
let mut config = test_config(&temp_home).await;
|
||||
config.model = Some("gpt-5.1-codex-max".to_string());
|
||||
config.model_provider_id = "openai".to_string();
|
||||
config.cli_auth_credentials_store_mode = AuthCredentialsStoreMode::File;
|
||||
set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs());
|
||||
|
||||
write_chatgpt_auth(
|
||||
temp_home.path(),
|
||||
ChatGptAuthFixture::new("access-chatgpt").plan_type("enterprise"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)
|
||||
.expect("write email-less ChatGPT auth");
|
||||
let mut app_server = crate::start_embedded_app_server_for_picker(&config)
|
||||
.await
|
||||
.expect("start embedded app server");
|
||||
let bootstrap = app_server
|
||||
.bootstrap(&config)
|
||||
.await
|
||||
.expect("bootstrap app server session");
|
||||
app_server.shutdown().await.expect("shut down app server");
|
||||
let account_display = bootstrap
|
||||
.status_account_display
|
||||
.expect("bootstrap should return ChatGPT account display");
|
||||
assert_eq!(
|
||||
account_display,
|
||||
StatusAccountDisplay::ChatGpt {
|
||||
email: None,
|
||||
plan: Some("Enterprise".to_string()),
|
||||
}
|
||||
);
|
||||
let usage = TokenUsage::default();
|
||||
let captured_at = chrono::Local
|
||||
.with_ymd_and_hms(2024, 1, 2, 3, 4, 5)
|
||||
.single()
|
||||
.expect("timestamp");
|
||||
let model_slug = get_model_offline_for_tests(config.model.as_deref());
|
||||
|
||||
let composite = new_status_output(
|
||||
&config,
|
||||
Some(&account_display),
|
||||
/*token_info*/ None,
|
||||
&usage,
|
||||
&None,
|
||||
/*thread_name*/ None,
|
||||
/*forked_from*/ None,
|
||||
/*rate_limits*/ None,
|
||||
None,
|
||||
captured_at,
|
||||
&model_slug,
|
||||
/*collaboration_mode*/ None,
|
||||
/*reasoning_effort_override*/ None,
|
||||
);
|
||||
let sanitized =
|
||||
sanitize_directory(render_lines(&composite.display_lines(/*width*/ 80))).join("\n");
|
||||
assert_snapshot!(sanitized);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_permissions_non_default_workspace_write_uses_workspace_label() {
|
||||
let temp_home = TempDir::new().expect("temp home");
|
||||
|
||||
@@ -503,6 +503,33 @@ def _annotate_schema(value: Any, base: str | None = None) -> None:
|
||||
_annotate_schema(child, base)
|
||||
|
||||
|
||||
def _make_chatgpt_account_email_nullable(schema: dict[str, Any]) -> None:
|
||||
definitions = schema.get("definitions")
|
||||
if not isinstance(definitions, dict):
|
||||
raise RuntimeError("Schema bundle is missing definitions")
|
||||
|
||||
account = definitions.get("Account")
|
||||
if not isinstance(account, dict):
|
||||
raise RuntimeError("Schema bundle is missing the Account definition")
|
||||
|
||||
for variant in account.get("oneOf", []):
|
||||
if not isinstance(variant, dict):
|
||||
continue
|
||||
properties = variant.get("properties")
|
||||
if not isinstance(properties, dict):
|
||||
continue
|
||||
account_type = properties.get("type")
|
||||
if not isinstance(account_type, dict) or account_type.get("enum") != ["chatgpt"]:
|
||||
continue
|
||||
email = properties.get("email")
|
||||
if not isinstance(email, dict):
|
||||
raise RuntimeError("ChatGPT account schema is missing email")
|
||||
email["type"] = ["string", "null"]
|
||||
return
|
||||
|
||||
raise RuntimeError("Schema bundle is missing the ChatGPT account variant")
|
||||
|
||||
|
||||
def generate_schema_from_pinned_runtime(schema_dir: Path) -> Path:
|
||||
"""Generate app-server schemas by invoking the installed pinned runtime binary."""
|
||||
codex_path = pinned_runtime_codex_path()
|
||||
@@ -525,6 +552,7 @@ def generate_schema_from_pinned_runtime(schema_dir: Path) -> Path:
|
||||
def _normalized_schema_bundle_text(schema_dir: Path) -> str:
|
||||
"""Normalize the schema bundle before feeding it to the Python type generator."""
|
||||
schema = json.loads(schema_bundle_path(schema_dir).read_text())
|
||||
_make_chatgpt_account_email_nullable(schema)
|
||||
definitions = schema.get("definitions", {})
|
||||
if isinstance(definitions, dict):
|
||||
for definition in definitions.values():
|
||||
@@ -580,9 +608,34 @@ def generate_v2_all(schema_dir: Path) -> None:
|
||||
],
|
||||
cwd=sdk_root(),
|
||||
)
|
||||
_require_nullable_chatgpt_account_email(out_path)
|
||||
_normalize_generated_timestamps(out_path)
|
||||
|
||||
|
||||
def _require_nullable_chatgpt_account_email(out_path: Path) -> None:
|
||||
"""Preserve required-but-nullable email semantics in the generated SDK model."""
|
||||
source = out_path.read_text()
|
||||
class_start = source.find("class ChatgptAccount(BaseModel):")
|
||||
if class_start == -1:
|
||||
raise RuntimeError("Generated SDK is missing ChatgptAccount")
|
||||
class_end = source.find("\n\nclass ", class_start)
|
||||
if class_end == -1:
|
||||
class_end = len(source)
|
||||
|
||||
class_source = source[class_start:class_end]
|
||||
nullable_with_default = " email: str | None = None"
|
||||
if class_source.count(nullable_with_default) != 1:
|
||||
raise RuntimeError(
|
||||
"Generated ChatgptAccount email did not have the expected nullable shape"
|
||||
)
|
||||
class_source = class_source.replace(
|
||||
nullable_with_default,
|
||||
" email: str | None",
|
||||
1,
|
||||
)
|
||||
out_path.write_text(source[:class_start] + class_source + source[class_end:])
|
||||
|
||||
|
||||
def _notification_specs(schema_dir: Path) -> list[tuple[str, str]]:
|
||||
"""Map each server notification method to its generated payload model class."""
|
||||
server_notifications = json.loads((schema_dir / "ServerNotification.json").read_text())
|
||||
|
||||
@@ -4654,7 +4654,7 @@ class ChatgptAccount(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
)
|
||||
email: str
|
||||
email: str | None
|
||||
plan_type: Annotated[PlanType, Field(alias="planType")]
|
||||
type: Annotated[Literal["chatgpt"], Field(title="ChatgptAccountType")]
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import tomllib
|
||||
from pydantic import ValidationError
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
@@ -306,6 +307,32 @@ def test_schema_normalization_only_flattens_string_literal_oneofs(
|
||||
]
|
||||
|
||||
|
||||
def test_schema_normalization_makes_chatgpt_account_email_nullable() -> None:
|
||||
script = _load_update_script_module()
|
||||
schema = {
|
||||
"definitions": {
|
||||
"Account": {
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"email": {"type": "string"},
|
||||
"type": {"enum": ["chatgpt"], "type": "string"},
|
||||
},
|
||||
"required": ["email", "type"],
|
||||
"type": "object",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
script._make_chatgpt_account_email_nullable(schema)
|
||||
|
||||
chatgpt_account = schema["definitions"]["Account"]["oneOf"][0]
|
||||
assert chatgpt_account["properties"]["email"]["type"] == ["string", "null"]
|
||||
assert "email" in chatgpt_account["required"]
|
||||
|
||||
|
||||
def test_python_codegen_schema_annotation_adds_stable_variant_titles(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
@@ -350,6 +377,17 @@ def test_generate_v2_all_uses_titles_for_generated_names() -> None:
|
||||
assert "ruff-format" in source
|
||||
|
||||
|
||||
def test_generated_chatgpt_account_email_is_required_nullable() -> None:
|
||||
from openai_codex.generated.v2_all import ChatgptAccount
|
||||
|
||||
account = ChatgptAccount.model_validate({"email": None, "planType": "pro", "type": "chatgpt"})
|
||||
assert account.email is None
|
||||
assert ChatgptAccount.model_fields["email"].is_required()
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
ChatgptAccount.model_validate({"planType": "pro", "type": "chatgpt"})
|
||||
|
||||
|
||||
def test_runtime_package_template_has_no_checked_in_binaries() -> None:
|
||||
runtime_root = ROOT.parent / "python-runtime" / "src" / "codex_cli_bin"
|
||||
assert sorted(
|
||||
|
||||
Reference in New Issue
Block a user