@browserbridge/bbx 1.9.0 → 1.11.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.
Files changed (33) hide show
  1. package/README.md +1 -1
  2. package/package.json +8 -10
  3. package/packages/agent-client/src/cli-args.js +35 -6
  4. package/packages/agent-client/src/cli-batch.js +10 -5
  5. package/packages/agent-client/src/cli-helpers.js +8 -0
  6. package/packages/agent-client/src/cli-proxy-remote.js +7 -4
  7. package/packages/agent-client/src/cli-setup-commands.js +3 -1
  8. package/packages/agent-client/src/cli.js +35 -15
  9. package/packages/agent-client/src/command-registry.js +7 -3
  10. package/packages/agent-client/src/mcp-config.js +8 -8
  11. package/packages/agent-client/src/npm-self-update.js +9 -2
  12. package/packages/agent-client/src/runtime.js +14 -5
  13. package/packages/agent-client/src/types.ts +2 -0
  14. package/packages/mcp-server/src/bin.js +1 -1
  15. package/packages/mcp-server/src/guidance.js +43 -6
  16. package/packages/mcp-server/src/handlers-capture.js +1 -1
  17. package/packages/mcp-server/src/handlers-page.js +162 -9
  18. package/packages/mcp-server/src/handlers-utils.js +145 -169
  19. package/packages/mcp-server/src/handlers.js +17 -5
  20. package/packages/mcp-server/src/server.js +255 -91
  21. package/packages/mcp-server/src/toolset.js +73 -0
  22. package/packages/native-host/src/daemon.js +24 -107
  23. package/packages/native-host/src/dom-baseline-owners.js +210 -0
  24. package/packages/native-host/src/native-host.js +14 -28
  25. package/packages/protocol/src/budget-presets.js +182 -0
  26. package/packages/protocol/src/har.js +34 -0
  27. package/packages/protocol/src/index.js +2 -0
  28. package/packages/protocol/src/json-lines.js +3 -2
  29. package/packages/protocol/src/protocol.js +10 -0
  30. package/packages/protocol/src/registry.js +83 -0
  31. package/packages/protocol/src/types.ts +3 -0
  32. package/skills/browser-bridge/SKILL.md +4 -0
  33. package/skills/browser-bridge/references/protocol.md +11 -5
