@foxden-app/foxclaw 0.5.20 → 0.5.21
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/.env.example +4 -0
- package/CHANGELOG.md +14 -0
- package/README.md +1 -1
- package/dist/auth/cross_node_sync.d.ts +55 -0
- package/dist/auth/cross_node_sync.js +199 -2
- package/dist/config.d.ts +2 -0
- package/dist/config.js +2 -0
- package/dist/controller/controller.d.ts +5 -1
- package/dist/controller/controller.js +177 -15
- package/dist/i18n.d.ts +26 -0
- package/dist/i18n.js +26 -0
- package/dist/main.js +141 -7
- package/dist/store/database.d.ts +9 -0
- package/dist/store/database.js +87 -0
- package/docs/cross-node-auth-sync.md +4 -0
- package/docs/user-manual.md +3 -0
- package/docs/zh/cross-node-auth-sync.md +4 -0
- package/docs/zh/user-manual.md +3 -0
- package/package.json +1 -1
package/dist/main.js
CHANGED
|
@@ -253,7 +253,9 @@ async function runServeCli() {
|
|
|
253
253
|
label: runtime.bot.username ? `@${runtime.bot.username}` : runtime.id,
|
|
254
254
|
authDir: runtime.authDir,
|
|
255
255
|
validate: async (context) => validateRefreshedAuthCandidate(runtime, context.candidateName),
|
|
256
|
-
notify: createAuthMirrorNotifier(store, runtime.id, runtime.bot, authNotificationAggregator
|
|
256
|
+
notify: createAuthMirrorNotifier(store, runtime.id, runtime.bot, authNotificationAggregator, {
|
|
257
|
+
quietAuthPoolMode: () => config.authAutoDeleteNeedsRepair,
|
|
258
|
+
}),
|
|
257
259
|
})), logger, path.join(APP_HOME, 'runtime', 'auth-mirror.json'), {
|
|
258
260
|
onSynced: async (event) => {
|
|
259
261
|
await authSync?.publishCandidate(event.record.candidateName);
|
|
@@ -324,7 +326,10 @@ async function runServeCli() {
|
|
|
324
326
|
canSelfUpdate: () => authSyncLocalIdle()
|
|
325
327
|
&& (authSync ? authSync.isIdle() : localAuthRefreshLease.isIdle()),
|
|
326
328
|
authCandidateUpdated: (runtimeId, candidateName) => mirror.syncRuntimeCandidate(runtimeId, candidateName).then(() => undefined),
|
|
327
|
-
authCandidateDeleted: (_runtimeId, candidateName
|
|
329
|
+
authCandidateDeleted: async (_runtimeId, candidateName, reason = null) => {
|
|
330
|
+
await mirror.deleteCandidate(candidateName);
|
|
331
|
+
await authSync?.publishCandidateDeletion(candidateName, reason);
|
|
332
|
+
},
|
|
328
333
|
recoverAuthCandidate: async (runtimeId, candidateName, options = {}) => {
|
|
329
334
|
const local = await mirror.recoverRuntimeCandidate(runtimeId, candidateName);
|
|
330
335
|
if (local)
|
|
@@ -424,8 +429,22 @@ async function runServeCli() {
|
|
|
424
429
|
return runtime.core.validateExternalCodexAuthCandidate(candidateName, raw, expectedAccountId);
|
|
425
430
|
},
|
|
426
431
|
importCandidate: (candidateName, raw, source) => mirror.importExternalCandidate(candidateName, raw, source),
|
|
432
|
+
deleteLocalCandidate: async (candidateName, source) => {
|
|
433
|
+
if (!authSyncLocalIdle()) {
|
|
434
|
+
return { ok: false, deleted: false, reason: 'runtime is not idle' };
|
|
435
|
+
}
|
|
436
|
+
await mirror.deleteCandidate(candidateName);
|
|
437
|
+
store.deleteCodexAuthCandidate(candidateName);
|
|
438
|
+
await Promise.all(runtimes.map(runtime => runtime.core.handleExternalCodexAuthCandidateDeleted(candidateName, source.reason ?? null)));
|
|
439
|
+
if (activeWeixinCore) {
|
|
440
|
+
await activeWeixinCore.handleExternalCodexAuthCandidateDeleted(candidateName, source.reason ?? null);
|
|
441
|
+
}
|
|
442
|
+
return { ok: true, deleted: true, reason: source.reason ?? null };
|
|
443
|
+
},
|
|
427
444
|
isIdle: authSyncLocalIdle,
|
|
428
|
-
notify: createAuthSyncNotifier(store, authSyncTransportBot.bot, authNotificationAggregator
|
|
445
|
+
notify: createAuthSyncNotifier(store, authSyncTransportBot.bot, authNotificationAggregator, {
|
|
446
|
+
quietAuthPoolMode: () => config.authAutoDeleteNeedsRepair,
|
|
447
|
+
}),
|
|
429
448
|
});
|
|
430
449
|
await authSync.initialize();
|
|
431
450
|
activeAuthSync = authSync;
|
|
@@ -494,7 +513,10 @@ async function runServeCli() {
|
|
|
494
513
|
canSelfUpdate: () => singleAuthSyncLocalIdle()
|
|
495
514
|
&& (singleAuthSync ? singleAuthSync.isIdle() : singleLocalAuthRefreshLease.isIdle()),
|
|
496
515
|
authCandidateUpdated: (runtimeId, candidateName) => singleMirror?.syncRuntimeCandidate(runtimeId, candidateName).then(() => undefined) ?? Promise.resolve(),
|
|
497
|
-
authCandidateDeleted: (_runtimeId, candidateName
|
|
516
|
+
authCandidateDeleted: async (_runtimeId, candidateName, reason = null) => {
|
|
517
|
+
await singleMirror?.deleteCandidate(candidateName);
|
|
518
|
+
await singleAuthSync?.publishCandidateDeletion(candidateName, reason);
|
|
519
|
+
},
|
|
498
520
|
recoverAuthCandidate: async (runtimeId, candidateName, options = {}) => {
|
|
499
521
|
const local = await singleMirror?.recoverRuntimeCandidate(runtimeId, candidateName) ?? null;
|
|
500
522
|
if (local)
|
|
@@ -570,8 +592,19 @@ async function runServeCli() {
|
|
|
570
592
|
return core.validateExternalCodexAuthCandidate(candidateName, raw, expectedAccountId);
|
|
571
593
|
},
|
|
572
594
|
importCandidate: (candidateName, raw, source) => singleMirror.importExternalCandidate(candidateName, raw, source),
|
|
595
|
+
deleteLocalCandidate: async (candidateName, source) => {
|
|
596
|
+
if (!singleAuthSyncLocalIdle()) {
|
|
597
|
+
return { ok: false, deleted: false, reason: 'runtime is not idle' };
|
|
598
|
+
}
|
|
599
|
+
await singleMirror.deleteCandidate(candidateName);
|
|
600
|
+
store.deleteCodexAuthCandidate(candidateName);
|
|
601
|
+
await core.handleExternalCodexAuthCandidateDeleted(candidateName, source.reason ?? null);
|
|
602
|
+
return { ok: true, deleted: true, reason: source.reason ?? null };
|
|
603
|
+
},
|
|
573
604
|
isIdle: singleAuthSyncLocalIdle,
|
|
574
|
-
notify: createAuthSyncNotifier(store, bot, authNotificationAggregator
|
|
605
|
+
notify: createAuthSyncNotifier(store, bot, authNotificationAggregator, {
|
|
606
|
+
quietAuthPoolMode: () => config.authAutoDeleteNeedsRepair,
|
|
607
|
+
}),
|
|
575
608
|
});
|
|
576
609
|
await singleAuthSync.initialize();
|
|
577
610
|
activeAuthSync = singleAuthSync;
|
|
@@ -641,8 +674,10 @@ async function runServeCli() {
|
|
|
641
674
|
throw error;
|
|
642
675
|
}
|
|
643
676
|
}
|
|
644
|
-
function createAuthMirrorNotifier(store, botId, bot, aggregator) {
|
|
677
|
+
function createAuthMirrorNotifier(store, botId, bot, aggregator, options = {}) {
|
|
645
678
|
return async (event) => {
|
|
679
|
+
if (options.quietAuthPoolMode?.())
|
|
680
|
+
return;
|
|
646
681
|
const privateScope = store.getTelegramPrivateScope(botId);
|
|
647
682
|
if (!privateScope)
|
|
648
683
|
return;
|
|
@@ -654,7 +689,27 @@ function createAuthMirrorNotifier(store, botId, bot, aggregator) {
|
|
|
654
689
|
}, event);
|
|
655
690
|
};
|
|
656
691
|
}
|
|
657
|
-
function createAuthSyncNotifier(store, bot, aggregator) {
|
|
692
|
+
function createAuthSyncNotifier(store, bot, aggregator, options = {}) {
|
|
693
|
+
const poolSummaryQueues = new Map();
|
|
694
|
+
const enqueuePoolSummary = (destination) => {
|
|
695
|
+
const existing = poolSummaryQueues.get(destination.key);
|
|
696
|
+
if (existing) {
|
|
697
|
+
clearTimeout(existing.timer);
|
|
698
|
+
}
|
|
699
|
+
const timer = setTimeout(() => {
|
|
700
|
+
const queued = poolSummaryQueues.get(destination.key);
|
|
701
|
+
if (!queued)
|
|
702
|
+
return;
|
|
703
|
+
poolSummaryQueues.delete(destination.key);
|
|
704
|
+
void queued.sendMessage(formatAuthPoolSummary(queued.locale, store.getCodexAuthPoolStats())).catch(() => undefined);
|
|
705
|
+
}, 2_000);
|
|
706
|
+
timer.unref();
|
|
707
|
+
poolSummaryQueues.set(destination.key, {
|
|
708
|
+
locale: destination.locale,
|
|
709
|
+
sendMessage: destination.sendMessage,
|
|
710
|
+
timer,
|
|
711
|
+
});
|
|
712
|
+
};
|
|
658
713
|
return async (event) => {
|
|
659
714
|
if (!bot.identity)
|
|
660
715
|
return;
|
|
@@ -667,6 +722,12 @@ function createAuthSyncNotifier(store, bot, aggregator) {
|
|
|
667
722
|
locale,
|
|
668
723
|
sendMessage: (text) => bot.sendMessage(privateScope.chatId, text),
|
|
669
724
|
};
|
|
725
|
+
if (options.quietAuthPoolMode?.() && isQuietAuthPoolNotification(event)) {
|
|
726
|
+
if (shouldSendQuietAuthPoolSummary(event)) {
|
|
727
|
+
enqueuePoolSummary(destination);
|
|
728
|
+
}
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
670
731
|
if (aggregator.enqueueAuthSync(destination, event)) {
|
|
671
732
|
return;
|
|
672
733
|
}
|
|
@@ -676,6 +737,45 @@ function createAuthSyncNotifier(store, bot, aggregator) {
|
|
|
676
737
|
function authNotificationDestinationKey(botId, chatId) {
|
|
677
738
|
return `${botId}:${chatId}`;
|
|
678
739
|
}
|
|
740
|
+
function formatAuthPoolSummary(locale, stats) {
|
|
741
|
+
return locale === 'zh'
|
|
742
|
+
? `auth 池:历史 ${stats.totalSeen},存活 ${stats.alive},因失效剔除 ${stats.deletedInvalid}。`
|
|
743
|
+
: `Auth pool: total seen ${stats.totalSeen}, alive ${stats.alive}, invalid-deleted ${stats.deletedInvalid}.`;
|
|
744
|
+
}
|
|
745
|
+
function isQuietAuthPoolNotification(event) {
|
|
746
|
+
switch (event.kind) {
|
|
747
|
+
case 'candidate_publish_started':
|
|
748
|
+
case 'candidate_publish_completed':
|
|
749
|
+
case 'push_all_started':
|
|
750
|
+
case 'push_all_completed':
|
|
751
|
+
case 'remote_bundle_received':
|
|
752
|
+
case 'remote_import_imported':
|
|
753
|
+
case 'remote_import_skipped':
|
|
754
|
+
case 'candidate_delete_sent':
|
|
755
|
+
case 'remote_delete_received':
|
|
756
|
+
case 'remote_delete_deleted':
|
|
757
|
+
case 'remote_delete_skipped':
|
|
758
|
+
case 'recovery_started':
|
|
759
|
+
case 'recovery_peer_empty':
|
|
760
|
+
case 'recovery_peer_bundle_received':
|
|
761
|
+
case 'recovery_failed':
|
|
762
|
+
case 'pull_request_received':
|
|
763
|
+
case 'pull_response_sent':
|
|
764
|
+
return true;
|
|
765
|
+
default:
|
|
766
|
+
return false;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
function shouldSendQuietAuthPoolSummary(event) {
|
|
770
|
+
switch (event.kind) {
|
|
771
|
+
case 'candidate_delete_sent':
|
|
772
|
+
case 'remote_delete_deleted':
|
|
773
|
+
case 'remote_delete_skipped':
|
|
774
|
+
return true;
|
|
775
|
+
default:
|
|
776
|
+
return false;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
679
779
|
function formatAuthSyncNotification(locale, event) {
|
|
680
780
|
const peers = 'peers' in event ? formatPeerList(event.peers, locale) : '';
|
|
681
781
|
if (locale === 'zh') {
|
|
@@ -704,6 +804,23 @@ function formatAuthSyncNotification(locale, event) {
|
|
|
704
804
|
return `${event.mode === 'pull' ? '跨节点拉取未改动本机文件' : '收到跨节点 auth 但未写盘'}:${event.candidateName}\n来源:${formatSource(event.sourceLabel, event.sourceNodeId)}\n原因:${event.reason}`;
|
|
705
805
|
case 'remote_import_failed':
|
|
706
806
|
return `${event.mode === 'pull' ? '跨节点拉取导入失败' : '跨节点 auth 导入失败'}:${event.candidateName}\n来源:${formatSource(event.sourceLabel, event.sourceNodeId)}\n原因:${event.reason}\n需要注意:如果其他候选也无法恢复,请人工介入重新登录或刷新这个 auth。`;
|
|
807
|
+
case 'candidate_delete_sent':
|
|
808
|
+
return `跨节点 auth 删除已发出:${event.candidateName}\nPeer:${peers}${event.reason ? `\n原因:${event.reason}` : ''}`;
|
|
809
|
+
case 'candidate_delete_failed':
|
|
810
|
+
return `跨节点 auth 删除发送失败:${event.candidateName}\nPeer:${peers}\n原因:${event.reason}`;
|
|
811
|
+
case 'remote_delete_received':
|
|
812
|
+
return [
|
|
813
|
+
`收到跨节点 auth 删除:${event.candidateName}`,
|
|
814
|
+
`来源:${formatSource(event.sourceLabel, event.sourceNodeId)},peer ${event.peer}`,
|
|
815
|
+
`处理:${event.queued ? `本机忙,已排队等待空闲后删除;当前待删除 ${event.queueLength}` : '本机空闲,正在删除同名候选'}`,
|
|
816
|
+
event.reason ? `原因:${event.reason}` : null,
|
|
817
|
+
].filter(Boolean).join('\n');
|
|
818
|
+
case 'remote_delete_deleted':
|
|
819
|
+
return `已执行跨节点 auth 删除:${event.candidateName}\n来源:${formatSource(event.sourceLabel, event.sourceNodeId)}${event.reason ? `\n原因:${event.reason}` : ''}`;
|
|
820
|
+
case 'remote_delete_skipped':
|
|
821
|
+
return `跨节点 auth 删除未改动本机文件:${event.candidateName}\n来源:${formatSource(event.sourceLabel, event.sourceNodeId)}\n原因:${event.reason}`;
|
|
822
|
+
case 'remote_delete_failed':
|
|
823
|
+
return `跨节点 auth 删除失败:${event.candidateName}\n来源:${formatSource(event.sourceLabel, event.sourceNodeId)}\n原因:${event.reason}`;
|
|
707
824
|
case 'recovery_started':
|
|
708
825
|
return `auth 恢复开始:${event.candidateName}\nRequest:${event.requestId}\n处理:同节点没有可用较新副本,正在向跨节点 peer 查询:${peers},最长等待 ${event.timeoutMs}ms`;
|
|
709
826
|
case 'recovery_peer_empty':
|
|
@@ -753,6 +870,23 @@ function formatAuthSyncNotification(locale, event) {
|
|
|
753
870
|
return `${event.mode === 'pull' ? 'Cross-node pull did not change local files' : 'Received cross-node auth but did not write it'}: ${event.candidateName}\nSource: ${formatSource(event.sourceLabel, event.sourceNodeId)}\nReason: ${event.reason}`;
|
|
754
871
|
case 'remote_import_failed':
|
|
755
872
|
return `${event.mode === 'pull' ? 'Cross-node pull import failed' : 'Cross-node auth import failed'}: ${event.candidateName}\nSource: ${formatSource(event.sourceLabel, event.sourceNodeId)}\nReason: ${event.reason}\nAttention: if no other candidate can recover this account, run device login or refresh this auth manually.`;
|
|
873
|
+
case 'candidate_delete_sent':
|
|
874
|
+
return `Cross-node auth delete sent: ${event.candidateName}\nPeers: ${peers}${event.reason ? `\nReason: ${event.reason}` : ''}`;
|
|
875
|
+
case 'candidate_delete_failed':
|
|
876
|
+
return `Cross-node auth delete send failed: ${event.candidateName}\nPeers: ${peers}\nReason: ${event.reason}`;
|
|
877
|
+
case 'remote_delete_received':
|
|
878
|
+
return [
|
|
879
|
+
`Received cross-node auth delete: ${event.candidateName}`,
|
|
880
|
+
`Source: ${formatSource(event.sourceLabel, event.sourceNodeId)}, peer ${event.peer}`,
|
|
881
|
+
`Action: ${event.queued ? `queued until this node is idle; pending deletes ${event.queueLength}` : 'deleting the matching local candidate'}`,
|
|
882
|
+
event.reason ? `Reason: ${event.reason}` : null,
|
|
883
|
+
].filter(Boolean).join('\n');
|
|
884
|
+
case 'remote_delete_deleted':
|
|
885
|
+
return `Applied cross-node auth delete: ${event.candidateName}\nSource: ${formatSource(event.sourceLabel, event.sourceNodeId)}${event.reason ? `\nReason: ${event.reason}` : ''}`;
|
|
886
|
+
case 'remote_delete_skipped':
|
|
887
|
+
return `Cross-node auth delete did not change local files: ${event.candidateName}\nSource: ${formatSource(event.sourceLabel, event.sourceNodeId)}\nReason: ${event.reason}`;
|
|
888
|
+
case 'remote_delete_failed':
|
|
889
|
+
return `Cross-node auth delete failed: ${event.candidateName}\nSource: ${formatSource(event.sourceLabel, event.sourceNodeId)}\nReason: ${event.reason}`;
|
|
756
890
|
case 'recovery_started':
|
|
757
891
|
return `Auth recovery started: ${event.candidateName}\nRequest: ${event.requestId}\nAction: no newer same-node copy was available; querying cross-node peers: ${peers}; timeout ${event.timeoutMs}ms`;
|
|
758
892
|
case 'recovery_peer_empty':
|
package/dist/store/database.d.ts
CHANGED
|
@@ -38,6 +38,11 @@ export interface CodexAuthQuotaSnapshotRecord {
|
|
|
38
38
|
secondaryRemainingPercent: number | null;
|
|
39
39
|
updatedAt: number;
|
|
40
40
|
}
|
|
41
|
+
export interface CodexAuthPoolStats {
|
|
42
|
+
totalSeen: number;
|
|
43
|
+
alive: number;
|
|
44
|
+
deletedInvalid: number;
|
|
45
|
+
}
|
|
41
46
|
export type CodexAuthCandidateState = 'active' | 'needs_repair';
|
|
42
47
|
export declare class BridgeStore {
|
|
43
48
|
private db;
|
|
@@ -125,6 +130,10 @@ export declare class BridgeStore {
|
|
|
125
130
|
setCodexAuthCandidateDisabled(name: string, disabled: boolean, runtimeId?: string): void;
|
|
126
131
|
setCodexAuthCandidateState(name: string, state: CodexAuthCandidateState, runtimeId?: string): void;
|
|
127
132
|
deleteCodexAuthCandidate(name: string): void;
|
|
133
|
+
recordCodexAuthPoolInventory(names: string[]): void;
|
|
134
|
+
recordCodexAuthCandidateInvalidDelete(name: string, reason?: string | null): void;
|
|
135
|
+
recordCodexAuthCandidateRemoved(name: string, reason?: string | null): void;
|
|
136
|
+
getCodexAuthPoolStats(): CodexAuthPoolStats;
|
|
128
137
|
setCodexAuthQuotaSnapshot(runtimeId: string, candidateName: string, accountId: string, quotaIdentityId: string, snapshot: Pick<CodexAuthQuotaSnapshotRecord, 'capturedAtMs' | 'planType' | 'primaryWindowDurationMins' | 'primaryRemainingPercent' | 'secondaryWindowDurationMins' | 'secondaryRemainingPercent'>): void;
|
|
129
138
|
listCodexAuthQuotaSnapshots(quotaIdentityIds: string[]): CodexAuthQuotaSnapshotRecord[];
|
|
130
139
|
private ensureColumn;
|
package/dist/store/database.js
CHANGED
|
@@ -197,6 +197,14 @@ export class BridgeStore {
|
|
|
197
197
|
);
|
|
198
198
|
CREATE INDEX IF NOT EXISTS codex_auth_quota_snapshots_account_idx
|
|
199
199
|
ON codex_auth_quota_snapshots(account_id);
|
|
200
|
+
CREATE TABLE IF NOT EXISTS codex_auth_pool_history (
|
|
201
|
+
name TEXT PRIMARY KEY,
|
|
202
|
+
first_seen_at INTEGER NOT NULL,
|
|
203
|
+
last_seen_at INTEGER NOT NULL,
|
|
204
|
+
deleted_at INTEGER,
|
|
205
|
+
delete_reason TEXT,
|
|
206
|
+
invalid_delete_count INTEGER NOT NULL DEFAULT 0
|
|
207
|
+
);
|
|
200
208
|
`);
|
|
201
209
|
this.ensureColumn('thread_cache', 'name', 'TEXT');
|
|
202
210
|
this.ensureColumn('thread_cache', 'model_provider', 'TEXT');
|
|
@@ -217,6 +225,7 @@ export class BridgeStore {
|
|
|
217
225
|
this.ensureColumn('codex_auth_quota_snapshots', 'primary_window_duration_mins', 'REAL');
|
|
218
226
|
this.ensureColumn('codex_auth_quota_snapshots', 'secondary_window_duration_mins', 'REAL');
|
|
219
227
|
this.ensureColumn('codex_auth_quota_snapshots', 'quota_identity_id', "TEXT NOT NULL DEFAULT ''");
|
|
228
|
+
this.ensureColumn('codex_auth_pool_history', 'invalid_delete_count', 'INTEGER NOT NULL DEFAULT 0');
|
|
220
229
|
this.db.prepare(`
|
|
221
230
|
UPDATE codex_auth_quota_snapshots
|
|
222
231
|
SET quota_identity_id = account_id
|
|
@@ -896,6 +905,84 @@ export class BridgeStore {
|
|
|
896
905
|
this.db.prepare('DELETE FROM codex_auth_candidate_runtime WHERE name = ?').run(name);
|
|
897
906
|
this.db.prepare('DELETE FROM codex_auth_quota_snapshots WHERE candidate_name = ?').run(name);
|
|
898
907
|
}
|
|
908
|
+
recordCodexAuthPoolInventory(names) {
|
|
909
|
+
if (names.length === 0) {
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
const now = Date.now();
|
|
913
|
+
const upsert = this.db.prepare(`
|
|
914
|
+
INSERT INTO codex_auth_pool_history (name, first_seen_at, last_seen_at, deleted_at, delete_reason, invalid_delete_count)
|
|
915
|
+
VALUES (?, ?, ?, NULL, NULL, 0)
|
|
916
|
+
ON CONFLICT(name) DO UPDATE SET
|
|
917
|
+
last_seen_at = excluded.last_seen_at,
|
|
918
|
+
deleted_at = NULL,
|
|
919
|
+
delete_reason = NULL
|
|
920
|
+
`);
|
|
921
|
+
const seen = new Set(names);
|
|
922
|
+
this.db.exec('BEGIN');
|
|
923
|
+
try {
|
|
924
|
+
for (const name of seen) {
|
|
925
|
+
upsert.run(name, now, now);
|
|
926
|
+
}
|
|
927
|
+
this.db.exec('COMMIT');
|
|
928
|
+
}
|
|
929
|
+
catch (error) {
|
|
930
|
+
this.db.exec('ROLLBACK');
|
|
931
|
+
throw error;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
recordCodexAuthCandidateInvalidDelete(name, reason = null) {
|
|
935
|
+
const now = Date.now();
|
|
936
|
+
this.db.prepare(`
|
|
937
|
+
INSERT INTO codex_auth_pool_history (
|
|
938
|
+
name,
|
|
939
|
+
first_seen_at,
|
|
940
|
+
last_seen_at,
|
|
941
|
+
deleted_at,
|
|
942
|
+
delete_reason,
|
|
943
|
+
invalid_delete_count
|
|
944
|
+
)
|
|
945
|
+
VALUES (?, ?, ?, ?, ?, 1)
|
|
946
|
+
ON CONFLICT(name) DO UPDATE SET
|
|
947
|
+
last_seen_at = excluded.last_seen_at,
|
|
948
|
+
deleted_at = excluded.deleted_at,
|
|
949
|
+
delete_reason = excluded.delete_reason,
|
|
950
|
+
invalid_delete_count = codex_auth_pool_history.invalid_delete_count
|
|
951
|
+
+ CASE WHEN codex_auth_pool_history.deleted_at IS NULL THEN 1 ELSE 0 END
|
|
952
|
+
`).run(name, now, now, now, reason);
|
|
953
|
+
}
|
|
954
|
+
recordCodexAuthCandidateRemoved(name, reason = null) {
|
|
955
|
+
const now = Date.now();
|
|
956
|
+
this.db.prepare(`
|
|
957
|
+
INSERT INTO codex_auth_pool_history (
|
|
958
|
+
name,
|
|
959
|
+
first_seen_at,
|
|
960
|
+
last_seen_at,
|
|
961
|
+
deleted_at,
|
|
962
|
+
delete_reason,
|
|
963
|
+
invalid_delete_count
|
|
964
|
+
)
|
|
965
|
+
VALUES (?, ?, ?, ?, ?, 0)
|
|
966
|
+
ON CONFLICT(name) DO UPDATE SET
|
|
967
|
+
last_seen_at = excluded.last_seen_at,
|
|
968
|
+
deleted_at = excluded.deleted_at,
|
|
969
|
+
delete_reason = excluded.delete_reason
|
|
970
|
+
`).run(name, now, now, now, reason);
|
|
971
|
+
}
|
|
972
|
+
getCodexAuthPoolStats() {
|
|
973
|
+
const row = this.db.prepare(`
|
|
974
|
+
SELECT
|
|
975
|
+
COUNT(*) AS total_seen,
|
|
976
|
+
COALESCE(SUM(CASE WHEN deleted_at IS NULL THEN 1 ELSE 0 END), 0) AS alive,
|
|
977
|
+
COALESCE(SUM(invalid_delete_count), 0) AS deleted_invalid
|
|
978
|
+
FROM codex_auth_pool_history
|
|
979
|
+
`).get();
|
|
980
|
+
return {
|
|
981
|
+
totalSeen: Number(row?.total_seen ?? 0),
|
|
982
|
+
alive: Number(row?.alive ?? 0),
|
|
983
|
+
deletedInvalid: Number(row?.deleted_invalid ?? 0),
|
|
984
|
+
};
|
|
985
|
+
}
|
|
899
986
|
setCodexAuthQuotaSnapshot(runtimeId, candidateName, accountId, quotaIdentityId, snapshot) {
|
|
900
987
|
this.db.prepare(`
|
|
901
988
|
INSERT INTO codex_auth_quota_snapshots (
|
|
@@ -92,6 +92,7 @@ AUTH_SYNC_KEY=<shared key with at least 32 bytes>
|
|
|
92
92
|
AUTH_SYNC_CLUSTER_ID=my-codex-auth-pool
|
|
93
93
|
AUTH_SYNC_NODE_ID=workstation-a
|
|
94
94
|
AUTH_SYNC_PEERS=@foxclaw_node_b_bot
|
|
95
|
+
AUTH_AUTO_DELETE_NEEDS_REPAIR=false
|
|
95
96
|
```
|
|
96
97
|
|
|
97
98
|
Node B:
|
|
@@ -103,6 +104,7 @@ AUTH_SYNC_KEY=<shared key with at least 32 bytes>
|
|
|
103
104
|
AUTH_SYNC_CLUSTER_ID=my-codex-auth-pool
|
|
104
105
|
AUTH_SYNC_NODE_ID=workstation-b
|
|
105
106
|
AUTH_SYNC_PEERS=@foxclaw_node_a_bot
|
|
107
|
+
AUTH_AUTO_DELETE_NEEDS_REPAIR=false
|
|
106
108
|
```
|
|
107
109
|
|
|
108
110
|
For more peers, separate bot usernames with commas:
|
|
@@ -168,6 +170,8 @@ Note: `/auth sync push all` saying “sent” only means this node successfully
|
|
|
168
170
|
|
|
169
171
|
When cross-node sync is enabled, the contact bot private chat receives node-level notifications: local auth updates and the peers being contacted, received remote bundles and whether they were queued or immediately validated, import success/skip/failure reasons, recovery peer queries and peer replies, and a manual-intervention notice when every peer lacks an importable copy. Refresh/send/import bursts are grouped into short summaries so one candidate update does not produce separate start, receive, mirror-write, and completion messages. Recovery and manual-intervention notices remain explicit. Remote import validation temporarily restarts the local Codex app-server; during that restart window FoxClaw treats the runtime as non-idle and asks ordinary messages to be resent shortly instead of running them against a restarting bridge. Notifications never include auth contents, tokens, or encrypted bundle payloads.
|
|
170
172
|
|
|
173
|
+
For a resource-rich pool where individual candidate maintenance is not important, enable `AUTH_AUTO_DELETE_NEEDS_REPAIR=true` or toggle it with `/config auth_auto_delete on`. Candidates that cannot be recovered and would otherwise be marked for repair are deleted locally, a delete tombstone is sent to peers, and candidate-level sync/delete chatter is collapsed into an auth-pool summary.
|
|
174
|
+
|
|
171
175
|
Starting in 0.5.2, `/auth sync status` separates sync-system `Last error` from per-auth `Candidate failures`. For example, a remote candidate that returns `token_invalidated` or has an expired access token is recorded under that candidate name only; current `auth.json` health is still determined by validating the current auth usage. `local candidate is already newer or equal` is a normal skip, not an error.
|
|
172
176
|
|
|
173
177
|
Manual `/auth` switches and `/auth reload` recover from same-node local mirrors only and do not send cross-node pull requests. FoxClaw queries peers only during automatic recovery after it detects a real auth problem. Recovery timeout notifications include the request id, candidate name, peer list, and wait duration; if another auth sync message arrived from the same peer during that wait, the notification says the peer was reachable but this request timed out.
|
package/docs/user-manual.md
CHANGED
|
@@ -448,6 +448,8 @@ AUTH_SYNC_PEERS=@other_node_bot,@third_node_bot
|
|
|
448
448
|
AUTH_SYNC_CLUSTER_ID=my-codex-auth-pool
|
|
449
449
|
# Optional; FoxClaw generates and persists a local node id when omitted.
|
|
450
450
|
AUTH_SYNC_NODE_ID=workstation-a
|
|
451
|
+
# Optional resource-rich mode: auto-delete unrecoverable candidates across peers.
|
|
452
|
+
AUTH_AUTO_DELETE_NEEDS_REPAIR=false
|
|
451
453
|
```
|
|
452
454
|
|
|
453
455
|
Safety boundaries:
|
|
@@ -457,6 +459,7 @@ Safety boundaries:
|
|
|
457
459
|
- Remote imports wait for global local idleness, temporarily switch to the candidate for app-server usage validation, and only then write the candidate.
|
|
458
460
|
- 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.
|
|
459
461
|
- 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.
|
|
462
|
+
- 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.
|
|
460
463
|
|
|
461
464
|
Dual-active behavior:
|
|
462
465
|
|
|
@@ -92,6 +92,7 @@ AUTH_SYNC_KEY=<至少32字节的共享密钥>
|
|
|
92
92
|
AUTH_SYNC_CLUSTER_ID=my-codex-auth-pool
|
|
93
93
|
AUTH_SYNC_NODE_ID=workstation-a
|
|
94
94
|
AUTH_SYNC_PEERS=@foxclaw_node_b_bot
|
|
95
|
+
AUTH_AUTO_DELETE_NEEDS_REPAIR=false
|
|
95
96
|
```
|
|
96
97
|
|
|
97
98
|
节点 B:
|
|
@@ -103,6 +104,7 @@ AUTH_SYNC_KEY=<至少32字节的共享密钥>
|
|
|
103
104
|
AUTH_SYNC_CLUSTER_ID=my-codex-auth-pool
|
|
104
105
|
AUTH_SYNC_NODE_ID=workstation-b
|
|
105
106
|
AUTH_SYNC_PEERS=@foxclaw_node_a_bot
|
|
107
|
+
AUTH_AUTO_DELETE_NEEDS_REPAIR=false
|
|
106
108
|
```
|
|
107
109
|
|
|
108
110
|
多节点时,`AUTH_SYNC_PEERS` 用英文逗号分隔:
|
|
@@ -168,6 +170,8 @@ auth sync 测试完成:已发送 1,收到回应 1。
|
|
|
168
170
|
|
|
169
171
|
启用跨节点同步后,联系人 bot 的私聊会收到节点级通知:本机 auth 更新并开始发往哪些 peer、收到远端包后是排队还是立即验证、导入成功/跳过/失败原因、auth 恢复时正在查询哪些 peer、peer 回应了什么,以及所有 peer 都无法提供可用副本时的人工介入提示。刷新、发送、导入密集发生时会合并成简短汇总,避免一个候选更新拆成开始、收到、镜像写入和完成多条消息;恢复和人工介入提示仍会明确发出。远端导入验证会临时重启本机 Codex app-server;这段窗口里 FoxClaw 会把 runtime 视为非空闲,并让普通消息稍后重发,而不是送进正在重启的 bridge。通知不会包含 auth 内容、token 或同步密文。
|
|
170
172
|
|
|
173
|
+
如果这是资源富裕的账号池、不关心单个候选如何维护,可以启用 `AUTH_AUTO_DELETE_NEEDS_REPAIR=true`,或用 `/config auth_auto_delete on` 运行时打开。无法恢复、原本会标记为需要修复的候选会直接本地删除,并向 peer 发送删除 tombstone;候选级同步/删除消息会压缩成 auth 池摘要。
|
|
174
|
+
|
|
171
175
|
从 0.5.2 起,`/auth sync status` 会把同步系统级 `最近错误` 和单个 auth 的 `候选失败` 分开显示。比如某个远端候选返回 `token_invalidated` 或 access token 过期时,只会记录到该候选名下面;当前 `auth.json` 是否健康仍以当前 auth 的 usage 验证为准。`local candidate is already newer or equal` 属于正常跳过,不会记为错误。
|
|
172
176
|
|
|
173
177
|
手动 `/auth` 切换和 `/auth reload` 只会尝试同节点本地 mirror 恢复,不会主动向跨节点 peer 发起 pull。只有 FoxClaw 检测到当前 auth 真的出现认证问题并进入自动恢复时,才会向 peer 查询可用副本。恢复超时通知会包含 request id、候选名、peer 列表和等待时长;如果等待期间收到过同 peer 的其他 auth sync 消息,通知会标明 peer 可达但该请求超时。
|
package/docs/zh/user-manual.md
CHANGED
|
@@ -448,6 +448,8 @@ AUTH_SYNC_PEERS=@other_node_bot,@third_node_bot
|
|
|
448
448
|
AUTH_SYNC_CLUSTER_ID=my-codex-auth-pool
|
|
449
449
|
# 可选;不填时 FoxClaw 会生成并持久化本机 node id
|
|
450
450
|
AUTH_SYNC_NODE_ID=workstation-a
|
|
451
|
+
# 可选;资源富裕模式下自动跨节点剔除无法恢复的候选
|
|
452
|
+
AUTH_AUTO_DELETE_NEEDS_REPAIR=false
|
|
451
453
|
```
|
|
452
454
|
|
|
453
455
|
安全边界:
|
|
@@ -457,6 +459,7 @@ AUTH_SYNC_NODE_ID=workstation-a
|
|
|
457
459
|
- 远端导入必须等本机全局空闲,再临时切换到待验证 auth、重启 app-server、读取 usage 验证成功后才写入候选。
|
|
458
460
|
- 同名候选如果已知属于不同 account id,或属于同一 account 下不同的可识别 ChatGPT 用户/邮箱,永远拒绝覆盖。
|
|
459
461
|
- 跨节点恢复只拉取 peer 已持有的有效副本,不会在恢复过程中直接轮换 refresh token;找不到有效副本时会停止,提示你人工维护授权。后台 9 天主动刷新会单独申请跨节点刷新锁,拿不到锁就跳过本轮。
|
|
462
|
+
- 如果开启 `AUTH_AUTO_DELETE_NEEDS_REPAIR=true` 或在 `/config` 中打开自动剔除,无法恢复的候选会直接删除并向 peer 传播删除 tombstone;私聊通知会压缩为 auth 池摘要:历史总数、存活数、因失效剔除数。
|
|
460
463
|
|
|
461
464
|
双主动流程:
|
|
462
465
|
|