@browserbridge/bbx 1.11.0 → 1.11.2

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.
@@ -44,7 +44,12 @@ import {
44
44
  getSocketPath,
45
45
  } from './config.js';
46
46
  import { ArtifactStore } from './artifact-store.js';
47
- import { bridgeAuthTokensEqual, ensureBridgeAuthToken, readBridgeAuthToken } from './auth-token.js';
47
+ import {
48
+ bridgeAuthTokensEqual,
49
+ ensureBridgeAuthToken,
50
+ ensureBridgeExtensionAuthToken,
51
+ readBridgeAuthToken,
52
+ } from './auth-token.js';
48
53
  import { normalizeDaemonLogger } from './daemon-logger.js';
49
54
  import { DomBaselineOwnerRegistry, isValidBaselineId } from './dom-baseline-owners.js';
50
55
  import { writeJsonLine } from './framing.js';
@@ -59,7 +64,7 @@ setProtocolPackageVersion(DAEMON_VERSION);
59
64
  /** @typedef {import('./config.js').BridgeTransport} BridgeTransport */
60
65
  /** @typedef {import('./daemon-logger.js').DaemonLoggerLike} DaemonLoggerLike */
61
66
  /** @typedef {'agent' | 'extension'} SocketRole */
62
- /** @typedef {import('node:net').Socket & { readonly __role?: SocketRole, __clientId?: string, __extensionId?: string, __browserName?: string, __profileLabel?: string, __browserExtensionId?: string, __accessEnabled?: boolean, __lastActiveAt?: number }} ClientSocket */
67
+ /** @typedef {import('node:net').Socket & { readonly __role?: SocketRole, __localControl?: boolean, __registrationTimeoutId?: NodeJS.Timeout, __invalidLineCount?: number, __clientId?: string, __extensionId?: string, __browserName?: string, __profileLabel?: string, __browserExtensionId?: string, __accessEnabled?: boolean, __lastActiveAt?: number }} ClientSocket */
63
68
  /** @typedef {{ socket: ClientSocket, timeoutId: NodeJS.Timeout, source?: string, mcpEra?: string, method?: string, protocolVersion?: string, baselineId?: string | null, automaticMcpRetry?: boolean, targets: Set<ClientSocket>, lastErrorResponse?: import('../../protocol/src/types.js').BridgeResponse }} PendingEntry */
64
69
  /**
65
70
  * @typedef {{
@@ -123,6 +128,14 @@ const DIAGNOSTIC_METHODS = new Set([
123
128
  'setup.get_status',
124
129
  'setup.install',
125
130
  ]);
131
+ export const DAEMON_REGISTRATION_TIMEOUT_MS = 5_000;
132
+ export const MAX_DAEMON_CONNECTIONS = 64;
133
+ export const MAX_DAEMON_PENDING_REQUESTS = 128;
134
+ export const MAX_DAEMON_PENDING_REQUESTS_PER_CLIENT = 16;
135
+ export const MAX_DAEMON_ACTIVE_HANDLERS_PER_CLIENT = 32;
136
+ export const MAX_DAEMON_INVALID_LINES = 3;
137
+ const MAX_CLIENT_ID_LENGTH = 128;
138
+ const MAX_ROUTING_LABEL_LENGTH = 128;
126
139
 
127
140
  /**
128
141
  * @param {unknown} value
@@ -134,6 +147,42 @@ function asHealthRecord(value) {
134
147
  : null;
135
148
  }
136
149
 
150
+ /**
151
+ * @param {unknown} value
152
+ * @returns {string | undefined}
153
+ */
154
+ function normalizeRoutingLabel(value) {
155
+ if (typeof value !== 'string' || value.length === 0 || value.length > MAX_ROUTING_LABEL_LENGTH) {
156
+ return undefined;
157
+ }
158
+ return hasAsciiControlCharacters(value) ? undefined : value;
159
+ }
160
+
161
+ /** @param {string} value @returns {boolean} */
162
+ function hasAsciiControlCharacters(value) {
163
+ for (let index = 0; index < value.length; index += 1) {
164
+ const code = value.charCodeAt(index);
165
+ if (code < 32 || code === 127) return true;
166
+ }
167
+ return false;
168
+ }
169
+
170
+ /**
171
+ * @param {unknown} value
172
+ * @returns {string | null}
173
+ */
174
+ function normalizeClientId(value) {
175
+ if (value == null || value === '') return randomUUID();
176
+ if (
177
+ typeof value !== 'string' ||
178
+ value.length > MAX_CLIENT_ID_LENGTH ||
179
+ !/^[A-Za-z0-9_.:-]+$/u.test(value)
180
+ ) {
181
+ return null;
182
+ }
183
+ return value;
184
+ }
185
+
137
186
  /**
138
187
  * @param {unknown} value
139
188
  * @returns {number | undefined}
@@ -383,6 +432,7 @@ export class BridgeDaemon {
383
432
  * setupInstaller?: (params: Record<string, unknown>) => Promise<SetupInstallResult>,
384
433
  * logger?: DaemonLoggerLike | Pick<Console, 'log' | 'error'>,
385
434
  * authToken?: string | null,
435
+ * extensionAuthToken?: string | null,
386
436
  * artifactStore?: ArtifactStore
387
437
  * }} [options={}]
388
438
  */
