@browserbridge/bbx 1.8.0 → 1.10.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 (48) 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 +70 -6
  6. package/packages/agent-client/src/cli-batch.js +127 -59
  7. package/packages/agent-client/src/cli-helpers.js +8 -0
  8. package/packages/agent-client/src/cli-output.js +8 -1
  9. package/packages/agent-client/src/cli-setup-commands.js +3 -1
  10. package/packages/agent-client/src/cli.js +254 -26
  11. package/packages/agent-client/src/client.js +156 -3
  12. package/packages/agent-client/src/command-registry.js +14 -4
  13. package/packages/agent-client/src/config.js +195 -0
  14. package/packages/agent-client/src/mcp-config.js +8 -8
  15. package/packages/agent-client/src/npm-self-update.js +305 -0
  16. package/packages/agent-client/src/remotes.js +4 -2
  17. package/packages/agent-client/src/runtime.js +99 -7
  18. package/packages/agent-client/src/types.ts +38 -0
  19. package/packages/mcp-server/src/guidance.js +37 -6
  20. package/packages/mcp-server/src/handlers-capture.js +29 -2
  21. package/packages/mcp-server/src/handlers-dom.js +54 -3
  22. package/packages/mcp-server/src/handlers-page.js +333 -60
  23. package/packages/mcp-server/src/handlers-utils.js +141 -151
  24. package/packages/mcp-server/src/handlers.js +17 -4
  25. package/packages/mcp-server/src/server.js +269 -51
  26. package/packages/mcp-server/src/toolset.js +48 -0
  27. package/packages/native-host/src/artifact-store.js +353 -0
  28. package/packages/native-host/src/config.js +9 -1
  29. package/packages/native-host/src/daemon-logger.js +3 -1
  30. package/packages/native-host/src/daemon.js +457 -27
  31. package/packages/native-host/src/dom-baseline-owners.js +210 -0
  32. package/packages/native-host/src/native-host.js +38 -29
  33. package/packages/protocol/src/budget-presets.js +182 -0
  34. package/packages/protocol/src/capability-values.js +2 -0
  35. package/packages/protocol/src/defaults.js +28 -0
  36. package/packages/protocol/src/errors.js +48 -5
  37. package/packages/protocol/src/har.js +34 -0
  38. package/packages/protocol/src/incidental-sanitizer.js +197 -0
  39. package/packages/protocol/src/index.js +4 -0
  40. package/packages/protocol/src/json-lines.js +3 -2
  41. package/packages/protocol/src/protocol.js +316 -6
  42. package/packages/protocol/src/recovery-telemetry.js +302 -0
  43. package/packages/protocol/src/registry.js +226 -6
  44. package/packages/protocol/src/summary.js +116 -25
  45. package/packages/protocol/src/types.ts +490 -4
  46. package/skills/browser-bridge/SKILL.md +26 -8
  47. package/skills/browser-bridge/references/protocol.md +125 -15
  48. package/skills/browser-bridge/references/token-efficiency.md +17 -7
@@ -29,6 +29,9 @@ import {
29
29
  getSupportedProtocolVersions,
30
30
  MAX_DAEMON_PENDING_TIMEOUT_MS,
31
31
  parseJsonLines,
32
+ RecoveryTelemetryCollector,
33
+ normalizeRecoveryTelemetrySummary,
34
+ sanitizeIncidentalValue,
32
35
  setProtocolPackageVersion,
33
36
  validateBridgeRequest,
34
37
  } from '../../protocol/src/index.js';
@@ -37,10 +40,13 @@ import {
37
40
  formatBridgeTransport,
38
41
  getBridgeListenTarget,
39
42
  getBridgeTransport,
43
+ getArtifactStorePath,
40
44
  getSocketPath,
41
45
  } from './config.js';
46
+ import { ArtifactStore } from './artifact-store.js';
42
47
  import { bridgeAuthTokensEqual, ensureBridgeAuthToken, readBridgeAuthToken } from './auth-token.js';
43
48
  import { normalizeDaemonLogger } from './daemon-logger.js';
49
+ import { DomBaselineOwnerRegistry, isValidBaselineId } from './dom-baseline-owners.js';
44
50
  import { writeJsonLine } from './framing.js';
45
51
 
46
52
  const DAEMON_VERSION = loadDaemonVersion();
@@ -54,7 +60,7 @@ setProtocolPackageVersion(DAEMON_VERSION);
54
60
  /** @typedef {import('./daemon-logger.js').DaemonLoggerLike} DaemonLoggerLike */
55
61
  /** @typedef {'agent' | 'extension'} SocketRole */
56
62
  /** @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 */
