fix(accounts): preserve migrated identities and restore cumulative usage

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-11-01 02:19:53 +08:00
co-authored by Codex
parent 6a95869a72
commit aca75490cb
19 changed files with 165 additions and 25 deletions
@@ -117,9 +117,6 @@ impl StartAccountAuthorizationRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
parse_provider(&self.provider)?;
validate_account_name(&self.name)?;
if self.account_id.is_none() && self.name.trim().is_empty() {
return Err(WireValidationError::new("name"));
}
if let Some(account_id) = self.account_id.as_deref() {
require_account_id(account_id, "accountId")?;
}
@@ -141,7 +138,7 @@ impl StartAccountAuthorizationRequest {
provider,
StartAuthorization {
context,
name: self.name,
name: self.name.trim().to_owned(),
reauthorization,
outbound_proxy: super::wire::proxy_selection(
self.outbound_proxy_id,
@@ -767,6 +767,24 @@ mod actions {
}
}
#[test]
fn authorization_accepts_optional_names_with_the_same_text_limits() {
for name in ["", " ", "My account"] {
let request: StartAccountAuthorizationRequest = serde_json::from_value(json!({
"provider": "openai", "name": name
}))
.expect("decode authorization");
assert!(request.validate().is_ok());
}
for name in ["bad\nname".to_owned(), "x".repeat(513)] {
let request: StartAccountAuthorizationRequest = serde_json::from_value(json!({
"provider": "openai", "name": name
}))
.expect("decode authorization");
assert_eq!(request.validate().unwrap_err().field(), "name");
}
}
#[test]
fn credential_recovery_requests_should_not_accept_client_revision_fences() {
let authorization: StartAccountAuthorizationRequest = serde_json::from_value(json!({
@@ -146,7 +146,6 @@ impl NewProviderAccount {
pub fn validate(&self) -> StoreResult<()> {
require_nonempty(ENTITY, "id", &self.id)?;
require_nonempty(ENTITY, "provider_kind", &self.provider_kind)?;
require_nonempty(ENTITY, "name", &self.name)?;
if let Some(upstream_user_id) = &self.upstream_user_id {
require_nonempty(ENTITY, "upstream_user_id", upstream_user_id)?;
}
@@ -9,6 +9,36 @@ use super::{
*,
};
#[tokio::test]
async fn authorization_persists_an_unnamed_account_with_its_email() {
let Some(database) = TestDatabase::create("authorization_unnamed").await else {
return;
};
let store = admin_account_store(&database.pool);
let mut prepared = credential("acct_unnamed", "unnamed-user");
prepared.name.clear();
prepared.email = Some("identity@example.com".into());
let (authorization, context) = command(prepared);
let result = store
.commit_authorization(authorization, &context)
.await
.unwrap();
let account = store
.load_account(
result.result.account_id.as_str(),
AccountRuntimeSnapshot::default(),
)
.await
.unwrap()
.unwrap();
assert_eq!(account.account.name, "");
assert_eq!(
account.account.email.as_deref(),
Some("identity@example.com")
);
database.close().await;
}
#[tokio::test]
async fn concurrent_authorization_retries_share_one_account_write_and_one_receipt() {
let Some(database) = TestDatabase::create("authorization_receipt_race").await else {
@@ -602,9 +602,6 @@ impl CodexCredentialAdmin {
.map_err(|_| CodexCredentialAdminError::InvalidInput)?;
let provider = ProviderKind::new(PROVIDER_NAME)
.map_err(|_| CodexCredentialAdminError::InvalidInput)?;
if input.name.trim().is_empty() {
return Err(CodexCredentialAdminError::InvalidInput);
}
let revision =
CredentialRevision::new(1).map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
let CodexOAuthMetadata {
@@ -184,8 +184,7 @@ impl CodexPendingAuthorization {
if !valid_text(&pending.flow_id)
|| !valid_text(&pending.owner_ref)
|| !valid_text(&pending.started_request_ref)
|| !(valid_text(&pending.name)
|| pending.name.is_empty() && pending.reauthorization.is_some())
|| !(valid_text(&pending.name) || pending.name.is_empty())
|| pending.expires_at <= Utc::now()
|| !valid_secret(pending.state.expose_secret())
|| !valid_secret(pending.nonce.expose_secret())
@@ -590,7 +589,7 @@ impl CodexOAuthAdminService {
reauthorization: Option<ProviderAccountId>,
installation_id: String,
) -> Result<CodexOAuthAuthorizationStarted, CodexOAuthAdminError> {
if !(valid_text(&name) || name.is_empty() && reauthorization.is_some()) {
if !(valid_text(&name) || name.is_empty()) {
return Err(CodexOAuthAdminError::InvalidInput);
}
let expires_at = Utc::now()
@@ -156,6 +156,13 @@ fn id_token(payload: serde_json::Value) -> String {
async fn complete(
id_token: String,
) -> Result<(CompletedCodexOAuthCredential, String, String), CodexOAuthAdminError> {
complete_with_name(id_token, "new OAuth account").await
}
async fn complete_with_name(
id_token: String,
name: &str,
) -> Result<(CompletedCodexOAuthCredential, String, String), CodexOAuthAdminError> {
let pending_store = Arc::new(PendingStore::default());
let service = CodexOAuthAdminService::new(
@@ -167,7 +174,13 @@ async fn complete(
);
let started = service
.start_authorization(StartCodexOAuthAuthorization {
mutation: mutation(),
mutation: PendingAuthorizationMutation::new(
ProviderKind::new("openai").expect("provider"),
AuthorizationMutationTarget::Create {
name: name.to_owned(),
},
mutation().owner_binding().clone(),
),
})
.await
.expect("start OAuth authorization");
@@ -463,6 +476,23 @@ async fn repeated_reauthorization_preserves_surface_id_with_a_cleared_name() {
assert_eq!(surface_ids[0], surface_ids[1]);
}
#[tokio::test]
async fn first_exchange_preserves_optional_local_name_and_upstream_email() {
for name in ["", "My account"] {
let (credential, _, _) = complete_with_name(
id_token(serde_json::json!({ "email": "identity@example.com" })),
name,
)
.await
.expect("complete authorization with optional name");
let CompletedCodexOAuthCredential::Create(account) = credential else {
panic!("expected account creation");
};
assert_eq!(account.account.name(), name);
assert_eq!(account.account.email(), Some("identity@example.com"));
}
}
#[tokio::test]
async fn first_exchange_persists_the_installation_id_without_reusing_it_as_the_surface_id() {
let (credential, pending_installation_id, surface_stable_id) = complete(id_token(
@@ -843,7 +843,7 @@ fn validate_scope(scope: &str) -> Result<(), GrokCredentialRepositoryError> {
}
fn validate_name(value: &str) -> Result<(), GrokCredentialRepositoryError> {
if value.trim().is_empty()
if (!value.is_empty() && value.trim().is_empty())
|| value.len() > MAX_NAME_BYTES
|| value.chars().any(char::is_control)
{
@@ -18,6 +18,18 @@ fn repository_rejects_identity_that_cannot_be_sent_as_official_header() {
));
}
#[tokio::test]
async fn create_should_allow_an_empty_local_name() {
let store = MemoryProviderAccountStore::shared();
let mut input = create_input("unnamed", "subject-unnamed");
input.name.clear();
seed_input(&store, &input)
.await
.expect("seed unnamed account");
let account = store.get_account(&input.account_id).await.unwrap().unwrap();
assert_eq!(account.name(), "");
}
#[tokio::test]
async fn lifecycle_projection_should_return_expiry_without_oauth_secrets() {
let store = MemoryProviderAccountStore::shared();
+1
View File
@@ -33,3 +33,4 @@ a0e409402611dbc76bb71393b8eda7c717756c18974dbabca305fdcb4b115f31 0032_usage_and
592b80972af3bd8b3527b42da3703ca1106ecaf8fd5273c676189ca28fcbd067 0033_proxy_and_reset_credits.sql
9c15a6b6c1f9eab8ad478772f98f8fc4a997b825d58d12cffe1a15579c5785b3 0034_account_error_protection.sql
bd73379456839e8a12d7591ad2a839901af0f5ba27871248569abdfa26bc83ef 0035_cpr_link_and_concurrency_defaults.sql
d05442ee4cb85db8131e015dbed9c6ad988f93306abcda34647317674857ee44 0036_account_display_names.sql
@@ -0,0 +1,27 @@
-- 默认授权名称不代表用户命名,清空后沿用邮箱展示
-- 已有改名审计或没有邮箱的账号保留原值
with migrated as (
update provider_accounts as account
set name = '', updated_at = greatest(now(), account.updated_at)
where account.authentication_kind = 'oauth'
and nullif(btrim(account.email), '') is not null
and (
(account.provider_kind = 'openai' and account.name in (
'openai OAuth', 'OpenAI 账号', 'OpenAI 賬號', 'OpenAI account',
'Codex 账号', 'Codex 賬號', 'Codex account'
))
or (account.provider_kind = 'xai' and account.name in (
'xai OAuth', 'xAI 账号', 'xAI 賬號', 'xAI account'
))
)
and not exists (
select 1 from admin_audit_events as audit
where audit.entity_kind = 'provider_account'
and audit.entity_ref = account.id
and 'name' = any(audit.changed_fields)
)
returning account.id
)
update runtime_settings
set config_revision = config_revision + 1, updated_at = greatest(now(), updated_at)
where exists (select 1 from migrated);
+6
View File
@@ -665,6 +665,12 @@ CPR_BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
docker compose -f deploy/compose.yaml build codex-proxy-rs
```
### 账号名称迁移
OAuth 账号仍使用默认授权名称(如「Codex 账号」「OpenAI 账号」「xAI 账号」)且保存了邮箱时,
升级会清空默认名称,账号列表按邮箱前缀和完整邮箱分两行展示。
自定义名称、已有改名审计记录的账号、无邮箱账号及 API Key 名称保留原值
### 错误保护配置升级
使用 `accountAutoFreeze*` 运行参数的部署升级时,数据库迁移删除全部旧保护设置,不转换规则或继承参数。
+3
View File
@@ -1403,6 +1403,9 @@ OAuth start 使用:
客户端不得提交 `credentialRevision`、旧 token 身份或其他并发控制字段。complete 请求也不重复提交
`accountId`,后端通过 `flowId` 中保存的目标绑定完成授权
`name` 可为空字符串,表示不设置本地名称;管理端新建授权默认留空,列表使用上游邮箱展示。
提供名称时去除首尾空白,长度与字符限制沿用账号名称规则
流程只允许发起它的管理员身份继续;尚未完成的流程过期后需重新发起。
账号提交成功后,`complete` 保留 24 小时的完成结果。同一管理员使用相同 `provider`、`flowId`
重试时返回原账号结果,不再执行授权或更新凭据;新提交的 `callbackUrl` 和 `settings` 不会再次应用
@@ -355,7 +355,6 @@
"importTasksCreated": "Import tasks created: {submitted}",
"importTaskCreated": "Import task created",
"partialSubmission": "Some import tasks were created, retry with the same input to submit the rest",
"accountName": "{provider} account",
"failedToStartAuthorization": "Failed to start authorization"
},
"importTasks": {
@@ -355,7 +355,6 @@
"importTasksCreated": "已创建 {submitted} 个导入任务",
"importTaskCreated": "导入任务已创建",
"partialSubmission": "部分导入任务已创建,保持输入不变重试可继续提交",
"accountName": "{provider} 账号",
"failedToStartAuthorization": "启动授权失败"
},
"importTasks": {
@@ -355,7 +355,6 @@
"importTasksCreated": "已創建 {submitted} 個導入任務",
"importTaskCreated": "導入任務已創建",
"partialSubmission": "部分導入任務已創建,保持輸入不變重試可繼續提交",
"accountName": "{provider} 賬號",
"failedToStartAuthorization": "啓動授權失敗"
},
"importTasks": {
@@ -29,17 +29,31 @@ const props = withDefaults(
},
)
const displayTitle = computed(() => accountIdentityLabel(props.account))
const emailText = computed(() => props.account.email?.trim())
const displayTitle = computed(() => {
const label = accountIdentityLabel(props.account)
return props.account.authenticationKind !== 'api_key' && label === emailText.value
? label.split('@')[0]
: label
})
const visibleNotes = computed(() => props.showNotes ? props.account.notes : undefined)
const identityTitle = computed(() => {
const email = props.account.email?.trim()
const email = emailText.value
return email && email !== displayTitle.value
? `${displayTitle.value}\n${email}`
: displayTitle.value
})
const secondaryText = computed(() => props.account.authenticationKind !== 'api_key' && emailText.value !== displayTitle.value
? emailText.value
: undefined)
const secondaryClass = computed(() => props.size === 'lg'
? 'mt-1 text-z-sm text-z-text-secondary'
: 'mt-0.5 font-mono text-z-xs text-z-text-quaternary')
const initial = computed(() => displayTitle.value.slice(0, 1).toUpperCase())
const avatarSizeClass = computed(() =>
@@ -89,6 +103,9 @@ const avatarToneClass = computed(() => {
<AccountPlanBadge v-if="showPlan" :upstream="account.upstream" :authentication-kind="account.authenticationKind" :plan-type="account.planType" :plan-type-display="account.planTypeDisplay" :size="metaSize" />
</div>
<AccountNotesPopover v-else-if="visibleNotes" :notes="visibleNotes" class="mt-0.5" />
<div v-else-if="secondaryText" class="truncate font-emphasis" :class="secondaryClass">
{{ secondaryText }}
</div>
</div>
</div>
</template>
@@ -18,6 +18,8 @@ const emit = defineEmits<{ showConcurrency: [] }>()
const { t } = useI18n({ useScope: 'global' })
const localUsageOnly = computed(() => props.account.authenticationKind === 'api_key' && !props.account.upstream?.discovered)
// 互联列表只展示此 Key 的消费限额,池内各账号的百分比不能代表整个上游
const quotaWindows = computed(() => props.account.upstream?.discovered
? props.account.quota.windows.filter(window => window.amount)
@@ -57,11 +59,11 @@ const additionalEntryCount = computed(() => Math.max(summaryEntries.value.length
<template>
<div class="box-border grid min-h-16.5 w-full min-w-0 content-center gap-1.5 py-1.5">
<div
v-if="hasTokenUsage || currentUsageWindow"
v-if="localUsageOnly || hasTokenUsage || currentUsageWindow"
class="flex min-w-0 items-baseline justify-between gap-2 leading-none"
>
<span
v-if="hasTokenUsage"
v-if="localUsageOnly || hasTokenUsage"
class="flex min-w-0 items-baseline gap-1 font-mono tabular-nums"
:title="summaryValue.title"
>
@@ -84,7 +86,14 @@ const additionalEntryCount = computed(() => Math.max(summaryEntries.value.length
</span>
</div>
<div class="flex min-w-0 items-end gap-2">
<div v-if="localUsageOnly" class="grid min-w-0 gap-1.5">
<div class="flex min-w-0 items-start justify-between gap-2 leading-3">
<span class="truncate text-[10px] font-bold text-z-text-quaternary">{{ usageWindowLabels[account.usage.windowKind] }}</span>
<AccountCapacityIndicator :capacity="account.capacity" @show-details="emit('showConcurrency')" />
</div>
<div class="h-1 w-full rounded-full bg-z-border-secondary" aria-hidden="true" />
</div>
<div v-else class="flex min-w-0 items-end gap-2">
<!-- 共用列轨道为容量留位,进度条仍铺满整行,容量标记独立于额度弹层 -->
<div class="grid min-w-0 flex-1 grid-cols-[minmax(0,1fr)_auto] gap-x-2">
<AccountQuotaSummaryEntry
@@ -5,7 +5,7 @@ import { useAsyncAction } from '@/composables/useAsyncAction'
import { t } from '@/i18n'
import { notification as ZNotification } from '@/utils/notification'
import { errorMessage, generateRequestId } from '@/utils/operation'
import { formatProviderLabel, isSupportedProvider } from '@/utils/providers'
import { isSupportedProvider } from '@/utils/providers'
import { accountCreateProvider, accountCreateSourceKey, accountImportModes, accountImportSettings, emptyAccountCreateForm } from '../utils/accountCreate'
import { accountImportBatches, accountImportDocuments, mixedImportDocuments } from '../utils/accountImport'
import { emptyApiKeyAccountForm } from '../utils/upstreamApiKey'
@@ -134,9 +134,7 @@ export function useAccountOnboarding(options: {
await authorization.start({
start: {
provider,
name: account?.name || account?.email || t('accounts.onboarding.accountName', {
provider: formatProviderLabel(provider),
}),
name: account?.name ?? '',
accountId: account?.id,
outboundProxyId: account
? undefined