@foxden-app/foxclaw 0.5.11 → 0.5.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  All notable FoxClaw changes are listed here. Each release note is bilingual so GitHub Releases and the npm package are useful to both Chinese and English readers.
4
4
 
5
+ ## 0.5.13 - 2026-06-08
6
+
7
+ ### 中文
8
+ - `auth.json_team_<localpart>` 候选现在会校验文件内 ChatGPT email localpart 是否匹配候选名;不匹配时 `/auth` 标为无效,避免继续显示另一位 seat 的额度。
9
+ - 本机 auth mirror 不再传播 team 候选名与文件身份不一致的 auth,并会在启动 reconcile 时用仍然匹配候选名的 runtime 副本修复错误副本。
10
+ - `/auth refresh all` 和当前候选额度刷新会跳过身份与 `team_` 候选名不匹配的文件,避免脏额度快照再次写入。
11
+
12
+ ### English
13
+ - `auth.json_team_<localpart>` candidates now verify that the ChatGPT email local part inside the auth file matches the candidate name; mismatches are marked invalid in `/auth` instead of displaying another seat's quota.
14
+ - The local auth mirror no longer propagates team candidates whose filename identity and auth payload disagree, and startup reconciliation can repair bad copies from a runtime copy that still matches the candidate name.
15
+ - `/auth refresh all` and current-candidate quota refresh now skip files whose identity does not match the `team_` candidate name, preventing dirty quota snapshots from being recorded again.
16
+
17
+ ## 0.5.12 - 2026-06-08
18
+
19
+ ### 中文
20
+ - `/auth` 额度快照现在按 ChatGPT 额度身份合并,优先区分 `chatgpt_user_id`,其次区分 email,避免同一个 Team account 下不同 seat 显示成同一份额度。
21
+ - 本机 auth mirror、跨节点 auth sync 和 `/auth refresh all` 会拒绝可识别为不同 ChatGPT 用户/邮箱的同名候选互相覆盖,即使它们共享同一个 account id。
22
+ - 额度快照数据库新增 `quota_identity_id` 并自动兼容旧数据;文档同步说明 account id 与额度身份的区别。
23
+
24
+ ### English
25
+ - `/auth` quota snapshots now merge by ChatGPT quota identity, preferring `chatgpt_user_id` and then email, so different seats under the same Team account do not display as one shared quota.
26
+ - Same-node auth mirroring, cross-node auth sync, and `/auth refresh all` now refuse to overwrite same-name candidates when they are identifiable as different ChatGPT users/emails, even if they share the same account id.
27
+ - Added a `quota_identity_id` quota-snapshot migration with backward compatibility for old data, and updated docs to distinguish account id from quota identity.
28
+
5
29
  ## 0.5.11 - 2026-06-08
6
30
 
7
31
  ### 中文
package/README.md CHANGED
@@ -105,7 +105,7 @@ FoxClaw 只响应 `TG_ALLOWED_USER_ID` 的消息——把机器人拉进群不
105
105
  **多账号管理:**
106
106
  - Codex 账户管理:`/account`、`/quota`、`/login_device`、`/auth add <name>`
107
107
  - 触发用量限制时自动在本地 `auth.json_*` 之间切换认证
108
- - `/auth` 面板分页查看、筛选、启用、禁用、搜索和切换候选账号;额度按真实窗口展示,多 bot 模式下会按账号 ID 汇总各 runtime 最近掌握的额度快照
108
+ - `/auth` 面板分页查看、筛选、启用、禁用、搜索和切换候选账号;额度按真实窗口展示,多 bot 模式下会按 ChatGPT 额度身份汇总各 runtime 最近掌握的额度快照
109
109
 
110
110
  **线程与会话:**
111
111
  - `/threads`、`/open`、`/new`、`/where`、`/interrupt`——稳定的聊天-线程绑定
package/README_EN.md CHANGED
@@ -105,7 +105,7 @@ FoxClaw accepts messages only from `TG_ALLOWED_USER_ID`. Putting the bot in a gr
105
105
  **Multi-account management:**
106
106
  - Codex account controls: `/account`, `/quota`, `/login_device`, `/auth add <name>`
107
107
  - Automatic auth rotation across local `auth.json_*` files when a usage limit is hit — seamless account switching
108
- - Paginated `/auth` panel to filter, enable, disable, search, and switch candidate accounts; quota uses observed window lengths, and multi-bot mode merges recent snapshots by account ID across runtimes
108
+ - Paginated `/auth` panel to filter, enable, disable, search, and switch candidate accounts; quota uses observed window lengths, and multi-bot mode merges recent snapshots by ChatGPT quota identity across runtimes
109
109
 
110
110
  **Threads and sessions:**
111
111
  - `/threads`, `/open`, `/new`, `/where`, `/interrupt` — sticky chat-to-thread binding
@@ -226,6 +226,7 @@ export declare class CrossNodeAuthSync {
226
226
  publishDigest(): Promise<void>;
227
227
  requestRecovery(candidateName: string, current?: {
228
228
  accountId: string | null;
229
+ quotaIdentityId?: string | null;
229
230
  lastRefreshMs: number | null;
230
231
  }): Promise<boolean>;
231
232
  acquireRefreshLease(reason: string): Promise<AuthSyncLeaseResult>;
@@ -275,6 +275,7 @@ export class CrossNodeAuthSync {
275
275
  requestId,
276
276
  candidateName,
277
277
  accountId: current?.accountId ?? null,
278
+ quotaIdentityId: current?.quotaIdentityId ?? null,
278
279
  lastRefreshMs: current?.lastRefreshMs ?? null,
279
280
  }).catch((error) => {
280
281
  clearTimeout(timer);
@@ -519,6 +520,18 @@ export class CrossNodeAuthSync {
519
520
  });
520
521
  return;
521
522
  }
