mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
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.
This commit is contained in:
@@ -6,7 +6,9 @@ use crate::config::{
|
||||
write_json_file, write_text_file,
|
||||
};
|
||||
use crate::error::AppError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
use toml_edit::DocumentMut;
|
||||
@@ -94,6 +96,14 @@ 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,
|
||||
}
|
||||
|
||||
/// Which Codex tool surface the generated model catalog should target.
|
||||
///
|
||||
@@ -148,23 +158,131 @@ pub fn get_codex_auth_path() -> PathBuf {
|
||||
get_codex_config_dir().join("auth.json")
|
||||
}
|
||||
|
||||
/// 切走托管 provider 时删除其残留在 `~/.codex/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 记录/比对。
|
||||
///
|
||||
/// 已知边界(B 方案固有):完整可刷新 bundle 与原生浏览器登录形状一致,无法可靠
|
||||
/// 区分「我们写的托管登录」与「用户手动 `codex login` 的同一账号」。因此这里按
|
||||
/// account_id 内容判定;在「用户手动登录了同一账号,又切到一个不写 auth.json 的
|
||||
/// config-only provider」这一罕见组合下,可能删除用户的原生登录。常见路径(切到
|
||||
/// 会写 auth 的 provider)由 `clear_stale_managed_codex_live_auth` 的守卫覆盖。
|
||||
/// 仅接受 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()))
|
||||
}
|
||||
|
||||
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 与最近一次记录的 marker(account_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)?;
|
||||
// 完整可刷新 bundle 无法凭形状/哈希区分,按 account_id 内容判定是否为该
|
||||
// 托管账号的登录;不是则不动(保护用户其它账号的原生登录)。
|
||||
if !codex_live_auth_is_managed_chatgpt_login(&auth, account_id) {
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -1379,14 +1379,19 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(changed, "rotated refresh_token should be adopted");
|
||||
|
||||
// 完整 bundle 反映新的 refresh_token;access_token 仍取自缓存(无需联网)。
|
||||
let bundle = manager
|
||||
.get_valid_token_bundle_for_account("acc-1")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(bundle.refresh_token, "rotated-rt");
|
||||
assert_eq!(bundle.access_token, "access-cached");
|
||||
assert_eq!(bundle.id_token.as_deref(), Some("id-2"));
|
||||
// 存储里的 refresh_token / id_token 已更新为盘上(CLI 轮换后)的值。
|
||||
{
|
||||
let accounts = manager.accounts.read().await;
|
||||
let account = accounts.get("acc-1").expect("account present");
|
||||
assert_eq!(account.refresh_token, "rotated-rt");
|
||||
assert_eq!(account.id_token.as_deref(), Some("id-2"));
|
||||
}
|
||||
// 采纳后清掉了该账号的缓存 access_token,以便下次按新 refresh_token 重取
|
||||
// (因此这里不再用 get_valid_token_bundle_for_account 断言——它会触发联网刷新)。
|
||||
assert!(
|
||||
!manager.access_tokens.read().await.contains_key("acc-1"),
|
||||
"adopt should invalidate the cached access token"
|
||||
);
|
||||
|
||||
// 未知账号不接管。
|
||||
assert!(!manager
|
||||
|
||||
@@ -965,6 +965,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
|
||||
|
||||
@@ -1711,27 +1711,6 @@ impl ProviderService {
|
||||
.filter(|id| !id.is_empty())
|
||||
}
|
||||
|
||||
/// 切走托管 Codex provider 时,清理其残留在 `~/.codex/auth.json` 的托管登录。
|
||||
///
|
||||
/// 但**跳过**「新 provider 自身就写入了同一账号 ChatGPT 登录」的情况(例如非托管
|
||||
/// 官方 provider 保存了该账号的原生登录)——那是它刚写入的有效 auth,不应误删。
|
||||
fn clear_stale_managed_codex_live_auth(
|
||||
new_provider: &Provider,
|
||||
account_id: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let new_provider_owns_same_login =
|
||||
new_provider
|
||||
.settings_config
|
||||
.get("auth")
|
||||
.is_some_and(|auth| {
|
||||
crate::codex_config::codex_live_auth_is_managed_chatgpt_login(auth, account_id)
|
||||
});
|
||||
if new_provider_owns_same_login {
|
||||
return Ok(());
|
||||
}
|
||||
crate::codex_config::clear_codex_live_auth_for_managed_account(account_id)
|
||||
}
|
||||
|
||||
/// 提交 current(settings/DB)前的预检:若目标是托管 Codex official provider,
|
||||
/// 先解析一次有效 live 配置(会联网换取并缓存 token)。失败即返回 Err,从而避免
|
||||
/// 留下「DB/UI 指向新 provider,但 live 仍是旧 provider」的不一致状态;后续真正
|
||||
@@ -2165,7 +2144,7 @@ impl ProviderService {
|
||||
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
|
||||
|
||||
if let Some(account_id) = unbound_codex_managed_account_id.as_deref() {
|
||||
Self::clear_stale_managed_codex_live_auth(&provider, account_id)?;
|
||||
crate::codex_config::clear_codex_live_auth_for_managed_account(account_id)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2182,7 +2161,7 @@ impl ProviderService {
|
||||
} else {
|
||||
write_live_with_common_config_for_state(state, &app_type, &provider)?;
|
||||
if let Some(account_id) = unbound_codex_managed_account_id.as_deref() {
|
||||
Self::clear_stale_managed_codex_live_auth(&provider, account_id)?;
|
||||
crate::codex_config::clear_codex_live_auth_for_managed_account(account_id)?;
|
||||
}
|
||||
// Sync MCP
|
||||
McpService::sync_all_enabled(state)?;
|
||||
@@ -2515,7 +2494,7 @@ impl ProviderService {
|
||||
&& Self::managed_codex_oauth_account_id(provider).is_none()
|
||||
{
|
||||
if let Some(account_id) = current_managed_codex_account_id.as_deref() {
|
||||
Self::clear_stale_managed_codex_live_auth(provider, account_id)?;
|
||||
crate::codex_config::clear_codex_live_auth_for_managed_account(account_id)?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1170,48 +1170,6 @@ impl ProxyService {
|
||||
}
|
||||
|
||||
/// 备份各应用的 Live 配置
|
||||
/// 若当前 Codex provider 是托管账号绑定,则把待备份的 live 配置中属于该账号的
|
||||
/// ChatGPT 登录 auth 清空。托管账号的可刷新 token 由 CodexOAuthManager 集中保管,
|
||||
/// 绝不应持久化进 Live 备份(否则完整 bundle 的 refresh_token 会落进 DB 备份槽)。
|
||||
fn sanitize_codex_backup_auth(&self, config: &mut Value) {
|
||||
let Some(current_id) =
|
||||
crate::settings::get_effective_current_provider(&self.db, &AppType::Codex)
|
||||
.ok()
|
||||
.flatten()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Ok(providers) = self.db.get_all_providers(AppType::Codex.as_str()) else {
|
||||
return;
|
||||
};
|
||||
let Some(provider) = providers.get(¤t_id) else {
|
||||
return;
|
||||
};
|
||||
if provider.category.as_deref() != Some("official") {
|
||||
return;
|
||||
}
|
||||
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;
|
||||
};
|
||||
let should_strip = config
|
||||
.get("auth")
|
||||
.map(|auth| {
|
||||
crate::codex_config::codex_live_auth_is_managed_chatgpt_login(auth, &account_id)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if should_strip {
|
||||
if let Some(obj) = config.as_object_mut() {
|
||||
obj.insert("auth".to_string(), json!({}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn backup_live_configs(&self) -> Result<(), String> {
|
||||
// Claude
|
||||
if let Ok(config) = self.read_claude_live() {
|
||||
@@ -1232,11 +1190,10 @@ impl ProxyService {
|
||||
}
|
||||
|
||||
// Codex
|
||||
if let Ok(mut config) = self.read_codex_live() {
|
||||
if let Ok(config) = self.read_codex_live() {
|
||||
if Self::live_has_proxy_placeholder_for_app(&AppType::Codex, &config) {
|
||||
log::warn!("codex Live 已被代理接管,不备份(避免把代理配置固化进备份槽);下次 stop 会从 SSOT 重建 Live");
|
||||
} else {
|
||||
self.sanitize_codex_backup_auth(&mut config);
|
||||
let json_str = serde_json::to_string(&config)
|
||||
.map_err(|e| format!("序列化 Codex 配置失败: {e}"))?;
|
||||
self.db
|
||||
@@ -1266,7 +1223,7 @@ impl ProxyService {
|
||||
|
||||
/// 备份指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)
|
||||
async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {
|
||||
let (app_type_str, mut config) = match app_type {
|
||||
let (app_type_str, config) = match app_type {
|
||||
AppType::Claude => ("claude", self.read_claude_live()?),
|
||||
AppType::Codex => ("codex", self.read_codex_live()?),
|
||||
AppType::Gemini => ("gemini", self.read_gemini_live()?),
|
||||
@@ -1282,11 +1239,6 @@ impl ProxyService {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 托管 Codex 账号的可刷新 token 不入备份(见 sanitize_codex_backup_auth)。
|
||||
if matches!(app_type, AppType::Codex) {
|
||||
self.sanitize_codex_backup_auth(&mut config);
|
||||
}
|
||||
|
||||
let json_str = serde_json::to_string(&config)
|
||||
.map_err(|e| format!("序列化 {app_type_str} 配置失败: {e}"))?;
|
||||
self.db
|
||||
@@ -2143,22 +2095,6 @@ impl ProxyService {
|
||||
&mut effective_settings,
|
||||
)
|
||||
.map_err(|e| format!("注入统一会话路由失败: {e}"))?;
|
||||
|
||||
// 托管账号:build 出的 effective_settings.auth 是完整可刷新 bundle(含
|
||||
// refresh_token),绝不能写进 DB 备份(token 由 CodexOAuthManager 集中保管)。
|
||||
// 覆盖上面的 clear/preserve 分支,统一剥离为占位;恢复时 live 由 manager
|
||||
// 重新派生。
|
||||
let provider_is_managed_codex = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.managed_account_id_for("codex_oauth"))
|
||||
.map(|id| !id.trim().is_empty())
|
||||
.unwrap_or(false);
|
||||
if provider_is_managed_codex {
|
||||
if let Some(obj) = effective_settings.as_object_mut() {
|
||||
obj.insert("auth".to_string(), json!({}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let backup_json = match app_type_enum {
|
||||
@@ -2184,6 +2120,19 @@ impl ProxyService {
|
||||
.await
|
||||
.map_err(|e| format!("更新 {app_type} 备份失败: {e}"))?;
|
||||
|
||||
if matches!(app_type_enum, AppType::Codex)
|
||||
&& provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.managed_account_id_for("codex_oauth"))
|
||||
.is_some()
|
||||
{
|
||||
if let Some(auth) = effective_settings.get("auth") {
|
||||
crate::codex_config::record_codex_managed_oauth_live_auth(auth)
|
||||
.map_err(|e| format!("记录 Codex 托管认证标记失败: {e}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("已更新 {app_type} Live 备份(热切换)");
|
||||
Ok(())
|
||||
}
|
||||
@@ -2366,10 +2315,10 @@ impl ProxyService {
|
||||
return Err("Codex 备份必须是 JSON 对象".to_string());
|
||||
};
|
||||
|
||||
// 托管账号写入的是完整可刷新 bundle(含 refresh_token),必须避免它被
|
||||
// 持久化进 Live 备份。按 account_id 内容判定:命中托管账号则清空备份 auth,
|
||||
// 否则原样保留用户自己的登录以便接管结束后还原。
|
||||
if crate::codex_config::codex_live_auth_is_managed_chatgpt_login(existing_auth, account_id)
|
||||
// 仅当备份里的 auth 是**本应用写入的**托管登录(与 marker 指纹匹配)时才清空;
|
||||
// 用户自己 `codex login` 的原生登录原样保留,以便接管结束后还原。
|
||||
if crate::codex_config::codex_auth_matches_recorded_managed_oauth(existing_auth, account_id)
|
||||
.map_err(|e| format!("检查 Codex 托管认证标记失败: {e}"))?
|
||||
{
|
||||
target_obj.insert("auth".to_string(), json!({}));
|
||||
return Ok(());
|
||||
|
||||
Reference in New Issue
Block a user