Files
codex/codex-rs/chatgpt/src/workspace_settings.rs
T
cooper-oai df7818c7d1 [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.
2026-06-05 17:36:18 -07:00

149 lines
4.0 KiB
Rust

use std::collections::HashMap;
use std::sync::RwLock;
use std::time::Duration;
use std::time::Instant;
use codex_core::config::Config;
use codex_login::CodexAuth;
use serde::Deserialize;
use crate::chatgpt_client::chatgpt_get_request_with_timeout;
const WORKSPACE_SETTINGS_TIMEOUT: Duration = Duration::from_secs(10);
const WORKSPACE_SETTINGS_CACHE_TTL: Duration = Duration::from_secs(15 * 60);
const CODEX_PLUGINS_BETA_SETTING: &str = "enable_plugins";
#[derive(Debug, Deserialize)]
struct WorkspaceSettingsResponse {
#[serde(default)]
beta_settings: HashMap<String, bool>,
}
#[derive(Debug, Default)]
pub struct WorkspaceSettingsCache {
entry: RwLock<Option<CachedWorkspaceSettings>>,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
struct WorkspaceSettingsCacheKey {
chatgpt_base_url: String,
account_id: String,
}
#[derive(Clone, Debug)]
struct CachedWorkspaceSettings {
key: WorkspaceSettingsCacheKey,
expires_at: Instant,
codex_plugins_enabled: bool,
}
impl WorkspaceSettingsCache {
fn get_codex_plugins_enabled(&self, key: &WorkspaceSettingsCacheKey) -> Option<bool> {
{
let entry = match self.entry.read() {
Ok(entry) => entry,
Err(err) => err.into_inner(),
};
let now = Instant::now();
if let Some(cached) = entry.as_ref()
&& now < cached.expires_at
&& cached.key == *key
{
return Some(cached.codex_plugins_enabled);
}
}
let mut entry = match self.entry.write() {
Ok(entry) => entry,
Err(err) => err.into_inner(),
};
let now = Instant::now();
if entry
.as_ref()
.is_some_and(|cached| now >= cached.expires_at || cached.key != *key)
{
*entry = None;
}
None
}
fn set_codex_plugins_enabled(&self, key: WorkspaceSettingsCacheKey, enabled: bool) {
let mut entry = match self.entry.write() {
Ok(entry) => entry,
Err(err) => err.into_inner(),
};
*entry = Some(CachedWorkspaceSettings {
key,
expires_at: Instant::now() + WORKSPACE_SETTINGS_CACHE_TTL,
codex_plugins_enabled: enabled,
});
}
}
pub async fn codex_plugins_enabled_for_workspace(
config: &Config,
auth: Option<&CodexAuth>,
cache: Option<&WorkspaceSettingsCache>,
) -> anyhow::Result<bool> {
let Some(auth) = auth else {
return Ok(true);
};
if !auth.is_chatgpt_auth() {
return Ok(true);
}
if !auth.is_workspace_account() {
return Ok(true);
}
let Some(account_id) = auth.get_account_id().filter(|id| !id.is_empty()) else {
return Ok(true);
};
let cache_key = WorkspaceSettingsCacheKey {
chatgpt_base_url: config.chatgpt_base_url.clone(),
account_id: account_id.clone(),
};
if let Some(cache) = cache
&& let Some(enabled) = cache.get_codex_plugins_enabled(&cache_key)
{
return Ok(enabled);
}
let encoded_account_id = encode_path_segment(&account_id);
let settings: WorkspaceSettingsResponse = chatgpt_get_request_with_timeout(
config,
format!("/accounts/{encoded_account_id}/settings"),
Some(WORKSPACE_SETTINGS_TIMEOUT),
)
.await?;
let codex_plugins_enabled = settings
.beta_settings
.get(CODEX_PLUGINS_BETA_SETTING)
.copied()
.unwrap_or(true);
if let Some(cache) = cache {
cache.set_codex_plugins_enabled(cache_key, codex_plugins_enabled);
}
Ok(codex_plugins_enabled)
}
fn encode_path_segment(value: &str) -> String {
let mut encoded = String::new();
for byte in value.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
encoded.push(byte as char);
} else {
encoded.push_str(&format!("%{byte:02X}"));
}
}
encoded
}
#[cfg(test)]
#[path = "workspace_settings_tests.rs"]
mod tests;