@foxden-app/foxclaw 0.5.10 → 0.5.12

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.12 - 2026-06-08
6
+
7
+ ### 中文
8
+ - `/auth` 额度快照现在按 ChatGPT 额度身份合并,优先区分 `chatgpt_user_id`,其次区分 email,避免同一个 Team account 下不同 seat 显示成同一份额度。
9
+ - 本机 auth mirror、跨节点 auth sync 和 `/auth refresh all` 会拒绝可识别为不同 ChatGPT 用户/邮箱的同名候选互相覆盖,即使它们共享同一个 account id。
10
+ - 额度快照数据库新增 `quota_identity_id` 并自动兼容旧数据;文档同步说明 account id 与额度身份的区别。
11
+
12
+ ### English
13
+ - `/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.
14
+ - 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.
15
+ - Added a `quota_identity_id` quota-snapshot migration with backward compatibility for old data, and updated docs to distinguish account id from quota identity.
16
+
17
+ ## 0.5.11 - 2026-06-08
18
+
19
+ ### 中文
20
+ - `/auth` 面板的 `Bot runtime` 现在显示 Telegram bot id,例如 `@WuguiAI2_Bot (bot8949529424)`,便于和 `~/.foxclaw/codex/telegram/<botid>/home` 对应。
21
+ - `/auth` 面板新增“安全同步”按钮,并支持 `/auth sync safe`,可在全局空闲时安全打平本机多 bot auth,并把已校验的候选推送到跨节点 peer。
22
+ - 本机 auth mirror 新增全量安全同步路径:只传播通过既有在线校验的刷新候选,同时补齐 canonical 中已知、同账号且更新的 runtime 副本。
23
+
24
+ ### English
25
+ - The `/auth` panel now shows the Telegram bot id in `Bot runtime`, for example `@WuguiAI2_Bot (bot8949529424)`, making it easy to match the runtime with `~/.foxclaw/codex/telegram/<botid>/home`.
26
+ - Added a Safe sync button to the `/auth` panel, plus `/auth sync safe`, to flatten same-node multi-bot auth while globally idle and push validated candidates to cross-node peers.
27
+ - Added a full safe-sync path for the local auth mirror: it only propagates candidates that pass the existing online validation and fills runtime copies from newer same-account canonical candidates.
28
+
5
29
  ## 0.5.10 - 2026-06-08
6
30
 
7
31
  ### 中文
package/README.md CHANGED
@@ -39,6 +39,7 @@ FoxClaw(狸爪)的目标很直接:让你用手机控制本机的 Codex,
39
39
  - 已经装好,想系统了解 `/help`、`/setup`、`/threads`、`/watch`、`/auth` 和账号轮转?看 [用户手册](./docs/zh/user-manual.md)。
40
40
  - 想把同一组合法 ChatGPT auth 候选同步到多台机器?看 [跨节点 auth 同步配置指南](./docs/zh/cross-node-auth-sync.md)。
41
41
  - 想了解每个版本改了什么?看 [更新日志](./CHANGELOG.md)。
42
+ - 维护者准备发版?看 [发布 runbook](./docs/zh/release.md)。
42
43
  - Git、Node、`.env` 都玩得转?直接往下看快速设置。
43
44
  - 卡住了?看 [故障排查](./docs/zh/troubleshooting.md)。
44
45
 
@@ -104,7 +105,7 @@ FoxClaw 只响应 `TG_ALLOWED_USER_ID` 的消息——把机器人拉进群不
104
105
  **多账号管理:**
105
106
  - Codex 账户管理:`/account`、`/quota`、`/login_device`、`/auth add <name>`
106
107
  - 触发用量限制时自动在本地 `auth.json_*` 之间切换认证
107
- - `/auth` 面板分页查看、筛选、启用、禁用、搜索和切换候选账号;额度按真实窗口展示,多 bot 模式下会按账号 ID 汇总各 runtime 最近掌握的额度快照
108
+ - `/auth` 面板分页查看、筛选、启用、禁用、搜索和切换候选账号;额度按真实窗口展示,多 bot 模式下会按 ChatGPT 额度身份汇总各 runtime 最近掌握的额度快照
108
109
 
109
110
  **线程与会话:**
110
111
  - `/threads`、`/open`、`/new`、`/where`、`/interrupt`——稳定的聊天-线程绑定
package/README_EN.md CHANGED
@@ -39,6 +39,7 @@ FoxClaw is more than message forwarding. It provides Telegram panels for Codex w
39
39
  - Already installed and want the full command guide for `/help`, `/setup`, `/threads`, `/watch`, `/auth`, and auth rotation? Read the [User Manual](./docs/user-manual.md).
40
40
  - Want to sync the same legally owned ChatGPT auth candidate pool across multiple machines? Read the [Cross-Node Auth Sync Setup Guide](./docs/cross-node-auth-sync.md).
41
41
  - Want to see what changed in each release? Read the [Changelog](./CHANGELOG.md).
42
+ - Maintaining a release? Use the [Release Runbook](./docs/release.md).
42
43
  - Already comfortable with Git, Node, and `.env` files? Use the quick setup below.
43
44
  - Something failed? Check [Troubleshooting](./docs/troubleshooting.md).
44
45
 
@@ -104,7 +105,7 @@ FoxClaw accepts messages only from `TG_ALLOWED_USER_ID`. Putting the bot in a gr
104
105
  **Multi-account management:**
105
106
  - Codex account controls: `/account`, `/quota`, `/login_device`, `/auth add <name>`
106
107
  - Automatic auth rotation across local `auth.json_*` files when a usage limit is hit — seamless account switching
107
- - 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
108
109
 
109
110
  **Threads and sessions:**
110
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 {
@@ -52,6 +55,10 @@ export interface AuthMirrorSyncedEvent {
52
55
  status: AuthMirrorStatus;
53
56
  record: AuthMirrorCandidateRecord;
54
57
  }
58
+ export interface AuthMirrorSyncAllResult {
59
+ synced: number;
60
+ skipped: number;
61
+ }
55
62
  export interface AuthMirrorHooks {
56
63
  onSynced?: (event: AuthMirrorSyncedEvent) => Promise<void> | void;
57
64
  }
@@ -77,8 +84,10 @@ export declare class AuthCandidateMirror {
77
84
  readRuntimeCandidate(runtimeId: string, candidateName: string): Promise<AuthMirrorCandidateRecord | null>;
78
85
  listNewestCandidates(): Promise<AuthMirrorCandidateRecord[]>;
79
86
  syncRuntimeCandidate(runtimeId: string, candidateName: string): Promise<boolean>;
87
+ syncAllRuntimeCandidates(): Promise<AuthMirrorSyncAllResult>;
80
88
  recoverRuntimeCandidate(runtimeId: string, candidateName: string): Promise<AuthMirrorRecovery | null>;
81
89
  private scan;
90
+ private distributeCanonicalCandidates;
82
91
  importExternalCandidate(candidateName: string, raw: string, source: {
83
92
  nodeId: string;
84
93
  label?: string | null;
@@ -126,6 +126,28 @@ export class AuthCandidateMirror {
126
126
  return false;
127
127
  return this.withActivity(() => this.propagateValidatedCandidate(runtime, candidateName));
128
128
  }
129
+ async syncAllRuntimeCandidates() {
130
+ return this.withActivity(async () => {
131
+ let synced = 0;
132
+ let skipped = 0;
133
+ for (const runtime of this.runtimes) {
134
+ const names = await listAuthCandidateNames(runtime.authDir);
135
+ for (const name of names) {
136
+ if (await this.propagateValidatedCandidate(runtime, name)) {
137
+ synced += 1;
138
+ }
139
+ else {
140
+ skipped += 1;
141
+ }
142
+ }
143
+ }
144
+ const distributed = await this.distributeCanonicalCandidates();
145
+ return {
146
+ synced: synced + distributed.synced,
147
+ skipped: skipped + distributed.skipped,
148
+ };
149
+ });
150
+ }
129
151
  async recoverRuntimeCandidate(runtimeId, candidateName) {
130
152
  if (!isAuthCandidateName(candidateName))
131
153
  return null;
@@ -139,7 +161,7 @@ export class AuthCandidateMirror {
139
161
  return null;
140
162
  const sources = await this.collectAuthRecords();
141
163
  const newest = sources
142
- .filter(entry => entry.record.accountId === destination.accountId)
164
+ .filter(entry => authRecordsCompatible(entry.record, destination))
143
165
  .reduce((current, entry) => (!current || entry.record.lastRefreshMs > current.record.lastRefreshMs ? entry : current), null);
144
166
  if (!newest || newest.record.lastRefreshMs <= destination.lastRefreshMs) {
145
167
  return null;
@@ -169,6 +191,33 @@ export class AuthCandidateMirror {
169
191
  }
170
192
  });
171
193
  }
194
+ async distributeCanonicalCandidates() {
195
+ let synced = 0;
196
+ let skipped = 0;
197
+ for (const name of await listAuthCandidateNames(this.canonicalDir)) {
198
+ const canonical = await readChatGptAuthRecord(path.join(this.canonicalDir, name));
199
+ if (!canonical) {
200
+ skipped += this.runtimes.length;
201
+ continue;
202
+ }
203
+ for (const runtime of this.runtimes) {
204
+ const destinationPath = path.join(runtime.authDir, name);
205
+ const destination = await readChatGptAuthRecord(destinationPath);
206
+ if (destination && !authRecordsCompatible(destination, canonical)) {
207
+ skipped += 1;
208
+ this.logger.warn('auth.mirror.distribution_conflict', { runtimeId: runtime.id, name });
209
+ continue;
210
+ }
211
+ if (destination && destination.lastRefreshMs >= canonical.lastRefreshMs) {
212
+ skipped += 1;
213
+ continue;
214
+ }
215
+ await atomicWrite(destinationPath, canonical.raw);
216
+ synced += 1;
217
+ }
218
+ }
219
+ return { synced, skipped };
220
+ }
172
221
  async importExternalCandidate(candidateName, raw, source) {
173
222
  if (!isAuthCandidateName(candidateName)) {
174
223
  return { ok: false, imported: false, reason: 'invalid candidate name' };
@@ -185,9 +234,9 @@ export class AuthCandidateMirror {
185
234
  }
186
235
  const existing = (await this.collectAuthRecords())
187
236
  .filter(entry => entry.candidateName === candidateName);
188
- const conflicting = existing.find(entry => entry.record.accountId !== metadata.accountId);
237
+ const conflicting = existing.find(entry => !authRecordsCompatible(entry.record, metadata));
189
238
  if (conflicting) {
190
- return { ok: false, imported: false, reason: 'same candidate belongs to a different account' };
239
+ return { ok: false, imported: false, reason: 'same candidate belongs to a different account or ChatGPT user' };
191
240
  }
192
241
  const newest = existing.reduce((current, entry) => (!current || entry.record.lastRefreshMs > current.record.lastRefreshMs ? entry : current), null);
193
242
  const previousRefresh = Math.max(newest?.record.lastRefreshMs ?? 0, this.lastSyncedRefresh.get(candidateName) ?? 0);
@@ -239,7 +288,7 @@ export class AuthCandidateMirror {
239
288
  return false;
240
289
  const canonicalPath = path.join(this.canonicalDir, name);
241
290
  const canonical = await readChatGptAuthRecord(canonicalPath);
242
- if (canonical && canonical.accountId !== record.accountId) {
291
+ if (canonical && !authRecordsCompatible(canonical, record)) {
243
292
  this.logger.warn('auth.mirror.account_conflict', { runtimeId: runtime.id, name });
244
293
  return false;
245
294
  }
@@ -373,8 +422,8 @@ export class AuthCandidateMirror {
373
422
  })))).filter((entry) => entry.record !== null);
374
423
  if (records.length === 0)
375
424
  return;
376
- const accountIds = new Set(records.map((entry) => entry.record.accountId));
377
- if (accountIds.size !== 1) {
425
+ const reference = records[0].record;
426
+ if (records.some(entry => !authRecordsCompatible(reference, entry.record))) {
378
427
  this.logger.warn('auth.mirror.startup_conflict', { name });
379
428
  return;
380
429
  }
@@ -495,7 +544,72 @@ export function parseChatGptAuthMetadata(raw) {
495
544
  const lastRefreshMs = typeof parsed.last_refresh === 'string' ? Date.parse(parsed.last_refresh) : NaN;
496
545
  if (!accountId || !Number.isFinite(lastRefreshMs))
497
546
  return null;
498
- return { accountId, lastRefreshMs };
547
+ const accessClaims = decodeJwtPayload(parsed.tokens?.access_token);
548
+ const idClaims = decodeJwtPayload(parsed.tokens?.id_token);
549
+ const userId = firstStringClaim(accessClaims, idClaims, 'https://api.openai.com/auth.chatgpt_user_id', 'https://api.openai.com/auth.user_id');
550
+ const email = firstStringClaim(accessClaims, idClaims, 'https://api.openai.com/profile.email', 'email');
551
+ return {
552
+ accountId,
553
+ quotaIdentityId: chatGptQuotaIdentityId(accountId, userId, email),
554
+ userId,
555
+ email,
556
+ lastRefreshMs,
557
+ };
558
+ }
559
+ catch {
560
+ return null;
561
+ }
562
+ }
563
+ function chatGptQuotaIdentityId(accountId, userId, email) {
564
+ if (userId) {
565
+ return `${accountId}:user:${userId}`;
566
+ }
567
+ if (email) {
568
+ return `${accountId}:email:${email.toLowerCase()}`;
569
+ }
570
+ return accountId;
571
+ }
572
+ function authRecordsCompatible(left, right) {
573
+ if (left.accountId !== right.accountId) {
574
+ return false;
575
+ }
576
+ if (!isSpecificQuotaIdentity(left) || !isSpecificQuotaIdentity(right)) {
577
+ return true;
578
+ }
579
+ return left.quotaIdentityId === right.quotaIdentityId;
580
+ }
581
+ function isSpecificQuotaIdentity(metadata) {
582
+ return metadata.quotaIdentityId !== metadata.accountId;
583
+ }
584
+ function firstStringClaim(primary, secondary, ...keys) {
585
+ for (const claims of [primary, secondary]) {
586
+ if (!claims) {
587
+ continue;
588
+ }
589
+ for (const key of keys) {
590
+ const value = claims[key];
591
+ if (typeof value === 'string' && value.trim()) {
592
+ return value;
593
+ }
594
+ }
595
+ }
596
+ return null;
597
+ }
598
+ function decodeJwtPayload(token) {
599
+ if (typeof token !== 'string') {
600
+ return null;
601
+ }
602
+ const parts = token.split('.');
603
+ if (parts.length < 2 || !parts[1]) {
604
+ return null;
605
+ }
606
+ try {
607
+ const base64 = parts[1].replaceAll('-', '+').replaceAll('_', '/');
608
+ const padded = base64 + '='.repeat((4 - base64.length % 4) % 4);
609
+ const parsed = JSON.parse(Buffer.from(padded, 'base64').toString('utf8'));
610
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
611
+ ? parsed
612
+ : null;
499
613
  }
500
614
  catch {
501
615
  return null;
@@ -19,6 +19,12 @@ export interface CoreCoordinator {
19
19
  }>;
20
20
  releaseAuthRefreshLease?: (leaseId: string | null) => Promise<void>;
21
21
  getAuthSyncStatus?: () => RuntimeStatus['authSync'];
22
+ authSyncSafeAll?: () => Promise<{
23
+ localSynced: number;
24
+ localSkipped: number;
25
+ sent: number;
26
+ skipped: number;
27
+ }>;
22
28
  authSyncPushAll?: () => Promise<{
23
29
  sent: number;
24
30
  skipped: number;
@@ -283,6 +289,7 @@ export declare class BridgeSessionCore {
283
289
  private notifyProactiveAuthRefresh;
284
290
  private handleAuthCommand;
285
291
  private handleAuthSyncCommand;
292
+ private runAuthSafeSyncAll;
286
293
  private handleAuthRefreshAllCommand;
287
294
  private handleAuthUseCommand;
288
295
  private handleAuthToggleCommand;
@@ -350,8 +357,8 @@ export declare class BridgeSessionCore {
350
357
  private refreshCurrentCodexAuthQuota;
351
358
  private applySharedCodexAuthQuotaSnapshots;
352
359
  private recordCodexAuthQuotaSnapshot;
353
- private readCodexAuthCandidateAccountIds;
354
- private codexAuthQuotaSnapshotMatchesAccount;
360
+ private readCodexAuthCandidateQuotaIdentities;
361
+ private codexAuthQuotaSnapshotMatchesIdentity;
355
362
  private readCodexAuthQuotaSnapshots;
356
363
  private writeCodexAuthQuotaSnapshots;
357
364
  private codexAuthQuotaSnapshotPath;