@foxden-app/foxclaw 0.4.16 → 0.5.0
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 +3 -1
- package/CHANGELOG.md +14 -0
- package/README.md +1 -1
- package/README_EN.md +1 -1
- package/dist/auth/cross_node_sync.d.ts +113 -3
- package/dist/auth/cross_node_sync.js +276 -39
- package/dist/controller/controller.d.ts +2 -0
- package/dist/controller/controller.js +7 -1
- package/dist/i18n.d.ts +8 -4
- package/dist/i18n.js +8 -4
- package/dist/main.js +142 -7
- package/dist/store/database.d.ts +4 -0
- package/dist/store/database.js +4 -0
- package/dist/types.d.ts +1 -0
- package/docs/cross-node-auth-sync.md +26 -6
- package/docs/user-manual.md +10 -9
- package/docs/zh/cross-node-auth-sync.md +26 -6
- package/docs/zh/user-manual.md +10 -9
- package/package.json +1 -1
- package/skills/foxclaw/SKILL.md +1 -1
|
@@ -6,6 +6,7 @@ import { isAuthCandidateName, parseChatGptAuthMetadata } from './mirror.js';
|
|
|
6
6
|
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
|
+
const TEST_TIMEOUT_MS = 8_000;
|
|
9
10
|
const LEASE_TIMEOUT_MS = 8_000;
|
|
10
11
|
const LEASE_TTL_MS = 60_000;
|
|
11
12
|
const REMOTE_ACCESS_TOKEN_MIN_TTL_MS = 60_000;
|
|
@@ -21,10 +22,12 @@ export class CrossNodeAuthSync {
|
|
|
21
22
|
pendingImports = [];
|
|
22
23
|
pendingPulls = new Map();
|
|
23
24
|
pendingLeases = new Map();
|
|
25
|
+
pendingTests = new Map();
|
|
24
26
|
seenNonces = new Map();
|
|
25
27
|
timer = null;
|
|
26
28
|
activeRemoteLease = null;
|
|
27
29
|
activeLocalLease = null;
|
|
30
|
+
lastNotifiedError = null;
|
|
28
31
|
state = {
|
|
29
32
|
lastSentAt: null,
|
|
30
33
|
lastReceivedAt: null,
|
|
@@ -89,6 +92,7 @@ export class CrossNodeAuthSync {
|
|
|
89
92
|
enabled: this.config.enabled,
|
|
90
93
|
nodeId: this.nodeId,
|
|
91
94
|
transport: this.config.transport,
|
|
95
|
+
transportLabel: this.config.transportLabel?.trim() || null,
|
|
92
96
|
peers: this.peers,
|
|
93
97
|
pendingImports: this.pendingImports.length,
|
|
94
98
|
lastSentAt: this.state.lastSentAt,
|
|
@@ -104,7 +108,8 @@ export class CrossNodeAuthSync {
|
|
|
104
108
|
isIdle() {
|
|
105
109
|
return this.pendingImports.length === 0
|
|
106
110
|
&& this.pendingPulls.size === 0
|
|
107
|
-
&& this.pendingLeases.size === 0
|
|
111
|
+
&& this.pendingLeases.size === 0
|
|
112
|
+
&& this.pendingTests.size === 0;
|
|
108
113
|
}
|
|
109
114
|
async publishCandidate(candidateName) {
|
|
110
115
|
if (!this.isReady())
|
|
@@ -112,10 +117,24 @@ export class CrossNodeAuthSync {
|
|
|
112
117
|
const record = await this.callbacks.readLocalCandidate(candidateName);
|
|
113
118
|
if (!record)
|
|
114
119
|
return false;
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
120
|
+
this.notify({ kind: 'candidate_publish_started', candidateName, peers: [...this.peers] });
|
|
121
|
+
try {
|
|
122
|
+
await this.sendToAll({
|
|
123
|
+
kind: 'push.bundle',
|
|
124
|
+
...bundleFromRecord(record),
|
|
125
|
+
});
|
|
126
|
+
this.notify({ kind: 'candidate_publish_completed', candidateName, peers: [...this.peers] });
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
this.recordError(`candidate publish failed for ${candidateName}: ${formatError(error)}`, false);
|
|
130
|
+
this.notify({
|
|
131
|
+
kind: 'candidate_publish_failed',
|
|
132
|
+
candidateName,
|
|
133
|
+
peers: [...this.peers],
|
|
134
|
+
reason: formatError(error),
|
|
135
|
+
});
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
119
138
|
return true;
|
|
120
139
|
}
|
|
121
140
|
async pushAll() {
|
|
@@ -123,18 +142,34 @@ export class CrossNodeAuthSync {
|
|
|
123
142
|
return { sent: 0, skipped: 0 };
|
|
124
143
|
let sent = 0;
|
|
125
144
|
let skipped = 0;
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
145
|
+
const records = await this.callbacks.listLocalCandidates();
|
|
146
|
+
this.notify({ kind: 'push_all_started', candidateCount: records.length, peers: [...this.peers] });
|
|
147
|
+
try {
|
|
148
|
+
for (const record of records) {
|
|
149
|
+
if (!isAuthCandidateName(record.candidateName)) {
|
|
150
|
+
skipped += 1;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
await this.sendToAll({
|
|
154
|
+
kind: 'push.bundle',
|
|
155
|
+
...bundleFromRecord(record),
|
|
156
|
+
});
|
|
157
|
+
sent += 1;
|
|
130
158
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
159
|
+
this.notify({ kind: 'push_all_completed', sent, skipped, peers: [...this.peers] });
|
|
160
|
+
return { sent, skipped };
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
this.recordError(`push all failed: ${formatError(error)}`, false);
|
|
164
|
+
this.notify({
|
|
165
|
+
kind: 'push_all_failed',
|
|
166
|
+
sent,
|
|
167
|
+
skipped,
|
|
168
|
+
peers: [...this.peers],
|
|
169
|
+
reason: formatError(error),
|
|
134
170
|
});
|
|
135
|
-
|
|
171
|
+
throw error;
|
|
136
172
|
}
|
|
137
|
-
return { sent, skipped };
|
|
138
173
|
}
|
|
139
174
|
async publishDigest() {
|
|
140
175
|
if (!this.isReady())
|
|
@@ -150,18 +185,28 @@ export class CrossNodeAuthSync {
|
|
|
150
185
|
if (!this.isReady() || !isAuthCandidateName(candidateName))
|
|
151
186
|
return false;
|
|
152
187
|
const requestId = crypto.randomUUID();
|
|
188
|
+
const peers = [...this.peers];
|
|
189
|
+
this.notify({ kind: 'recovery_started', candidateName, peers });
|
|
153
190
|
const result = await new Promise((resolve) => {
|
|
154
191
|
const timer = setTimeout(() => {
|
|
155
192
|
const pending = this.pendingPulls.get(requestId);
|
|
156
193
|
if (pending) {
|
|
157
194
|
pending.finished = true;
|
|
158
195
|
this.pendingPulls.delete(requestId);
|
|
196
|
+
this.notify({
|
|
197
|
+
kind: 'recovery_failed',
|
|
198
|
+
candidateName,
|
|
199
|
+
peers,
|
|
200
|
+
reason: `timed out waiting for ${this.peers.length} auth sync peer response(s)`,
|
|
201
|
+
});
|
|
159
202
|
resolve(false);
|
|
160
203
|
}
|
|
161
204
|
}, PULL_TIMEOUT_MS);
|
|
162
205
|
timer.unref();
|
|
163
206
|
this.pendingPulls.set(requestId, {
|
|
164
207
|
candidateName,
|
|
208
|
+
peers,
|
|
209
|
+
emptyReplies: new Map(),
|
|
165
210
|
resolve,
|
|
166
211
|
timer,
|
|
167
212
|
finished: false,
|
|
@@ -175,7 +220,13 @@ export class CrossNodeAuthSync {
|
|
|
175
220
|
}).catch((error) => {
|
|
176
221
|
clearTimeout(timer);
|
|
177
222
|
this.pendingPulls.delete(requestId);
|
|
178
|
-
this.recordError(`pull request failed: ${formatError(error)}
|
|
223
|
+
this.recordError(`pull request failed: ${formatError(error)}`, false);
|
|
224
|
+
this.notify({
|
|
225
|
+
kind: 'recovery_failed',
|
|
226
|
+
candidateName,
|
|
227
|
+
peers: [...this.peers],
|
|
228
|
+
reason: formatError(error),
|
|
229
|
+
});
|
|
179
230
|
resolve(false);
|
|
180
231
|
});
|
|
181
232
|
});
|
|
@@ -240,9 +291,37 @@ export class CrossNodeAuthSync {
|
|
|
240
291
|
}
|
|
241
292
|
async testPeers() {
|
|
242
293
|
if (!this.isReady())
|
|
243
|
-
return { sent: 0 };
|
|
244
|
-
|
|
245
|
-
|
|
294
|
+
return { sent: 0, replied: 0, missing: [] };
|
|
295
|
+
const requestId = crypto.randomUUID();
|
|
296
|
+
const peers = [...this.peers];
|
|
297
|
+
const resultPromise = new Promise((resolve) => {
|
|
298
|
+
const timer = setTimeout(() => {
|
|
299
|
+
this.finishPendingTest(requestId);
|
|
300
|
+
}, TEST_TIMEOUT_MS);
|
|
301
|
+
timer.unref();
|
|
302
|
+
this.pendingTests.set(requestId, {
|
|
303
|
+
peers,
|
|
304
|
+
replies: new Set(),
|
|
305
|
+
resolve,
|
|
306
|
+
timer,
|
|
307
|
+
finished: false,
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
try {
|
|
311
|
+
await this.sendToAll({ kind: 'test.ping', requestId });
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
const pending = this.pendingTests.get(requestId);
|
|
315
|
+
if (pending) {
|
|
316
|
+
clearTimeout(pending.timer);
|
|
317
|
+
this.pendingTests.delete(requestId);
|
|
318
|
+
}
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
if (peers.length === 0) {
|
|
322
|
+
this.finishPendingTest(requestId);
|
|
323
|
+
}
|
|
324
|
+
return resultPromise;
|
|
246
325
|
}
|
|
247
326
|
async handleIncomingEnvelope(rawEnvelope, peer) {
|
|
248
327
|
if (!this.isReady() || !this.isAllowedPeer(peer)) {
|
|
@@ -269,7 +348,7 @@ export class CrossNodeAuthSync {
|
|
|
269
348
|
this.enqueueImport(message, senderNodeId, sourceLabel, normalizePeerIdentity(peer));
|
|
270
349
|
return;
|
|
271
350
|
case 'pull.request':
|
|
272
|
-
await this.handlePullRequest(message, normalizePeerIdentity(peer));
|
|
351
|
+
await this.handlePullRequest(message, senderNodeId, normalizePeerIdentity(peer));
|
|
273
352
|
return;
|
|
274
353
|
case 'pull.response':
|
|
275
354
|
await this.handlePullResponse(message, senderNodeId, sourceLabel, normalizePeerIdentity(peer));
|
|
@@ -285,6 +364,7 @@ export class CrossNodeAuthSync {
|
|
|
285
364
|
});
|
|
286
365
|
return;
|
|
287
366
|
case 'test.pong':
|
|
367
|
+
this.handleTestPong(message.requestId, normalizePeerIdentity(peer));
|
|
288
368
|
this.logger.info('auth.sync.test_pong', { peer: normalizePeerIdentity(peer), nodeId: message.nodeId });
|
|
289
369
|
return;
|
|
290
370
|
case 'lease.request':
|
|
@@ -303,20 +383,47 @@ export class CrossNodeAuthSync {
|
|
|
303
383
|
return;
|
|
304
384
|
}
|
|
305
385
|
}
|
|
306
|
-
async handlePullRequest(message, peer) {
|
|
386
|
+
async handlePullRequest(message, requesterNodeId, peer) {
|
|
307
387
|
if (!isAuthCandidateName(message.candidateName))
|
|
308
388
|
return;
|
|
389
|
+
this.notify({
|
|
390
|
+
kind: 'pull_request_received',
|
|
391
|
+
candidateName: message.candidateName,
|
|
392
|
+
peer,
|
|
393
|
+
requesterNodeId,
|
|
394
|
+
});
|
|
309
395
|
const record = await this.callbacks.readLocalCandidate(message.candidateName);
|
|
310
396
|
if (!record) {
|
|
311
397
|
await this.sendToPeer(peer, { kind: 'pull.response', requestId: message.requestId, bundle: null, reason: 'candidate not found' });
|
|
398
|
+
this.notify({
|
|
399
|
+
kind: 'pull_response_sent',
|
|
400
|
+
candidateName: message.candidateName,
|
|
401
|
+
peer,
|
|
402
|
+
result: 'candidate_not_found',
|
|
403
|
+
reason: 'candidate not found',
|
|
404
|
+
});
|
|
312
405
|
return;
|
|
313
406
|
}
|
|
314
407
|
if (message.accountId && record.accountId !== message.accountId) {
|
|
315
408
|
await this.sendToPeer(peer, { kind: 'pull.response', requestId: message.requestId, bundle: null, reason: 'account mismatch' });
|
|
409
|
+
this.notify({
|
|
410
|
+
kind: 'pull_response_sent',
|
|
411
|
+
candidateName: message.candidateName,
|
|
412
|
+
peer,
|
|
413
|
+
result: 'account_mismatch',
|
|
414
|
+
reason: 'account mismatch',
|
|
415
|
+
});
|
|
316
416
|
return;
|
|
317
417
|
}
|
|
318
418
|
if (message.lastRefreshMs !== null && record.lastRefreshMs <= message.lastRefreshMs) {
|
|
319
419
|
await this.sendToPeer(peer, { kind: 'pull.response', requestId: message.requestId, bundle: null, reason: 'not newer' });
|
|
420
|
+
this.notify({
|
|
421
|
+
kind: 'pull_response_sent',
|
|
422
|
+
candidateName: message.candidateName,
|
|
423
|
+
peer,
|
|
424
|
+
result: 'not_newer',
|
|
425
|
+
reason: 'not newer',
|
|
426
|
+
});
|
|
320
427
|
return;
|
|
321
428
|
}
|
|
322
429
|
await this.sendToPeer(peer, {
|
|
@@ -324,22 +431,68 @@ export class CrossNodeAuthSync {
|
|
|
324
431
|
requestId: message.requestId,
|
|
325
432
|
bundle: bundleFromRecord(record),
|
|
326
433
|
});
|
|
434
|
+
this.notify({
|
|
435
|
+
kind: 'pull_response_sent',
|
|
436
|
+
candidateName: message.candidateName,
|
|
437
|
+
peer,
|
|
438
|
+
result: 'sent',
|
|
439
|
+
reason: null,
|
|
440
|
+
});
|
|
327
441
|
}
|
|
328
442
|
async handlePullResponse(message, senderNodeId, sourceLabel, peer) {
|
|
329
443
|
const pending = this.pendingPulls.get(message.requestId);
|
|
330
444
|
if (!pending || pending.finished)
|
|
331
445
|
return;
|
|
446
|
+
const matchedPeer = this.matchConfiguredPeer(peer) ?? peer;
|
|
332
447
|
if (!message.bundle || message.bundle.candidateName !== pending.candidateName) {
|
|
448
|
+
const reason = message.reason ?? 'peer did not return a matching candidate';
|
|
449
|
+
this.notify({
|
|
450
|
+
kind: 'recovery_peer_empty',
|
|
451
|
+
candidateName: pending.candidateName,
|
|
452
|
+
peer,
|
|
453
|
+
reason,
|
|
454
|
+
});
|
|
455
|
+
this.markPullPeerUnavailable(message.requestId, matchedPeer, reason);
|
|
333
456
|
return;
|
|
334
457
|
}
|
|
335
|
-
|
|
336
|
-
|
|
458
|
+
this.notify({
|
|
459
|
+
kind: 'recovery_peer_bundle_received',
|
|
460
|
+
candidateName: pending.candidateName,
|
|
461
|
+
peer,
|
|
462
|
+
sourceNodeId: senderNodeId,
|
|
463
|
+
});
|
|
464
|
+
const outcome = await this.validateAndImport(message.bundle, senderNodeId, sourceLabel, peer, 'pull');
|
|
465
|
+
if (!outcome.imported) {
|
|
466
|
+
this.markPullPeerUnavailable(message.requestId, matchedPeer, outcome.reason ?? 'peer candidate was not imported');
|
|
337
467
|
return;
|
|
468
|
+
}
|
|
338
469
|
pending.finished = true;
|
|
339
470
|
clearTimeout(pending.timer);
|
|
340
471
|
this.pendingPulls.delete(message.requestId);
|
|
341
472
|
pending.resolve(true);
|
|
342
473
|
}
|
|
474
|
+
markPullPeerUnavailable(requestId, peer, reason) {
|
|
475
|
+
const pending = this.pendingPulls.get(requestId);
|
|
476
|
+
if (!pending || pending.finished)
|
|
477
|
+
return;
|
|
478
|
+
pending.emptyReplies.set(peer, reason);
|
|
479
|
+
if (!pending.peers.every(peerName => pending.emptyReplies.has(peerName))) {
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
pending.finished = true;
|
|
483
|
+
clearTimeout(pending.timer);
|
|
484
|
+
this.pendingPulls.delete(requestId);
|
|
485
|
+
const details = pending.peers
|
|
486
|
+
.map(peerName => `${peerName}: ${pending.emptyReplies.get(peerName) ?? 'no usable candidate'}`)
|
|
487
|
+
.join('; ');
|
|
488
|
+
this.notify({
|
|
489
|
+
kind: 'recovery_failed',
|
|
490
|
+
candidateName: pending.candidateName,
|
|
491
|
+
peers: pending.peers,
|
|
492
|
+
reason: `all peers replied without an importable auth candidate${details ? ` (${details})` : ''}`,
|
|
493
|
+
});
|
|
494
|
+
pending.resolve(false);
|
|
495
|
+
}
|
|
343
496
|
async handleDigest(message, peer) {
|
|
344
497
|
const remote = new Map(message.records.map(record => [record.candidateName, record]));
|
|
345
498
|
for (const local of await this.callbacks.listLocalCandidates()) {
|
|
@@ -395,6 +548,7 @@ export class CrossNodeAuthSync {
|
|
|
395
548
|
}
|
|
396
549
|
}
|
|
397
550
|
enqueueImport(bundle, sourceNodeId, sourceLabel, fromPeer) {
|
|
551
|
+
const queued = !this.callbacks.isIdle() || this.pendingImports.length > 0;
|
|
398
552
|
this.pendingImports.push({
|
|
399
553
|
bundle,
|
|
400
554
|
sourceNodeId,
|
|
@@ -402,6 +556,15 @@ export class CrossNodeAuthSync {
|
|
|
402
556
|
receivedAt: Date.now(),
|
|
403
557
|
fromPeer,
|
|
404
558
|
});
|
|
559
|
+
this.notify({
|
|
560
|
+
kind: 'remote_bundle_received',
|
|
561
|
+
candidateName: bundle.candidateName,
|
|
562
|
+
sourceNodeId,
|
|
563
|
+
sourceLabel: sourceLabel ?? fromPeer,
|
|
564
|
+
peer: fromPeer,
|
|
565
|
+
queued,
|
|
566
|
+
queueLength: this.pendingImports.length,
|
|
567
|
+
});
|
|
405
568
|
void this.processPendingImports().catch((error) => {
|
|
406
569
|
this.recordError(`remote import failed: ${formatError(error)}`);
|
|
407
570
|
});
|
|
@@ -411,40 +574,35 @@ export class CrossNodeAuthSync {
|
|
|
411
574
|
return;
|
|
412
575
|
while (this.pendingImports.length > 0 && this.callbacks.isIdle()) {
|
|
413
576
|
const pending = this.pendingImports.shift();
|
|
414
|
-
await this.validateAndImport(pending.bundle, pending.sourceNodeId, pending.sourceLabel, pending.fromPeer);
|
|
577
|
+
await this.validateAndImport(pending.bundle, pending.sourceNodeId, pending.sourceLabel, pending.fromPeer, 'push');
|
|
415
578
|
}
|
|
416
579
|
}
|
|
417
|
-
async validateAndImport(bundle, sourceNodeId, sourceLabel, fromPeer) {
|
|
580
|
+
async validateAndImport(bundle, sourceNodeId, sourceLabel, fromPeer, mode) {
|
|
581
|
+
const source = sourceLabel ?? fromPeer;
|
|
418
582
|
if (!isValidBundle(bundle)) {
|
|
419
|
-
this.
|
|
420
|
-
return false;
|
|
583
|
+
return this.rejectImport(bundle, sourceNodeId, source, fromPeer, mode, 'remote bundle shape is invalid');
|
|
421
584
|
}
|
|
422
585
|
const metadata = parseChatGptAuthMetadata(bundle.rawAuth);
|
|
423
586
|
if (!metadata || metadata.accountId !== bundle.accountId || metadata.lastRefreshMs !== bundle.lastRefreshMs) {
|
|
424
|
-
this.
|
|
425
|
-
return false;
|
|
587
|
+
return this.rejectImport(bundle, sourceNodeId, source, fromPeer, mode, `remote bundle metadata mismatch for ${bundle.candidateName}`);
|
|
426
588
|
}
|
|
427
589
|
if (sha256(bundle.rawAuth) !== bundle.authSha256) {
|
|
428
|
-
this.
|
|
429
|
-
return false;
|
|
590
|
+
return this.rejectImport(bundle, sourceNodeId, source, fromPeer, mode, `remote bundle hash mismatch for ${bundle.candidateName}`);
|
|
430
591
|
}
|
|
431
592
|
const expiresAt = readAccessTokenExpiresAtMs(bundle.rawAuth);
|
|
432
593
|
if (expiresAt === null || expiresAt <= Date.now() + REMOTE_ACCESS_TOKEN_MIN_TTL_MS) {
|
|
433
|
-
this.
|
|
434
|
-
return false;
|
|
594
|
+
return this.rejectImport(bundle, sourceNodeId, source, fromPeer, mode, `remote access token is expired or missing exp for ${bundle.candidateName}`);
|
|
435
595
|
}
|
|
436
596
|
const validation = await this.callbacks.validateCandidate(bundle.candidateName, bundle.rawAuth, bundle.accountId);
|
|
437
597
|
if (!validation.ok) {
|
|
438
|
-
this.
|
|
439
|
-
return false;
|
|
598
|
+
return this.rejectImport(bundle, sourceNodeId, source, fromPeer, mode, `remote candidate validation failed for ${bundle.candidateName}: ${validation.reason ?? 'unknown'}`);
|
|
440
599
|
}
|
|
441
600
|
const result = await this.callbacks.importCandidate(bundle.candidateName, bundle.rawAuth, {
|
|
442
601
|
nodeId: sourceNodeId,
|
|
443
|
-
label:
|
|
602
|
+
label: source,
|
|
444
603
|
});
|
|
445
604
|
if (!result.ok) {
|
|
446
|
-
this.
|
|
447
|
-
return false;
|
|
605
|
+
return this.rejectImport(bundle, sourceNodeId, source, fromPeer, mode, `remote candidate import failed for ${bundle.candidateName}: ${result.reason ?? 'unknown'}`);
|
|
448
606
|
}
|
|
449
607
|
if (result.imported) {
|
|
450
608
|
this.state.lastImportedAt = new Date().toISOString();
|
|
@@ -452,8 +610,27 @@ export class CrossNodeAuthSync {
|
|
|
452
610
|
this.state.lastError = null;
|
|
453
611
|
await this.writeState();
|
|
454
612
|
this.logger.info('auth.sync.imported', { candidateName: bundle.candidateName, sourceNodeId });
|
|
613
|
+
this.notify({
|
|
614
|
+
kind: 'remote_import_imported',
|
|
615
|
+
candidateName: bundle.candidateName,
|
|
616
|
+
sourceNodeId,
|
|
617
|
+
sourceLabel: source,
|
|
618
|
+
peer: fromPeer,
|
|
619
|
+
mode,
|
|
620
|
+
});
|
|
455
621
|
}
|
|
456
|
-
|
|
622
|
+
else {
|
|
623
|
+
this.notify({
|
|
624
|
+
kind: 'remote_import_skipped',
|
|
625
|
+
candidateName: bundle.candidateName,
|
|
626
|
+
sourceNodeId,
|
|
627
|
+
sourceLabel: source,
|
|
628
|
+
peer: fromPeer,
|
|
629
|
+
mode,
|
|
630
|
+
reason: result.reason ?? 'local candidate did not need an update',
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
return { ok: true, imported: result.imported, reason: result.reason ?? null };
|
|
457
634
|
}
|
|
458
635
|
async sendToAll(message) {
|
|
459
636
|
await Promise.all(this.peers.map(peer => this.sendToPeer(peer, message)));
|
|
@@ -462,8 +639,35 @@ export class CrossNodeAuthSync {
|
|
|
462
639
|
const envelope = this.sealEnvelope(message);
|
|
463
640
|
await this.transport.send(peer, envelope);
|
|
464
641
|
this.state.lastSentAt = new Date().toISOString();
|
|
642
|
+
if (this.state.lastError?.includes('USER_BOT_TO_BOT_DISABLED')) {
|
|
643
|
+
this.state.lastError = null;
|
|
644
|
+
}
|
|
465
645
|
await this.writeState();
|
|
466
646
|
}
|
|
647
|
+
handleTestPong(requestId, peer) {
|
|
648
|
+
const pending = this.pendingTests.get(requestId);
|
|
649
|
+
if (!pending || pending.finished)
|
|
650
|
+
return;
|
|
651
|
+
const matchedPeer = this.matchConfiguredPeer(peer) ?? peer;
|
|
652
|
+
pending.replies.add(matchedPeer);
|
|
653
|
+
if (pending.peers.every(peerName => pending.replies.has(peerName))) {
|
|
654
|
+
this.finishPendingTest(requestId);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
finishPendingTest(requestId) {
|
|
658
|
+
const pending = this.pendingTests.get(requestId);
|
|
659
|
+
if (!pending || pending.finished)
|
|
660
|
+
return;
|
|
661
|
+
pending.finished = true;
|
|
662
|
+
clearTimeout(pending.timer);
|
|
663
|
+
this.pendingTests.delete(requestId);
|
|
664
|
+
const missing = pending.peers.filter(peer => !pending.replies.has(peer));
|
|
665
|
+
pending.resolve({
|
|
666
|
+
sent: pending.peers.length,
|
|
667
|
+
replied: pending.replies.size,
|
|
668
|
+
missing,
|
|
669
|
+
});
|
|
670
|
+
}
|
|
467
671
|
sealEnvelope(message) {
|
|
468
672
|
if (!this.key || !this.nodeId) {
|
|
469
673
|
throw new Error('auth sync is not initialized');
|
|
@@ -528,6 +732,10 @@ export class CrossNodeAuthSync {
|
|
|
528
732
|
];
|
|
529
733
|
return keys.some(key => this.peerKeys.has(key));
|
|
530
734
|
}
|
|
735
|
+
matchConfiguredPeer(peer) {
|
|
736
|
+
const keys = new Set(expandPeerKeys(peer));
|
|
737
|
+
return this.peers.find(configuredPeer => expandPeerKeys(configuredPeer).some(key => keys.has(key))) ?? null;
|
|
738
|
+
}
|
|
531
739
|
expireLeases() {
|
|
532
740
|
const now = Date.now();
|
|
533
741
|
if (this.activeRemoteLease && this.activeRemoteLease.expiresAt <= now) {
|
|
@@ -537,12 +745,41 @@ export class CrossNodeAuthSync {
|
|
|
537
745
|
this.activeLocalLease = null;
|
|
538
746
|
}
|
|
539
747
|
}
|
|
540
|
-
recordError(message) {
|
|
748
|
+
recordError(message, notify = true) {
|
|
541
749
|
this.state.lastError = message;
|
|
542
750
|
this.logger.warn('auth.sync.error', { error: message });
|
|
543
751
|
void this.writeState().catch((error) => {
|
|
544
752
|
this.logger.warn('auth.sync.state_write_failed', { error: formatError(error) });
|
|
545
753
|
});
|
|
754
|
+
if (notify) {
|
|
755
|
+
this.notifyError(message);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
rejectImport(bundle, sourceNodeId, sourceLabel, fromPeer, mode, reason) {
|
|
759
|
+
this.recordError(reason, false);
|
|
760
|
+
this.notify({
|
|
761
|
+
kind: 'remote_import_failed',
|
|
762
|
+
candidateName: bundle.candidateName,
|
|
763
|
+
sourceNodeId,
|
|
764
|
+
sourceLabel,
|
|
765
|
+
peer: fromPeer,
|
|
766
|
+
mode,
|
|
767
|
+
reason,
|
|
768
|
+
});
|
|
769
|
+
return { ok: false, imported: false, reason };
|
|
770
|
+
}
|
|
771
|
+
notify(event) {
|
|
772
|
+
if (!this.callbacks.notify)
|
|
773
|
+
return;
|
|
774
|
+
void this.callbacks.notify(event).catch((error) => {
|
|
775
|
+
this.logger.warn('auth.sync.notify_failed', { error: formatError(error) });
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
notifyError(reason) {
|
|
779
|
+
if (this.lastNotifiedError === reason)
|
|
780
|
+
return;
|
|
781
|
+
this.lastNotifiedError = reason;
|
|
782
|
+
this.notify({ kind: 'sync_error', reason });
|
|
546
783
|
}
|
|
547
784
|
async writeState() {
|
|
548
785
|
if (!this.config.enabled)
|
|
@@ -430,6 +430,7 @@ export class BridgeSessionCore {
|
|
|
430
430
|
if (serviceStatus.authSync?.enabled) {
|
|
431
431
|
lines.push(t(locale, 'status_auth_sync', {
|
|
432
432
|
node: serviceStatus.authSync.nodeId ?? t(locale, 'unknown'),
|
|
433
|
+
contact: serviceStatus.authSync.transportLabel ?? t(locale, 'unknown'),
|
|
433
434
|
peers: serviceStatus.authSync.peers.length,
|
|
434
435
|
pending: serviceStatus.authSync.pendingImports,
|
|
435
436
|
}));
|
|
@@ -4009,7 +4010,11 @@ export class BridgeSessionCore {
|
|
|
4009
4010
|
await this.sendMessage(scopeId, t(locale, 'auth_sync_disabled'));
|
|
4010
4011
|
return;
|
|
4011
4012
|
}
|
|
4012
|
-
|
|
4013
|
+
const message = [
|
|
4014
|
+
t(locale, 'auth_sync_test_sent', { sent: result.sent, replied: result.replied }),
|
|
4015
|
+
...(result.missing.length > 0 ? [t(locale, 'auth_sync_test_missing', { value: result.missing.join(', ') })] : []),
|
|
4016
|
+
].join('\n');
|
|
4017
|
+
await this.sendMessage(scopeId, message);
|
|
4013
4018
|
return;
|
|
4014
4019
|
}
|
|
4015
4020
|
if (action === 'push' && args[1]?.toLowerCase() === 'all') {
|
|
@@ -8117,6 +8122,7 @@ function formatAuthSyncStatus(locale, status) {
|
|
|
8117
8122
|
const lines = [
|
|
8118
8123
|
t(locale, 'auth_sync_status_title'),
|
|
8119
8124
|
t(locale, 'auth_sync_status_node', { value: status.nodeId ?? t(locale, 'unknown') }),
|
|
8125
|
+
t(locale, 'auth_sync_status_transport', { value: status.transportLabel ?? t(locale, 'unknown') }),
|
|
8120
8126
|
t(locale, 'auth_sync_status_peers', { value: status.peers.length === 0 ? t(locale, 'none') : status.peers.join(', ') }),
|
|
8121
8127
|
t(locale, 'auth_sync_status_pending', { value: status.pendingImports }),
|
|
8122
8128
|
t(locale, 'auth_sync_status_sent', { value: status.lastSentAt ?? t(locale, 'none') }),
|
package/dist/i18n.d.ts
CHANGED
|
@@ -125,7 +125,7 @@ declare const MESSAGES: {
|
|
|
125
125
|
readonly status_runtime_weixin: "- Weixin default runtime: connected {connected}, active turns {turns}";
|
|
126
126
|
readonly status_auth_mirror_none: "Last auth mirror: none recorded";
|
|
127
127
|
readonly status_auth_mirror_synced: "Last auth mirror: {candidate} from {source} at {time}";
|
|
128
|
-
readonly status_auth_sync: "Cross-node auth sync: node {node}, peers {peers}, pending imports {pending}";
|
|
128
|
+
readonly status_auth_sync: "Cross-node auth sync: node {node}, contact {contact}, peers {peers}, pending imports {pending}";
|
|
129
129
|
readonly status_auth_sync_error: "Cross-node auth sync error: {value}";
|
|
130
130
|
readonly status_last_update_none: "Last service update: none recorded";
|
|
131
131
|
readonly status_last_update: "Last service update: {from} -> {to} at {time}";
|
|
@@ -203,11 +203,13 @@ declare const MESSAGES: {
|
|
|
203
203
|
readonly auth_add_reverted: "Restored previous auth.";
|
|
204
204
|
readonly auth_add_missing_file: "Login completed, but the new auth file was not created: {value}";
|
|
205
205
|
readonly auth_sync_disabled: "Cross-node auth sync is disabled.";
|
|
206
|
-
readonly auth_sync_test_sent: "Auth sync test
|
|
206
|
+
readonly auth_sync_test_sent: "Auth sync test complete: sent {sent}, replies {replied}.";
|
|
207
|
+
readonly auth_sync_test_missing: "Missing replies: {value}";
|
|
207
208
|
readonly auth_sync_push_blocked_active: "Cannot push auth sync while any runtime, approval, input, login, or auth mirror write is active.";
|
|
208
209
|
readonly auth_sync_push_done: "Auth sync push complete: sent {sent}, skipped {skipped}.";
|
|
209
210
|
readonly auth_sync_status_title: "Cross-node auth sync:";
|
|
210
211
|
readonly auth_sync_status_node: "Node: {value}";
|
|
212
|
+
readonly auth_sync_status_transport: "Contact bot: {value}";
|
|
211
213
|
readonly auth_sync_status_peers: "Peers: {value}";
|
|
212
214
|
readonly auth_sync_status_pending: "Pending imports: {value}";
|
|
213
215
|
readonly auth_sync_status_sent: "Last sent: {value}";
|
|
@@ -750,7 +752,7 @@ declare const MESSAGES: {
|
|
|
750
752
|
readonly status_runtime_weixin: "- 微信默认运行时:连接 {connected},进行中回复 {turns}";
|
|
751
753
|
readonly status_auth_mirror_none: "最近 auth 镜像:暂无记录";
|
|
752
754
|
readonly status_auth_mirror_synced: "最近 auth 镜像:{candidate} 由 {source} 于 {time} 同步";
|
|
753
|
-
readonly status_auth_sync: "跨节点 auth 同步:节点 {node},peer {peers},待导入 {pending}";
|
|
755
|
+
readonly status_auth_sync: "跨节点 auth 同步:节点 {node},联系人 {contact},peer {peers},待导入 {pending}";
|
|
754
756
|
readonly status_auth_sync_error: "跨节点 auth 同步错误:{value}";
|
|
755
757
|
readonly status_last_update_none: "最近服务升级:暂无记录";
|
|
756
758
|
readonly status_last_update: "最近服务升级:{from} -> {to}({time})";
|
|
@@ -828,11 +830,13 @@ declare const MESSAGES: {
|
|
|
828
830
|
readonly auth_add_reverted: "已恢复之前的 auth。";
|
|
829
831
|
readonly auth_add_missing_file: "登录已完成,但没有创建新的 auth 文件:{value}";
|
|
830
832
|
readonly auth_sync_disabled: "跨节点 auth 同步未启用。";
|
|
831
|
-
readonly auth_sync_test_sent: "
|
|
833
|
+
readonly auth_sync_test_sent: "auth sync 测试完成:已发送 {sent},收到回应 {replied}。";
|
|
834
|
+
readonly auth_sync_test_missing: "未回应:{value}";
|
|
832
835
|
readonly auth_sync_push_blocked_active: "当前有任一 runtime、审批、待输入、登录或 auth 镜像写入在进行中,不能推送 auth 同步。";
|
|
833
836
|
readonly auth_sync_push_done: "auth 同步推送完成:已发送 {sent},已跳过 {skipped}。";
|
|
834
837
|
readonly auth_sync_status_title: "跨节点 auth 同步:";
|
|
835
838
|
readonly auth_sync_status_node: "节点:{value}";
|
|
839
|
+
readonly auth_sync_status_transport: "联系人 bot:{value}";
|
|
836
840
|
readonly auth_sync_status_peers: "Peer:{value}";
|
|
837
841
|
readonly auth_sync_status_pending: "待导入:{value}";
|
|
838
842
|
readonly auth_sync_status_sent: "最近发送:{value}";
|
package/dist/i18n.js
CHANGED
|
@@ -123,7 +123,7 @@ const MESSAGES = {
|
|
|
123
123
|
status_runtime_weixin: '- Weixin default runtime: connected {connected}, active turns {turns}',
|
|
124
124
|
status_auth_mirror_none: 'Last auth mirror: none recorded',
|
|
125
125
|
status_auth_mirror_synced: 'Last auth mirror: {candidate} from {source} at {time}',
|
|
126
|
-
status_auth_sync: 'Cross-node auth sync: node {node}, peers {peers}, pending imports {pending}',
|
|
126
|
+
status_auth_sync: 'Cross-node auth sync: node {node}, contact {contact}, peers {peers}, pending imports {pending}',
|
|
127
127
|
status_auth_sync_error: 'Cross-node auth sync error: {value}',
|
|
128
128
|
status_last_update_none: 'Last service update: none recorded',
|
|
129
129
|
status_last_update: 'Last service update: {from} -> {to} at {time}',
|
|
@@ -201,11 +201,13 @@ const MESSAGES = {
|
|
|
201
201
|
auth_add_reverted: 'Restored previous auth.',
|
|
202
202
|
auth_add_missing_file: 'Login completed, but the new auth file was not created: {value}',
|
|
203
203
|
auth_sync_disabled: 'Cross-node auth sync is disabled.',
|
|
204
|
-
auth_sync_test_sent: 'Auth sync test
|
|
204
|
+
auth_sync_test_sent: 'Auth sync test complete: sent {sent}, replies {replied}.',
|
|
205
|
+
auth_sync_test_missing: 'Missing replies: {value}',
|
|
205
206
|
auth_sync_push_blocked_active: 'Cannot push auth sync while any runtime, approval, input, login, or auth mirror write is active.',
|
|
206
207
|
auth_sync_push_done: 'Auth sync push complete: sent {sent}, skipped {skipped}.',
|
|
207
208
|
auth_sync_status_title: 'Cross-node auth sync:',
|
|
208
209
|
auth_sync_status_node: 'Node: {value}',
|
|
210
|
+
auth_sync_status_transport: 'Contact bot: {value}',
|
|
209
211
|
auth_sync_status_peers: 'Peers: {value}',
|
|
210
212
|
auth_sync_status_pending: 'Pending imports: {value}',
|
|
211
213
|
auth_sync_status_sent: 'Last sent: {value}',
|
|
@@ -748,7 +750,7 @@ const MESSAGES = {
|
|
|
748
750
|
status_runtime_weixin: '- 微信默认运行时:连接 {connected},进行中回复 {turns}',
|
|
749
751
|
status_auth_mirror_none: '最近 auth 镜像:暂无记录',
|
|
750
752
|
status_auth_mirror_synced: '最近 auth 镜像:{candidate} 由 {source} 于 {time} 同步',
|
|
751
|
-
status_auth_sync: '跨节点 auth 同步:节点 {node},peer {peers},待导入 {pending}',
|
|
753
|
+
status_auth_sync: '跨节点 auth 同步:节点 {node},联系人 {contact},peer {peers},待导入 {pending}',
|
|
752
754
|
status_auth_sync_error: '跨节点 auth 同步错误:{value}',
|
|
753
755
|
status_last_update_none: '最近服务升级:暂无记录',
|
|
754
756
|
status_last_update: '最近服务升级:{from} -> {to}({time})',
|
|
@@ -826,11 +828,13 @@ const MESSAGES = {
|
|
|
826
828
|
auth_add_reverted: '已恢复之前的 auth。',
|
|
827
829
|
auth_add_missing_file: '登录已完成,但没有创建新的 auth 文件:{value}',
|
|
828
830
|
auth_sync_disabled: '跨节点 auth 同步未启用。',
|
|
829
|
-
auth_sync_test_sent: '
|
|
831
|
+
auth_sync_test_sent: 'auth sync 测试完成:已发送 {sent},收到回应 {replied}。',
|
|
832
|
+
auth_sync_test_missing: '未回应:{value}',
|
|
830
833
|
auth_sync_push_blocked_active: '当前有任一 runtime、审批、待输入、登录或 auth 镜像写入在进行中,不能推送 auth 同步。',
|
|
831
834
|
auth_sync_push_done: 'auth 同步推送完成:已发送 {sent},已跳过 {skipped}。',
|
|
832
835
|
auth_sync_status_title: '跨节点 auth 同步:',
|
|
833
836
|
auth_sync_status_node: '节点:{value}',
|
|
837
|
+
auth_sync_status_transport: '联系人 bot:{value}',
|
|
834
838
|
auth_sync_status_peers: 'Peer:{value}',
|
|
835
839
|
auth_sync_status_pending: '待导入:{value}',
|
|
836
840
|
auth_sync_status_sent: '最近发送:{value}',
|