@@ -394,6 +444,7 @@ export class BridgeDaemon {
394
444
  setupInstaller = installSetupTarget,
395
445
  logger = undefined,
396
446
  authToken = undefined,
447
+ extensionAuthToken = undefined,
397
448
  artifactStore = new ArtifactStore(getArtifactStorePath()),
398
449
  } = {}) {
399
450
  this.transport = socketPath ? createSocketBridgeTransport(socketPath) : transport;
@@ -412,6 +463,11 @@ export class BridgeDaemon {
412
463
  this.extensionSockets = new Map();
413
464
  /** @type {Map<string, ClientSocket>} */
414
465
  this.agentSockets = new Map();
466
+ /** @type {Set<ClientSocket>} */
467
+ this.clientSockets = new Set();
468
+ /** @type {Map<ClientSocket, number>} */
469
+ this.activeHandlers = new Map();
470
+ this.activeHandlerCount = 0;
415
471
  /** @type {Map<string, PendingEntry>} */
416
472
  this.pendingRequests = new Map();
417
473
  /** @type {Map<ClientSocket, Set<string>>} */
@@ -437,6 +493,8 @@ export class BridgeDaemon {
437
493
  this.requestStartTimes = new Map();
438
494
  /** @type {string | null | undefined} */
439
495
  this.authToken = authToken;
496
+ /** @type {string | null | undefined} */
497
+ this.extensionAuthToken = extensionAuthToken;
440
498
  this.artifactStore = artifactStore;
441
499
  this.artifactStoreInitialized = false;
442
500
  this.domBaselines = new DomBaselineOwnerRegistry({
@@ -516,7 +574,7 @@ export class BridgeDaemon {
516
574
  pending = this.pendingRequests.get(requestId),
517
575
  preserveArtifacts = false
518
576
  ) {
519
- if (!pending) {
577
+ if (!pending || this.pendingRequests.get(requestId) !== pending) {
520
578
  return undefined;
521
579
  }
522
580
  clearTimeout(pending.timeoutId);
@@ -569,6 +627,7 @@ export class BridgeDaemon {
569
627
  * @returns {void}
570
628
  */
571
629
  registerSocket(socket, message) {
630
+ this.clientSockets.add(socket);
572
631
  if (socket.__role) {
573
632
  void writeJsonLine(socket, {
574
633
  type: 'registration_failed',
@@ -576,30 +635,38 @@ export class BridgeDaemon {
576
635
  code: ERROR_CODES.INVALID_REQUEST,
577
636
  message: `Socket is already registered as ${socket.__role}.`,
578
637
  },
579
- });
638
+ }).catch(() => socket.destroy());
580
639
  return;
581
640
  }
582
641
 
583
- if (this.isAuthRequired() && !bridgeAuthTokensEqual(message.authToken, this.authToken)) {
584
- this.logger.error('socket registration rejected', { role: message.role ?? null });
642
+ if (message.role !== 'extension' && message.role !== 'agent') {
585
643
  void writeJsonLine(socket, {
586
644
  type: 'registration_failed',
587
645
  error: {
588
- code: ERROR_CODES.ACCESS_DENIED,
589
- message: 'Bridge daemon authentication failed.',
646
+ code: ERROR_CODES.INVALID_REQUEST,
647
+ message: 'Socket role must be "agent" or "extension".',
590
648
  },
591
- }).finally(() => socket.destroy());
649
+ }).catch(() => socket.destroy());
592
650
  return;
593
651
  }
594
652
 
595
- if (message.role !== 'extension' && message.role !== 'agent') {
653
+ const expectedToken =
654
+ message.role === 'extension'
655
+ ? this.extensionAuthToken
656
+ : this.isAuthRequired()
657
+ ? this.authToken
658
+ : null;
659
+ if (expectedToken && !bridgeAuthTokensEqual(message.authToken, expectedToken)) {
660
+ this.logger.error('socket registration rejected', { role: message.role ?? null });
596
661
  void writeJsonLine(socket, {
597
662
  type: 'registration_failed',
598
663
  error: {
599
- code: ERROR_CODES.INVALID_REQUEST,
600
- message: 'Socket role must be "agent" or "extension".',
664
+ code: ERROR_CODES.ACCESS_DENIED,
665
+ message: 'Bridge daemon authentication failed.',
601
666
  },
602
- });
667
+ })
668
+ .catch(() => {})
669
+ .finally(() => socket.destroy());
603
670
  return;
604
671
  }
605
672
 
@@ -609,13 +676,16 @@ export class BridgeDaemon {
609
676
  configurable: false,
610
677
  writable: false,
611
678
  });
679
+ if (socket.__registrationTimeoutId) {
680
+ clearTimeout(socket.__registrationTimeoutId);
681
+ socket.__registrationTimeoutId = undefined;
682
+ }
612
683
  if (message.role === 'extension') {
684
+ socket.__localControl = true;
613
685
  const extensionId = randomUUID();
614
686
  socket.__extensionId = extensionId;
615
- socket.__browserName =
616
- typeof message.browserName === 'string' ? message.browserName : undefined;
617
- socket.__profileLabel =
618
- typeof message.profileLabel === 'string' ? message.profileLabel : undefined;
687
+ socket.__browserName = normalizeRoutingLabel(message.browserName);
688
+ socket.__profileLabel = normalizeRoutingLabel(message.profileLabel);
619
689
  socket.__browserExtensionId = normalizeBrowserExtensionId(message.browserExtensionId);
620
690
  socket.__lastActiveAt = Date.now();
621
691
  this.extensionSockets.set(extensionId, socket);
@@ -625,12 +695,27 @@ export class BridgeDaemon {
625
695
  browserName: socket.__browserName ?? null,
626
696
  profileLabel: socket.__profileLabel ?? null,
627
697
  });
628
- void writeJsonLine(socket, { type: 'registered', role: 'extension' });
698
+ void writeJsonLine(socket, { type: 'registered', role: 'extension' }).catch(() =>
699
+ socket.destroy()
700
+ );
629
701
  return;
630
702
  }
631
703
 
632
704
  if (message.role === 'agent') {
633
- const clientId = message.clientId || randomUUID();
705
+ socket.__localControl = this.transport.type === 'socket';
706
+ const clientId = normalizeClientId(message.clientId);
707
+ if (!clientId) {
708
+ void writeJsonLine(socket, {
709
+ type: 'registration_failed',
710
+ error: {
711
+ code: ERROR_CODES.INVALID_REQUEST,
712
+ message: 'Agent clientId is invalid.',
713
+ },
714
+ })
715
+ .catch(() => {})
716
+ .finally(() => socket.destroy());
717
+ return;
718
+ }
634
719
  this.agentSockets.set(clientId, socket);
635
720
  socket.__clientId = clientId;
636
721
  this.logger.info('agent registered', { clientId });
@@ -638,7 +723,7 @@ export class BridgeDaemon {
638
723
  type: 'registered',
639
724
  role: 'agent',
640
725
  clientId,
641
- });
726
+ }).catch(() => socket.destroy());
642
727
  return;
643
728
  }