63
+ /** @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
64
  /**
59
65
  * @typedef {{
60
66
  * installAgentFiles: typeof import('../../agent-client/src/install.js').installAgentFiles,
@@ -110,6 +116,13 @@ const HEALTH_DEBUGGER_REASONS = new Set([
110
116
  'target_closed',
111
117
  ]);
112
118
  const HEALTH_CAPTURE_STATES = new Set(['stopped', 'armed', 'active', 'stop_failed']);
119
+ const DIAGNOSTIC_METHODS = new Set([
120
+ 'health.ping',
121
+ 'daemon.metrics',
122
+ 'log.tail',
123
+ 'setup.get_status',
124
+ 'setup.install',
125
+ ]);
113
126
 
114
127
  /**
115
128
  * @param {unknown} value
@@ -146,6 +159,12 @@ function normalizeExtensionHealthResult(value) {
146
159
  /** @type {Record<string, unknown>} */
147
160
  const normalized = {};
148
161
  if (result.extension === 'ok') normalized.extension = 'ok';
162
+ if (
163
+ typeof result.extensionVersion === 'string' &&
164
+ /^\d+\.\d+\.\d+$/u.test(result.extensionVersion)
165
+ ) {
166
+ normalized.extensionVersion = result.extensionVersion;
167
+ }
149
168
 
150
169
  const access = asHealthRecord(result.access);
