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:
efrazer-oai
2026-06-22 13:19:40 -07:00
committed by GitHub
Unverified
parent 1659c4a629
commit 5a67d898a5
29 changed files with 485 additions and 61 deletions
@@ -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(