[codex-rs] support v2 personal access tokens (#25731)

## Summary

- add v2 personal access token support for `codex login
--with-access-token` and `CODEX_ACCESS_TOKEN`
- classify opaque `at-` tokens separately from legacy Agent Identity
JWTs
- hydrate required ChatGPT account metadata through AuthAPI
`/v1/user-auth-credential/whoami`
- use PATs directly as bearer tokens while preserving existing ChatGPT
account surfaces
- expose PAT-backed auth as the explicit `personalAccessToken`
app-server auth mode

## Implementation

PAT auth is intentionally small and stateless. Loading a PAT performs
one AuthAPI metadata request, stores the hydrated metadata in the
in-memory auth object, and redacts the secret from debug output. Legacy
Agent Identity JWT handling remains unchanged. The shared access-token
classifier lives in a private neutral module because it dispatches
between both credential types.

PAT hydration fails closed when AuthAPI omits any required metadata,
including email. Hydrated metadata is intentionally not persisted:
startup performs a live `whoami` preflight so revoked tokens or changed
account metadata are not accepted from a stale cache.

## Workspace restriction scope

This change intentionally does **not** apply
`forced_chatgpt_workspace_id` to PAT authentication. The setting is a
client-side config guardrail, not an authorization boundary, and PAT
does not currently require workspace-ID parity. The PAT login and
`CODEX_ACCESS_TOKEN` paths therefore validate through AuthAPI without
threading workspace-restriction state through access-token loading.
Existing workspace checks for non-PAT auth remain on their established
paths.

## App-server compatibility

The public app-server `AuthMode` is shared across v1 and v2, and
PAT-backed auth reports `personalAccessToken` through both APIs.
Following human review, this intentionally removes the temporary v1
compatibility mapping that reported PATs as `chatgpt`; the deprecated v1
API is kept in parity with v2 rather than maintaining a separate closed
enum. Clients with exhaustive auth-mode handling in either API version
must add the new case and should generally treat it as ChatGPT-backed
unless they need PAT-specific behavior.

The v1 auth-status response still omits the raw PAT when `includeToken`
is requested because that response cannot carry the account metadata
needed to reuse the credential safely. Persisted PAT auth also omits the
new enum value so older Codex builds can deserialize `auth.json` and
infer PAT auth from the credential field after a rollback.

## Validation

Latest review-fix validation:

- `CARGO_INCREMENTAL=0 just test -p codex-login` (126 passed)
- `CARGO_INCREMENTAL=0 just test -p codex-cli` (263 passed)
- `CARGO_INCREMENTAL=0 just test -p codex-cli
stored_auth_validation_handles_personal_access_token`
- `CARGO_INCREMENTAL=0 just test -p codex-app-server-protocol` (226
passed)
- `CARGO_INCREMENTAL=0 just test -p codex-models-manager
refresh_available_models_uses_remote_only_catalog_for_chatgpt_auth`
- `CARGO_INCREMENTAL=0 just test -p codex-tui
existing_non_oauth_chatgpt_login_counts_as_signed_in`
- `CARGO_INCREMENTAL=0 just fix -p codex-login -p
codex-app-server-protocol -p codex-models-manager -p codex-tui -p
codex-cli`
- `just fmt`
- `git diff --check`

The broader `codex-tui` suite previously compiled and ran 2,834 tests.
Three unrelated environment-sensitive guardian/IDE-socket tests failed
after retries; the PAT-relevant TUI coverage passed.
This commit is contained in:
cooper-oai
2026-06-05 17:36:18 -07:00
committed by GitHub
parent 61a913d9c8
commit df7818c7d1
42 changed files with 980 additions and 97 deletions
+40 -1
View File
@@ -1308,6 +1308,7 @@ fn stored_auth_mode(auth: &codex_login::AuthDotJson) -> &'static str {
codex_app_server_protocol::AuthMode::Chatgpt => "chatgpt",
codex_app_server_protocol::AuthMode::ChatgptAuthTokens => "chatgpt_auth_tokens",
codex_app_server_protocol::AuthMode::AgentIdentity => "agent_identity",
codex_app_server_protocol::AuthMode::PersonalAccessToken => "personal_access_token",
}
}
@@ -1317,6 +1318,8 @@ fn stored_auth_mode_value(auth: &AuthDotJson) -> codex_app_server_protocol::Auth
}
if auth.openai_api_key.is_some() {
codex_app_server_protocol::AuthMode::ApiKey
} else if auth.personal_access_token.is_some() {
codex_app_server_protocol::AuthMode::PersonalAccessToken
} else {
codex_app_server_protocol::AuthMode::Chatgpt
}
@@ -1380,6 +1383,15 @@ fn stored_auth_issues(
issues.push("agent identity auth is missing an agent identity token");
}
}
codex_app_server_protocol::AuthMode::PersonalAccessToken => {
if auth
.personal_access_token
.as_deref()
.is_none_or(|token| token.trim().is_empty())
{
issues.push("personal access token auth is missing a personal access token");
}
}
}
issues
}
@@ -2408,6 +2420,7 @@ fn auth_mode_name(auth: &CodexAuth) -> &'static str {
codex_app_server_protocol::AuthMode::Chatgpt => "chatgpt",
codex_app_server_protocol::AuthMode::ChatgptAuthTokens => "chatgpt_auth_tokens",
codex_app_server_protocol::AuthMode::AgentIdentity => "agent_identity",
codex_app_server_protocol::AuthMode::PersonalAccessToken => "personal_access_token",
}
}
@@ -2541,7 +2554,8 @@ fn provider_auth_reachability_mode_from_auth(
Some(
codex_app_server_protocol::AuthMode::Chatgpt
| codex_app_server_protocol::AuthMode::ChatgptAuthTokens
| codex_app_server_protocol::AuthMode::AgentIdentity,
| codex_app_server_protocol::AuthMode::AgentIdentity
| codex_app_server_protocol::AuthMode::PersonalAccessToken,
)
| None => ProviderAuthReachabilityMode::Chatgpt,
}
@@ -3401,6 +3415,7 @@ mod tests {
tokens: None,
last_refresh: None,
agent_identity: None,
personal_access_token: None,
};
assert_eq!(
@@ -3418,6 +3433,7 @@ mod tests {
tokens: None,
last_refresh: None,
agent_identity: None,
personal_access_token: None,
};
assert_eq!(
@@ -3429,6 +3445,28 @@ mod tests {
);
}
#[test]
fn stored_auth_validation_handles_personal_access_token() {
let mut auth = AuthDotJson {
auth_mode: None,
openai_api_key: None,
tokens: None,
last_refresh: None,
agent_identity: None,
personal_access_token: Some("at-test".to_string()),
};
assert_eq!(stored_auth_mode(&auth), "personal_access_token");
assert!(stored_auth_issues(&auth, |_| false).is_empty());
auth.auth_mode = Some(codex_app_server_protocol::AuthMode::PersonalAccessToken);
auth.personal_access_token = None;
assert_eq!(
stored_auth_issues(&auth, |_| false),
vec!["personal access token auth is missing a personal access token"]
);
}
#[test]
fn provider_reachability_mode_uses_api_key_auth() {
let api_key_auth = AuthDotJson {
@@ -3437,6 +3475,7 @@ mod tests {
tokens: None,
last_refresh: None,
agent_identity: None,
personal_access_token: None,
};
assert_eq!(
+4
View File
@@ -391,6 +391,10 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! {
eprintln!("Logged in using access token");
std::process::exit(0);
}
AuthMode::PersonalAccessToken => {
eprintln!("Logged in using personal access token");
std::process::exit(0);
}
},
Ok(None) => {
eprintln!("Not logged in");