644
729
  }
@@ -649,6 +734,10 @@ export class BridgeDaemon {
649
734
  if (this.authToken === undefined) {
650
735
  this.authToken = this.transport.type === 'tcp' ? await ensureBridgeAuthToken() : null;
651
736
  }
737
+ if (this.extensionAuthToken === undefined) {
738
+ this.extensionAuthToken =
739
+ this.transport.type === 'tcp' ? await ensureBridgeExtensionAuthToken() : null;
740
+ }
652
741
 
653
742
  if (this.transport.type === 'socket' && !isWindowsNamedPipePath(this.socketPath)) {
654
743
  const socketDir = path.dirname(this.socketPath);
@@ -658,7 +747,7 @@ export class BridgeDaemon {
658
747
  }
659
748
  try {
660
749
  await fs.promises.access(this.socketPath);
661
- if (await pingExistingDaemon(this.transport)) {
750
+ if (await hasLiveListener(this.transport)) {
662
751
  throw new Error(
663
752
  `Another daemon is already running on ${this.socketPath}. Stop it before starting a new one.`
664
753
  );
@@ -666,38 +755,79 @@ export class BridgeDaemon {
666
755
  this.logger.info('Removing stale socket from previous run', {
667
756
  socketPath: this.socketPath,
668
757
  });
758
+ await fs.promises.rm(this.socketPath, { force: true });
669
759
  } catch (error) {
670
- if (error instanceof Error && error.message.startsWith('Another daemon')) {
760
+ if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 'ENOENT')
671
761
  throw error;
672
- }
673
762
  // Socket does not exist - normal startup.
674
763
  }
675
- await fs.promises.rm(this.socketPath, { force: true });
676
764
  }
677
765
 
678
766
  this.server = net.createServer((socket) => {
679
767
  const typedSocket = /** @type {ClientSocket} */ (socket);
768
+ this.clientSockets.add(typedSocket);
769
+ typedSocket.__invalidLineCount = 0;
770
+ typedSocket.__registrationTimeoutId = setTimeout(() => {
771
+ if (!typedSocket.__role) {
772
+ this.logger.warn('unregistered socket timed out');
773
+ typedSocket.destroy();
774
+ }
775
+ }, DAEMON_REGISTRATION_TIMEOUT_MS);
776
+ typedSocket.__registrationTimeoutId.unref?.();
680
777
  typedSocket.on('error', (err) => {
681
778
  this.logger.error('socket error', { message: err.message });
682
779
  });
683
780
  parseJsonLines(
684
781
  typedSocket,
685
782
  (raw) => {
783
+ if (typedSocket.destroyed) return;
686
784
  const message = /** @type {DaemonMessage} */ (raw);
687
- void this.handleClientMessage(typedSocket, message).catch((err) => {
688
- this.logger.error('handler error', {
689
- message: err instanceof Error ? err.message : String(err),
785
+ const admitted =
786
+ message?.type === 'agent.request' || message?.type === 'extension.setup_status.request';
787
+ const active = this.activeHandlers.get(typedSocket) ?? 0;
788
+ if (
789
+ admitted &&
790
+ (active >= MAX_DAEMON_ACTIVE_HANDLERS_PER_CLIENT ||
791
+ this.activeHandlerCount >= MAX_DAEMON_PENDING_REQUESTS)
792
+ ) {
793
+ typedSocket.destroy();
794
+ return;
795
+ }
796
+ if (admitted) {
797
+ this.activeHandlers.set(typedSocket, active + 1);
798
+ this.activeHandlerCount += 1;
799
+ }
800
+ void this.handleClientMessage(typedSocket, message)
801
+ .catch((err) => {
802
+ this.logger.error('handler error', {
803
+ message: err instanceof Error ? err.message : String(err),
804
+ });
805
+ })
806
+ .finally(() => {
807
+ if (admitted) {
808
+ const remaining = (this.activeHandlers.get(typedSocket) ?? 1) - 1;
809
+ if (remaining) this.activeHandlers.set(typedSocket, remaining);
810
+ else this.activeHandlers.delete(typedSocket);
811
+ this.activeHandlerCount -= 1;
812
+ }
690
813
  });
691
- });
692
814
  },
693
815
  {
694
816
  onProtocolError: (error) => {
695
817
  this.logger.error('socket protocol error', { message: error.message });
696
818
  },
819
+ onInvalidLine: () => {
820
+ typedSocket.__invalidLineCount = (typedSocket.__invalidLineCount ?? 0) + 1;
821
+ if (typedSocket.__invalidLineCount >= MAX_DAEMON_INVALID_LINES) {
822
+ this.logger.warn('socket sent too many malformed JSON lines');
823
+ typedSocket.destroy();
824
+ }
825
+ },
697
826
  }
698
827
  );
699
828
  typedSocket.on('close', () => this.handleSocketClose(typedSocket));
700
829
  });
830
+ this.server.maxConnections = MAX_DAEMON_CONNECTIONS;
701
831
 
702
832
  const server = this.server;
703
833
  await new Promise((resolve, reject) => {
@@ -753,6 +883,10 @@ export class BridgeDaemon {
753
883
  this.pendingRequests.clear();
754
884
  this.pendingRequestsByOwnerSocket.clear();
755
885
  this.pendingRequestsByTargetSocket.clear();
886
+ this.requestStartTimes.clear();
887
+
888
+ for (const socket of this.clientSockets) socket.destroy();
889
+ this.clientSockets.clear();
756
890
 
757
891
  for (const socket of this.agentSockets.values()) {
758
892
  socket.destroy();
@@ -772,6 +906,7 @@ export class BridgeDaemon {
772
906
 
773
907
  if (this.server) {
774
908
  const server = this.server;
909
+ const ownedListener = server.listening;
775
910
  this.server = null;
776
911
  try {
777
912
  await new Promise((resolve, reject) => {
@@ -784,7 +919,11 @@ export class BridgeDaemon {
784
919
  });
785
920
  });
786
921
  } finally {
787
- if (this.transport.type === 'socket' && !isWindowsNamedPipePath(this.socketPath)) {
922
+ if (
923
+ ownedListener &&
924
+ this.transport.type === 'socket' &&
925
+ !isWindowsNamedPipePath(this.socketPath)
926
+ ) {
788
927
  await fs.promises.rm(this.socketPath, { force: true });
789
928
  }
790
929
  }
@@ -1076,7 +1215,7 @@ export class BridgeDaemon {
1076
1215
  }
1077
1216
 
1078
1217
  if (request.method === 'setup.install') {
1079
- if (socket.__role === 'agent' && !this.isLocalSocket(socket)) {
1218
+ if (!socket.__localControl) {
1080
1219
  const response = createFailure(
1081
1220
  request.id,
1082
1221
  ERROR_CODES.ACCESS_DENIED,
@@ -1115,6 +1254,26 @@ export class BridgeDaemon {
1115
1254
  typeof request.meta?.target_profile === 'string' ? request.meta.target_profile : null;
1116
1255
  const hasExplicitTarget = Boolean(targetBrowser || targetProfile);
1117
1256
 
1257
+ const ownerPendingCount = this.pendingRequestsByOwnerSocket.get(socket)?.size ?? 0;
1258
+ if (
1259
+ this.pendingRequests.size >= MAX_DAEMON_PENDING_REQUESTS ||
1260
+ ownerPendingCount >= MAX_DAEMON_PENDING_REQUESTS_PER_CLIENT
1261
+ ) {
1262
+ const response = createFailure(
1263
+ request.id,
1264
+ ERROR_CODES.INVALID_REQUEST,
1265
+ 'Daemon pending-request limit reached. Wait for existing requests to finish.',
1266
+ {
1267
+ maxPendingRequests: MAX_DAEMON_PENDING_REQUESTS,
1268
+ maxPendingRequestsPerClient: MAX_DAEMON_PENDING_REQUESTS_PER_CLIENT,
1269
+ },
1270
+ { method: request.method }
1271
+ );
1272
+ await writeJsonLine(socket, { type: 'agent.response', response });
1273
+ this.recordDaemonRequestOutcome(request, false);
1274
+ return;
1275
+ }
1276
+
1118
1277
  const baselineId =
1119
1278
  typeof request.params.baselineId === 'string' ? request.params.baselineId : null;
1120
1279
  const usesBaselineOwner =
@@ -1209,7 +1368,7 @@ export class BridgeDaemon {
1209
1368
  message: error instanceof Error ? error.message : String(error),
1210
1369
  });
1211
1370
  const pending = this.pendingRequests.get(request.id);
1212
- if (!pending) {
1371
+ if (!pending || !pending.targets.has(target)) {
1213
1372
  return;
1214
1373
  }
1215
1374
  this.removePendingTarget(request.id, pending, target);
@@ -1222,23 +1381,6 @@ export class BridgeDaemon {
1222
1381
  }
1223
1382
  }
1224
1383
 
1225
- /**
1226
- * @param {ClientSocket} socket
1227
- * @returns {boolean}
1228
- */
1229
- isLocalSocket(socket) {
1230
- if (this.transport.type === 'socket') {
1231
- return true;
1232
- }
1233
- const address = socket.remoteAddress ?? '';
1234
- return (
1235
- address === '127.0.0.1' ||
1236
- address === '::1' ||
1237
- address === 'localhost' ||
1238
- address === '::ffff:127.0.0.1'
1239
- );
1240
- }
1241
-
1242
1384
  /**
1243
1385
  * Select from a snapshot so routing neither mutates nor observes mutations to
1244
1386
  * the live extension map while candidates are ordered.
@@ -1348,13 +1490,15 @@ export class BridgeDaemon {
1348
1490
  */
1349
1491
  handleExtensionIdentity(socket, message) {
1350
1492
  let changed = false;
1351
- if (typeof message.browserName === 'string') {
1352
- changed = changed || socket.__browserName !== message.browserName;
1353
- socket.__browserName = message.browserName;
1493
+ const browserName = normalizeRoutingLabel(message.browserName);
1494
+ if (browserName) {
1495
+ changed = changed || socket.__browserName !== browserName;
1496
+ socket.__browserName = browserName;
1354
1497
  }
1355
- if (typeof message.profileLabel === 'string') {
1356
- changed = changed || socket.__profileLabel !== message.profileLabel;
1357
- socket.__profileLabel = message.profileLabel;
1498
+ const profileLabel = normalizeRoutingLabel(message.profileLabel);
1499
+ if (profileLabel) {
1500
+ changed = changed || socket.__profileLabel !== profileLabel;
1501
+ socket.__profileLabel = profileLabel;
1358
1502
  }
1359
1503
  if (typeof message.browserExtensionId === 'string') {
1360
1504
  const browserExtensionId = normalizeBrowserExtensionId(message.browserExtensionId);
@@ -1399,6 +1543,20 @@ export class BridgeDaemon {
1399
1543
  const ownerId = pending.socket.__clientId;
1400
1544
  if (!ownerId) return;
1401
1545
  try {
1546
+ if (
1547
+ message.type === 'extension.artifact.chunk' ||
1548
+ message.type === 'extension.artifact.commit'
1549
+ ) {
1550
+ const transfer = this.artifactStore.transfers.get(String(message.artifactId ?? ''));
1551
+ if (
1552
+ !transfer ||
1553
+ transfer.requestId !== requestId ||
1554
+ transfer.ownerId !== ownerId ||
1555
+ transfer.extensionId !== socket.__extensionId
1556
+ ) {
1557
+ throw new Error('Artifact transfer does not belong to this request and extension.');
1558
+ }
1559
+ }
1402
1560
  if (message.type === 'extension.artifact.begin') {
1403
1561
  this.artifactStore.begin({
1404
1562
  artifactId: String(artifact.artifactId ?? ''),
@@ -1457,12 +1615,11 @@ export class BridgeDaemon {
1457
1615
 
1458
1616
  /**
1459
1617
  * @param {ClientSocket} socket
1460
- * @param {DaemonMessage} message
1618
+ * @param {DaemonMessage} _message
1461
1619
  * @returns {void}
1462
1620
  */
1463
- handleExtensionActivity(socket, message) {
1464
- socket.__lastActiveAt =
1465
- typeof message.at === 'number' && Number.isFinite(message.at) ? message.at : Date.now();
1621
+ handleExtensionActivity(socket, _message) {
1622
+ socket.__lastActiveAt = Date.now();
1466
1623
  }
1467
1624
 
1468
1625
  /**
@@ -1495,6 +1652,7 @@ export class BridgeDaemon {
1495
1652
  }
1496
1653
 
1497
1654
  this.removePendingTarget(responseMessage.id, pending, socket);
1655
+ clearTimeout(pending.timeoutId);
1498
1656
 
1499
1657
  if (responseMessage.ok) {
1500
1658
  const result =
@@ -1540,7 +1698,7 @@ export class BridgeDaemon {
1540
1698
  }
1541
1699
  }
1542
1700
  if (!this.domBaselines.register(baselineId, socket, result.expiresAt)) {
1543
- await this.releaseOrphanDomBaseline(socket, responseMessage);
1701
+ await this.releaseOrphanDomBaseline(socket, responseMessage).catch(() => {});
1544
1702
  pending.lastErrorResponse = createFailure(
1545
1703
  responseMessage.id,
1546
1704
  ERROR_CODES.DOM_BASELINE_INVALIDATED,
@@ -1592,11 +1750,12 @@ export class BridgeDaemon {
1592
1750
  if (baselineId) this.domBaselines.delete(baselineId);
1593
1751
  await this.releaseOrphanDomBaseline(socket, responseMessage).catch(() => {});
1594
1752
  }
1595
- this.clearPendingRequest(responseMessage.id, pending);
1596
- this.recordRequestCompletion(responseMessage.id, false, pending);
1753
+ if (this.clearPendingRequest(responseMessage.id, pending)) {
1754
+ this.recordRequestCompletion(responseMessage.id, false, pending);
1755
+ }
1597
1756
  throw error;
1598
1757
  }
1599
- this.clearPendingRequest(responseMessage.id, pending, true);
1758
+ if (!this.clearPendingRequest(responseMessage.id, pending, true)) return;
1600
1759
  this.recordRequestCompletion(responseMessage.id, true, pending);
1601
1760
  this.pushLog({
1602
1761
  at: new Date().toISOString(),
@@ -1623,6 +1782,11 @@ export class BridgeDaemon {
1623
1782
  * @returns {void}
1624
1783
  */
1625
1784
  handleSocketClose(socket) {
1785
+ this.clientSockets.delete(socket);
1786
+ if (socket.__registrationTimeoutId) {
1787
+ clearTimeout(socket.__registrationTimeoutId);
1788
+ socket.__registrationTimeoutId = undefined;
1789
+ }
1626
1790
  if (socket.__extensionId) {
1627
1791
  this.artifactStore.deleteByExtension(socket.__extensionId);
1628
1792
  this.domBaselines.clearForSocket(socket);
@@ -1681,7 +1845,7 @@ export class BridgeDaemon {
1681
1845
  * @returns {Promise<void>}
1682
1846
  */
1683
1847
  async finishPendingRequestIfExhausted(requestId, pending) {
1684
- if (pending.targets.size > 0 || !this.pendingRequests.has(requestId)) {
1848
+ if (pending.targets.size > 0 || this.pendingRequests.get(requestId) !== pending) {
1685
1849
  return;
1686
1850
  }
1687
1851
 
@@ -1875,6 +2039,37 @@ export async function pingExistingDaemon(transport) {
1875
2039
  });
1876
2040
  }
1877
2041
 
2042
+ /**
2043
+ * Only a refused or missing endpoint proves a Unix socket is stale.
2044
+ * @param {BridgeTransport} transport
2045
+ * @returns {Promise<boolean>}
2046
+ */
2047
+ export function hasLiveListener(transport) {
2048
+ return new Promise((resolve, reject) => {
2049
+ const socket =
2050
+ transport.type === 'tcp'
2051
+ ? net.createConnection({ host: transport.host, port: transport.port })
2052
+ : net.createConnection(transport.socketPath);
2053
+ const timer = setTimeout(() => finish(true), DAEMON_EXISTING_SOCKET_PING_TIMEOUT_MS);
2054
+ /** @param {boolean} live @param {Error} [error] */
2055
+ function finish(live, error) {
2056
+ clearTimeout(timer);
2057
+ socket.destroy();
2058
+ if (error) reject(error);
2059
+ else resolve(live);
2060
+ }
2061
+ socket.once('connect', () => finish(true));
2062
+ socket.once('error', (error) => {
2063
+ if (
2064
+ 'code' in error &&
2065
+ (error.code === 'ENOENT' || error.code === 'ECONNREFUSED' || error.code === 'ENOTSOCK')
2066
+ )
2067
+ finish(false);
2068
+ else finish(true, error);
2069
+ });
2070
+ });
2071
+ }
2072
+
1878
2073
  /**
1879
2074
  * @param {Record<string, unknown>} params
1880
2075
  * @returns {SetupInstallParams & { action: 'install' | 'uninstall', kind: 'mcp' | 'skill', target: string }}