Compare commits

...

15 Commits

Author SHA1 Message Date
SaladDay 0acce5477a Merge main and harden managed Codex account flow 2026-07-18 10:06:58 +00:00
SaladDay d6ebc24cb3 fix(codex-oauth): keep managed token in restore backup; fix failing tests
CI caught three test failures from the previous commit. Root cause: the Live
backup (proxy_live_backup) is local restore state that is replayed to
~/.codex/auth.json when proxy takeover ends, so it must contain the real auth to
restore a working login. Stripping it (the earlier "don't leak into backup"
change) broke restore and over-stripped a user's native login.

- Revert the backup auth stripping: remove sanitize_codex_backup_auth from the
  initial/strict snapshot paths and the forced auth={} in
  update_live_backup_from_provider_inner. The managed token belongs in the
  restore backup (the refresh_token is already persisted by CodexOAuthManager, so
  this is not a new exposure).
- Restore the ownership marker (account_id + access_token fingerprint) so backup
  cleanup can tell our managed write from a user's native `codex login` of the
  same account. extract now also tolerates the full-bundle shape (refresh_token +
  last_refresh) so it fingerprints ①'s refreshable writes. clear_codex_auth_in_backup
  and clear_codex_live_auth_for_managed_account use the marker again; the
  account_id-only helper is dropped.
- Fix the adopt unit test: adopt now invalidates the cached access token, so the
  test asserts the stored refresh_token/id_token were updated and the cache was
  cleared instead of reading it back through get_valid_token_bundle (which would
  trigger a network refresh).

Token stays out of the exported provider settings_config (backfill strip) — only
the local restore backup keeps it, matching pre-existing behavior.
2026-07-01 15:43:49 +00:00
SaladDay c72bd49213 fix(codex-oauth): make managed accounts safe & usable with the bare CLI
Addresses review feedback on #3879. These backend changes are deeply
intertwined across shared files, so they are committed together.

Refreshable managed auth (bare `codex` support):
- Write a full, native-shaped auth.json bundle (tokens.refresh_token + top-level
  last_refresh) instead of an access-only token, so the Codex CLI can self-refresh
  and the managed account keeps working past ~1h without a proxy.
- last_refresh reflects the access token's real obtained-at time (not write time),
  so the CLI doesn't treat a cached token as freshly refreshed.
- Read back the CLI-rotated refresh_token from ~/.codex/auth.json before writing
  (account_id-matched, chatgpt-mode guarded) so re-switching doesn't clobber the
  CLI's valid login with a stale refresh_token.

Never persist managed tokens at rest:
- Backfill of a managed provider always replaces live auth with the stored
  placeholder (even on marker mismatch), so native tokens can't leak into the
  provider's DB config.
- Sanitize managed auth out of Live backups on the initial/strict snapshot paths
  and in update_live_backup_from_provider_inner before serialization.

Concurrency / blocking:
- Drop the redundant outer Arc<RwLock<CodexOAuthManager>> (all methods are &self)
  so token refresh no longer holds a coarse lock across the network; give OAuth
  token/device requests a 30s timeout instead of the shared 600s client default.
- Add a persistence lock and linearize the access-token cache under a consistent
  accounts -> access_tokens order (existence-checked reads/writes; remove/clear
  clear the cache atomically) to prevent stale or resurrected cache entries.

Switch ordering:
- Preflight the managed token before committing the current provider on
  switch/add/update, so a failed token fetch can't leave DB/UI pointing at a
  provider whose live config was never written.

Cleanup:
- Remove the now-dead content-fingerprint marker machinery; managed writes are
  now shape-identical to a native login, so ownership is judged by account_id.

Known, documented limitations (B-scheme, narrow & recoverable): a remove+re-login
of the same account within an in-flight refresh window (ABA), a login authorized
after logout, and switch-away cleanup being unable to distinguish our write from a
user's native login of the same account.
2026-07-01 15:19:12 +00:00
SaladDay d3df16b5d1 fix(providers): don't clear managed OAuth binding on status-query failure
useManagedAuth now exposes isStatusSuccess. The account-clear effect in the
Codex/Copilot selector sections only unbinds when the status query has
*successfully* loaded and the bound account is genuinely gone. Previously a
failed or still-pending query yielded an empty accounts array, which silently
cleared the selection; the subsequent save then dropped providerType/authBinding
and turned a managed provider into a broken unbound config.

Addresses review feedback on #3879.
2026-07-01 15:18:53 +00:00
SaladDay eca472dbf7 fix(i18n): add missing managed-account selector keys
The managed OAuth account selector referenced codexOauth.chatgptAccount,
codexOauth.manageAccounts, copilot.githubAccount, copilot.manageAccounts and
providerForm.providerKeyStatusLoading with Chinese defaultValue fallbacks, so
the en/ja/zh-TW UIs rendered Chinese text. Add the translations across all four
locales.

Addresses review feedback on #3879.
2026-07-01 15:18:45 +00:00
Jason ef731c9919 Merge remote-tracking branch 'origin/main' into feature/managed-oauth-account-selector 2026-07-01 17:04:59 +08:00
SaladDay 3928c590a9 style(codex): satisfy prettier format check for reauth UI 2026-06-23 17:05:27 +00:00
SaladDay fc5fa85807 feat(codex): write managed ChatGPT id_token into live auth + reauth prompt
Bind the selected ChatGPT account's id_token into the Codex official
managed auth.json so it matches a native browser login for the fields
that drive behavior (auth_mode, account/plan/email via id_token claims).

- Persist id_token on CodexAccountData; capture at login and on refresh
  (empty string treated as missing). Add get_valid_token_and_id_token_for_account.
- codex_managed_oauth_live_auth now writes tokens.id_token when available.
- Keep the managed-vs-native safety marker intact: extract_codex_managed_oauth_auth
  tolerates id_token but still rejects native logins (which carry refresh_token /
  top-level last_refresh), so cc-switch never clears a real browser login.
  refresh_token and last_refresh are intentionally NOT written for this reason.
- Surface reauth_required (account has no stored id_token) through
  GitHubAccount -> ManagedAuthAccount -> TS. Legacy accounts get a styled
  amber prompt + one-click re-login (device flow) in CodexOAuthSection,
  flagged in both the selector dropdown and the select-mode hint.
- i18n: reauth strings added for en / ja / zh / zh-TW.