151
170
  if (access) {
@@ -216,6 +235,49 @@ function normalizeExtensionHealthResult(value) {
216
235
  normalized.capture = normalizedCapture;
217
236
  }
218
237
 
238
+ const domBaselines = asHealthRecord(result.domBaselines);
239
+ if (domBaselines) {
240
+ /** @type {Record<string, unknown>} */
241
+ const normalizedBaselines = {};
242
+ for (const key of ['baselineCount', 'bytes', 'tabCount']) {
243
+ const count = normalizeHealthCount(domBaselines[key]);
244
+ if (count !== undefined) normalizedBaselines[key] = count;
245
+ }
246
+ const operationSource = asHealthRecord(domBaselines.operations);
247
+ if (operationSource) {
248
+ /** @type {Record<string, unknown>} */
249
+ const operations = {};
250
+ for (const name of [
251
+ 'create',
252
+ 'get',
253
+ 'compare',
254
+ 'release',
255
+ 'invalidate',
256
+ 'clearTab',
257
+ 'clearWindow',
258
+ 'clearAll',
259
+ 'invalidateNavigation',
260
+ 'pruneExpired',
261
+ ]) {
262
+ const source = asHealthRecord(operationSource[name]);
263
+ if (!source) continue;
264
+ const calls = normalizeHealthCount(source.calls);
265
+ const totalLatencyMs = normalizeHealthCount(source.totalLatencyMs);
266
+ const maxLatencyMs = normalizeHealthCount(source.maxLatencyMs);
267
+ operations[name] = {
268
+ ...(calls === undefined ? {} : { calls }),
269
+ ...(totalLatencyMs === undefined ? {} : { totalLatencyMs }),
270
+ ...(maxLatencyMs === undefined ? {} : { maxLatencyMs }),
271
+ };
272
+ }
273
+ normalizedBaselines.operations = operations;
274
+ }
275
+ normalized.domBaselines = normalizedBaselines;
276
+ }
277
+
278
+ const recovery = normalizeRecoveryTelemetrySummary(result.recovery, 'routedExtension');
279
+ if (recovery) normalized.recovery = recovery;
280
+
219
281
  const extensionSupportedVersions = Array.isArray(result.supported_versions)
220
282
  ? result.supported_versions
221
283
  .filter(
@@ -230,6 +292,19 @@ function normalizeExtensionHealthResult(value) {
230
292
  return normalized;
231
293
  }
232
294
 
295
+ /**
296
+ * @param {import('../../protocol/src/types.js').RecoveryTelemetrySummary} daemon
297
+ * @param {unknown} extensionResult
298
+ */
299
+ function createHealthRecoveryPayload(daemon, extensionResult) {
300
+ const source = asHealthRecord(extensionResult);
301
+ const routedExtension = normalizeRecoveryTelemetrySummary(source?.recovery, 'routedExtension');
302
+ return {
303
+ daemon,
304
+ ...(routedExtension ? { routedExtension } : {}),
305
+ };
306
+ }
307
+
233
308
  /**
234
309
  * @param {string | undefined} requestedVersion
235
310
  * @returns {{ supported_versions: readonly string[], deprecated_since?: string, migration_hint?: string }}
@@ -289,9 +364,15 @@ export function isWindowsNamedPipePath(socketPath) {
289
364
  * accessEnabled?: boolean,
290
365
  * authToken?: string,
291
366
  * at?: number
367
+ * artifactId?: string,
368
+ * artifact?: Record<string, unknown>,
369
+ * chunkIndex?: number,
370
+ * data?: string
292
371
  * }} DaemonMessage
293
372
  */
294
373
 
374
+ /** @typedef {import('../../protocol/src/types.js').ArtifactKind} ArtifactKind */
375
+
295
376
  export class BridgeDaemon {
296
377
  /**
297
378
  * @param {{
@@ -301,7 +382,8 @@ export class BridgeDaemon {
301
382
  * setupStatusLoader?: () => Promise<SetupStatus>,
302
383
  * setupInstaller?: (params: Record<string, unknown>) => Promise<SetupInstallResult>,
303
384
  * logger?: DaemonLoggerLike | Pick<Console, 'log' | 'error'>,
304
- * authToken?: string | null
385
+ * authToken?: string | null,
386
+ * artifactStore?: ArtifactStore
305
387
  * }} [options={}]
306
388
  */
307
389
  constructor({
@@ -312,6 +394,7 @@ export class BridgeDaemon {
312
394
  setupInstaller = installSetupTarget,
313
395
  logger = undefined,
314
396
  authToken = undefined,
397
+ artifactStore = new ArtifactStore(getArtifactStorePath()),
315
398
  } = {}) {
316
399
  this.transport = socketPath ? createSocketBridgeTransport(socketPath) : transport;
317
400
  this.socketPath =
@@ -354,6 +437,13 @@ export class BridgeDaemon {
354
437
  this.requestStartTimes = new Map();
355
438
  /** @type {string | null | undefined} */
356
439
  this.authToken = authToken;
440
+ this.artifactStore = artifactStore;
441
+ this.artifactStoreInitialized = false;
442
+ this.domBaselines = new DomBaselineOwnerRegistry({
443
+ isCurrentExtensionSocket: (socket) =>
444
+ this.extensionSockets.get(socket.__extensionId ?? '') === socket,
445
+ });
446
+ this.recoveryTelemetry = new RecoveryTelemetryCollector();
357
447
  }
358
448
 
359
449
  /**
@@ -421,11 +511,17 @@ export class BridgeDaemon {
421
511
  * @param {PendingEntry | undefined} [pending]
422
512
  * @returns {PendingEntry | undefined}
423
513
  */
424
- clearPendingRequest(requestId, pending = this.pendingRequests.get(requestId)) {
514
+ clearPendingRequest(
515
+ requestId,
516
+ pending = this.pendingRequests.get(requestId),
517
+ preserveArtifacts = false
518
+ ) {
425
519
  if (!pending) {
426
520
  return undefined;
427
521
  }
428
522
  clearTimeout(pending.timeoutId);
523
+ if (preserveArtifacts) this.artifactStore.abortRequest(requestId);
524
+ else this.artifactStore.deleteRequest(requestId);
429
525
  this.pendingRequests.delete(requestId);
430
526
  this.removePendingRequestIndex(this.pendingRequestsByOwnerSocket, pending.socket, requestId);
431
527
  for (const targetSocket of pending.targets) {
@@ -618,6 +714,9 @@ export class BridgeDaemon {
618
714
  await fs.promises.chmod(this.socketPath, 0o600);
619
715
  }
620
716
 
717
+ this.artifactStore.reset();
718
+ this.artifactStoreInitialized = true;
719
+
621
720
  this.logger.info('Daemon listening', {
622
721
  transport: formatBridgeTransport(this.transport),
623
722
  socketPath: this.socketPath ?? null,
@@ -665,6 +764,11 @@ export class BridgeDaemon {
665
764
  }
666
765
  this.extensionSockets.clear();
667
766
  this.invalidateConnectedExtensionsCache();
767
+ this.domBaselines.clear();
768
+ if (this.artifactStoreInitialized) {
769
+ this.artifactStore.reset();
770
+ this.artifactStoreInitialized = false;
771
+ }
668
772
 
669
773
  if (this.server) {
670
774
  const server = this.server;
@@ -737,6 +841,13 @@ export class BridgeDaemon {
737
841
  return this.handleExtensionActivity(socket, message);
738
842
  }
739
843
 
844
+ if (message?.type?.startsWith('extension.artifact.')) {
845
+ if (socket.__role !== 'extension') {
846
+ return this.rejectMessageForRole(socket, message);
847
+ }
848
+ return this.handleExtensionArtifact(socket, message);
849
+ }
850
+
740
851
  if (message?.type === 'extension.setup_status.request') {
741
852
  if (socket.__role !== 'extension') {
742
853
  return this.rejectMessageForRole(socket, message);
@@ -745,11 +856,12 @@ export class BridgeDaemon {
745
856
  }
746
857
 
747
858
  if (message?.type === 'agent.request') {
748
- const isExtensionSetupRequest =
859
+ const isExtensionLocalRequest =
749
860
  socket.__role === 'extension' &&
750
- (message.request?.method === 'setup.get_status' ||
861
+ (message.request?.method === 'health.ping' ||
862
+ message.request?.method === 'setup.get_status' ||
751
863
  message.request?.method === 'setup.install');
752
- if (socket.__role !== 'agent' && !isExtensionSetupRequest) {
864
+ if (socket.__role !== 'agent' && !isExtensionLocalRequest) {
753
865
  return this.rejectMessageForRole(socket, message);
754
866
  }
755
867
  return this.handleAgentRequest(socket, message);
@@ -848,6 +960,7 @@ export class BridgeDaemon {
848
960
  `Request id ${JSON.stringify(request.id)} is already in flight.`
849
961
  );
850
962
  await writeJsonLine(socket, { type: 'agent.response', response });
963
+ this.recordDaemonRequestOutcome(request, false);
851
964
  return;
852
965
  }
853
966
 
@@ -861,6 +974,9 @@ export class BridgeDaemon {
861
974
  transport: formatBridgeTransport(this.transport),
862
975
  proxy: this.getProxyStatusPayload(),
863
976
  connectedExtensions: [],
977
+ recovery: {
978
+ daemon: this.recoveryTelemetry.snapshot('daemon'),
979
+ },
864
980
  daemon_supported_versions: getSupportedProtocolVersions(),
865
981
  ...getVersionNegotiationPayload(request.meta?.protocol_version),
866
982
  });
@@ -880,6 +996,7 @@ export class BridgeDaemon {
880
996
  }
881
997
 
882
998
  if (request.method === 'daemon.metrics') {
999
+ this.domBaselines.prune();
883
1000
  const now = Date.now();
884
1001
  const uptimeMs = this.startedAt > 0 ? now - this.startedAt : 0;
885
1002
  const avgResponseTimeMs =
@@ -894,11 +1011,62 @@ export class BridgeDaemon {
894
1011
  requestsProcessed: this.requestsProcessed,
895
1012
  requestsFailed: this.requestsFailed,
896
1013
  avgResponseTimeMs,
1014
+ domBaselineOwners: this.domBaselines.size,
1015
+ recovery: this.recoveryTelemetry.snapshot('daemon'),
897
1016
  });
898
1017
  await writeJsonLine(socket, { type: 'agent.response', response });
899
1018
  return;
900
1019
  }
901
1020
 
1021
+ if (request.method === 'artifact.read' || request.method === 'artifact.delete') {
1022
+ const ownerId = socket.__clientId;
1023
+ if (!ownerId) {
1024
+ const response = createFailure(
1025
+ request.id,
1026
+ ERROR_CODES.ACCESS_DENIED,
1027
+ 'Artifact access requires a registered agent client.',
1028
+ null,
1029
+ { method: request.method }
1030
+ );
1031
+ await writeJsonLine(socket, { type: 'agent.response', response });
1032
+ this.recordDaemonRequestOutcome(request, false);
1033
+ return;
1034
+ }
1035
+ try {
1036
+ const result =
1037
+ request.method === 'artifact.read'
1038
+ ? this.artifactStore.read(
1039
+ String(request.params.artifactId),
1040
+ ownerId,
1041
+ Number(request.params.offset),
1042
+ Number(request.params.maxBytes)
1043
+ )
1044
+ : this.artifactStore.delete(String(request.params.artifactId), ownerId);
1045
+ await writeJsonLine(socket, {
1046
+ type: 'agent.response',
1047
+ response: createSuccess(request.id, result, { method: request.method }),
1048
+ });
1049
+ this.recordDaemonRequestOutcome(request, true);
1050
+ } catch (error) {
1051
+ const code =
1052
+ error && typeof error === 'object' && 'code' in error
1053
+ ? /** @type {import('../../protocol/src/types.js').ErrorCode} */ (error.code)
1054
+ : ERROR_CODES.INTERNAL_ERROR;
1055
+ await writeJsonLine(socket, {
1056
+ type: 'agent.response',
1057
+ response: createFailure(
1058
+ request.id,
1059
+ code,
1060
+ error instanceof Error ? error.message : String(error),
1061
+ null,
1062
+ { method: request.method }
1063
+ ),
1064
+ });
1065
+ this.recordDaemonRequestOutcome(request, false);
1066
+ }
1067
+ return;
1068
+ }
1069
+
902
1070
  if (request.method === 'setup.get_status') {
903
1071
  const response = createSuccess(request.id, await this.setupStatusLoader(), {
904
1072
  method: request.method,
@@ -947,17 +1115,48 @@ export class BridgeDaemon {
947
1115
  typeof request.meta?.target_profile === 'string' ? request.meta.target_profile : null;
948
1116
  const hasExplicitTarget = Boolean(targetBrowser || targetProfile);
949
1117
 
950
- const target = this.selectExtensionTarget(targetBrowser, targetProfile);
1118
+ const baselineId =
1119
+ typeof request.params.baselineId === 'string' ? request.params.baselineId : null;
1120
+ const usesBaselineOwner =
1121
+ request.method === 'dom.baseline.compare' ||
1122
+ request.method === 'dom.baseline.describe' ||
1123
+ request.method === 'dom.baseline.release';
1124
+ const baselineOwner = usesBaselineOwner ? this.domBaselines.get(baselineId ?? '') : null;
1125
+ const target = usesBaselineOwner
1126
+ ? baselineOwner &&
1127
+ (!targetBrowser || baselineOwner.__browserName === targetBrowser) &&
1128
+ (!targetProfile || baselineOwner.__profileLabel === targetProfile)
1129
+ ? baselineOwner
1130
+ : null
1131
+ : this.selectExtensionTarget(targetBrowser, targetProfile);
951
1132
 
952
1133
  if (!target) {
1134
+ if (usesBaselineOwner) {
1135
+ const response =
1136
+ request.method === 'dom.baseline.release'
1137
+ ? createSuccess(request.id, { baselineId, released: false }, { method: request.method })
1138
+ : createFailure(
1139
+ request.id,
1140
+ ERROR_CODES.DOM_BASELINE_NOT_FOUND,
1141
+ 'DOM baseline is missing, expired, released, or its browser destination disconnected.',
1142
+ { baselineId },
1143
+ { method: request.method }
1144
+ );
1145
+ await writeJsonLine(socket, { type: 'agent.response', response });
1146
+ this.recordDaemonRequestOutcome(request, response.ok);
1147
+ return;
1148
+ }
953
1149
  const response = createFailure(
954
1150
  request.id,
955
1151
  ERROR_CODES.EXTENSION_DISCONNECTED,
956
1152
  hasExplicitTarget
957
1153
  ? `No connected extension matches target_browser="${targetBrowser ?? '*'}" target_profile="${targetProfile ?? '*'}".`
958
- : 'The Chrome extension is not connected to the local bridge daemon.'
1154
+ : 'The Chrome extension is not connected to the local bridge daemon.',
1155
+ null,
1156
+ { method: request.method }
959
1157
  );
960
1158
  await writeJsonLine(socket, { type: 'agent.response', response });
1159
+ this.recordDaemonRequestOutcome(request, false);
961
1160
  return;
962
1161
  }
963
1162
 
@@ -966,16 +1165,21 @@ export class BridgeDaemon {
966
1165
  method: request.method,
967
1166
  protocolVersion: request.meta?.protocol_version,
968
1167
  source: typeof request.meta?.source === 'string' ? request.meta.source : '',
1168
+ baselineId,
1169
+ automaticMcpRetry: isAutomaticMcpRetry(request.meta),
969
1170
  targets: new Set([target]),
970
1171
  timeoutId: setTimeout(() => {
971
1172
  const pending = this.pendingRequests.get(request.id);
972
1173
  if (!pending) return;
1174
+ this.domBaselines.markAbandonedCreate(request.id, pending);
973
1175
  this.clearPendingRequest(request.id, pending);
974
- this.recordRequestCompletion(request.id, false);
1176
+ this.recordRequestCompletion(request.id, false, pending);
975
1177
  const response = createFailure(
976
1178
  request.id,
977
1179
  ERROR_CODES.TIMEOUT,
978
- 'Extension did not respond in time.'
1180
+ 'Extension did not respond in time.',
1181
+ null,
1182
+ { method: pending.method }
979
1183
  );
980
1184
  void writeJsonLine(pending.socket, {
981
1185
  type: 'agent.response',
@@ -1066,6 +1270,34 @@ export class BridgeDaemon {
1066
1270
  return candidates[0]?.[1] ?? null;
1067
1271
  }
1068
1272
 
1273
+ /**
1274
+ * @param {ClientSocket} socket
1275
+ * @param {import('../../protocol/src/types.js').BridgeResponse} response
1276
+ */
1277
+ async releaseOrphanDomBaseline(socket, response) {
1278
+ const result =
1279
+ response.result && typeof response.result === 'object'
1280
+ ? /** @type {Record<string, unknown>} */ (response.result)
1281
+ : {};
1282
+ const baselineId = typeof result.baselineId === 'string' ? result.baselineId : '';
1283
+ if (!isValidBaselineId(baselineId)) return;
1284
+ const scope =
1285
+ result.scope && typeof result.scope === 'object'
1286
+ ? /** @type {Record<string, unknown>} */ (result.scope)
1287
+ : {};
1288
+ const tabId = Number.isSafeInteger(scope.tabId) ? Number(scope.tabId) : null;
1289
+ await writeJsonLine(socket, {
1290
+ type: 'extension.request',
1291
+ request: {
1292
+ id: `orphan_release_${randomUUID().replaceAll('-', '')}`,
1293
+ method: 'dom.baseline.release',
1294
+ tab_id: tabId,
1295
+ params: { baselineId },
1296
+ meta: { protocol_version: getProtocolVersion(), token_budget: null },
1297
+ },
1298
+ });
1299
+ }
1300
+
1069
1301
  /**
1070
1302
  * @param {BridgeRequest} request
1071
1303
  * @returns {number}
@@ -1140,6 +1372,10 @@ export class BridgeDaemon {
1140
1372
  */
1141
1373
  handleExtensionAccessUpdate(socket, message) {
1142
1374
  const accessEnabled = Boolean(message.accessEnabled);
1375
+ if (!accessEnabled && socket.__extensionId) {
1376
+ this.artifactStore.deleteByExtension(socket.__extensionId);
1377
+ this.domBaselines.clearForSocket(socket);
1378
+ }
1143
1379
  if (socket.__accessEnabled !== accessEnabled) {
1144
1380
  socket.__accessEnabled = accessEnabled;
1145
1381
  this.invalidateConnectedExtensionsCache();
@@ -1148,6 +1384,58 @@ export class BridgeDaemon {
1148
1384
  socket.__accessEnabled = accessEnabled;
1149
1385
  }
1150
1386
 
1387
+ /**
1388
+ * @param {ClientSocket} socket
1389
+ * @param {DaemonMessage} message
1390
+ * @returns {void}
1391
+ */
1392
+ handleExtensionArtifact(socket, message) {
1393
+ const artifact =
1394
+ message.artifact && typeof message.artifact === 'object' ? message.artifact : {};
1395
+ const requestId = typeof artifact.requestId === 'string' ? artifact.requestId : '';
1396
+ const pending = this.pendingRequests.get(requestId);
1397
+ if (!pending || !pending.targets.has(socket) || !socket.__extensionId) return;
1398
+ const ownerId = pending.socket.__clientId;
1399
+ if (!ownerId) return;
1400
+ try {
1401
+ if (message.type === 'extension.artifact.begin') {
1402
+ this.artifactStore.begin({
1403
+ artifactId: String(artifact.artifactId ?? ''),
1404
+ requestId,
1405
+ ownerId,
1406
+ extensionId: socket.__extensionId,
1407
+ kind: /** @type {ArtifactKind} */ (artifact.kind),
1408
+ mimeType: String(artifact.mimeType ?? ''),
1409
+ totalBytes: Number(artifact.byteLength),
1410
+ sha256: String(artifact.sha256 ?? ''),
1411
+ chunkCount: Number(artifact.chunkCount),
1412
+ createdAt: String(artifact.createdAt ?? ''),
1413
+ expiresAt: String(artifact.expiresAt ?? ''),
1414
+ });
1415
+ } else if (message.type === 'extension.artifact.chunk') {
1416
+ this.artifactStore.writeChunk(
1417
+ String(message.artifactId ?? ''),
1418
+ Number(message.chunkIndex),
1419
+ String(message.data ?? '')
1420
+ );
1421
+ } else if (message.type === 'extension.artifact.commit') {
1422
+ this.artifactStore.commit(String(message.artifactId ?? ''));
1423
+ }
1424
+ } catch (error) {
1425
+ const code =
1426
+ error && typeof error === 'object' && 'code' in error
1427
+ ? /** @type {import('../../protocol/src/types.js').ErrorCode} */ (error.code)
1428
+ : ERROR_CODES.ARTIFACT_TRANSFER_INVALID;
1429
+ pending.lastErrorResponse = createFailure(
1430
+ requestId,
1431
+ code,
1432
+ error instanceof Error ? error.message : String(error),
1433
+ null,
1434
+ { method: pending.method }
1435
+ );
1436
+ }
1437
+ }
1438
+
1151
1439
  /**
1152
1440
  * Structured remote-exposure status for health payloads. The proxy counts
1153
1441
  * as enabled only when the daemon listens on a TCP address other than
@@ -1188,22 +1476,83 @@ export class BridgeDaemon {
1188
1476
  }
1189
1477
 
1190
1478
  const pending = this.pendingRequests.get(responseMessage.id);
1191
- if (!pending || !pending.targets.has(socket)) {
1479
+ if (!pending) {
1480
+ if (this.domBaselines.takeAbandonedCreate(responseMessage.id, socket)) {
1481
+ if (responseMessage.ok) {
1482
+ await this.releaseOrphanDomBaseline(socket, responseMessage).catch((error) => {
1483
+ this.logger.error('orphan DOM baseline release failed', {
1484
+ requestId: responseMessage.id,
1485
+ message: error instanceof Error ? error.message : String(error),
1486
+ });
1487
+ });
1488
+ }
1489
+ }
1490
+ return;
1491
+ }
1492
+ if (!pending.targets.has(socket)) {
1192
1493
  return;
1193
1494
  }
1194
1495
 
1195
1496
  this.removePendingTarget(responseMessage.id, pending, socket);
1196
1497
 
1197
1498
  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
- });
1499
+ const result =
1500
+ responseMessage.result && typeof responseMessage.result === 'object'
1501
+ ? /** @type {Record<string, unknown>} */ (responseMessage.result)
1502
+ : {};
1503
+ const artifact =
1504
+ result.artifact && typeof result.artifact === 'object'
1505
+ ? /** @type {Record<string, unknown>} */ (result.artifact)
1506
+ : null;
1507
+ const expectedArtifactKind = getArtifactKindForMethod(pending.method);
1508
+ if (
1509
+ result.delivery === 'artifact' &&
1510
+ (!artifact ||
1511
+ artifact.kind !== expectedArtifactKind ||
1512
+ !pending.socket.__clientId ||
1513
+ !this.artifactStore.matchesCommitted(
1514
+ String(artifact.artifactId ?? ''),
1515
+ pending.socket.__clientId,
1516
+ responseMessage.id,
1517
+ artifact,
1518
+ expectedArtifactKind ?? undefined
1519
+ ))
1520
+ ) {
1521
+ pending.lastErrorResponse ??= createFailure(
1522
+ responseMessage.id,
1523
+ ERROR_CODES.ARTIFACT_TRANSFER_INVALID,
1524
+ 'Artifact metadata does not match the request or was not committed before the response.',
1525
+ null,
1526
+ { method: pending.method }
1527
+ );
1528
+ }
1529
+ if (pending.lastErrorResponse) {
1530
+ await this.finishPendingRequestIfExhausted(responseMessage.id, pending);
1531
+ return;
1532
+ }
1533
+ if (pending.method === 'dom.baseline.create') {
1534
+ const baselineId = typeof result.baselineId === 'string' ? result.baselineId : '';
1535
+ const evicted = Array.isArray(result.evicted) ? result.evicted : [];
1536
+ for (const item of evicted) {
1537
+ if (item && typeof item === 'object' && 'baselineId' in item) {
1538
+ this.domBaselines.deleteIfOwnedBy(String(item.baselineId), socket);
1539
+ }
1540
+ }
1541
+ if (!this.domBaselines.register(baselineId, socket, result.expiresAt)) {
1542
+ await this.releaseOrphanDomBaseline(socket, responseMessage);
1543
+ pending.lastErrorResponse = createFailure(
1544
+ responseMessage.id,
1545
+ ERROR_CODES.DOM_BASELINE_INVALIDATED,
1546
+ 'DOM baseline owner metadata is invalid.',
1547
+ null,
1548
+ { method: pending.method }
1549
+ );
1550
+ await this.finishPendingRequestIfExhausted(responseMessage.id, pending);
1551
+ return;
1552
+ }
1553
+ } else if (pending.method === 'dom.baseline.release' && pending.baselineId) {
1554
+ this.domBaselines.delete(pending.baselineId);
1555
+ }
1207
1556
  const response =
1208
1557
  pending.method === 'health.ping'
1209
1558
  ? createSuccess(
@@ -1219,6 +1568,10 @@ export class BridgeDaemon {
1219
1568
  daemonVersion: DAEMON_VERSION,
1220
1569
  daemon_supported_versions: getSupportedProtocolVersions(),
1221
1570
  proxy: this.getProxyStatusPayload(),
1571
+ recovery: createHealthRecoveryPayload(
1572
+ this.recoveryTelemetry.snapshot('daemon'),
1573
+ responseMessage.result
1574
+ ),
1222
1575
  },
1223
1576
  {
1224
1577
  method: pending.method,
@@ -1226,14 +1579,38 @@ export class BridgeDaemon {
1226
1579
  )
1227
1580
  : responseMessage;
1228
1581
 
1229
- await writeJsonLine(pending.socket, {
1230
- type: 'agent.response',
1231
- response,
1582
+ try {
1583
+ await writeJsonLine(pending.socket, {
1584
+ type: 'agent.response',
1585
+ response,
1586
+ });
1587
+ } catch (error) {
1588
+ if (pending.method === 'dom.baseline.create') {
1589
+ const baselineId =
1590
+ result && typeof result.baselineId === 'string' ? result.baselineId : null;
1591
+ if (baselineId) this.domBaselines.delete(baselineId);
1592
+ await this.releaseOrphanDomBaseline(socket, responseMessage).catch(() => {});
1593
+ }
1594
+ this.clearPendingRequest(responseMessage.id, pending);
1595
+ this.recordRequestCompletion(responseMessage.id, false, pending);
1596
+ throw error;
1597
+ }
1598
+ this.clearPendingRequest(responseMessage.id, pending, true);
1599
+ this.recordRequestCompletion(responseMessage.id, true, pending);
1600
+ this.pushLog({
1601
+ at: new Date().toISOString(),
1602
+ method: responseMessage.meta?.method ?? null,
1603
+ ok: true,
1604
+ id: responseMessage.id,
1605
+ source: pending.source || null,
1232
1606
  });
1233
1607
  return;
1234
1608
  }
1235
1609
 
1236
1610
  // A routed request has one target, so its error is final.
1611
+ if (pending.baselineId && responseMessage.error?.code === ERROR_CODES.DOM_BASELINE_NOT_FOUND) {
1612
+ this.domBaselines.delete(pending.baselineId);
1613
+ }
1237
1614
  pending.lastErrorResponse = responseMessage;
1238
1615
 
1239
1616
  await this.finishPendingRequestIfExhausted(responseMessage.id, pending);
@@ -1245,6 +1622,8 @@ export class BridgeDaemon {
1245
1622
  */
1246
1623
  handleSocketClose(socket) {
1247
1624
  if (socket.__extensionId) {
1625
+ this.artifactStore.deleteByExtension(socket.__extensionId);
1626
+ this.domBaselines.clearForSocket(socket);
1248
1627
  this.logger.info('extension disconnected', { extensionId: socket.__extensionId });
1249
1628
  if (this.extensionSockets.get(socket.__extensionId) === socket) {
1250
1629
  this.extensionSockets.delete(socket.__extensionId);
@@ -1266,8 +1645,9 @@ export class BridgeDaemon {
1266
1645
  if (!pending) {
1267
1646
  continue;
1268
1647
  }
1648
+ this.domBaselines.markAbandonedCreate(id, pending);
1269
1649
  this.clearPendingRequest(id, pending);
1270
- this.recordRequestCompletion(id, false);
1650
+ this.recordRequestCompletion(id, false, pending);
1271
1651
  }
1272
1652
  }
1273
1653
 
@@ -1304,7 +1684,7 @@ export class BridgeDaemon {
1304
1684
  }
1305
1685
 
1306
1686
  this.clearPendingRequest(requestId, pending);
1307
- this.recordRequestCompletion(requestId, false);
1687
+ this.recordRequestCompletion(requestId, false, pending);
1308
1688
 
1309
1689
  const response =
1310
1690
  pending.lastErrorResponse ??
@@ -1333,9 +1713,10 @@ export class BridgeDaemon {
1333
1713
  /**
1334
1714
  * @param {string} requestId
1335
1715
  * @param {boolean} ok
1716
+ * @param {PendingEntry | undefined} [pending]
1336
1717
  * @returns {void}
1337
1718
  */
1338
- recordRequestCompletion(requestId, ok) {
1719
+ recordRequestCompletion(requestId, ok, pending) {
1339
1720
  const startedAt = this.requestStartTimes.get(requestId);
1340
1721
  this.requestStartTimes.delete(requestId);
1341
1722
  this.requestsProcessed += 1;
@@ -1345,6 +1726,34 @@ export class BridgeDaemon {
1345
1726
  if (typeof startedAt === 'number') {
1346
1727
  this.totalResponseTimeMs += Date.now() - startedAt;
1347
1728
  }
1729
+ if (pending?.method && !DIAGNOSTIC_METHODS.has(pending.method)) {
1730
+ this.recordDaemonRequestOutcome(
1731
+ {
1732
+ method: /** @type {BridgeRequest['method']} */ (pending.method),
1733
+ meta: {
1734
+ protocol_version: pending.protocolVersion ?? getProtocolVersion(),
1735
+ token_budget: null,
1736
+ ...(pending.automaticMcpRetry
1737
+ ? { source: 'mcp', automatic_retry: { attempt: 2, reason: 'retryable_error' } }
1738
+ : {}),
1739
+ },
1740
+ },
1741
+ ok
1742
+ );
1743
+ }
1744
+ }
1745
+
1746
+ /**
1747
+ * @param {Pick<BridgeRequest, 'method' | 'meta'>} request
1748
+ * @param {boolean} ok
1749
+ */
1750
+ recordDaemonRequestOutcome(request, ok) {
1751
+ if (DIAGNOSTIC_METHODS.has(request.method)) return;
1752
+ const outcome = ok ? 'success' : 'failure';
1753
+ this.recoveryTelemetry.record('request_outcome', outcome, request.method);
1754
+ if (isAutomaticMcpRetry(request.meta)) {
1755
+ this.recoveryTelemetry.record('automatic_mcp_retry', outcome, request.method);
1756
+ }
1348
1757
  }
1349
1758
 
1350
1759
  /**
@@ -1352,13 +1761,34 @@ export class BridgeDaemon {
1352
1761
  * @returns {void}
1353
1762
  */
1354
1763
  pushLog(entry) {
1355
- this.recentLog.push(entry);
1764
+ this.recentLog.push(/** @type {Record<string, unknown>} */ (sanitizeIncidentalValue(entry)));
1356
1765
  if (this.recentLog.length > DAEMON_RECENT_LOG_LIMIT) {
1357
1766
  this.recentLog.shift();
1358
1767
  }
1359
1768
  }
1360
1769
  }
1361
1770
 
1771
+ /** @param {Record<string, unknown>} meta */
1772
+ function isAutomaticMcpRetry(meta) {
1773
+ const marker = asHealthRecord(meta.automatic_retry);
1774
+ if (meta.source !== 'mcp' || !marker) return false;
1775
+ const keys = Object.keys(marker).sort();
1776
+ return (
1777
+ keys.length === 2 &&
1778
+ keys[0] === 'attempt' &&
1779
+ keys[1] === 'reason' &&
1780
+ marker.attempt === 2 &&
1781
+ marker.reason === 'retryable_error'
1782
+ );
1783
+ }
1784
+
1785
+ /** @param {string | undefined} method @returns {ArtifactKind | null} */
1786
+ function getArtifactKindForMethod(method) {
1787
+ if (method?.startsWith('screenshot.')) return 'screenshot';
1788
+ if (method === 'network.export_har') return 'har';
1789
+ return null;
1790
+ }
1791
+
1362
1792
  /**
1363
1793
  * Check whether a daemon is already listening on the given transport.
1364
1794
  * Connects, sends a health.ping, and waits up to 500 ms for a response.