@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.
@@ -1065,7 +1065,7 @@ export class BridgeSessionCore {
1065
1065
  await this.handleAuthListViewCallback(event, authClearSearchMatch[1], 'clear_search', locale);
1066
1066
  return;
1067
1067
  }
1068
- const authActionMatch = /^auth:([a-f0-9]+):(login_device|reload|refresh_all_confirm|refresh_all_cancel|refresh_all)$/.exec(event.data);
1068
+ const authActionMatch = /^auth:([a-f0-9]+):(login_device|reload|safe_sync|refresh_all_confirm|refresh_all_cancel|refresh_all)$/.exec(event.data);
1069
1069
  if (authActionMatch) {
1070
1070
  await this.handleAuthPanelActionCallback(event, authActionMatch[1], authActionMatch[2], locale);
1071
1071
  return;
@@ -2956,7 +2956,7 @@ export class BridgeSessionCore {
2956
2956
  authDisplayBotLabel() {
2957
2957
  if (!this.config.tgScopeBotId)
2958
2958
  return null;
2959
- return this.botUsername ? `@${this.botUsername}` : this.config.tgScopeBotId;
2959
+ return this.botUsername ? `@${this.botUsername} (${this.config.tgScopeBotId})` : this.config.tgScopeBotId;
2960
2960
  }
2961
2961
  ownsScope(scopeId) {
2962
2962
  if (scopeId.startsWith(BRIDGE_SCOPE_WEIXIN_PREFIX)) {
@@ -4467,17 +4467,19 @@ export class BridgeSessionCore {
4467
4467
  await this.sendMessage(scopeId, message);
4468
4468
  return;
4469
4469
  }
4470
- if (action === 'push' && args[1]?.toLowerCase() === 'all') {
4470
+ if (action === 'safe' || (action === 'push' && args[1]?.toLowerCase() === 'all')) {
4471
4471
  if (!this.canRunGlobalAuthRefresh()) {
4472
4472
  await this.sendMessage(scopeId, t(locale, 'auth_sync_push_blocked_active'));
4473
4473
  return;
4474
4474
  }
4475
- const result = await this.coordinator?.authSyncPushAll?.();
4475
+ const result = await this.runAuthSafeSyncAll();
4476
4476
  if (!result) {
4477
4477
  await this.sendMessage(scopeId, t(locale, 'auth_sync_disabled'));
4478
4478
  return;
4479
4479
  }
4480
- await this.sendMessage(scopeId, t(locale, 'auth_sync_push_done', {
4480
+ await this.sendMessage(scopeId, t(locale, 'auth_sync_safe_done', {
4481
+ localSynced: result.localSynced,
4482
+ localSkipped: result.localSkipped,
4481
4483
  sent: result.sent,
4482
4484
  skipped: result.skipped,
4483
4485
  }));
@@ -4485,6 +4487,16 @@ export class BridgeSessionCore {
4485
4487
  }
4486
4488
  await this.sendMessage(scopeId, t(locale, 'usage_auth_sync'));
4487
4489
  }
4490
+ async runAuthSafeSyncAll() {
4491
+ const safeResult = await this.coordinator?.authSyncSafeAll?.();
4492
+ if (safeResult) {
4493
+ return safeResult;
4494
+ }
4495
+ const pushResult = await this.coordinator?.authSyncPushAll?.();
4496
+ return pushResult
4497
+ ? { localSynced: 0, localSkipped: 0, sent: pushResult.sent, skipped: pushResult.skipped }
4498
+ : null;
4499
+ }
4488
4500
  async handleAuthRefreshAllCommand(scopeId, locale, confirmed = false) {
4489
4501
  if (!this.canRunGlobalAuthRefresh()) {
4490
4502
  await this.sendMessage(scopeId, t(locale, 'auth_refresh_all_blocked_active'));
@@ -4993,6 +5005,37 @@ export class BridgeSessionCore {
4993
5005
  await this.handleLoginDeviceCommand(event.scopeId, locale);
4994
5006
  return;
4995
5007
  }
5008
+ if (action === 'safe_sync') {
5009
+ if (!this.canRunGlobalAuthRefresh()) {
5010
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_sync_push_blocked_active'));
5011
+ return;
5012
+ }
5013
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_sync_safe_starting'));
5014
+ if (record.messageId !== null) {
5015
+ await this.editMessage(event.scopeId, record.messageId, t(locale, 'auth_sync_safe_starting'), []);
5016
+ }
5017
+ const result = await this.runAuthSafeSyncAll();
5018
+ if (!result) {
5019
+ if (record.messageId !== null) {
5020
+ await this.editMessage(event.scopeId, record.messageId, t(locale, 'auth_sync_disabled'), authChoiceKeyboard(locale, record));
5021
+ }
5022
+ return;
5023
+ }
5024
+ const state = await this.listCodexAuthState();
5025
+ await this.applySharedCodexAuthQuotaSnapshots(state);
5026
+ record.candidates = state.candidates;
5027
+ record.createdAt = Date.now();
5028
+ clampCodexAuthListOffset(record);
5029
+ if (record.messageId !== null) {
5030
+ await this.editMessage(event.scopeId, record.messageId, `${t(locale, 'auth_sync_safe_done', {
5031
+ localSynced: result.localSynced,
5032
+ localSkipped: result.localSkipped,
5033
+ sent: result.sent,
5034
+ skipped: result.skipped,
5035
+ })}\n\n${renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(event.scopeId) !== null, record)}`, authChoiceKeyboard(locale, record));
5036
+ }
5037
+ return;
5038
+ }
4996
5039
  if (action === 'refresh_all') {
4997
5040
  if (!this.canRunGlobalAuthRefresh()) {
4998
5041
  await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_refresh_all_blocked_active'));
@@ -5267,15 +5310,15 @@ export class BridgeSessionCore {
5267
5310
  async listCodexAuthState() {
5268
5311
  const state = await listCodexAuthState(this.store.listDisabledCodexAuthCandidateNames(this.authRuntimeId()), this.resolveAuthDir());
5269
5312
  const snapshots = await this.readCodexAuthQuotaSnapshots();
5270
- const candidateAccountIds = await this.readCodexAuthCandidateAccountIds(state.candidates);
5313
+ const candidateQuotaIdentities = await this.readCodexAuthCandidateQuotaIdentities(state.candidates);
5271
5314
  state.candidates.forEach((candidate) => {
5272
- const candidateAccountId = candidateAccountIds.get(candidate.name) ?? null;
5315
+ const candidateQuotaIdentity = candidateQuotaIdentities.get(candidate.name) ?? null;
5273
5316
  const snapshot = snapshots[candidate.name] ?? null;
5274
- candidate.quota = this.codexAuthQuotaSnapshotMatchesAccount(snapshot, candidateAccountId)
5317
+ candidate.quota = this.codexAuthQuotaSnapshotMatchesIdentity(snapshot, candidateQuotaIdentity)
5275
5318
  ? snapshot
5276
5319
  : null;
5277
5320
  });
5278
- await this.applySharedCodexAuthQuotaSnapshots(state, candidateAccountIds);
5321
+ await this.applySharedCodexAuthQuotaSnapshots(state, candidateQuotaIdentities);
5279
5322
  return state;
5280
5323
  }
5281
5324
  resolveAuthDir() {
@@ -5384,13 +5427,13 @@ export class BridgeSessionCore {
5384
5427
  throw new Error('Codex did not return ChatGPT rate limits after refresh');
5385
5428
  }
5386
5429
  const after = await readChatGptAuthMetadata(candidate.path);
5387
- if (!after || after.accountId !== before.accountId) {
5388
- throw new Error('refreshed auth account id did not match the original candidate');
5430
+ if (!after || !chatGptAuthMetadataCompatible(before, after)) {
5431
+ throw new Error('refreshed auth identity did not match the original candidate');
5389
5432
  }
5390
5433
  if (after.lastRefreshMs <= before.lastRefreshMs) {
5391
5434
  throw new Error('Codex did not advance last_refresh');
5392
5435
  }
5393
- await this.recordCodexAuthQuotaSnapshot(candidate.name, after.accountId, snapshot);
5436
+ await this.recordCodexAuthQuotaSnapshot(candidate.name, after, snapshot);
5394
5437
  await this.syncCodexAuthCandidate(candidate.name);
5395
5438
  result.refreshed.push(candidate.name);
5396
5439
  }
@@ -5616,9 +5659,9 @@ export class BridgeSessionCore {
5616
5659
  if (!snapshot) {
5617
5660
  return;
5618
5661
  }
5619
- const quota = authQuotaSnapshotFromRateLimit(snapshot, metadata?.accountId ?? null);
5662
+ const quota = authQuotaSnapshotFromRateLimit(snapshot, metadata?.accountId ?? null, metadata?.quotaIdentityId ?? null);
5620
5663
  candidate.quota = quota;
5621
- await this.recordCodexAuthQuotaSnapshot(candidate.name, metadata?.accountId ?? null, snapshot);
5664
+ await this.recordCodexAuthQuotaSnapshot(candidate.name, metadata, snapshot);
5622
5665
  await this.applySharedCodexAuthQuotaSnapshots(state);
5623
5666
  await this.syncCodexAuthCandidate(candidate.name);
5624
5667
  }
@@ -5626,36 +5669,36 @@ export class BridgeSessionCore {
5626
5669
  this.logger.warn('codex.auth_quota_refresh_failed', { error: formatUserError(error) });
5627
5670
  }
5628
5671
  }
5629
- async applySharedCodexAuthQuotaSnapshots(state, candidateAccountIds) {
5630
- const accountIds = candidateAccountIds ?? await this.readCodexAuthCandidateAccountIds(state.candidates);
5631
- const uniqueAccountIds = [...new Set(accountIds.values())];
5632
- if (uniqueAccountIds.length === 0) {
5672
+ async applySharedCodexAuthQuotaSnapshots(state, candidateQuotaIdentities) {
5673
+ const quotaIdentities = candidateQuotaIdentities ?? await this.readCodexAuthCandidateQuotaIdentities(state.candidates);
5674
+ const uniqueQuotaIdentityIds = [...new Set([...quotaIdentities.values()].map(identity => identity.quotaIdentityId))];
5675
+ if (uniqueQuotaIdentityIds.length === 0) {
5633
5676
  return;
5634
5677
  }
5635
- const snapshotsByAccount = new Map();
5636
- for (const record of this.store.listCodexAuthQuotaSnapshots(uniqueAccountIds)) {
5678
+ const snapshotsByIdentity = new Map();
5679
+ for (const record of this.store.listCodexAuthQuotaSnapshots(uniqueQuotaIdentityIds)) {
5637
5680
  if (!isFiniteCodexAuthQuotaSnapshotRecord(record)) {
5638
5681
  continue;
5639
5682
  }
5640
- snapshotsByAccount.set(record.accountId, mergeCodexAuthQuotaSnapshots(snapshotsByAccount.get(record.accountId) ?? null, codexAuthQuotaSnapshotFromRecord(record)));
5683
+ snapshotsByIdentity.set(record.quotaIdentityId, mergeCodexAuthQuotaSnapshots(snapshotsByIdentity.get(record.quotaIdentityId) ?? null, codexAuthQuotaSnapshotFromRecord(record)));
5641
5684
  }
5642
5685
  for (const candidate of state.candidates) {
5643
- const accountId = accountIds.get(candidate.name);
5644
- if (!accountId) {
5686
+ const quotaIdentity = quotaIdentities.get(candidate.name);
5687
+ if (!quotaIdentity) {
5645
5688
  continue;
5646
5689
  }
5647
- candidate.quota = mergeCodexAuthQuotaSnapshots(candidate.quota, snapshotsByAccount.get(accountId) ?? null);
5690
+ candidate.quota = mergeCodexAuthQuotaSnapshots(candidate.quota, snapshotsByIdentity.get(quotaIdentity.quotaIdentityId) ?? null);
5648
5691
  }
5649
5692
  }
5650
- async recordCodexAuthQuotaSnapshot(candidateName, accountId, snapshot) {
5651
- const quota = authQuotaSnapshotFromRateLimit(snapshot, accountId);
5693
+ async recordCodexAuthQuotaSnapshot(candidateName, metadata, snapshot) {
5694
+ const quota = authQuotaSnapshotFromRateLimit(snapshot, metadata?.accountId ?? null, metadata?.quotaIdentityId ?? null);
5652
5695
  this.authQuotaSnapshots[candidateName] = quota;
5653
- if (accountId) {
5654
- this.store.setCodexAuthQuotaSnapshot(this.authRuntimeId(), candidateName, accountId, quota);
5696
+ if (metadata) {
5697
+ this.store.setCodexAuthQuotaSnapshot(this.authRuntimeId(), candidateName, metadata.accountId, metadata.quotaIdentityId, quota);
5655
5698
  }
5656
5699
  await this.writeCodexAuthQuotaSnapshots();
5657
5700
  }
5658
- async readCodexAuthCandidateAccountIds(candidates) {
5701
+ async readCodexAuthCandidateQuotaIdentities(candidates) {
5659
5702
  const entries = await Promise.all(candidates.map(async (candidate) => {
5660
5703
  const metadata = await readChatGptAuthMetadata(candidate.path);
5661
5704
  candidate.credentialKind = metadata
@@ -5664,24 +5707,28 @@ export class BridgeSessionCore {
5664
5707
  ? 'api-key'
5665
5708
  : 'invalid';
5666
5709
  candidate.credentialLastRefreshMs = metadata?.lastRefreshMs ?? null;
5667
- return [candidate.name, metadata?.accountId ?? null];
5710
+ return [candidate.name, metadata ? {
5711
+ accountId: metadata.accountId,
5712
+ quotaIdentityId: metadata.quotaIdentityId,
5713
+ } : null];
5668
5714
  }));
5669
- const accountIds = new Map();
5670
- for (const [name, accountId] of entries) {
5671
- if (accountId) {
5672
- accountIds.set(name, accountId);
5715
+ const quotaIdentities = new Map();
5716
+ for (const [name, quotaIdentity] of entries) {
5717
+ if (quotaIdentity) {
5718
+ quotaIdentities.set(name, quotaIdentity);
5673
5719
  }
5674
5720
  }
5675
- return accountIds;
5721
+ return quotaIdentities;
5676
5722
  }
5677
- codexAuthQuotaSnapshotMatchesAccount(snapshot, accountId) {
5723
+ codexAuthQuotaSnapshotMatchesIdentity(snapshot, identity) {
5678
5724
  if (!snapshot) {
5679
5725
  return false;
5680
5726
  }
5681
- if (!snapshot.accountId) {
5682
- return accountId === null;
5727
+ const snapshotIdentityId = snapshot.quotaIdentityId ?? snapshot.accountId ?? null;
5728
+ if (!snapshotIdentityId) {
5729
+ return identity === null;
5683
5730
  }
5684
- return accountId !== null && snapshot.accountId === accountId;
5731
+ return identity !== null && snapshotIdentityId === identity.quotaIdentityId;
5685
5732
  }
5686
5733
  async readCodexAuthQuotaSnapshots() {
5687
5734
  if (this.authQuotaSnapshotsLoaded) {
@@ -8409,7 +8456,7 @@ function renderAuthListMessage(locale, state, botLabel = null, includeWeixinCopy
8409
8456
  if (state.candidates.length === 0) {
8410
8457
  lines.push(t(locale, 'auth_no_candidates'));
8411
8458
  if (includeWeixinCopyPaste) {
8412
- lines.push('', t(locale, 'weixin_copy_paste_divider'), t(locale, 'weixin_copy_auth_title'), '/login_device', '/auth reload', '/permissions');
8459
+ lines.push('', t(locale, 'weixin_copy_paste_divider'), t(locale, 'weixin_copy_auth_title'), '/login_device', '/auth sync safe', '/auth reload', '/permissions');
8413
8460
  }
8414
8461
  return lines.join('\n');
8415
8462
  }
@@ -8442,7 +8489,7 @@ function renderAuthListMessage(locale, state, botLabel = null, includeWeixinCopy
8442
8489
  if (includeWeixinCopyPaste) {
8443
8490
  lines.push('', t(locale, 'weixin_copy_paste_divider'), t(locale, 'weixin_copy_auth_title'), ...page.visible.map(({ index }) => `/auth use ${index + 1}`), ...page.visible.map(({ candidate, index }) => candidate.disabled
8444
8491
  ? `/auth enable ${index + 1}`
8445
- : `/auth disable ${index + 1}`), '/auth filter all', '/auth filter enabled', '/auth filter attention', '/auth list <keyword>', '/login_device', '/auth reload', '/permissions');
8492
+ : `/auth disable ${index + 1}`), '/auth filter all', '/auth filter enabled', '/auth filter attention', '/auth list <keyword>', '/login_device', '/auth sync safe', '/auth reload', '/permissions');
8446
8493
  }
8447
8494
  return lines.join('\n');
8448
8495
  }
@@ -8480,7 +8527,10 @@ function authChoiceKeyboard(locale, record) {
8480
8527
  { text: t(locale, 'button_permissions'), callback_data: 'nav:permissions' },
8481
8528
  { text: t(locale, 'button_login_device'), callback_data: `auth:${record.localId}:login_device` },
8482
8529
  ]);
8483
- rows.push([{ text: t(locale, 'button_auth_reload'), callback_data: `auth:${record.localId}:reload` }]);
8530
+ rows.push([
8531
+ { text: t(locale, 'button_auth_safe_sync'), callback_data: `auth:${record.localId}:safe_sync` },
8532
+ { text: t(locale, 'button_auth_reload'), callback_data: `auth:${record.localId}:reload` },
8533
+ ]);
8484
8534
  return rows;
8485
8535
  }
8486
8536
  function formatCodexAuthCandidateDisplayName(name) {
@@ -9322,10 +9372,11 @@ function formatRemainingUsagePercent(usedPercent) {
9322
9372
  const remainingPercent = remainingUsagePercent(usedPercent);
9323
9373
  return remainingPercent === null ? '?' : formatUsagePercent(remainingPercent);
9324
9374
  }
9325
- function authQuotaSnapshotFromRateLimit(snapshot, accountId = null) {
9375
+ function authQuotaSnapshotFromRateLimit(snapshot, accountId = null, quotaIdentityId = null) {
9326
9376
  return {
9327
9377
  capturedAtMs: Date.now(),
9328
9378
  accountId,
9379
+ quotaIdentityId,
9329
9380
  planType: snapshot.planType,
9330
9381
  primaryWindowDurationMins: snapshot.primary?.windowDurationMins ?? null,
9331
9382
  primaryRemainingPercent: snapshot.primary ? remainingUsagePercent(snapshot.primary.usedPercent) : null,
@@ -9337,6 +9388,7 @@ function codexAuthQuotaSnapshotFromRecord(record) {
9337
9388
  return {
9338
9389
  capturedAtMs: record.capturedAtMs,
9339
9390
  accountId: record.accountId,
9391
+ quotaIdentityId: record.quotaIdentityId,
9340
9392
  planType: record.planType,
9341
9393
  primaryWindowDurationMins: record.primaryWindowDurationMins,
9342
9394
  primaryRemainingPercent: record.primaryRemainingPercent,
@@ -9356,6 +9408,7 @@ function mergeCodexAuthQuotaSnapshots(current, incoming) {
9356
9408
  return {
9357
9409
  ...freshest,
9358
9410
  accountId: freshest.accountId ?? older.accountId ?? null,
9411
+ quotaIdentityId: freshest.quotaIdentityId ?? older.quotaIdentityId ?? null,
9359
9412
  };
9360
9413
  }
9361
9414
  function isFiniteCodexAuthQuotaSnapshotRecord(record) {
@@ -9404,6 +9457,7 @@ function isCodexAuthQuotaSnapshot(value) {
9404
9457
  return typeof snapshot.capturedAtMs === 'number'
9405
9458
  && Number.isFinite(snapshot.capturedAtMs)
9406
9459
  && (snapshot.accountId === undefined || snapshot.accountId === null || typeof snapshot.accountId === 'string')
9460
+ && (snapshot.quotaIdentityId === undefined || snapshot.quotaIdentityId === null || typeof snapshot.quotaIdentityId === 'string')
9407
9461
  && (snapshot.planType === undefined || isNullableString(snapshot.planType))
9408
9462
  && (snapshot.primaryWindowDurationMins === undefined || isNullableFiniteNumber(snapshot.primaryWindowDurationMins))
9409
9463
  && isNullableFiniteNumber(snapshot.primaryRemainingPercent)
@@ -9414,6 +9468,7 @@ function normalizeCodexAuthQuotaSnapshot(snapshot) {
9414
9468
  return {
9415
9469
  capturedAtMs: snapshot.capturedAtMs,
9416
9470
  accountId: snapshot.accountId ?? null,
9471
+ quotaIdentityId: snapshot.quotaIdentityId ?? snapshot.accountId ?? null,
9417
9472
  planType: snapshot.planType ?? null,
9418
9473
  primaryWindowDurationMins: snapshot.primaryWindowDurationMins ?? null,
9419
9474
  primaryRemainingPercent: snapshot.primaryRemainingPercent,
@@ -9421,6 +9476,15 @@ function normalizeCodexAuthQuotaSnapshot(snapshot) {
9421
9476
  secondaryRemainingPercent: snapshot.secondaryRemainingPercent,
9422
9477
  };
9423
9478
  }
9479
+ function chatGptAuthMetadataCompatible(left, right) {
9480
+ if (left.accountId !== right.accountId) {
9481
+ return false;
9482
+ }
9483
+ if (left.quotaIdentityId === left.accountId || right.quotaIdentityId === right.accountId) {
9484
+ return true;
9485
+ }
9486
+ return left.quotaIdentityId === right.quotaIdentityId;
9487
+ }
9424
9488
  function isNullableFiniteNumber(value) {
9425
9489
  return value === null || (typeof value === 'number' && Number.isFinite(value));
9426
9490
  }
package/dist/i18n.d.ts CHANGED
@@ -143,8 +143,8 @@ declare const MESSAGES: {
143
143
  readonly auth_reload_restarting: "Restarting Codex app-server to reload auth...";
144
144
  readonly auth_reload_done: "Codex app-server restarted. Current auth has been reloaded.";
145
145
  readonly auth_reload_blocked_active: "Cannot reload Codex auth while a turn, approval, or question is active. Wait or use /interrupt first.";
146
- readonly usage_auth: "Usage: /auth [list [keyword]|filter <all|enabled|attention>|page <n>|use <n>|enable <n>|disable <n>|reload|refresh all [confirm]|sync <status|test|push all>|add <name>]";
147
- readonly usage_auth_sync: "Usage: /auth sync <status|events [filter]|trace <requestId>|test|push all>";
146
+ readonly usage_auth: "Usage: /auth [list [keyword]|filter <all|enabled|attention>|page <n>|use <n>|enable <n>|disable <n>|reload|refresh all [confirm]|sync <status|test|safe|push all>|add <name>]";
147
+ readonly usage_auth_sync: "Usage: /auth sync <status|events [filter]|trace <requestId>|test|safe|push all>";
148
148
  readonly usage_auth_add: "Usage: /auth add <name>. Use letters, numbers, dot, dash, or underscore.";
149
149
  readonly auth_list_title: "Codex auth files:";
150
150
  readonly auth_bot: "Bot runtime: {value}";
@@ -212,6 +212,8 @@ declare const MESSAGES: {
212
212
  readonly auth_sync_test_sent: "Auth sync test complete: sent {sent}, replies {replied}.";
213
213
  readonly auth_sync_test_missing: "Missing replies: {value}";
214
214
  readonly auth_sync_push_blocked_active: "Cannot push auth sync while any runtime, approval, input, login, or auth mirror write is active.";
215
+ readonly auth_sync_safe_starting: "Safely syncing auth across local bot runtimes and cross-node peers...";
216
+ readonly auth_sync_safe_done: "Safe auth sync complete: local synced {localSynced}, local skipped {localSkipped}; cross-node sent {sent}, skipped {skipped}.";
215
217
  readonly auth_sync_push_done: "Auth sync push complete: sent {sent}, skipped {skipped}.";
216
218
  readonly auth_sync_status_title: "Cross-node auth sync:";
217
219
  readonly auth_sync_status_node: "Node: {value}";
@@ -235,6 +237,7 @@ declare const MESSAGES: {
235
237
  readonly auth_sync_trace_missing: "Usage: /auth sync trace <requestId>";
236
238
  readonly button_login_device: "🔑 Login";
237
239
  readonly button_auth_reload: "🔄 Reload auth";
240
+ readonly button_auth_safe_sync: "🧷 Safe sync";
238
241
  readonly button_auth_refresh_all_confirm: "⚠️ Accept risk & refresh";
239
242
  readonly button_auth_enable: "✅";
240
243
  readonly button_auth_disable: "⏸️";
@@ -796,8 +799,8 @@ declare const MESSAGES: {
796
799
  readonly auth_reload_restarting: "正在重启 Codex app-server 以重新读取 auth...";
797
800
  readonly auth_reload_done: "Codex app-server 已重启,当前 auth 已重新读取。";
798
801
  readonly auth_reload_blocked_active: "当前有回复、审批或问题在进行中,不能重载 Codex auth。请先等待,或使用 /interrupt。";
799
- readonly usage_auth: "用法:/auth [list [关键词]|filter <all|enabled|attention>|page <页码>|use <编号>|enable <编号>|disable <编号>|reload|refresh all [confirm]|sync <status|test|push all>|add <名称>]";
800
- readonly usage_auth_sync: "用法:/auth sync <status|events [过滤]|trace <requestId>|test|push all>";
802
+ readonly usage_auth: "用法:/auth [list [关键词]|filter <all|enabled|attention>|page <页码>|use <编号>|enable <编号>|disable <编号>|reload|refresh all [confirm]|sync <status|test|safe|push all>|add <名称>]";
803
+ readonly usage_auth_sync: "用法:/auth sync <status|events [过滤]|trace <requestId>|test|safe|push all>";
801
804
  readonly usage_auth_add: "用法:/auth add <名称>。名称只能包含字母、数字、点、短横线或下划线。";
802
805
  readonly auth_list_title: "Codex auth 文件:";
803
806
  readonly auth_bot: "Bot runtime:{value}";
@@ -865,6 +868,8 @@ declare const MESSAGES: {
865
868
  readonly auth_sync_test_sent: "auth sync 测试完成:已发送 {sent},收到回应 {replied}。";
866
869
  readonly auth_sync_test_missing: "未回应:{value}";
867
870
  readonly auth_sync_push_blocked_active: "当前有任一 runtime、审批、待输入、登录或 auth 镜像写入在进行中,不能推送 auth 同步。";
871
+ readonly auth_sync_safe_starting: "正在安全同步本机多 bot runtime 和跨节点 auth...";
872
+ readonly auth_sync_safe_done: "安全 auth 同步完成:本机同步 {localSynced},本机跳过 {localSkipped};跨节点发送 {sent},跳过 {skipped}。";
868
873
  readonly auth_sync_push_done: "auth 同步推送完成:已发送 {sent},已跳过 {skipped}。";
869
874
  readonly auth_sync_status_title: "跨节点 auth 同步:";
870
875
  readonly auth_sync_status_node: "节点:{value}";
@@ -888,6 +893,7 @@ declare const MESSAGES: {
888
893
  readonly auth_sync_trace_missing: "用法:/auth sync trace <requestId>";
889
894
  readonly button_login_device: "🔑 设备登录";
890
895
  readonly button_auth_reload: "🔄 重载 auth";
896
+ readonly button_auth_safe_sync: "🧷 安全同步";
891
897
  readonly button_auth_refresh_all_confirm: "⚠️ 接受风险并刷新";
892
898
  readonly button_auth_enable: "✅";
893
899
  readonly button_auth_disable: "⏸️";
package/dist/i18n.js CHANGED
@@ -141,8 +141,8 @@ const MESSAGES = {
141
141
  auth_reload_restarting: 'Restarting Codex app-server to reload auth...',
142
142
  auth_reload_done: 'Codex app-server restarted. Current auth has been reloaded.',
143
143
  auth_reload_blocked_active: 'Cannot reload Codex auth while a turn, approval, or question is active. Wait or use /interrupt first.',
144
- usage_auth: 'Usage: /auth [list [keyword]|filter <all|enabled|attention>|page <n>|use <n>|enable <n>|disable <n>|reload|refresh all [confirm]|sync <status|test|push all>|add <name>]',
145
- usage_auth_sync: 'Usage: /auth sync <status|events [filter]|trace <requestId>|test|push all>',
144
+ usage_auth: 'Usage: /auth [list [keyword]|filter <all|enabled|attention>|page <n>|use <n>|enable <n>|disable <n>|reload|refresh all [confirm]|sync <status|test|safe|push all>|add <name>]',
145
+ usage_auth_sync: 'Usage: /auth sync <status|events [filter]|trace <requestId>|test|safe|push all>',
146
146
  usage_auth_add: 'Usage: /auth add <name>. Use letters, numbers, dot, dash, or underscore.',
147
147
  auth_list_title: 'Codex auth files:',
148
148
  auth_bot: 'Bot runtime: {value}',
@@ -210,6 +210,8 @@ const MESSAGES = {
210
210
  auth_sync_test_sent: 'Auth sync test complete: sent {sent}, replies {replied}.',
211
211
  auth_sync_test_missing: 'Missing replies: {value}',
212
212
  auth_sync_push_blocked_active: 'Cannot push auth sync while any runtime, approval, input, login, or auth mirror write is active.',
213
+ auth_sync_safe_starting: 'Safely syncing auth across local bot runtimes and cross-node peers...',
214
+ auth_sync_safe_done: 'Safe auth sync complete: local synced {localSynced}, local skipped {localSkipped}; cross-node sent {sent}, skipped {skipped}.',
213
215
  auth_sync_push_done: 'Auth sync push complete: sent {sent}, skipped {skipped}.',
214
216
  auth_sync_status_title: 'Cross-node auth sync:',
215
217
  auth_sync_status_node: 'Node: {value}',
@@ -233,6 +235,7 @@ const MESSAGES = {
233
235
  auth_sync_trace_missing: 'Usage: /auth sync trace <requestId>',
234
236
  button_login_device: '🔑 Login',
235
237
  button_auth_reload: '🔄 Reload auth',
238
+ button_auth_safe_sync: '🧷 Safe sync',
236
239
  button_auth_refresh_all_confirm: '⚠️ Accept risk & refresh',
237
240
  button_auth_enable: '✅',
238
241
  button_auth_disable: '⏸️',
@@ -794,8 +797,8 @@ const MESSAGES = {
794
797
  auth_reload_restarting: '正在重启 Codex app-server 以重新读取 auth...',
795
798
  auth_reload_done: 'Codex app-server 已重启,当前 auth 已重新读取。',
796
799
  auth_reload_blocked_active: '当前有回复、审批或问题在进行中,不能重载 Codex auth。请先等待,或使用 /interrupt。',
797
- usage_auth: '用法:/auth [list [关键词]|filter <all|enabled|attention>|page <页码>|use <编号>|enable <编号>|disable <编号>|reload|refresh all [confirm]|sync <status|test|push all>|add <名称>]',
798
- usage_auth_sync: '用法:/auth sync <status|events [过滤]|trace <requestId>|test|push all>',
800
+ usage_auth: '用法:/auth [list [关键词]|filter <all|enabled|attention>|page <页码>|use <编号>|enable <编号>|disable <编号>|reload|refresh all [confirm]|sync <status|test|safe|push all>|add <名称>]',
801
+ usage_auth_sync: '用法:/auth sync <status|events [过滤]|trace <requestId>|test|safe|push all>',
799
802
  usage_auth_add: '用法:/auth add <名称>。名称只能包含字母、数字、点、短横线或下划线。',
800
803
  auth_list_title: 'Codex auth 文件:',
801
804
  auth_bot: 'Bot runtime:{value}',
@@ -863,6 +866,8 @@ const MESSAGES = {
863
866
  auth_sync_test_sent: 'auth sync 测试完成:已发送 {sent},收到回应 {replied}。',
864
867
  auth_sync_test_missing: '未回应:{value}',
865
868
  auth_sync_push_blocked_active: '当前有任一 runtime、审批、待输入、登录或 auth 镜像写入在进行中,不能推送 auth 同步。',
869
+ auth_sync_safe_starting: '正在安全同步本机多 bot runtime 和跨节点 auth...',
870
+ auth_sync_safe_done: '安全 auth 同步完成:本机同步 {localSynced},本机跳过 {localSkipped};跨节点发送 {sent},跳过 {skipped}。',
866
871
  auth_sync_push_done: 'auth 同步推送完成:已发送 {sent},已跳过 {skipped}。',
867
872
  auth_sync_status_title: '跨节点 auth 同步:',
868
873
  auth_sync_status_node: '节点:{value}',
@@ -886,6 +891,7 @@ const MESSAGES = {
886
891
  auth_sync_trace_missing: '用法:/auth sync trace <requestId>',
887
892
  button_login_device: '🔑 设备登录',
888
893
  button_auth_reload: '🔄 重载 auth',
894
+ button_auth_safe_sync: '🧷 安全同步',
889
895
  button_auth_refresh_all_confirm: '⚠️ 接受风险并刷新',
890
896
  button_auth_enable: '✅',
891
897
  button_auth_disable: '⏸️',
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
  },
@@ -342,6 +343,16 @@ async function runServeCli() {
342
343
  releaseAuthRefreshLease: (leaseId) => authSync?.releaseRefreshLease(leaseId)
343
344
  ?? localAuthRefreshLease.release(leaseId),
344
345
  getAuthSyncStatus: () => authSync?.getStatus() ?? null,
346
+ authSyncSafeAll: async () => {
347
+ const local = await mirror.syncAllRuntimeCandidates();
348
+ const remote = await authSync?.pushAll() ?? { sent: 0, skipped: 0 };
349
+ return {
350
+ localSynced: local.synced,
351
+ localSkipped: local.skipped,
352
+ sent: remote.sent,
353
+ skipped: remote.skipped,
354
+ };
355
+ },
345
356
  authSyncPushAll: () => authSync?.pushAll() ?? Promise.resolve({ sent: 0, skipped: 0 }),
346
357
  authSyncTest: () => authSync?.testPeers() ?? Promise.resolve({ sent: 0, replied: 0, missing: [] }),
347
358
  statusUpdated: () => writeAggregateStatus(),
@@ -492,6 +503,7 @@ async function runServeCli() {
492
503
  ?? null;
493
504
  return await singleAuthSync?.requestRecovery(candidateName, {
494
505
  accountId: current?.accountId ?? null,
506
+ quotaIdentityId: current?.quotaIdentityId ?? null,
495
507
  lastRefreshMs: current?.lastRefreshMs ?? null,
496
508
  }) ?? false;
497
509
  },
@@ -500,6 +512,16 @@ async function runServeCli() {
500
512
  releaseAuthRefreshLease: (leaseId) => singleAuthSync?.releaseRefreshLease(leaseId)
501
513
  ?? singleLocalAuthRefreshLease.release(leaseId),
502
514
  getAuthSyncStatus: () => singleAuthSync?.getStatus() ?? null,
515
+ authSyncSafeAll: async () => {
516
+ const local = await singleMirror?.syncAllRuntimeCandidates() ?? { synced: 0, skipped: 0 };
517
+ const remote = await singleAuthSync?.pushAll() ?? { sent: 0, skipped: 0 };
518
+ return {
519
+ localSynced: local.synced,
520
+ localSkipped: local.skipped,
521
+ sent: remote.sent,
522
+ skipped: remote.skipped,
523
+ };
524
+ },
503
525
  authSyncPushAll: () => singleAuthSync?.pushAll() ?? Promise.resolve({ sent: 0, skipped: 0 }),
504
526
  authSyncTest: () => singleAuthSync?.testPeers() ?? Promise.resolve({ sent: 0, replied: 0, missing: [] }),
505
527
  statusUpdated: (status) => {
@@ -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?**