mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
df7818c7d1
## 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.
149 lines
4.0 KiB
Rust
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;
|