@@ -0,0 +1,73 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Browser Bridge starts with a compact fixed tool surface. Specialized typed
5
+ * tools remain registered but disabled until browser_toolset enables one by
6
+ * exact name. browser_call always keeps the full bridge protocol reachable.
7
+ */
8
+
9
+ /** @type {readonly string[]} */
10
+ export const INITIAL_TOOLSET_TOOLS = Object.freeze([
11
+ 'browser_access',
12
+ 'browser_batch',
13
+ 'browser_call',
14
+ 'browser_health',
15
+ 'browser_status',
16
+ 'browser_toolset',
17
+ ]);
18
+
19
+ /**
20
+ * Modern MCP tool lists cannot change as a side effect of another request.
21
+ * Keep the compact surface static and use browser_call for full protocol reach.
22
+ *
23
+ * @type {readonly string[]}
24
+ */
25
+ export const MODERN_TOOLSET_TOOLS = Object.freeze([
26
+ 'browser_access',
27
+ 'browser_batch',
28
+ 'browser_call',
29
+ 'browser_health',
30
+ 'browser_skill',
31
+ 'browser_status',
32
+ ]);
33
+
34
+ /** @typedef {'browser_dom' | 'browser_styles_layout' | 'browser_page' | 'browser_logs' | 'browser_input' | 'browser_navigation' | 'browser_tabs' | 'browser_capture' | 'browser_artifact' | 'browser_patch' | 'browser_intercept' | 'browser_investigate' | 'browser_sensitive_read' | 'browser_setup' | 'browser_skill'} LoadableToolName */
35
+
36
+ /** @type {readonly LoadableToolName[]} */
37
+ export const LOADABLE_TOOLSET_TOOLS = Object.freeze([
38
+ 'browser_dom',
39
+ 'browser_styles_layout',
40
+ 'browser_page',
41
+ 'browser_logs',
42
+ 'browser_input',
43
+ 'browser_navigation',
44
+ 'browser_tabs',
45
+ 'browser_capture',
46
+ 'browser_artifact',
47
+ 'browser_patch',
48
+ 'browser_intercept',
49
+ 'browser_investigate',
50
+ 'browser_sensitive_read',
51
+ 'browser_setup',
52
+ 'browser_skill',
53
+ ]);
54
+
55
+ const INITIAL_TOOLSET = new Set(INITIAL_TOOLSET_TOOLS);
56
+ const MODERN_TOOLSET = new Set(MODERN_TOOLSET_TOOLS);
57
+
58
+ /**
59
+ * @param {string} toolName
60
+ * @returns {boolean}
61
+ */
62
+ export function isInitiallyEnabledTool(toolName) {
63
+ return INITIAL_TOOLSET.has(toolName);
64
+ }
65
+
66
+ /**
67
+ * @param {string} toolName
68
+ * @param {'legacy' | 'modern'} era
69
+ * @returns {boolean}
70
+ */
71
+ export function isToolEnabledForEra(toolName, era) {
72
+ return era === 'modern' ? MODERN_TOOLSET.has(toolName) : INITIAL_TOOLSET.has(toolName);
73
+ }
@@ -24,12 +24,10 @@ import {
24
24
  DAEMON_RECENT_LOG_LIMIT,
25
25
  DEFAULT_DAEMON_PENDING_TIMEOUT_MS,
26
26
  DEFAULT_LOG_TAIL_LIMIT,
27
- DOM_BASELINE_TTL_MS,
28
27
  ERROR_CODES,
29
28
  getProtocolVersion,
30
29
  getSupportedProtocolVersions,
31
30
  MAX_DAEMON_PENDING_TIMEOUT_MS,
32
- MAX_DOM_BASELINES_GLOBAL,
33
31
  parseJsonLines,
34
32
  RecoveryTelemetryCollector,
35
33
  normalizeRecoveryTelemetrySummary,
@@ -48,6 +46,7 @@ import {
48
46
  import { ArtifactStore } from './artifact-store.js';
49
47
  import { bridgeAuthTokensEqual, ensureBridgeAuthToken, readBridgeAuthToken } from './auth-token.js';
50
48
  import { normalizeDaemonLogger } from './daemon-logger.js';
49
+ import { DomBaselineOwnerRegistry, isValidBaselineId } from './dom-baseline-owners.js';
51
50
  import { writeJsonLine } from './framing.js';
52
51
 
53
52
  const DAEMON_VERSION = loadDaemonVersion();
@@ -61,7 +60,7 @@ setProtocolPackageVersion(DAEMON_VERSION);
61
60
  /** @typedef {import('./daemon-logger.js').DaemonLoggerLike} DaemonLoggerLike */
62
61
  /** @typedef {'agent' | 'extension'} SocketRole */
63
62
  /** @typedef {import('node:net').Socket & { readonly __role?: SocketRole, __clientId?: string, __extensionId?: string, __browserName?: string, __profileLabel?: string, __browserExtensionId?: string, __accessEnabled?: boolean, __lastActiveAt?: number }} ClientSocket */
64
- /** @typedef {{ socket: ClientSocket, timeoutId: NodeJS.Timeout, source?: string, method?: string, protocolVersion?: string, baselineId?: string | null, automaticMcpRetry?: boolean, targets: Set<ClientSocket>, lastErrorResponse?: import('../../protocol/src/types.js').BridgeResponse }} PendingEntry */
63
+ /** @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 */
65
64
  /**
66
65
  * @typedef {{
67
66
  * installAgentFiles: typeof import('../../agent-client/src/install.js').installAgentFiles,
@@ -440,10 +439,10 @@ export class BridgeDaemon {
440
439
  this.authToken = authToken;
441
440
  this.artifactStore = artifactStore;
442
441
  this.artifactStoreInitialized = false;
443
- /** @type {Map<string, { socket: ClientSocket, expiresAt: number }>} */
444
- this.domBaselineOwners = new Map();
445
- /** @type {Map<string, { socket: ClientSocket, expiresAt: number }>} */
446
- this.abandonedDomBaselineCreates = new Map();
442
+ this.domBaselines = new DomBaselineOwnerRegistry({
443
+ isCurrentExtensionSocket: (socket) =>
444
+ this.extensionSockets.get(socket.__extensionId ?? '') === socket,
445
+ });
447
446
  this.recoveryTelemetry = new RecoveryTelemetryCollector();
448
447
  }
449
448
 
@@ -765,8 +764,7 @@ export class BridgeDaemon {
765
764
  }
766
765
  this.extensionSockets.clear();
767
766
  this.invalidateConnectedExtensionsCache();
768
- this.domBaselineOwners.clear();
769
- this.abandonedDomBaselineCreates.clear();
767
+ this.domBaselines.clear();
770
768
  if (this.artifactStoreInitialized) {
771
769
  this.artifactStore.reset();
772
770
  this.artifactStoreInitialized = false;
@@ -998,7 +996,7 @@ export class BridgeDaemon {
998
996
  }
999
997
 
1000
998
  if (request.method === 'daemon.metrics') {
1001
- this.pruneDomBaselineOwners();
999
+ this.domBaselines.prune();
1002
1000
  const now = Date.now();
1003
1001
  const uptimeMs = this.startedAt > 0 ? now - this.startedAt : 0;
1004
1002
  const avgResponseTimeMs =
@@ -1013,7 +1011,7 @@ export class BridgeDaemon {
1013
1011
  requestsProcessed: this.requestsProcessed,
1014
1012
  requestsFailed: this.requestsFailed,
1015
1013
  avgResponseTimeMs,
1016
- domBaselineOwners: this.domBaselineOwners.size,
1014
+ domBaselineOwners: this.domBaselines.size,
1017
1015
  recovery: this.recoveryTelemetry.snapshot('daemon'),
1018
1016
  });
1019
1017
  await writeJsonLine(socket, { type: 'agent.response', response });
@@ -1123,7 +1121,7 @@ export class BridgeDaemon {
1123
1121
  request.method === 'dom.baseline.compare' ||
1124
1122
  request.method === 'dom.baseline.describe' ||
1125
1123
  request.method === 'dom.baseline.release';
1126
- const baselineOwner = usesBaselineOwner ? this.getDomBaselineOwner(baselineId ?? '') : null;
1124
+ const baselineOwner = usesBaselineOwner ? this.domBaselines.get(baselineId ?? '') : null;
1127
1125
  const target = usesBaselineOwner
1128
1126
  ? baselineOwner &&
1129
1127
  (!targetBrowser || baselineOwner.__browserName === targetBrowser) &&
@@ -1167,13 +1165,14 @@ export class BridgeDaemon {
1167
1165
  method: request.method,
1168
1166
  protocolVersion: request.meta?.protocol_version,
1169
1167
  source: typeof request.meta?.source === 'string' ? request.meta.source : '',
1168
+ mcpEra: typeof request.meta?.mcp_era === 'string' ? request.meta.mcp_era : '',
1170
1169
  baselineId,
1171
1170
  automaticMcpRetry: isAutomaticMcpRetry(request.meta),
1172
1171
  targets: new Set([target]),
1173
1172
  timeoutId: setTimeout(() => {
1174
1173
  const pending = this.pendingRequests.get(request.id);
1175
1174
  if (!pending) return;
1176
- this.markAbandonedDomBaselineCreate(request.id, pending);
1175
+ this.domBaselines.markAbandonedCreate(request.id, pending);
1177
1176
  this.clearPendingRequest(request.id, pending);
1178
1177
  this.recordRequestCompletion(request.id, false, pending);
1179
1178
  const response = createFailure(
@@ -1272,85 +1271,6 @@ export class BridgeDaemon {
1272
1271
  return candidates[0]?.[1] ?? null;
1273
1272
  }
1274
1273
 
1275
- /** @param {string} baselineId */
1276
- getDomBaselineOwner(baselineId) {
1277
- this.pruneDomBaselineOwners();
1278
- const owner = this.domBaselineOwners.get(baselineId);
1279
- if (!owner || !owner.socket.__extensionId || !owner.socket.__accessEnabled) return null;
1280
- if (this.extensionSockets.get(owner.socket.__extensionId) !== owner.socket) {
1281
- this.domBaselineOwners.delete(baselineId);
1282
- return null;
1283
- }
1284
- return owner.socket;
1285
- }
1286
-
1287
- /** @param {string} baselineId @param {ClientSocket} socket @param {unknown} expiresAt */
1288
- registerDomBaselineOwner(baselineId, socket, expiresAt) {
1289
- this.pruneDomBaselineOwners();
1290
- const parsedExpiry = typeof expiresAt === 'string' ? Date.parse(expiresAt) : Number.NaN;
1291
- const now = Date.now();
1292
- if (
1293
- !/^baseline_[A-Za-z0-9_-]{32,64}$/u.test(baselineId) ||
1294
- !socket.__extensionId ||
1295
- !Number.isFinite(parsedExpiry) ||
1296
- parsedExpiry <= now ||
1297
- parsedExpiry > now + DOM_BASELINE_TTL_MS + 10_000
1298
- ) {
1299
- return false;
1300
- }
1301
- const existing = this.domBaselineOwners.get(baselineId);
1302
- if (existing && existing.socket !== socket) return false;
1303
- const maxOwnerMappings = MAX_DOM_BASELINES_GLOBAL * 4;
1304
- while (
1305
- !existing &&
1306
- [...this.domBaselineOwners.values()].filter((owner) => owner.socket === socket).length >=
1307
- maxOwnerMappings
1308
- ) {
1309
- const oldest = [...this.domBaselineOwners].find(([, owner]) => owner.socket === socket);
1310
- if (!oldest) break;
1311
- this.domBaselineOwners.delete(oldest[0]);
1312
- }
1313
- this.domBaselineOwners.set(baselineId, { socket, expiresAt: parsedExpiry });
1314
- return true;
1315
- }
1316
-
1317
- pruneDomBaselineOwners() {
1318
- const now = Date.now();
1319
- for (const [baselineId, owner] of this.domBaselineOwners) {
1320
- if (owner.expiresAt <= now) this.domBaselineOwners.delete(baselineId);
1321
- }
1322
- }
1323
-
1324
- /** @param {ClientSocket} socket */
1325
- clearDomBaselineOwnersForSocket(socket) {
1326
- for (const [baselineId, owner] of this.domBaselineOwners) {
1327
- if (owner.socket === socket) this.domBaselineOwners.delete(baselineId);
1328
- }
1329
- for (const [requestId, abandoned] of this.abandonedDomBaselineCreates) {
1330
- if (abandoned.socket === socket) this.abandonedDomBaselineCreates.delete(requestId);
1331
- }
1332
- }
1333
-
1334
- /** @param {string} requestId @param {PendingEntry} pending */
1335
- markAbandonedDomBaselineCreate(requestId, pending) {
1336
- if (pending.method !== 'dom.baseline.create') return;
1337
- const target = pending.targets.values().next().value;
1338
- if (!target) return;
1339
- const now = Date.now();
1340
- for (const [id, entry] of this.abandonedDomBaselineCreates) {
1341
- if (entry.expiresAt <= now) this.abandonedDomBaselineCreates.delete(id);
1342
- }
1343
- this.abandonedDomBaselineCreates.set(requestId, {
1344
- socket: target,
1345
- expiresAt: now + DOM_BASELINE_TTL_MS,
1346
- });
1347
- while (this.abandonedDomBaselineCreates.size > MAX_DOM_BASELINES_GLOBAL * 4) {
1348
- const oldestId = this.abandonedDomBaselineCreates.keys().next().value;
1349
- if (typeof oldestId !== 'string') break;
1350
- this.abandonedDomBaselineCreates.delete(oldestId);
1351
- }
1352
- }
1353
-
1354
1274
  /**
1355
1275
  * @param {ClientSocket} socket
1356
1276
  * @param {import('../../protocol/src/types.js').BridgeResponse} response
@@ -1361,7 +1281,7 @@ export class BridgeDaemon {
1361
1281
  ? /** @type {Record<string, unknown>} */ (response.result)
1362
1282
  : {};
1363
1283
  const baselineId = typeof result.baselineId === 'string' ? result.baselineId : '';
1364
- if (!/^baseline_[A-Za-z0-9_-]{32,64}$/u.test(baselineId)) return;
1284
+ if (!isValidBaselineId(baselineId)) return;
1365
1285
  const scope =
1366
1286
  result.scope && typeof result.scope === 'object'
1367
1287
  ? /** @type {Record<string, unknown>} */ (result.scope)
@@ -1455,7 +1375,7 @@ export class BridgeDaemon {
1455
1375
  const accessEnabled = Boolean(message.accessEnabled);
1456
1376
  if (!accessEnabled && socket.__extensionId) {
1457
1377
  this.artifactStore.deleteByExtension(socket.__extensionId);
1458
- this.clearDomBaselineOwnersForSocket(socket);
1378
+ this.domBaselines.clearForSocket(socket);
1459
1379
  }
1460
1380
  if (socket.__accessEnabled !== accessEnabled) {
1461
1381
  socket.__accessEnabled = accessEnabled;
@@ -1558,9 +1478,7 @@ export class BridgeDaemon {
1558
1478
 
1559
1479
  const pending = this.pendingRequests.get(responseMessage.id);
1560
1480
  if (!pending) {
1561
- const abandoned = this.abandonedDomBaselineCreates.get(responseMessage.id);
1562
- if (abandoned?.socket === socket) {
1563
- this.abandonedDomBaselineCreates.delete(responseMessage.id);
1481
+ if (this.domBaselines.takeAbandonedCreate(responseMessage.id, socket)) {
1564
1482
  if (responseMessage.ok) {
1565
1483
  await this.releaseOrphanDomBaseline(socket, responseMessage).catch((error) => {
1566
1484
  this.logger.error('orphan DOM baseline release failed', {
@@ -1618,13 +1536,10 @@ export class BridgeDaemon {
1618
1536
  const evicted = Array.isArray(result.evicted) ? result.evicted : [];
1619
1537
  for (const item of evicted) {
1620
1538
  if (item && typeof item === 'object' && 'baselineId' in item) {
1621
- const evictedId = String(item.baselineId);
1622
- if (this.domBaselineOwners.get(evictedId)?.socket === socket) {
1623
- this.domBaselineOwners.delete(evictedId);
1624
- }
1539
+ this.domBaselines.deleteIfOwnedBy(String(item.baselineId), socket);
1625
1540
  }
1626
1541
  }
1627
- if (!this.registerDomBaselineOwner(baselineId, socket, result.expiresAt)) {
1542
+ if (!this.domBaselines.register(baselineId, socket, result.expiresAt)) {
1628
1543
  await this.releaseOrphanDomBaseline(socket, responseMessage);
1629
1544
  pending.lastErrorResponse = createFailure(
1630
1545
  responseMessage.id,
@@ -1637,7 +1552,7 @@ export class BridgeDaemon {
1637
1552
  return;
1638
1553
  }
1639
1554
  } else if (pending.method === 'dom.baseline.release' && pending.baselineId) {
1640
- this.domBaselineOwners.delete(pending.baselineId);
1555
+ this.domBaselines.delete(pending.baselineId);
1641
1556
  }
1642
1557
  const response =
1643
1558
  pending.method === 'health.ping'
@@ -1674,7 +1589,7 @@ export class BridgeDaemon {
1674
1589
  if (pending.method === 'dom.baseline.create') {
1675
1590
  const baselineId =
1676
1591
  result && typeof result.baselineId === 'string' ? result.baselineId : null;
1677
- if (baselineId) this.domBaselineOwners.delete(baselineId);
1592
+ if (baselineId) this.domBaselines.delete(baselineId);
1678
1593
  await this.releaseOrphanDomBaseline(socket, responseMessage).catch(() => {});
1679
1594
  }
1680
1595
  this.clearPendingRequest(responseMessage.id, pending);
@@ -1689,13 +1604,14 @@ export class BridgeDaemon {
1689
1604
  ok: true,
1690
1605
  id: responseMessage.id,
1691
1606
  source: pending.source || null,
1607
+ ...(pending.mcpEra ? { mcpEra: pending.mcpEra } : {}),
1692
1608
  });
1693
1609
  return;
1694
1610
  }
1695
1611
 
1696
1612
  // A routed request has one target, so its error is final.
1697
1613
  if (pending.baselineId && responseMessage.error?.code === ERROR_CODES.DOM_BASELINE_NOT_FOUND) {
1698
- this.domBaselineOwners.delete(pending.baselineId);
1614
+ this.domBaselines.delete(pending.baselineId);
1699
1615
  }
1700
1616
  pending.lastErrorResponse = responseMessage;
1701
1617
 
@@ -1709,7 +1625,7 @@ export class BridgeDaemon {
1709
1625
  handleSocketClose(socket) {
1710
1626
  if (socket.__extensionId) {
1711
1627
  this.artifactStore.deleteByExtension(socket.__extensionId);
1712
- this.clearDomBaselineOwnersForSocket(socket);
1628
+ this.domBaselines.clearForSocket(socket);
1713
1629
  this.logger.info('extension disconnected', { extensionId: socket.__extensionId });
1714
1630
  if (this.extensionSockets.get(socket.__extensionId) === socket) {
1715
1631
  this.extensionSockets.delete(socket.__extensionId);
@@ -1731,7 +1647,7 @@ export class BridgeDaemon {
1731
1647
  if (!pending) {
1732
1648
  continue;
1733
1649
  }
1734
- this.markAbandonedDomBaselineCreate(id, pending);
1650
+ this.domBaselines.markAbandonedCreate(id, pending);
1735
1651
  this.clearPendingRequest(id, pending);
1736
1652
  this.recordRequestCompletion(id, false, pending);
1737
1653
  }
@@ -1788,6 +1704,7 @@ export class BridgeDaemon {
1788
1704
  ok: false,
1789
1705
  id: requestId,
1790
1706
  source: pending.source || null,
1707
+ ...(pending.mcpEra ? { mcpEra: pending.mcpEra } : {}),
1791
1708
  });
1792
1709
 
1793
1710
  await writeJsonLine(pending.socket, {
@@ -0,0 +1,210 @@
1
+ // @ts-check
2
+
3
+ import { DOM_BASELINE_TTL_MS, MAX_DOM_BASELINES_GLOBAL } from '../../protocol/src/index.js';
4
+
5
+ /**
6
+ * Ownership bookkeeping for retained semantic DOM baselines.
7
+ *
8
+ * A baseline lives in the extension's memory, but the daemon has to know which
9
+ * extension socket owns it so a later `compare`/`describe`/`release` is routed
10
+ * back to the same document, and so the state is dropped when that socket goes
11
+ * away. This module owns only that mapping and its expiry rules; sending the
12
+ * release request over the wire stays with the daemon, which owns transport.
13
+ *
14
+ * @typedef {import('./daemon.js').ClientSocket} ClientSocket
15
+ * @typedef {{ socket: ClientSocket, expiresAt: number }} BaselineOwner
16
+ */
17
+
18
+ /**
19
+ * Baseline IDs are minted by the extension. The daemon re-validates the shape
20
+ * before trusting one as a map key so a malformed response cannot grow state.
21
+ */
22
+ const BASELINE_ID_PATTERN = /^baseline_[A-Za-z0-9_-]{32,64}$/u;
23
+
24
+ /**
25
+ * Expiry timestamps are allowed a small skew over the nominal TTL to absorb
26
+ * clock jitter between the extension and the daemon.
27
+ */
28
+ const EXPIRY_SKEW_MS = 10_000;
29
+
30
+ /** Cap on tracked mappings per socket, and on abandoned-create entries. */
31
+ const MAX_OWNER_MAPPINGS = MAX_DOM_BASELINES_GLOBAL * 4;
32
+
33
+ /**
34
+ * @param {unknown} baselineId
35
+ * @returns {boolean}
36
+ */
37
+ export function isValidBaselineId(baselineId) {
38
+ return typeof baselineId === 'string' && BASELINE_ID_PATTERN.test(baselineId);
39
+ }
40
+
41
+ export class DomBaselineOwnerRegistry {
42
+ /**
43
+ * @param {{ isCurrentExtensionSocket: (socket: ClientSocket) => boolean }} options
44
+ */
45
+ constructor({ isCurrentExtensionSocket }) {
46
+ /** @type {Map<string, BaselineOwner>} */
47
+ this.owners = new Map();
48
+ /**
49
+ * Creates whose agent went away before the extension replied. The response
50
+ * still arrives later, and the baseline it reports has to be released.
51
+ * @type {Map<string, BaselineOwner>}
52
+ */
53
+ this.abandonedCreates = new Map();
54
+ this.isCurrentExtensionSocket = isCurrentExtensionSocket;
55
+ }
56
+
57
+ /** @returns {number} */
58
+ get size() {
59
+ return this.owners.size;
60
+ }
61
+
62
+ /** Drop every mapping whose TTL has passed. */
63
+ prune() {
64
+ const now = Date.now();
65
+ for (const [baselineId, owner] of this.owners) {
66
+ if (owner.expiresAt <= now) this.owners.delete(baselineId);
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Resolve the socket that owns a baseline, or null when the owner is gone,
72
+ * has lost access, or has been replaced by a newer socket for the same
73
+ * extension.
74
+ *
75
+ * @param {string} baselineId
76
+ * @returns {ClientSocket | null}
77
+ */
78
+ get(baselineId) {
79
+ this.prune();
80
+ const owner = this.owners.get(baselineId);
81
+ if (!owner || !owner.socket.__extensionId || !owner.socket.__accessEnabled) return null;
82
+ if (!this.isCurrentExtensionSocket(owner.socket)) {
83
+ this.owners.delete(baselineId);
84
+ return null;
85
+ }
86
+ return owner.socket;
87
+ }
88
+
89
+ /**
90
+ * Record ownership for a freshly created baseline.
91
+ *
92
+ * Returns false when the reported ID or expiry is unusable, or when another
93
+ * socket already claims the ID; the caller releases the orphan in that case.
94
+ *
95
+ * @param {string} baselineId
96
+ * @param {ClientSocket} socket
97
+ * @param {unknown} expiresAt
98
+ * @returns {boolean}
99
+ */
100
+ register(baselineId, socket, expiresAt) {
101
+ this.prune();
102
+ const parsedExpiry = typeof expiresAt === 'string' ? Date.parse(expiresAt) : Number.NaN;
103
+ const now = Date.now();
104
+ if (
105
+ !isValidBaselineId(baselineId) ||
106
+ !socket.__extensionId ||
107
+ !Number.isFinite(parsedExpiry) ||
108
+ parsedExpiry <= now ||
109
+ parsedExpiry > now + DOM_BASELINE_TTL_MS + EXPIRY_SKEW_MS
110
+ ) {
111
+ return false;
112
+ }
113
+ const existing = this.owners.get(baselineId);
114
+ if (existing && existing.socket !== socket) return false;
115
+ while (
116
+ !existing &&
117
+ [...this.owners.values()].filter((owner) => owner.socket === socket).length >=
118
+ MAX_OWNER_MAPPINGS
119
+ ) {
120
+ const oldest = [...this.owners].find(([, owner]) => owner.socket === socket);
121
+ if (!oldest) break;
122
+ this.owners.delete(oldest[0]);
123
+ }
124
+ this.owners.set(baselineId, { socket, expiresAt: parsedExpiry });
125
+ return true;
126
+ }
127
+
128
+ /**
129
+ * @param {string} baselineId
130
+ * @returns {boolean}
131
+ */
132
+ delete(baselineId) {
133
+ return this.owners.delete(baselineId);
134
+ }
135
+
136
+ /**
137
+ * Drop a mapping only when the given socket still owns it, so one extension
138
+ * cannot evict another's baseline by reporting its ID.
139
+ *
140
+ * @param {string} baselineId
141
+ * @param {ClientSocket} socket
142
+ * @returns {boolean}
143
+ */
144
+ deleteIfOwnedBy(baselineId, socket) {
145
+ if (this.owners.get(baselineId)?.socket !== socket) return false;
146
+ return this.owners.delete(baselineId);
147
+ }
148
+
149
+ /**
150
+ * Forget every baseline and abandoned create belonging to a socket.
151
+ *
152
+ * @param {ClientSocket} socket
153
+ */
154
+ clearForSocket(socket) {
155
+ for (const [baselineId, owner] of this.owners) {
156
+ if (owner.socket === socket) this.owners.delete(baselineId);
157
+ }
158
+ for (const [requestId, abandoned] of this.abandonedCreates) {
159
+ if (abandoned.socket === socket) this.abandonedCreates.delete(requestId);
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Remember a `dom.baseline.create` whose agent disconnected before the
165
+ * extension replied, so the eventual response can be released rather than
166
+ * leaking a retained snapshot in the page.
167
+ *
168
+ * @param {string} requestId
169
+ * @param {{ method?: string, targets: Set<ClientSocket> }} pending
170
+ */
171
+ markAbandonedCreate(requestId, pending) {
172
+ if (pending.method !== 'dom.baseline.create') return;
173
+ const target = pending.targets.values().next().value;
174
+ if (!target) return;
175
+ const now = Date.now();
176
+ for (const [id, entry] of this.abandonedCreates) {
177
+ if (entry.expiresAt <= now) this.abandonedCreates.delete(id);
178
+ }
179
+ this.abandonedCreates.set(requestId, {
180
+ socket: target,
181
+ expiresAt: now + DOM_BASELINE_TTL_MS,
182
+ });
183
+ while (this.abandonedCreates.size > MAX_OWNER_MAPPINGS) {
184
+ const oldestId = this.abandonedCreates.keys().next().value;
185
+ if (typeof oldestId !== 'string') break;
186
+ this.abandonedCreates.delete(oldestId);
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Claim an abandoned create for the socket that is answering it. Returns
192
+ * false when the request was not abandoned or belongs to another socket.
193
+ *
194
+ * @param {string} requestId
195
+ * @param {ClientSocket} socket
196
+ * @returns {boolean}
197
+ */
198
+ takeAbandonedCreate(requestId, socket) {
199
+ const abandoned = this.abandonedCreates.get(requestId);
200
+ if (abandoned?.socket !== socket) return false;
201
+ this.abandonedCreates.delete(requestId);
202
+ return true;
203
+ }
204
+
205
+ /** Drop all tracked state, used on daemon shutdown. */
206
+ clear() {
207
+ this.owners.clear();
208
+ this.abandonedCreates.clear();
209
+ }
210
+ }
@@ -5,7 +5,7 @@ import net from 'node:net';
5
5
  import {
6
6
  createFailure,
7
7
  ERROR_CODES,
8
- MAX_JSON_LINE_BYTES,
8
+ parseJsonLines,
9
9
  sanitizeIncidentalText,
10
10
  } from '../../protocol/src/index.js';
11
11
  import { readBridgeAuthToken } from './auth-token.js';
@@ -170,32 +170,10 @@ export async function runNativeHost({
170
170
  ...(authToken ? { authToken } : {}),
171
171
  });
172
172
 
173
- let lineBuffer = '';
174
- socket.on('data', (chunk) => {
175
- lineBuffer += chunk;
176
- if (!lineBuffer.includes('\n') && Buffer.byteLength(lineBuffer, 'utf8') > MAX_JSON_LINE_BYTES) {
177
- console.error(`native-host: daemon JSON line exceeded ${MAX_JSON_LINE_BYTES} bytes`);
178
- socket.destroy();
179
- return;
180
- }
181
- while (lineBuffer.includes('\n')) {
182
- const index = lineBuffer.indexOf('\n');
183
- const line = lineBuffer.slice(0, index).trim();
184
- lineBuffer = lineBuffer.slice(index + 1);
185
- if (!line) {
186
- continue;
187
- }
188
- if (Buffer.byteLength(line, 'utf8') > MAX_JSON_LINE_BYTES) {
189
- console.error(`native-host: daemon JSON line exceeded ${MAX_JSON_LINE_BYTES} bytes`);
190
- socket.destroy();
191
- return;
192
- }
193
- let message;
194
- try {
195
- message = JSON.parse(line);
196
- } catch {
197
- continue;
198
- }
173
+ parseJsonLines(
174
+ socket,
175
+ (raw) => {
176
+ const message = /** @type {Record<string, unknown>} */ (raw);
199
177
  void (async () => {
200
178
  if (message.type === 'extension.request') {
201
179
  await writeNativeMessageQueued(message.request);
@@ -228,8 +206,16 @@ export async function runNativeHost({
228
206
  err instanceof Error ? err.message : err
229
207
  );
230
208
  });
209
+ },
210
+ {
211
+ onProtocolError: (error) => {
212
+ console.error(`native-host: ${error.message}`);
213
+ },
214
+ onInvalidLine: (error) => {
215
+ console.error(`native-host: ignoring malformed daemon JSON line: ${error.message}`);
216
+ },
231
217
  }
232
- });
218
+ );
233
219
 
234
220
  createNativeMessageReader(
235
221
  process.stdin,