@foxden-app/foxclaw 0.5.73 → 0.5.76

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,28 @@
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.76 - 2026-07-11
6
+
7
+ ### 中文
8
+ - `/auth` 面板新增“全节点自检并同步”按钮,命令等价入口为 `/auth sync audit`。发起节点会申请跨节点刷新锁,通知所有已配置 peer 逐个通过 Codex usage 接口验证 auth,并在同一轮中汇总节点、有效候选、无效候选、未回应、忙碌和身份冲突状态。
9
+ - 集群审计会在同名、同 account、同 ChatGPT 用户身份范围内采用最新且验证有效的副本,并分发给所有 peer。完整审计确认较新的副本无效时,允许经过验证的较旧有效副本替换它;任一节点未回应或忙碌时仍坚持普通的只接受更新副本规则。
10
+ - 只有所有 peer 都回应、没有任何有效副本,并且至少两个节点独立确认无效时,才会把候选统一标记为 `?` 等待人工处理;有效副本导入后会恢复 active 状态并清除旧失败记录。
11
+ - 临期主动刷新阈值由 9 天统一为 `last_refresh` 已满 8 天。集群自检完整结束后,由主动发起的 bot 刷新符合条件的已启用 ChatGPT auth,再向所有 peer 分发;同一节点的 canonical 和各 Codex home 副本都会参与检查,避免漏掉本机仍有效的旧副本。
12
+
13
+ ### English
14
+ - Added **Check all nodes and reconcile auth** to the `/auth` panel, with `/auth sync audit` as the command equivalent. The initiating node acquires the cross-node refresh lease, asks every configured peer to validate each auth through the Codex usage endpoint, and summarizes responding, missing, busy, valid, invalid, and identity-conflicting states in one run.
15
+ - Cluster audit selects the newest validated copy among same-name candidates with matching account and ChatGPT user identity, then distributes it to every peer. A validated older copy may replace a newer invalid copy only after a complete audit; missing or busy peers keep normal newer-only import semantics.
16
+ - A candidate is marked `?` only when every peer responds, no valid copy exists, and at least two nodes independently confirm it invalid. Importing a valid copy restores the active state and clears stale failure records.
17
+ - Unified proactive maintenance at `last_refresh` age 8 days instead of 9. After a complete audit, the initiating bot refreshes eligible enabled ChatGPT auth and distributes the result. Audits inspect canonical and per-Codex-home copies on each node so an older but still valid local copy is not overlooked.
18
+
19
+ ## 0.5.74 - 2026-07-11
20
+
21
+ ### 中文
22
+ - FoxClaw 现在优先保留 Node 实际调用的 `process.argv[1]` 作为自身入口,而不是使用会解析软链接的 `import.meta.url`。这样 pnpm 11 shim 中的 `global/v11/<instance>` 布局信息不会在进入更新器前丢失,自更新可以保持在 pnpm 11 隔离全局目录中完成。
23
+
24
+ ### English
25
+ - FoxClaw now preserves Node's invoked `process.argv[1]` as its own entry point instead of relying on `import.meta.url`, which resolves symlinks. This retains pnpm 11's `global/v11/<instance>` layout information before self-update starts, allowing updates to remain within the pnpm 11 isolated global installation.
26
+
5
27
  ## 0.5.73 - 2026-07-11
6
28
 
7
29
  ### 中文
@@ -62,6 +62,19 @@ export interface AuthSyncValidationResult {
62
62
  ok: boolean;
63
63
  reason?: string | null;
64
64
  }
65
+ export interface AuthSyncClusterAuditResult {
66
+ requestId: string;
67
+ nodesExpected: number;
68
+ nodesResponded: number;
69
+ missingPeers: string[];
70
+ busyNodes: string[];
71
+ checkedCandidates: number;
72
+ validCandidates: number;
73
+ invalidCandidates: number;
74
+ synchronizedCandidates: string[];
75
+ consensusInvalidCandidates: string[];
76
+ identityConflicts: string[];
77
+ }
65
78
  export type AuthSyncRemoteImportMode = 'push' | 'pull';
66
79
  export type AuthSyncPullResponseResult = 'sent' | 'candidate_not_found' | 'account_mismatch' | 'not_newer';
