mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[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:
committed by
GitHub
Unverified
parent
61a913d9c8
commit
df7818c7d1
@@ -525,6 +525,13 @@
|
||||
"agentIdentity"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Programmatic Codex auth backed by a personal access token.",
|
||||
"enum": [
|
||||
"personalAccessToken"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+7
@@ -6677,6 +6677,13 @@
|
||||
"agentIdentity"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Programmatic Codex auth backed by a personal access token.",
|
||||
"enum": [
|
||||
"personalAccessToken"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+7
@@ -999,6 +999,13 @@
|
||||
"agentIdentity"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Programmatic Codex auth backed by a personal access token.",
|
||||
"enum": [
|
||||
"personalAccessToken"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -31,6 +31,13 @@
|
||||
"agentIdentity"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Programmatic Codex auth backed by a personal access token.",
|
||||
"enum": [
|
||||
"personalAccessToken"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+1
-1
@@ -5,4 +5,4 @@
|
||||
/**
|
||||
* Authentication mode for OpenAI-backed providers.
|
||||
*/
|
||||
export type AuthMode = "apikey" | "chatgpt" | "chatgptAuthTokens" | "agentIdentity";
|
||||
export type AuthMode = "apikey" | "chatgpt" | "chatgptAuthTokens" | "agentIdentity" | "personalAccessToken";
|
||||
|
||||
@@ -36,6 +36,21 @@ pub enum AuthMode {
|
||||
#[ts(rename = "agentIdentity")]
|
||||
#[strum(serialize = "agentIdentity")]
|
||||
AgentIdentity,
|
||||
/// Programmatic Codex auth backed by a personal access token.
|
||||
#[serde(rename = "personalAccessToken")]
|
||||
#[ts(rename = "personalAccessToken")]
|
||||
#[strum(serialize = "personalAccessToken")]
|
||||
PersonalAccessToken,
|
||||
}
|
||||
|
||||
impl AuthMode {
|
||||
/// Returns whether this mode represents an authenticated human ChatGPT account.
|
||||
pub fn has_chatgpt_account(self) -> bool {
|
||||
match self {
|
||||
Self::Chatgpt | Self::ChatgptAuthTokens | Self::PersonalAccessToken => true,
|
||||
Self::ApiKey | Self::AgentIdentity => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! experimental_reason_expr {
|
||||
|
||||
@@ -115,6 +115,7 @@ fn remote_control_auth_dot_json(account_id: Option<&str>) -> AuthDotJson {
|
||||
}),
|
||||
last_refresh: Some(chrono::Utc::now()),
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1769,6 +1769,7 @@ mod tests {
|
||||
}),
|
||||
last_refresh: Some(Utc::now()),
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1770,6 +1770,7 @@ Codex supports these authentication modes. The current mode is surfaced in `acco
|
||||
|
||||
- **API key (`apiKey`)**: Caller supplies an OpenAI API key via `account/login/start` with `type: "apiKey"`. The API key is saved and used for API requests.
|
||||
- **ChatGPT managed (`chatgpt`)** (recommended): Codex owns the ChatGPT OAuth flow and refresh tokens. Start via `account/login/start` with `type: "chatgpt"` for the browser flow or `type: "chatgptDeviceCode"` for device code; Codex persists tokens to disk and refreshes them automatically.
|
||||
- **Personal access token (`personalAccessToken`)**: Codex uses a ChatGPT-backed personal access token loaded outside the app-server login RPCs, such as with `codex login --with-access-token` or `CODEX_ACCESS_TOKEN`.
|
||||
|
||||
### API Overview
|
||||
|
||||
@@ -1778,7 +1779,7 @@ Codex supports these authentication modes. The current mode is surfaced in `acco
|
||||
- `account/login/completed` (notify) — emitted when a login attempt finishes (success or error).
|
||||
- `account/login/cancel` — cancel a pending managed ChatGPT login by `loginId`.
|
||||
- `account/logout` — sign out; triggers `account/updated`.
|
||||
- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, or `null`) and includes the current ChatGPT `planType` when available.
|
||||
- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, `personalAccessToken`, or `null`) and includes the current ChatGPT `planType` when available.
|
||||
- `account/rateLimits/read` — fetch ChatGPT rate limits and an optional effective monthly credit limit; updates arrive via `account/rateLimits/updated` (notify).
|
||||
- `account/usage/read` — fetch ChatGPT account token-activity summary and daily buckets.
|
||||
- `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. This is a sparse rolling update; merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot.
|
||||
|
||||
@@ -357,7 +357,6 @@ use codex_mcp::discover_supported_scopes;
|
||||
use codex_mcp::read_mcp_resource as read_mcp_resource_without_thread;
|
||||
use codex_mcp::resolve_oauth_scopes;
|
||||
use codex_memories_write::clear_memory_roots_contents;
|
||||
use codex_model_provider::ProviderAccountError;
|
||||
use codex_model_provider::create_model_provider;
|
||||
use codex_models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets;
|
||||
use codex_protocol::ThreadId;
|
||||
|
||||
@@ -776,24 +776,28 @@ impl AccountRequestProcessor {
|
||||
let permanent_refresh_failure =
|
||||
self.auth_manager.refresh_failure_for_auth(&auth).is_some();
|
||||
let auth_mode = auth.api_auth_mode();
|
||||
let (reported_auth_method, token_opt) =
|
||||
if matches!(auth, CodexAuth::AgentIdentity(_))
|
||||
|| include_token && permanent_refresh_failure
|
||||
{
|
||||
(Some(auth_mode), None)
|
||||
} else {
|
||||
match auth.get_token() {
|
||||
Ok(token) if !token.is_empty() => {
|
||||
let tok = if include_token { Some(token) } else { None };
|
||||
(Some(auth_mode), tok)
|
||||
}
|
||||
Ok(_) => (None, None),
|
||||
Err(err) => {
|
||||
tracing::warn!("failed to get token for auth status: {err}");
|
||||
(None, None)
|
||||
}
|
||||
let (reported_auth_method, token_opt) = if matches!(
|
||||
auth,
|
||||
CodexAuth::AgentIdentity(_) | CodexAuth::PersonalAccessToken(_)
|
||||
) || include_token
|
||||
&& permanent_refresh_failure
|
||||
{
|
||||
// This response cannot represent the metadata needed to reuse these
|
||||
// credentials.
|
||||
(Some(auth_mode), None)
|
||||
} else {
|
||||
match auth.get_token() {
|
||||
Ok(token) if !token.is_empty() => {
|
||||
let tok = if include_token { Some(token) } else { None };
|
||||
(Some(auth_mode), tok)
|
||||
}
|
||||
};
|
||||
Ok(_) => (None, None),
|
||||
Err(err) => {
|
||||
tracing::warn!("failed to get token for auth status: {err}");
|
||||
(None, None)
|
||||
}
|
||||
}
|
||||
};
|
||||
GetAuthStatusResponse {
|
||||
auth_method: reported_auth_method,
|
||||
auth_token: token_opt,
|
||||
@@ -825,11 +829,7 @@ impl AccountRequestProcessor {
|
||||
);
|
||||
let account_state = match provider.account_state() {
|
||||
Ok(account_state) => account_state,
|
||||
Err(ProviderAccountError::MissingChatgptAccountDetails) => {
|
||||
return Err(invalid_request(
|
||||
"email and plan type are required for chatgpt authentication",
|
||||
));
|
||||
}
|
||||
Err(err) => return Err(invalid_request(err.to_string())),
|
||||
};
|
||||
let account = account_state.account.map(Account::from);
|
||||
|
||||
|
||||
@@ -164,6 +164,7 @@ pub fn write_chatgpt_auth(
|
||||
tokens: Some(tokens),
|
||||
last_refresh,
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
};
|
||||
|
||||
save_auth(codex_home, &auth, cli_auth_credentials_store_mode).context("write auth.json")
|
||||
|
||||
@@ -21,6 +21,7 @@ use tokio::time::timeout;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::header;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
@@ -160,6 +161,64 @@ async fn get_auth_status_with_api_key() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn get_auth_status_with_personal_access_token_omits_token() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path())?;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.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",
|
||||
"chatgpt_user_id": "user-123",
|
||||
"chatgpt_account_id": "account-123",
|
||||
"chatgpt_plan_type": "pro",
|
||||
"chatgpt_account_is_fedramp": false,
|
||||
})))
|
||||
.expect(1..)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let authapi_base_url = server.uri();
|
||||
let mut mcp = TestAppServer::new_with_env(
|
||||
codex_home.path(),
|
||||
&[
|
||||
("OPENAI_API_KEY", None),
|
||||
("CODEX_ACCESS_TOKEN", Some("at-test-token")),
|
||||
("CODEX_AUTHAPI_BASE_URL", Some(authapi_base_url.as_str())),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_get_auth_status_request(GetAuthStatusParams {
|
||||
include_token: Some(true),
|
||||
refresh_token: Some(false),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let status: GetAuthStatusResponse = to_response(resp)?;
|
||||
assert_eq!(
|
||||
status,
|
||||
GetAuthStatusResponse {
|
||||
auth_method: Some(AuthMode::PersonalAccessToken),
|
||||
auth_token: None,
|
||||
requires_openai_auth: Some(true),
|
||||
}
|
||||
);
|
||||
|
||||
server.verify().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn get_auth_status_with_api_key_when_auth_not_required() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
@@ -118,6 +118,7 @@ async fn list_apps_returns_empty_with_api_key_auth() -> Result<()> {
|
||||
tokens: None,
|
||||
last_refresh: None,
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
},
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
@@ -3,7 +3,6 @@ use std::sync::RwLock;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::Context;
|
||||
use codex_core::config::Config;
|
||||
use codex_login::CodexAuth;
|
||||
use serde::Deserialize;
|
||||
@@ -93,20 +92,17 @@ pub async fn codex_plugins_enabled_for_workspace(
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let token_data = auth
|
||||
.get_token_data()
|
||||
.context("ChatGPT token data is not available")?;
|
||||
if !token_data.id_token.is_workspace_account() {
|
||||
if !auth.is_workspace_account() {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let Some(account_id) = token_data.account_id.as_deref().filter(|id| !id.is_empty()) else {
|
||||
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.to_string(),
|
||||
account_id: account_id.clone(),
|
||||
};
|
||||
if let Some(cache) = cache
|
||||
&& let Some(enabled) = cache.get_codex_plugins_enabled(&cache_key)
|
||||
@@ -114,7 +110,7 @@ pub async fn codex_plugins_enabled_for_workspace(
|
||||
return Ok(enabled);
|
||||
}
|
||||
|
||||
let encoded_account_id = encode_path_segment(account_id);
|
||||
let encoded_account_id = encode_path_segment(&account_id);
|
||||
let settings: WorkspaceSettingsResponse = chatgpt_get_request_with_timeout(
|
||||
config,
|
||||
format!("/accounts/{encoded_account_id}/settings"),
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -1950,9 +1950,10 @@ impl AuthRequestTelemetryContext {
|
||||
Self {
|
||||
auth_mode: auth_mode.map(|mode| match mode {
|
||||
AuthMode::ApiKey => "ApiKey",
|
||||
AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens | AuthMode::AgentIdentity => {
|
||||
"Chatgpt"
|
||||
}
|
||||
AuthMode::Chatgpt
|
||||
| AuthMode::ChatgptAuthTokens
|
||||
| AuthMode::AgentIdentity
|
||||
| AuthMode::PersonalAccessToken => "Chatgpt",
|
||||
}),
|
||||
auth_header_attached: auth_telemetry.attached,
|
||||
auth_header_name: auth_telemetry.name,
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
use assert_cmd::Command as AssertCommand;
|
||||
use codex_git_utils::collect_git_info;
|
||||
use codex_login::CODEX_ACCESS_TOKEN_ENV_VAR;
|
||||
use codex_login::CODEX_API_KEY_ENV_VAR;
|
||||
use codex_protocol::protocol::GitInfo;
|
||||
use core_test_support::fs_wait;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
use uuid::Uuid;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::header;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
const PERSONAL_ACCESS_TOKEN: &str = "at-cli-test";
|
||||
const PERSONAL_ACCESS_TOKEN_AUTHORIZATION: &str = "Bearer at-cli-test";
|
||||
const PERSONAL_ACCESS_TOKEN_ACCOUNT_ID: &str = "account-pat";
|
||||
const WHOAMI_PATH: &str = "/v1/user-auth-credential/whoami";
|
||||
const CLOUD_CONFIG_BUNDLE_PATH: &str = "/backend-api/wham/config/bundle";
|
||||
|
||||
fn repo_root() -> std::path::PathBuf {
|
||||
#[expect(clippy::expect_used)]
|
||||
@@ -23,6 +36,118 @@ fn cli_sse_response() -> String {
|
||||
])
|
||||
}
|
||||
|
||||
async fn mount_personal_access_token_startup(server: &MockServer) {
|
||||
Mock::given(method("GET"))
|
||||
.and(path(WHOAMI_PATH))
|
||||
.and(header("authorization", PERSONAL_ACCESS_TOKEN_AUTHORIZATION))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"email": "user@example.com",
|
||||
"chatgpt_user_id": "user-pat",
|
||||
"chatgpt_account_id": PERSONAL_ACCESS_TOKEN_ACCOUNT_ID,
|
||||
"chatgpt_plan_type": "enterprise",
|
||||
"chatgpt_account_is_fedramp": true,
|
||||
})))
|
||||
.expect(1..)
|
||||
.mount(server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(CLOUD_CONFIG_BUNDLE_PATH))
|
||||
.and(header("authorization", PERSONAL_ACCESS_TOKEN_AUTHORIZATION))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
|
||||
.expect(1)
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[expect(clippy::unwrap_used)]
|
||||
fn personal_access_token_exec_command(server: &MockServer, home: &TempDir) -> AssertCommand {
|
||||
let bin = codex_utils_cargo_bin::cargo_bin("codex").unwrap();
|
||||
let mut cmd = AssertCommand::new(bin);
|
||||
cmd.timeout(Duration::from_secs(30));
|
||||
cmd.arg("exec")
|
||||
.arg("--skip-git-repo-check")
|
||||
.arg("-c")
|
||||
.arg(format!("openai_base_url=\"{}/api/codex\"", server.uri()))
|
||||
.arg("-c")
|
||||
.arg(format!("chatgpt_base_url=\"{}/backend-api\"", server.uri()))
|
||||
.arg("-C")
|
||||
.arg(repo_root())
|
||||
.arg("hello?");
|
||||
cmd.env("CODEX_HOME", home.path())
|
||||
.env(CODEX_ACCESS_TOKEN_ENV_VAR, PERSONAL_ACCESS_TOKEN)
|
||||
.env("CODEX_AUTHAPI_BASE_URL", server.uri())
|
||||
.env_remove(CODEX_API_KEY_ENV_VAR)
|
||||
.env_remove("OPENAI_API_KEY");
|
||||
cmd
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn responses_mode_stream_cli_supports_personal_access_tokens() {
|
||||
skip_if_no_network!();
|
||||
|
||||
let server = MockServer::start().await;
|
||||
mount_personal_access_token_startup(&server).await;
|
||||
let resp_mock = responses::mount_sse_once(&server, cli_sse_response()).await;
|
||||
let home = TempDir::new().unwrap();
|
||||
|
||||
let output = personal_access_token_exec_command(&server, &home)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"codex-cli exec failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let request = resp_mock.single_request();
|
||||
assert_eq!(request.path(), "/api/codex/responses");
|
||||
assert_eq!(
|
||||
request.header("authorization").as_deref(),
|
||||
Some("Bearer at-cli-test")
|
||||
);
|
||||
assert_eq!(
|
||||
request.header("chatgpt-account-id").as_deref(),
|
||||
Some(PERSONAL_ACCESS_TOKEN_ACCOUNT_ID)
|
||||
);
|
||||
assert_eq!(request.header("x-openai-fedramp").as_deref(), Some("true"));
|
||||
server.verify().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn responses_mode_stream_cli_does_not_attempt_oauth_refresh_for_personal_access_tokens_after_401()
|
||||
{
|
||||
skip_if_no_network!();
|
||||
|
||||
let server = MockServer::start().await;
|
||||
mount_personal_access_token_startup(&server).await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/codex/responses"))
|
||||
.and(header("authorization", PERSONAL_ACCESS_TOKEN_AUTHORIZATION))
|
||||
.and(header(
|
||||
"chatgpt-account-id",
|
||||
PERSONAL_ACCESS_TOKEN_ACCOUNT_ID,
|
||||
))
|
||||
.and(header("x-openai-fedramp", "true"))
|
||||
.respond_with(ResponseTemplate::new(401).set_body_string("unauthorized"))
|
||||
.expect(1..)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
.respond_with(ResponseTemplate::new(500))
|
||||
.expect(0)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let home = TempDir::new().unwrap();
|
||||
|
||||
let output = personal_access_token_exec_command(&server, &home)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
assert!(!output.status.success());
|
||||
server.verify().await;
|
||||
}
|
||||
|
||||
/// Tests streaming the Responses API through the CLI using a mock server.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn responses_mode_stream_cli() {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
const PERSONAL_ACCESS_TOKEN_PREFIX: &str = "at-";
|
||||
|
||||
pub(super) enum CodexAccessToken<'a> {
|
||||
PersonalAccessToken(&'a str),
|
||||
AgentIdentityJwt(&'a str),
|
||||
}
|
||||
|
||||
pub(super) fn classify_codex_access_token(access_token: &str) -> CodexAccessToken<'_> {
|
||||
if access_token.starts_with(PERSONAL_ACCESS_TOKEN_PREFIX) {
|
||||
CodexAccessToken::PersonalAccessToken(access_token)
|
||||
} else {
|
||||
CodexAccessToken::AgentIdentityJwt(access_token)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "access_token_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,13 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn classifies_personal_access_tokens_by_prefix() {
|
||||
assert!(matches!(
|
||||
classify_codex_access_token("at-example"),
|
||||
CodexAccessToken::PersonalAccessToken("at-example")
|
||||
));
|
||||
assert!(matches!(
|
||||
classify_codex_access_token("header.payload.signature"),
|
||||
CodexAccessToken::AgentIdentityJwt("header.payload.signature")
|
||||
));
|
||||
}
|
||||
@@ -19,6 +19,7 @@ use tempfile::tempdir;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::header;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
@@ -126,6 +127,84 @@ async fn login_with_access_token_writes_only_token() {
|
||||
server.verify().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial(codex_auth_env)]
|
||||
async fn login_with_access_token_writes_only_personal_access_token() {
|
||||
let dir = tempdir().unwrap();
|
||||
let auth_path = dir.path().join("auth.json");
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/v1/user-auth-credential/whoami"))
|
||||
.and(header("authorization", "Bearer at-login-test"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_json(personal_access_token_whoami(WORKSPACE_ID_ALLOWED)),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri());
|
||||
super::login_with_access_token(
|
||||
dir.path(),
|
||||
"at-login-test",
|
||||
AuthCredentialsStoreMode::File,
|
||||
/*chatgpt_base_url*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("personal access token login should succeed");
|
||||
|
||||
let storage = FileAuthStorage::new(dir.path().to_path_buf());
|
||||
let auth = storage
|
||||
.try_read_auth_json(&auth_path)
|
||||
.expect("auth.json should parse");
|
||||
assert_eq!(
|
||||
auth,
|
||||
AuthDotJson {
|
||||
auth_mode: None,
|
||||
openai_api_key: None,
|
||||
tokens: None,
|
||||
last_refresh: None,
|
||||
agent_identity: None,
|
||||
personal_access_token: Some("at-login-test".to_string()),
|
||||
}
|
||||
);
|
||||
assert_eq!(auth.resolved_mode(), AuthMode::PersonalAccessToken);
|
||||
let persisted: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(auth_path).unwrap()).unwrap();
|
||||
assert!(persisted.get("auth_mode").is_none());
|
||||
server.verify().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial(codex_auth_env)]
|
||||
async fn login_with_access_token_rejects_invalid_personal_access_token() {
|
||||
let dir = tempdir().unwrap();
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/v1/user-auth-credential/whoami"))
|
||||
.respond_with(ResponseTemplate::new(403))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri());
|
||||
|
||||
let err = super::login_with_access_token(
|
||||
dir.path(),
|
||||
"at-invalid-login",
|
||||
AuthCredentialsStoreMode::File,
|
||||
/*chatgpt_base_url*/ None,
|
||||
)
|
||||
.await
|
||||
.expect_err("invalid personal access token should fail");
|
||||
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::Other);
|
||||
assert!(
|
||||
!get_auth_file(dir.path()).exists(),
|
||||
"invalid personal access token should not write auth.json"
|
||||
);
|
||||
server.verify().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_with_access_token_rejects_invalid_jwt() {
|
||||
let dir = tempdir().unwrap();
|
||||
@@ -245,6 +324,7 @@ async fn pro_account_with_no_api_key_uses_chatgpt_auth() {
|
||||
}),
|
||||
last_refresh: Some(last_refresh),
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
},
|
||||
auth_dot_json
|
||||
);
|
||||
@@ -286,6 +366,7 @@ fn logout_removes_auth_file() -> Result<(), std::io::Error> {
|
||||
tokens: None,
|
||||
last_refresh: None,
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
};
|
||||
super::save_auth(dir.path(), &auth_dot_json, AuthCredentialsStoreMode::File)?;
|
||||
let auth_file = get_auth_file(dir.path());
|
||||
@@ -762,6 +843,98 @@ async fn load_auth_reads_access_token_from_env() {
|
||||
server.verify().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial(codex_auth_env)]
|
||||
async fn load_auth_reads_personal_access_token_from_env() {
|
||||
let codex_home = tempdir().unwrap();
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/v1/user-auth-credential/whoami"))
|
||||
.and(header("authorization", "Bearer at-env-test"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_json(personal_access_token_whoami(WORKSPACE_ID_ALLOWED)),
|
||||
)
|
||||
.expect(2)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri());
|
||||
let _access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, "at-env-test");
|
||||
|
||||
for auth_credentials_store_mode in [
|
||||
AuthCredentialsStoreMode::File,
|
||||
AuthCredentialsStoreMode::Ephemeral,
|
||||
] {
|
||||
let auth = super::load_auth(
|
||||
codex_home.path(),
|
||||
/*enable_codex_api_key_env*/ false,
|
||||
auth_credentials_store_mode,
|
||||
/*chatgpt_base_url*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("env auth should load")
|
||||
.expect("env auth should be present");
|
||||
|
||||
assert_eq!(auth.api_auth_mode(), AuthMode::PersonalAccessToken);
|
||||
assert_eq!(
|
||||
auth.get_token()
|
||||
.expect("personal access token should be exposed"),
|
||||
"at-env-test"
|
||||
);
|
||||
assert_eq!(auth.get_account_id().as_deref(), Some(WORKSPACE_ID_ALLOWED));
|
||||
assert_eq!(auth.get_chatgpt_user_id().as_deref(), Some("user-123"));
|
||||
assert_eq!(
|
||||
auth.get_account_email().as_deref(),
|
||||
Some("user@example.com")
|
||||
);
|
||||
assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Business));
|
||||
assert!(auth.is_fedramp_account());
|
||||
}
|
||||
assert!(
|
||||
!get_auth_file(codex_home.path()).exists(),
|
||||
"env auth should not write auth.json"
|
||||
);
|
||||
server.verify().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial(codex_auth_env)]
|
||||
async fn personal_access_token_does_not_offer_unauthorized_recovery() {
|
||||
let codex_home = tempdir().unwrap();
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/v1/user-auth-credential/whoami"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_json(personal_access_token_whoami(WORKSPACE_ID_ALLOWED)),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri());
|
||||
let _access_token_guard =
|
||||
EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, "at-no-unauthorized-recovery");
|
||||
let manager = Arc::new(
|
||||
AuthManager::new(
|
||||
codex_home.path().to_path_buf(),
|
||||
/*enable_codex_api_key_env*/ false,
|
||||
AuthCredentialsStoreMode::File,
|
||||
/*chatgpt_base_url*/ None,
|
||||
)
|
||||
.await,
|
||||
);
|
||||
|
||||
let recovery = manager.unauthorized_recovery();
|
||||
|
||||
assert!(!recovery.has_next());
|
||||
assert_eq!(recovery.unavailable_reason(), "not_refreshable_auth");
|
||||
manager
|
||||
.refresh_token_from_authority()
|
||||
.await
|
||||
.expect("personal access tokens do not use OAuth refresh");
|
||||
server.verify().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial(codex_auth_env)]
|
||||
async fn load_auth_keeps_codex_api_key_env_precedence() {
|
||||
@@ -938,6 +1111,7 @@ async fn enforce_login_restrictions_logs_out_for_agent_identity_workspace_mismat
|
||||
tokens: None,
|
||||
last_refresh: None,
|
||||
agent_identity: Some(agent_identity),
|
||||
personal_access_token: None,
|
||||
},
|
||||
AuthCredentialsStoreMode::File,
|
||||
)
|
||||
@@ -1091,6 +1265,16 @@ fn test_jwks_body() -> serde_json::Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn personal_access_token_whoami(account_id: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"email": "user@example.com",
|
||||
"chatgpt_user_id": "user-123",
|
||||
"chatgpt_account_id": account_id,
|
||||
"chatgpt_plan_type": "business",
|
||||
"chatgpt_account_is_fedramp": true,
|
||||
})
|
||||
}
|
||||
|
||||
const TEST_AGENT_IDENTITY_RSA_PRIVATE_KEY_PEM: &[u8] = br#"-----BEGIN PRIVATE KEY-----
|
||||
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDWpAXYypOsYAwO
|
||||
bvBduMk/mxaoYDze0AZSzaSzLuIlcsl2EKDgC3AabhIWXh/qTGEJLOU3VB1e5mO9
|
||||
|
||||
@@ -24,9 +24,12 @@ use codex_app_server_protocol::AuthMode as ApiAuthMode;
|
||||
use codex_protocol::config_types::ForcedLoginMethod;
|
||||
use codex_protocol::config_types::ModelProviderAuthInfo;
|
||||
|
||||
use super::access_token::CodexAccessToken;
|
||||
use super::access_token::classify_codex_access_token;
|
||||
use super::external_bearer::BearerTokenRefresher;
|
||||
use super::revoke::revoke_auth_tokens;
|
||||
pub use crate::auth::agent_identity::AgentIdentityAuth;
|
||||
pub use crate::auth::personal_access_token::PersonalAccessTokenAuth;
|
||||
pub use crate::auth::storage::AgentIdentityAuthRecord;
|
||||
pub use crate::auth::storage::AuthDotJson;
|
||||
use crate::auth::storage::AuthStorageBackend;
|
||||
@@ -53,11 +56,15 @@ pub enum CodexAuth {
|
||||
Chatgpt(ChatgptAuth),
|
||||
ChatgptAuthTokens(ChatgptAuthTokens),
|
||||
AgentIdentity(AgentIdentityAuth),
|
||||
PersonalAccessToken(PersonalAccessTokenAuth),
|
||||
}
|
||||
|
||||
impl PartialEq for CodexAuth {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.api_auth_mode() == other.api_auth_mode()
|
||||
match (self, other) {
|
||||
(Self::PersonalAccessToken(a), Self::PersonalAccessToken(b)) => a == b,
|
||||
_ => self.api_auth_mode() == other.api_auth_mode(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,6 +227,14 @@ impl CodexAuth {
|
||||
};
|
||||
return Self::from_agent_identity_jwt(&agent_identity, chatgpt_base_url).await;
|
||||
}
|
||||
if auth_mode == ApiAuthMode::PersonalAccessToken {
|
||||
let Some(personal_access_token) = auth_dot_json.personal_access_token.as_deref() else {
|
||||
return Err(std::io::Error::other(
|
||||
"personal access token auth is missing a personal access token.",
|
||||
));
|
||||
};
|
||||
return Self::from_personal_access_token(personal_access_token).await;
|
||||
}
|
||||
|
||||
let storage_mode = auth_dot_json.storage_mode(auth_credentials_store_mode);
|
||||
let state = ChatgptAuthState {
|
||||
@@ -237,6 +252,9 @@ impl CodexAuth {
|
||||
}
|
||||
ApiAuthMode::ApiKey => unreachable!("api key mode is handled above"),
|
||||
ApiAuthMode::AgentIdentity => unreachable!("agent identity mode is handled above"),
|
||||
ApiAuthMode::PersonalAccessToken => {
|
||||
unreachable!("personal access token mode is handled above")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,11 +284,18 @@ impl CodexAuth {
|
||||
Ok(Self::AgentIdentity(AgentIdentityAuth::load(record).await?))
|
||||
}
|
||||
|
||||
pub async fn from_personal_access_token(access_token: &str) -> std::io::Result<Self> {
|
||||
Ok(Self::PersonalAccessToken(
|
||||
PersonalAccessTokenAuth::load(access_token).await?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn auth_mode(&self) -> AuthMode {
|
||||
match self {
|
||||
Self::ApiKey(_) => AuthMode::ApiKey,
|
||||
Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) => AuthMode::Chatgpt,
|
||||
Self::AgentIdentity(_) => AuthMode::AgentIdentity,
|
||||
Self::PersonalAccessToken(_) => AuthMode::PersonalAccessToken,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,6 +305,7 @@ impl CodexAuth {
|
||||
Self::Chatgpt(_) => ApiAuthMode::Chatgpt,
|
||||
Self::ChatgptAuthTokens(_) => ApiAuthMode::ChatgptAuthTokens,
|
||||
Self::AgentIdentity(_) => ApiAuthMode::AgentIdentity,
|
||||
Self::PersonalAccessToken(_) => ApiAuthMode::PersonalAccessToken,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,14 +313,21 @@ impl CodexAuth {
|
||||
self.auth_mode() == AuthMode::ApiKey
|
||||
}
|
||||
|
||||
pub fn is_personal_access_token_auth(&self) -> bool {
|
||||
self.auth_mode() == AuthMode::PersonalAccessToken
|
||||
}
|
||||
|
||||
pub fn is_chatgpt_auth(&self) -> bool {
|
||||
matches!(self, Self::Chatgpt(_) | Self::ChatgptAuthTokens(_))
|
||||
self.api_auth_mode().has_chatgpt_account()
|
||||
}
|
||||
|
||||
pub fn uses_codex_backend(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) | Self::AgentIdentity(_)
|
||||
Self::Chatgpt(_)
|
||||
| Self::ChatgptAuthTokens(_)
|
||||
| Self::AgentIdentity(_)
|
||||
| Self::PersonalAccessToken(_)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -302,11 +335,18 @@ impl CodexAuth {
|
||||
matches!(self, Self::ChatgptAuthTokens(_))
|
||||
}
|
||||
|
||||
fn supports_unauthorized_recovery(&self) -> bool {
|
||||
matches!(self, Self::Chatgpt(_) | Self::ChatgptAuthTokens(_))
|
||||
}
|
||||
|
||||
/// Returns `None` if `auth_mode() != AuthMode::ApiKey`.
|
||||
pub fn api_key(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::ApiKey(auth) => Some(auth.api_key.as_str()),
|
||||
Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) | Self::AgentIdentity(_) => None,
|
||||
Self::Chatgpt(_)
|
||||
| Self::ChatgptAuthTokens(_)
|
||||
| Self::AgentIdentity(_)
|
||||
| Self::PersonalAccessToken(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,6 +374,7 @@ impl CodexAuth {
|
||||
Self::AgentIdentity(_) => Err(std::io::Error::other(
|
||||
"agent identity auth does not expose a bearer token",
|
||||
)),
|
||||
Self::PersonalAccessToken(auth) => Ok(auth.access_token().to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,6 +382,7 @@ impl CodexAuth {
|
||||
pub fn get_account_id(&self) -> Option<String> {
|
||||
match self {
|
||||
Self::AgentIdentity(auth) => Some(auth.account_id().to_string()),
|
||||
Self::PersonalAccessToken(auth) => Some(auth.account_id().to_string()),
|
||||
_ => self.get_current_token_data().and_then(|t| t.account_id),
|
||||
}
|
||||
}
|
||||
@@ -349,6 +391,7 @@ impl CodexAuth {
|
||||
pub fn is_fedramp_account(&self) -> bool {
|
||||
match self {
|
||||
Self::AgentIdentity(auth) => auth.is_fedramp_account(),
|
||||
Self::PersonalAccessToken(auth) => auth.is_fedramp_account(),
|
||||
_ => self
|
||||
.get_current_token_data()
|
||||
.is_some_and(|t| t.id_token.is_fedramp_account()),
|
||||
@@ -359,6 +402,7 @@ impl CodexAuth {
|
||||
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.get_current_token_data().and_then(|t| t.id_token.email),
|
||||
}
|
||||
}
|
||||
@@ -367,6 +411,7 @@ impl CodexAuth {
|
||||
pub fn get_chatgpt_user_id(&self) -> Option<String> {
|
||||
match self {
|
||||
Self::AgentIdentity(auth) => Some(auth.chatgpt_user_id().to_string()),
|
||||
Self::PersonalAccessToken(auth) => Some(auth.chatgpt_user_id().to_string()),
|
||||
_ => self
|
||||
.get_current_token_data()
|
||||
.and_then(|t| t.id_token.chatgpt_user_id),
|
||||
@@ -380,6 +425,9 @@ impl CodexAuth {
|
||||
if let Self::AgentIdentity(auth) = self {
|
||||
return Some(auth.plan_type());
|
||||
}
|
||||
if let Self::PersonalAccessToken(auth) = self {
|
||||
return Some(auth.plan_type());
|
||||
}
|
||||
|
||||
self.get_current_token_data().map(|t| {
|
||||
t.id_token
|
||||
@@ -399,7 +447,7 @@ impl CodexAuth {
|
||||
let state = match self {
|
||||
Self::Chatgpt(auth) => &auth.state,
|
||||
Self::ChatgptAuthTokens(auth) => &auth.state,
|
||||
Self::ApiKey(_) | Self::AgentIdentity(_) => return None,
|
||||
Self::ApiKey(_) | Self::AgentIdentity(_) | Self::PersonalAccessToken(_) => return None,
|
||||
};
|
||||
#[expect(clippy::unwrap_used)]
|
||||
state.auth_dot_json.lock().unwrap().clone()
|
||||
@@ -423,6 +471,7 @@ impl CodexAuth {
|
||||
}),
|
||||
last_refresh: Some(Utc::now()),
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
};
|
||||
|
||||
let client = create_client();
|
||||
@@ -539,6 +588,7 @@ pub fn login_with_api_key(
|
||||
tokens: None,
|
||||
last_refresh: None,
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
};
|
||||
save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode)
|
||||
}
|
||||
@@ -550,17 +600,35 @@ pub async fn login_with_access_token(
|
||||
auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||||
chatgpt_base_url: Option<&str>,
|
||||
) -> std::io::Result<()> {
|
||||
let base_url = chatgpt_base_url
|
||||
.unwrap_or(DEFAULT_CHATGPT_BACKEND_BASE_URL)
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
verified_agent_identity_record(access_token, &base_url).await?;
|
||||
let auth_dot_json = AuthDotJson {
|
||||
auth_mode: Some(ApiAuthMode::AgentIdentity),
|
||||
openai_api_key: None,
|
||||
tokens: None,
|
||||
last_refresh: None,
|
||||
agent_identity: Some(access_token.to_string()),
|
||||
let auth_dot_json = match classify_codex_access_token(access_token) {
|
||||
CodexAccessToken::PersonalAccessToken(access_token) => {
|
||||
PersonalAccessTokenAuth::load(access_token).await?;
|
||||
AuthDotJson {
|
||||
// Infer PAT auth from the credential field so older Codex builds can still
|
||||
// deserialize auth.json after a rollback.
|
||||
auth_mode: None,
|
||||
openai_api_key: None,
|
||||
tokens: None,
|
||||
last_refresh: None,
|
||||
agent_identity: None,
|
||||
personal_access_token: Some(access_token.to_string()),
|
||||
}
|
||||
}
|
||||
CodexAccessToken::AgentIdentityJwt(jwt) => {
|
||||
let base_url = chatgpt_base_url
|
||||
.unwrap_or(DEFAULT_CHATGPT_BACKEND_BASE_URL)
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
verified_agent_identity_record(jwt, &base_url).await?;
|
||||
AuthDotJson {
|
||||
auth_mode: Some(ApiAuthMode::AgentIdentity),
|
||||
openai_api_key: None,
|
||||
tokens: None,
|
||||
last_refresh: None,
|
||||
agent_identity: Some(jwt.to_string()),
|
||||
personal_access_token: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode)
|
||||
}
|
||||
@@ -633,10 +701,12 @@ pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<
|
||||
(ForcedLoginMethod::Api, AuthMode::ApiKey) => None,
|
||||
(ForcedLoginMethod::Chatgpt, AuthMode::Chatgpt)
|
||||
| (ForcedLoginMethod::Chatgpt, AuthMode::ChatgptAuthTokens)
|
||||
| (ForcedLoginMethod::Chatgpt, AuthMode::AgentIdentity) => None,
|
||||
| (ForcedLoginMethod::Chatgpt, AuthMode::AgentIdentity)
|
||||
| (ForcedLoginMethod::Chatgpt, AuthMode::PersonalAccessToken) => None,
|
||||
(ForcedLoginMethod::Api, AuthMode::Chatgpt)
|
||||
| (ForcedLoginMethod::Api, AuthMode::ChatgptAuthTokens)
|
||||
| (ForcedLoginMethod::Api, AuthMode::AgentIdentity) => Some(
|
||||
| (ForcedLoginMethod::Api, AuthMode::AgentIdentity)
|
||||
| (ForcedLoginMethod::Api, AuthMode::PersonalAccessToken) => Some(
|
||||
"API key login is required, but ChatGPT is currently being used. Logging out."
|
||||
.to_string(),
|
||||
),
|
||||
@@ -657,7 +727,7 @@ pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<
|
||||
|
||||
if let Some(expected_account_ids) = config.forced_chatgpt_workspace_id.as_deref() {
|
||||
let chatgpt_account_id = match &auth {
|
||||
CodexAuth::ApiKey(_) => return Ok(()),
|
||||
CodexAuth::ApiKey(_) | CodexAuth::PersonalAccessToken(_) => return Ok(()),
|
||||
CodexAuth::AgentIdentity(_) => auth.get_account_id(),
|
||||
CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) => {
|
||||
let token_data = match auth.get_token_data() {
|
||||
@@ -758,17 +828,26 @@ async fn load_auth(
|
||||
return Ok(Some(auth));
|
||||
}
|
||||
|
||||
if let Some(access_token) = read_codex_access_token_from_env() {
|
||||
return match classify_codex_access_token(&access_token) {
|
||||
CodexAccessToken::PersonalAccessToken(access_token) => {
|
||||
CodexAuth::from_personal_access_token(access_token)
|
||||
.await
|
||||
.map(Some)
|
||||
}
|
||||
CodexAccessToken::AgentIdentityJwt(jwt) => {
|
||||
CodexAuth::from_agent_identity_jwt(jwt, chatgpt_base_url)
|
||||
.await
|
||||
.map(Some)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// If the caller explicitly requested ephemeral auth, there is no persisted fallback.
|
||||
if auth_credentials_store_mode == AuthCredentialsStoreMode::Ephemeral {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if let Some(agent_identity) = read_codex_access_token_from_env() {
|
||||
return CodexAuth::from_agent_identity_jwt(&agent_identity, chatgpt_base_url)
|
||||
.await
|
||||
.map(Some);
|
||||
}
|
||||
|
||||
// Fall back to the configured persistent store (file/keyring/auto) for managed auth.
|
||||
let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode);
|
||||
let auth_dot_json = match storage.load()? {
|
||||
@@ -963,6 +1042,7 @@ impl AuthDotJson {
|
||||
tokens: Some(tokens),
|
||||
last_refresh: Some(Utc::now()),
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -983,6 +1063,9 @@ impl AuthDotJson {
|
||||
if let Some(mode) = self.auth_mode {
|
||||
return mode;
|
||||
}
|
||||
if self.personal_access_token.is_some() {
|
||||
return ApiAuthMode::PersonalAccessToken;
|
||||
}
|
||||
if self.openai_api_key.is_some() {
|
||||
return ApiAuthMode::ApiKey;
|
||||
}
|
||||
@@ -1126,7 +1209,7 @@ impl UnauthorizedRecovery {
|
||||
.manager
|
||||
.auth_cached()
|
||||
.as_ref()
|
||||
.is_some_and(CodexAuth::is_chatgpt_auth)
|
||||
.is_some_and(CodexAuth::supports_unauthorized_recovery)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -1147,11 +1230,20 @@ impl UnauthorizedRecovery {
|
||||
};
|
||||
}
|
||||
|
||||
if self
|
||||
.manager
|
||||
.auth_cached()
|
||||
.as_ref()
|
||||
.is_some_and(CodexAuth::is_personal_access_token_auth)
|
||||
{
|
||||
return "not_refreshable_auth";
|
||||
}
|
||||
|
||||
if !self
|
||||
.manager
|
||||
.auth_cached()
|
||||
.as_ref()
|
||||
.is_some_and(CodexAuth::is_chatgpt_auth)
|
||||
.is_some_and(CodexAuth::supports_unauthorized_recovery)
|
||||
{
|
||||
return "not_chatgpt_auth";
|
||||
}
|
||||
@@ -1497,6 +1589,7 @@ impl AuthManager {
|
||||
}
|
||||
_ => false,
|
||||
},
|
||||
(ApiAuthMode::PersonalAccessToken, ApiAuthMode::PersonalAccessToken) => a == b,
|
||||
_ => false,
|
||||
},
|
||||
_ => false,
|
||||
@@ -1690,7 +1783,7 @@ impl AuthManager {
|
||||
let auth_before_reload = self.auth_cached();
|
||||
if auth_before_reload
|
||||
.as_ref()
|
||||
.is_some_and(CodexAuth::is_api_key_auth)
|
||||
.is_some_and(|auth| auth.is_api_key_auth() || auth.is_personal_access_token_auth())
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1756,7 +1849,9 @@ impl AuthManager {
|
||||
self.refresh_and_persist_chatgpt_token(&chatgpt_auth, token_data.refresh_token)
|
||||
.await
|
||||
}
|
||||
CodexAuth::ApiKey(_) | CodexAuth::AgentIdentity(_) => Ok(()),
|
||||
CodexAuth::ApiKey(_)
|
||||
| CodexAuth::AgentIdentity(_)
|
||||
| CodexAuth::PersonalAccessToken(_) => Ok(()),
|
||||
};
|
||||
if let Err(RefreshTokenError::Permanent(error)) = &result {
|
||||
self.record_permanent_refresh_failure_if_unchanged(&attempted_auth, error);
|
||||
@@ -1805,7 +1900,12 @@ impl AuthManager {
|
||||
pub fn current_auth_uses_codex_backend(&self) -> bool {
|
||||
matches!(
|
||||
self.auth_mode(),
|
||||
Some(AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens | AuthMode::AgentIdentity)
|
||||
Some(
|
||||
AuthMode::Chatgpt
|
||||
| AuthMode::ChatgptAuthTokens
|
||||
| AuthMode::AgentIdentity
|
||||
| AuthMode::PersonalAccessToken
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
mod access_token;
|
||||
mod agent_identity;
|
||||
pub mod default_client;
|
||||
pub mod error;
|
||||
mod personal_access_token;
|
||||
mod storage;
|
||||
mod util;
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
use codex_client::CodexHttpClient;
|
||||
use codex_protocol::account::PlanType as AccountPlanType;
|
||||
use codex_protocol::auth::PlanType as InternalPlanType;
|
||||
use serde::Deserialize;
|
||||
use std::env;
|
||||
use std::fmt;
|
||||
|
||||
use crate::default_client::create_client;
|
||||
|
||||
const PROD_AUTHAPI_BASE_URL: &str = "https://auth.openai.com/api/accounts";
|
||||
const CODEX_AUTHAPI_BASE_URL_ENV_VAR: &str = "CODEX_AUTHAPI_BASE_URL";
|
||||
const WHOAMI_PATH: &str = "/v1/user-auth-credential/whoami";
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
|
||||
struct PersonalAccessTokenMetadata {
|
||||
email: String,
|
||||
chatgpt_user_id: String,
|
||||
chatgpt_account_id: String,
|
||||
chatgpt_plan_type: String,
|
||||
chatgpt_account_is_fedramp: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct PersonalAccessTokenAuth {
|
||||
access_token: String,
|
||||
metadata: PersonalAccessTokenMetadata,
|
||||
}
|
||||
|
||||
impl fmt::Debug for PersonalAccessTokenAuth {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("PersonalAccessTokenAuth")
|
||||
.field("access_token", &"<redacted>")
|
||||
.field("metadata", &self.metadata)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl PersonalAccessTokenAuth {
|
||||
pub(super) async fn load(access_token: &str) -> std::io::Result<Self> {
|
||||
let authapi_base_url = env::var(CODEX_AUTHAPI_BASE_URL_ENV_VAR)
|
||||
.ok()
|
||||
.map(|base_url| base_url.trim().trim_end_matches('/').to_string())
|
||||
.filter(|base_url| !base_url.is_empty())
|
||||
.unwrap_or_else(|| PROD_AUTHAPI_BASE_URL.to_string());
|
||||
hydrate_personal_access_token(&create_client(), &authapi_base_url, access_token).await
|
||||
}
|
||||
|
||||
pub fn access_token(&self) -> &str {
|
||||
&self.access_token
|
||||
}
|
||||
|
||||
pub fn account_id(&self) -> &str {
|
||||
&self.metadata.chatgpt_account_id
|
||||
}
|
||||
|
||||
pub fn chatgpt_user_id(&self) -> &str {
|
||||
&self.metadata.chatgpt_user_id
|
||||
}
|
||||
|
||||
pub fn email(&self) -> &str {
|
||||
&self.metadata.email
|
||||
}
|
||||
|
||||
pub fn plan_type(&self) -> AccountPlanType {
|
||||
InternalPlanType::from_raw_value(&self.metadata.chatgpt_plan_type).into()
|
||||
}
|
||||
|
||||
pub fn is_fedramp_account(&self) -> bool {
|
||||
self.metadata.chatgpt_account_is_fedramp
|
||||
}
|
||||
}
|
||||
|
||||
async fn hydrate_personal_access_token(
|
||||
client: &CodexHttpClient,
|
||||
authapi_base_url: &str,
|
||||
access_token: &str,
|
||||
) -> std::io::Result<PersonalAccessTokenAuth> {
|
||||
let endpoint = format!("{}{WHOAMI_PATH}", authapi_base_url.trim_end_matches('/'));
|
||||
let response = client
|
||||
.get(&endpoint)
|
||||
.bearer_auth(access_token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
std::io::Error::other(format!(
|
||||
"failed to request personal access token metadata: {err}"
|
||||
))
|
||||
})?;
|
||||
if !response.status().is_success() {
|
||||
return Err(std::io::Error::other(format!(
|
||||
"personal access token metadata request failed with status {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let metadata = response
|
||||
.json::<PersonalAccessTokenMetadata>()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
std::io::Error::other(format!(
|
||||
"failed to decode personal access token metadata: {err}"
|
||||
))
|
||||
})?;
|
||||
Ok(PersonalAccessTokenAuth {
|
||||
access_token: access_token.to_string(),
|
||||
metadata,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "personal_access_token_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,71 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::header;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
fn response(email: Option<&str>) -> serde_json::Value {
|
||||
json!({
|
||||
"email": email,
|
||||
"chatgpt_user_id": "user-123",
|
||||
"chatgpt_account_id": "account-123",
|
||||
"chatgpt_plan_type": "enterprise",
|
||||
"chatgpt_account_is_fedramp": true,
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hydrate_sends_bearer_token_and_preserves_metadata() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(WHOAMI_PATH))
|
||||
.and(header("authorization", "Bearer at-example"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(response(Some("user@example.com"))))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let auth = hydrate_personal_access_token(&create_client(), &server.uri(), "at-example")
|
||||
.await
|
||||
.expect("personal access token hydration should succeed");
|
||||
|
||||
assert_eq!(
|
||||
auth,
|
||||
PersonalAccessTokenAuth {
|
||||
access_token: "at-example".to_string(),
|
||||
metadata: PersonalAccessTokenMetadata {
|
||||
email: "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(),
|
||||
chatgpt_account_is_fedramp: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
server.verify().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hydrate_rejects_missing_email() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(WHOAMI_PATH))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(response(/*email*/ None)))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let err = hydrate_personal_access_token(&create_client(), &server.uri(), "at-example")
|
||||
.await
|
||||
.expect_err("personal access token hydration should reject missing email");
|
||||
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("failed to decode personal access token metadata")
|
||||
);
|
||||
server.verify().await;
|
||||
}
|
||||
@@ -45,6 +45,9 @@ pub struct AuthDotJson {
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub agent_identity: Option<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub personal_access_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)]
|
||||
|
||||
@@ -19,6 +19,7 @@ async fn file_storage_load_returns_auth_dot_json() -> anyhow::Result<()> {
|
||||
tokens: None,
|
||||
last_refresh: Some(Utc::now()),
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
};
|
||||
|
||||
storage
|
||||
@@ -40,6 +41,7 @@ async fn file_storage_save_persists_auth_dot_json() -> anyhow::Result<()> {
|
||||
tokens: None,
|
||||
last_refresh: Some(Utc::now()),
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
};
|
||||
|
||||
let file = get_auth_file(codex_home.path());
|
||||
@@ -73,6 +75,27 @@ async fn file_storage_round_trips_agent_identity_auth() -> anyhow::Result<()> {
|
||||
tokens: None,
|
||||
last_refresh: None,
|
||||
agent_identity: Some(agent_identity),
|
||||
personal_access_token: None,
|
||||
};
|
||||
|
||||
storage.save(&auth_dot_json)?;
|
||||
|
||||
let loaded = storage.load()?;
|
||||
assert_eq!(Some(auth_dot_json), loaded);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_storage_round_trips_personal_access_token_auth() -> 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::PersonalAccessToken),
|
||||
openai_api_key: None,
|
||||
tokens: None,
|
||||
last_refresh: None,
|
||||
agent_identity: None,
|
||||
personal_access_token: Some("at-example".to_string()),
|
||||
};
|
||||
|
||||
storage.save(&auth_dot_json)?;
|
||||
@@ -122,6 +145,7 @@ fn file_storage_delete_removes_auth_file() -> anyhow::Result<()> {
|
||||
tokens: None,
|
||||
last_refresh: None,
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
};
|
||||
let storage = create_auth_storage(dir.path().to_path_buf(), AuthCredentialsStoreMode::File);
|
||||
storage.save(&auth_dot_json)?;
|
||||
@@ -146,6 +170,7 @@ fn ephemeral_storage_save_load_delete_is_in_memory_only() -> anyhow::Result<()>
|
||||
tokens: None,
|
||||
last_refresh: Some(Utc::now()),
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
};
|
||||
|
||||
storage.save(&auth_dot_json)?;
|
||||
@@ -245,6 +270,7 @@ fn auth_with_prefix(prefix: &str) -> AuthDotJson {
|
||||
}),
|
||||
last_refresh: None,
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,6 +296,7 @@ fn keyring_auth_storage_load_returns_deserialized_auth() -> anyhow::Result<()> {
|
||||
tokens: None,
|
||||
last_refresh: None,
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
};
|
||||
seed_keyring_with_auth(
|
||||
&mock_keyring,
|
||||
@@ -313,6 +340,7 @@ fn keyring_auth_storage_save_persists_and_removes_fallback_file() -> anyhow::Res
|
||||
}),
|
||||
last_refresh: Some(Utc::now()),
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
};
|
||||
|
||||
storage.save(&auth)?;
|
||||
|
||||
@@ -822,6 +822,7 @@ pub(crate) async fn persist_tokens_async(
|
||||
tokens: Some(tokens),
|
||||
last_refresh: Some(Utc::now()),
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
};
|
||||
save_auth(&codex_home, &auth, auth_credentials_store_mode)?;
|
||||
Ok::<_, io::Error>((previous_auth, auth))
|
||||
@@ -940,6 +941,20 @@ pub(crate) fn ensure_workspace_allowed(
|
||||
return Err("Login is restricted to a specific workspace, but the token did not include an chatgpt_account_id claim.".to_string());
|
||||
};
|
||||
|
||||
ensure_workspace_account_allowed(Some(expected), actual)
|
||||
}
|
||||
|
||||
/// Validates an already known ChatGPT account ID against an optional workspace restriction.
|
||||
///
|
||||
/// PAT login calls this directly because `/whoami` supplies the account ID without an ID token.
|
||||
pub(crate) fn ensure_workspace_account_allowed(
|
||||
expected: Option<&[String]>,
|
||||
actual: &str,
|
||||
) -> Result<(), String> {
|
||||
let Some(expected) = expected else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if expected.iter().any(|workspace_id| workspace_id == actual) {
|
||||
Ok(())
|
||||
} else {
|
||||
@@ -1311,6 +1326,7 @@ mod tests {
|
||||
}),
|
||||
last_refresh: None,
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ async fn refresh_token_succeeds_updates_storage() -> Result<()> {
|
||||
tokens: Some(initial_tokens.clone()),
|
||||
last_refresh: Some(initial_last_refresh),
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
};
|
||||
ctx.write_auth(&initial_auth).await?;
|
||||
|
||||
@@ -119,6 +120,7 @@ async fn refresh_token_refreshes_when_auth_is_unchanged() -> Result<()> {
|
||||
tokens: Some(initial_tokens.clone()),
|
||||
last_refresh: Some(initial_last_refresh),
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
};
|
||||
ctx.write_auth(&initial_auth).await?;
|
||||
|
||||
@@ -184,6 +186,7 @@ async fn auth_refreshes_when_access_token_is_near_expiry() -> Result<()> {
|
||||
tokens: Some(initial_tokens.clone()),
|
||||
last_refresh: Some(initial_last_refresh),
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
};
|
||||
ctx.write_auth(&initial_auth).await?;
|
||||
|
||||
@@ -234,6 +237,7 @@ async fn auth_skips_access_token_outside_refresh_window() -> Result<()> {
|
||||
tokens: Some(initial_tokens.clone()),
|
||||
last_refresh: Some(initial_last_refresh),
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
};
|
||||
ctx.write_auth(&initial_auth).await?;
|
||||
|
||||
@@ -270,6 +274,7 @@ async fn refresh_token_skips_refresh_when_auth_changed() -> Result<()> {
|
||||
tokens: Some(initial_tokens),
|
||||
last_refresh: Some(initial_last_refresh),
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
};
|
||||
ctx.write_auth(&initial_auth).await?;
|
||||
|
||||
@@ -280,6 +285,7 @@ async fn refresh_token_skips_refresh_when_auth_changed() -> Result<()> {
|
||||
tokens: Some(disk_tokens.clone()),
|
||||
last_refresh: Some(initial_last_refresh),
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
};
|
||||
save_auth(
|
||||
ctx.codex_home.path(),
|
||||
@@ -335,6 +341,7 @@ async fn refresh_token_errors_on_account_mismatch() -> Result<()> {
|
||||
tokens: Some(initial_tokens.clone()),
|
||||
last_refresh: Some(initial_last_refresh),
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
};
|
||||
ctx.write_auth(&initial_auth).await?;
|
||||
|
||||
@@ -346,6 +353,7 @@ async fn refresh_token_errors_on_account_mismatch() -> Result<()> {
|
||||
tokens: Some(disk_tokens),
|
||||
last_refresh: Some(initial_last_refresh),
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
};
|
||||
save_auth(
|
||||
ctx.codex_home.path(),
|
||||
@@ -405,6 +413,7 @@ async fn returns_fresh_tokens_as_is() -> Result<()> {
|
||||
tokens: Some(initial_tokens.clone()),
|
||||
last_refresh: Some(stale_refresh),
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
};
|
||||
ctx.write_auth(&initial_auth).await?;
|
||||
|
||||
@@ -453,6 +462,7 @@ async fn refreshes_token_when_access_token_is_expired() -> Result<()> {
|
||||
tokens: Some(initial_tokens.clone()),
|
||||
last_refresh: Some(fresh_refresh),
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
};
|
||||
ctx.write_auth(&initial_auth).await?;
|
||||
|
||||
@@ -503,6 +513,7 @@ async fn auth_reloads_disk_auth_when_cached_auth_is_stale() -> Result<()> {
|
||||
tokens: Some(initial_tokens),
|
||||
last_refresh: Some(stale_refresh),
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
};
|
||||
ctx.write_auth(&initial_auth).await?;
|
||||
|
||||
@@ -514,6 +525,7 @@ async fn auth_reloads_disk_auth_when_cached_auth_is_stale() -> Result<()> {
|
||||
tokens: Some(disk_tokens.clone()),
|
||||
last_refresh: Some(fresh_refresh),
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
};
|
||||
save_auth(
|
||||
ctx.codex_home.path(),
|
||||
@@ -566,6 +578,7 @@ async fn auth_reloads_disk_auth_without_calling_expired_refresh_token() -> Resul
|
||||
tokens: Some(initial_tokens),
|
||||
last_refresh: Some(stale_refresh),
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
};
|
||||
ctx.write_auth(&initial_auth).await?;
|
||||
|
||||
@@ -577,6 +590,7 @@ async fn auth_reloads_disk_auth_without_calling_expired_refresh_token() -> Resul
|
||||
tokens: Some(disk_tokens.clone()),
|
||||
last_refresh: Some(fresh_refresh),
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
};
|
||||
save_auth(
|
||||
ctx.codex_home.path(),
|
||||
@@ -627,6 +641,7 @@ async fn refresh_token_returns_permanent_error_for_expired_refresh_token() -> Re
|
||||
tokens: Some(initial_tokens.clone()),
|
||||
last_refresh: Some(initial_last_refresh),
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
};
|
||||
ctx.write_auth(&initial_auth).await?;
|
||||
|
||||
@@ -680,6 +695,7 @@ async fn refresh_token_does_not_retry_after_permanent_failure() -> Result<()> {
|
||||
tokens: Some(initial_tokens.clone()),
|
||||
last_refresh: Some(initial_last_refresh),
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
};
|
||||
ctx.write_auth(&initial_auth).await?;
|
||||
|
||||
@@ -747,6 +763,7 @@ async fn refresh_token_does_not_retry_after_bad_request_reused_failure() -> Resu
|
||||
tokens: Some(initial_tokens.clone()),
|
||||
last_refresh: Some(initial_last_refresh),
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
};
|
||||
ctx.write_auth(&initial_auth).await?;
|
||||
|
||||
@@ -814,6 +831,7 @@ async fn refresh_token_reloads_changed_auth_after_permanent_failure() -> Result<
|
||||
tokens: Some(initial_tokens.clone()),
|
||||
last_refresh: Some(initial_last_refresh),
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
};
|
||||
ctx.write_auth(&initial_auth).await?;
|
||||
|
||||
@@ -836,6 +854,7 @@ async fn refresh_token_reloads_changed_auth_after_permanent_failure() -> Result<
|
||||
tokens: Some(disk_tokens.clone()),
|
||||
last_refresh: Some(fresh_refresh),
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
};
|
||||
save_auth(
|
||||
ctx.codex_home.path(),
|
||||
@@ -895,6 +914,7 @@ async fn refresh_token_returns_transient_error_on_server_failure() -> Result<()>
|
||||
tokens: Some(initial_tokens.clone()),
|
||||
last_refresh: Some(initial_last_refresh),
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
};
|
||||
ctx.write_auth(&initial_auth).await?;
|
||||
|
||||
@@ -948,6 +968,7 @@ async fn unauthorized_recovery_reloads_then_refreshes_tokens() -> Result<()> {
|
||||
tokens: Some(initial_tokens.clone()),
|
||||
last_refresh: Some(initial_last_refresh),
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
};
|
||||
ctx.write_auth(&initial_auth).await?;
|
||||
|
||||
@@ -958,6 +979,7 @@ async fn unauthorized_recovery_reloads_then_refreshes_tokens() -> Result<()> {
|
||||
tokens: Some(disk_tokens.clone()),
|
||||
last_refresh: Some(initial_last_refresh),
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
};
|
||||
save_auth(
|
||||
ctx.codex_home.path(),
|
||||
@@ -1042,6 +1064,7 @@ async fn unauthorized_recovery_errors_on_account_mismatch() -> Result<()> {
|
||||
tokens: Some(initial_tokens.clone()),
|
||||
last_refresh: Some(initial_last_refresh),
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
};
|
||||
ctx.write_auth(&initial_auth).await?;
|
||||
|
||||
@@ -1053,6 +1076,7 @@ async fn unauthorized_recovery_errors_on_account_mismatch() -> Result<()> {
|
||||
tokens: Some(disk_tokens),
|
||||
last_refresh: Some(initial_last_refresh),
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
};
|
||||
save_auth(
|
||||
ctx.codex_home.path(),
|
||||
@@ -1111,6 +1135,7 @@ async fn unauthorized_recovery_requires_chatgpt_auth() -> Result<()> {
|
||||
tokens: None,
|
||||
last_refresh: None,
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
};
|
||||
ctx.write_auth(&auth).await?;
|
||||
|
||||
|
||||
@@ -195,6 +195,7 @@ fn chatgpt_auth_with_refresh_token(refresh_token: &str) -> AuthDotJson {
|
||||
}),
|
||||
last_refresh: None,
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -237,7 +237,12 @@ impl ModelProviderInfo {
|
||||
pub fn to_api_provider(&self, auth_mode: Option<AuthMode>) -> CodexResult<ApiProvider> {
|
||||
let default_base_url = if matches!(
|
||||
auth_mode,
|
||||
Some(AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens | AuthMode::AgentIdentity)
|
||||
Some(
|
||||
AuthMode::Chatgpt
|
||||
| AuthMode::ChatgptAuthTokens
|
||||
| AuthMode::AgentIdentity
|
||||
| AuthMode::PersonalAccessToken
|
||||
)
|
||||
) {
|
||||
CHATGPT_CODEX_BASE_URL
|
||||
} else {
|
||||
|
||||
@@ -139,6 +139,15 @@ fn test_supports_remote_compaction_for_openai() {
|
||||
assert!(provider.supports_remote_compaction());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_personal_access_token_uses_chatgpt_codex_base_url() {
|
||||
let api_provider = ModelProviderInfo::create_openai_provider(/*base_url*/ None)
|
||||
.to_api_provider(Some(AuthMode::PersonalAccessToken))
|
||||
.expect("OpenAI provider should build API provider");
|
||||
|
||||
assert_eq!(api_provider.base_url, CHATGPT_CODEX_BASE_URL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_supports_remote_compaction_for_azure_name() {
|
||||
let provider = ModelProviderInfo {
|
||||
|
||||
@@ -109,13 +109,14 @@ pub fn auth_provider_from_auth(auth: &CodexAuth) -> SharedAuthProvider {
|
||||
CodexAuth::AgentIdentity(auth) => {
|
||||
Arc::new(AgentIdentityAuthProvider { auth: auth.clone() })
|
||||
}
|
||||
CodexAuth::ApiKey(_) | CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) => {
|
||||
Arc::new(BearerAuthProvider {
|
||||
token: auth.get_token().ok(),
|
||||
account_id: auth.get_account_id(),
|
||||
is_fedramp_account: auth.is_fedramp_account(),
|
||||
})
|
||||
}
|
||||
CodexAuth::ApiKey(_)
|
||||
| CodexAuth::Chatgpt(_)
|
||||
| CodexAuth::ChatgptAuthTokens(_)
|
||||
| CodexAuth::PersonalAccessToken(_) => Arc::new(BearerAuthProvider {
|
||||
token: auth.get_token().ok(),
|
||||
account_id: auth.get_account_id(),
|
||||
is_fedramp_account: auth.is_fedramp_account(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -212,7 +212,8 @@ impl ModelProvider for ConfiguredModelProvider {
|
||||
CodexAuth::ApiKey(_) => Ok(ProviderAccount::ApiKey),
|
||||
CodexAuth::Chatgpt(_)
|
||||
| CodexAuth::ChatgptAuthTokens(_)
|
||||
| CodexAuth::AgentIdentity(_) => {
|
||||
| CodexAuth::AgentIdentity(_)
|
||||
| CodexAuth::PersonalAccessToken(_) => {
|
||||
let email = auth.get_account_email();
|
||||
let plan_type = auth.account_plan_type();
|
||||
|
||||
@@ -453,6 +454,21 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_provider_rejects_chatgpt_account_state_without_email() {
|
||||
let provider = create_model_provider(
|
||||
ModelProviderInfo::create_openai_provider(/*base_url*/ None),
|
||||
Some(AuthManager::from_auth_for_testing(
|
||||
CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
||||
)),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
provider.account_state(),
|
||||
Err(ProviderAccountError::MissingChatgptAccountDetails)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_non_openai_provider_returns_no_account_state() {
|
||||
let provider = create_model_provider(
|
||||
|
||||
@@ -328,10 +328,9 @@ impl OpenAiModelsManager {
|
||||
.iter()
|
||||
.any(|model| model.visibility == ModelVisibility::List)
|
||||
&& self.auth_manager.as_ref().is_some_and(|auth_manager| {
|
||||
matches!(
|
||||
auth_manager.auth_mode(),
|
||||
Some(AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens)
|
||||
)
|
||||
auth_manager
|
||||
.auth_mode()
|
||||
.is_some_and(AuthMode::has_chatgpt_account)
|
||||
});
|
||||
if should_use_remote_models_only {
|
||||
*self.remote_models.write().await = models;
|
||||
|
||||
@@ -211,6 +211,7 @@ c2ln",
|
||||
}),
|
||||
last_refresh: Some(Utc::now()),
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
};
|
||||
std::fs::create_dir_all(codex_home).expect("codex home should be created");
|
||||
std::fs::write(
|
||||
|
||||
@@ -57,7 +57,8 @@ impl From<codex_app_server_protocol::AuthMode> for TelemetryAuthMode {
|
||||
codex_app_server_protocol::AuthMode::ApiKey => Self::ApiKey,
|
||||
codex_app_server_protocol::AuthMode::Chatgpt
|
||||
| codex_app_server_protocol::AuthMode::ChatgptAuthTokens
|
||||
| codex_app_server_protocol::AuthMode::AgentIdentity => Self::Chatgpt,
|
||||
| codex_app_server_protocol::AuthMode::AgentIdentity
|
||||
| codex_app_server_protocol::AuthMode::PersonalAccessToken => Self::Chatgpt,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,10 +87,9 @@ impl App {
|
||||
notification.plan_type,
|
||||
),
|
||||
notification.plan_type,
|
||||
matches!(
|
||||
notification.auth_mode,
|
||||
Some(AuthMode::Chatgpt) | Some(AuthMode::ChatgptAuthTokens)
|
||||
),
|
||||
notification
|
||||
.auth_mode
|
||||
.is_some_and(AuthMode::has_chatgpt_account),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1192,7 +1192,8 @@ pub(crate) fn status_account_display_from_auth_mode(
|
||||
Some(AuthMode::ApiKey) => Some(StatusAccountDisplay::ApiKey),
|
||||
Some(AuthMode::Chatgpt)
|
||||
| Some(AuthMode::ChatgptAuthTokens)
|
||||
| Some(AuthMode::AgentIdentity) => Some(StatusAccountDisplay::ChatGpt {
|
||||
| Some(AuthMode::AgentIdentity)
|
||||
| Some(AuthMode::PersonalAccessToken) => Some(StatusAccountDisplay::ChatGpt {
|
||||
email: None,
|
||||
plan: plan_type.map(plan_type_display_name),
|
||||
}),
|
||||
|
||||
@@ -109,6 +109,7 @@ mod tests {
|
||||
}),
|
||||
last_refresh: Some(Utc::now()),
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
};
|
||||
save_auth(codex_home, &auth, AuthCredentialsStoreMode::File)
|
||||
.expect("chatgpt auth should save");
|
||||
@@ -156,6 +157,7 @@ mod tests {
|
||||
tokens: None,
|
||||
last_refresh: None,
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
},
|
||||
AuthCredentialsStoreMode::File,
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
use codex_app_server_client::AppServerRequestHandle;
|
||||
use codex_app_server_protocol::AccountLoginCompletedNotification;
|
||||
use codex_app_server_protocol::AccountUpdatedNotification;
|
||||
#[cfg(test)]
|
||||
use codex_app_server_protocol::AuthMode as AppServerAuthMode;
|
||||
use codex_app_server_protocol::CancelLoginAccountParams;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
@@ -845,8 +846,7 @@ impl AuthModeWidget {
|
||||
fn handle_existing_chatgpt_login(&mut self) -> bool {
|
||||
if matches!(
|
||||
self.login_status,
|
||||
LoginStatus::AuthMode(AppServerAuthMode::Chatgpt)
|
||||
| LoginStatus::AuthMode(AppServerAuthMode::ChatgptAuthTokens)
|
||||
LoginStatus::AuthMode(auth_mode) if auth_mode.has_chatgpt_account()
|
||||
) {
|
||||
*self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess;
|
||||
self.request_frame.schedule_frame();
|
||||
@@ -1106,17 +1106,22 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn existing_chatgpt_auth_tokens_login_counts_as_signed_in() {
|
||||
let (mut widget, _tmp) = widget_forced_chatgpt().await;
|
||||
widget.login_status = LoginStatus::AuthMode(AppServerAuthMode::ChatgptAuthTokens);
|
||||
async fn existing_non_oauth_chatgpt_login_counts_as_signed_in() {
|
||||
for auth_mode in [
|
||||
AppServerAuthMode::ChatgptAuthTokens,
|
||||
AppServerAuthMode::PersonalAccessToken,
|
||||
] {
|
||||
let (mut widget, _tmp) = widget_forced_chatgpt().await;
|
||||
widget.login_status = LoginStatus::AuthMode(auth_mode);
|
||||
|
||||
let handled = widget.handle_existing_chatgpt_login();
|
||||
let handled = widget.handle_existing_chatgpt_login();
|
||||
|
||||
assert_eq!(handled, true);
|
||||
assert!(matches!(
|
||||
&*widget.sign_in_state.read().unwrap(),
|
||||
SignInState::ChatGptSuccess
|
||||
));
|
||||
assert_eq!(handled, true);
|
||||
assert!(matches!(
|
||||
&*widget.sign_in_state.read().unwrap(),
|
||||
SignInState::ChatGptSuccess
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user