@browserbridge/bbx 1.8.0 → 1.9.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 (39) hide show
  1. package/PRIVACY.md +29 -3
  2. package/README.md +10 -3
  3. package/SECURITY.md +12 -0
  4. package/package.json +7 -1
  5. package/packages/agent-client/src/cli-args.js +35 -0
  6. package/packages/agent-client/src/cli-batch.js +121 -58
  7. package/packages/agent-client/src/cli-output.js +8 -1
  8. package/packages/agent-client/src/cli.js +227 -19
  9. package/packages/agent-client/src/client.js +156 -3
  10. package/packages/agent-client/src/command-registry.js +8 -2
  11. package/packages/agent-client/src/config.js +195 -0
  12. package/packages/agent-client/src/npm-self-update.js +305 -0
  13. package/packages/agent-client/src/remotes.js +4 -2
  14. package/packages/agent-client/src/runtime.js +97 -5
  15. package/packages/agent-client/src/types.ts +38 -0
  16. package/packages/mcp-server/src/handlers-capture.js +29 -2
  17. package/packages/mcp-server/src/handlers-dom.js +54 -3
  18. package/packages/mcp-server/src/handlers-page.js +180 -60
  19. package/packages/mcp-server/src/handlers-utils.js +35 -4
  20. package/packages/mcp-server/src/handlers.js +10 -3
  21. package/packages/mcp-server/src/server.js +75 -6
  22. package/packages/native-host/src/artifact-store.js +353 -0
  23. package/packages/native-host/src/config.js +9 -1
  24. package/packages/native-host/src/daemon-logger.js +3 -1
  25. package/packages/native-host/src/daemon.js +543 -27
  26. package/packages/native-host/src/native-host.js +25 -2
  27. package/packages/protocol/src/capability-values.js +2 -0
  28. package/packages/protocol/src/defaults.js +28 -0
  29. package/packages/protocol/src/errors.js +48 -5
  30. package/packages/protocol/src/incidental-sanitizer.js +197 -0
  31. package/packages/protocol/src/index.js +2 -0
  32. package/packages/protocol/src/protocol.js +316 -6
  33. package/packages/protocol/src/recovery-telemetry.js +302 -0
  34. package/packages/protocol/src/registry.js +143 -6
  35. package/packages/protocol/src/summary.js +116 -25
  36. package/packages/protocol/src/types.ts +489 -4
  37. package/skills/browser-bridge/SKILL.md +22 -8
  38. package/skills/browser-bridge/references/protocol.md +117 -13
  39. package/skills/browser-bridge/references/token-efficiency.md +17 -7
@@ -24,11 +24,16 @@ 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,
27
28
  ERROR_CODES,
28
29
  getProtocolVersion,
29
30
  getSupportedProtocolVersions,
30
31
  MAX_DAEMON_PENDING_TIMEOUT_MS,
32
+ MAX_DOM_BASELINES_GLOBAL,
31
33
  parseJsonLines,
34
+ RecoveryTelemetryCollector,
35
+ normalizeRecoveryTelemetrySummary,
36
+ sanitizeIncidentalValue,
32
37
  setProtocolPackageVersion,
33
38
  validateBridgeRequest,
34
39
  } from '../../protocol/src/index.js';
@@ -37,8 +42,10 @@ import {
37
42
  formatBridgeTransport,
38
43
  getBridgeListenTarget,
39
44
  getBridgeTransport,
45
+ getArtifactStorePath,
40
46
  getSocketPath,
41
47
  } from './config.js';
48
+ import { ArtifactStore } from './artifact-store.js';
42
49
  import { bridgeAuthTokensEqual, ensureBridgeAuthToken, readBridgeAuthToken } from './auth-token.js';
43
50
  import { normalizeDaemonLogger } from './daemon-logger.js';
44
51
  import { writeJsonLine } from './framing.js';
@@ -54,7 +61,7 @@ setProtocolPackageVersion(DAEMON_VERSION);
54
61
  /** @typedef {import('./daemon-logger.js').DaemonLoggerLike} DaemonLoggerLike */
55
62
  /** @typedef {'agent' | 'extension'} SocketRole */
56
63
  /** @typedef {import('node:net').Socket & { readonly __role?: SocketRole, __clientId?: string, __extensionId?: string, __browserName?: string, __profileLabel?: string, __browserExtensionId?: string, __accessEnabled?: boolean, __lastActiveAt?: number }} ClientSocket */
57
- /** @typedef {{ socket: ClientSocket, timeoutId: NodeJS.Timeout, source?: string, method?: string, protocolVersion?: string, targets: Set<ClientSocket>, lastErrorResponse?: import('../../protocol/src/types.js').BridgeResponse }} PendingEntry */
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 */
58
65
  /**
59
66
  * @typedef {{
60
67
  * installAgentFiles: typeof import('../../agent-client/src/install.js').installAgentFiles,
@@ -110,6 +117,13 @@ const HEALTH_DEBUGGER_REASONS = new Set([
110
117
  'target_closed',
111
118
  ]);
112
119
  const HEALTH_CAPTURE_STATES = new Set(['stopped', 'armed', 'active', 'stop_failed']);
120
+ const DIAGNOSTIC_METHODS = new Set([
121
+ 'health.ping',
122
+ 'daemon.metrics',
123
+ 'log.tail',
124
+ 'setup.get_status',
125
+ 'setup.install',
126
+ ]);
113
127
 
114
128
  /**
115
129
  * @param {unknown} value
@@ -146,6 +160,12 @@ function normalizeExtensionHealthResult(value) {
146
160
  /** @type {Record<string, unknown>} */
