@foxden-app/foxclaw 0.5.74 → 0.5.77
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 +22 -0
- package/dist/auth/cross_node_sync.d.ts +29 -0
- package/dist/auth/cross_node_sync.js +320 -1
- package/dist/auth/mirror.d.ts +2 -0
- package/dist/auth/mirror.js +23 -1
- package/dist/controller/controller.d.ts +4 -0
- package/dist/controller/controller.js +143 -2
- package/dist/i18n.d.ts +28 -4
- package/dist/i18n.js +28 -4
- package/dist/main.js +29 -0
- package/docs/cross-node-auth-sync.md +13 -3
- package/docs/devlogs//346/212/200/346/234/257/351/200/232/350/256/257/01./346/212/212 Codex /345/270/246/345/210/260/346/234/200/345/220/216/344/270/200/345/205/254/351/207/214/357/274/232FoxClaw /347/232/204 auth /347/224/237/345/221/275/345/221/250/346/234/237/344/270/216/350/267/250/350/212/202/347/202/271/345/220/214/346/255/245.md" +2 -2
- package/docs/user-manual.md +6 -3
- package/docs/zh/cross-node-auth-sync.md +13 -3
- package/docs/zh/user-manual.md +6 -3
- package/package.json +1 -1
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.77 - 2026-07-11
|
|
6
|
+
|
|
7
|
+
### 中文
|
|
8
|
+
- 修复跨节点升级广播触发的后台升级完成后,旧服务把内部 `cluster:*` scope 当成 Telegram scope 解析并反复记录 `Expected telegram: scope` 的问题。集群升级现在只记录完成状态并清理轮询文件,不再尝试向不存在的聊天发送回报。
|
|
9
|
+
|
|
10
|
+
### English
|
|
11
|
+
- Fixed cluster-broadcast updates repeatedly logging `Expected telegram: scope` after completion because the old service tried to parse the internal `cluster:*` scope as a Telegram destination. Cluster updates now record completion and clear the poll state without trying to send a chat reply to a non-chat scope.
|
|
12
|
+
|
|
13
|
+
## 0.5.76 - 2026-07-11
|
|
14
|
+
|
|
15
|
+
### 中文
|
|
16
|
+
- `/auth` 面板新增“全节点自检并同步”按钮,命令等价入口为 `/auth sync audit`。发起节点会申请跨节点刷新锁,通知所有已配置 peer 逐个通过 Codex usage 接口验证 auth,并在同一轮中汇总节点、有效候选、无效候选、未回应、忙碌和身份冲突状态。
|
|
17
|
+
- 集群审计会在同名、同 account、同 ChatGPT 用户身份范围内采用最新且验证有效的副本,并分发给所有 peer。完整审计确认较新的副本无效时,允许经过验证的较旧有效副本替换它;任一节点未回应或忙碌时仍坚持普通的只接受更新副本规则。
|
|
18
|
+
- 只有所有 peer 都回应、没有任何有效副本,并且至少两个节点独立确认无效时,才会把候选统一标记为 `?` 等待人工处理;有效副本导入后会恢复 active 状态并清除旧失败记录。
|
|
19
|
+
- 临期主动刷新阈值由 9 天统一为 `last_refresh` 已满 8 天。集群自检完整结束后,由主动发起的 bot 刷新符合条件的已启用 ChatGPT auth,再向所有 peer 分发;同一节点的 canonical 和各 Codex home 副本都会参与检查,避免漏掉本机仍有效的旧副本。
|
|
20
|
+
|
|
21
|
+
### English
|
|
22
|
+
- 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.
|
|
23
|
+
- 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.
|
|
24
|
+
- 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.
|
|
25
|
+
- 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.
|
|
26
|
+
|
|
5
27
|
## 0.5.74 - 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
|
}
|
package/dist/auth/mirror.d.ts
CHANGED
|
@@ -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;
|
package/dist/auth/mirror.js
CHANGED
|
@@ -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 =
|
|
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
|
}
|
|
@@ -4647,6 +4655,11 @@ export class BridgeSessionCore {
|
|
|
4647
4655
|
this.scheduleSelfUpdateStatusPoll();
|
|
4648
4656
|
return;
|
|
4649
4657
|
}
|
|
4658
|
+
if (status.scopeId.startsWith('cluster:')) {
|
|
4659
|
+
this.coordinator?.selfUpdateCompleted?.(status);
|
|
4660
|
+
await this.selfUpdater?.clearStatus();
|
|
4661
|
+
return;
|
|
4662
|
+
}
|
|
4650
4663
|
if (!this.ownsScope(status.scopeId)) {
|
|
4651
4664
|
this.scheduleSelfUpdateStatusPoll();
|
|
4652
4665
|
return;
|
|
@@ -4935,6 +4948,20 @@ export class BridgeSessionCore {
|
|
|
4935
4948
|
await this.sendMessage(scopeId, message);
|
|
4936
4949
|
return;
|
|
4937
4950
|
}
|
|
4951
|
+
if (action === 'audit' || action === 'check') {
|
|
4952
|
+
if (!this.canRunGlobalAuthRefresh()) {
|
|
4953
|
+
await this.sendMessage(scopeId, t(locale, 'auth_cluster_audit_blocked_active'));
|
|
4954
|
+
return;
|
|
4955
|
+
}
|
|
4956
|
+
await this.sendMessage(scopeId, t(locale, 'auth_cluster_audit_starting'));
|
|
4957
|
+
const outcome = await this.runAuthClusterAudit();
|
|
4958
|
+
if (!outcome) {
|
|
4959
|
+
await this.sendMessage(scopeId, t(locale, 'auth_sync_disabled'));
|
|
4960
|
+
return;
|
|
4961
|
+
}
|
|
4962
|
+
await this.sendRichInternalMessage(scopeId, '/auth sync audit', formatAuthClusterAuditResult(locale, outcome));
|
|
4963
|
+
return;
|
|
4964
|
+
}
|
|
4938
4965
|
if (action === 'safe' || (action === 'push' && args[1]?.toLowerCase() === 'all')) {
|
|
4939
4966
|
if (!this.canRunGlobalAuthRefresh()) {
|
|
4940
4967
|
await this.sendMessage(scopeId, t(locale, 'auth_sync_push_blocked_active'));
|
|
@@ -4965,6 +4992,48 @@ export class BridgeSessionCore {
|
|
|
4965
4992
|
? { localSynced: 0, localSkipped: 0, sent: pushResult.sent, skipped: pushResult.skipped }
|
|
4966
4993
|
: null;
|
|
4967
4994
|
}
|
|
4995
|
+
async runAuthClusterAudit() {
|
|
4996
|
+
if (!this.coordinator?.authSyncAudit)
|
|
4997
|
+
return null;
|
|
4998
|
+
const lease = await this.coordinator.acquireAuthRefreshLease?.('cluster auth audit and stale refresh');
|
|
4999
|
+
if (lease && !lease.ok) {
|
|
5000
|
+
throw new UserFacingError(lease.reason ?? 'cluster auth audit lease was not granted');
|
|
5001
|
+
}
|
|
5002
|
+
try {
|
|
5003
|
+
const audit = await this.coordinator.authSyncAudit();
|
|
5004
|
+
if (!audit)
|
|
5005
|
+
return null;
|
|
5006
|
+
const refresh = { refreshed: [], skipped: [], failed: [] };
|
|
5007
|
+
let refreshSkippedReason = null;
|
|
5008
|
+
const complete = audit.nodesResponded === audit.nodesExpected
|
|
5009
|
+
&& audit.missingPeers.length === 0
|
|
5010
|
+
&& audit.busyNodes.length === 0;
|
|
5011
|
+
if (complete) {
|
|
5012
|
+
const state = await this.listCodexAuthState();
|
|
5013
|
+
const staleNames = new Set(state.candidates
|
|
5014
|
+
.filter(candidate => (!candidate.disabled
|
|
5015
|
+
&& candidate.state !== 'needs_repair'
|
|
5016
|
+
&& candidate.credentialKind === 'chatgpt'
|
|
5017
|
+
&& candidate.credentialLastRefreshMs !== null
|
|
5018
|
+
&& candidate.credentialLastRefreshMs <= Date.now() - CODEX_AUTH_PROACTIVE_REFRESH_DAYS * 24 * 60 * 60_000))
|
|
5019
|
+
.map(candidate => candidate.name));
|
|
5020
|
+
if (staleNames.size > 0) {
|
|
5021
|
+
const refreshed = await this.refreshCodexAuthCandidates(staleNames);
|
|
5022
|
+
refresh.refreshed.push(...refreshed.refreshed);
|
|
5023
|
+
refresh.skipped.push(...refreshed.skipped);
|
|
5024
|
+
refresh.failed.push(...refreshed.failed);
|
|
5025
|
+
}
|
|
5026
|
+
}
|
|
5027
|
+
else {
|
|
5028
|
+
refreshSkippedReason = 'cluster audit was incomplete';
|
|
5029
|
+
}
|
|
5030
|
+
const push = await this.coordinator.authSyncPushAll?.() ?? { sent: 0, skipped: 0 };
|
|
5031
|
+
return { audit, refresh, push, refreshSkippedReason };
|
|
5032
|
+
}
|
|
5033
|
+
finally {
|
|
5034
|
+
await this.coordinator.releaseAuthRefreshLease?.(lease?.leaseId ?? null);
|
|
5035
|
+
}
|
|
5036
|
+
}
|
|
4968
5037
|
async handleAuthRefreshAllCommand(scopeId, locale, confirmed = false) {
|
|
4969
5038
|
if (!this.canRunGlobalAuthRefresh()) {
|
|
4970
5039
|
await this.sendMessage(scopeId, t(locale, 'auth_refresh_all_blocked_active'));
|
|
@@ -5756,6 +5825,42 @@ export class BridgeSessionCore {
|
|
|
5756
5825
|
}
|
|
5757
5826
|
return;
|
|
5758
5827
|
}
|
|
5828
|
+
if (action === 'cluster_audit') {
|
|
5829
|
+
if (!this.canRunGlobalAuthRefresh()) {
|
|
5830
|
+
await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_cluster_audit_blocked_active'));
|
|
5831
|
+
return;
|
|
5832
|
+
}
|
|
5833
|
+
await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_cluster_audit_starting'));
|
|
5834
|
+
if (record.messageId !== null) {
|
|
5835
|
+
await this.editAuthPanelMessage(event.scopeId, record.messageId, t(locale, 'auth_cluster_audit_starting'), []);
|
|
5836
|
+
this.pauseStalePanelDeletion(event.scopeId, record.messageId);
|
|
5837
|
+
}
|
|
5838
|
+
let outcome;
|
|
5839
|
+
try {
|
|
5840
|
+
outcome = await this.runAuthClusterAudit();
|
|
5841
|
+
}
|
|
5842
|
+
catch (error) {
|
|
5843
|
+
if (record.messageId !== null) {
|
|
5844
|
+
await this.editAuthPanelMessage(event.scopeId, record.messageId, t(locale, 'auth_cluster_audit_failed', { error: formatUserError(error) }), authChoiceKeyboard(locale, record));
|
|
5845
|
+
}
|
|
5846
|
+
return;
|
|
5847
|
+
}
|
|
5848
|
+
if (!outcome) {
|
|
5849
|
+
if (record.messageId !== null) {
|
|
5850
|
+
await this.editAuthPanelMessage(event.scopeId, record.messageId, t(locale, 'auth_sync_disabled'), authChoiceKeyboard(locale, record));
|
|
5851
|
+
}
|
|
5852
|
+
return;
|
|
5853
|
+
}
|
|
5854
|
+
const state = await this.listCodexAuthState();
|
|
5855
|
+
await this.applySharedCodexAuthQuotaSnapshots(state);
|
|
5856
|
+
record.candidates = state.candidates;
|
|
5857
|
+
record.createdAt = Date.now();
|
|
5858
|
+
clampCodexAuthListOffset(record);
|
|
5859
|
+
if (record.messageId !== null) {
|
|
5860
|
+
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));
|
|
5861
|
+
}
|
|
5862
|
+
return;
|
|
5863
|
+
}
|
|
5759
5864
|
if (action === 'refresh_all') {
|
|
5760
5865
|
if (!this.canRunGlobalAuthRefresh()) {
|
|
5761
5866
|
await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_refresh_all_blocked_active'));
|
|
@@ -10392,6 +10497,9 @@ function authChoiceKeyboard(locale, record) {
|
|
|
10392
10497
|
{ text: t(locale, 'button_permissions'), callback_data: 'nav:permissions' },
|
|
10393
10498
|
{ text: t(locale, 'button_login_device'), callback_data: `auth:${record.localId}:login_device` },
|
|
10394
10499
|
]);
|
|
10500
|
+
rows.push([
|
|
10501
|
+
{ text: t(locale, 'button_auth_cluster_audit'), callback_data: `auth:${record.localId}:cluster_audit` },
|
|
10502
|
+
]);
|
|
10395
10503
|
rows.push([
|
|
10396
10504
|
{ text: t(locale, 'button_auth_safe_sync'), callback_data: `auth:${record.localId}:safe_sync` },
|
|
10397
10505
|
{ text: t(locale, 'button_auth_reload'), callback_data: `auth:${record.localId}:reload` },
|
|
@@ -10507,6 +10615,39 @@ function formatAuthRefreshAllResult(locale, result, mode = 'manual') {
|
|
|
10507
10615
|
}
|
|
10508
10616
|
return lines.join('\n');
|
|
10509
10617
|
}
|
|
10618
|
+
function formatAuthClusterAuditResult(locale, outcome) {
|
|
10619
|
+
const { audit, refresh, push } = outcome;
|
|
10620
|
+
const lines = [t(locale, 'auth_cluster_audit_summary', {
|
|
10621
|
+
responded: audit.nodesResponded,
|
|
10622
|
+
expected: audit.nodesExpected,
|
|
10623
|
+
checked: audit.checkedCandidates,
|
|
10624
|
+
valid: audit.validCandidates,
|
|
10625
|
+
invalid: audit.invalidCandidates,
|
|
10626
|
+
})];
|
|
10627
|
+
if (audit.synchronizedCandidates.length > 0) {
|
|
10628
|
+
lines.push(t(locale, 'auth_cluster_audit_synced', { value: audit.synchronizedCandidates.join(', ') }));
|
|
10629
|
+
}
|
|
10630
|
+
if (audit.consensusInvalidCandidates.length > 0) {
|
|
10631
|
+
lines.push(t(locale, 'auth_cluster_audit_repair', { value: audit.consensusInvalidCandidates.join(', ') }));
|
|
10632
|
+
}
|
|
10633
|
+
if (audit.missingPeers.length > 0) {
|
|
10634
|
+
lines.push(t(locale, 'auth_cluster_audit_missing', { value: audit.missingPeers.join(', ') }));
|
|
10635
|
+
}
|
|
10636
|
+
if (audit.busyNodes.length > 0) {
|
|
10637
|
+
lines.push(t(locale, 'auth_cluster_audit_busy', { value: audit.busyNodes.join(', ') }));
|
|
10638
|
+
}
|
|
10639
|
+
if (audit.identityConflicts.length > 0) {
|
|
10640
|
+
lines.push(t(locale, 'auth_cluster_audit_conflicts', { value: audit.identityConflicts.join(', ') }));
|
|
10641
|
+
}
|
|
10642
|
+
if (outcome.refreshSkippedReason) {
|
|
10643
|
+
lines.push(t(locale, 'auth_cluster_audit_refresh_skipped'));
|
|
10644
|
+
}
|
|
10645
|
+
else {
|
|
10646
|
+
lines.push(formatAuthRefreshAllResult(locale, refresh, 'proactive'));
|
|
10647
|
+
}
|
|
10648
|
+
lines.push(t(locale, 'auth_cluster_audit_push', { sent: push.sent, skipped: push.skipped }));
|
|
10649
|
+
return lines.join('\n');
|
|
10650
|
+
}
|
|
10510
10651
|
function formatAuthSyncStatus(locale, status, proactiveRefresh = null) {
|
|
10511
10652
|
if (!status?.enabled) {
|
|
10512
10653
|
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
|
|
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: "
|
|
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
|
|
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: '
|
|
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
|
@@ -755,6 +755,7 @@ async function runServeCli() {
|
|
|
755
755
|
},
|
|
756
756
|
authSyncPushAll: () => authSync?.pushAll() ?? Promise.resolve({ sent: 0, skipped: 0 }),
|
|
757
757
|
authSyncTest: () => authSync?.testPeers() ?? Promise.resolve({ sent: 0, replied: 0, missing: [] }),
|
|
758
|
+
authSyncAudit: () => authSync?.auditCluster() ?? Promise.resolve(null),
|
|
758
759
|
statusUpdated: () => writeAggregateStatus(),
|
|
759
760
|
getServiceStatus: async () => ({
|
|
760
761
|
currentVersion: readPackageVersion(),
|
|
@@ -818,6 +819,7 @@ async function runServeCli() {
|
|
|
818
819
|
}, {
|
|
819
820
|
readLocalCandidate: (candidateName) => mirror.readNewestCandidate(candidateName),
|
|
820
821
|
listLocalCandidates: () => mirror.listNewestCandidates(),
|
|
822
|
+
listLocalCandidateCopies: () => mirror.listCandidateCopies(),
|
|
821
823
|
validateCandidate: async (candidateName, raw, expectedAccountId) => {
|
|
822
824
|
if (!authSyncLocalIdle()) {
|
|
823
825
|
return { ok: false, reason: 'runtime is not idle' };
|
|
@@ -829,6 +831,7 @@ async function runServeCli() {
|
|
|
829
831
|
return runtime.core.validateExternalCodexAuthCandidate(candidateName, raw, expectedAccountId);
|
|
830
832
|
},
|
|
831
833
|
importCandidate: (candidateName, raw, source) => mirror.importExternalCandidate(candidateName, raw, source),
|
|
834
|
+
markCandidateState: (candidateName, state, expected) => markAuditedAuthCandidateState(store, mirror, candidateName, state, expected, authRuntimeIds),
|
|
832
835
|
deleteLocalCandidate: async (candidateName, source) => {
|
|
833
836
|
if (!authSyncLocalIdle()) {
|
|
834
837
|
return { ok: false, deleted: false, reason: 'runtime is not idle' };
|
|
@@ -961,6 +964,7 @@ async function runServeCli() {
|
|
|
961
964
|
},
|
|
962
965
|
authSyncPushAll: () => singleAuthSync?.pushAll() ?? Promise.resolve({ sent: 0, skipped: 0 }),
|
|
963
966
|
authSyncTest: () => singleAuthSync?.testPeers() ?? Promise.resolve({ sent: 0, replied: 0, missing: [] }),
|
|
967
|
+
authSyncAudit: () => singleAuthSync?.auditCluster() ?? Promise.resolve(null),
|
|
964
968
|
statusUpdated: (status) => {
|
|
965
969
|
writeRuntimeStatus(config.statusPath, {
|
|
966
970
|
...status,
|
|
@@ -1000,6 +1004,7 @@ async function runServeCli() {
|
|
|
1000
1004
|
}, {
|
|
1001
1005
|
readLocalCandidate: (candidateName) => singleMirror.readNewestCandidate(candidateName),
|
|
1002
1006
|
listLocalCandidates: () => singleMirror.listNewestCandidates(),
|
|
1007
|
+
listLocalCandidateCopies: () => singleMirror.listCandidateCopies(),
|
|
1003
1008
|
validateCandidate: async (candidateName, raw, expectedAccountId) => {
|
|
1004
1009
|
if (!singleAuthSyncLocalIdle()) {
|
|
1005
1010
|
return { ok: false, reason: 'runtime is not idle' };
|
|
@@ -1007,6 +1012,7 @@ async function runServeCli() {
|
|
|
1007
1012
|
return core.validateExternalCodexAuthCandidate(candidateName, raw, expectedAccountId);
|
|
1008
1013
|
},
|
|
1009
1014
|
importCandidate: (candidateName, raw, source) => singleMirror.importExternalCandidate(candidateName, raw, source),
|
|
1015
|
+
markCandidateState: (candidateName, state, expected) => markAuditedAuthCandidateState(store, singleMirror, candidateName, state, expected, ['default']),
|
|
1010
1016
|
deleteLocalCandidate: async (candidateName, source) => {
|
|
1011
1017
|
if (!singleAuthSyncLocalIdle()) {
|
|
1012
1018
|
return { ok: false, deleted: false, reason: 'runtime is not idle' };
|
|
@@ -1101,6 +1107,29 @@ function restoreImportedAuthCandidateState(store, candidateName, runtimeIds) {
|
|
|
1101
1107
|
store.setCodexAuthCandidateDisabled(candidateName, false, runtimeId);
|
|
1102
1108
|
}
|
|
1103
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
|
+
}
|
|
1104
1133
|
function createAuthMirrorNotifier(store, botId, bot, aggregator, options = {}) {
|
|
1105
1134
|
return async (event) => {
|
|
1106
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
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
136
|
+
3. **Lease**:凡是会旋转 refresh token 的操作,例如 `/auth refresh all confirm`、后台 8 天主动刷新或集群 auth 自检,必须先申请跨节点刷新锁。任一 peer 忙碌、拒绝或超时,都阻止本轮刷新。
|
|
137
137
|
|
|
138
138
|
这里最重要的是克制。
|
|
139
139
|
|
package/docs/user-manual.md
CHANGED
|
@@ -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`
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
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`
|
|
222
|
+
不要手动定期强刷。Codex 会按 access token 到期自动刷新;FoxClaw 也会在已启用 ChatGPT 候选 `last_refresh` 已满 8 天时,等全局空闲并拿到跨节点刷新锁后主动刷新。`/auth refresh all confirm` 仍然是人工维护命令,用于你明确接受 refresh token 轮换风险的场景。
|
package/docs/zh/user-manual.md
CHANGED
|
@@ -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`
|
|
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;找不到有效副本时会停止,提示你人工维护授权。后台
|
|
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
|
|
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
|
命令等价用法:
|