523
+ if (!quotaIdentitiesCompatible(record.accountId, record.quotaIdentityId, message.quotaIdentityId ?? null)) {
524
+ await this.sendToPeer(peer, { kind: 'pull.response', requestId: message.requestId, bundle: null, reason: 'quota identity mismatch' });
525
+ this.recordEvent({ direction: 'local', kind: 'pull.response', stage: 'account_mismatch', peer, requestId: message.requestId, candidateName: message.candidateName, detail: 'quota identity mismatch' });
526
+ this.notify({
527
+ kind: 'pull_response_sent',
528
+ candidateName: message.candidateName,
529
+ peer,
530
+ result: 'account_mismatch',
531
+ reason: 'quota identity mismatch',
532
+ });
533
+ return;
534
+ }
522
535
  if (message.lastRefreshMs !== null && record.lastRefreshMs <= message.lastRefreshMs) {
523
536
  await this.sendToPeer(peer, { kind: 'pull.response', requestId: message.requestId, bundle: null, reason: 'not newer' });
524
537
  this.recordEvent({ direction: 'local', kind: 'pull.response', stage: 'not_newer', peer, requestId: message.requestId, candidateName: message.candidateName, detail: 'not newer' });
@@ -728,6 +741,9 @@ export class CrossNodeAuthSync {
728
741
  if (!metadata || metadata.accountId !== bundle.accountId || metadata.lastRefreshMs !== bundle.lastRefreshMs) {
729
742
  return this.rejectImport(bundle, sourceNodeId, source, fromPeer, mode, `remote bundle metadata mismatch for ${bundle.candidateName}`);
730
743
  }
744
+ if (bundle.quotaIdentityId && metadata.quotaIdentityId !== bundle.quotaIdentityId) {
745
+ return this.rejectImport(bundle, sourceNodeId, source, fromPeer, mode, `remote bundle quota identity mismatch for ${bundle.candidateName}`);
746
+ }
731
747
  if (sha256(bundle.rawAuth) !== bundle.authSha256) {
732
748
  return this.rejectImport(bundle, sourceNodeId, source, fromPeer, mode, `remote bundle hash mismatch for ${bundle.candidateName}`);
733
749
  }
@@ -1078,6 +1094,7 @@ function bundleFromRecord(record) {
1078
1094
  return {
1079
1095
  candidateName: record.candidateName,
1080
1096
  accountId: record.accountId,
1097
+ quotaIdentityId: record.quotaIdentityId,
1081
1098
  lastRefreshMs: record.lastRefreshMs,
1082
1099
  rawAuth: record.raw,
1083
1100
  authSha256: sha256(record.raw),
@@ -1087,11 +1104,18 @@ function isValidBundle(value) {
1087
1104
  return typeof value.candidateName === 'string'
1088
1105
  && isAuthCandidateName(value.candidateName)
1089
1106
  && typeof value.accountId === 'string'
1107
+ && (value.quotaIdentityId === undefined || value.quotaIdentityId === null || typeof value.quotaIdentityId === 'string')
1090
1108
  && typeof value.lastRefreshMs === 'number'
1091
1109
  && Number.isFinite(value.lastRefreshMs)
1092
1110
  && typeof value.rawAuth === 'string'
1093
1111
  && typeof value.authSha256 === 'string';
1094
1112
  }
1113
+ function quotaIdentitiesCompatible(accountId, left, right) {
1114
+ if (!left || !right || left === accountId || right === accountId) {
1115
+ return true;
1116
+ }
1117
+ return left === right;
1118
+ }
1095
1119
  function decodeSharedKey(raw) {
1096
1120
  const value = raw.trim();
1097
1121
  const candidates = [];
@@ -32,6 +32,9 @@ export interface AuthMirrorValidationResult {
32
32
  }
33
33
  export interface ChatGptAuthMetadata {
34
34
  accountId: string;
35
+ quotaIdentityId: string;
36
+ userId: string | null;
37
+ email: string | null;
35
38
  lastRefreshMs: number;
36
39
  }
37
40
  export interface ChatGptAuthRecord extends ChatGptAuthMetadata {
@@ -106,3 +109,4 @@ export declare function isAuthCandidateName(name: string): boolean;
106
109
  export declare function readChatGptAuthRecord(filePath: string): Promise<ChatGptAuthRecord | null>;
107
110
  export declare function readChatGptAuthMetadata(filePath: string): Promise<ChatGptAuthMetadata | null>;
108
111
  export declare function parseChatGptAuthMetadata(raw: string): ChatGptAuthMetadata | null;
112
+ export declare function chatGptAuthMetadataMatchesCandidateName(candidateName: string, metadata: Pick<ChatGptAuthMetadata, 'email'>): boolean;
@@ -159,9 +159,18 @@ export class AuthCandidateMirror {
159
159
  const destination = await readChatGptAuthRecord(destinationPath);
160
160
  if (!destination)
161
161
  return null;
162
+ const destinationMatchesName = chatGptAuthMetadataMatchesCandidateName(candidateName, destination);
162
163
  const sources = await this.collectAuthRecords();
163
164
  const newest = sources
164
- .filter(entry => entry.record.accountId === destination.accountId)
165
+ .filter((entry) => {
166
+ if (!chatGptAuthMetadataMatchesCandidateName(candidateName, entry.record)) {
167
+ return false;
168
+ }
169
+ if (!destinationMatchesName) {
170
+ return entry.candidateName === candidateName;
171
+ }
172
+ return authRecordsCompatible(entry.record, destination);
173
+ })
165
174
  .reduce((current, entry) => (!current || entry.record.lastRefreshMs > current.record.lastRefreshMs ? entry : current), null);
166
175
  if (!newest || newest.record.lastRefreshMs <= destination.lastRefreshMs) {
167
176
  return null;
@@ -200,10 +209,17 @@ export class AuthCandidateMirror {
200
209
  skipped += this.runtimes.length;
201
210
  continue;
202
211
  }
212
+ if (!chatGptAuthMetadataMatchesCandidateName(name, canonical)) {
213
+ skipped += this.runtimes.length;
214
+ this.logger.warn('auth.mirror.distribution_identity_mismatch', { name });
215
+ continue;
216
+ }
203
217
  for (const runtime of this.runtimes) {
204
218
  const destinationPath = path.join(runtime.authDir, name);
205
219
  const destination = await readChatGptAuthRecord(destinationPath);
206
- if (destination && destination.accountId !== canonical.accountId) {
220
+ if (destination
221
+ && chatGptAuthMetadataMatchesCandidateName(name, destination)
222
+ && !authRecordsCompatible(destination, canonical)) {
207
223
  skipped += 1;
208
224
  this.logger.warn('auth.mirror.distribution_conflict', { runtimeId: runtime.id, name });
209
225
  continue;
@@ -232,11 +248,14 @@ export class AuthCandidateMirror {
232
248
  if (!metadata) {
233
249
  return { ok: false, imported: false, reason: 'invalid ChatGPT auth payload' };
234
250
  }
251
+ if (!chatGptAuthMetadataMatchesCandidateName(candidateName, metadata)) {
252
+ return { ok: false, imported: false, reason: 'candidate auth identity does not match candidate name' };
253
+ }
235
254
  const existing = (await this.collectAuthRecords())
236
- .filter(entry => entry.candidateName === candidateName);
237
- const conflicting = existing.find(entry => entry.record.accountId !== metadata.accountId);
255
+ .filter(entry => entry.candidateName === candidateName && chatGptAuthMetadataMatchesCandidateName(candidateName, entry.record));
256
+ const conflicting = existing.find(entry => !authRecordsCompatible(entry.record, metadata));
238
257
  if (conflicting) {
239
- return { ok: false, imported: false, reason: 'same candidate belongs to a different account' };
258
+ return { ok: false, imported: false, reason: 'same candidate belongs to a different account or ChatGPT user' };
240
259
  }
241
260
  const newest = existing.reduce((current, entry) => (!current || entry.record.lastRefreshMs > current.record.lastRefreshMs ? entry : current), null);
242
261
  const previousRefresh = Math.max(newest?.record.lastRefreshMs ?? 0, this.lastSyncedRefresh.get(candidateName) ?? 0);
@@ -286,9 +305,15 @@ export class AuthCandidateMirror {
286
305
  const record = await readChatGptAuthRecord(sourcePath);
287
306
  if (!record)
288
307
  return false;
308
+ if (!chatGptAuthMetadataMatchesCandidateName(name, record)) {
309
+ this.logger.warn('auth.mirror.identity_mismatch', { runtimeId: runtime.id, name });
310
+ return false;
311
+ }
289
312
  const canonicalPath = path.join(this.canonicalDir, name);
290
313
  const canonical = await readChatGptAuthRecord(canonicalPath);
291
- if (canonical && canonical.accountId !== record.accountId) {
314
+ if (canonical
315
+ && chatGptAuthMetadataMatchesCandidateName(name, canonical)
316
+ && !authRecordsCompatible(canonical, record)) {
292
317
  this.logger.warn('auth.mirror.account_conflict', { runtimeId: runtime.id, name });
293
318
  return false;
294
319
  }
@@ -315,7 +340,15 @@ export class AuthCandidateMirror {
315
340
  await atomicWrite(canonicalPath, record.raw);
316
341
  for (const target of this.runtimes) {
317
342
  if (target.id !== runtime.id) {
318
- await atomicWrite(path.join(target.authDir, name), record.raw);
343
+ const targetPath = path.join(target.authDir, name);
344
+ const targetRecord = await readChatGptAuthRecord(targetPath);
345
+ if (targetRecord
346
+ && chatGptAuthMetadataMatchesCandidateName(name, targetRecord)
347
+ && !authRecordsCompatible(targetRecord, record)) {
348
+ this.logger.warn('auth.mirror.target_conflict', { sourceRuntimeId: runtime.id, targetRuntimeId: target.id, name });
349
+ continue;
350
+ }
351
+ await atomicWrite(targetPath, record.raw);
319
352
  }
320
353
  }
321
354
  this.lastSyncedRefresh.set(name, record.lastRefreshMs);
@@ -422,12 +455,17 @@ export class AuthCandidateMirror {
422
455
  })))).filter((entry) => entry.record !== null);
423
456
  if (records.length === 0)
424
457
  return;
425
- const accountIds = new Set(records.map((entry) => entry.record.accountId));
426
- if (accountIds.size !== 1) {
458
+ const trustedRecords = records.filter(entry => chatGptAuthMetadataMatchesCandidateName(name, entry.record));
459
+ if (trustedRecords.length === 0) {
460
+ this.logger.warn('auth.mirror.startup_identity_mismatch', { name });
461
+ return;
462
+ }
463
+ const reference = trustedRecords[0].record;
464
+ if (trustedRecords.some(entry => !authRecordsCompatible(reference, entry.record))) {
427
465
  this.logger.warn('auth.mirror.startup_conflict', { name });
428
466
  return;
429
467
  }
430
- const newest = records.reduce((current, entry) => (entry.record.lastRefreshMs > current.record.lastRefreshMs ? entry : current));
468
+ const newest = trustedRecords.reduce((current, entry) => (entry.record.lastRefreshMs > current.record.lastRefreshMs ? entry : current));
431
469
  this.lastSyncedRefresh.set(name, newest.record.lastRefreshMs);
432
470
  for (const destination of paths) {
433
471
  await atomicWrite(destination, newest.record.raw);
@@ -544,7 +582,92 @@ export function parseChatGptAuthMetadata(raw) {
544
582
  const lastRefreshMs = typeof parsed.last_refresh === 'string' ? Date.parse(parsed.last_refresh) : NaN;
545
583
  if (!accountId || !Number.isFinite(lastRefreshMs))
546
584
  return null;
547
- return { accountId, lastRefreshMs };
585
+ const accessClaims = decodeJwtPayload(parsed.tokens?.access_token);
586
+ const idClaims = decodeJwtPayload(parsed.tokens?.id_token);
587
+ const userId = firstStringClaim(accessClaims, idClaims, 'https://api.openai.com/auth.chatgpt_user_id', 'https://api.openai.com/auth.user_id');
588
+ const email = firstStringClaim(accessClaims, idClaims, 'https://api.openai.com/profile.email', 'email');
589
+ return {
590
+ accountId,
591
+ quotaIdentityId: chatGptQuotaIdentityId(accountId, userId, email),
592
+ userId,
593
+ email,
594
+ lastRefreshMs,
595
+ };
596
+ }
597
+ catch {
598
+ return null;
599
+ }
600
+ }
601
+ export function chatGptAuthMetadataMatchesCandidateName(candidateName, metadata) {
602
+ const expectedLocalPart = expectedTeamCandidateEmailLocalPart(candidateName);
603
+ if (!expectedLocalPart || !metadata.email) {
604
+ return true;
605
+ }
606
+ const actualLocalPart = metadata.email.split('@', 1)[0]?.trim().toLowerCase() ?? '';
607
+ return actualLocalPart === expectedLocalPart;
608
+ }
609
+ function expectedTeamCandidateEmailLocalPart(candidateName) {
610
+ const prefix = 'auth.json_team_';
611
+ if (!candidateName.startsWith(prefix)) {
612
+ return null;
613
+ }
614
+ const raw = candidateName.slice(prefix.length).trim().toLowerCase();
615
+ if (!raw || raw.includes('/') || raw.includes('\\')) {
616
+ return null;
617
+ }
618
+ const localPart = raw.includes('@') ? raw.split('@', 1)[0] : raw;
619
+ return /^[a-z0-9][a-z0-9._+-]*$/.test(localPart) ? localPart : null;
620
+ }
621
+ function chatGptQuotaIdentityId(accountId, userId, email) {
622
+ if (userId) {
623
+ return `${accountId}:user:${userId}`;
624
+ }
625
+ if (email) {
626
+ return `${accountId}:email:${email.toLowerCase()}`;
627
+ }
628
+ return accountId;
629
+ }
630
+ function authRecordsCompatible(left, right) {
631
+ if (left.accountId !== right.accountId) {
632
+ return false;
633
+ }
634
+ if (!isSpecificQuotaIdentity(left) || !isSpecificQuotaIdentity(right)) {
635
+ return true;
636
+ }
637
+ return left.quotaIdentityId === right.quotaIdentityId;
638
+ }
639
+ function isSpecificQuotaIdentity(metadata) {
640
+ return metadata.quotaIdentityId !== metadata.accountId;
641
+ }
642
+ function firstStringClaim(primary, secondary, ...keys) {
643
+ for (const claims of [primary, secondary]) {
644
+ if (!claims) {
645
+ continue;
646
+ }
647
+ for (const key of keys) {
648
+ const value = claims[key];
649
+ if (typeof value === 'string' && value.trim()) {
650
+ return value;
651
+ }
652
+ }
653
+ }
654
+ return null;
655
+ }
656
+ function decodeJwtPayload(token) {
657
+ if (typeof token !== 'string') {
658
+ return null;
659
+ }
660
+ const parts = token.split('.');
661
+ if (parts.length < 2 || !parts[1]) {
662
+ return null;
663
+ }
664
+ try {
665
+ const base64 = parts[1].replaceAll('-', '+').replaceAll('_', '/');
666
+ const padded = base64 + '='.repeat((4 - base64.length % 4) % 4);
667
+ const parsed = JSON.parse(Buffer.from(padded, 'base64').toString('utf8'));
668
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
669
+ ? parsed
670
+ : null;
548
671
  }
549
672
  catch {
550
673
  return null;
@@ -357,8 +357,8 @@ export declare class BridgeSessionCore {
357
357
  private refreshCurrentCodexAuthQuota;
358
358
  private applySharedCodexAuthQuotaSnapshots;
359
359
  private recordCodexAuthQuotaSnapshot;
360
- private readCodexAuthCandidateAccountIds;
361
- private codexAuthQuotaSnapshotMatchesAccount;
360
+ private readCodexAuthCandidateQuotaIdentities;
361
+ private codexAuthQuotaSnapshotMatchesIdentity;
362
362
  private readCodexAuthQuotaSnapshots;
363
363
  private writeCodexAuthQuotaSnapshots;
364
364
  private codexAuthQuotaSnapshotPath;
@@ -3,7 +3,7 @@ import fs from 'node:fs/promises';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { normalizeLocale, t } from '../i18n.js';
6
- import { parseChatGptAuthMetadata, readChatGptAuthMetadata } from '../auth/mirror.js';
6
+ import { chatGptAuthMetadataMatchesCandidateName, parseChatGptAuthMetadata, readChatGptAuthMetadata, } from '../auth/mirror.js';
7
7
  import { parseCommand } from './commands.js';
8
8
  import { buildAccessSettingsKeyboard, buildModelSettingsKeyboard, buildSetupPanelKeyboard, buildThreadListKeyboard, buildThreadsKeyboard, clampEffortToModel, formatAccessPresetLabel, formatActiveTurnMessageModeLabel, formatAccessSettingsMessage, formatApprovalPolicyLabel, formatCollaborationModeLabel, formatModelSettingsMessage, formatSandboxModeLabel, formatServiceTierStatusLabel, formatSetupPanelMessage, formatThreadContextSummary, formatThreadsMessage, formatWeixinAccessCopyPaste, formatWeixinModelCopyPaste, formatWeixinThreadsCopyPaste, formatWeixinWhereNavCopyPaste, formatWhereMessage, normalizeRequestedEffort, resolveCurrentModel, resolveActiveTurnMessageMode, resolveRequestedModel, } from './presentation.js';
9
9
  import { clampServiceTierToModel, resolveFastTierForModel } from './service_tier.js';
@@ -5310,15 +5310,15 @@ export class BridgeSessionCore {
5310
5310
  async listCodexAuthState() {
5311
5311
  const state = await listCodexAuthState(this.store.listDisabledCodexAuthCandidateNames(this.authRuntimeId()), this.resolveAuthDir());
5312
5312
  const snapshots = await this.readCodexAuthQuotaSnapshots();
5313
- const candidateAccountIds = await this.readCodexAuthCandidateAccountIds(state.candidates);
5313
+ const candidateQuotaIdentities = await this.readCodexAuthCandidateQuotaIdentities(state.candidates);
5314
5314
  state.candidates.forEach((candidate) => {
5315
- const candidateAccountId = candidateAccountIds.get(candidate.name) ?? null;
5315
+ const candidateQuotaIdentity = candidateQuotaIdentities.get(candidate.name) ?? null;
5316
5316
  const snapshot = snapshots[candidate.name] ?? null;
5317
- candidate.quota = this.codexAuthQuotaSnapshotMatchesAccount(snapshot, candidateAccountId)
5317
+ candidate.quota = this.codexAuthQuotaSnapshotMatchesIdentity(snapshot, candidateQuotaIdentity)
5318
5318
  ? snapshot
5319
5319
  : null;
5320
5320
  });
5321
- await this.applySharedCodexAuthQuotaSnapshots(state, candidateAccountIds);
5321
+ await this.applySharedCodexAuthQuotaSnapshots(state, candidateQuotaIdentities);
5322
5322
  return state;
5323
5323
  }
5324
5324
  resolveAuthDir() {
@@ -5410,7 +5410,7 @@ export class BridgeSessionCore {
5410
5410
  try {
5411
5411
  for (const candidate of candidates) {
5412
5412
  const before = await readChatGptAuthMetadata(candidate.path);
5413
- if (!before) {
5413
+ if (!before || !chatGptAuthMetadataMatchesCandidateName(candidate.name, before)) {
5414
5414
  result.skipped.push(candidate.name);
5415
5415
  continue;
5416
5416
  }
@@ -5427,13 +5427,15 @@ export class BridgeSessionCore {
5427
5427
  throw new Error('Codex did not return ChatGPT rate limits after refresh');
5428
5428
  }
5429
5429
  const after = await readChatGptAuthMetadata(candidate.path);
5430
- if (!after || after.accountId !== before.accountId) {
5431
- throw new Error('refreshed auth account id did not match the original candidate');
5430
+ if (!after
5431
+ || !chatGptAuthMetadataMatchesCandidateName(candidate.name, after)
5432
+ || !chatGptAuthMetadataCompatible(before, after)) {
5433
+ throw new Error('refreshed auth identity did not match the original candidate');
5432
5434
  }
5433
5435
  if (after.lastRefreshMs <= before.lastRefreshMs) {
5434
5436
  throw new Error('Codex did not advance last_refresh');
5435
5437
  }
5436
- await this.recordCodexAuthQuotaSnapshot(candidate.name, after.accountId, snapshot);
5438
+ await this.recordCodexAuthQuotaSnapshot(candidate.name, after, snapshot);
5437
5439
  await this.syncCodexAuthCandidate(candidate.name);
5438
5440
  result.refreshed.push(candidate.name);
5439
5441
  }
@@ -5655,13 +5657,16 @@ export class BridgeSessionCore {
5655
5657
  }
5656
5658
  try {
5657
5659
  const metadata = await readChatGptAuthMetadata(candidate.path);
5660
+ if (!metadata || !chatGptAuthMetadataMatchesCandidateName(candidate.name, metadata)) {
5661
+ return;
5662
+ }
5658
5663
  const snapshot = selectCodexRateLimitSnapshot(await this.app.readAccountRateLimits());
5659
5664
  if (!snapshot) {
5660
5665
  return;
5661
5666
  }
5662
- const quota = authQuotaSnapshotFromRateLimit(snapshot, metadata?.accountId ?? null);
5667
+ const quota = authQuotaSnapshotFromRateLimit(snapshot, metadata?.accountId ?? null, metadata?.quotaIdentityId ?? null);
5663
5668
  candidate.quota = quota;
5664
- await this.recordCodexAuthQuotaSnapshot(candidate.name, metadata?.accountId ?? null, snapshot);
5669
+ await this.recordCodexAuthQuotaSnapshot(candidate.name, metadata, snapshot);
5665
5670
  await this.applySharedCodexAuthQuotaSnapshots(state);
5666
5671
  await this.syncCodexAuthCandidate(candidate.name);
5667
5672
  }
@@ -5669,62 +5674,69 @@ export class BridgeSessionCore {
5669
5674
  this.logger.warn('codex.auth_quota_refresh_failed', { error: formatUserError(error) });
5670
5675
  }
5671
5676
  }
5672
- async applySharedCodexAuthQuotaSnapshots(state, candidateAccountIds) {
5673
- const accountIds = candidateAccountIds ?? await this.readCodexAuthCandidateAccountIds(state.candidates);
5674
- const uniqueAccountIds = [...new Set(accountIds.values())];
5675
- if (uniqueAccountIds.length === 0) {
5677
+ async applySharedCodexAuthQuotaSnapshots(state, candidateQuotaIdentities) {
5678
+ const quotaIdentities = candidateQuotaIdentities ?? await this.readCodexAuthCandidateQuotaIdentities(state.candidates);
5679
+ const uniqueQuotaIdentityIds = [...new Set([...quotaIdentities.values()].map(identity => identity.quotaIdentityId))];
5680
+ if (uniqueQuotaIdentityIds.length === 0) {
5676
5681
  return;
5677
5682
  }
5678
- const snapshotsByAccount = new Map();
5679
- for (const record of this.store.listCodexAuthQuotaSnapshots(uniqueAccountIds)) {
5683
+ const snapshotsByIdentity = new Map();
5684
+ for (const record of this.store.listCodexAuthQuotaSnapshots(uniqueQuotaIdentityIds)) {
5680
5685
  if (!isFiniteCodexAuthQuotaSnapshotRecord(record)) {
5681
5686
  continue;
5682
5687
  }
5683
- snapshotsByAccount.set(record.accountId, mergeCodexAuthQuotaSnapshots(snapshotsByAccount.get(record.accountId) ?? null, codexAuthQuotaSnapshotFromRecord(record)));
5688
+ snapshotsByIdentity.set(record.quotaIdentityId, mergeCodexAuthQuotaSnapshots(snapshotsByIdentity.get(record.quotaIdentityId) ?? null, codexAuthQuotaSnapshotFromRecord(record)));
5684
5689
  }
5685
5690
  for (const candidate of state.candidates) {
5686
- const accountId = accountIds.get(candidate.name);
5687
- if (!accountId) {
5691
+ const quotaIdentity = quotaIdentities.get(candidate.name);
5692
+ if (!quotaIdentity) {
5688
5693
  continue;
5689
5694
  }
5690
- candidate.quota = mergeCodexAuthQuotaSnapshots(candidate.quota, snapshotsByAccount.get(accountId) ?? null);
5695
+ candidate.quota = mergeCodexAuthQuotaSnapshots(candidate.quota, snapshotsByIdentity.get(quotaIdentity.quotaIdentityId) ?? null);
5691
5696
  }
5692
5697
  }
5693
- async recordCodexAuthQuotaSnapshot(candidateName, accountId, snapshot) {
5694
- const quota = authQuotaSnapshotFromRateLimit(snapshot, accountId);
5698
+ async recordCodexAuthQuotaSnapshot(candidateName, metadata, snapshot) {
5699
+ const quota = authQuotaSnapshotFromRateLimit(snapshot, metadata?.accountId ?? null, metadata?.quotaIdentityId ?? null);
5695
5700
  this.authQuotaSnapshots[candidateName] = quota;
5696
- if (accountId) {
5697
- this.store.setCodexAuthQuotaSnapshot(this.authRuntimeId(), candidateName, accountId, quota);
5701
+ if (metadata) {
5702
+ this.store.setCodexAuthQuotaSnapshot(this.authRuntimeId(), candidateName, metadata.accountId, metadata.quotaIdentityId, quota);
5698
5703
  }
5699
5704
  await this.writeCodexAuthQuotaSnapshots();
5700
5705
  }
5701
- async readCodexAuthCandidateAccountIds(candidates) {
5706
+ async readCodexAuthCandidateQuotaIdentities(candidates) {
5702
5707
  const entries = await Promise.all(candidates.map(async (candidate) => {
5703
5708
  const metadata = await readChatGptAuthMetadata(candidate.path);
5704
- candidate.credentialKind = metadata
5709
+ const metadataMatchesName = metadata
5710
+ ? chatGptAuthMetadataMatchesCandidateName(candidate.name, metadata)
5711
+ : false;
5712
+ candidate.credentialKind = metadata && metadataMatchesName
5705
5713
  ? 'chatgpt'
5706
5714
  : await isCodexApiKeyAuthCandidate(candidate.path)
5707
5715
  ? 'api-key'
5708
5716
  : 'invalid';
5709
- candidate.credentialLastRefreshMs = metadata?.lastRefreshMs ?? null;
5710
- return [candidate.name, metadata?.accountId ?? null];
5717
+ candidate.credentialLastRefreshMs = metadata && metadataMatchesName ? metadata.lastRefreshMs : null;
5718
+ return [candidate.name, metadata && metadataMatchesName ? {
5719
+ accountId: metadata.accountId,
5720
+ quotaIdentityId: metadata.quotaIdentityId,
5721
+ } : null];
5711
5722
  }));
5712
- const accountIds = new Map();
5713
- for (const [name, accountId] of entries) {
5714
- if (accountId) {
5715
- accountIds.set(name, accountId);
5723
+ const quotaIdentities = new Map();
5724
+ for (const [name, quotaIdentity] of entries) {
5725
+ if (quotaIdentity) {
5726
+ quotaIdentities.set(name, quotaIdentity);
5716
5727
  }
5717
5728
  }
5718
- return accountIds;
5729
+ return quotaIdentities;
5719
5730
  }
5720
- codexAuthQuotaSnapshotMatchesAccount(snapshot, accountId) {
5731
+ codexAuthQuotaSnapshotMatchesIdentity(snapshot, identity) {
5721
5732
  if (!snapshot) {
5722
5733
  return false;
5723
5734
  }
5724
- if (!snapshot.accountId) {
5725
- return accountId === null;
5735
+ const snapshotIdentityId = snapshot.quotaIdentityId ?? snapshot.accountId ?? null;
5736
+ if (!snapshotIdentityId) {
5737
+ return identity === null;
5726
5738
  }
5727
- return accountId !== null && snapshot.accountId === accountId;
5739
+ return identity !== null && snapshotIdentityId === identity.quotaIdentityId;
5728
5740
  }
5729
5741
  async readCodexAuthQuotaSnapshots() {
5730
5742
  if (this.authQuotaSnapshotsLoaded) {
@@ -9368,10 +9380,11 @@ function formatRemainingUsagePercent(usedPercent) {
9368
9380
  const remainingPercent = remainingUsagePercent(usedPercent);
9369
9381
  return remainingPercent === null ? '?' : formatUsagePercent(remainingPercent);
9370
9382
  }
9371
- function authQuotaSnapshotFromRateLimit(snapshot, accountId = null) {
9383
+ function authQuotaSnapshotFromRateLimit(snapshot, accountId = null, quotaIdentityId = null) {
9372
9384
  return {
9373
9385
  capturedAtMs: Date.now(),
9374
9386
  accountId,
9387
+ quotaIdentityId,
9375
9388
  planType: snapshot.planType,
9376
9389
  primaryWindowDurationMins: snapshot.primary?.windowDurationMins ?? null,
9377
9390
  primaryRemainingPercent: snapshot.primary ? remainingUsagePercent(snapshot.primary.usedPercent) : null,
@@ -9383,6 +9396,7 @@ function codexAuthQuotaSnapshotFromRecord(record) {
9383
9396
  return {
9384
9397
  capturedAtMs: record.capturedAtMs,
9385
9398
  accountId: record.accountId,
9399
+ quotaIdentityId: record.quotaIdentityId,
9386
9400
  planType: record.planType,
9387
9401
  primaryWindowDurationMins: record.primaryWindowDurationMins,
9388
9402
  primaryRemainingPercent: record.primaryRemainingPercent,
@@ -9402,6 +9416,7 @@ function mergeCodexAuthQuotaSnapshots(current, incoming) {
9402
9416
  return {
9403
9417
  ...freshest,
9404
9418
  accountId: freshest.accountId ?? older.accountId ?? null,
9419
+ quotaIdentityId: freshest.quotaIdentityId ?? older.quotaIdentityId ?? null,
9405
9420
  };
9406
9421
  }
9407
9422
  function isFiniteCodexAuthQuotaSnapshotRecord(record) {
@@ -9450,6 +9465,7 @@ function isCodexAuthQuotaSnapshot(value) {
9450
9465
  return typeof snapshot.capturedAtMs === 'number'
9451
9466
  && Number.isFinite(snapshot.capturedAtMs)
9452
9467
  && (snapshot.accountId === undefined || snapshot.accountId === null || typeof snapshot.accountId === 'string')
9468
+ && (snapshot.quotaIdentityId === undefined || snapshot.quotaIdentityId === null || typeof snapshot.quotaIdentityId === 'string')
9453
9469
  && (snapshot.planType === undefined || isNullableString(snapshot.planType))
9454
9470
  && (snapshot.primaryWindowDurationMins === undefined || isNullableFiniteNumber(snapshot.primaryWindowDurationMins))
9455
9471
  && isNullableFiniteNumber(snapshot.primaryRemainingPercent)
@@ -9460,6 +9476,7 @@ function normalizeCodexAuthQuotaSnapshot(snapshot) {
9460
9476
  return {
9461
9477
  capturedAtMs: snapshot.capturedAtMs,
9462
9478
  accountId: snapshot.accountId ?? null,
9479
+ quotaIdentityId: snapshot.quotaIdentityId ?? snapshot.accountId ?? null,
9463
9480
  planType: snapshot.planType ?? null,
9464
9481
  primaryWindowDurationMins: snapshot.primaryWindowDurationMins ?? null,
9465
9482
  primaryRemainingPercent: snapshot.primaryRemainingPercent,
@@ -9467,6 +9484,15 @@ function normalizeCodexAuthQuotaSnapshot(snapshot) {
9467
9484
  secondaryRemainingPercent: snapshot.secondaryRemainingPercent,
9468
9485
  };
9469
9486
  }
9487
+ function chatGptAuthMetadataCompatible(left, right) {
9488
+ if (left.accountId !== right.accountId) {
9489
+ return false;
9490
+ }
9491
+ if (left.quotaIdentityId === left.accountId || right.quotaIdentityId === right.accountId) {
9492
+ return true;
9493
+ }
9494
+ return left.quotaIdentityId === right.quotaIdentityId;
9495
+ }
9470
9496
  function isNullableFiniteNumber(value) {
9471
9497
  return value === null || (typeof value === 'number' && Number.isFinite(value));
9472
9498
  }
package/dist/main.js CHANGED
@@ -334,6 +334,7 @@ async function runServeCli() {
334
334
  ?? await mirror.readNewestCandidate(candidateName);
335
335
  return await authSync?.requestRecovery(candidateName, {
336
336
  accountId: current?.accountId ?? null,
337
+ quotaIdentityId: current?.quotaIdentityId ?? null,
337
338
  lastRefreshMs: current?.lastRefreshMs ?? null,
338
339
  }) ?? false;
339
340
  },
@@ -502,6 +503,7 @@ async function runServeCli() {
502
503
  ?? null;
503
504
  return await singleAuthSync?.requestRecovery(candidateName, {
504
505
  accountId: current?.accountId ?? null,
506
+ quotaIdentityId: current?.quotaIdentityId ?? null,
505
507
  lastRefreshMs: current?.lastRefreshMs ?? null,
506
508
  }) ?? false;
507
509
  },
@@ -28,6 +28,7 @@ export interface CodexAuthQuotaSnapshotRecord {
28
28
  runtimeId: string;
29
29
  candidateName: string;
30
30
  accountId: string;
31
+ quotaIdentityId: string;
31
32
  capturedAtMs: number;
32
33
  planType: string | null;
33
34
  primaryWindowDurationMins: number | null;
@@ -117,7 +118,7 @@ export declare class BridgeStore {
117
118
  setWeixinContextToken(scopeId: string, contextToken: string): void;
118
119
  listDisabledCodexAuthCandidateNames(runtimeId?: string): Set<string>;
119
120
  setCodexAuthCandidateDisabled(name: string, disabled: boolean, runtimeId?: string): void;
120
- setCodexAuthQuotaSnapshot(runtimeId: string, candidateName: string, accountId: string, snapshot: Pick<CodexAuthQuotaSnapshotRecord, 'capturedAtMs' | 'planType' | 'primaryWindowDurationMins' | 'primaryRemainingPercent' | 'secondaryWindowDurationMins' | 'secondaryRemainingPercent'>): void;
121
- listCodexAuthQuotaSnapshots(accountIds: string[]): CodexAuthQuotaSnapshotRecord[];
121
+ setCodexAuthQuotaSnapshot(runtimeId: string, candidateName: string, accountId: string, quotaIdentityId: string, snapshot: Pick<CodexAuthQuotaSnapshotRecord, 'capturedAtMs' | 'planType' | 'primaryWindowDurationMins' | 'primaryRemainingPercent' | 'secondaryWindowDurationMins' | 'secondaryRemainingPercent'>): void;
122
+ listCodexAuthQuotaSnapshots(quotaIdentityIds: string[]): CodexAuthQuotaSnapshotRecord[];
122
123
  private ensureColumn;
123
124
  }
@@ -182,6 +182,7 @@ export class BridgeStore {
182
182
  runtime_id TEXT NOT NULL,
183
183
  candidate_name TEXT NOT NULL,
184
184
  account_id TEXT NOT NULL,
185
+ quota_identity_id TEXT NOT NULL DEFAULT '',
185
186
  captured_at_ms INTEGER NOT NULL,
186
187
  plan_type TEXT,
187
188
  primary_window_duration_mins REAL,
@@ -209,6 +210,16 @@ export class BridgeStore {
209
210
  this.ensureColumn('codex_auth_quota_snapshots', 'plan_type', 'TEXT');
210
211
  this.ensureColumn('codex_auth_quota_snapshots', 'primary_window_duration_mins', 'REAL');
211
212
  this.ensureColumn('codex_auth_quota_snapshots', 'secondary_window_duration_mins', 'REAL');
213
+ this.ensureColumn('codex_auth_quota_snapshots', 'quota_identity_id', "TEXT NOT NULL DEFAULT ''");
214
+ this.db.prepare(`
215
+ UPDATE codex_auth_quota_snapshots
216
+ SET quota_identity_id = account_id
217
+ WHERE quota_identity_id = ''
218
+ `).run();
219
+ this.db.exec(`
220
+ CREATE INDEX IF NOT EXISTS codex_auth_quota_snapshots_quota_identity_idx
221
+ ON codex_auth_quota_snapshots(quota_identity_id);
222
+ `);
212
223
  migrateLegacyBridgeScopeIds(this.db);
213
224
  }
214
225
  getTelegramOffset(botKey) {
@@ -839,12 +850,13 @@ export class BridgeStore {
839
850
  ON CONFLICT(name) DO UPDATE SET disabled = excluded.disabled, updated_at = excluded.updated_at
840
851
  `).run(name, disabled ? 1 : 0, Date.now());
841
852
  }
842
- setCodexAuthQuotaSnapshot(runtimeId, candidateName, accountId, snapshot) {
853
+ setCodexAuthQuotaSnapshot(runtimeId, candidateName, accountId, quotaIdentityId, snapshot) {
843
854
  this.db.prepare(`
844
855
  INSERT INTO codex_auth_quota_snapshots (
845
856
  runtime_id,
846
857
  candidate_name,
847
858
  account_id,
859
+ quota_identity_id,
848
860
  captured_at_ms,
849
861
  plan_type,
850
862
  primary_window_duration_mins,
@@ -853,9 +865,10 @@ export class BridgeStore {
853
865
  secondary_remaining_percent,
854
866
  updated_at
855
867
  )
856
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
868
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
857
869
  ON CONFLICT(runtime_id, candidate_name) DO UPDATE SET
858
870
  account_id = excluded.account_id,
871
+ quota_identity_id = excluded.quota_identity_id,
859
872
  captured_at_ms = excluded.captured_at_ms,
860
873
  plan_type = excluded.plan_type,
861
874
  primary_window_duration_mins = excluded.primary_window_duration_mins,
@@ -863,19 +876,20 @@ export class BridgeStore {
863
876
  secondary_window_duration_mins = excluded.secondary_window_duration_mins,
864
877
  secondary_remaining_percent = excluded.secondary_remaining_percent,
865
878
  updated_at = excluded.updated_at
866
- `).run(runtimeId, candidateName, accountId, snapshot.capturedAtMs, snapshot.planType, snapshot.primaryWindowDurationMins, snapshot.primaryRemainingPercent, snapshot.secondaryWindowDurationMins, snapshot.secondaryRemainingPercent, Date.now());
879
+ `).run(runtimeId, candidateName, accountId, quotaIdentityId, snapshot.capturedAtMs, snapshot.planType, snapshot.primaryWindowDurationMins, snapshot.primaryRemainingPercent, snapshot.secondaryWindowDurationMins, snapshot.secondaryRemainingPercent, Date.now());
867
880
  }
868
- listCodexAuthQuotaSnapshots(accountIds) {
869
- const uniqueAccountIds = [...new Set(accountIds.filter(Boolean))];
870
- if (uniqueAccountIds.length === 0) {
881
+ listCodexAuthQuotaSnapshots(quotaIdentityIds) {
882
+ const uniqueQuotaIdentityIds = [...new Set(quotaIdentityIds.filter(Boolean))];
883
+ if (uniqueQuotaIdentityIds.length === 0) {
871
884
  return [];
872
885
  }
873
- const placeholders = uniqueAccountIds.map(() => '?').join(', ');
886
+ const placeholders = uniqueQuotaIdentityIds.map(() => '?').join(', ');
874
887
  const rows = this.db.prepare(`
875
888
  SELECT
876
889
  runtime_id,
877
890
  candidate_name,
878
891
  account_id,
892
+ quota_identity_id,
879
893
  captured_at_ms,
880
894
  plan_type,
881
895
  primary_window_duration_mins,
@@ -884,12 +898,13 @@ export class BridgeStore {
884
898
  secondary_remaining_percent,
885
899
  updated_at
886
900
  FROM codex_auth_quota_snapshots
887
- WHERE account_id IN (${placeholders})
888
- `).all(...uniqueAccountIds);
901
+ WHERE quota_identity_id IN (${placeholders})
902
+ `).all(...uniqueQuotaIdentityIds);
889
903
  return rows.map(row => ({
890
904
  runtimeId: String(row.runtime_id),
891
905
  candidateName: String(row.candidate_name),
892
906
  accountId: String(row.account_id),
907
+ quotaIdentityId: String(row.quota_identity_id),
893
908
  capturedAtMs: Number(row.captured_at_ms),
894
909
  planType: nullableString(row.plan_type),
895
910
  primaryWindowDurationMins: nullableNumber(row.primary_window_duration_mins),
@@ -33,7 +33,7 @@ Safety boundaries:
33
33
  - FoxClaw only accepts sync files from bots listed in `AUTH_SYNC_PEERS`.
34
34
  - Wrong `AUTH_SYNC_KEY`, cluster, nonce, or payload validation never writes files.
35
35
  - Remote imports wait for global local idleness, then run temporary usage validation before writing a candidate.
36
- - A same-name candidate known to belong to a different account id is never overwritten.
36
+ - A same-name candidate known to belong to a different account id, or to a different identifiable ChatGPT user/email under the same account, is never overwritten.
37
37
  - Sync packets do not create reply chains. FoxClaw filters by packet type, nonce, and peer allowlist to avoid bot-to-bot loops.
38
38
 
39
39
  Telegram's official Bot Features documentation says private bot-to-bot messaging requires Bot-to-Bot Communication Mode on both sender and recipient, and it calls out loop-prevention requirements. See https://core.telegram.org/bots/features#bot-to-bot-communication
@@ -164,7 +164,7 @@ If it shows `Missing replies: @peer_bot`, Telegram delivery may have succeeded,
164
164
 
165
165
  Confirm that pending imports were processed, or that the candidate exists or has a newer timestamp.
166
166
 
167
- Note: `/auth sync push all` saying “sent” only means this node successfully handed encrypted packages to Telegram. It does not prove the peer wrote files. The peer imports only when it is globally idle, usage validation succeeds, same-name candidates belong to the same account id, and the remote `last_refresh` is newer than the local copy. If the local file is already equal or newer, it will not change and `Last import` may remain empty.
167
+ Note: `/auth sync push all` saying “sent” only means this node successfully handed encrypted packages to Telegram. It does not prove the peer wrote files. The peer imports only when it is globally idle, usage validation succeeds, same-name candidates belong to the same account id and compatible ChatGPT user/email identity, and the remote `last_refresh` is newer than the local copy. If the local file is already equal or newer, it will not change and `Last import` may remain empty.
168
168
 
169
169
  When cross-node sync is enabled, the contact bot private chat receives node-level notifications: local auth updates and the peers being contacted, received remote bundles and whether they were queued or immediately validated, import success/skip/failure reasons, recovery peer queries and peer replies, and a manual-intervention notice when every peer lacks an importable copy. Notifications never include auth contents, tokens, or encrypted bundle payloads.
170
170
 
@@ -198,7 +198,7 @@ With cross-node sync enabled, this command first requests a cross-node refresh l
198
198
 
199
199
  - The local node may not be globally idle. Active turns, approvals, inputs, login flows, and mirror writes make imports wait.
200
200
  - Usage validation failure rejects the write.
201
- - Same-name candidates from different account ids are refused.
201
+ - Same-name candidates from different account ids, or from different identifiable ChatGPT users/emails under the same account, are refused.
202
202
  - Run `/auth sync events <candidate>` or `/auth sync trace <requestId>` to see the receive, validation, skip, or failure records kept by FoxClaw.
203
203
 
204
204
  **Should I periodically run `/auth refresh all confirm` as keepalive?**
@@ -359,7 +359,7 @@ FoxClaw recognizes candidate names in these forms:
359
359
  - `auth.json.<name>`
360
360
  - `auth.json-<name>`
361
361
 
362
- `auth.json` is what Codex currently uses. When switching accounts, FoxClaw points `auth.json` at one candidate. Candidate contents are Codex-generated JSON and should not be hand-written. In multi-bot mode, FoxClaw mirrors a candidate only when its account identity matches, its refresh timestamp is newer, and the active app-server verifies it against the ChatGPT usage endpoint, preventing a same-name candidate from overwriting a different account. Before an auth switch or reload, FoxClaw also searches the other Codex homes for a newer credential with the same account ID, restores it into the requesting runtime, then verifies and mirrors it after restart.
362
+ `auth.json` is what Codex currently uses. When switching accounts, FoxClaw points `auth.json` at one candidate. Candidate contents are Codex-generated JSON and should not be hand-written. In multi-bot mode, FoxClaw mirrors a candidate only when its account identity and identifiable ChatGPT user/email identity are compatible, its refresh timestamp is newer, and the active app-server verifies it against the ChatGPT usage endpoint, preventing a same-name candidate from overwriting a different account or Team seat. Before an auth switch or reload, FoxClaw also searches the other Codex homes for a newer compatible credential, restores it into the requesting runtime, then verifies and mirrors it after restart.
363
363
 
364
364
  If you already have a working `auth.json`, you can save it as a candidate:
365
365
 
@@ -395,7 +395,7 @@ If the login is cancelled or fails, FoxClaw tries to restore the previous auth t
395
395
 
396
396
  `/auth` lists candidate accounts, the current account, and the auth directory. It also provides buttons for switching, disabling, login, and reload. In multi-bot mode the panel names the `@botname` runtime being managed, because private chats, groups, and topics on one bot share that bot's current auth. The panel shows 8 candidates per page and supports paging, `All / Enabled / Attention` filters, and `/auth list <keyword>` filename search for large local inventories. Panel text and buttons omit the repeated `auth.json_` prefix from standard candidate filenames, so `auth.json_personal` on disk renders as `personal`; files are not renamed, and search and commands still operate on the original candidates. `/auth use <n>` always uses the full-list candidate number, independent of panel paging.
397
397
 
398
- Candidate rows are prefixed with observed `window:remaining-percent` values. For example, a Plus account may show `5h:20|7d:25`, while an account with one monthly window may show `30d:97`. Buttons use a compact two-number `primary|secondary` form such as `20|25`; unknown values render as `—`. The current auth quota is refreshed when the panel opens; other candidates are not switched merely to query quota. When multiple bot runtimes have recently used the same ChatGPT account, FoxClaw combines their cached quota snapshots by verified account ID, so one bot's `/auth` panel can show quota information learned by another bot without mixing different accounts.
398
+ Candidate rows are prefixed with observed `window:remaining-percent` values. For example, a Plus account may show `5h:20|7d:25`, while an account with one monthly window may show `30d:97`. Buttons use a compact two-number `primary|secondary` form such as `20|25`; unknown values render as `—`. The current auth quota is refreshed when the panel opens; other candidates are not switched merely to query quota. When multiple bot runtimes have recently used the same ChatGPT quota identity, FoxClaw combines their cached quota snapshots by verified user/email identity under the account, so one bot's `/auth` panel can show quota information learned by another bot without mixing different seats on the same Team account.
399
399
 
400
400
  Approximation:
401
401
 
@@ -453,7 +453,7 @@ Safety boundaries:
453
453
  - Telegram only carries ciphertext. Auth contents, candidate names, account ids, and `last_refresh` live inside the AES-256-GCM encrypted payload.
454
454
  - FoxClaw only accepts sync files from bots listed in `AUTH_SYNC_PEERS`; wrong key, cluster, nonce, or payload validation never writes files.
455
455
  - Remote imports wait for global local idleness, temporarily switch to the candidate for app-server usage validation, and only then write the candidate.
456
- - A same-name candidate known to belong to a different account id is never overwritten.
456
+ - A same-name candidate known to belong to a different account id, or to a different identifiable ChatGPT user/email under the same account, is never overwritten.
457
457
  - Cross-node recovery only pulls an already-held valid peer copy and does not rotate refresh tokens during recovery. If no peer has a usable copy, it stops and asks you to maintain auth manually. The background 9-day proactive refresh separately requests the cross-node refresh lease and skips that cycle if the lease is not granted.
458
458
 
459
459
  Dual-active behavior:
@@ -33,7 +33,7 @@
33
33
  - 只接收 `AUTH_SYNC_PEERS` 中列出的 peer bot 发来的同步文件。
34
34
  - `AUTH_SYNC_KEY`、cluster、nonce 或 payload 校验失败时不会写盘。
35
35
  - 远端导入必须等本机全局空闲,再临时验证 usage;验证成功后才写入候选。
36
- - 同名候选如果已知属于不同 account id,永远拒绝覆盖。
36
+ - 同名候选如果已知属于不同 account id,或属于同一 account 下不同的可识别 ChatGPT 用户/邮箱,永远拒绝覆盖。
37
37
  - 同步包不会触发自动回复链路;FoxClaw 对包类型、nonce 和 peer allowlist 做过滤,避免 bot-to-bot 循环。
38
38
 
39
39
  Telegram 官方 Bot Features 文档说明:私聊 bot-to-bot 需要发送方和接收方都启用 Bot-to-Bot Communication Mode,并提醒开发者处理 loop prevention。参考:https://core.telegram.org/bots/features#bot-to-bot-communication
@@ -164,7 +164,7 @@ auth sync 测试完成:已发送 1,收到回应 1。
164
164
 
165
165
  确认待导入清单被处理,或候选已经出现/更新时间变新。
166
166
 
167
- 注意:`/auth sync push all` 的“已发送”只代表本节点把加密包发给 Telegram 成功,不代表对端已经写盘。对端只有在全局空闲、usage 验证通过、同名候选 account id 一致,并且远端 `last_refresh` 比本地更新时才会覆盖文件。如果本地已经是相同或更新版本,文件不会变化,`最近导入` 也可能保持为空。
167
+ 注意:`/auth sync push all` 的“已发送”只代表本节点把加密包发给 Telegram 成功,不代表对端已经写盘。对端只有在全局空闲、usage 验证通过、同名候选 account id 一致且 ChatGPT 用户/邮箱身份兼容,并且远端 `last_refresh` 比本地更新时才会覆盖文件。如果本地已经是相同或更新版本,文件不会变化,`最近导入` 也可能保持为空。
168
168
 
169
169
  启用跨节点同步后,联系人 bot 的私聊会收到节点级通知:本机 auth 更新并开始发往哪些 peer、收到远端包后是排队还是立即验证、导入成功/跳过/失败原因、auth 恢复时正在查询哪些 peer、peer 回应了什么,以及所有 peer 都无法提供可用副本时的人工介入提示。通知不会包含 auth 内容、token 或同步密文。
170
170
 
@@ -198,7 +198,7 @@ auth sync 测试完成:已发送 1,收到回应 1。
198
198
 
199
199
  - 本机可能不是全局空闲;有 turn、审批、待输入、登录流程或镜像写入时会排队。
200
200
  - usage 验证失败会拒绝写盘。
201
- - 同名候选属于不同 account id 时会拒绝覆盖。
201
+ - 同名候选属于不同 account id,或属于同一 account 下不同的可识别 ChatGPT 用户/邮箱时会拒绝覆盖。
202
202
  - 执行 `/auth sync events <候选名>` 或 `/auth sync trace <requestId>`,查看 FoxClaw 记录的接收、验证、跳过或失败流水。
203
203
 
204
204
  **要不要定期 `/auth refresh all confirm` 保活**
@@ -359,7 +359,7 @@ FoxClaw 识别这些候选文件名:
359
359
  - `auth.json.<name>`
360
360
  - `auth.json-<name>`
361
361
 
362
- `auth.json` 是 Codex 当前使用的文件。切换账号时,FoxClaw 会把 `auth.json` 指向某个候选文件。候选文件内容是 Codex CLI 生成的 JSON,不建议手写这些字段。多 bot 镜像只在账号标识一致、刷新时间更新,并且由当前 app-server 通过 ChatGPT 用量接口在线验证时复制候选,避免同名候选意外覆盖成另一个账号。切换或重载 auth 之前,FoxClaw 还会在其他 Codex home 中查找同账号 ID 的较新凭据,先恢复到发起操作的 runtime,再在重启后验证并镜像。
362
+ `auth.json` 是 Codex 当前使用的文件。切换账号时,FoxClaw 会把 `auth.json` 指向某个候选文件。候选文件内容是 Codex CLI 生成的 JSON,不建议手写这些字段。多 bot 镜像只在账号标识和可识别的 ChatGPT 用户/邮箱身份兼容、刷新时间更新,并且由当前 app-server 通过 ChatGPT 用量接口在线验证时复制候选,避免同名候选意外覆盖成另一个账号或同一 Team 下的另一个 seat。切换或重载 auth 之前,FoxClaw 还会在其他 Codex home 中查找兼容的较新凭据,先恢复到发起操作的 runtime,再在重启后验证并镜像。
363
363
 
364
364
  如果你已经有一个可用的 `auth.json`,可以先备份成候选:
365
365
 
@@ -395,7 +395,7 @@ cp -L ~/.codex/auth.json ~/.codex/auth.json_personal
395
395
 
396
396
  `/auth` 会列出候选账号、当前账号和 auth 目录,并提供按钮切换、禁用、登录和重载。多 bot 模式中,面板顶部还会显示当前正在管理的 `@botname`,因为该 bot 内的私聊、群聊和话题共享同一个当前 auth。面板每页显示 8 个候选,支持翻页、`全部 / 已启用 / 需关注` 筛选和 `/auth list <关键词>` 文件名搜索,适合管理较大的本地候选清单。面板文本和按钮会省略标准候选文件名中重复的 `auth.json_` 前缀,例如磁盘上的 `auth.json_personal` 显示为 `personal`;文件本身不会重命名,搜索和命令仍按原候选工作。命令 `/auth use <n>` 的编号始终对应完整候选列表,不会因为分页变化。
397
397
 
398
- 文本列表中每个候选名前的 `窗口:剩余百分比` 来自最近一次观察到的真实额度窗口,例如 Plus 账号可能显示 `5h:20|7d:25`,只有一个月度窗口的账号可能显示 `30d:97`。按钮为了适配窄屏,只显示两个剩余百分比数字,例如 `20|25`;未知值显示为 `—`。当前 auth 会在打开面板时刷新额度;其他候选不会为了查询额度被自动切换。如果多个 bot runtime 最近使用过同一个 ChatGPT 账号,FoxClaw 会按已验证的账号 ID 合并它们缓存到的额度快照,因此一个 bot 的 `/auth` 面板可以显示另一个 bot 掌握到的额度信息,同时不会把不同账号混在一起。
398
+ 文本列表中每个候选名前的 `窗口:剩余百分比` 来自最近一次观察到的真实额度窗口,例如 Plus 账号可能显示 `5h:20|7d:25`,只有一个月度窗口的账号可能显示 `30d:97`。按钮为了适配窄屏,只显示两个剩余百分比数字,例如 `20|25`;未知值显示为 `—`。当前 auth 会在打开面板时刷新额度;其他候选不会为了查询额度被自动切换。如果多个 bot runtime 最近使用过同一个 ChatGPT 额度身份,FoxClaw 会按账号下已验证的用户/邮箱身份合并缓存到的额度快照,因此一个 bot 的 `/auth` 面板可以显示另一个 bot 掌握到的额度信息,同时不会把同一个 Team account 里的不同 seat 混在一起。
399
399
 
400
400
  示意:
401
401
 
@@ -453,7 +453,7 @@ AUTH_SYNC_NODE_ID=workstation-a
453
453
  - Telegram 只承载密文;auth 文件内容、候选名、account id、`last_refresh` 都在 AES-256-GCM 加密 payload 内。
454
454
  - 只接收 `AUTH_SYNC_PEERS` 中 peer bot 发来的同步文件;密钥、cluster、nonce 或 payload 校验失败时不会写盘。
455
455
  - 远端导入必须等本机全局空闲,再临时切换到待验证 auth、重启 app-server、读取 usage 验证成功后才写入候选。
456
- - 同名候选如果已知属于不同 account id,永远拒绝覆盖。
456
+ - 同名候选如果已知属于不同 account id,或属于同一 account 下不同的可识别 ChatGPT 用户/邮箱,永远拒绝覆盖。
457
457
  - 跨节点恢复只拉取 peer 已持有的有效副本,不会在恢复过程中直接轮换 refresh token;找不到有效副本时会停止,提示你人工维护授权。后台 9 天主动刷新会单独申请跨节点刷新锁,拿不到锁就跳过本轮。
458
458
 
459
459
  双主动流程:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxden-app/foxclaw",
3
- "version": "0.5.11",
3
+ "version": "0.5.13",
4
4
  "description": "Foxden local execution claw for controlling Codex from trusted chat interfaces.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",