Verified: pnpm typecheck + build:renderer green; cross-model review of Rust
correctness, the managed-vs-native safety invariant, and the id_token lifecycle.
2026-06-23 16:59:24 +00:00
SaladDay 0aeca9a235 Merge remote-tracking branch 'origin/main' into feature/managed-oauth-account-selector
# Conflicts:
#	src/components/providers/forms/ProviderForm.tsx
2026-06-23 15:54:43 +00:00
Jason 71d3128db7 fix(i18n): localize Codex managed-account none option label
ProviderForm passed a hardcoded Chinese string as codexOauthNoneOptionLabel,
so en/ja/zh-TW users saw Chinese text in the Codex account selector's
"none" option. Add codexOauth.noneOptionLabel to all four locales and
resolve it via t().
2026-06-15 20:55:21 +08:00
Jason 44cc8245ab fix(provider-form): show Codex FAST mode toggle in account-select mode
The FAST mode switch was gated on mode === "manage", but the Claude and
Claude Desktop Codex OAuth forms render CodexOAuthSection in select mode
while still passing onFastModeChange. The toggle was therefore hidden:
new providers couldn't enable FAST mode and existing ones couldn't turn
it off, even though the backend still honors meta.codexFastMode. Gate the
toggle on onFastModeChange presence instead, restoring the pre-refactor
behavior without affecting the manage panel or native Codex form (which
do not pass the handler).
2026-06-15 20:55:14 +08:00
Jason 7cbdae9eca Merge remote-tracking branch 'origin/main' into feature/managed-oauth-account-selector
# Conflicts:
#	src-tauri/src/services/provider/live.rs
#	src-tauri/src/services/proxy.rs
2026-06-15 15:04:01 +08:00
saladday 84801c7ed2 Clarify unmanaged Codex login option 2026-06-08 12:43:32 +08:00
saladday 39ecccdbdf Consolidate managed account entry points 2026-06-08 12:43:32 +08:00
saladday 7480cec0ba Add managed ChatGPT account binding for Codex 2026-06-08 04:39:53 +08:00
35 changed files with 5066 additions and 501 deletions
+484
View File
@@ -7,7 +7,9 @@ use crate::config::{
};
use crate::error::AppError;
use crate::model_capabilities::{image_input_capability_from_modalities, ImageInputCapability};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::fs;
use std::process::Command;
use toml_edit::DocumentMut;
@@ -101,6 +103,174 @@ fn codex_native_gateway_rejects_web_search(config_text: &str) -> bool {
false
}
const CODEX_MODEL_CATALOG_TEMPLATE_SLUG: &str = "gpt-5.5";
const CODEX_MANAGED_OAUTH_LIVE_AUTH_MARKER_FILENAME: &str = "codex_managed_oauth_live_auth.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CodexManagedOAuthLiveAuthMarker {
version: u32,
account_id: String,
access_token_sha256: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CodexLiveFileState {
path: PathBuf,
contents: Option<Vec<u8>>,
#[cfg(unix)]
mode: Option<u32>,
}
impl CodexLiveFileState {
fn capture(path: PathBuf) -> Result<Self, AppError> {
if !path.exists() {
return Ok(Self {
path,
contents: None,
#[cfg(unix)]
mode: None,
});
}
let contents = fs::read(&path).map_err(|error| AppError::io(&path, error))?;
#[cfg(unix)]
let mode = {
use std::os::unix::fs::PermissionsExt;
Some(
fs::metadata(&path)
.map_err(|error| AppError::io(&path, error))?
.permissions()
.mode(),
)
};
Ok(Self {
path,
contents: Some(contents),
#[cfg(unix)]
mode,
})
}
fn restore(&self) -> Result<(), AppError> {
match self.contents.as_deref() {
Some(contents) => {
atomic_write(&self.path, contents)?;
#[cfg(unix)]
if let Some(mode) = self.mode {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&self.path, fs::Permissions::from_mode(mode))
.map_err(|error| AppError::io(&self.path, error))?;
}
Ok(())
}
None => delete_file(&self.path),
}
}
}
/// Exact rollback state for a managed Codex live write. The generated catalog
/// and ownership marker are part of the same logical commit as auth/config.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CodexLiveStateSnapshot {
auth: CodexLiveFileState,
config: CodexLiveFileState,
catalog: CodexLiveFileState,
managed_marker: CodexLiveFileState,
}
impl CodexLiveStateSnapshot {
pub(crate) fn capture() -> Result<Self, AppError> {
Ok(Self {
auth: CodexLiveFileState::capture(get_codex_auth_path())?,
config: CodexLiveFileState::capture(get_codex_config_path())?,
catalog: CodexLiveFileState::capture(get_codex_model_catalog_path())?,
managed_marker: CodexLiveFileState::capture(
get_codex_managed_oauth_live_auth_marker_path(),
)?,
})
}
/// Roll back config/catalog exactly while retaining a demonstrably newer
/// ChatGPT auth generation for the same account. OAuth refresh can advance
/// auth.json after a provider transaction captures its snapshot; restoring
/// that snapshot blindly would invalidate the CLI's newly rotated token.
///
/// Cross-account writes are still rolled back exactly: an A -> B transaction
/// that fails must restore A even if B refreshed while it was briefly live.
/// The marker follows auth as one generation bundle.
pub(crate) fn restore_preserving_newer_same_account_auth(&self) -> Result<(), AppError> {
let mut failures = Vec::new();
let current_auth = match CodexLiveFileState::capture(get_codex_auth_path()) {
Ok(state) => Some(state),
Err(error) => {
// Inspection failure must not prevent config/catalog and the
// remaining rollback files from being attempted.
failures.push(format!("inspect current auth: {error}"));
None
}
};
let snapshot_generation = Self::chatgpt_auth_generation(&self.auth);
let current_generation = current_auth
.as_ref()
.and_then(Self::chatgpt_auth_generation);
let preserve_current_auth = match (snapshot_generation, current_generation) {
(Some((snapshot_account, snapshot_time)), Some((current_account, current_time)))
if snapshot_account == current_account =>
{
match (snapshot_time, current_time) {
(Some(snapshot_time), Some(current_time)) => current_time > snapshot_time,
(None, Some(_)) => true,
_ => false,
}
}
_ => false,
};
for (label, state) in [("catalog", &self.catalog), ("config", &self.config)] {
if let Err(error) = state.restore() {
failures.push(format!("{label}: {error}"));
}
}
if !preserve_current_auth {
for (label, state) in [
("auth", &self.auth),
("managed marker", &self.managed_marker),
] {
if let Err(error) = state.restore() {
failures.push(format!("{label}: {error}"));
}
}
}
if failures.is_empty() {
Ok(())
} else {
Err(AppError::Message(format!(
"恢复 Codex Live 状态失败: {}",
failures.join("; ")
)))
}
}
fn chatgpt_auth_generation(state: &CodexLiveFileState) -> Option<(String, Option<i64>)> {
let auth: Value = serde_json::from_slice(state.contents.as_deref()?).ok()?;
if auth.get("auth_mode").and_then(Value::as_str) != Some("chatgpt") {
return None;
}
let account_id = auth
.pointer("/tokens/account_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|account_id| !account_id.is_empty())?
.to_string();
let last_refresh_ms = auth
.get("last_refresh")
.and_then(Value::as_str)
.and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok())
.map(|value| value.timestamp_millis());
Some((account_id, last_refresh_ms))
}
}
/// Which Codex tool surface the generated model catalog should target.
///
@@ -168,6 +338,288 @@ pub fn get_codex_auth_path() -> PathBuf {
get_codex_config_dir().join("auth.json")
}
fn get_codex_managed_oauth_live_auth_marker_path() -> PathBuf {
crate::config::get_app_config_dir().join(CODEX_MANAGED_OAUTH_LIVE_AUTH_MARKER_FILENAME)
}
fn sha256_hex(text: &str) -> String {
let digest = Sha256::digest(text.as_bytes());
digest
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
}
/// 从 live/备份的 Codex `auth` 中提取 `(account_id, access_token)`,用于 marker 记录/比对。
///
/// 仅接受 ChatGPT 登录形状(`auth_mode == "chatgpt"`、`OPENAI_API_KEY` 可清空)。
/// 托管账号写入的完整 bundle 会额外带 `tokens.refresh_token` 与顶层 `last_refresh`
/// 这里一并容忍,从而能对完整 bundle 记录/比对指纹(marker 靠 account_id +
/// access_token 哈希识别「我们写的那一份」,与用户原生登录区分开)。
fn extract_codex_managed_oauth_auth(auth: &Value) -> Option<(String, String)> {
let auth_obj = auth.as_object()?;
if auth_obj.keys().any(|key| {
!matches!(
key.as_str(),
"auth_mode" | "OPENAI_API_KEY" | "tokens" | "last_refresh"
)
}) {
return None;
}
if auth.get("auth_mode").and_then(|value| value.as_str()) != Some("chatgpt") {
return None;
}
let api_key_is_clearable = auth
.get("OPENAI_API_KEY")
.is_none_or(|value| value.is_null() || value.as_str() == Some("PROXY_MANAGED"));
if !api_key_is_clearable {
return None;
}
let tokens = auth.get("tokens").and_then(|value| value.as_object())?;
if tokens.keys().any(|key| {
!matches!(
key.as_str(),
"access_token" | "account_id" | "id_token" | "refresh_token"
)
}) {
return None;
}
let account_id = tokens
.get("account_id")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|id| !id.is_empty())?;
let access_token = tokens
.get("access_token")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|token| !token.is_empty())?;
Some((account_id.to_string(), access_token.to_string()))
}
/// Build the native-shaped ChatGPT auth bundle shared by cc-switch and Codex CLI.
pub fn codex_managed_oauth_auth_value(
account_id: &str,
access_token: &str,
id_token: Option<&str>,
refresh_token: &str,
last_refresh: &str,
) -> Value {
let mut tokens = serde_json::Map::new();
if let Some(id_token) = id_token {
tokens.insert("id_token".to_string(), Value::String(id_token.to_string()));
}
tokens.insert(
"access_token".to_string(),
Value::String(access_token.to_string()),
);
tokens.insert(
"refresh_token".to_string(),
Value::String(refresh_token.to_string()),
);
tokens.insert(
"account_id".to_string(),
Value::String(account_id.to_string()),
);
json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": Value::Object(tokens),
"last_refresh": last_refresh,
})
}
pub fn record_codex_managed_oauth_live_auth(auth: &Value) -> Result<(), AppError> {
let Some((account_id, access_token)) = extract_codex_managed_oauth_auth(auth) else {
return Ok(());
};
let marker = CodexManagedOAuthLiveAuthMarker {
version: 1,
account_id,
access_token_sha256: sha256_hex(&access_token),
};
crate::config::write_json_file(&get_codex_managed_oauth_live_auth_marker_path(), &marker)
}
pub fn codex_auth_matches_recorded_managed_oauth(
auth: &Value,
account_id: &str,
) -> Result<bool, AppError> {
let account_id = account_id.trim();
if account_id.is_empty() {
return Ok(false);
}
let Some((auth_account_id, access_token)) = extract_codex_managed_oauth_auth(auth) else {
return Ok(false);
};
if auth_account_id != account_id {
return Ok(false);
}
let marker_path = get_codex_managed_oauth_live_auth_marker_path();
let marker: CodexManagedOAuthLiveAuthMarker = match read_json_file(&marker_path) {
Ok(marker) => marker,
Err(err) => {
log::warn!(
"Failed to read Codex managed OAuth auth marker at {}: {err}",
marker_path.display()
);
return Ok(false);
}
};
Ok(marker.version == 1
&& marker.account_id == account_id
&& marker.access_token_sha256 == sha256_hex(&access_token))
}
/// 切走托管 provider 时删除其残留在 `~/.codex/auth.json` 的**由本应用写入的**托管登录。
///
/// 仅当 live auth 与最近一次记录的 markeraccount_id + access_token 指纹)匹配时才删除,
/// 从而绝不误删用户自己 `codex login` 的原生登录。
pub fn clear_codex_live_auth_for_managed_account(account_id: &str) -> Result<(), AppError> {
let auth_path = get_codex_auth_path();
if auth_path.exists() {
let auth: Value = read_json_file(&auth_path)?;
if !codex_auth_matches_recorded_managed_oauth(&auth, account_id)? {
return Ok(());
}
delete_file(&auth_path)?;
let _ = delete_file(&get_codex_managed_oauth_live_auth_marker_path());
}
Ok(())
}
/// 判断给定的 Codex `auth`(来自 live auth.json 或 Live 备份)是否是「属于
/// `account_id` 的 ChatGPT 托管登录」。
///
/// 托管账号写入的是**完整可刷新 bundle**,与原生浏览器登录形状一致(都含
/// refresh_token),且 Codex CLI 会轮换 token 使旧的 access_token 指纹失效,因此
/// 无法再凭形状/哈希区分。这里采用**基于内容的 account_id 判定**:只要是 chatgpt
/// 模式、且 `tokens.account_id` 命中托管账号,即视为该账号的登录。对同一账号的原生
/// 登录会被同等处理(同账号,无损)。
///
/// 用于 Live 备份剥离:避免把托管账号的可刷新 token 持久化进备份配置。
pub fn codex_live_auth_is_managed_chatgpt_login(auth: &Value, account_id: &str) -> bool {
let account_id = account_id.trim();
if account_id.is_empty() {
return false;
}
let Some(obj) = auth.as_object() else {
return false;
};
if obj.get("auth_mode").and_then(|value| value.as_str()) != Some("chatgpt") {
return false;
}
let api_key_clearable = obj
.get("OPENAI_API_KEY")
.is_none_or(|value| value.is_null() || value.as_str() == Some("PROXY_MANAGED"));
if !api_key_clearable {
return false;
}
obj.get("tokens")
.and_then(|tokens| tokens.as_object())
.and_then(|tokens| tokens.get("account_id"))
.and_then(|value| value.as_str())
.map(str::trim)
== Some(account_id)
}
/// 读回 Codex CLI 当前 `~/.codex/auth.json` 中属于 `account_id` 的 refresh_token /
/// id_token(仅当磁盘上的登录账号与之一致时)。
///
/// 用于切换回托管 provider 前,采纳 CLI 自行刷新时轮换出的最新 refresh_token,避免
/// 用陈腐 token 覆盖 CLI 的有效登录(“裸跑 codex” 反复切换场景)。
pub fn read_codex_live_auth_refresh_for_account(
account_id: &str,
) -> Option<(String, Option<String>, Option<i64>)> {
let account_id = account_id.trim();
if account_id.is_empty() {
return None;
}
let auth_path = get_codex_auth_path();
if !auth_path.exists() {
return None;
}
let auth: Value = read_json_file(&auth_path).ok()?;
// 仅在磁盘上确是「该 account_id 的 ChatGPT 登录」时才采纳其 refresh_token
// 避免从非 chatgpt/异常 auth 里误取 token。
if !codex_live_auth_is_managed_chatgpt_login(&auth, account_id) {
return None;
}
let tokens = auth.get("tokens")?.as_object()?;
let refresh_token = tokens.get("refresh_token")?.as_str()?.trim().to_string();
if refresh_token.is_empty() {
return None;
}
let id_token = tokens
.get("id_token")
.and_then(|value| value.as_str())
.map(|token| token.to_string());
let last_refresh_ms = auth
.get("last_refresh")
.and_then(Value::as_str)
.and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok())
.map(|value| value.timestamp_millis());
Some((refresh_token, id_token, last_refresh_ms))
}
/// Keep Codex CLI's live auth in the same refresh-token generation after the
/// manager refreshes a managed account.
///
/// The write is compare-and-swap-like: it only proceeds while auth.json still
/// contains the exact refresh token used for the network request. If Codex CLI
/// refreshed concurrently, its newer file wins and is never overwritten.
/// Ownership is preserved separately: a file previously recorded as cc-switch
/// managed gets a new marker fingerprint, while a same-account native login is
/// updated without becoming eligible for deletion on unbind.
pub fn sync_codex_managed_oauth_live_auth_after_refresh(
account_id: &str,
expected_refresh_token: &str,
refreshed_auth: &Value,
) -> Result<bool, AppError> {
let account_id = account_id.trim();
let expected_refresh_token = expected_refresh_token.trim();
if account_id.is_empty() || expected_refresh_token.is_empty() {
return Ok(false);
}
let auth_path = get_codex_auth_path();
if !auth_path.exists() {
return Ok(false);
}
let current_auth: Value = read_json_file(&auth_path)?;
if !codex_live_auth_is_managed_chatgpt_login(&current_auth, account_id) {
return Ok(false);
}
let current_refresh_token = current_auth
.pointer("/tokens/refresh_token")
.and_then(Value::as_str)
.map(str::trim);
if current_refresh_token != Some(expected_refresh_token) {
return Ok(false);
}
let marker_path = get_codex_managed_oauth_live_auth_marker_path();
let was_recorded_managed = marker_path.exists()
&& codex_auth_matches_recorded_managed_oauth(&current_auth, account_id)?;
write_json_file(&auth_path, refreshed_auth)?;
if was_recorded_managed {
record_codex_managed_oauth_live_auth(refreshed_auth)?;
}
Ok(true)
}
/// 获取 Codex config.toml 路径
pub fn get_codex_config_path() -> PathBuf {
get_codex_config_dir().join("config.toml")
@@ -2221,6 +2673,38 @@ base_url = "https://single.example.com/v1"
);
}
#[test]
fn managed_chatgpt_login_matched_by_account_id_including_full_refresh_bundle() {
// ① 之后托管写入的是含 refresh_token 的完整 bundle;备份剥离必须凭 account_id
// 认出它,避免把可刷新 token 持久化进 Live 备份。
let full_bundle = json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": {
"id_token": "id",
"access_token": "access",
"refresh_token": "refresh-secret",
"account_id": "acct-managed"
},
"last_refresh": "2026-01-02T03:04:05.000000000Z"
});
assert!(
codex_live_auth_is_managed_chatgpt_login(&full_bundle, "acct-managed"),
"a full refreshable bundle for the managed account must be recognized"
);
assert!(
!codex_live_auth_is_managed_chatgpt_login(&full_bundle, "acct-other"),
"a login for a different account must not match"
);
// 非 chatgpt 模式(API key)不应命中。
let api_key_auth = json!({ "OPENAI_API_KEY": "sk-live" });
assert!(!codex_live_auth_is_managed_chatgpt_login(
&api_key_auth,
"acct-managed"
));
}
#[test]
fn prepare_provider_live_config_uses_top_level_token_for_reserved_provider() {
let input = r#"model_provider = "openai"
+10 -7
View File
@@ -19,6 +19,8 @@ pub struct ManagedAuthAccount {
pub authenticated_at: i64,
pub is_default: bool,
pub github_domain: String,
/// 托管账号是否需要重新登录以补全缺失的凭据(Codex 旧账号缺少 id_token
pub reauth_required: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
@@ -55,6 +57,7 @@ fn map_account(
) -> ManagedAuthAccount {
ManagedAuthAccount {
is_default: default_account_id == Some(account.id.as_str()),
reauth_required: account.reauth_required,
id: account.id,
provider: provider.to_string(),
login: account.login,
@@ -96,7 +99,7 @@ pub async fn auth_start_login(
Ok(map_device_code_response(auth_provider, response))
}
AUTH_PROVIDER_CODEX_OAUTH => {
let auth_manager = codex_state.0.read().await;
let auth_manager = &codex_state.0;
let response = auth_manager
.start_device_flow()
.await
@@ -134,7 +137,7 @@ pub async fn auth_poll_for_account(
}
}
AUTH_PROVIDER_CODEX_OAUTH => {
let auth_manager = codex_state.0.write().await;
let auth_manager = &codex_state.0;
match auth_manager.poll_for_token(&device_code).await {
Ok(account) => {
let default_account_id = auth_manager.get_status().await.default_account_id;
@@ -169,7 +172,7 @@ pub async fn auth_list_accounts(
.collect())
}
AUTH_PROVIDER_CODEX_OAUTH => {
let auth_manager = codex_state.0.read().await;
let auth_manager = &codex_state.0;
let status = auth_manager.get_status().await;
let default_account_id = status.default_account_id.clone();
Ok(status
@@ -209,7 +212,7 @@ pub async fn auth_get_status(
})
}
AUTH_PROVIDER_CODEX_OAUTH => {
let auth_manager = codex_state.0.read().await;
let auth_manager = &codex_state.0;
let status = auth_manager.get_status().await;
let default_account_id = status.default_account_id.clone();
Ok(ManagedAuthStatus {
@@ -247,7 +250,7 @@ pub async fn auth_remove_account(
.map_err(|e| e.to_string())
}
AUTH_PROVIDER_CODEX_OAUTH => {
let auth_manager = codex_state.0.write().await;
let auth_manager = &codex_state.0;
auth_manager
.remove_account(&account_id)
.await
@@ -274,7 +277,7 @@ pub async fn auth_set_default_account(
.map_err(|e| e.to_string())
}
AUTH_PROVIDER_CODEX_OAUTH => {
let auth_manager = codex_state.0.write().await;
let auth_manager = &codex_state.0;
auth_manager
.set_default_account(&account_id)
.await
@@ -297,7 +300,7 @@ pub async fn auth_logout(
auth_manager.clear_auth().await.map_err(|e| e.to_string())
}
AUTH_PROVIDER_CODEX_OAUTH => {
let auth_manager = codex_state.0.write().await;
let auth_manager = &codex_state.0;
auth_manager.clear_auth().await.map_err(|e| e.to_string())
}
_ => unreachable!(),
+7 -4
View File
@@ -10,10 +10,13 @@ use crate::services::model_fetch::FetchedModel;
use crate::services::subscription::{query_codex_quota, CredentialStatus, SubscriptionQuota};
use std::sync::Arc;
use tauri::State;
use tokio::sync::RwLock;
/// Codex OAuth 认证状态
pub struct CodexOAuthState(pub Arc<RwLock<CodexOAuthManager>>);
///
/// `CodexOAuthManager` 内部已使用细粒度锁且所有方法均为 `&self`,因此这里
/// 直接持有 `Arc`,不再包一层 `RwLock`——避免任一命令持有粗粒度锁跨网络刷新
/// 时阻塞其他命令(切换 / 认证中心操作 / token 读取)。
pub struct CodexOAuthState(pub Arc<CodexOAuthManager>);
/// 查询 Codex OAuth (ChatGPT Plus/Pro) 订阅额度
///
@@ -26,7 +29,7 @@ pub async fn get_codex_oauth_quota(
account_id: Option<String>,
state: State<'_, CodexOAuthState>,
) -> Result<SubscriptionQuota, String> {
let manager = state.0.read().await;
let manager = &state.0;
// 解析最终使用的账号 ID:显式 > 默认账号 > 无账号 (not_found)
let resolved = match account_id {
@@ -69,7 +72,7 @@ pub async fn get_codex_oauth_models(
account_id: Option<String>,
state: State<'_, CodexOAuthState>,
) -> Result<Vec<FetchedModel>, String> {
let manager = state.0.read().await;
let manager = &state.0;
let resolved = match account_id
.as_deref()
.map(str::trim)
+3 -5
View File
@@ -996,13 +996,11 @@ pub fn run() {
// 初始化 CodexOAuthManager (ChatGPT Plus/Pro 反代)
{
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
use commands::CodexOAuthState;
use tokio::sync::RwLock;
let app_config_dir = crate::config::get_app_config_dir();
let codex_oauth_manager = CodexOAuthManager::new(app_config_dir);
app.manage(CodexOAuthState(Arc::new(RwLock::new(codex_oauth_manager))));
let codex_oauth_manager =
app.state::<AppState>().codex_oauth_manager.clone();
app.manage(CodexOAuthState(codex_oauth_manager));
log::info!("✓ CodexOAuthManager initialized");
}
+1 -3
View File
@@ -23,7 +23,6 @@ use super::{
ProxyError,
};
use crate::commands::{CodexOAuthState, CopilotAuthState};
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
use crate::proxy::providers::copilot_auth::CopilotAuthManager;
use crate::{
app_config::AppType,
@@ -1620,8 +1619,7 @@ impl RequestForwarder {
if auth.strategy == AuthStrategy::CodexOAuth {
if let Some(app_handle) = &self.app_handle {
let codex_state = app_handle.state::<CodexOAuthState>();
let codex_auth: tokio::sync::RwLockReadGuard<'_, CodexOAuthManager> =
codex_state.0.read().await;
let codex_auth = &codex_state.0;
// 从 provider.meta 获取关联的 ChatGPT 账号 ID
let account_id = provider
File diff suppressed because it is too large Load Diff
@@ -340,6 +340,10 @@ pub struct GitHubAccount {
/// GitHub 域名(github.com 或 GHES 域名)
#[serde(default = "default_github_domain")]
pub github_domain: String,
/// 托管账号是否需要重新登录以补全缺失的凭据。
/// Codex:缺少持久化 id_token 的旧账号为 trueCopilot 恒为 false。
#[serde(default)]
pub reauth_required: bool,
}
impl From<&GitHubAccountData> for GitHubAccount {
@@ -350,6 +354,7 @@ impl From<&GitHubAccountData> for GitHubAccount {
avatar_url: data.user.avatar_url.clone(),
authenticated_at: data.authenticated_at,
github_domain: data.github_domain.clone(),
reauth_required: false,
}
}
}
@@ -543,6 +548,7 @@ impl CopilotAuthManager {
avatar_url: user.avatar_url.clone(),
authenticated_at: now,
github_domain,
reauth_required: false,
};
{
@@ -1546,6 +1552,7 @@ mod tests {
avatar_url: Some("https://example.com/avatar.png".to_string()),
authenticated_at: 1234567890,
github_domain: DEFAULT_GITHUB_DOMAIN.to_string(),
reauth_required: false,
}],
default_account_id: Some("12345".to_string()),
migration_error: None,
+442 -9
View File
@@ -3,6 +3,7 @@
//! Handles reading and writing live configuration files for Claude, Codex, and Gemini.
use std::collections::HashMap;
use std::sync::Arc;
use serde_json::{json, Value};
use toml_edit::{DocumentMut, Item, TableLike};
@@ -13,6 +14,7 @@ use crate::config::{delete_file, get_claude_settings_path, read_json_file, write
use crate::database::Database;
use crate::error::AppError;
use crate::provider::Provider;
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
use crate::services::mcp::McpService;
use crate::store::AppState;
@@ -695,14 +697,31 @@ pub(crate) fn build_effective_settings_with_common_config(
Ok(effective_settings)
}
pub(crate) fn write_live_with_common_config(
db: &Database,
pub(crate) fn write_live_with_common_config_for_state(
state: &AppState,
app_type: &AppType,
provider: &Provider,
) -> Result<(), AppError> {
let mut effective_provider = provider.clone();
effective_provider.settings_config =
build_effective_settings_with_common_config(db, app_type, provider)?;
write_live_with_common_config_for_codex_oauth_manager(
state.db.as_ref(),
app_type,
provider,
&state.codex_oauth_manager,
)
}
pub(crate) fn write_live_with_common_config_for_codex_oauth_manager(
db: &Database,
app_type: &AppType,
provider: &Provider,
codex_oauth_manager: &Arc<CodexOAuthManager>,
) -> Result<(), AppError> {
let effective_provider = build_effective_provider_for_live_with_codex_oauth_manager(
db,
app_type,
provider,
codex_oauth_manager,
)?;
if matches!(app_type, AppType::ClaudeDesktop) {
crate::claude_desktop_config::apply_provider(db, &effective_provider)?;
@@ -717,6 +736,132 @@ pub(crate) fn write_live_with_common_config(
write_live_snapshot(app_type, &effective_provider)
}
pub(crate) fn build_effective_provider_for_live_with_codex_oauth_manager(
db: &Database,
app_type: &AppType,
provider: &Provider,
codex_oauth_manager: &Arc<CodexOAuthManager>,
) -> Result<Provider, AppError> {
let mut effective_provider = provider.clone();
effective_provider.settings_config =
build_effective_settings_with_common_config(db, app_type, provider)?;
apply_codex_managed_oauth_auth(app_type, &mut effective_provider, Some(codex_oauth_manager))?;
Ok(effective_provider)
}
fn apply_codex_managed_oauth_auth(
app_type: &AppType,
provider: &mut Provider,
codex_oauth_manager: Option<&Arc<CodexOAuthManager>>,
) -> Result<(), AppError> {
if !matches!(app_type, AppType::Codex) || provider.category.as_deref() != Some("official") {
return Ok(());
}
let Some(account_id) = provider
.meta
.as_ref()
.and_then(|meta| meta.managed_account_id_for("codex_oauth"))
.map(|id| id.trim().to_string())
.filter(|id| !id.is_empty())
else {
return Ok(());
};
let Some(manager) = codex_oauth_manager else {
return Err(AppError::Message(
"Codex OAuth 托管账号不可用,请重启应用后重试".to_string(),
));
};
let auth = get_codex_managed_oauth_live_auth_value(manager.clone(), account_id.clone())?;
let Some(settings_obj) = provider.settings_config.as_object_mut() else {
return Err(AppError::Config(
"Codex 供应商配置必须是 JSON 对象".to_string(),
));
};
settings_obj.insert("auth".to_string(), auth);
Ok(())
}
/// 构建写入托管 Codex `auth.json` 的完整可刷新 auth(含 refresh_token + last_refresh)。
///
/// 步骤:
/// 1. **读回**:若 Codex CLI 已自行刷新并轮换 refresh_token,先采纳盘上最新值,避免
/// 用陈腐 refresh_token 覆盖 CLI 的有效登录(反复切换场景)。
/// 2. 取有效 token 束(必要时刷新 access_token)。
/// 3. 按原生浏览器登录形状生成完整 auth。
///
/// 不再持有外层锁:manager 内部按账号加锁刷新,网络阻塞不会波及其他账号操作或
/// token 读取。
fn get_codex_managed_oauth_live_auth_value(
manager: Arc<CodexOAuthManager>,
account_id: String,
) -> Result<Value, AppError> {
std::thread::spawn(move || {
tauri::async_runtime::block_on(async move {
if let Some((refresh_token, id_token, last_refresh_ms)) =
crate::codex_config::read_codex_live_auth_refresh_for_account(&account_id)
{
if let Err(err) = manager
.adopt_account_refresh_token(
&account_id,
refresh_token,
id_token,
last_refresh_ms,
)
.await
{
log::warn!(
"读回 Codex CLI 轮换后的 refresh_token 失败(account={account_id}: {err}"
);
}
}
let bundle = manager
.get_valid_token_bundle_for_account(&account_id)
.await
.map_err(|err| {
format!(
"Codex OAuth 账号 {account_id} 认证失败,请重新登录 ChatGPT 账号: {err}"
)
})?;
Ok::<Value, String>(codex_managed_oauth_live_auth(
&account_id,
&bundle.access_token,
bundle.id_token.as_deref(),
&bundle.refresh_token,
&bundle.last_refresh,
))
})
})
.join()
.map_err(|_| AppError::Message("Codex OAuth token 获取线程异常退出".to_string()))?
.map_err(AppError::Message)
}
pub(crate) fn codex_managed_oauth_live_auth(
account_id: &str,
access_token: &str,
id_token: Option<&str>,
refresh_token: &str,
last_refresh: &str,
) -> Value {
// 与原生 Codex 浏览器登录的形状对齐:tokens 字段顺序 id_token、access_token、
// refresh_token、account_id,并带顶层 last_refresh。**必须**包含 refresh_token
// 否则 Codex CLI 在 access_token 过期后无法自刷新(“裸跑 codex” 会静默失效)。
crate::codex_config::codex_managed_oauth_auth_value(
account_id,
access_token,
id_token,
refresh_token,
last_refresh,
)
}
pub(crate) fn strip_common_config_from_live_settings(
db: &Database,
app_type: &AppType,
@@ -864,6 +1009,8 @@ fn restore_live_settings_for_provider_backfill(
);
}
strip_codex_managed_oauth_auth_for_backfill(provider, &mut settings);
// MCP 服务器归 DB mcp_servers 表所有,live 里的 [mcp_servers] 是同步投影;
// 回填时剥掉,否则已删除的服务器会随供应商快照复活(逐条 reconcile 清不掉孤儿)。
if let Err(err) = crate::codex_config::strip_codex_mcp_servers_from_settings(&mut settings) {
@@ -902,6 +1049,40 @@ fn restore_live_settings_for_provider_backfill(
settings
}
/// 回填(backfill)托管 Codex 官方 provider 时,剥离 live 的 `auth`。
///
/// 托管 provider 的存储配置**永远不应**持久化真实 OAuth tokentoken 由
/// `CodexOAuthManager` 按账号集中保管,provider 配置只保留绑定与占位 auth。
///
/// 因此无论 live 里当前是什么(我们写入的托管 auth、被 CLI 轮换过的 token、
/// 还是用户自己浏览器登录的原生 auth,后者含真实 access/refresh_token),
/// 都统一替换为 provider 存储的占位 auth,避免把真实凭据回填进 DB 配置。
fn strip_codex_managed_oauth_auth_for_backfill(provider: &Provider, settings: &mut Value) {
let is_managed = provider
.meta
.as_ref()
.and_then(|meta| meta.managed_account_id_for("codex_oauth"))
.is_some();
if !is_managed {
return;
}
// live 里没有 auth 就无需处理。
if settings.get("auth").is_none() {
return;
}
let stored_auth = provider
.settings_config
.get("auth")
.filter(|auth| auth.is_object())
.cloned()
.unwrap_or_else(|| json!({}));
if let Some(obj) = settings.as_object_mut() {
obj.insert("auth".to_string(), stored_auth);
}
}
pub(crate) fn normalize_provider_common_config_for_storage(
db: &Database,
app_type: &AppType,
@@ -1050,6 +1231,14 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
config_str,
profile,
)?;
if provider
.meta
.as_ref()
.and_then(|meta| meta.managed_account_id_for("codex_oauth"))
.is_some()
{
crate::codex_config::record_codex_managed_oauth_live_auth(auth)?;
}
}
AppType::Gemini => {
// Delegate to write_gemini_live which handles env file writing correctly
@@ -1184,7 +1373,7 @@ fn sync_all_providers_to_live(state: &AppState, app_type: &AppType) -> Result<()
continue;
}
if let Err(e) = write_live_with_common_config(state.db.as_ref(), app_type, provider) {
if let Err(e) = write_live_with_common_config_for_state(state, app_type, provider) {
log::warn!(
"Failed to sync {:?} provider '{}' to live: {e}",
app_type,
@@ -1214,7 +1403,7 @@ pub(crate) fn sync_current_provider_for_app_to_live(
let providers = state.db.get_all_providers(app_type.as_str())?;
if let Some(provider) = providers.get(&current_id) {
write_live_with_common_config(state.db.as_ref(), app_type, provider)?;
write_live_with_common_config_for_state(state, app_type, provider)?;
}
}
@@ -1253,7 +1442,7 @@ fn sync_current_provider_for_app_respecting_takeover(
// that normal provider sync must not rewrite the managed live file.
if has_live_backup || live_taken_over {
if matches!(app_type, AppType::ClaudeDesktop) {
write_live_with_common_config(state.db.as_ref(), app_type, provider)?;
write_live_with_common_config_for_state(state, app_type, provider)?;
} else {
futures::executor::block_on(
state
@@ -1265,7 +1454,7 @@ fn sync_current_provider_for_app_respecting_takeover(
return Ok(());
}
write_live_with_common_config(state.db.as_ref(), app_type, provider)
write_live_with_common_config_for_state(state, app_type, provider)
}
/// Sync current provider to live configuration
@@ -1952,6 +2141,7 @@ pub fn remove_openclaw_provider_from_live(provider_id: &str) -> Result<(), AppEr
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::{AuthBinding, AuthBindingSource, ProviderMeta};
use serde_json::json;
#[test]
@@ -2375,6 +2565,249 @@ base_url = "https://a.example/v1"
assert_eq!(stripped, settings);
}
#[test]
fn codex_managed_oauth_live_auth_matches_codex_cli_shape() {
assert_eq!(
codex_managed_oauth_live_auth(
"acct-managed",
"access-token",
Some("id-token"),
"refresh-token",
"2026-01-02T03:04:05.000000000Z",
),
json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": {
"id_token": "id-token",
"access_token": "access-token",
"refresh_token": "refresh-token",
"account_id": "acct-managed"
},
"last_refresh": "2026-01-02T03:04:05.000000000Z"
}),
"managed live auth must carry refresh_token + last_refresh so the Codex CLI can self-refresh"
);
}
#[test]
fn codex_managed_oauth_live_auth_without_id_token_omits_it() {
assert_eq!(
codex_managed_oauth_live_auth(
"acct-managed",
"access-token",
None,
"refresh-token",
"2026-01-02T03:04:05.000000000Z",
),
json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": {
"access_token": "access-token",
"refresh_token": "refresh-token",
"account_id": "acct-managed"
},
"last_refresh": "2026-01-02T03:04:05.000000000Z"
}),
"without a stored id_token the field is omitted rather than written as null"
);
}
#[test]
fn codex_official_managed_oauth_binding_replaces_auth_with_selected_account_token() {
let temp = tempfile::tempdir().expect("tempdir");
let manager = Arc::new(CodexOAuthManager::new(temp.path().to_path_buf()));
tauri::async_runtime::block_on(async {
manager
.add_test_account_with_access_token(
"acct-managed",
"managed-token",
Some("managed-id-token"),
)
.await
.expect("seed managed account");
});
let mut provider = Provider::with_id(
"openai-official".to_string(),
"OpenAI Official".to_string(),
json!({
"auth": {
"OPENAI_API_KEY": "stale-key"
},
"config": ""
}),
None,
);
provider.category = Some("official".to_string());
provider.meta = Some(ProviderMeta {
auth_binding: Some(AuthBinding {
source: AuthBindingSource::ManagedAccount,
auth_provider: Some("codex_oauth".to_string()),
account_id: Some("acct-managed".to_string()),
}),
..Default::default()
});
apply_codex_managed_oauth_auth(&AppType::Codex, &mut provider, Some(&manager))
.expect("apply managed OAuth auth");
// last_refresh 是写入时刻的时间戳(非确定),因此逐字段断言而非整体等值。
let auth = provider.settings_config.get("auth").expect("auth written");
assert_eq!(
auth.get("auth_mode").and_then(|v| v.as_str()),
Some("chatgpt")
);
assert!(auth.get("OPENAI_API_KEY").is_some_and(|v| v.is_null()));
let tokens = auth
.get("tokens")
.and_then(|v| v.as_object())
.expect("tokens object");
assert_eq!(
tokens.get("account_id").and_then(|v| v.as_str()),
Some("acct-managed")
);
assert_eq!(
tokens.get("access_token").and_then(|v| v.as_str()),
Some("managed-token")
);
assert_eq!(
tokens.get("id_token").and_then(|v| v.as_str()),
Some("managed-id-token")
);
assert_eq!(
tokens.get("refresh_token").and_then(|v| v.as_str()),
Some("test-refresh-token"),
"managed live auth must carry the account's refresh_token so the Codex CLI can self-refresh"
);
assert!(
auth.get("last_refresh")
.and_then(|v| v.as_str())
.is_some_and(|s| s.ends_with('Z')),
"managed live auth must include an RFC3339 last_refresh timestamp"
);
}
#[test]
fn codex_official_without_binding_does_not_fall_back_to_managed_default_account() {
let temp = tempfile::tempdir().expect("tempdir");
let manager = Arc::new(CodexOAuthManager::new(temp.path().to_path_buf()));
tauri::async_runtime::block_on(async {
manager
.add_test_account_with_access_token("acct-managed", "managed-token", None)
.await
.expect("seed managed account");
});
let original_auth = json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": {
"access_token": "native-codex-token",
"account_id": "acct-native"
}
});
let mut provider = Provider::with_id(
"openai-official".to_string(),
"OpenAI Official".to_string(),
json!({
"auth": original_auth.clone(),
"config": ""
}),
None,
);
provider.category = Some("official".to_string());
apply_codex_managed_oauth_auth(&AppType::Codex, &mut provider, Some(&manager))
.expect("no binding should be a no-op");
assert_eq!(
provider.settings_config.get("auth"),
Some(&original_auth),
"unbound official providers must keep native Codex auth instead of using the managed default account"
);
}
#[test]
fn backfill_never_persists_native_tokens_into_managed_provider_config() {
// 托管 provider 的存储配置以占位 auth 表示;但 live 里此刻是用户自己
// 浏览器登录的原生 auth(含真实 refresh_token)。backfill 必须把 live
// auth 换回存储占位,绝不把原生 access/refresh_token 回填进 DB 配置。
let mut provider = Provider::with_id(
"openai-official".to_string(),
"OpenAI Official".to_string(),
json!({
"auth": {},
"config": ""
}),
None,
);
provider.category = Some("official".to_string());
provider.meta = Some(ProviderMeta {
auth_binding: Some(AuthBinding {
source: AuthBindingSource::ManagedAccount,
auth_provider: Some("codex_oauth".to_string()),
account_id: Some("acct-managed".to_string()),
}),
..Default::default()
});
let mut live_settings = json!({
"auth": {
"OPENAI_API_KEY": null,
"tokens": {
"id_token": "native-id",
"access_token": "native-access-secret",
"refresh_token": "native-refresh-secret",
"account_id": "acct-native"
},
"last_refresh": "2026-01-01T00:00:00Z"
},
"config": ""
});
strip_codex_managed_oauth_auth_for_backfill(&provider, &mut live_settings);
assert_eq!(
live_settings.get("auth"),
Some(&json!({})),
"managed provider backfill must reset live auth to the stored placeholder"
);
let serialized = live_settings.to_string();
assert!(
!serialized.contains("native-refresh-secret"),
"native refresh_token must not leak into a managed provider's backfilled config"
);
assert!(
!serialized.contains("native-access-secret"),
"native access_token must not leak into a managed provider's backfilled config"
);
}
#[test]
fn backfill_leaves_non_managed_provider_auth_untouched() {
// 非托管 provider 不受此剥离影响:其 auth 就该原样保留。
let mut provider = Provider::with_id(
"custom".to_string(),
"Custom".to_string(),
json!({ "auth": { "OPENAI_API_KEY": "user-key" }, "config": "" }),
None,
);
provider.category = Some("custom".to_string());
let mut live_settings = json!({
"auth": { "OPENAI_API_KEY": "user-key" },
"config": ""
});
let before = live_settings.clone();
strip_codex_managed_oauth_auth_for_backfill(&provider, &mut live_settings);
assert_eq!(
live_settings, before,
"non-managed provider auth must be left untouched by managed-oauth backfill stripping"
);
}
#[test]
fn explicit_common_config_flag_overrides_legacy_subset_detection() {
let mut provider = Provider::with_id(
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -1,4 +1,5 @@
use crate::database::Database;
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
use crate::services::{ProxyService, UsageCache};
use std::sync::Arc;
@@ -7,17 +8,24 @@ pub struct AppState {
pub db: Arc<Database>,
pub proxy_service: ProxyService,
pub usage_cache: Arc<UsageCache>,
// 内部已使用细粒度锁(accounts/access_tokens/refresh_locks),所有方法均为
// `&self`,无需外层 RwLock;避免持有粗粒度锁跨网络刷新导致的连锁阻塞。
pub codex_oauth_manager: Arc<CodexOAuthManager>,
}
impl AppState {
/// 创建新的应用状态
pub fn new(db: Arc<Database>) -> Self {
let proxy_service = ProxyService::new(db.clone());
let codex_oauth_manager =
Arc::new(CodexOAuthManager::new(crate::config::get_app_config_dir()));
let proxy_service =
ProxyService::new_with_codex_oauth_manager(db.clone(), codex_oauth_manager.clone());
Self {
db,
proxy_service,
usage_cache: Arc::new(UsageCache::new()),
codex_oauth_manager,
}
}
}
+23 -6
View File
@@ -28,6 +28,25 @@ interface FullScreenPanelProps {
const DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px - match App.tsx
const HEADER_HEIGHT = 64; // px - match App.tsx
let bodyScrollLockCount = 0;
let bodyOverflowBeforeFirstLock: string | null = null;
const lockBodyScroll = () => {
if (bodyScrollLockCount === 0) {
bodyOverflowBeforeFirstLock = document.body.style.overflow;
document.body.style.overflow = "hidden";
}
bodyScrollLockCount += 1;
};
const unlockBodyScroll = () => {
bodyScrollLockCount = Math.max(0, bodyScrollLockCount - 1);
if (bodyScrollLockCount === 0) {
document.body.style.overflow = bodyOverflowBeforeFirstLock ?? "";
bodyOverflowBeforeFirstLock = null;
}
};
/**
* Reusable full-screen panel component
* Handles portal rendering, header with back button, and footer
@@ -42,12 +61,10 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
contentClassName,
}) => {
React.useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
}
return () => {
document.body.style.overflow = "";
};
if (!isOpen) return;
lockBodyScroll();
return unlockBodyScroll;
}, [isOpen]);
// ESC 键关闭面板
+31 -7
View File
@@ -12,6 +12,7 @@ import {
ProviderForm,
type ProviderFormValues,
} from "@/components/providers/forms/ProviderForm";
import { AuthSettingsPanel } from "@/components/providers/AuthSettingsPanel";
import { UniversalProviderFormModal } from "@/components/universal/UniversalProviderFormModal";
import { UniversalProviderPanel } from "@/components/universal";
import { providerPresets } from "@/config/claudeProviderPresets";
@@ -22,6 +23,7 @@ import { extractCodexBaseUrl } from "@/utils/providerConfigUtils";
import { extractGrokBuildBaseUrl } from "@/utils/grokBuildConfig";
import type { OpenClawSuggestedDefaults } from "@/config/openclawProviderPresets";
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
import type { ManagedAuthProvider } from "@/lib/api";
interface AddProviderDialogProps {
open: boolean;
@@ -58,6 +60,21 @@ export function AddProviderDialog({
const [selectedUniversalPreset, setSelectedUniversalPreset] =
useState<UniversalProviderPreset | null>(null);
const [isFormSubmitting, setIsFormSubmitting] = useState(false);
const [authSettingsTarget, setAuthSettingsTarget] =
useState<ManagedAuthProvider | null>(null);
const closeDialog = useCallback(() => {
setAuthSettingsTarget(null);
onOpenChange(false);
}, [onOpenChange]);
const handlePanelClose = useCallback(() => {
if (authSettingsTarget) {
setAuthSettingsTarget(null);
return;
}
closeDialog();
}, [authSettingsTarget, closeDialog]);
const handleUniversalProviderSave = useCallback(
async (provider: UniversalProvider) => {
@@ -305,9 +322,9 @@ export function AddProviderDialog({
}
await onSubmit(providerData);
onOpenChange(false);
closeDialog();
},
[appId, onSubmit, onOpenChange],
[appId, onSubmit, closeDialog],
);
const footer =
@@ -318,7 +335,7 @@ export function AddProviderDialog({
</span>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
onClick={closeDialog}
className="border-border/20 hover:bg-accent hover:text-accent-foreground"
>
{t("common.cancel")}
@@ -337,7 +354,7 @@ export function AddProviderDialog({
<>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
onClick={closeDialog}
className="border-border/20 hover:bg-accent hover:text-accent-foreground"
>
{t("common.cancel")}
@@ -356,7 +373,7 @@ export function AddProviderDialog({
<FullScreenPanel
isOpen={open}
title={t("provider.addNewProvider")}
onClose={() => onOpenChange(false)}
onClose={handlePanelClose}
footer={footer}
contentClassName="pt-3"
>
@@ -379,7 +396,8 @@ export function AddProviderDialog({
appId={appId}
submitLabel={t("common.add")}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
onCancel={closeDialog}
onManageAuthAccounts={setAuthSettingsTarget}
onSubmittingChange={setIsFormSubmitting}
showButtons={false}
/>
@@ -395,7 +413,8 @@ export function AddProviderDialog({
appId={appId}
submitLabel={t("common.add")}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
onCancel={closeDialog}
onManageAuthAccounts={setAuthSettingsTarget}
onSubmittingChange={setIsFormSubmitting}
showButtons={false}
/>
@@ -409,6 +428,11 @@ export function AddProviderDialog({
initialPreset={selectedUniversalPreset}
/>
)}
<AuthSettingsPanel
target={authSettingsTarget}
onClose={() => setAuthSettingsTarget(null)}
/>
</FullScreenPanel>
);
}
@@ -0,0 +1,24 @@
import { useTranslation } from "react-i18next";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { AuthCenterPanel } from "@/components/settings/AuthCenterPanel";
import type { ManagedAuthProvider } from "@/lib/api";
interface AuthSettingsPanelProps {
target: ManagedAuthProvider | null;
onClose: () => void;
}
export function AuthSettingsPanel({ target, onClose }: AuthSettingsPanelProps) {
const { t } = useTranslation();
const isOpen = target !== null;
return (
<FullScreenPanel
isOpen={isOpen}
title={t("settings.tabAuth", { defaultValue: "认证" })}
onClose={onClose}
>
{target ? <AuthCenterPanel authScrollTarget={target} /> : null}
</FullScreenPanel>
);
}
@@ -8,7 +8,14 @@ import {
ProviderForm,
type ProviderFormValues,
} from "@/components/providers/forms/ProviderForm";
import { openclawApi, providersApi, vscodeApi, type AppId } from "@/lib/api";
import { AuthSettingsPanel } from "@/components/providers/AuthSettingsPanel";
import {
openclawApi,
providersApi,
vscodeApi,
type AppId,
type ManagedAuthProvider,
} from "@/lib/api";
interface EditProviderDialogProps {
open: boolean;
@@ -32,6 +39,8 @@ export function EditProviderDialog({
}: EditProviderDialogProps) {
const { t } = useTranslation();
const [isFormSubmitting, setIsFormSubmitting] = useState(false);
const [authSettingsTarget, setAuthSettingsTarget] =
useState<ManagedAuthProvider | null>(null);
// 默认使用传入的 provider.settingsConfig,若当前编辑对象是"当前生效供应商",则尝试读取实时配置替换初始值
const [liveSettings, setLiveSettings] = useState<Record<
@@ -42,6 +51,19 @@ export function EditProviderDialog({
// 使用 ref 标记是否已经加载过,防止重复读取覆盖用户编辑
const [hasLoadedLive, setHasLoadedLive] = useState(false);
const closeDialog = useCallback(() => {
setAuthSettingsTarget(null);
onOpenChange(false);
}, [onOpenChange]);
const handlePanelClose = useCallback(() => {
if (authSettingsTarget) {
setAuthSettingsTarget(null);
return;
}
closeDialog();
}, [authSettingsTarget, closeDialog]);
useEffect(() => {
let cancelled = false;
const load = async () => {
@@ -212,9 +234,9 @@ export function EditProviderDialog({
provider: updatedProvider,
originalId: provider.id,
});
onOpenChange(false);
closeDialog();
},
[appId, onSubmit, onOpenChange, provider],
[appId, onSubmit, closeDialog, provider],
);
if (!provider || !initialData) {
@@ -225,7 +247,7 @@ export function EditProviderDialog({
<FullScreenPanel
isOpen={open}
title={t("provider.editProvider")}
onClose={() => onOpenChange(false)}
onClose={handlePanelClose}
footer={
<Button
type="submit"
@@ -243,12 +265,17 @@ export function EditProviderDialog({
providerId={provider.id}
submitLabel={t("common.save")}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
onCancel={closeDialog}
onManageAuthAccounts={setAuthSettingsTarget}
onSubmittingChange={setIsFormSubmitting}
initialData={initialData}
showButtons={false}
isProxyTakeover={isProxyTakeover}
/>
<AuthSettingsPanel
target={authSettingsTarget}
onClose={() => setAuthSettingsTarget(null)}
/>
</FullScreenPanel>
);
}
@@ -68,6 +68,7 @@ import {
type ClaudeDesktopDefaultRoute,
} from "@/lib/api/providers";
import { resolveManagedAccountId } from "@/lib/authBinding";
import type { ManagedAuthProvider } from "@/lib/api";
export type ClaudeDesktopProviderFormValues = ProviderFormData & {
presetId?: string;
@@ -102,6 +103,7 @@ export interface ClaudeDesktopProviderFormProps {
iconColor?: string;
};
showButtons?: boolean;
onManageAuthAccounts?: (target: ManagedAuthProvider) => void;
}
type RouteRow = {
@@ -254,6 +256,7 @@ export function ClaudeDesktopProviderForm({
onSubmittingChange,
initialData,
showButtons = true,
onManageAuthAccounts,
}: ClaudeDesktopProviderFormProps) {
const { t } = useTranslation();
const initialMode = initialData?.meta?.claudeDesktopMode ?? "direct";
@@ -770,13 +773,25 @@ export function ClaudeDesktopProviderForm({
<div className="rounded-lg border border-border-default bg-muted/20 p-3">
{activeProviderType === "github_copilot" ? (
<CopilotAuthSection
mode="select"
selectedAccountId={selectedGitHubAccountId}
onAccountSelect={setSelectedGitHubAccountId}
onManageAccounts={
onManageAuthAccounts
? () => onManageAuthAccounts("github_copilot")
: undefined
}
/>
) : (
<CodexOAuthSection
mode="select"
selectedAccountId={selectedCodexAccountId}
onAccountSelect={setSelectedCodexAccountId}
onManageAccounts={
onManageAuthAccounts
? () => onManageAuthAccounts("codex_oauth")
: undefined
}
fastModeEnabled={codexFastMode}
onFastModeChange={setCodexFastMode}
/>
@@ -54,6 +54,7 @@ import type {
ClaudeApiFormat,
ClaudeApiKeyField,
} from "@/types";
import type { ManagedAuthProvider } from "@/lib/api";
import {
hasClaudeOneMMarker,
setClaudeOneMMarker,
@@ -89,6 +90,8 @@ interface ClaudeFormFieldsProps {
selectedGitHubAccountId?: string | null;
/** GitHub 账号选择回调(多账号支持) */
onGitHubAccountSelect?: (accountId: string | null) => void;
/** 打开托管账号管理入口 */
onManageAuthAccounts?: (target: ManagedAuthProvider) => void;
// Codex OAuth (ChatGPT Plus/Pro)
isCodexOauthPreset?: boolean;
@@ -168,6 +171,7 @@ export function ClaudeFormFields({
isCopilotAuthenticated,
selectedGitHubAccountId,
onGitHubAccountSelect,
onManageAuthAccounts,
isCodexOauthPreset,
isCodexOauthAuthenticated,
selectedCodexAccountId,
@@ -604,16 +608,28 @@ export function ClaudeFormFields({
{/* GitHub Copilot OAuth 认证 */}
{isCopilotPreset && (
<CopilotAuthSection
mode="select"
selectedAccountId={selectedGitHubAccountId}
onAccountSelect={onGitHubAccountSelect}
onManageAccounts={
onManageAuthAccounts
? () => onManageAuthAccounts("github_copilot")
: undefined
}
/>
)}
{/* Codex OAuth 认证 (ChatGPT Plus/Pro) */}
{isCodexOauthPreset && (
<CodexOAuthSection
mode="select"
selectedAccountId={selectedCodexAccountId}
onAccountSelect={onCodexAccountSelect}
onManageAccounts={
onManageAuthAccounts
? () => onManageAuthAccounts("codex_oauth")
: undefined
}
fastModeEnabled={codexFastMode}
onFastModeChange={onCodexFastModeChange}
/>
@@ -26,6 +26,7 @@ import {
Trash2,
} from "lucide-react";
import EndpointSpeedTest from "./EndpointSpeedTest";
import { CodexOAuthSection } from "./CodexOAuthSection";
import { ApiKeySection, EndpointField, ModelDropdown } from "./shared";
import {
fetchModelsForConfig,
@@ -43,6 +44,7 @@ import type {
PromptCacheRoutingMode,
ProviderCategory,
} from "@/types";
import type { ManagedAuthProvider } from "@/lib/api";
import type { AppId } from "@/lib/api";
interface EndpointCandidate {
@@ -60,6 +62,11 @@ interface CodexFormFieldsProps {
websiteUrl: string;
isPartner?: boolean;
partnerPromotionKey?: string;
isCodexOauthPreset?: boolean;
selectedCodexAccountId?: string | null;
onCodexAccountSelect?: (accountId: string | null) => void;
onManageAuthAccounts?: (target: ManagedAuthProvider) => void;
codexOauthNoneOptionLabel?: string;
// Base URL
shouldShowSpeedTest: boolean;
@@ -166,6 +173,11 @@ export function CodexFormFields({
websiteUrl,
isPartner,
partnerPromotionKey,
isCodexOauthPreset = false,
selectedCodexAccountId,
onCodexAccountSelect,
onManageAuthAccounts,
codexOauthNoneOptionLabel,
shouldShowSpeedTest,
codexBaseUrl,
onBaseUrlChange,
@@ -433,26 +445,43 @@ export function CodexFormFields({
return (
<>
{/* Codex OAuth 账号选择 */}
{isCodexOauthPreset && (
<CodexOAuthSection
mode="select"
selectedAccountId={selectedCodexAccountId}
onAccountSelect={onCodexAccountSelect}
onManageAccounts={
onManageAuthAccounts
? () => onManageAuthAccounts("codex_oauth")
: undefined
}
noneOptionLabel={codexOauthNoneOptionLabel}
/>
)}
{/* Codex API Key 输入框 */}
<ApiKeySection
id="codexApiKey"
label="API Key"
value={codexApiKey}
onChange={onApiKeyChange}
category={category}
shouldShowLink={shouldShowApiKeyLink}
websiteUrl={websiteUrl}
isPartner={isPartner}
partnerPromotionKey={partnerPromotionKey}
placeholder={{
official: t("providerForm.codexOfficialNoApiKey", {
defaultValue: "官方供应商无需 API Key",
}),
thirdParty: t("providerForm.codexApiKeyAutoFill", {
defaultValue: "输入 API Key,将自动填充到配置",
}),
}}
/>
{!isCodexOauthPreset && (
<ApiKeySection
id="codexApiKey"
label="API Key"
value={codexApiKey}
onChange={onApiKeyChange}
category={category}
shouldShowLink={shouldShowApiKeyLink}
websiteUrl={websiteUrl}
isPartner={isPartner}
partnerPromotionKey={partnerPromotionKey}
placeholder={{
official: t("providerForm.codexOfficialNoApiKey", {
defaultValue: "官方供应商无需 API Key",
}),
thirdParty: t("providerForm.codexApiKeyAutoFill", {
defaultValue: "输入 API Key,将自动填充到配置",
}),
}}
/>
)}
{/* Codex Base URL 输入框 */}
{shouldShowSpeedTest && (
@@ -21,16 +21,25 @@ import {
X,
Sparkles,
User,
Settings2,
AlertTriangle,
RefreshCw,
} from "lucide-react";
import { useCodexOauth } from "./hooks/useCodexOauth";
import { copyText } from "@/lib/clipboard";
interface CodexOAuthSectionProps {
className?: string;
/** select 模式只展示账号选择和管理入口;manage 模式展示完整账号管理 */
mode?: "manage" | "select";
/** 当前选中的 ChatGPT 账号 ID */
selectedAccountId?: string | null;
/** 账号选择回调 */
onAccountSelect?: (accountId: string | null) => void;
/** 打开账号管理入口 */
onManageAccounts?: () => void;
/** 空选择项文案;默认表示使用托管认证的默认账号 */
noneOptionLabel?: string;
/** 是否开启 Codex FAST mode */
fastModeEnabled?: boolean;
/** FAST mode 切换回调 */
@@ -45,8 +54,11 @@ interface CodexOAuthSectionProps {
*/
export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
className,
mode = "manage",
selectedAccountId,
onAccountSelect,
onManageAccounts,
noneOptionLabel,
fastModeEnabled = false,
onFastModeChange,
}) => {
@@ -56,6 +68,8 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
const {
accounts,
defaultAccountId,
isStatusSuccess,
isStatusError,
hasAnyAccount,
pollingState,
deviceCode,
@@ -69,6 +83,7 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
setDefaultAccount,
cancelAuth,
logout,
refetchStatus,
} = useCodexOauth();
const copyUserCode = async () => {
@@ -83,6 +98,25 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
onAccountSelect?.(value === "none" ? null : value);
};
React.useEffect(() => {
// Only clear a bound account when the status query has *successfully*
// loaded and the account is genuinely gone. On a failed/pending query
// `accounts` is an empty array, which must not silently unbind the
// provider's managed account (that would corrupt the saved config).
if (
mode !== "select" ||
!selectedAccountId ||
!onAccountSelect ||
!isStatusSuccess
) {
return;
}
if (!accounts.some((account) => account.id === selectedAccountId)) {
onAccountSelect(null);
}
}, [accounts, isStatusSuccess, mode, onAccountSelect, selectedAccountId]);
const handleRemoveAccount = (accountId: string, e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
@@ -92,58 +126,184 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
}
};
// 升级前登录的旧账号没有持久化 id_token,需重新登录补全
const hasReauthAccounts = accounts.some((account) => account.reauth_required);
const selectedAccountNeedsReauth =
!!selectedAccountId &&
accounts.some(
(account) => account.id === selectedAccountId && account.reauth_required,
);
const accountSelect = isStatusSuccess &&
onAccountSelect &&
(mode === "select" || hasAnyAccount || noneOptionLabel) && (
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{mode === "select"
? t("codexOauth.chatgptAccount", "ChatGPT 账号")
: t("codexOauth.selectAccount", "选择账号")}
</Label>
<Select
value={selectedAccountId || "none"}
onValueChange={handleAccountSelect}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"codexOauth.selectAccountPlaceholder",
"选择一个 ChatGPT 账号",
)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
<span className="text-muted-foreground">
{noneOptionLabel ??
t("codexOauth.useDefaultAccount", "使用默认账号")}
</span>
</SelectItem>
{accounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
<span>{account.login}</span>
{account.reauth_required && (
<span className="ml-1 inline-flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
<AlertTriangle className="h-3 w-3" />
{t("codexOauth.reauthBadge", "需要重新登录")}
</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
return (
<div className={`space-y-4 ${className || ""}`}>
{/* 认证状态标题 */}
<div className="flex items-center justify-between">
<Label>{t("codexOauth.authStatus", "认证状态")}</Label>
<Badge
variant={hasAnyAccount ? "default" : "secondary"}
className={hasAnyAccount ? "bg-green-500 hover:bg-green-600" : ""}
{mode === "manage" && (
<div className="flex items-center justify-between">
<Label>{t("codexOauth.authStatus", "认证状态")}</Label>
<Badge
variant={
isStatusError
? "destructive"
: hasAnyAccount
? "default"
: "secondary"
}
className={
isStatusSuccess && hasAnyAccount
? "bg-green-500 hover:bg-green-600"
: ""
}
>
{isStatusError
? t("codexOauth.statusUnavailable", "状态不可用")
: !isStatusSuccess
? t("codexOauth.statusLoading", "正在加载...")
: hasAnyAccount
? t("codexOauth.accountCount", {
count: accounts.length,
defaultValue: `${accounts.length} 个账号`,
})
: t("codexOauth.notAuthenticated", "未认证")}
</Badge>
</div>
)}
{isStatusError && (
<div
role="alert"
className="flex items-center gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive"
>
{hasAnyAccount
? t("codexOauth.accountCount", {
count: accounts.length,
defaultValue: `${accounts.length} 个账号`,
})
: t("codexOauth.notAuthenticated", "未认证")}
</Badge>
</div>
<AlertTriangle className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1">
{t(
"codexOauth.statusLoadFailed",
"无法加载 ChatGPT 账号状态,请重试。",
)}
</span>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 shrink-0"
onClick={() => void refetchStatus()}
>
<RefreshCw className="mr-1 h-3.5 w-3.5" />
{t("codexOauth.retry", "重试")}
</Button>
</div>
)}
{!isStatusSuccess && !isStatusError && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("codexOauth.statusLoading", "正在加载...")}
</div>
)}
{/* 旧账号需重新登录提示(缺少 id_token) */}
{mode === "manage" && hasReauthAccounts && (
<div className="flex items-start gap-3 rounded-lg border border-amber-300/70 bg-amber-50 p-3 text-amber-900 dark:border-amber-500/40 dark:bg-amber-950/40 dark:text-amber-100">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
<div className="space-y-1">
<p className="text-sm font-medium">
{t("codexOauth.reauthTitle", "部分账号需要重新登录")}
</p>
<p className="text-xs leading-relaxed text-amber-800/90 dark:text-amber-200/80">
{t(
"codexOauth.reauthDescription",
"为与浏览器登录行为保持一致,这些账号需要重新登录以补全所需的登录凭据(id_token)。重新登录后即可正常用于托管绑定。",
)}
</p>
</div>
</div>
)}
{/* 账号选择器 */}
{hasAnyAccount && onAccountSelect && (
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{t("codexOauth.selectAccount", "选择账号")}
</Label>
<Select
value={selectedAccountId || "none"}
onValueChange={handleAccountSelect}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"codexOauth.selectAccountPlaceholder",
"选择一个 ChatGPT 账号",
)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
<span className="text-muted-foreground">
{t("codexOauth.useDefaultAccount", "使用默认账号")}
</span>
</SelectItem>
{accounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
<span>{account.login}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{mode === "select" && accountSelect ? (
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
<div className="min-w-0 flex-1">{accountSelect}</div>
{onManageAccounts && (
<Button
type="button"
variant="outline"
onClick={onManageAccounts}
className="h-9 shrink-0"
>
<Settings2 className="h-4 w-4" />
{t("codexOauth.manageAccounts", "管理账号")}
</Button>
)}
</div>
) : (
accountSelect
)}
{/* select 模式:所选账号需重新登录的内联提示 */}
{mode === "select" && selectedAccountNeedsReauth && (
<div className="flex items-start gap-2 rounded-md border border-amber-300/70 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-500/40 dark:bg-amber-950/40 dark:text-amber-100">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
<div className="flex-1 leading-relaxed">
{t(
"codexOauth.reauthSelectHint",
"该账号需重新登录以启用托管绑定。",
)}
{onManageAccounts && (
<button
type="button"
onClick={onManageAccounts}
className="ml-1 font-medium underline underline-offset-2 hover:text-amber-700 dark:hover:text-amber-100"
>
{t("codexOauth.reauthNow", "立即重新登录")}
</button>
)}
</div>
</div>
)}
@@ -169,7 +329,7 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
)}
{/* 已登录账号列表 */}
{hasAnyAccount && (
{mode === "manage" && isStatusSuccess && hasAnyAccount && (
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{t("codexOauth.loggedInAccounts", "已登录账号")}
@@ -178,23 +338,51 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
{accounts.map((account) => (
<div
key={account.id}
className="flex items-center justify-between p-2 rounded-md border bg-muted/30"
className={`flex items-center justify-between gap-2 p-2 rounded-md border ${
account.reauth_required
? "border-amber-300/70 bg-amber-50/70 dark:border-amber-500/40 dark:bg-amber-950/30"
: "bg-muted/30"
}`}
>
<div className="flex items-center gap-2">
<User className="h-5 w-5 text-muted-foreground" />
<span className="text-sm font-medium">{account.login}</span>
<div className="flex min-w-0 items-center gap-2">
<User className="h-5 w-5 shrink-0 text-muted-foreground" />
<span className="truncate text-sm font-medium">
{account.login}
</span>
{defaultAccountId === account.id && (
<Badge variant="secondary" className="text-xs">
<Badge variant="secondary" className="shrink-0 text-xs">
{t("codexOauth.defaultAccount", "默认")}
</Badge>
)}
{selectedAccountId === account.id && (
<Badge variant="outline" className="text-xs">
<Badge variant="outline" className="shrink-0 text-xs">
{t("codexOauth.selected", "已选中")}
</Badge>
)}
{account.reauth_required && (
<Badge
variant="outline"
className="shrink-0 gap-1 border-amber-400/70 text-xs text-amber-700 dark:border-amber-500/50 dark:text-amber-300"
>
<AlertTriangle className="h-3 w-3" />
{t("codexOauth.reauthBadge", "需要重新登录")}
</Badge>
)}
</div>
<div className="flex items-center gap-1">
<div className="flex shrink-0 items-center gap-1">
{account.reauth_required && (
<Button
type="button"
variant="outline"
size="sm"
className="h-7 gap-1 border-amber-400/70 px-2 text-xs text-amber-700 hover:bg-amber-100 dark:border-amber-500/50 dark:text-amber-300 dark:hover:bg-amber-900/40"
onClick={addAccount}
disabled={isAddingAccount}
>
<RefreshCw className="h-3.5 w-3.5" />
{t("codexOauth.reauthLogin", "重新登录")}
</Button>
)}
{defaultAccountId !== account.id && (
<Button
type="button"
@@ -211,7 +399,7 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-red-500"
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-red-500"
onClick={(e) => handleRemoveAccount(account.id, e)}
disabled={isRemovingAccount}
title={t("codexOauth.removeAccount", "移除账号")}
@@ -226,34 +414,40 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
)}
{/* 未认证 - 登录按钮 */}
{!hasAnyAccount && pollingState === "idle" && (
<Button
type="button"
onClick={addAccount}
className="w-full"
variant="outline"
>
<Sparkles className="mr-2 h-4 w-4" />
{t("codexOauth.loginWithChatGPT", "使用 ChatGPT 登录")}
</Button>
)}
{mode === "manage" &&
isStatusSuccess &&
!hasAnyAccount &&
pollingState === "idle" && (
<Button
type="button"
onClick={addAccount}
className="w-full"
variant="outline"
>
<Sparkles className="mr-2 h-4 w-4" />
{t("codexOauth.loginWithChatGPT", "使用 ChatGPT 登录")}
</Button>
)}
{/* 已有账号 - 添加更多按钮 */}
{hasAnyAccount && pollingState === "idle" && (
<Button
type="button"
onClick={addAccount}
className="w-full"
variant="outline"
disabled={isAddingAccount}
>
<Plus className="mr-2 h-4 w-4" />
{t("codexOauth.addAnotherAccount", "添加其他账号")}
</Button>
)}
{mode === "manage" &&
isStatusSuccess &&
hasAnyAccount &&
pollingState === "idle" && (
<Button
type="button"
onClick={addAccount}
className="w-full"
variant="outline"
disabled={isAddingAccount}
>
<Plus className="mr-2 h-4 w-4" />
{t("codexOauth.addAnotherAccount", "添加其他账号")}
</Button>
)}
{/* 轮询中状态 */}
{isPolling && deviceCode && (
{mode === "manage" && isPolling && deviceCode && (
<div className="space-y-3 p-4 rounded-lg border border-border bg-muted/50">
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
@@ -310,7 +504,7 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
)}
{/* 错误状态 */}
{pollingState === "error" && error && (
{mode === "manage" && pollingState === "error" && error && (
<div className="space-y-2">
<p className="text-sm text-red-500">{error}</p>
<div className="flex gap-2">
@@ -335,17 +529,20 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
)}
{/* 注销所有账号 */}
{hasAnyAccount && accounts.length > 1 && (
<Button
type="button"
variant="outline"
onClick={logout}
className="w-full text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950"
>
<LogOut className="mr-2 h-4 w-4" />
{t("codexOauth.logoutAll", "注销所有账号")}
</Button>
)}
{mode === "manage" &&
isStatusSuccess &&
hasAnyAccount &&
accounts.length > 1 && (
<Button
type="button"
variant="outline"
onClick={logout}
className="w-full text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950"
>
<LogOut className="mr-2 h-4 w-4" />
{t("codexOauth.logoutAll", "注销所有账号")}
</Button>
)}
</div>
);
};
@@ -21,6 +21,9 @@ import {
Plus,
X,
User,
Settings2,
AlertTriangle,
RefreshCw,
} from "lucide-react";
import { useCopilotAuth } from "./hooks/useCopilotAuth";
import { copyText } from "@/lib/clipboard";
@@ -28,10 +31,14 @@ import type { GitHubAccount } from "@/lib/api";
interface CopilotAuthSectionProps {
className?: string;
/** select 模式只展示账号选择和管理入口;manage 模式展示完整账号管理 */
mode?: "manage" | "select";
/** 当前选中的 GitHub 账号 ID */
selectedAccountId?: string | null;
/** 账号选择回调 */
onAccountSelect?: (accountId: string | null) => void;
/** 打开账号管理入口 */
onManageAccounts?: () => void;
}
/**
@@ -41,8 +48,10 @@ interface CopilotAuthSectionProps {
*/
export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
className,
mode = "manage",
selectedAccountId,
onAccountSelect,
onManageAccounts,
}) => {
const { t } = useTranslation();
const [copied, setCopied] = React.useState(false);
@@ -64,6 +73,8 @@ export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
accounts,
defaultAccountId,
migrationError,
isStatusSuccess,
isStatusError,
hasAnyAccount,
pollingState,
deviceCode,
@@ -77,6 +88,7 @@ export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
setDefaultAccount,
cancelAuth,
logout,
refetchStatus,
} = useCopilotAuth(effectiveGithubDomain);
// 复制用户码
@@ -93,6 +105,24 @@ export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
onAccountSelect?.(value === "none" ? null : value);
};
React.useEffect(() => {
// Only clear a bound account once the status query has *successfully*
// loaded and the account is genuinely gone. A failed/pending query yields
// an empty `accounts` array, which must not silently unbind the provider.
if (
mode !== "select" ||
!selectedAccountId ||
!onAccountSelect ||
!isStatusSuccess
) {
return;
}
if (!accounts.some((account) => account.id === selectedAccountId)) {
onAccountSelect(null);
}
}, [accounts, isStatusSuccess, mode, onAccountSelect, selectedAccountId]);
// 处理移除账号
const handleRemoveAccount = (accountId: string, e: React.MouseEvent) => {
e.stopPropagation();
@@ -109,60 +139,150 @@ export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
return <CopilotAccountAvatar account={account} />;
};
const accountSelect = isStatusSuccess &&
onAccountSelect &&
(mode === "select" || hasAnyAccount) && (
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{mode === "select"
? t("copilot.githubAccount", "GitHub 账号")
: t("copilot.selectAccount", "选择账号")}
</Label>
<Select
value={selectedAccountId || "none"}
onValueChange={handleAccountSelect}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"copilot.selectAccountPlaceholder",
"选择一个 GitHub 账号",
)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
<span className="text-muted-foreground">
{t("copilot.useDefaultAccount", "使用默认账号")}
</span>
</SelectItem>
{accounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
<div className="flex items-center gap-2">
{renderAvatar(account)}
<span>{account.login}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
return (
<div className={`space-y-4 ${className || ""}`}>
{/* 认证状态标题 */}
<div className="flex items-center justify-between">
<Label>{t("copilot.authStatus", "GitHub Copilot 认证")}</Label>
<Badge
variant={hasAnyAccount ? "default" : "secondary"}
className={hasAnyAccount ? "bg-green-500 hover:bg-green-600" : ""}
{mode === "manage" && (
<div className="flex items-center justify-between">
<Label>{t("copilot.authStatus", "GitHub Copilot 认证")}</Label>
<Badge
variant={
isStatusError
? "destructive"
: hasAnyAccount
? "default"
: "secondary"
}
className={
isStatusSuccess && hasAnyAccount
? "bg-green-500 hover:bg-green-600"
: ""
}
>
{isStatusError
? t("copilot.statusUnavailable", "状态不可用")
: !isStatusSuccess
? t("copilot.statusLoading", "正在加载...")
: hasAnyAccount
? t("copilot.accountCount", {
count: accounts.length,
defaultValue: `${accounts.length} 个账号`,
})
: t("copilot.notAuthenticated", "未认证")}
</Badge>
</div>
)}
{isStatusError && (
<div
role="alert"
className="flex items-center gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive"
>
{hasAnyAccount
? t("copilot.accountCount", {
count: accounts.length,
defaultValue: `${accounts.length} 个账号`,
})
: t("copilot.notAuthenticated", "未认证")}
</Badge>
</div>
<AlertTriangle className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1">
{t(
"copilot.statusLoadFailed",
"无法加载 GitHub Copilot 账号状态,请重试。",
)}
</span>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 shrink-0"
onClick={() => void refetchStatus()}
>
<RefreshCw className="mr-1 h-3.5 w-3.5" />
{t("copilot.retry", "重试")}
</Button>
</div>
)}
{!isStatusSuccess && !isStatusError && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("copilot.statusLoading", "正在加载...")}
</div>
)}
{/* GitHub 部署类型选择 */}
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{t("copilot.deploymentType", "GitHub 部署类型")}
</Label>
<Select
value={deploymentType}
onValueChange={(v) =>
setDeploymentType(v as "github.com" | "enterprise")
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="github.com">
{t("copilot.deploymentGitHubCom", "GitHub.com")}
</SelectItem>
<SelectItem value="enterprise">
{t("copilot.deploymentEnterprise", "GitHub Enterprise Server")}
</SelectItem>
</SelectContent>
</Select>
{deploymentType === "enterprise" && (
<Input
placeholder={t(
"copilot.enterpriseDomainPlaceholder",
"例如:company.ghe.com",
)}
value={enterpriseDomain}
onChange={(e) => setEnterpriseDomain(e.target.value)}
/>
)}
</div>
{mode === "manage" && (
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{t("copilot.deploymentType", "GitHub 部署类型")}
</Label>
<Select
value={deploymentType}
onValueChange={(v) =>
setDeploymentType(v as "github.com" | "enterprise")
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="github.com">
{t("copilot.deploymentGitHubCom", "GitHub.com")}
</SelectItem>
<SelectItem value="enterprise">
{t("copilot.deploymentEnterprise", "GitHub Enterprise Server")}
</SelectItem>
</SelectContent>
</Select>
{deploymentType === "enterprise" && (
<Input
placeholder={t(
"copilot.enterpriseDomainPlaceholder",
"例如:company.ghe.com",
)}
value={enterpriseDomain}
onChange={(e) => setEnterpriseDomain(e.target.value)}
/>
)}
</div>
)}
{migrationError && (
{mode === "manage" && migrationError && (
<p className="text-sm text-amber-600 dark:text-amber-400">
{t("copilot.migrationFailed", {
error: migrationError,
@@ -172,44 +292,27 @@ export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
)}
{/* 账号选择器(有账号时显示) */}
{hasAnyAccount && onAccountSelect && (
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{t("copilot.selectAccount", "选择账号")}
</Label>
<Select
value={selectedAccountId || "none"}
onValueChange={handleAccountSelect}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"copilot.selectAccountPlaceholder",
"选择一个 GitHub 账号",
)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
<span className="text-muted-foreground">
{t("copilot.useDefaultAccount", "使用默认账号")}
</span>
</SelectItem>
{accounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
<div className="flex items-center gap-2">
{renderAvatar(account)}
<span>{account.login}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{mode === "select" && accountSelect ? (
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
<div className="min-w-0 flex-1">{accountSelect}</div>
{onManageAccounts && (
<Button
type="button"
variant="outline"
onClick={onManageAccounts}
className="h-9 shrink-0"
>
<Settings2 className="h-4 w-4" />
{t("copilot.manageAccounts", "管理账号")}
</Button>
)}
</div>
) : (
accountSelect
)}
{/* 已登录账号列表 */}
{hasAnyAccount && (
{mode === "manage" && isStatusSuccess && hasAnyAccount && (
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{t("copilot.loggedInAccounts", "已登录账号")}
@@ -272,38 +375,46 @@ export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
)}
{/* 未认证状态 - 登录按钮 */}
{!hasAnyAccount && pollingState === "idle" && (
<Button
type="button"
onClick={addAccount}
className="w-full"
variant="outline"
disabled={deploymentType === "enterprise" && !enterpriseDomain.trim()}
>
<Github className="mr-2 h-4 w-4" />
{t("copilot.loginWithGitHub", "使用 GitHub 登录")}
</Button>
)}
{mode === "manage" &&
isStatusSuccess &&
!hasAnyAccount &&
pollingState === "idle" && (
<Button
type="button"
onClick={addAccount}
className="w-full"
variant="outline"
disabled={
deploymentType === "enterprise" && !enterpriseDomain.trim()
}
>
<Github className="mr-2 h-4 w-4" />
{t("copilot.loginWithGitHub", "使用 GitHub 登录")}
</Button>
)}
{/* 已有账号 - 添加更多账号按钮 */}
{hasAnyAccount && pollingState === "idle" && (
<Button
type="button"
onClick={addAccount}
className="w-full"
variant="outline"
disabled={
isAddingAccount ||
(deploymentType === "enterprise" && !enterpriseDomain.trim())
}
>
<Plus className="mr-2 h-4 w-4" />
{t("copilot.addAnotherAccount", "添加其他账号")}
</Button>
)}
{mode === "manage" &&
isStatusSuccess &&
hasAnyAccount &&
pollingState === "idle" && (
<Button
type="button"
onClick={addAccount}
className="w-full"
variant="outline"
disabled={
isAddingAccount ||
(deploymentType === "enterprise" && !enterpriseDomain.trim())
}
>
<Plus className="mr-2 h-4 w-4" />
{t("copilot.addAnotherAccount", "添加其他账号")}
</Button>
)}
{/* 轮询中状态 */}
{isPolling && deviceCode && (
{mode === "manage" && isPolling && deviceCode && (
<div className="space-y-3 p-4 rounded-lg border border-border bg-muted/50">
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
@@ -363,7 +474,7 @@ export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
)}
{/* 错误状态 */}
{pollingState === "error" && error && (
{mode === "manage" && pollingState === "error" && error && (
<div className="space-y-2">
<p className="text-sm text-red-500">{error}</p>
<div className="flex gap-2">
@@ -388,17 +499,20 @@ export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
)}
{/* 注销所有账号按钮 */}
{hasAnyAccount && accounts.length > 1 && (
<Button
type="button"
variant="outline"
onClick={logout}
className="w-full text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950"
>
<LogOut className="mr-2 h-4 w-4" />
{t("copilot.logoutAll", "注销所有账号")}
</Button>
)}
{mode === "manage" &&
isStatusSuccess &&
hasAnyAccount &&
accounts.length > 1 && (
<Button
type="button"
variant="outline"
onClick={logout}
className="w-full text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950"
>
<LogOut className="mr-2 h-4 w-4" />
{t("copilot.logoutAll", "注销所有账号")}
</Button>
)}
</div>
);
};
+149 -44
View File
@@ -12,7 +12,12 @@ import {
buildLocalProxyRequestOverrides,
formatRequestOverrideObject,
} from "@/lib/requestOverrides";
import { providersApi, settingsApi, type AppId } from "@/lib/api";
import {
providersApi,
settingsApi,
type AppId,
type ManagedAuthProvider,
} from "@/lib/api";
import { useDarkMode } from "@/hooks/useDarkMode";
import type {
ProviderCategory,
@@ -134,6 +139,16 @@ type PresetEntry = {
| HermesProviderPreset;
};
function getPresetProviderType(
preset: PresetEntry["preset"] | null | undefined,
): "github_copilot" | "codex_oauth" | undefined {
if (!preset || !("providerType" in preset)) return undefined;
return preset.providerType === "github_copilot" ||
preset.providerType === "codex_oauth"
? preset.providerType
: undefined;
}
export const normalizeCodexCatalogModelsForSave = (
models: CodexCatalogModel[],
): CodexCatalogModel[] => {
@@ -225,6 +240,7 @@ export interface ProviderFormProps {
onCancel: () => void;
onUniversalPresetSelect?: (preset: UniversalProviderPreset) => void;
onManageUniversalProviders?: () => void;
onManageAuthAccounts?: (target: ManagedAuthProvider) => void;
onSubmittingChange?: (isSubmitting: boolean) => void;
initialData?: {
name?: string;
@@ -259,6 +275,7 @@ function ProviderFormFull({
onCancel,
onUniversalPresetSelect,
onManageUniversalProviders,
onManageAuthAccounts,
onSubmittingChange,
initialData,
showButtons = true,
@@ -361,6 +378,13 @@ function ProviderFormFull({
initialData?.meta?.pricingModelSource,
),
});
setSelectedGitHubAccountId(
resolveManagedAccountId(initialData?.meta, "github_copilot"),
);
setSelectedCodexAccountId(
resolveManagedAccountId(initialData?.meta, "codex_oauth"),
);
setCodexFastMode(initialData?.meta?.codexFastMode ?? false);
setCodexChatReasoning(initialData?.meta?.codexChatReasoning ?? {});
setPromptCacheRouting(initialData?.meta?.promptCacheRouting ?? "auto");
setCustomUserAgent(initialData?.meta?.customUserAgent ?? "");
@@ -514,10 +538,18 @@ function ProviderFormFull({
);
// Copilot OAuth 认证状态(仅 Claude 应用需要)
const { isAuthenticated: isCopilotAuthenticated } = useCopilotAuth();
const {
isAuthenticated: isCopilotAuthenticated,
isStatusSuccess: isCopilotStatusSuccess,
isStatusError: isCopilotStatusError,
} = useCopilotAuth();
// Codex OAuth 认证状态(ChatGPT Plus/Pro 反代)
const { isAuthenticated: isCodexOauthAuthenticated } = useCodexOauth();
const {
isAuthenticated: isCodexOauthAuthenticated,
isStatusSuccess: isCodexOauthStatusSuccess,
isStatusError: isCodexOauthStatusError,
} = useCodexOauth();
// 选中的 GitHub 账号 ID(多账号支持)
const [selectedGitHubAccountId, setSelectedGitHubAccountId] = useState<
@@ -704,6 +736,40 @@ function ProviderFormFull({
}));
}, [appId]);
const selectedPresetEntry = useMemo(
() =>
selectedPresetId && selectedPresetId !== "custom"
? (presetEntries.find((entry) => entry.id === selectedPresetId) ?? null)
: null,
[presetEntries, selectedPresetId],
);
const selectedPresetProviderType = getPresetProviderType(
selectedPresetEntry?.preset,
);
const initialProviderType = initialData?.meta?.providerType;
const isCopilotProvider =
appId === "claude" &&
(selectedPresetProviderType === "github_copilot" ||
initialProviderType === "github_copilot" ||
baseUrl.includes("githubcopilot.com"));
const isClaudeCodexOauthProvider =
appId === "claude" &&
(selectedPresetProviderType === "codex_oauth" ||
initialProviderType === "codex_oauth");
const isCodexOfficialProvider =
appId === "codex" &&
(category === "official" ||
(selectedPresetProviderType === "codex_oauth" &&
selectedPresetEntry?.preset.category === "official"));
const isCodexOfficialManagedOauthBound =
isCodexOfficialProvider && Boolean(selectedCodexAccountId);
const wasCodexOfficialManagedOauthBound =
appId === "codex" &&
initialData?.category === "official" &&
Boolean(resolveManagedAccountId(initialData?.meta, "codex_oauth"));
const requiresCodexOauthLogin =
isClaudeCodexOauthProvider || isCodexOfficialManagedOauthBound;
const {
templateValues,
templateValueEntries,
@@ -1139,13 +1205,22 @@ function ProviderFormFull({
}
// OAuth 未登录:B 类(token 根本不存在,保存了也没法建立)
const isCopilotProvider =
templatePreset?.providerType === "github_copilot" ||
initialData?.meta?.providerType === "github_copilot" ||
baseUrl.includes("githubcopilot.com");
const isCodexOauthProvider =
templatePreset?.providerType === "codex_oauth" ||
initialData?.meta?.providerType === "codex_oauth";
if (isCopilotProvider && isCopilotStatusError) {
toast.error(
t("copilot.statusLoadFailed", {
defaultValue: "无法加载 GitHub Copilot 账号状态,请重试。",
}),
);
return;
}
if (isCopilotProvider && !isCopilotStatusSuccess) {
toast.error(
t("copilot.statusLoading", {
defaultValue: "正在加载 GitHub Copilot 账号状态,请稍后再试。",
}),
);
return;
}
if (isCopilotProvider && !isCopilotAuthenticated) {
toast.error(
t("copilot.loginRequired", {
@@ -1154,7 +1229,23 @@ function ProviderFormFull({
);
return;
}
if (isCodexOauthProvider && !isCodexOauthAuthenticated) {
if (requiresCodexOauthLogin && isCodexOauthStatusError) {
toast.error(
t("codexOauth.statusLoadFailed", {
defaultValue: "无法加载 ChatGPT 账号状态,请重试。",
}),
);
return;
}
if (requiresCodexOauthLogin && !isCodexOauthStatusSuccess) {
toast.error(
t("codexOauth.statusLoading", {
defaultValue: "正在加载 ChatGPT 账号状态,请稍后再试。",
}),
);
return;
}
if (requiresCodexOauthLogin && !isCodexOauthAuthenticated) {
toast.error(
t("codexOauth.loginRequired", {
defaultValue: "请先登录 ChatGPT 账号",
@@ -1198,14 +1289,18 @@ function ProviderFormFull({
// cloud_provider(如 Bedrock)通过模板变量处理认证,跳过通用校验
if (category !== "official" && category !== "cloud_provider") {
if (appId === "claude") {
if (!isCodexOauthProvider && !baseUrl.trim()) {
if (!isClaudeCodexOauthProvider && !baseUrl.trim()) {
issues.push(
t("providerForm.endpointRequired", {
defaultValue: "非官方供应商请填写 API 端点",
}),
);
}
if (!isCopilotProvider && !isCodexOauthProvider && !apiKey.trim()) {
if (
!isCopilotProvider &&
!isClaudeCodexOauthProvider &&
!apiKey.trim()
) {
issues.push(
t("providerForm.apiKeyRequired", {
defaultValue: "非官方供应商请填写 API Key",
@@ -1270,20 +1365,17 @@ function ProviderFormFull({
return;
}
// OAuth / 其它身份识别(与 handleSubmit 保持一致)
const isCopilotProvider =
templatePreset?.providerType === "github_copilot" ||
initialData?.meta?.providerType === "github_copilot" ||
baseUrl.includes("githubcopilot.com");
const isCodexOauthProvider =
templatePreset?.providerType === "codex_oauth" ||
initialData?.meta?.providerType === "codex_oauth";
let settingsConfig: string;
if (appId === "codex") {
try {
const authJson = JSON.parse(codexAuth);
const shouldStripManagedCodexAuth =
category === "official" &&
(isCodexOfficialManagedOauthBound ||
wasCodexOfficialManagedOauthBound);
const authJson = shouldStripManagedCodexAuth
? {}
: JSON.parse(codexAuth);
let normalizedCodexConfig =
category !== "official" && (codexConfig ?? "").trim()
? setCodexWireApi(codexConfig ?? "", "responses")
@@ -1453,9 +1545,11 @@ function ProviderFormFull({
const baseMeta: ProviderMeta | undefined =
payload.meta ?? (initialData?.meta ? { ...initialData.meta } : undefined);
// 确定 providerType(新建时从预设获取,编辑时从现有数据获取)
const providerType =
templatePreset?.providerType || initialData?.meta?.providerType;
const providerType = isCopilotProvider
? "github_copilot"
: isClaudeCodexOauthProvider || isCodexOfficialManagedOauthBound
? "codex_oauth"
: undefined;
const nextMeta: ProviderMeta = {
...(baseMeta ?? {}),
@@ -1477,19 +1571,25 @@ function ProviderFormFull({
authProvider: "github_copilot",
accountId: selectedGitHubAccountId ?? undefined,
}
: isCodexOauthProvider
: isClaudeCodexOauthProvider
? {
source: "managed_account",
authProvider: "codex_oauth",
accountId: selectedCodexAccountId ?? undefined,
}
: undefined,
: isCodexOfficialManagedOauthBound
? {
source: "managed_account",
authProvider: "codex_oauth",
accountId: selectedCodexAccountId ?? undefined,
}
: undefined,
// GitHub Copilot 多账号:保存关联的账号 ID
githubAccountId:
isCopilotProvider && selectedGitHubAccountId
? selectedGitHubAccountId
: undefined,
codexFastMode: isCodexOauthProvider ? codexFastMode : undefined,
codexFastMode: isClaudeCodexOauthProvider ? codexFastMode : undefined,
codexChatReasoning:
appId === "codex" &&
category !== "official" &&
@@ -1557,9 +1657,18 @@ function ProviderFormFull({
: undefined,
};
if (!isCodexOauthProvider && "codexFastMode" in nextMeta) {
if (!isClaudeCodexOauthProvider && "codexFastMode" in nextMeta) {
delete nextMeta.codexFastMode;
}
if (!providerType && "providerType" in nextMeta) {
delete nextMeta.providerType;
}
if (!nextMeta.authBinding && "authBinding" in nextMeta) {
delete nextMeta.authBinding;
}
if (!nextMeta.githubAccountId && "githubAccountId" in nextMeta) {
delete nextMeta.githubAccountId;
}
payload.meta = nextMeta;
@@ -2100,26 +2209,17 @@ function ProviderFormFull({
websiteUrl={claudeWebsiteUrl}
isPartner={isClaudePartner}
partnerPromotionKey={claudePartnerPromotionKey}
isCopilotPreset={
templatePreset?.providerType === "github_copilot" ||
initialData?.meta?.providerType === "github_copilot" ||
baseUrl.includes("githubcopilot.com")
}
isCodexOauthPreset={
templatePreset?.providerType === "codex_oauth" ||
initialData?.meta?.providerType === "codex_oauth"
}
isCopilotPreset={isCopilotProvider}
isCodexOauthPreset={isClaudeCodexOauthProvider}
usesOAuth={
templatePreset?.requiresOAuth === true ||
templatePreset?.providerType === "github_copilot" ||
initialData?.meta?.providerType === "github_copilot" ||
baseUrl.includes("githubcopilot.com") ||
templatePreset?.providerType === "codex_oauth" ||
initialData?.meta?.providerType === "codex_oauth"
isCopilotProvider ||
isClaudeCodexOauthProvider
}
isCopilotAuthenticated={isCopilotAuthenticated}
selectedGitHubAccountId={selectedGitHubAccountId}
onGitHubAccountSelect={setSelectedGitHubAccountId}
onManageAuthAccounts={onManageAuthAccounts}
isCodexOauthAuthenticated={isCodexOauthAuthenticated}
selectedCodexAccountId={selectedCodexAccountId}
onCodexAccountSelect={setSelectedCodexAccountId}
@@ -2178,6 +2278,11 @@ function ProviderFormFull({
websiteUrl={codexWebsiteUrl}
isPartner={isCodexPartner}
partnerPromotionKey={codexPartnerPromotionKey}
isCodexOauthPreset={isCodexOfficialProvider}
selectedCodexAccountId={selectedCodexAccountId}
onCodexAccountSelect={setSelectedCodexAccountId}
onManageAuthAccounts={onManageAuthAccounts}
codexOauthNoneOptionLabel={t("codexOauth.noneOptionLabel")}
shouldShowSpeedTest={shouldShowSpeedTest}
codexBaseUrl={codexBaseUrl}
onBaseUrlChange={handleCodexBaseUrlChange}
@@ -30,6 +30,8 @@ export function useManagedAuth(
const {
data: authStatus,
isLoading: isLoadingStatus,
isSuccess: isStatusSuccess,
isError: isStatusError,
refetch: refetchStatus,
} = useQuery<ManagedAuthStatus>({
queryKey,
@@ -215,6 +217,10 @@ export function useManagedAuth(
return {
authStatus,
isLoadingStatus,
// Distinguish "status loaded successfully" from "loading / failed" so
// callers don't treat a failed query's empty `accounts` as authoritative.
isStatusSuccess,
isStatusError,
accounts,
hasAnyAccount: accounts.length > 0,
isAuthenticated: authStatus?.authenticated ?? false,
+39 -3
View File
@@ -1,12 +1,42 @@
import { useEffect, useRef } from "react";
import { Github, ShieldCheck } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import { CodexIcon } from "@/components/BrandIcons";
import { CopilotAuthSection } from "@/components/providers/forms/CopilotAuthSection";
import { CodexOAuthSection } from "@/components/providers/forms/CodexOAuthSection";
import type { ManagedAuthProvider } from "@/lib/api";
export function AuthCenterPanel() {
interface AuthCenterPanelProps {
authScrollTarget?: ManagedAuthProvider | null;
}
export function AuthCenterPanel({ authScrollTarget }: AuthCenterPanelProps) {
const { t } = useTranslation();
const copilotSectionRef = useRef<HTMLElement | null>(null);
const codexOauthSectionRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!authScrollTarget) return;
const sectionRef =
authScrollTarget === "github_copilot"
? copilotSectionRef
: codexOauthSectionRef;
const frame = requestAnimationFrame(() => {
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
sectionRef.current?.scrollIntoView({
behavior: prefersReducedMotion ? "auto" : "smooth",
block: "start",
});
});
return () => cancelAnimationFrame(frame);
}, [authScrollTarget]);
return (
<div className="space-y-6">
@@ -34,7 +64,10 @@ export function AuthCenterPanel() {
</div>
</section>
<section className="rounded-xl border border-border/60 bg-card/60 p-6">
<section
ref={copilotSectionRef}
className="scroll-mt-4 rounded-xl border border-border/60 bg-card/60 p-6"
>
<div className="mb-4 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-muted">
<Github className="h-5 w-5" />
@@ -52,7 +85,10 @@ export function AuthCenterPanel() {
<CopilotAuthSection />
</section>
<section className="rounded-xl border border-border/60 bg-card/60 p-6">
<section
ref={codexOauthSectionRef}
className="scroll-mt-4 rounded-xl border border-border/60 bg-card/60 p-6"
>
<div className="mb-4 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-muted">
<CodexIcon size={20} />
+3
View File
@@ -37,6 +37,8 @@ export interface CodexProviderPreset {
modelCatalog?: CodexCatalogModel[];
// Codex Responses -> Chat Completions reasoning capability defaults
codexChatReasoning?: CodexChatReasoning;
// Managed OAuth provider type
providerType?: "codex_oauth";
// Session-based prompt-cache routing override for Chat Completions upstreams
promptCacheRouting?: PromptCacheRoutingMode;
}
@@ -111,6 +113,7 @@ export const codexProviderPresets: CodexProviderPreset[] = [
websiteUrl: "https://chatgpt.com/codex",
isOfficial: true,
category: "official",
providerType: "codex_oauth",
auth: {},
config: ``,
theme: {
+19 -1
View File
@@ -1163,7 +1163,8 @@
"fetchModelsAuthFailed": "API Key is invalid or lacks permission",
"fetchModelsNotSupported": "This provider does not support fetching model list",
"fetchModelsEndpointNotFound": "No reachable models endpoint found. Please check the Base URL or confirm whether the provider exposes this API.",
"fetchModelsTimeout": "Request timed out, please check network connection"
"fetchModelsTimeout": "Request timed out, please check network connection",
"providerKeyStatusLoading": "Loading provider key status, please try again shortly"
},
"copilot": {
"authSection": "GitHub Copilot Authentication",
@@ -1171,7 +1172,12 @@
"authenticated": "Authenticated as {{username}}",
"notAuthenticated": "Not authenticated",
"loginWithGitHub": "Login with GitHub",
"githubAccount": "GitHub account",
"manageAccounts": "Manage accounts",
"loginRequired": "Please login to GitHub Copilot first",
"statusLoading": "Loading account status...",
"statusUnavailable": "Status unavailable",
"statusLoadFailed": "Failed to load GitHub Copilot account status. Please retry.",
"waitingForAuth": "Waiting for authorization...",
"enterCode": "Please enter the code in your browser:",
"logout": "Logout",
@@ -1204,12 +1210,24 @@
"notAuthenticated": "Not authenticated",
"loginWithChatGPT": "Sign in with ChatGPT",
"loginRequired": "Please sign in to ChatGPT first",
"statusLoading": "Loading account status...",
"statusUnavailable": "Status unavailable",
"statusLoadFailed": "Failed to load ChatGPT account status. Please retry.",
"waitingForAuth": "Waiting for authorization...",
"enterCode": "Enter the code in your browser:",
"accountCount": "{{count}} account(s)",
"selectAccount": "Select account",
"chatgptAccount": "ChatGPT account",
"manageAccounts": "Manage accounts",
"selectAccountPlaceholder": "Choose a ChatGPT account",
"useDefaultAccount": "Use default account",
"noneOptionLabel": "Don't bind a managed account, use browser login",
"reauthTitle": "Some accounts need to sign in again",
"reauthDescription": "To match the browser-login behavior, these accounts must sign in again to restore the required login credential (id_token). Once re-authenticated they can be used for managed binding.",
"reauthBadge": "Re-login required",
"reauthLogin": "Re-login",
"reauthSelectHint": "This account needs to sign in again to enable managed binding.",
"reauthNow": "Re-login now",
"loggedInAccounts": "Logged in accounts",
"defaultAccount": "Default",
"selected": "Selected",
+19 -1
View File
@@ -1163,7 +1163,8 @@
"fetchModelsAuthFailed": "API Key が無効か、権限がありません",
"fetchModelsNotSupported": "このプロバイダーはモデル一覧の取得に対応していません",
"fetchModelsEndpointNotFound": "利用可能なモデル一覧エンドポイントが見つかりません。Base URL を確認するか、プロバイダーが該当 API を公開しているかご確認ください",
"fetchModelsTimeout": "リクエストがタイムアウトしました。ネットワーク接続を確認してください"
"fetchModelsTimeout": "リクエストがタイムアウトしました。ネットワーク接続を確認してください",
"providerKeyStatusLoading": "プロバイダー識別状態を読み込み中です。しばらくしてからもう一度お試しください"
},
"copilot": {
"authSection": "GitHub Copilot 認証",
@@ -1171,7 +1172,12 @@
"authenticated": "認証済み: {{username}}",
"notAuthenticated": "未認証",
"loginWithGitHub": "GitHub でログイン",
"githubAccount": "GitHub アカウント",
"manageAccounts": "アカウント管理",
"loginRequired": "先に GitHub Copilot にログインしてください",
"statusLoading": "アカウント状態を読み込み中...",
"statusUnavailable": "状態を取得できません",
"statusLoadFailed": "GitHub Copilot のアカウント状態を読み込めませんでした。再試行してください。",
"waitingForAuth": "認証を待っています...",
"enterCode": "ブラウザで以下のコードを入力してください:",
"logout": "ログアウト",
@@ -1204,12 +1210,24 @@
"notAuthenticated": "未認証",
"loginWithChatGPT": "ChatGPT でログイン",
"loginRequired": "先に ChatGPT にログインしてください",
"statusLoading": "アカウント状態を読み込み中...",
"statusUnavailable": "状態を取得できません",
"statusLoadFailed": "ChatGPT のアカウント状態を読み込めませんでした。再試行してください。",
"waitingForAuth": "認証を待機中...",
"enterCode": "ブラウザで以下のコードを入力してください:",
"accountCount": "{{count}} アカウント",
"selectAccount": "アカウントを選択",
"chatgptAccount": "ChatGPT アカウント",
"manageAccounts": "アカウント管理",
"selectAccountPlaceholder": "ChatGPT アカウントを選択",
"useDefaultAccount": "デフォルトアカウントを使用",
"noneOptionLabel": "管理アカウントをバインドせず、ブラウザでログイン",
"reauthTitle": "再ログインが必要なアカウントがあります",
"reauthDescription": "ブラウザログインと同じ挙動にするため、これらのアカウントは再ログインして必要な認証情報(id_token)を補完する必要があります。再ログイン後は管理アカウントのバインドに利用できます。",
"reauthBadge": "再ログインが必要",
"reauthLogin": "再ログイン",
"reauthSelectHint": "このアカウントは管理バインドを有効にするため再ログインが必要です。",
"reauthNow": "今すぐ再ログイン",
"loggedInAccounts": "ログイン済みアカウント",
"defaultAccount": "デフォルト",
"selected": "選択中",
+19 -1
View File
@@ -1135,7 +1135,8 @@
"fetchModelsAuthFailed": "API Key 無效或無權限",
"fetchModelsNotSupported": "該供應商不支援取得模型清單",
"fetchModelsEndpointNotFound": "未找到可用的模型清單端點,請檢查 Base URL 或確認供應商是否開放該 API",
"fetchModelsTimeout": "請求逾時,請檢查網路連線"
"fetchModelsTimeout": "請求逾時,請檢查網路連線",
"providerKeyStatusLoading": "正在載入供應商標識狀態,請稍後再試"
},
"copilot": {
"authSection": "GitHub Copilot 驗證",
@@ -1143,7 +1144,12 @@
"authenticated": "已驗證: {{username}}",
"notAuthenticated": "未驗證",
"loginWithGitHub": "使用 GitHub 登入",
"githubAccount": "GitHub 帳號",
"manageAccounts": "管理帳號",
"loginRequired": "請先登入 GitHub Copilot",
"statusLoading": "正在載入帳號狀態...",
"statusUnavailable": "狀態無法使用",
"statusLoadFailed": "無法載入 GitHub Copilot 帳號狀態,請重試。",
"waitingForAuth": "等待授權中...",
"enterCode": "請在瀏覽器中輸入驗證碼:",
"logout": "登出",
@@ -1176,12 +1182,24 @@
"notAuthenticated": "未驗證",
"loginWithChatGPT": "使用 ChatGPT 登入",
"loginRequired": "請先登入 ChatGPT 帳號",
"statusLoading": "正在載入帳號狀態...",
"statusUnavailable": "狀態無法使用",
"statusLoadFailed": "無法載入 ChatGPT 帳號狀態,請重試。",
"waitingForAuth": "等待授權中...",
"enterCode": "請在瀏覽器中輸入以下驗證碼:",
"accountCount": "{{count}} 個帳號",
"selectAccount": "選擇帳號",
"chatgptAccount": "ChatGPT 帳號",
"manageAccounts": "管理帳號",
"selectAccountPlaceholder": "選擇一個 ChatGPT 帳號",
"useDefaultAccount": "使用預設帳號",
"noneOptionLabel": "暫不綁定託管帳號,使用瀏覽器登入",
"reauthTitle": "部分帳號需要重新登入",
"reauthDescription": "為與瀏覽器登入行為保持一致,這些帳號需要重新登入以補全所需的登入憑證(id_token)。重新登入後即可正常用於託管綁定。",
"reauthBadge": "需要重新登入",
"reauthLogin": "重新登入",
"reauthSelectHint": "該帳號需重新登入以啟用託管綁定。",
"reauthNow": "立即重新登入",
"loggedInAccounts": "已登入帳號",
"defaultAccount": "預設",
"selected": "已選取",
+19 -1
View File
@@ -1163,7 +1163,8 @@
"fetchModelsAuthFailed": "API Key 无效或无权限",
"fetchModelsNotSupported": "该供应商不支持获取模型列表",
"fetchModelsEndpointNotFound": "未找到可用的模型列表端点,请检查 Base URL 或确认供应商是否开放该接口",
"fetchModelsTimeout": "请求超时,请检查网络连接"
"fetchModelsTimeout": "请求超时,请检查网络连接",
"providerKeyStatusLoading": "正在加载供应商标识状态,请稍后再试"
},
"copilot": {
"authSection": "GitHub Copilot 认证",
@@ -1171,7 +1172,12 @@
"authenticated": "已认证: {{username}}",
"notAuthenticated": "未认证",
"loginWithGitHub": "使用 GitHub 登录",
"githubAccount": "GitHub 账号",
"manageAccounts": "管理账号",
"loginRequired": "请先登录 GitHub Copilot",
"statusLoading": "正在加载账号状态...",
"statusUnavailable": "状态不可用",
"statusLoadFailed": "无法加载 GitHub Copilot 账号状态,请重试。",
"waitingForAuth": "等待授权中...",
"enterCode": "请在浏览器中输入验证码:",
"logout": "注销",
@@ -1204,12 +1210,24 @@
"notAuthenticated": "未认证",
"loginWithChatGPT": "使用 ChatGPT 登录",
"loginRequired": "请先登录 ChatGPT 账号",
"statusLoading": "正在加载账号状态...",
"statusUnavailable": "状态不可用",
"statusLoadFailed": "无法加载 ChatGPT 账号状态,请重试。",
"waitingForAuth": "等待授权中...",
"enterCode": "请在浏览器中输入以下验证码:",
"accountCount": "{{count}} 个账号",
"selectAccount": "选择账号",
"chatgptAccount": "ChatGPT 账号",
"manageAccounts": "管理账号",
"selectAccountPlaceholder": "选择一个 ChatGPT 账号",
"useDefaultAccount": "使用默认账号",
"noneOptionLabel": "暂不绑定托管账号,使用浏览器登录",
"reauthTitle": "部分账号需要重新登录",
"reauthDescription": "为与浏览器登录行为保持一致,这些账号需要重新登录以补全所需的登录凭据(id_token)。重新登录后即可正常用于托管绑定。",
"reauthBadge": "需要重新登录",
"reauthLogin": "重新登录",
"reauthSelectHint": "该账号需重新登录以启用托管绑定。",
"reauthNow": "立即重新登录",
"loggedInAccounts": "已登录账号",
"defaultAccount": "默认",
"selected": "已选中",
+2
View File
@@ -10,6 +10,8 @@ export interface ManagedAuthAccount {
authenticated_at: number;
is_default: boolean;
github_domain: string;
/** 是否需要重新登录以补全缺失的凭据(Codex 旧账号缺少 id_token */
reauth_required?: boolean;
}
export interface ManagedAuthStatus {
+17 -1
View File
@@ -50,7 +50,23 @@ export const useAddProviderMutation = (appId: AppId) => {
if (!officialProvider) {
throw new Error("Codex official provider was not created");
}
return officialProvider;
// The fixed seed supplies identity/order, while the Add Provider form
// supplies the selected managed-account binding and editable metadata.
// Returning the seed directly would silently discard meta.authBinding,
// making the Official account selector appear to save while doing
// nothing. Persist the merged fixed-id row through the normal update
// path so current-provider live sync and managed auth preflight run too.
const updatedOfficialProvider: Provider = {
...officialProvider,
...rest,
id: CODEX_OFFICIAL_PROVIDER_ID,
category: "official",
createdAt: officialProvider.createdAt,
sortIndex: officialProvider.sortIndex,
};
await providersApi.update(updatedOfficialProvider, appId);
return updatedOfficialProvider;
}
let id: string;
@@ -2,6 +2,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AddProviderDialog } from "@/components/providers/AddProviderDialog";
import type { ProviderFormValues } from "@/components/providers/forms/ProviderForm";
import { codexProviderPresets } from "@/config/codexProviderPresets";
vi.mock("@/components/ui/dialog", () => ({
Dialog: ({ children }: { children: React.ReactNode }) => (
@@ -126,6 +127,57 @@ describe("AddProviderDialog", () => {
});
});
it("submits the managed account selected from the Codex Official preset", async () => {
const handleSubmit = vi.fn().mockResolvedValue(undefined);
const officialPresetIndex = codexProviderPresets.findIndex(
(preset) =>
preset.category === "official" && preset.providerType === "codex_oauth",
);
expect(officialPresetIndex).toBeGreaterThanOrEqual(0);
mockFormValues = {
name: "OpenAI Official",
websiteUrl: "https://chatgpt.com/codex",
settingsConfig: JSON.stringify({ auth: {}, config: "" }),
presetId: `codex-${officialPresetIndex}`,
presetCategory: "official",
meta: {
providerType: "codex_oauth",
authBinding: {
source: "managed_account",
authProvider: "codex_oauth",
accountId: "acct-managed",
},
},
};
render(
<AddProviderDialog
open
onOpenChange={vi.fn()}
appId="codex"
onSubmit={handleSubmit}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "common.add" }));
await waitFor(() => expect(handleSubmit).toHaveBeenCalledTimes(1));
expect(handleSubmit).toHaveBeenCalledWith(
expect.objectContaining({
category: "official",
ensureCodexOfficialSeed: true,
meta: expect.objectContaining({
authBinding: {
source: "managed_account",
authProvider: "codex_oauth",
accountId: "acct-managed",
},
}),
}),
);
});
it("新建 Grok Build 自定义供应商时不补默认 Grok 图标", async () => {
const handleSubmit = vi.fn().mockResolvedValue(undefined);
+33
View File
@@ -0,0 +1,33 @@
import { render } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
const Panels = ({ innerOpen }: { innerOpen: boolean }) => (
<>
<FullScreenPanel isOpen title="Outer" onClose={() => undefined}>
outer
</FullScreenPanel>
<FullScreenPanel isOpen={innerOpen} title="Inner" onClose={() => undefined}>
inner
</FullScreenPanel>
</>
);
describe("FullScreenPanel body scroll locking", () => {
afterEach(() => {
document.body.style.overflow = "";
});
it("keeps the body locked when a nested panel closes", () => {
document.body.style.overflow = "clip";
const view = render(<Panels innerOpen />);
expect(document.body.style.overflow).toBe("hidden");
view.rerender(<Panels innerOpen={false} />);
expect(document.body.style.overflow).toBe("hidden");
view.unmount();
expect(document.body.style.overflow).toBe("clip");
});
});
@@ -0,0 +1,86 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CodexOAuthSection } from "@/components/providers/forms/CodexOAuthSection";
import { CopilotAuthSection } from "@/components/providers/forms/CopilotAuthSection";
const authMocks = vi.hoisted(() => ({
refetchCodex: vi.fn(),
refetchCopilot: vi.fn(),
}));
const failedStatus = (refetchStatus: () => void) => ({
accounts: [],
defaultAccountId: null,
migrationError: null,
isStatusSuccess: false,
isStatusError: true,
hasAnyAccount: false,
pollingState: "idle" as const,
deviceCode: null,
error: null,
isPolling: false,
isAddingAccount: false,
isRemovingAccount: false,
isSettingDefaultAccount: false,
addAccount: vi.fn(),
removeAccount: vi.fn(),
setDefaultAccount: vi.fn(),
cancelAuth: vi.fn(),
logout: vi.fn(),
refetchStatus,
});
vi.mock("@/components/providers/forms/hooks/useCodexOauth", () => ({
useCodexOauth: () => failedStatus(authMocks.refetchCodex),
}));
vi.mock("@/components/providers/forms/hooks/useCopilotAuth", () => ({
useCopilotAuth: () => failedStatus(authMocks.refetchCopilot),
}));
describe("managed auth status failures", () => {
beforeEach(() => {
authMocks.refetchCodex.mockResolvedValue(undefined);
authMocks.refetchCopilot.mockResolvedValue(undefined);
});
it("shows a retryable error instead of an empty Codex account selector", () => {
const onAccountSelect = vi.fn();
render(
<CodexOAuthSection
mode="select"
selectedAccountId="acct-existing"
onAccountSelect={onAccountSelect}
/>,
);
expect(screen.getByRole("alert")).toHaveTextContent(
"无法加载 ChatGPT 账号状态,请重试。",
);
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
expect(onAccountSelect).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "重试" }));
expect(authMocks.refetchCodex).toHaveBeenCalledTimes(1);
});
it("shows a retryable error instead of an empty Copilot account selector", () => {
const onAccountSelect = vi.fn();
render(
<CopilotAuthSection
mode="select"
selectedAccountId="acct-existing"
onAccountSelect={onAccountSelect}
/>,
);
expect(screen.getByRole("alert")).toHaveTextContent(
"无法加载 GitHub Copilot 账号状态,请重试。",
);
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
expect(onAccountSelect).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "重试" }));
expect(authMocks.refetchCopilot).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,150 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import {
ProviderForm,
type ProviderFormValues,
} from "@/components/providers/forms/ProviderForm";
import { createTestQueryClient } from "../utils/testQueryClient";
vi.mock("@/components/providers/forms/CodexOAuthSection", () => ({
CodexOAuthSection: ({
onAccountSelect,
}: {
onAccountSelect?: (accountId: string | null) => void;
}) => (
<div>
<button type="button" onClick={() => onAccountSelect?.("acct-managed")}>
select-managed-account
</button>
<button type="button" onClick={() => onAccountSelect?.(null)}>
select-native-login
</button>
</div>
),
}));
vi.mock("@/components/providers/forms/CodexConfigEditor", () => ({
default: () => <div data-testid="codex-config-editor" />,
}));
vi.mock("@/components/providers/forms/ProviderAdvancedConfig", () => ({
ProviderAdvancedConfig: () => <div data-testid="advanced-config" />,
}));
vi.mock("@/components/providers/forms/hooks", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/components/providers/forms/hooks")>();
return {
...actual,
useCopilotAuth: () => ({
isAuthenticated: false,
isStatusSuccess: true,
isStatusError: false,
}),
useCodexOauth: () => ({
isAuthenticated: true,
isStatusSuccess: true,
isStatusError: false,
}),
useCodexCommonConfig: () => ({
useCommonConfig: false,
commonConfigSnippet: "",
commonConfigError: null,
handleCommonConfigToggle: vi.fn(),
handleCommonConfigSnippetChange: vi.fn(),
isExtracting: false,
handleExtract: vi.fn(),
clearCommonConfigError: vi.fn(),
}),
useGeminiCommonConfig: () => ({
useCommonConfig: false,
commonConfigSnippet: "",
commonConfigError: null,
handleCommonConfigToggle: vi.fn(),
handleCommonConfigSnippetChange: vi.fn(),
isExtracting: false,
handleExtract: vi.fn(),
clearCommonConfigError: vi.fn(),
}),
};
});
vi.mock("@/lib/query", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/query")>();
return {
...actual,
useSettingsQuery: () => ({
data: { commonConfigConfirmed: true },
}),
};
});
function renderCodexForm(onSubmit: (values: ProviderFormValues) => void) {
const queryClient = createTestQueryClient();
return render(
<QueryClientProvider client={queryClient}>
<ProviderForm
appId="codex"
submitLabel="save-provider"
onSubmit={onSubmit}
onCancel={vi.fn()}
/>
</QueryClientProvider>,
);
}
describe("ProviderForm Codex Official managed account", () => {
it("persists the selected managed account while stripping OAuth secrets", async () => {
const onSubmit = vi.fn();
renderCodexForm(onSubmit);
fireEvent.click(screen.getByRole("button", { name: /OpenAI Official/ }));
fireEvent.click(
await screen.findByRole("button", { name: "select-managed-account" }),
);
fireEvent.click(screen.getByRole("button", { name: "save-provider" }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
const submitted = onSubmit.mock.calls[0][0] as ProviderFormValues;
expect(submitted).toEqual(
expect.objectContaining({
presetId: "codex-0",
presetCategory: "official",
meta: expect.objectContaining({
providerType: "codex_oauth",
authBinding: {
source: "managed_account",
authProvider: "codex_oauth",
accountId: "acct-managed",
},
}),
}),
);
expect(JSON.parse(submitted.settingsConfig)).toEqual({
auth: {},
config: "",
});
});
it("keeps Official on native browser login when no managed account is selected", async () => {
const onSubmit = vi.fn();
renderCodexForm(onSubmit);
fireEvent.click(screen.getByRole("button", { name: /OpenAI Official/ }));
fireEvent.click(
await screen.findByRole("button", { name: "select-managed-account" }),
);
fireEvent.click(
screen.getByRole("button", { name: "select-native-login" }),
);
fireEvent.click(screen.getByRole("button", { name: "save-provider" }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
const submitted = onSubmit.mock.calls[0][0] as ProviderFormValues;
expect(submitted.presetId).toBe("codex-0");
expect(submitted.presetCategory).toBe("official");
expect(submitted.meta?.providerType).toBeUndefined();
expect(submitted.meta?.authBinding).toBeUndefined();
});
});
+77 -2
View File
@@ -7,6 +7,7 @@ import type { Provider } from "@/types";
const apiMocks = vi.hoisted(() => ({
add: vi.fn(),
update: vi.fn(),
ensureClaudeDesktopOfficialProvider: vi.fn(),
ensureCodexOfficialProvider: vi.fn(),
getAll: vi.fn(),
@@ -20,6 +21,7 @@ const uuidMocks = vi.hoisted(() => ({
vi.mock("@/lib/api", () => ({
providersApi: {
add: (...args: unknown[]) => apiMocks.add(...args),
update: (...args: unknown[]) => apiMocks.update(...args),
ensureClaudeDesktopOfficialProvider: (...args: unknown[]) =>
apiMocks.ensureClaudeDesktopOfficialProvider(...args),
ensureCodexOfficialProvider: (...args: unknown[]) =>
@@ -59,6 +61,7 @@ function createWrapper() {
beforeEach(() => {
apiMocks.add.mockReset().mockResolvedValue(true);
apiMocks.update.mockReset().mockResolvedValue(true);
apiMocks.ensureClaudeDesktopOfficialProvider
.mockReset()
.mockResolvedValue(true);
@@ -138,7 +141,7 @@ describe("useAddProviderMutation", () => {
expect(persistedProvider).toEqual(seedProvider);
});
it("recreates and returns the fixed Codex official seed", async () => {
it("persists a managed account binding onto the fixed Codex official seed", async () => {
const seedProvider: Provider = {
id: "codex-official",
name: "OpenAI Official",
@@ -158,6 +161,13 @@ describe("useAddProviderMutation", () => {
name: "OpenAI Official",
settingsConfig: { auth: {}, config: "" },
category: "official",
meta: {
authBinding: {
source: "managed_account",
authProvider: "codex_oauth",
accountId: "acct-managed",
},
},
ensureCodexOfficialSeed: true,
}),
);
@@ -165,6 +175,71 @@ describe("useAddProviderMutation", () => {
expect(apiMocks.ensureCodexOfficialProvider).toHaveBeenCalledTimes(1);
expect(apiMocks.getAll).toHaveBeenCalledWith("codex");
expect(apiMocks.add).not.toHaveBeenCalled();
expect(persistedProvider).toEqual(seedProvider);
expect(apiMocks.update).toHaveBeenCalledWith(
expect.objectContaining({
id: "codex-official",
meta: {
authBinding: {
source: "managed_account",
authProvider: "codex_oauth",
accountId: "acct-managed",
},
},
}),
"codex",
);
expect(persistedProvider).toEqual(
expect.objectContaining({
id: "codex-official",
meta: expect.objectContaining({
authBinding: expect.objectContaining({
accountId: "acct-managed",
}),
}),
}),
);
});
it("clears a prior managed binding when Codex Official uses native login", async () => {
const seedProvider: Provider = {
id: "codex-official",
name: "OpenAI Official",
settingsConfig: { auth: {}, config: "" },
category: "official",
meta: {
providerType: "codex_oauth",
authBinding: {
source: "managed_account",
authProvider: "codex_oauth",
accountId: "acct-managed",
},
},
};
apiMocks.getAll.mockResolvedValueOnce({
"codex-official": seedProvider,
});
const { wrapper } = createWrapper();
const { result } = renderHook(() => useAddProviderMutation("codex"), {
wrapper,
});
const persistedProvider = await act(async () =>
result.current.mutateAsync({
name: "OpenAI Official",
settingsConfig: { auth: {}, config: "" },
category: "official",
meta: {},
ensureCodexOfficialSeed: true,
}),
);
expect(apiMocks.update).toHaveBeenCalledWith(
expect.objectContaining({
id: "codex-official",
meta: {},
}),
"codex",
);
expect(persistedProvider.meta).toEqual({});
});
});