@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.
@@ -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
- await this.sendToAll({
116
- kind: 'push.bundle',
117
- ...bundleFromRecord(record),
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
- for (const record of await this.callbacks.listLocalCandidates()) {
127
- if (!isAuthCandidateName(record.candidateName)) {
128
- skipped += 1;
129
- continue;
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
- await this.sendToAll({
132
- kind: 'push.bundle',
133
- ...bundleFromRecord(record),
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
- sent += 1;
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
- await this.sendToAll({ kind: 'test.ping', requestId: crypto.randomUUID() });
245
- return { sent: this.peers.length };
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
- const imported = await this.validateAndImport(message.bundle, senderNodeId, sourceLabel, peer);
336
- if (!imported)
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.recordError('remote bundle shape is invalid');
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.recordError(`remote bundle metadata mismatch for ${bundle.candidateName}`);
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.recordError(`remote bundle hash mismatch for ${bundle.candidateName}`);
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.recordError(`remote access token is expired or missing exp for ${bundle.candidateName}`);
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.recordError(`remote candidate validation failed for ${bundle.candidateName}: ${validation.reason ?? 'unknown'}`);
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: sourceLabel ?? fromPeer,
602
+ label: source,
444
603
  });
445
604
  if (!result.ok) {
446
- this.recordError(`remote candidate import failed for ${bundle.candidateName}: ${result.reason ?? 'unknown'}`);
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
- return result.imported;
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)
@@ -23,6 +23,8 @@ export interface CoreCoordinator {
23
23
  }>;
24
24
  authSyncTest?: () => Promise<{
25
25
  sent: number;
26
+ replied: number;
27
+ missing: string[];
26
28
  }>;
27
29
  statusUpdated?: (status: RuntimeStatus) => void;
28
30
  getServiceStatus?: () => Promise<{
@@ -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
- await this.sendMessage(scopeId, t(locale, 'auth_sync_test_sent', { count: result.sent }));
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 ping sent to {count} peer(s).";
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: "已向 {count} 个 peer 发送 auth sync 测试 ping。";
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 ping sent to {count} peer(s).',
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: '已向 {count} 个 peer 发送 auth sync 测试 ping。',
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}',