67
80
  export type AuthSyncNotification = {
@@ -219,11 +232,18 @@ export type AuthSyncNotification = {
219
232
  export interface AuthSyncImportCallbacks {
220
233
  readLocalCandidate: (candidateName: string) => Promise<AuthMirrorCandidateRecord | null>;
221
234
  listLocalCandidates: () => Promise<AuthMirrorCandidateRecord[]>;
235
+ listLocalCandidateCopies?: () => Promise<AuthMirrorCandidateRecord[]>;
222
236
  validateCandidate: (candidateName: string, raw: string, expectedAccountId: string) => Promise<AuthSyncValidationResult>;
223
237
  importCandidate: (candidateName: string, raw: string, source: {
224
238
  nodeId: string;
225
239
  label?: string | null;
240
+ replaceExisting?: boolean;
226
241
  }) => Promise<AuthMirrorImportResult>;
242
+ markCandidateState?: (candidateName: string, state: 'active' | 'needs_repair', expected: {
243
+ accountId: string;
244
+ quotaIdentityId: string | null;
245
+ maxLastRefreshMs: number;
246
+ }) => Promise<boolean>;
227
247
  deleteLocalCandidate?: (candidateName: string, source: {
228
248
  nodeId: string;
229
249
  label?: string | null;
@@ -278,6 +298,7 @@ export declare class CrossNodeAuthSync {
278
298
  private readonly pendingPulls;
279
299
  private readonly pendingLeases;
280
300
  private readonly pendingTests;
301
+ private readonly pendingAudits;
281
302
  private seenNonces;
282
303
  private lastPeerActivityAt;
283
304
  private timer;
@@ -308,12 +329,20 @@ export declare class CrossNodeAuthSync {
308
329
  acquireRefreshLease(reason: string): Promise<AuthSyncLeaseResult>;
309
330
  releaseRefreshLease(leaseId: string | null): Promise<void>;
310
331
  testPeers(): Promise<AuthSyncTestResult>;
332
+ auditCluster(): Promise<AuthSyncClusterAuditResult>;
311
333
  publishServiceUpdateRequest(targetVersion: string | null): Promise<{
312
334
  sent: number;
313
335
  peers: string[];
314
336
  }>;
315
337
  handleIncomingEnvelope(rawEnvelope: string, peer: AuthSyncPeerIdentity): Promise<boolean>;
316
338
  private handleMessage;
339
+ private handleAuditRequest;
340
+ private handleAuditResponse;
341
+ private handleAuditApply;
342
+ private applyAuditState;
343
+ private buildAuditReport;
344
+ private reconcileAuditReports;
345
+ private finishPendingAudit;
317
346
  private handleServiceUpdateRequest;
318
347
  private handlePullRequest;
319
348
  private handlePullResponse;
@@ -7,6 +7,7 @@ const ENVELOPE_MAGIC = 'foxclaw-auth-sync';
7
7
  const NONCE_RETENTION_MS = 7 * 24 * 60 * 60_000;
8
8
  const PULL_TIMEOUT_MS = 12_000;
9
9
  const TEST_TIMEOUT_MS = 8_000;
10
+ const AUDIT_TIMEOUT_MS = 5 * 60_000;
10
11
  const LEASE_TIMEOUT_MS = 8_000;
11
12
  const LEASE_TTL_MS = 10 * 60_000;
12
13
  const REMOTE_ACCESS_TOKEN_MIN_TTL_MS = 60_000;
@@ -25,6 +26,7 @@ export class CrossNodeAuthSync {
25
26
  pendingPulls = new Map();
26
27
  pendingLeases = new Map();
27
28
  pendingTests = new Map();
29
+ pendingAudits = new Map();
28
30
  seenNonces = new Map();
29
31
  lastPeerActivityAt = new Map();
30
32
  timer = null;
@@ -132,6 +134,7 @@ export class CrossNodeAuthSync {
132
134
  && this.pendingPulls.size === 0
133
135
  && this.pendingLeases.size === 0
134
136
  && this.pendingTests.size === 0
137
+ && this.pendingAudits.size === 0
135
138
  && this.activeLocalLease === null
136
139
  && this.activeRemoteLease === null;
137
140
  }
@@ -475,6 +478,63 @@ export class CrossNodeAuthSync {
475
478
  }
476
479
  return resultPromise;
477
480
  }
481
+ async auditCluster() {
482
+ const requestId = crypto.randomUUID();
483
+ const peers = [...this.peers];
484
+ const localReportPromise = this.buildAuditReport();
485
+ const remoteReportsPromise = new Promise((resolve) => {
486
+ if (!this.isReady() || peers.length === 0) {
487
+ resolve({ reports: new Map(), missing: [] });
488
+ return;
489
+ }
490
+ const timer = setTimeout(() => this.finishPendingAudit(requestId), AUDIT_TIMEOUT_MS);
491
+ timer.unref();
492
+ this.pendingAudits.set(requestId, {
493
+ peers,
494
+ reports: new Map(),
495
+ resolve,
496
+ timer,
497
+ finished: false,
498
+ });
499
+ });
500
+ this.recordEvent({
501
+ direction: 'local',
502
+ kind: 'audit.request',
503
+ stage: 'started',
504
+ peer: null,
505
+ requestId,
506
+ candidateName: null,
507
+ detail: `peers=${peers.join(', ') || 'none'}`,
508
+ });
509
+ if (this.isReady() && peers.length > 0) {
510
+ try {
511
+ await this.sendToAll({ kind: 'audit.request', requestId, requestedAt: new Date().toISOString() });
512
+ }
513
+ catch (error) {
514
+ const pending = this.pendingAudits.get(requestId);
515
+ if (pending) {
516
+ clearTimeout(pending.timer);
517
+ this.pendingAudits.delete(requestId);
518
+ pending.finished = true;
519
+ pending.resolve({ reports: pending.reports, missing: pending.peers.filter(peer => !pending.reports.has(peer)) });
520
+ }
521
+ throw error;
522
+ }
523
+ }
524
+ const [localReport, remote] = await Promise.all([localReportPromise, remoteReportsPromise]);
525
+ const reports = [localReport, ...remote.reports.values()];
526
+ const result = await this.reconcileAuditReports(requestId, reports, remote.missing);
527
+ this.recordEvent({
528
+ direction: 'local',
529
+ kind: 'audit.request',
530
+ stage: 'completed',
531
+ peer: null,
532
+ requestId,
533
+ candidateName: null,
534
+ detail: `responded=${result.nodesResponded}/${result.nodesExpected}; synchronized=${result.synchronizedCandidates.length}; repair=${result.consensusInvalidCandidates.length}`,
535
+ });
536
+ return result;
537
+ }
478
538
  async publishServiceUpdateRequest(targetVersion) {
479
539
  if (!this.isReady()) {
480
540
  return { sent: 0, peers: [] };
@@ -575,6 +635,18 @@ export class CrossNodeAuthSync {
575
635
  this.recordEvent({ direction: 'in', kind: 'lease.release', stage: 'released', peer: normalizePeerIdentity(peer), requestId: message.leaseId, candidateName: null, detail: null });
576
636
  }
577
637
  return;
638
+ case 'audit.request':
639
+ await this.handleAuditRequest(message, normalizePeerIdentity(peer));
640
+ return;
641
+ case 'audit.response':
642
+ this.handleAuditResponse(message, normalizePeerIdentity(peer));
643
+ return;
644
+ case 'audit.apply':
645
+ await this.handleAuditApply(message, senderNodeId, sourceLabel, normalizePeerIdentity(peer));
646
+ return;
647
+ case 'audit.state':
648
+ await this.applyAuditState(message);
649
+ return;
578
650
  case 'service.update.request':
579
651
  await this.handleServiceUpdateRequest(message, senderNodeId, sourceLabel, normalizePeerIdentity(peer));
580
652
  return;
@@ -582,6 +654,224 @@ export class CrossNodeAuthSync {
582
654
  return;
583
655
  }
584
656
  }
657
+ async handleAuditRequest(message, peer) {
658
+ const report = await this.buildAuditReport();
659
+ await this.sendToPeer(peer, { kind: 'audit.response', requestId: message.requestId, report });
660
+ this.recordEvent({
661
+ direction: 'local',
662
+ kind: 'audit.response',
663
+ stage: 'sent',
664
+ peer,
665
+ requestId: message.requestId,
666
+ candidateName: null,
667
+ detail: `${report.status}; records=${report.records.length}`,
668
+ });
669
+ }
670
+ handleAuditResponse(message, peer) {
671
+ const pending = this.pendingAudits.get(message.requestId);
672
+ if (!pending || pending.finished || !isValidAuditReport(message.report))
673
+ return;
674
+ const matchedPeer = this.matchConfiguredPeer(peer) ?? peer;
675
+ pending.reports.set(matchedPeer, message.report);
676
+ this.recordEvent({
677
+ direction: 'local',
678
+ kind: 'audit.response',
679
+ stage: 'received',
680
+ peer: matchedPeer,
681
+ requestId: message.requestId,
682
+ candidateName: null,
683
+ detail: `${message.report.status}; records=${message.report.records.length}; node=${message.report.nodeId}`,
684
+ });
685
+ if (pending.peers.every(peerName => pending.reports.has(peerName))) {
686
+ this.finishPendingAudit(message.requestId);
687
+ }
688
+ }
689
+ async handleAuditApply(message, senderNodeId, sourceLabel, peer) {
690
+ const outcome = await this.validateAndImport({ ...message.bundle, requestId: message.requestId }, senderNodeId, sourceLabel, peer, 'push', message.replaceExisting);
691
+ if (!outcome.ok)
692
+ return;
693
+ const bundle = message.bundle;
694
+ const stateRestored = await this.callbacks.markCandidateState?.(bundle.candidateName, 'active', {
695
+ accountId: bundle.accountId,
696
+ quotaIdentityId: bundle.quotaIdentityId ?? null,
697
+ maxLastRefreshMs: bundle.lastRefreshMs,
698
+ });
699
+ if (stateRestored && this.clearCandidateFailure(bundle.candidateName)) {
700
+ await this.writeState();
701
+ }
702
+ }
703
+ async applyAuditState(message) {
704
+ const applied = await this.callbacks.markCandidateState?.(message.candidateName, message.state, {
705
+ accountId: message.accountId,
706
+ quotaIdentityId: message.quotaIdentityId,
707
+ maxLastRefreshMs: message.maxLastRefreshMs,
708
+ });
709
+ if (applied && message.state === 'active' && this.clearCandidateFailure(message.candidateName)) {
710
+ await this.writeState();
711
+ }
712
+ this.recordEvent({
713
+ direction: 'local',
714
+ kind: 'audit.state',
715
+ stage: message.state,
716
+ peer: null,
717
+ requestId: message.requestId,
718
+ candidateName: message.candidateName,
719
+ detail: null,
720
+ });
721
+ }
722
+ async buildAuditReport() {
723
+ if (!this.callbacks.isIdle()) {
724
+ return {
725
+ nodeId: this.nodeId ?? 'local',
726
+ status: 'busy',
727
+ records: [],
728
+ reason: 'runtime is not idle',
729
+ };
730
+ }
731
+ const records = [];
732
+ const candidates = await (this.callbacks.listLocalCandidateCopies?.() ?? this.callbacks.listLocalCandidates());
733
+ for (const candidate of candidates) {
734
+ const bundle = bundleFromRecord(candidate);
735
+ const expiresAt = readAccessTokenExpiresAtMs(candidate.raw);
736
+ let validation;
737
+ if (expiresAt === null || expiresAt <= Date.now() + REMOTE_ACCESS_TOKEN_MIN_TTL_MS) {
738
+ validation = { ok: false, reason: 'access token is expired or missing exp' };
739
+ }
740
+ else {
741
+ validation = await this.callbacks.validateCandidate(candidate.candidateName, candidate.raw, candidate.accountId);
742
+ }
743
+ records.push({
744
+ candidateName: candidate.candidateName,
745
+ accountId: candidate.accountId,
746
+ quotaIdentityId: candidate.quotaIdentityId ?? null,
747
+ lastRefreshMs: candidate.lastRefreshMs,
748
+ status: validation.ok ? 'valid' : 'invalid',
749
+ reason: validation.ok ? null : validation.reason ?? 'usage validation failed',
750
+ bundle: validation.ok ? bundle : null,
751
+ });
752
+ }
753
+ return {
754
+ nodeId: this.nodeId ?? 'local',
755
+ status: 'completed',
756
+ records,
757
+ reason: null,
758
+ };
759
+ }
760
+ async reconcileAuditReports(requestId, reports, missingPeers) {
761
+ const completedReports = reports.filter(report => report.status === 'completed');
762
+ const busyNodes = reports.filter(report => report.status === 'busy').map(report => report.nodeId);
763
+ const byCandidate = new Map();
764
+ for (const report of completedReports) {
765
+ for (const record of report.records) {
766
+ const entries = byCandidate.get(record.candidateName) ?? [];
767
+ entries.push({ report, record });
768
+ byCandidate.set(record.candidateName, entries);
769
+ }
770
+ }
771
+ const synchronizedCandidates = [];
772
+ const consensusInvalidCandidates = [];
773
+ const identityConflicts = [];
774
+ const completeAudit = missingPeers.length === 0 && busyNodes.length === 0;
775
+ let validCandidates = 0;
776
+ let invalidCandidates = 0;
777
+ for (const [candidateName, entries] of byCandidate) {
778
+ const identities = new Set(entries.map(({ record }) => auditIdentityKey(record.accountId, record.quotaIdentityId)));
779
+ if (identities.size !== 1) {
780
+ identityConflicts.push(candidateName);
781
+ continue;
782
+ }
783
+ const valid = entries
784
+ .filter(({ record }) => record.status === 'valid' && record.bundle !== null)
785
+ .sort((left, right) => right.record.lastRefreshMs - left.record.lastRefreshMs);
786
+ if (valid.length > 0) {
787
+ validCandidates += 1;
788
+ const selected = valid[0];
789
+ const bundle = selected.record.bundle;
790
+ const currentLocal = await this.callbacks.readLocalCandidate(candidateName);
791
+ let locallyValidated = selected.report.nodeId === this.nodeId
792
+ && currentLocal?.lastRefreshMs === bundle.lastRefreshMs
793
+ && sha256(currentLocal.raw) === bundle.authSha256;
794
+ if (!locallyValidated) {
795
+ const outcome = await this.validateAndImport({ ...bundle, requestId }, selected.report.nodeId, selected.report.nodeId, selected.report.nodeId, 'push', completeAudit);
796
+ locallyValidated = outcome.ok;
797
+ }
798
+ if (!locallyValidated)
799
+ continue;
800
+ const stateRestored = await this.callbacks.markCandidateState?.(candidateName, 'active', {
801
+ accountId: bundle.accountId,
802
+ quotaIdentityId: bundle.quotaIdentityId ?? null,
803
+ maxLastRefreshMs: bundle.lastRefreshMs,
804
+ });
805
+ if (stateRestored && this.clearCandidateFailure(candidateName)) {
806
+ await this.writeState();
807
+ }
808
+ if (this.isReady()) {
809
+ await this.sendToAll({ kind: 'audit.apply', requestId, bundle, replaceExisting: completeAudit });
810
+ await this.sendToAll({
811
+ kind: 'audit.state',
812
+ requestId,
813
+ candidateName,
814
+ state: 'active',
815
+ accountId: bundle.accountId,
816
+ quotaIdentityId: bundle.quotaIdentityId ?? null,
817
+ maxLastRefreshMs: bundle.lastRefreshMs,
818
+ });
819
+ }
820
+ synchronizedCandidates.push(candidateName);
821
+ continue;
822
+ }
823
+ invalidCandidates += 1;
824
+ const invalidEntries = entries.filter(({ record }) => record.status === 'invalid');
825
+ const invalidNodes = new Set(invalidEntries.map(({ report }) => report.nodeId));
826
+ const consensusReached = completeAudit
827
+ && invalidNodes.size >= 2
828
+ && invalidEntries.length === entries.length;
829
+ if (!consensusReached)
830
+ continue;
831
+ const latest = invalidEntries.reduce((current, entry) => (!current || entry.record.lastRefreshMs > current.record.lastRefreshMs ? entry : current), null);
832
+ if (!latest)
833
+ continue;
834
+ const expected = {
835
+ accountId: latest.record.accountId,
836
+ quotaIdentityId: latest.record.quotaIdentityId,
837
+ maxLastRefreshMs: latest.record.lastRefreshMs,
838
+ };
839
+ await this.callbacks.markCandidateState?.(candidateName, 'needs_repair', expected);
840
+ if (this.isReady()) {
841
+ await this.sendToAll({
842
+ kind: 'audit.state',
843
+ requestId,
844
+ candidateName,
845
+ state: 'needs_repair',
846
+ ...expected,
847
+ });
848
+ }
849
+ consensusInvalidCandidates.push(candidateName);
850
+ }
851
+ return {
852
+ requestId,
853
+ nodesExpected: this.peers.length + 1,
854
+ nodesResponded: reports.length,
855
+ missingPeers,
856
+ busyNodes,
857
+ checkedCandidates: byCandidate.size,
858
+ validCandidates,
859
+ invalidCandidates,
860
+ synchronizedCandidates,
861
+ consensusInvalidCandidates,
862
+ identityConflicts,
863
+ };
864
+ }
865
+ finishPendingAudit(requestId) {
866
+ const pending = this.pendingAudits.get(requestId);
867
+ if (!pending || pending.finished)
868
+ return;
869
+ pending.finished = true;
870
+ clearTimeout(pending.timer);
871
+ this.pendingAudits.delete(requestId);
872
+ const missing = pending.peers.filter(peer => !pending.reports.has(peer));
873
+ pending.resolve({ reports: pending.reports, missing });
874
+ }
585
875
  async handleServiceUpdateRequest(message, senderNodeId, sourceLabel, peer) {
586
876
  let result = {
587
877
  accepted: false,
@@ -1036,7 +1326,7 @@ export class CrossNodeAuthSync {
1036
1326
  reason: result.reason ?? 'candidate was already absent',
1037
1327
  });
1038
1328
  }
1039
- async validateAndImport(bundle, sourceNodeId, sourceLabel, fromPeer, mode) {
1329
+ async validateAndImport(bundle, sourceNodeId, sourceLabel, fromPeer, mode, replaceExisting = false) {
1040
1330
  const source = sourceLabel ?? fromPeer;
1041
1331
  if (!isValidBundle(bundle)) {
1042
1332
  return this.rejectImport(bundle, sourceNodeId, source, fromPeer, mode, 'remote bundle shape is invalid');
@@ -1062,6 +1352,7 @@ export class CrossNodeAuthSync {
1062
1352
  const result = await this.callbacks.importCandidate(bundle.candidateName, bundle.rawAuth, {
1063
1353
  nodeId: sourceNodeId,
1064
1354
  label: source,
1355
+ replaceExisting,
1065
1356
  });
1066
1357
  if (!result.ok) {
1067
1358
  return this.rejectImport(bundle, sourceNodeId, source, fromPeer, mode, `remote candidate import failed for ${bundle.candidateName}: ${result.reason ?? 'unknown'}`);
@@ -1414,6 +1705,26 @@ function isValidBundle(value) {
1414
1705
  && typeof value.rawAuth === 'string'
1415
1706
  && typeof value.authSha256 === 'string';
1416
1707
  }
1708
+ function isValidAuditReport(value) {
1709
+ return Boolean(value)
1710
+ && typeof value.nodeId === 'string'
1711
+ && (value.status === 'completed' || value.status === 'busy')
1712
+ && Array.isArray(value.records)
1713
+ && (typeof value.reason === 'string' || value.reason === null)
1714
+ && value.records.every(record => (typeof record.candidateName === 'string'
1715
+ && isAuthCandidateName(record.candidateName)
1716
+ && typeof record.accountId === 'string'
1717
+ && (typeof record.quotaIdentityId === 'string' || record.quotaIdentityId === null)
1718
+ && typeof record.lastRefreshMs === 'number'
1719
+ && Number.isFinite(record.lastRefreshMs)
1720
+ && (record.status === 'valid' || record.status === 'invalid')
1721
+ && (typeof record.reason === 'string' || record.reason === null)
1722
+ && (record.bundle === null || isValidBundle(record.bundle))
1723
+ && (record.status !== 'valid' || record.bundle !== null)));
1724
+ }
1725
+ function auditIdentityKey(accountId, quotaIdentityId) {
1726
+ return `${accountId}\u0000${quotaIdentityId || accountId}`;
1727
+ }
1417
1728
  function quotaIdentitiesCompatible(accountId, left, right) {
1418
1729
  if (!left || !right || left === accountId || right === accountId) {
1419
1730
  return true;
@@ -1522,6 +1833,10 @@ function requestIdFromMessage(message) {
1522
1833
  case 'delete.candidate':
1523
1834
  case 'test.ping':
1524
1835
  case 'test.pong':
1836
+ case 'audit.request':
1837
+ case 'audit.response':
1838
+ case 'audit.apply':
1839
+ case 'audit.state':
1525
1840
  case 'service.update.request':
1526
1841
  return message.requestId;
1527
1842
  case 'lease.request':
@@ -1542,6 +1857,10 @@ function candidateNameFromMessage(message) {
1542
1857
  return message.candidateName;
1543
1858
  case 'pull.response':
1544
1859
  return message.bundle?.candidateName ?? null;
1860
+ case 'audit.apply':
1861
+ return message.bundle.candidateName;
1862
+ case 'audit.state':
1863
+ return message.candidateName;
1545
1864
  default:
1546
1865
  return null;
1547
1866
  }
@@ -95,6 +95,7 @@ export declare class AuthCandidateMirror {
95
95
  readNewestCandidate(candidateName: string): Promise<AuthMirrorCandidateRecord | null>;
96
96
  readRuntimeCandidate(runtimeId: string, candidateName: string): Promise<AuthMirrorCandidateRecord | null>;
97
97
  listNewestCandidates(): Promise<AuthMirrorCandidateRecord[]>;
98
+ listCandidateCopies(): Promise<AuthMirrorCandidateRecord[]>;
98
99
  syncRuntimeCandidate(runtimeId: string, candidateName: string): Promise<boolean>;
99
100
  deleteCandidate(candidateName: string): Promise<boolean>;
100
101
  syncAllRuntimeCandidates(): Promise<AuthMirrorSyncAllResult>;
@@ -104,6 +105,7 @@ export declare class AuthCandidateMirror {
104
105
  importExternalCandidate(candidateName: string, raw: string, source: {
105
106
  nodeId: string;
106
107
  label?: string | null;
108
+ replaceExisting?: boolean;
107
109
  }): Promise<AuthMirrorImportResult>;
108
110
  private propagateValidatedCandidate;
109
111
  private validateRuntimeCandidate;
@@ -118,6 +118,28 @@ export class AuthCandidateMirror {
118
118
  return [...byName.values()].sort((a, b) => a.candidateName.localeCompare(b.candidateName));
119
119
  });
120
120
  }
121
+ async listCandidateCopies() {
122
+ return this.withActivity(async () => {
123
+ const copies = [];
124
+ const seen = new Set();
125
+ for (const entry of await this.collectAuthRecords()) {
126
+ if (!isAuthCandidateName(entry.candidateName))
127
+ continue;
128
+ const key = `${entry.candidateName}\u0000${entry.record.accountId}\u0000${entry.record.quotaIdentityId}\u0000${entry.record.lastRefreshMs}\u0000${entry.record.raw}`;
129
+ if (seen.has(key))
130
+ continue;
131
+ seen.add(key);
132
+ copies.push({
133
+ ...entry.record,
134
+ candidateName: entry.candidateName,
135
+ sourceRuntimeId: entry.runtimeId,
136
+ sourceLabel: this.runtimeLabel(entry.runtimeId),
137
+ });
138
+ }
139
+ return copies.sort((left, right) => (left.candidateName.localeCompare(right.candidateName)
140
+ || right.lastRefreshMs - left.lastRefreshMs));
141
+ });
142
+ }
121
143
  async syncRuntimeCandidate(runtimeId, candidateName) {
122
144
  if (!isAuthCandidateName(candidateName))
123
145
  return false;
@@ -280,7 +302,7 @@ export class AuthCandidateMirror {
280
302
  }
281
303
  const newest = existing.reduce((current, entry) => (!current || entry.record.lastRefreshMs > current.record.lastRefreshMs ? entry : current), null);
282
304
  const previousRefresh = Math.max(newest?.record.lastRefreshMs ?? 0, this.lastSyncedRefresh.get(candidateName) ?? 0);
283
- if (metadata.lastRefreshMs <= previousRefresh) {
305
+ if (metadata.lastRefreshMs <= previousRefresh && !source.replaceExisting) {
284
306
  return { ok: true, imported: false, reason: 'local candidate is already newer or equal' };
285
307
  }
286
308
  await atomicWrite(path.join(this.canonicalDir, candidateName), raw);
@@ -1,6 +1,7 @@
1
1
  import type { AppConfig } from '../config.js';
2
2
  import type { Logger } from '../logger.js';
3
3
  import type { BridgeStore } from '../store/database.js';
4
+ import { type AuthSyncClusterAuditResult } from '../auth/cross_node_sync.js';
4
5
  import type { RuntimeStatus } from '../types.js';
5
6
  import type { TelegramGateway, TelegramTextEvent } from '../telegram/gateway.js';
6
7
  import { BridgeMessagingRouter } from '../channels/bridge_messaging_router.js';
@@ -36,6 +37,7 @@ export interface CoreCoordinator {
36
37
  replied: number;
37
38
  missing: string[];
38
39
  }>;
40
+ authSyncAudit?: () => Promise<AuthSyncClusterAuditResult | null>;
39
41
  statusUpdated?: (status: RuntimeStatus) => void;
40
42
  getServiceStatus?: () => Promise<{
41
43
  currentVersion?: string;
@@ -240,6 +242,7 @@ export declare class BridgeSessionCore {
240
242
  private editRichInternalMessage;
241
243
  private editAuthPanelMessage;
242
244
  private scheduleStalePanelDeletion;
245
+ private pauseStalePanelDeletion;
243
246
  private deleteMessage;
244
247
  private sendTyping;
245
248
  private sendObservedCliUserMessage;
@@ -313,6 +316,7 @@ export declare class BridgeSessionCore {
313
316
  private handleAuthCommand;
314
317
  private handleAuthSyncCommand;
315
318
  private runAuthSafeSyncAll;
319
+ private runAuthClusterAudit;
316
320
  private handleAuthRefreshAllCommand;
317
321
  private handleAuthUseCommand;
318
322
  private handleAuthToggleCommand;
@@ -41,7 +41,7 @@ const CODEX_AUTH_QUOTA_SNAPSHOT_FILENAME = 'codex-auth-quota.json';
41
41
  const CODEX_AUTH_LIST_PAGE_SIZE = 8;
42
42
  const CODEX_AUTH_LOW_QUOTA_PERCENT = 10;
43
43
  const CODEX_AUTH_STALE_CREDENTIAL_DAYS = 8;
44
- const CODEX_AUTH_PROACTIVE_REFRESH_DAYS = 9;
44
+ const CODEX_AUTH_PROACTIVE_REFRESH_DAYS = 8;
45
45
  const CODEX_AUTH_PROACTIVE_REFRESH_INTERVAL_MS = 60 * 60_000;
46
46
  const CODEX_AUTH_PROACTIVE_REFRESH_INITIAL_DELAY_MS = 5 * 60_000;
47
47
  const USER_INPUT_SUBMITTED_NOTICE_MS = 90_000;
@@ -1136,7 +1136,7 @@ export class BridgeSessionCore {
1136
1136
  await this.handleAuthListViewCallback(event, authClearSearchMatch[1], 'clear_search', locale);
1137
1137
  return;
1138
1138
  }
1139
- const authActionMatch = /^auth:([a-f0-9]+):(login_device|reload|safe_sync|refresh_all_confirm|refresh_all_cancel|refresh_all)$/.exec(event.data);
1139
+ const authActionMatch = /^auth:([a-f0-9]+):(login_device|reload|safe_sync|cluster_audit|refresh_all_confirm|refresh_all_cancel|refresh_all)$/.exec(event.data);
1140
1140
  if (authActionMatch) {
1141
1141
  await this.handleAuthPanelActionCallback(event, authActionMatch[1], authActionMatch[2], locale);
1142
1142
  return;
@@ -3468,6 +3468,14 @@ export class BridgeSessionCore {
3468
3468
  timer.unref();
3469
3469
  this.stalePanelDeleteTimers.set(key, timer);
3470
3470
  }
3471
+ pauseStalePanelDeletion(scopeId, messageId) {
3472
+ const key = `${scopeId}:${messageId}`;
3473
+ const timer = this.stalePanelDeleteTimers.get(key);
3474
+ if (!timer)
3475
+ return;
3476
+ clearTimeout(timer);
3477
+ this.stalePanelDeleteTimers.delete(key);
3478
+ }
3471
3479
  async deleteMessage(scopeId, messageId) {
3472
3480
  await this.messaging.deleteMessage(scopeId, messageId);
3473
3481
  }
@@ -4935,6 +4943,20 @@ export class BridgeSessionCore {
4935
4943
  await this.sendMessage(scopeId, message);
4936
4944
  return;
4937
4945
  }
4946
+ if (action === 'audit' || action === 'check') {
4947
+ if (!this.canRunGlobalAuthRefresh()) {
4948
+ await this.sendMessage(scopeId, t(locale, 'auth_cluster_audit_blocked_active'));
4949
+ return;
4950
+ }
4951
+ await this.sendMessage(scopeId, t(locale, 'auth_cluster_audit_starting'));
4952
+ const outcome = await this.runAuthClusterAudit();
4953
+ if (!outcome) {
4954
+ await this.sendMessage(scopeId, t(locale, 'auth_sync_disabled'));
4955
+ return;
4956
+ }
4957
+ await this.sendRichInternalMessage(scopeId, '/auth sync audit', formatAuthClusterAuditResult(locale, outcome));
4958
+ return;
4959
+ }
4938
4960
  if (action === 'safe' || (action === 'push' && args[1]?.toLowerCase() === 'all')) {
4939
4961
  if (!this.canRunGlobalAuthRefresh()) {
4940
4962
  await this.sendMessage(scopeId, t(locale, 'auth_sync_push_blocked_active'));
@@ -4965,6 +4987,48 @@ export class BridgeSessionCore {
4965
4987
  ? { localSynced: 0, localSkipped: 0, sent: pushResult.sent, skipped: pushResult.skipped }
4966
4988
  : null;
4967
4989
  }
4990
+ async runAuthClusterAudit() {
4991
+ if (!this.coordinator?.authSyncAudit)
4992
+ return null;
4993
+ const lease = await this.coordinator.acquireAuthRefreshLease?.('cluster auth audit and stale refresh');
4994
+ if (lease && !lease.ok) {
4995
+ throw new UserFacingError(lease.reason ?? 'cluster auth audit lease was not granted');
4996
+ }
4997
+ try {
4998
+ const audit = await this.coordinator.authSyncAudit();
4999
+ if (!audit)
5000
+ return null;
5001
+ const refresh = { refreshed: [], skipped: [], failed: [] };
5002
+ let refreshSkippedReason = null;
5003
+ const complete = audit.nodesResponded === audit.nodesExpected
5004
+ && audit.missingPeers.length === 0
5005
+ && audit.busyNodes.length === 0;
5006
+ if (complete) {
5007
+ const state = await this.listCodexAuthState();
5008
+ const staleNames = new Set(state.candidates
5009
+ .filter(candidate => (!candidate.disabled
5010
+ && candidate.state !== 'needs_repair'
5011
+ && candidate.credentialKind === 'chatgpt'
5012
+ && candidate.credentialLastRefreshMs !== null
5013
+ && candidate.credentialLastRefreshMs <= Date.now() - CODEX_AUTH_PROACTIVE_REFRESH_DAYS * 24 * 60 * 60_000))
5014
+ .map(candidate => candidate.name));
5015
+ if (staleNames.size > 0) {
5016
+ const refreshed = await this.refreshCodexAuthCandidates(staleNames);
5017
+ refresh.refreshed.push(...refreshed.refreshed);
5018
+ refresh.skipped.push(...refreshed.skipped);
5019
+ refresh.failed.push(...refreshed.failed);
5020
+ }
5021
+ }
5022
+ else {
5023
+ refreshSkippedReason = 'cluster audit was incomplete';
5024
+ }
5025
+ const push = await this.coordinator.authSyncPushAll?.() ?? { sent: 0, skipped: 0 };
5026
+ return { audit, refresh, push, refreshSkippedReason };
5027
+ }
5028
+ finally {
5029
+ await this.coordinator.releaseAuthRefreshLease?.(lease?.leaseId ?? null);
5030
+ }
5031
+ }
4968
5032
  async handleAuthRefreshAllCommand(scopeId, locale, confirmed = false) {
4969
5033
  if (!this.canRunGlobalAuthRefresh()) {
4970
5034
  await this.sendMessage(scopeId, t(locale, 'auth_refresh_all_blocked_active'));
@@ -5756,6 +5820,42 @@ export class BridgeSessionCore {
5756
5820
  }
5757
5821
  return;
5758
5822
  }
5823
+ if (action === 'cluster_audit') {
5824
+ if (!this.canRunGlobalAuthRefresh()) {
5825
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_cluster_audit_blocked_active'));
5826
+ return;
5827
+ }
5828
+ await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_cluster_audit_starting'));
5829
+ if (record.messageId !== null) {
5830
+ await this.editAuthPanelMessage(event.scopeId, record.messageId, t(locale, 'auth_cluster_audit_starting'), []);
5831
+ this.pauseStalePanelDeletion(event.scopeId, record.messageId);
5832
+ }
5833
+ let outcome;
5834
+ try {
5835
+ outcome = await this.runAuthClusterAudit();
5836
+ }
5837
+ catch (error) {
5838
+ if (record.messageId !== null) {
5839
+ await this.editAuthPanelMessage(event.scopeId, record.messageId, t(locale, 'auth_cluster_audit_failed', { error: formatUserError(error) }), authChoiceKeyboard(locale, record));
5840
+ }
5841
+ return;
5842
+ }
5843
+ if (!outcome) {
5844
+ if (record.messageId !== null) {
5845
+ await this.editAuthPanelMessage(event.scopeId, record.messageId, t(locale, 'auth_sync_disabled'), authChoiceKeyboard(locale, record));
5846
+ }
5847
+ return;
5848
+ }
5849
+ const state = await this.listCodexAuthState();
5850
+ await this.applySharedCodexAuthQuotaSnapshots(state);
5851
+ record.candidates = state.candidates;
5852
+ record.createdAt = Date.now();
5853
+ clampCodexAuthListOffset(record);
5854
+ if (record.messageId !== null) {
5855
+ await this.editAuthPanelMessage(event.scopeId, record.messageId, `${formatAuthClusterAuditResult(locale, outcome)}\n\n${renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(event.scopeId) !== null, record)}`, authChoiceKeyboard(locale, record));
5856
+ }
5857
+ return;
5858
+ }
5759
5859
  if (action === 'refresh_all') {
5760
5860
  if (!this.canRunGlobalAuthRefresh()) {
5761
5861
  await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_refresh_all_blocked_active'));
@@ -10392,6 +10492,9 @@ function authChoiceKeyboard(locale, record) {
10392
10492
  { text: t(locale, 'button_permissions'), callback_data: 'nav:permissions' },
10393
10493
  { text: t(locale, 'button_login_device'), callback_data: `auth:${record.localId}:login_device` },
10394
10494
  ]);
10495
+ rows.push([
10496
+ { text: t(locale, 'button_auth_cluster_audit'), callback_data: `auth:${record.localId}:cluster_audit` },
10497
+ ]);
10395
10498
  rows.push([
10396
10499
  { text: t(locale, 'button_auth_safe_sync'), callback_data: `auth:${record.localId}:safe_sync` },
10397
10500
  { text: t(locale, 'button_auth_reload'), callback_data: `auth:${record.localId}:reload` },
@@ -10507,6 +10610,39 @@ function formatAuthRefreshAllResult(locale, result, mode = 'manual') {
10507
10610
  }
10508
10611
  return lines.join('\n');
10509
10612
  }
10613
+ function formatAuthClusterAuditResult(locale, outcome) {
10614
+ const { audit, refresh, push } = outcome;
10615
+ const lines = [t(locale, 'auth_cluster_audit_summary', {
10616
+ responded: audit.nodesResponded,
10617
+ expected: audit.nodesExpected,
10618
+ checked: audit.checkedCandidates,
10619
+ valid: audit.validCandidates,
10620
+ invalid: audit.invalidCandidates,
10621
+ })];
10622
+ if (audit.synchronizedCandidates.length > 0) {
10623
+ lines.push(t(locale, 'auth_cluster_audit_synced', { value: audit.synchronizedCandidates.join(', ') }));
10624
+ }
10625
+ if (audit.consensusInvalidCandidates.length > 0) {
10626
+ lines.push(t(locale, 'auth_cluster_audit_repair', { value: audit.consensusInvalidCandidates.join(', ') }));
10627
+ }
10628
+ if (audit.missingPeers.length > 0) {
10629
+ lines.push(t(locale, 'auth_cluster_audit_missing', { value: audit.missingPeers.join(', ') }));
10630
+ }
10631
+ if (audit.busyNodes.length > 0) {
10632
+ lines.push(t(locale, 'auth_cluster_audit_busy', { value: audit.busyNodes.join(', ') }));
10633
+ }
10634
+ if (audit.identityConflicts.length > 0) {
10635
+ lines.push(t(locale, 'auth_cluster_audit_conflicts', { value: audit.identityConflicts.join(', ') }));
10636
+ }
10637
+ if (outcome.refreshSkippedReason) {
10638
+ lines.push(t(locale, 'auth_cluster_audit_refresh_skipped'));
10639
+ }
10640
+ else {
10641
+ lines.push(formatAuthRefreshAllResult(locale, refresh, 'proactive'));
10642
+ }
10643
+ lines.push(t(locale, 'auth_cluster_audit_push', { sent: push.sent, skipped: push.skipped }));
10644
+ return lines.join('\n');
10645
+ }
10510
10646
  function formatAuthSyncStatus(locale, status, proactiveRefresh = null) {
10511
10647
  if (!status?.enabled) {
10512
10648
  return t(locale, 'auth_sync_disabled');
package/dist/i18n.d.ts CHANGED
@@ -147,7 +147,7 @@ declare const MESSAGES: {
147
147
  readonly auth_reload_done: "Codex app-server restarted. Current auth has been reloaded.";
148
148
  readonly auth_reload_blocked_active: "Cannot reload Codex auth while a turn, approval, or question is active. Wait or use /interrupt first.";
149
149
  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>]";
150
- readonly usage_auth_sync: "Usage: /auth sync <status|events [filter]|trace <requestId>|test|safe|push all>";
150
+ readonly usage_auth_sync: "Usage: /auth sync <status|events [filter]|trace <requestId>|test|audit|safe|push all>";
151
151
  readonly usage_auth_add: "Usage: /auth add <name>. Use letters, numbers, dot, dash, or underscore.";
152
152
  readonly auth_list_title: "Codex auth files:";
153
153
  readonly auth_bot: "Bot runtime: {value}";
@@ -213,7 +213,7 @@ declare const MESSAGES: {
213
213
  readonly auth_refresh_all_blocked_active: "Cannot refresh all auth candidates while any runtime, approval, input, login, or auth mirror write is active. Wait or use /interrupt first.";
214
214
  readonly auth_refresh_all_lease_failed: "Cross-node refresh lock was not granted: {error}";
215
215
  readonly auth_refresh_all_done: "Auth refresh all complete: {refreshed} refreshed, {skipped} skipped, {failed} failed.";
216
- readonly auth_proactive_refresh_starting: "Proactive auth refresh started for candidates older than 9 days: {value}";
216
+ readonly auth_proactive_refresh_starting: "Proactive auth refresh started for candidates last refreshed at least 8 days ago: {value}";
217
217
  readonly auth_proactive_refresh_lease_failed: "Proactive auth refresh skipped because the cross-node refresh lock was not granted: {error}";
218
218
  readonly auth_proactive_refresh_done: "Proactive auth refresh complete: {refreshed} refreshed, {skipped} skipped, {failed} failed.";
219
219
  readonly auth_refresh_all_refreshed: "Refreshed: {value}";
@@ -247,6 +247,17 @@ declare const MESSAGES: {
247
247
  readonly auth_sync_push_blocked_active: "Cannot push auth sync while any runtime, approval, input, login, or auth mirror write is active.";
248
248
  readonly auth_sync_safe_starting: "Safely syncing auth across local bot runtimes and cross-node peers...";
249
249
  readonly auth_sync_safe_done: "Safe auth sync complete: local synced {localSynced}, local skipped {localSkipped}; cross-node sent {sent}, skipped {skipped}.";
250
+ readonly auth_cluster_audit_starting: "Asking all auth-sync peers to validate accounts, reconcile the newest usable credentials, and refresh credentials at least 8 days old...";
251
+ readonly auth_cluster_audit_blocked_active: "Cluster auth check requires every local runtime, approval, input, login, and auth mirror write to be idle.";
252
+ readonly auth_cluster_audit_failed: "Cluster auth check failed: {error}";
253
+ readonly auth_cluster_audit_summary: "Cluster auth check: nodes {responded}/{expected}; accounts checked {checked}, usable {valid}, unusable {invalid}.";
254
+ readonly auth_cluster_audit_synced: "Newest usable auth distributed: {value}";
255
+ readonly auth_cluster_audit_repair: "Marked for manual repair after multi-node consensus: {value}";
256
+ readonly auth_cluster_audit_missing: "No response: {value}";
257
+ readonly auth_cluster_audit_busy: "Busy nodes: {value}";
258
+ readonly auth_cluster_audit_conflicts: "Identity conflicts left unchanged: {value}";
259
+ readonly auth_cluster_audit_refresh_skipped: "Stale refresh skipped because the cluster check was incomplete.";
260
+ readonly auth_cluster_audit_push: "Final distribution: sent {sent}, skipped {skipped}.";
250
261
  readonly auth_sync_push_done: "Auth sync push complete: sent {sent}, skipped {skipped}.";
251
262
  readonly auth_sync_status_title: "Cross-node auth sync:";
252
263
  readonly auth_sync_status_node: "Node: {value}";
@@ -272,6 +283,7 @@ declare const MESSAGES: {
272
283
  readonly button_login_device: "🔑 Login";
273
284
  readonly button_auth_reload: "🔄 Reload auth";
274
285
  readonly button_auth_safe_sync: "🧷 Safe sync";
286
+ readonly button_auth_cluster_audit: "🩺 Check all nodes and reconcile auth";
275
287
  readonly button_auth_refresh_all_confirm: "⚠️ Accept risk & refresh";
276
288
  readonly button_auth_repair_login: "🔑 Login repair";
277
289
  readonly button_auth_delete: "🗑️ Delete";
@@ -858,7 +870,7 @@ declare const MESSAGES: {
858
870
  readonly auth_reload_done: "Codex app-server 已重启,当前 auth 已重新读取。";
859
871
  readonly auth_reload_blocked_active: "当前有回复、审批或问题在进行中,不能重载 Codex auth。请先等待,或使用 /interrupt。";
860
872
  readonly usage_auth: "用法:/auth [list [关键词]|filter <all|enabled|attention>|page <页码>|use <编号>|enable <编号>|disable <编号>|reload|refresh all [confirm]|sync <status|test|safe|push all>|add <名称>]";
861
- readonly usage_auth_sync: "用法:/auth sync <status|events [过滤]|trace <requestId>|test|safe|push all>";
873
+ readonly usage_auth_sync: "用法:/auth sync <status|events [过滤]|trace <requestId>|test|audit|safe|push all>";
862
874
  readonly usage_auth_add: "用法:/auth add <名称>。名称只能包含字母、数字、点、短横线或下划线。";
863
875
  readonly auth_list_title: "Codex auth 文件:";
864
876
  readonly auth_bot: "Bot runtime:{value}";
@@ -924,7 +936,7 @@ declare const MESSAGES: {
924
936
  readonly auth_refresh_all_blocked_active: "当前有任一 runtime、审批、待输入、登录或 auth 镜像写入在进行中,不能刷新全部 auth。请先等待,或使用 /interrupt。";
925
937
  readonly auth_refresh_all_lease_failed: "跨节点刷新锁未授予:{error}";
926
938
  readonly auth_refresh_all_done: "全部 auth 刷新完成:已刷新 {refreshed},已跳过 {skipped},失败 {failed}。";
927
- readonly auth_proactive_refresh_starting: "开始主动刷新超过 9 天未更新的 auth 候选:{value}";
939
+ readonly auth_proactive_refresh_starting: "开始主动刷新上次刷新时间已满 8 天的 auth 候选:{value}";
928
940
  readonly auth_proactive_refresh_lease_failed: "主动 auth 刷新已跳过:跨节点刷新锁未授予:{error}";
929
941
  readonly auth_proactive_refresh_done: "主动 auth 刷新完成:已刷新 {refreshed},已跳过 {skipped},失败 {failed}。";
930
942
  readonly auth_refresh_all_refreshed: "已刷新:{value}";
@@ -958,6 +970,17 @@ declare const MESSAGES: {
958
970
  readonly auth_sync_push_blocked_active: "当前有任一 runtime、审批、待输入、登录或 auth 镜像写入在进行中,不能推送 auth 同步。";
959
971
  readonly auth_sync_safe_starting: "正在安全同步本机多 bot runtime 和跨节点 auth...";
960
972
  readonly auth_sync_safe_done: "安全 auth 同步完成:本机同步 {localSynced},本机跳过 {localSkipped};跨节点发送 {sent},跳过 {skipped}。";
973
+ readonly auth_cluster_audit_starting: "正在通知所有 auth sync 节点逐账号自检,协商最新有效凭据,并由本节点刷新已满 8 天的 auth...";
974
+ readonly auth_cluster_audit_blocked_active: "集群 auth 自检要求本机所有 runtime、审批、待输入、登录和 auth 镜像写入均为空闲。";
975
+ readonly auth_cluster_audit_failed: "集群 auth 自检失败:{error}";
976
+ readonly auth_cluster_audit_summary: "集群 auth 自检:节点 {responded}/{expected};检查账号 {checked},有效 {valid},无有效副本 {invalid}。";
977
+ readonly auth_cluster_audit_synced: "已采用并分发最新有效 auth:{value}";
978
+ readonly auth_cluster_audit_repair: "多节点确认无效,已标记问号等待人工处理:{value}";
979
+ readonly auth_cluster_audit_missing: "未回应节点:{value}";
980
+ readonly auth_cluster_audit_busy: "忙碌节点:{value}";
981
+ readonly auth_cluster_audit_conflicts: "账号身份冲突,保持原状:{value}";
982
+ readonly auth_cluster_audit_refresh_skipped: "集群自检不完整,本次未执行临期 auth 刷新。";
983
+ readonly auth_cluster_audit_push: "最终分发:已发送 {sent},已跳过 {skipped}。";
961
984
  readonly auth_sync_push_done: "auth 同步推送完成:已发送 {sent},已跳过 {skipped}。";
962
985
  readonly auth_sync_status_title: "跨节点 auth 同步:";
963
986
  readonly auth_sync_status_node: "节点:{value}";
@@ -983,6 +1006,7 @@ declare const MESSAGES: {
983
1006
  readonly button_login_device: "🔑 设备登录";
984
1007
  readonly button_auth_reload: "🔄 重载 auth";
985
1008
  readonly button_auth_safe_sync: "🧷 安全同步";
1009
+ readonly button_auth_cluster_audit: "🩺 全节点自检并同步";
986
1010
  readonly button_auth_refresh_all_confirm: "⚠️ 接受风险并刷新";
987
1011
  readonly button_auth_repair_login: "🔑 登录修复";
988
1012
  readonly button_auth_delete: "🗑️ 删除";
package/dist/i18n.js CHANGED
@@ -145,7 +145,7 @@ const MESSAGES = {
145
145
  auth_reload_done: 'Codex app-server restarted. Current auth has been reloaded.',
146
146
  auth_reload_blocked_active: 'Cannot reload Codex auth while a turn, approval, or question is active. Wait or use /interrupt first.',
147
147
  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>]',
148
- usage_auth_sync: 'Usage: /auth sync <status|events [filter]|trace <requestId>|test|safe|push all>',
148
+ usage_auth_sync: 'Usage: /auth sync <status|events [filter]|trace <requestId>|test|audit|safe|push all>',
149
149
  usage_auth_add: 'Usage: /auth add <name>. Use letters, numbers, dot, dash, or underscore.',
150
150
  auth_list_title: 'Codex auth files:',
151
151
  auth_bot: 'Bot runtime: {value}',
@@ -211,7 +211,7 @@ const MESSAGES = {
211
211
  auth_refresh_all_blocked_active: 'Cannot refresh all auth candidates while any runtime, approval, input, login, or auth mirror write is active. Wait or use /interrupt first.',
212
212
  auth_refresh_all_lease_failed: 'Cross-node refresh lock was not granted: {error}',
213
213
  auth_refresh_all_done: 'Auth refresh all complete: {refreshed} refreshed, {skipped} skipped, {failed} failed.',
214
- auth_proactive_refresh_starting: 'Proactive auth refresh started for candidates older than 9 days: {value}',
214
+ auth_proactive_refresh_starting: 'Proactive auth refresh started for candidates last refreshed at least 8 days ago: {value}',
215
215
  auth_proactive_refresh_lease_failed: 'Proactive auth refresh skipped because the cross-node refresh lock was not granted: {error}',
216
216
  auth_proactive_refresh_done: 'Proactive auth refresh complete: {refreshed} refreshed, {skipped} skipped, {failed} failed.',
217
217
  auth_refresh_all_refreshed: 'Refreshed: {value}',
@@ -245,6 +245,17 @@ const MESSAGES = {
245
245
  auth_sync_push_blocked_active: 'Cannot push auth sync while any runtime, approval, input, login, or auth mirror write is active.',
246
246
  auth_sync_safe_starting: 'Safely syncing auth across local bot runtimes and cross-node peers...',
247
247
  auth_sync_safe_done: 'Safe auth sync complete: local synced {localSynced}, local skipped {localSkipped}; cross-node sent {sent}, skipped {skipped}.',
248
+ auth_cluster_audit_starting: 'Asking all auth-sync peers to validate accounts, reconcile the newest usable credentials, and refresh credentials at least 8 days old...',
249
+ auth_cluster_audit_blocked_active: 'Cluster auth check requires every local runtime, approval, input, login, and auth mirror write to be idle.',
250
+ auth_cluster_audit_failed: 'Cluster auth check failed: {error}',
251
+ auth_cluster_audit_summary: 'Cluster auth check: nodes {responded}/{expected}; accounts checked {checked}, usable {valid}, unusable {invalid}.',
252
+ auth_cluster_audit_synced: 'Newest usable auth distributed: {value}',
253
+ auth_cluster_audit_repair: 'Marked for manual repair after multi-node consensus: {value}',
254
+ auth_cluster_audit_missing: 'No response: {value}',
255
+ auth_cluster_audit_busy: 'Busy nodes: {value}',
256
+ auth_cluster_audit_conflicts: 'Identity conflicts left unchanged: {value}',
257
+ auth_cluster_audit_refresh_skipped: 'Stale refresh skipped because the cluster check was incomplete.',
258
+ auth_cluster_audit_push: 'Final distribution: sent {sent}, skipped {skipped}.',
248
259
  auth_sync_push_done: 'Auth sync push complete: sent {sent}, skipped {skipped}.',
249
260
  auth_sync_status_title: 'Cross-node auth sync:',
250
261
  auth_sync_status_node: 'Node: {value}',
@@ -270,6 +281,7 @@ const MESSAGES = {
270
281
  button_login_device: '🔑 Login',
271
282
  button_auth_reload: '🔄 Reload auth',
272
283
  button_auth_safe_sync: '🧷 Safe sync',
284
+ button_auth_cluster_audit: '🩺 Check all nodes and reconcile auth',
273
285
  button_auth_refresh_all_confirm: '⚠️ Accept risk & refresh',
274
286
  button_auth_repair_login: '🔑 Login repair',
275
287
  button_auth_delete: '🗑️ Delete',
@@ -856,7 +868,7 @@ const MESSAGES = {
856
868
  auth_reload_done: 'Codex app-server 已重启,当前 auth 已重新读取。',
857
869
  auth_reload_blocked_active: '当前有回复、审批或问题在进行中,不能重载 Codex auth。请先等待,或使用 /interrupt。',
858
870
  usage_auth: '用法:/auth [list [关键词]|filter <all|enabled|attention>|page <页码>|use <编号>|enable <编号>|disable <编号>|reload|refresh all [confirm]|sync <status|test|safe|push all>|add <名称>]',
859
- usage_auth_sync: '用法:/auth sync <status|events [过滤]|trace <requestId>|test|safe|push all>',
871
+ usage_auth_sync: '用法:/auth sync <status|events [过滤]|trace <requestId>|test|audit|safe|push all>',
860
872
  usage_auth_add: '用法:/auth add <名称>。名称只能包含字母、数字、点、短横线或下划线。',
861
873
  auth_list_title: 'Codex auth 文件:',
862
874
  auth_bot: 'Bot runtime:{value}',
@@ -922,7 +934,7 @@ const MESSAGES = {
922
934
  auth_refresh_all_blocked_active: '当前有任一 runtime、审批、待输入、登录或 auth 镜像写入在进行中,不能刷新全部 auth。请先等待,或使用 /interrupt。',
923
935
  auth_refresh_all_lease_failed: '跨节点刷新锁未授予:{error}',
924
936
  auth_refresh_all_done: '全部 auth 刷新完成:已刷新 {refreshed},已跳过 {skipped},失败 {failed}。',
925
- auth_proactive_refresh_starting: '开始主动刷新超过 9 天未更新的 auth 候选:{value}',
937
+ auth_proactive_refresh_starting: '开始主动刷新上次刷新时间已满 8 天的 auth 候选:{value}',
926
938
  auth_proactive_refresh_lease_failed: '主动 auth 刷新已跳过:跨节点刷新锁未授予:{error}',
927
939
  auth_proactive_refresh_done: '主动 auth 刷新完成:已刷新 {refreshed},已跳过 {skipped},失败 {failed}。',
928
940
  auth_refresh_all_refreshed: '已刷新:{value}',
@@ -956,6 +968,17 @@ const MESSAGES = {
956
968
  auth_sync_push_blocked_active: '当前有任一 runtime、审批、待输入、登录或 auth 镜像写入在进行中,不能推送 auth 同步。',
957
969
  auth_sync_safe_starting: '正在安全同步本机多 bot runtime 和跨节点 auth...',
958
970
  auth_sync_safe_done: '安全 auth 同步完成:本机同步 {localSynced},本机跳过 {localSkipped};跨节点发送 {sent},跳过 {skipped}。',
971
+ auth_cluster_audit_starting: '正在通知所有 auth sync 节点逐账号自检,协商最新有效凭据,并由本节点刷新已满 8 天的 auth...',
972
+ auth_cluster_audit_blocked_active: '集群 auth 自检要求本机所有 runtime、审批、待输入、登录和 auth 镜像写入均为空闲。',
973
+ auth_cluster_audit_failed: '集群 auth 自检失败:{error}',
974
+ auth_cluster_audit_summary: '集群 auth 自检:节点 {responded}/{expected};检查账号 {checked},有效 {valid},无有效副本 {invalid}。',
975
+ auth_cluster_audit_synced: '已采用并分发最新有效 auth:{value}',
976
+ auth_cluster_audit_repair: '多节点确认无效,已标记问号等待人工处理:{value}',
977
+ auth_cluster_audit_missing: '未回应节点:{value}',
978
+ auth_cluster_audit_busy: '忙碌节点:{value}',
979
+ auth_cluster_audit_conflicts: '账号身份冲突,保持原状:{value}',
980
+ auth_cluster_audit_refresh_skipped: '集群自检不完整,本次未执行临期 auth 刷新。',
981
+ auth_cluster_audit_push: '最终分发:已发送 {sent},已跳过 {skipped}。',
959
982
  auth_sync_push_done: 'auth 同步推送完成:已发送 {sent},已跳过 {skipped}。',
960
983
  auth_sync_status_title: '跨节点 auth 同步:',
961
984
  auth_sync_status_node: '节点:{value}',
@@ -981,6 +1004,7 @@ const MESSAGES = {
981
1004
  button_login_device: '🔑 设备登录',
982
1005
  button_auth_reload: '🔄 重载 auth',
983
1006
  button_auth_safe_sync: '🧷 安全同步',
1007
+ button_auth_cluster_audit: '🩺 全节点自检并同步',
984
1008
  button_auth_refresh_all_confirm: '⚠️ 接受风险并刷新',
985
1009
  button_auth_repair_login: '🔑 登录修复',
986
1010
  button_auth_delete: '🗑️ 删除',
package/dist/main.js CHANGED
@@ -22,7 +22,9 @@ const rawCommand = process.argv[2];
22
22
  const command = rawCommand || 'serve';
23
23
  loadEnv();
24
24
  const packageRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
25
- const entryPoint = fileURLToPath(import.meta.url);
25
+ const entryPoint = process.argv[1]
26
+ ? path.resolve(process.argv[1])
27
+ : fileURLToPath(import.meta.url);
26
28
  const PROXY_ENV_KEYS = [
27
29
  'HTTP_PROXY',
28
30
  'HTTPS_PROXY',
@@ -753,6 +755,7 @@ async function runServeCli() {
753
755
  },
754
756
  authSyncPushAll: () => authSync?.pushAll() ?? Promise.resolve({ sent: 0, skipped: 0 }),
755
757
  authSyncTest: () => authSync?.testPeers() ?? Promise.resolve({ sent: 0, replied: 0, missing: [] }),
758
+ authSyncAudit: () => authSync?.auditCluster() ?? Promise.resolve(null),
756
759
  statusUpdated: () => writeAggregateStatus(),
757
760
  getServiceStatus: async () => ({
758
761
  currentVersion: readPackageVersion(),
@@ -816,6 +819,7 @@ async function runServeCli() {
816
819
  }, {
817
820
  readLocalCandidate: (candidateName) => mirror.readNewestCandidate(candidateName),
818
821
  listLocalCandidates: () => mirror.listNewestCandidates(),
822
+ listLocalCandidateCopies: () => mirror.listCandidateCopies(),
819
823
  validateCandidate: async (candidateName, raw, expectedAccountId) => {
820
824
  if (!authSyncLocalIdle()) {
821
825
  return { ok: false, reason: 'runtime is not idle' };
@@ -827,6 +831,7 @@ async function runServeCli() {
827
831
  return runtime.core.validateExternalCodexAuthCandidate(candidateName, raw, expectedAccountId);
828
832
  },
829
833
  importCandidate: (candidateName, raw, source) => mirror.importExternalCandidate(candidateName, raw, source),
834
+ markCandidateState: (candidateName, state, expected) => markAuditedAuthCandidateState(store, mirror, candidateName, state, expected, authRuntimeIds),
830
835
  deleteLocalCandidate: async (candidateName, source) => {
831
836
  if (!authSyncLocalIdle()) {
832
837
  return { ok: false, deleted: false, reason: 'runtime is not idle' };
@@ -959,6 +964,7 @@ async function runServeCli() {
959
964
  },
960
965
  authSyncPushAll: () => singleAuthSync?.pushAll() ?? Promise.resolve({ sent: 0, skipped: 0 }),
961
966
  authSyncTest: () => singleAuthSync?.testPeers() ?? Promise.resolve({ sent: 0, replied: 0, missing: [] }),
967
+ authSyncAudit: () => singleAuthSync?.auditCluster() ?? Promise.resolve(null),
962
968
  statusUpdated: (status) => {
963
969
  writeRuntimeStatus(config.statusPath, {
964
970
  ...status,
@@ -998,6 +1004,7 @@ async function runServeCli() {
998
1004
  }, {
999
1005
  readLocalCandidate: (candidateName) => singleMirror.readNewestCandidate(candidateName),
1000
1006
  listLocalCandidates: () => singleMirror.listNewestCandidates(),
1007
+ listLocalCandidateCopies: () => singleMirror.listCandidateCopies(),
1001
1008
  validateCandidate: async (candidateName, raw, expectedAccountId) => {
1002
1009
  if (!singleAuthSyncLocalIdle()) {
1003
1010
  return { ok: false, reason: 'runtime is not idle' };
@@ -1005,6 +1012,7 @@ async function runServeCli() {
1005
1012
  return core.validateExternalCodexAuthCandidate(candidateName, raw, expectedAccountId);
1006
1013
  },
1007
1014
  importCandidate: (candidateName, raw, source) => singleMirror.importExternalCandidate(candidateName, raw, source),
1015
+ markCandidateState: (candidateName, state, expected) => markAuditedAuthCandidateState(store, singleMirror, candidateName, state, expected, ['default']),
1008
1016
  deleteLocalCandidate: async (candidateName, source) => {
1009
1017
  if (!singleAuthSyncLocalIdle()) {
1010
1018
  return { ok: false, deleted: false, reason: 'runtime is not idle' };
@@ -1099,6 +1107,29 @@ function restoreImportedAuthCandidateState(store, candidateName, runtimeIds) {
1099
1107
  store.setCodexAuthCandidateDisabled(candidateName, false, runtimeId);
1100
1108
  }
1101
1109
  }
1110
+ async function markAuditedAuthCandidateState(store, mirror, candidateName, state, expected, runtimeIds) {
1111
+ const record = await mirror.readNewestCandidate(candidateName);
1112
+ if (!record || record.accountId !== expected.accountId || record.lastRefreshMs > expected.maxLastRefreshMs) {
1113
+ return false;
1114
+ }
1115
+ if (expected.quotaIdentityId
1116
+ && expected.quotaIdentityId !== expected.accountId
1117
+ && record.quotaIdentityId !== record.accountId
1118
+ && record.quotaIdentityId !== expected.quotaIdentityId) {
1119
+ return false;
1120
+ }
1121
+ store.setCodexAuthCandidateState(candidateName, state);
1122
+ if (state === 'active') {
1123
+ store.setCodexAuthCandidateDisabled(candidateName, false);
1124
+ }
1125
+ for (const runtimeId of runtimeIds) {
1126
+ store.setCodexAuthCandidateState(candidateName, state, runtimeId);
1127
+ if (state === 'active') {
1128
+ store.setCodexAuthCandidateDisabled(candidateName, false, runtimeId);
1129
+ }
1130
+ }
1131
+ return true;
1132
+ }
1102
1133
  function createAuthMirrorNotifier(store, botId, bot, aggregator, options = {}) {
1103
1134
  return async (event) => {
1104
1135
  if (options.suppressBackgroundNotifications?.())
@@ -10,7 +10,7 @@ Use it when:
10
10
 
11
11
  - You legally own and maintain the ChatGPT accounts and auth files.
12
12
  - Multiple machines run FoxClaw, and each machine has at least one Telegram bot.
13
- - You want auth files to stay fresh across nodes, and you allow FoxClaw to proactively refresh enabled ChatGPT candidates whose `last_refresh` is older than 9 days after it obtains the cross-node refresh lease.
13
+ - You want auth files to stay fresh across nodes, and you allow FoxClaw to proactively refresh enabled ChatGPT candidates whose `last_refresh` is at least 8 days old after it obtains the cross-node refresh lease.
14
14
  - The recommended default is one contact bot per node for cross-node sync. Other bots on the same node continue to use local auth mirroring.
15
15
 
16
16
  Do not use it when:
@@ -25,7 +25,7 @@ Cross-node sync combines three active paths:
25
25
 
26
26
  - **Push**: after local login, Codex automatic refresh, or `/auth refresh all confirm` succeeds and passes usage validation, FoxClaw sends the newer candidate to peers.
27
27
  - **Pull**: before auth switch or reload, FoxClaw first searches local runtimes for a newer candidate. If none exists, it asks peers for a newer same-name, same-account candidate.
28
- - **Lease**: before `/auth refresh all confirm` or the background 9-day proactive refresh rotates refresh tokens, FoxClaw requests a cross-node refresh lease. Any busy, denying, or non-responsive peer blocks the refresh.
28
+ - **Lease**: before `/auth refresh all confirm`, the background 8-day proactive refresh, or the `/auth` cluster check rotates refresh tokens, FoxClaw requests a cross-node refresh lease. Any busy, denying, or non-responsive peer blocks the refresh.
29
29
 
30
30
  Safety boundaries:
31
31
 
@@ -153,6 +153,16 @@ Auth sync test complete: sent 1, replies 1.
153
153
 
154
154
  If it shows `Missing replies: @peer_bot`, Telegram delivery may have succeeded, but the peer did not receive, decrypt, pass allowlist validation, or run the same auth sync configuration.
155
155
 
156
+ The `/auth` panel also provides **Check all nodes and reconcile auth** (`/auth sync audit`). It asks every configured peer to validate every local candidate against the usage endpoint, then:
157
+
158
+ - selects the newest usage-validated copy for each same-name, same-account and same-user identity and distributes it to every peer;
159
+ - allows a validated older copy to replace a newer timestamped copy only when the newer copy failed this explicit audit;
160
+ - marks a candidate `?` only when every peer responded, no node has a valid copy, and at least two nodes independently reported it invalid;
161
+ - leaves candidates unchanged when any peer is missing or busy, or when account/user identities conflict;
162
+ - refreshes enabled ChatGPT candidates whose `last_refresh` is at least 8 days old on the initiating node, then distributes the refreshed copies.
163
+
164
+ The audit reaches all entries in the initiating node's `AUTH_SYNC_PEERS`. Use reciprocal full-mesh peer lists when every node must participate in one operation.
165
+
156
166
  3. Use a low-risk candidate for the first broadcast. Make sure every runtime is idle, then run on node A:
157
167
 
158
168
  ```text
@@ -209,4 +219,4 @@ With cross-node sync enabled, this command first requests a cross-node refresh l
209
219
 
210
220
  **Should I periodically run `/auth refresh all confirm` as keepalive?**
211
221
 
212
- Do not force it manually on a schedule. Codex refreshes automatically when access tokens expire, and FoxClaw now proactively refreshes enabled ChatGPT candidates whose `last_refresh` is older than 9 days after the node is globally idle and obtains the cross-node refresh lease. `/auth refresh all confirm` remains a manual maintenance command for cases where you explicitly accept refresh-token rotation risk.
222
+ Do not force it manually on a schedule. Codex refreshes automatically when access tokens expire, and FoxClaw proactively refreshes enabled ChatGPT candidates whose `last_refresh` is at least 8 days old after the node is globally idle and obtains the cross-node refresh lease. `/auth refresh all confirm` remains a manual maintenance command for cases where you explicitly accept refresh-token rotation risk.
@@ -80,7 +80,7 @@ OpenAI 没有公开 ChatGPT refresh token 的固定有效期,也没有公开
80
80
  所以 FoxClaw 的策略更克制:
81
81
 
82
82
  - `/auth refresh all` 是人工维护命令,需要显式确认风险;
83
- - 已启用 ChatGPT 候选 `last_refresh` 超过 9 天时,后台才主动关注;
83
+ - 已启用 ChatGPT 候选 `last_refresh` 已满 8 天时,后台才主动关注;
84
84
  - 主动刷新必须等所有 runtime 空闲,没有审批、待输入、登录流程和 auth 镜像写入;
85
85
  - 如果启用跨节点同步,刷新前还要先拿到跨节点刷新锁。
86
86
 
@@ -133,7 +133,7 @@ FoxClaw 用 Telegram Bot-to-Bot 做了一套低频控制面。
133
133
 
134
134
  1. **Push**:本节点登录、Codex 自动刷新或手动刷新成功,并通过 usage 验证后,把较新的候选加密推给 peer。
135
135
  2. **Pull**:本节点发现当前候选不可用,先查同节点镜像;如果没有可用较新副本,再向 peer 请求同名同账号的较新候选。
136
- 3. **Lease**:凡是会旋转 refresh token 的操作,例如 `/auth refresh all confirm` 或后台 9 天主动刷新,必须先申请跨节点刷新锁。任一 peer 忙碌、拒绝或超时,都阻止本轮刷新。
136
+ 3. **Lease**:凡是会旋转 refresh token 的操作,例如 `/auth refresh all confirm`、后台 8 天主动刷新或集群 auth 自检,必须先申请跨节点刷新锁。任一 peer 忙碌、拒绝或超时,都阻止本轮刷新。
137
137
 
138
138
  这里最重要的是克制。
139
139
 
@@ -439,11 +439,13 @@ Quota remaining: window:percent|auth
439
439
 
440
440
  The right-side `✅` / `⏸️` button controls whether the candidate participates in auto-rotation. Tapping it toggles enabled/disabled, and the refreshed list shows the new state. Tapping a candidate switches auth, restarts that runtime, and refreshes the same panel with its buttons intact so you can switch again immediately. `--` means no quota snapshot has been observed for that candidate yet. Health summaries distinguish ready, low quota, quota exhausted, quota unknown, not recently refreshed, API key, invalid auth file, and needs login repair states.
441
441
 
442
+ The **Check all nodes and reconcile auth** button asks every configured auth-sync peer to validate its candidates. FoxClaw adopts the newest valid copy, distributes it, marks an account `?` only after complete multi-node invalid consensus, and has the initiating node refresh enabled ChatGPT credentials last refreshed at least 8 days ago. Missing, busy, or identity-conflicting peers prevent invalid marking and stale refresh for that run.
443
+
442
444
  When an auth candidate has already failed while in use and FoxClaw cannot recover a newer same-account credential from local mirror or cross-node sync, it is marked as `needs login repair`. These candidates are skipped by auto-rotation and proactive refresh, and are hidden from the `Enabled` filter. Their row shows a `?` action. Tapping it opens two choices: Login repair starts device-code login with that candidate selected; Delete removes the candidate from canonical storage and all local bot runtimes, and clears cached quota for it.
443
445
 
444
446
  `/auth refresh all` is a command-only maintenance action because ChatGPT refresh tokens are rotated. It is allowed only when every Telegram runtime, the Weixin runtime, approvals, inputs, logins, and auth mirroring are idle. The command first shows a risk confirmation: if OpenAI/Codex consumes an old refresh token but the new token cannot be saved because of network, process, or disk failure, that candidate may require device login or phone verification again. After confirmation, FoxClaw visits every ChatGPT candidate, asks Codex to force-refresh tokens with `account/read refreshToken=true`, verifies the result through the usage endpoint, mirrors successful candidates, restores the original current auth, and shows a summary.
445
447
 
446
- OpenAI does not publish a fixed ChatGPT refresh-token lifetime or an old-token replay grace period. Codex refreshes automatically when an access token approaches expiry; when it cannot parse the access-token `exp`, current Codex uses a `last_refresh` fallback of about 8 days. The panel labels candidates without a refresh record in that interval as `not recently refreshed`. FoxClaw also checks once per hour in the background: if an enabled ChatGPT candidate has a `last_refresh` older than 9 days, FoxClaw proactively refreshes that batch only when every runtime is idle, no approvals/inputs/logins/auth mirror writes are active, and the node holds the cross-node refresh lease. The private bot chat shows one proactive-refresh status message and edits it to the final result. Newer candidates continue through same-node mirroring and cross-node sync, and bursty mirror/cross-node refresh notices are grouped into short summary messages.
448
+ OpenAI does not publish a fixed ChatGPT refresh-token lifetime or an old-token replay grace period. Codex refreshes automatically when an access token approaches expiry; when it cannot parse the access-token `exp`, current Codex uses a `last_refresh` fallback of about 8 days. The panel labels candidates without a refresh record in that interval as `not recently refreshed`. FoxClaw also checks once per hour in the background: if an enabled ChatGPT candidate has a `last_refresh` at least 8 days old, FoxClaw proactively refreshes that batch only when every runtime is idle, no approvals/inputs/logins/auth mirror writes are active, and the node holds the cross-node refresh lease. The private bot chat shows one proactive-refresh status message and edits it to the final result. Newer candidates continue through same-node mirroring and cross-node sync, and bursty mirror/cross-node refresh notices are grouped into short summary messages.
447
449
 
448
450
  ### 6.4 Cross-Node Auth Sync
449
451
 
@@ -480,14 +482,14 @@ Safety boundaries:
480
482
  - FoxClaw only accepts sync files from bots listed in `AUTH_SYNC_PEERS`; wrong key, cluster, nonce, or payload validation never writes files.
481
483
  - Remote imports wait for global local idleness, temporarily switch to the candidate for app-server usage validation, and only then write the candidate.
482
484
  - 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.
483
- - 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.
485
+ - 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 8-day proactive refresh separately requests the cross-node refresh lease and skips that cycle if the lease is not granted.
484
486
  - When `AUTH_AUTO_DELETE_NEEDS_REPAIR=true` is enabled, or the same option is turned on in `/config`, unrecoverable candidates are deleted and propagated to peers with a delete tombstone. Private notifications collapse to an auth-pool summary: total seen, alive, and invalid-deleted.
485
487
 
486
488
  Dual-active behavior:
487
489
 
488
490
  - Push: after local login, Codex automatic refresh, or `/auth refresh all` succeeds and passes local mirror validation, the newer candidate is encrypted and pushed to peers.
489
491
  - Pull: before auth switch or reload, FoxClaw first searches local runtimes for a newer same-account candidate; if none is found, it asks peers for a newer same-name same-account copy.
490
- - Lease: `/auth refresh all confirm` and the background 9-day proactive refresh request a cross-node refresh lease before rotating tokens. Any busy, denying, or non-responsive peer blocks the refresh.
492
+ - Lease: `/auth refresh all confirm`, the background 8-day proactive refresh, and the cluster audit request a cross-node refresh lease before rotating tokens. Any busy, denying, or non-responsive peer blocks the refresh.
491
493
 
492
494
  Commands:
493
495
 
@@ -495,6 +497,7 @@ Commands:
495
497
  - `/auth sync events [filter]`: show recent sync event records, optionally filtered by candidate, peer, request id, kind, stage, or detail.
496
498
  - `/auth sync trace <requestId>`: show recent records for one request id or event id.
497
499
  - `/auth sync test`: send an encrypted ping and wait for peer pong replies to verify peer config, shared key, and Bot-to-Bot private messages.
500
+ - `/auth sync audit`: run the same validate, reconcile, consensus, stale-refresh, and distribution flow as the `/auth` cluster-check button.
498
501
  - `/auth sync push all`: manually broadcast all locally verified candidates without refreshing tokens. “Sent” does not mean the peer imported files; check `/auth sync status` and `/auth` on the peer.
499
502
 
500
503
  Equivalent commands:
@@ -10,7 +10,7 @@
10
10
 
11
11
  - 这些 ChatGPT 账号和 auth 文件都由你合法拥有和维护。
12
12
  - 多台机器都运行 FoxClaw,并且每台机器至少有一个 Telegram bot。
13
- - 你希望 auth 文件在节点间自动保持较新,并允许 FoxClaw 在已启用 ChatGPT 候选 `last_refresh` 超过 9 天时,持有跨节点刷新锁后主动刷新。
13
+ - 你希望 auth 文件在节点间自动保持较新,并允许 FoxClaw 在已启用 ChatGPT 候选 `last_refresh` 已满 8 天时,持有跨节点刷新锁后主动刷新。
14
14
  - 默认推荐每台机器只选择一个“联系人 bot”参与跨节点同步;同一节点内其他 bot 继续使用原本的本机 auth 镜像。
15
15
 
16
16
  不适合:
@@ -25,7 +25,7 @@
25
25
 
26
26
  - **Push**:本节点登录、Codex 自动刷新或 `/auth refresh all confirm` 成功并通过本机 usage 验证后,把较新的候选加密发送给 peer。
27
27
  - **Pull**:本节点切换或重载 auth 前,如果本机其他 runtime 没有更新副本,会向 peer 请求同名、同账号的较新候选。
28
- - **Lease**:执行会旋转 refresh token 的 `/auth refresh all confirm` 或后台 9 天主动刷新前,先向 peer 申请跨节点刷新锁;任一 peer 忙碌、拒绝或无响应都会阻止刷新。
28
+ - **Lease**:执行会旋转 refresh token 的 `/auth refresh all confirm`、后台 8 天主动刷新或 `/auth` 集群自检前,先向 peer 申请跨节点刷新锁;任一 peer 忙碌、拒绝或无响应都会阻止刷新。
29
29
 
30
30
  安全边界:
31
31
 
@@ -153,6 +153,16 @@ auth sync 测试完成:已发送 1,收到回应 1。
153
153
 
154
154
  如果显示 `未回应:@peer_bot`,说明 Telegram 发送可能成功,但对方没有成功接收、解密、通过 allowlist,或没有运行同一组 auth sync 配置。
155
155
 
156
+ `/auth` 面板还提供 **全节点自检并同步**(命令等价入口是 `/auth sync audit`)。它会让所有已配置 peer 用 usage 接口逐个验证本机候选,然后:
157
+
158
+ - 对同名、同 account、同 ChatGPT 用户身份的候选,选择最新且验证有效的副本并分发给所有 peer;
159
+ - 只有显式审计已经证明时间戳更晚的副本无效时,才允许经过验证的较旧有效副本替换它;
160
+ - 仅当所有 peer 都回应、没有任何有效副本,且至少两个节点独立判定无效时,才把候选标为 `?`;
161
+ - 任一 peer 未回应、忙碌或账号/用户身份冲突时,不做无效裁决;
162
+ - 由发起节点刷新 `last_refresh` 已满 8 天的已启用 ChatGPT 候选,再把刷新结果分发给所有 peer。
163
+
164
+ 一次审计覆盖发起节点 `AUTH_SYNC_PEERS` 中的全部联系人。要让每个节点都参与同一次操作,请使用相互 allowlist 的全互联 peer 配置。
165
+
156
166
  3. 用低风险候选做第一次广播。先确认所有 bot runtime 空闲,然后在节点 A 执行:
157
167
 
158
168
  ```text
@@ -209,4 +219,4 @@ auth sync 测试完成:已发送 1,收到回应 1。
209
219
 
210
220
  **要不要定期 `/auth refresh all confirm` 保活**
211
221
 
212
- 不要手动定期强刷。Codex 会按 access token 到期自动刷新;FoxClaw 也会在已启用 ChatGPT 候选 `last_refresh` 超过 9 天时,等全局空闲并拿到跨节点刷新锁后主动刷新。`/auth refresh all confirm` 仍然是人工维护命令,用于你明确接受 refresh token 轮换风险的场景。
222
+ 不要手动定期强刷。Codex 会按 access token 到期自动刷新;FoxClaw 也会在已启用 ChatGPT 候选 `last_refresh` 已满 8 天时,等全局空闲并拿到跨节点刷新锁后主动刷新。`/auth refresh all confirm` 仍然是人工维护命令,用于你明确接受 refresh token 轮换风险的场景。
@@ -439,11 +439,13 @@ Candidates: 2
439
439
 
440
440
  右侧 `✅` / `⏸️` 表示当前是否参与自动轮转。点一下会切换启用/禁用,列表刷新后图标会随状态变化。点击候选会切换 auth、重启对应 runtime,并在原消息上刷新面板且保留按钮,因此可以立即连续切换。`--` 表示该候选还没有额度历史快照。健康摘要会区分正常、额度偏低、额度耗尽、额度未知、长期未刷新、API key、无效 auth 文件和需要登录修复。
441
441
 
442
+ **全节点自检并同步** 按钮会通知所有已配置 auth-sync peer 验证本机候选。FoxClaw 会采用并分发最新有效副本;只有完整的多节点无效共识才会标记 `?`;`last_refresh` 已满 8 天的已启用 ChatGPT auth 由发起节点刷新后再分发。存在未回应、忙碌或身份冲突节点时,本轮不会做无效裁决和临期刷新。
443
+
442
444
  当某个候选在实际使用中已经失败,并且 FoxClaw 无法从本机 mirror 或跨节点同步恢复同账号较新凭据时,会标为“需要登录修复”。这类候选不会参与自动轮转和后台主动刷新,也不会出现在“已启用”筛选里。它的按钮会显示 `?`。点击后有两个选择:`登录修复` 会在选中该候选的状态下启动设备码登录;`删除` 会从 canonical 和所有本机 bot runtime 中删除这个候选,并清理它的额度缓存。
443
445
 
444
446
  `/auth refresh all` 是仅命令入口的维护操作,因为 ChatGPT refresh token 会被轮换。只有所有 Telegram runtime、微信 runtime、审批、待输入、登录流程和 auth 镜像写入都空闲时才允许执行。命令会先显示风险确认:如果 OpenAI/Codex 已经消费旧 refresh token,但因为网络、进程或磁盘故障导致新 token 没能成功保存,该候选可能需要重新设备登录,甚至重新手机号验证。确认后,它会逐个访问 ChatGPT 候选,让 Codex 通过 `account/read refreshToken=true` 强制刷新 token,再用 usage 接口验证,成功后镜像到其他 bot home,最后恢复原本的当前 auth 并显示摘要。
445
447
 
446
- OpenAI 没有公开 ChatGPT refresh token 的固定有效期或旧 token 重放宽限期。Codex 会在 access token 临近到期时自动刷新;如果 access token 里无法解析 `exp`,Codex 当前使用 `last_refresh` 超过约 8 天作为兜底刷新条件。面板把超过 8 天没有刷新记录的候选标为“长期未刷新”。FoxClaw 还会在后台每小时检查一次:已启用的 ChatGPT 候选如果 `last_refresh` 超过 9 天,会在所有 runtime 空闲、没有审批/待输入/登录/auth 镜像写入,并且拿到跨节点刷新锁后,主动刷新这一批候选。私聊里会显示一条主动刷新状态消息,并在结束时编辑成最终结果;较新的候选会继续镜像和跨节点同步,成批出现的镜像/跨节点刷新通知会合并成简短汇总。
448
+ OpenAI 没有公开 ChatGPT refresh token 的固定有效期或旧 token 重放宽限期。Codex 会在 access token 临近到期时自动刷新;如果 access token 里无法解析 `exp`,Codex 当前使用 `last_refresh` 超过约 8 天作为兜底刷新条件。面板把超过 8 天没有刷新记录的候选标为“长期未刷新”。FoxClaw 还会在后台每小时检查一次:已启用的 ChatGPT 候选如果 `last_refresh` 已满 8 天,会在所有 runtime 空闲、没有审批/待输入/登录/auth 镜像写入,并且拿到跨节点刷新锁后,主动刷新这一批候选。私聊里会显示一条主动刷新状态消息,并在结束时编辑成最终结果;较新的候选会继续镜像和跨节点同步,成批出现的镜像/跨节点刷新通知会合并成简短汇总。
447
449
 
448
450
  ### 6.4 跨节点 auth 同步
449
451
 
@@ -480,14 +482,14 @@ AUTH_AUTO_DELETE_NEEDS_REPAIR=false
480
482
  - 只接收 `AUTH_SYNC_PEERS` 中 peer bot 发来的同步文件;密钥、cluster、nonce 或 payload 校验失败时不会写盘。
481
483
  - 远端导入必须等本机全局空闲,再临时切换到待验证 auth、重启 app-server、读取 usage 验证成功后才写入候选。
482
484
  - 同名候选如果已知属于不同 account id,或属于同一 account 下不同的可识别 ChatGPT 用户/邮箱,永远拒绝覆盖。
483
- - 跨节点恢复只拉取 peer 已持有的有效副本,不会在恢复过程中直接轮换 refresh token;找不到有效副本时会停止,提示你人工维护授权。后台 9 天主动刷新会单独申请跨节点刷新锁,拿不到锁就跳过本轮。
485
+ - 跨节点恢复只拉取 peer 已持有的有效副本,不会在恢复过程中直接轮换 refresh token;找不到有效副本时会停止,提示你人工维护授权。后台 8 天主动刷新会单独申请跨节点刷新锁,拿不到锁就跳过本轮。
484
486
  - 如果开启 `AUTH_AUTO_DELETE_NEEDS_REPAIR=true` 或在 `/config` 中打开自动剔除,无法恢复的候选会直接删除并向 peer 传播删除 tombstone;私聊通知会压缩为 auth 池摘要:历史总数、存活数、因失效剔除数。
485
487
 
486
488
  双主动流程:
487
489
 
488
490
  - push:本节点登录、Codex 自动刷新或 `/auth refresh all` 成功并通过本机镜像验证后,会主动把较新的候选加密推送给 peer。
489
491
  - pull:本节点切换或重载 auth 前如果发现本地候选不是最新,会先查本机其他 runtime;仍找不到时,再向 peer 拉取同名同账号的较新副本。
490
- - lease:执行会旋转 refresh token 的 `/auth refresh all confirm` 或后台 9 天主动刷新前,会向 peer 申请跨节点刷新锁。任一 peer 忙碌、拒绝或无响应都会阻止刷新。
492
+ - lease:执行会旋转 refresh token 的 `/auth refresh all confirm`、后台 8 天主动刷新或集群自检前,会向 peer 申请跨节点刷新锁。任一 peer 忙碌、拒绝或无响应都会阻止刷新。
491
493
 
492
494
  命令:
493
495
 
@@ -495,6 +497,7 @@ AUTH_AUTO_DELETE_NEEDS_REPAIR=false
495
497
  - `/auth sync events [过滤]`:查看最近同步事件,可按候选名、peer、request id、事件类型、阶段或详情过滤。
496
498
  - `/auth sync trace <requestId>`:查看某个 request id 或事件 id 的最近流水。
497
499
  - `/auth sync test`:发送加密 ping 并等待 peer 返回 pong,确认 peer、共享密钥和 Bot-to-Bot 私聊可用。
500
+ - `/auth sync audit`:执行与 `/auth` 面板“全节点自检并同步”按钮相同的验证、协商、无效共识、临期刷新和分发流程。
498
501
  - `/auth sync push all`:手动广播当前节点已验证的全部候选,不刷新 token;“已发送”不等于对端已经导入,需要在 peer 上看 `/auth sync status` 和 `/auth`。
499
502
 
500
503
  命令等价用法:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxden-app/foxclaw",
3
- "version": "0.5.73",
3
+ "version": "0.5.76",
4
4
  "description": "Foxden local execution claw for controlling Codex from trusted chat interfaces.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",