147
161
  const normalized = {};
148
162
  if (result.extension === 'ok') normalized.extension = 'ok';
163
+ if (
164
+ typeof result.extensionVersion === 'string' &&
165
+ /^\d+\.\d+\.\d+$/u.test(result.extensionVersion)
166
+ ) {
167
+ normalized.extensionVersion = result.extensionVersion;
168
+ }
149
169
 
150
170
  const access = asHealthRecord(result.access);
151
171
  if (access) {
@@ -216,6 +236,49 @@ function normalizeExtensionHealthResult(value) {
216
236
  normalized.capture = normalizedCapture;
217
237
  }
218
238
 
239
+ const domBaselines = asHealthRecord(result.domBaselines);
240
+ if (domBaselines) {
241
+ /** @type {Record<string, unknown>} */
242
+ const normalizedBaselines = {};
243
+ for (const key of ['baselineCount', 'bytes', 'tabCount']) {
244
+ const count = normalizeHealthCount(domBaselines[key]);
245
+ if (count !== undefined) normalizedBaselines[key] = count;
246
+ }
247
+ const operationSource = asHealthRecord(domBaselines.operations);
248
+ if (operationSource) {
249
+ /** @type {Record<string, unknown>} */
250
+ const operations = {};
251
+ for (const name of [
252
+ 'create',
253
+ 'get',
254
+ 'compare',
255
+ 'release',
256
+ 'invalidate',
257
+ 'clearTab',
258
+ 'clearWindow',
259
+ 'clearAll',
260
+ 'invalidateNavigation',
261
+ 'pruneExpired',
262
+ ]) {
263
+ const source = asHealthRecord(operationSource[name]);
264
+ if (!source) continue;
265
+ const calls = normalizeHealthCount(source.calls);
266
+ const totalLatencyMs = normalizeHealthCount(source.totalLatencyMs);
267
+ const maxLatencyMs = normalizeHealthCount(source.maxLatencyMs);
268
+ operations[name] = {
269
+ ...(calls === undefined ? {} : { calls }),
270
+ ...(totalLatencyMs === undefined ? {} : { totalLatencyMs }),
271
+ ...(maxLatencyMs === undefined ? {} : { maxLatencyMs }),
272
+ };
273
+ }
274
+ normalizedBaselines.operations = operations;
275
+ }
276
+ normalized.domBaselines = normalizedBaselines;
277
+ }
278
+
279
+ const recovery = normalizeRecoveryTelemetrySummary(result.recovery, 'routedExtension');
280
+ if (recovery) normalized.recovery = recovery;
281
+
219
282
  const extensionSupportedVersions = Array.isArray(result.supported_versions)
220
283
  ? result.supported_versions
221
284
  .filter(
@@ -230,6 +293,19 @@ function normalizeExtensionHealthResult(value) {
230
293
  return normalized;
231
294
  }
232
295
 
296
+ /**
297
+ * @param {import('../../protocol/src/types.js').RecoveryTelemetrySummary} daemon
298
+ * @param {unknown} extensionResult
299
+ */
300
+ function createHealthRecoveryPayload(daemon, extensionResult) {
301
+ const source = asHealthRecord(extensionResult);
302
+ const routedExtension = normalizeRecoveryTelemetrySummary(source?.recovery, 'routedExtension');
303
+ return {
304
+ daemon,
305
+ ...(routedExtension ? { routedExtension } : {}),
306
+ };
307
+ }
308
+
233
309
  /**
234
310
  * @param {string | undefined} requestedVersion
235
311
  * @returns {{ supported_versions: readonly string[], deprecated_since?: string, migration_hint?: string }}
@@ -289,9 +365,15 @@ export function isWindowsNamedPipePath(socketPath) {
289
365
  * accessEnabled?: boolean,
290
366
  * authToken?: string,
291
367
  * at?: number
368
+ * artifactId?: string,
369
+ * artifact?: Record<string, unknown>,
370
+ * chunkIndex?: number,
371
+ * data?: string
292
372
  * }} DaemonMessage
293
373
  */
294
374
 
375
+ /** @typedef {import('../../protocol/src/types.js').ArtifactKind} ArtifactKind */
376
+
295
377
  export class BridgeDaemon {
296
378
  /**
297
379
  * @param {{
@@ -301,7 +383,8 @@ export class BridgeDaemon {
301
383
  * setupStatusLoader?: () => Promise<SetupStatus>,
302
384
  * setupInstaller?: (params: Record<string, unknown>) => Promise<SetupInstallResult>,
303
385
  * logger?: DaemonLoggerLike | Pick<Console, 'log' | 'error'>,
304
- * authToken?: string | null
386
+ * authToken?: string | null,
387
+ * artifactStore?: ArtifactStore
305
388
  * }} [options={}]
306
389
  */
307
390
  constructor({
@@ -312,6 +395,7 @@ export class BridgeDaemon {
312
395
  setupInstaller = installSetupTarget,
313
396
  logger = undefined,
314
397
  authToken = undefined,
398
+ artifactStore = new ArtifactStore(getArtifactStorePath()),
315
399
  } = {}) {
316
400
  this.transport = socketPath ? createSocketBridgeTransport(socketPath) : transport;
317
401
  this.socketPath =
@@ -354,6 +438,13 @@ export class BridgeDaemon {
354
438
  this.requestStartTimes = new Map();
355
439
  /** @type {string | null | undefined} */
356
440
  this.authToken = authToken;
441
+ this.artifactStore = artifactStore;
442
+ 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();
447
+ this.recoveryTelemetry = new RecoveryTelemetryCollector();
357
448
  }
358
449
 
359
450
  /**
@@ -421,11 +512,17 @@ export class BridgeDaemon {
421
512
  * @param {PendingEntry | undefined} [pending]
422
513
  * @returns {PendingEntry | undefined}
423
514
  */
424
- clearPendingRequest(requestId, pending = this.pendingRequests.get(requestId)) {
515
+ clearPendingRequest(
516
+ requestId,
517
+ pending = this.pendingRequests.get(requestId),
518
+ preserveArtifacts = false
519
+ ) {
425
520
  if (!pending) {
426
521
  return undefined;
427
522
  }
428
523
  clearTimeout(pending.timeoutId);
524
+ if (preserveArtifacts) this.artifactStore.abortRequest(requestId);
525
+ else this.artifactStore.deleteRequest(requestId);
429
526
  this.pendingRequests.delete(requestId);
430
527
  this.removePendingRequestIndex(this.pendingRequestsByOwnerSocket, pending.socket, requestId);
431
528
  for (const targetSocket of pending.targets) {
@@ -618,6 +715,9 @@ export class BridgeDaemon {
618
715
  await fs.promises.chmod(this.socketPath, 0o600);
619
716
  }
620
717
 
718
+ this.artifactStore.reset();
719
+ this.artifactStoreInitialized = true;
720
+
621
721
  this.logger.info('Daemon listening', {
622
722
  transport: formatBridgeTransport(this.transport),
623
723
  socketPath: this.socketPath ?? null,
@@ -665,6 +765,12 @@ export class BridgeDaemon {
665
765
  }
666
766
  this.extensionSockets.clear();
667
767
  this.invalidateConnectedExtensionsCache();
768
+ this.domBaselineOwners.clear();
769
+ this.abandonedDomBaselineCreates.clear();
770
+ if (this.artifactStoreInitialized) {
771
+ this.artifactStore.reset();
772
+ this.artifactStoreInitialized = false;
773
+ }
668
774
 
669
775
  if (this.server) {
670
776
  const server = this.server;
@@ -737,6 +843,13 @@ export class BridgeDaemon {
737
843
  return this.handleExtensionActivity(socket, message);
738
844
  }
739
845
 
846
+ if (message?.type?.startsWith('extension.artifact.')) {
847
+ if (socket.__role !== 'extension') {
848
+ return this.rejectMessageForRole(socket, message);
849
+ }
850
+ return this.handleExtensionArtifact(socket, message);
851
+ }
852
+
740
853
  if (message?.type === 'extension.setup_status.request') {
741
854
  if (socket.__role !== 'extension') {
742
855
  return this.rejectMessageForRole(socket, message);
@@ -745,11 +858,12 @@ export class BridgeDaemon {
745
858
  }
746
859
 
747
860
  if (message?.type === 'agent.request') {
748
- const isExtensionSetupRequest =
861
+ const isExtensionLocalRequest =
749
862
  socket.__role === 'extension' &&
750
- (message.request?.method === 'setup.get_status' ||
863
+ (message.request?.method === 'health.ping' ||
864
+ message.request?.method === 'setup.get_status' ||
751
865
  message.request?.method === 'setup.install');
752
- if (socket.__role !== 'agent' && !isExtensionSetupRequest) {
866
+ if (socket.__role !== 'agent' && !isExtensionLocalRequest) {
753
867
  return this.rejectMessageForRole(socket, message);
754
868
  }
755
869
  return this.handleAgentRequest(socket, message);
@@ -848,6 +962,7 @@ export class BridgeDaemon {
848
962
  `Request id ${JSON.stringify(request.id)} is already in flight.`
849
963
  );
850
964
  await writeJsonLine(socket, { type: 'agent.response', response });
965
+ this.recordDaemonRequestOutcome(request, false);
851
966
  return;
852
967
  }
853
968
 
@@ -861,6 +976,9 @@ export class BridgeDaemon {
861
976
  transport: formatBridgeTransport(this.transport),
862
977
  proxy: this.getProxyStatusPayload(),
863
978
  connectedExtensions: [],
979
+ recovery: {
980
+ daemon: this.recoveryTelemetry.snapshot('daemon'),
981
+ },
864
982
  daemon_supported_versions: getSupportedProtocolVersions(),
865
983
  ...getVersionNegotiationPayload(request.meta?.protocol_version),
866
984
  });
@@ -880,6 +998,7 @@ export class BridgeDaemon {
880
998
  }
881
999
 
882
1000
  if (request.method === 'daemon.metrics') {
1001
+ this.pruneDomBaselineOwners();
883
1002
  const now = Date.now();
884
1003
  const uptimeMs = this.startedAt > 0 ? now - this.startedAt : 0;
885
1004
  const avgResponseTimeMs =
@@ -894,11 +1013,62 @@ export class BridgeDaemon {
894
1013
  requestsProcessed: this.requestsProcessed,
895
1014
  requestsFailed: this.requestsFailed,
896
1015
  avgResponseTimeMs,
1016
+ domBaselineOwners: this.domBaselineOwners.size,
1017
+ recovery: this.recoveryTelemetry.snapshot('daemon'),
897
1018
  });
898
1019
  await writeJsonLine(socket, { type: 'agent.response', response });
899
1020
  return;
900
1021
  }
901
1022
 
1023
+ if (request.method === 'artifact.read' || request.method === 'artifact.delete') {
1024
+ const ownerId = socket.__clientId;
1025
+ if (!ownerId) {
1026
+ const response = createFailure(
1027
+ request.id,
1028
+ ERROR_CODES.ACCESS_DENIED,
1029
+ 'Artifact access requires a registered agent client.',
1030
+ null,
1031
+ { method: request.method }
1032
+ );
1033
+ await writeJsonLine(socket, { type: 'agent.response', response });
1034
+ this.recordDaemonRequestOutcome(request, false);
1035
+ return;
1036
+ }
1037
+ try {
1038
+ const result =
1039
+ request.method === 'artifact.read'
1040
+ ? this.artifactStore.read(
1041
+ String(request.params.artifactId),
1042
+ ownerId,
1043
+ Number(request.params.offset),
1044
+ Number(request.params.maxBytes)
1045
+ )
1046
+ : this.artifactStore.delete(String(request.params.artifactId), ownerId);
1047
+ await writeJsonLine(socket, {
1048
+ type: 'agent.response',
1049
+ response: createSuccess(request.id, result, { method: request.method }),
1050
+ });
1051
+ this.recordDaemonRequestOutcome(request, true);
1052
+ } catch (error) {
1053
+ const code =
1054
+ error && typeof error === 'object' && 'code' in error
1055
+ ? /** @type {import('../../protocol/src/types.js').ErrorCode} */ (error.code)
1056
+ : ERROR_CODES.INTERNAL_ERROR;
1057
+ await writeJsonLine(socket, {
1058
+ type: 'agent.response',
1059
+ response: createFailure(
1060
+ request.id,
1061
+ code,
1062
+ error instanceof Error ? error.message : String(error),
1063
+ null,
1064
+ { method: request.method }
1065
+ ),
1066
+ });
1067
+ this.recordDaemonRequestOutcome(request, false);
1068
+ }
1069
+ return;
1070
+ }
1071
+
902
1072
  if (request.method === 'setup.get_status') {
903
1073
  const response = createSuccess(request.id, await this.setupStatusLoader(), {
904
1074
  method: request.method,
@@ -947,17 +1117,48 @@ export class BridgeDaemon {
947
1117
  typeof request.meta?.target_profile === 'string' ? request.meta.target_profile : null;
948
1118
  const hasExplicitTarget = Boolean(targetBrowser || targetProfile);
949
1119
 
950
- const target = this.selectExtensionTarget(targetBrowser, targetProfile);
1120
+ const baselineId =
1121
+ typeof request.params.baselineId === 'string' ? request.params.baselineId : null;
1122
+ const usesBaselineOwner =
1123
+ request.method === 'dom.baseline.compare' ||
1124
+ request.method === 'dom.baseline.describe' ||
1125
+ request.method === 'dom.baseline.release';
1126
+ const baselineOwner = usesBaselineOwner ? this.getDomBaselineOwner(baselineId ?? '') : null;
1127
+ const target = usesBaselineOwner
1128
+ ? baselineOwner &&
1129
+ (!targetBrowser || baselineOwner.__browserName === targetBrowser) &&
1130
+ (!targetProfile || baselineOwner.__profileLabel === targetProfile)
1131
+ ? baselineOwner
1132
+ : null
1133
+ : this.selectExtensionTarget(targetBrowser, targetProfile);
951
1134
 
952
1135
  if (!target) {
1136
+ if (usesBaselineOwner) {
1137
+ const response =
1138
+ request.method === 'dom.baseline.release'
1139
+ ? createSuccess(request.id, { baselineId, released: false }, { method: request.method })
1140
+ : createFailure(
1141
+ request.id,
1142
+ ERROR_CODES.DOM_BASELINE_NOT_FOUND,
1143
+ 'DOM baseline is missing, expired, released, or its browser destination disconnected.',
1144
+ { baselineId },
1145
+ { method: request.method }
1146
+ );
1147
+ await writeJsonLine(socket, { type: 'agent.response', response });
1148
+ this.recordDaemonRequestOutcome(request, response.ok);
1149
+ return;
1150
+ }
953
1151
  const response = createFailure(
954
1152
  request.id,
955
1153
  ERROR_CODES.EXTENSION_DISCONNECTED,
956
1154
  hasExplicitTarget
957
1155
  ? `No connected extension matches target_browser="${targetBrowser ?? '*'}" target_profile="${targetProfile ?? '*'}".`
958
- : 'The Chrome extension is not connected to the local bridge daemon.'
1156
+ : 'The Chrome extension is not connected to the local bridge daemon.',
1157
+ null,
1158
+ { method: request.method }
959
1159
  );
960
1160
  await writeJsonLine(socket, { type: 'agent.response', response });
1161
+ this.recordDaemonRequestOutcome(request, false);
961
1162
  return;
962
1163
  }
963
1164
 
@@ -966,16 +1167,21 @@ export class BridgeDaemon {
966
1167
  method: request.method,
967
1168
  protocolVersion: request.meta?.protocol_version,
968
1169
  source: typeof request.meta?.source === 'string' ? request.meta.source : '',
1170
+ baselineId,
1171
+ automaticMcpRetry: isAutomaticMcpRetry(request.meta),
969
1172
  targets: new Set([target]),
970
1173
  timeoutId: setTimeout(() => {
971
1174
  const pending = this.pendingRequests.get(request.id);
972
1175
  if (!pending) return;
1176
+ this.markAbandonedDomBaselineCreate(request.id, pending);
973
1177
  this.clearPendingRequest(request.id, pending);
974
- this.recordRequestCompletion(request.id, false);
1178
+ this.recordRequestCompletion(request.id, false, pending);
975
1179
  const response = createFailure(
976
1180
  request.id,
977
1181
  ERROR_CODES.TIMEOUT,
978
- 'Extension did not respond in time.'
1182
+ 'Extension did not respond in time.',
1183
+ null,
1184
+ { method: pending.method }
979
1185
  );
980
1186
  void writeJsonLine(pending.socket, {
981
1187
  type: 'agent.response',
@@ -1066,6 +1272,113 @@ export class BridgeDaemon {
1066
1272
  return candidates[0]?.[1] ?? null;
1067
1273
  }
1068
1274
 
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
+ /**
1355
+ * @param {ClientSocket} socket
1356
+ * @param {import('../../protocol/src/types.js').BridgeResponse} response
1357
+ */
1358
+ async releaseOrphanDomBaseline(socket, response) {
1359
+ const result =
1360
+ response.result && typeof response.result === 'object'
1361
+ ? /** @type {Record<string, unknown>} */ (response.result)
1362
+ : {};
1363
+ const baselineId = typeof result.baselineId === 'string' ? result.baselineId : '';
1364
+ if (!/^baseline_[A-Za-z0-9_-]{32,64}$/u.test(baselineId)) return;
1365
+ const scope =
1366
+ result.scope && typeof result.scope === 'object'
1367
+ ? /** @type {Record<string, unknown>} */ (result.scope)
1368
+ : {};
1369
+ const tabId = Number.isSafeInteger(scope.tabId) ? Number(scope.tabId) : null;
1370
+ await writeJsonLine(socket, {
1371
+ type: 'extension.request',
1372
+ request: {
1373
+ id: `orphan_release_${randomUUID().replaceAll('-', '')}`,
1374
+ method: 'dom.baseline.release',
1375
+ tab_id: tabId,
1376
+ params: { baselineId },
1377
+ meta: { protocol_version: getProtocolVersion(), token_budget: null },
1378
+ },
1379
+ });
1380
+ }
1381
+
1069
1382
  /**
1070
1383
  * @param {BridgeRequest} request
1071
1384
  * @returns {number}
@@ -1140,6 +1453,10 @@ export class BridgeDaemon {
1140
1453
  */
1141
1454
  handleExtensionAccessUpdate(socket, message) {
1142
1455
  const accessEnabled = Boolean(message.accessEnabled);
1456
+ if (!accessEnabled && socket.__extensionId) {
1457
+ this.artifactStore.deleteByExtension(socket.__extensionId);
1458
+ this.clearDomBaselineOwnersForSocket(socket);
1459
+ }
1143
1460
  if (socket.__accessEnabled !== accessEnabled) {
1144
1461
  socket.__accessEnabled = accessEnabled;
1145
1462
  this.invalidateConnectedExtensionsCache();
@@ -1148,6 +1465,58 @@ export class BridgeDaemon {
1148
1465
  socket.__accessEnabled = accessEnabled;
1149
1466
  }
1150
1467
 
1468
+ /**
1469
+ * @param {ClientSocket} socket
1470
+ * @param {DaemonMessage} message
1471
+ * @returns {void}
1472
+ */
1473
+ handleExtensionArtifact(socket, message) {
1474
+ const artifact =
1475
+ message.artifact && typeof message.artifact === 'object' ? message.artifact : {};
1476
+ const requestId = typeof artifact.requestId === 'string' ? artifact.requestId : '';
1477
+ const pending = this.pendingRequests.get(requestId);
1478
+ if (!pending || !pending.targets.has(socket) || !socket.__extensionId) return;
1479
+ const ownerId = pending.socket.__clientId;
1480
+ if (!ownerId) return;
1481
+ try {
1482
+ if (message.type === 'extension.artifact.begin') {
1483
+ this.artifactStore.begin({
1484
+ artifactId: String(artifact.artifactId ?? ''),
1485
+ requestId,
1486
+ ownerId,
1487
+ extensionId: socket.__extensionId,
1488
+ kind: /** @type {ArtifactKind} */ (artifact.kind),
1489
+ mimeType: String(artifact.mimeType ?? ''),
1490
+ totalBytes: Number(artifact.byteLength),
1491
+ sha256: String(artifact.sha256 ?? ''),
1492
+ chunkCount: Number(artifact.chunkCount),
1493
+ createdAt: String(artifact.createdAt ?? ''),
1494
+ expiresAt: String(artifact.expiresAt ?? ''),
1495
+ });
1496
+ } else if (message.type === 'extension.artifact.chunk') {
1497
+ this.artifactStore.writeChunk(
1498
+ String(message.artifactId ?? ''),
1499
+ Number(message.chunkIndex),
1500
+ String(message.data ?? '')
1501
+ );
1502
+ } else if (message.type === 'extension.artifact.commit') {
1503
+ this.artifactStore.commit(String(message.artifactId ?? ''));
1504
+ }
1505
+ } catch (error) {
1506
+ const code =
1507
+ error && typeof error === 'object' && 'code' in error
1508
+ ? /** @type {import('../../protocol/src/types.js').ErrorCode} */ (error.code)
1509
+ : ERROR_CODES.ARTIFACT_TRANSFER_INVALID;
1510
+ pending.lastErrorResponse = createFailure(
1511
+ requestId,
1512
+ code,
1513
+ error instanceof Error ? error.message : String(error),
1514
+ null,
1515
+ { method: pending.method }
1516
+ );
1517
+ }
1518
+ }
1519
+
1151
1520
  /**
1152
1521
  * Structured remote-exposure status for health payloads. The proxy counts
1153
1522
  * as enabled only when the daemon listens on a TCP address other than
@@ -1188,22 +1557,88 @@ export class BridgeDaemon {
1188
1557
  }
1189
1558
 
1190
1559
  const pending = this.pendingRequests.get(responseMessage.id);
1191
- if (!pending || !pending.targets.has(socket)) {
1560
+ if (!pending) {
1561
+ const abandoned = this.abandonedDomBaselineCreates.get(responseMessage.id);
1562
+ if (abandoned?.socket === socket) {
1563
+ this.abandonedDomBaselineCreates.delete(responseMessage.id);
1564
+ if (responseMessage.ok) {
1565
+ await this.releaseOrphanDomBaseline(socket, responseMessage).catch((error) => {
1566
+ this.logger.error('orphan DOM baseline release failed', {
1567
+ requestId: responseMessage.id,
1568
+ message: error instanceof Error ? error.message : String(error),
1569
+ });
1570
+ });
1571
+ }
1572
+ }
1573
+ return;
1574
+ }
1575
+ if (!pending.targets.has(socket)) {
1192
1576
  return;
1193
1577
  }
1194
1578
 
1195
1579
  this.removePendingTarget(responseMessage.id, pending, socket);
1196
1580
 
1197
1581
  if (responseMessage.ok) {
1198
- this.clearPendingRequest(responseMessage.id, pending);
1199
- this.recordRequestCompletion(responseMessage.id, true);
1200
- this.pushLog({
1201
- at: new Date().toISOString(),
1202
- method: responseMessage.meta?.method ?? null,
1203
- ok: true,
1204
- id: responseMessage.id,
1205
- source: pending.source || null,
1206
- });
1582
+ const result =
1583
+ responseMessage.result && typeof responseMessage.result === 'object'
1584
+ ? /** @type {Record<string, unknown>} */ (responseMessage.result)
1585
+ : {};
1586
+ const artifact =
1587
+ result.artifact && typeof result.artifact === 'object'
1588
+ ? /** @type {Record<string, unknown>} */ (result.artifact)
1589
+ : null;
1590
+ const expectedArtifactKind = getArtifactKindForMethod(pending.method);
1591
+ if (
1592
+ result.delivery === 'artifact' &&
1593
+ (!artifact ||
1594
+ artifact.kind !== expectedArtifactKind ||
1595
+ !pending.socket.__clientId ||
1596
+ !this.artifactStore.matchesCommitted(
1597
+ String(artifact.artifactId ?? ''),
1598
+ pending.socket.__clientId,
1599
+ responseMessage.id,
1600
+ artifact,
1601
+ expectedArtifactKind ?? undefined
1602
+ ))
1603
+ ) {
1604
+ pending.lastErrorResponse ??= createFailure(
1605
+ responseMessage.id,
1606
+ ERROR_CODES.ARTIFACT_TRANSFER_INVALID,
1607
+ 'Artifact metadata does not match the request or was not committed before the response.',
1608
+ null,
1609
+ { method: pending.method }
1610
+ );
1611
+ }
1612
+ if (pending.lastErrorResponse) {
1613
+ await this.finishPendingRequestIfExhausted(responseMessage.id, pending);
1614
+ return;
1615
+ }
1616
+ if (pending.method === 'dom.baseline.create') {
1617
+ const baselineId = typeof result.baselineId === 'string' ? result.baselineId : '';
1618
+ const evicted = Array.isArray(result.evicted) ? result.evicted : [];
1619
+ for (const item of evicted) {
1620
+ 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
+ }
1625
+ }
1626
+ }
1627
+ if (!this.registerDomBaselineOwner(baselineId, socket, result.expiresAt)) {
1628
+ await this.releaseOrphanDomBaseline(socket, responseMessage);
1629
+ pending.lastErrorResponse = createFailure(
1630
+ responseMessage.id,
1631
+ ERROR_CODES.DOM_BASELINE_INVALIDATED,
1632
+ 'DOM baseline owner metadata is invalid.',
1633
+ null,
1634
+ { method: pending.method }
1635
+ );
1636
+ await this.finishPendingRequestIfExhausted(responseMessage.id, pending);
1637
+ return;
1638
+ }
1639
+ } else if (pending.method === 'dom.baseline.release' && pending.baselineId) {
1640
+ this.domBaselineOwners.delete(pending.baselineId);
1641
+ }
1207
1642
  const response =
1208
1643
  pending.method === 'health.ping'
1209
1644
  ? createSuccess(
@@ -1219,6 +1654,10 @@ export class BridgeDaemon {
1219
1654
  daemonVersion: DAEMON_VERSION,
1220
1655
  daemon_supported_versions: getSupportedProtocolVersions(),
1221
1656
  proxy: this.getProxyStatusPayload(),
1657
+ recovery: createHealthRecoveryPayload(
1658
+ this.recoveryTelemetry.snapshot('daemon'),
1659
+ responseMessage.result
1660
+ ),
1222
1661
  },
1223
1662
  {
1224
1663
  method: pending.method,
@@ -1226,14 +1665,38 @@ export class BridgeDaemon {
1226
1665
  )
1227
1666
  : responseMessage;
1228
1667
 
1229
- await writeJsonLine(pending.socket, {
1230
- type: 'agent.response',
1231
- response,
1668
+ try {
1669
+ await writeJsonLine(pending.socket, {
1670
+ type: 'agent.response',
1671
+ response,
1672
+ });
1673
+ } catch (error) {
1674
+ if (pending.method === 'dom.baseline.create') {
1675
+ const baselineId =
1676
+ result && typeof result.baselineId === 'string' ? result.baselineId : null;
1677
+ if (baselineId) this.domBaselineOwners.delete(baselineId);
1678
+ await this.releaseOrphanDomBaseline(socket, responseMessage).catch(() => {});
1679
+ }
1680
+ this.clearPendingRequest(responseMessage.id, pending);
1681
+ this.recordRequestCompletion(responseMessage.id, false, pending);
1682
+ throw error;
1683
+ }
1684
+ this.clearPendingRequest(responseMessage.id, pending, true);
1685
+ this.recordRequestCompletion(responseMessage.id, true, pending);
1686
+ this.pushLog({
1687
+ at: new Date().toISOString(),
1688
+ method: responseMessage.meta?.method ?? null,
1689
+ ok: true,
1690
+ id: responseMessage.id,
1691
+ source: pending.source || null,
1232
1692
  });
1233
1693
  return;
1234
1694
  }
1235
1695
 
1236
1696
  // A routed request has one target, so its error is final.
1697
+ if (pending.baselineId && responseMessage.error?.code === ERROR_CODES.DOM_BASELINE_NOT_FOUND) {
1698
+ this.domBaselineOwners.delete(pending.baselineId);
1699
+ }
1237
1700
  pending.lastErrorResponse = responseMessage;
1238
1701
 
1239
1702
  await this.finishPendingRequestIfExhausted(responseMessage.id, pending);
@@ -1245,6 +1708,8 @@ export class BridgeDaemon {
1245
1708
  */
1246
1709
  handleSocketClose(socket) {
1247
1710
  if (socket.__extensionId) {
1711
+ this.artifactStore.deleteByExtension(socket.__extensionId);
1712
+ this.clearDomBaselineOwnersForSocket(socket);
1248
1713
  this.logger.info('extension disconnected', { extensionId: socket.__extensionId });
1249
1714
  if (this.extensionSockets.get(socket.__extensionId) === socket) {
1250
1715
  this.extensionSockets.delete(socket.__extensionId);
@@ -1266,8 +1731,9 @@ export class BridgeDaemon {
1266
1731
  if (!pending) {
1267
1732
  continue;
1268
1733
  }
1734
+ this.markAbandonedDomBaselineCreate(id, pending);
1269
1735
  this.clearPendingRequest(id, pending);
1270
- this.recordRequestCompletion(id, false);
1736
+ this.recordRequestCompletion(id, false, pending);
1271
1737
  }
1272
1738
  }
1273
1739
 
@@ -1304,7 +1770,7 @@ export class BridgeDaemon {
1304
1770
  }
1305
1771
 
1306
1772
  this.clearPendingRequest(requestId, pending);
1307
- this.recordRequestCompletion(requestId, false);
1773
+ this.recordRequestCompletion(requestId, false, pending);
1308
1774
 
1309
1775
  const response =
1310
1776
  pending.lastErrorResponse ??
@@ -1333,9 +1799,10 @@ export class BridgeDaemon {
1333
1799
  /**
1334
1800
  * @param {string} requestId
1335
1801
  * @param {boolean} ok
1802
+ * @param {PendingEntry | undefined} [pending]
1336
1803
  * @returns {void}
1337
1804
  */
1338
- recordRequestCompletion(requestId, ok) {
1805
+ recordRequestCompletion(requestId, ok, pending) {
1339
1806
  const startedAt = this.requestStartTimes.get(requestId);
1340
1807
  this.requestStartTimes.delete(requestId);
1341
1808
  this.requestsProcessed += 1;
@@ -1345,6 +1812,34 @@ export class BridgeDaemon {
1345
1812
  if (typeof startedAt === 'number') {
1346
1813
  this.totalResponseTimeMs += Date.now() - startedAt;
1347
1814
  }
1815
+ if (pending?.method && !DIAGNOSTIC_METHODS.has(pending.method)) {
1816
+ this.recordDaemonRequestOutcome(
1817
+ {
1818
+ method: /** @type {BridgeRequest['method']} */ (pending.method),
1819
+ meta: {
1820
+ protocol_version: pending.protocolVersion ?? getProtocolVersion(),
1821
+ token_budget: null,
1822
+ ...(pending.automaticMcpRetry
1823
+ ? { source: 'mcp', automatic_retry: { attempt: 2, reason: 'retryable_error' } }
1824
+ : {}),
1825
+ },
1826
+ },
1827
+ ok
1828
+ );
1829
+ }
1830
+ }
1831
+
1832
+ /**
1833
+ * @param {Pick<BridgeRequest, 'method' | 'meta'>} request
1834
+ * @param {boolean} ok
1835
+ */
1836
+ recordDaemonRequestOutcome(request, ok) {
1837
+ if (DIAGNOSTIC_METHODS.has(request.method)) return;
1838
+ const outcome = ok ? 'success' : 'failure';
1839
+ this.recoveryTelemetry.record('request_outcome', outcome, request.method);
1840
+ if (isAutomaticMcpRetry(request.meta)) {
1841
+ this.recoveryTelemetry.record('automatic_mcp_retry', outcome, request.method);
1842
+ }
1348
1843
  }
1349
1844
 
1350
1845
  /**
@@ -1352,13 +1847,34 @@ export class BridgeDaemon {
1352
1847
  * @returns {void}
1353
1848
  */
1354
1849
  pushLog(entry) {
1355
- this.recentLog.push(entry);
1850
+ this.recentLog.push(/** @type {Record<string, unknown>} */ (sanitizeIncidentalValue(entry)));
1356
1851
  if (this.recentLog.length > DAEMON_RECENT_LOG_LIMIT) {
1357
1852
  this.recentLog.shift();
1358
1853
  }
1359
1854
  }
1360
1855
  }
1361
1856
 
1857
+ /** @param {Record<string, unknown>} meta */
1858
+ function isAutomaticMcpRetry(meta) {
1859
+ const marker = asHealthRecord(meta.automatic_retry);
1860
+ if (meta.source !== 'mcp' || !marker) return false;
1861
+ const keys = Object.keys(marker).sort();
1862
+ return (
1863
+ keys.length === 2 &&
1864
+ keys[0] === 'attempt' &&
1865
+ keys[1] === 'reason' &&
1866
+ marker.attempt === 2 &&
1867
+ marker.reason === 'retryable_error'
1868
+ );
1869
+ }
1870
+
1871
+ /** @param {string | undefined} method @returns {ArtifactKind | null} */
1872
+ function getArtifactKindForMethod(method) {
1873
+ if (method?.startsWith('screenshot.')) return 'screenshot';
1874
+ if (method === 'network.export_har') return 'har';
1875
+ return null;
1876
+ }
1877
+
1362
1878
  /**
1363
1879
  * Check whether a daemon is already listening on the given transport.
1364
1880
  * Connects, sends a health.ping, and waits up to 500 ms for a response.