@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.
- package/PRIVACY.md +29 -3
- package/README.md +10 -3
- package/SECURITY.md +12 -0
- package/package.json +7 -1
- package/packages/agent-client/src/cli-args.js +35 -0
- package/packages/agent-client/src/cli-batch.js +121 -58
- package/packages/agent-client/src/cli-output.js +8 -1
- package/packages/agent-client/src/cli.js +227 -19
- package/packages/agent-client/src/client.js +156 -3
- package/packages/agent-client/src/command-registry.js +8 -2
- package/packages/agent-client/src/config.js +195 -0
- package/packages/agent-client/src/npm-self-update.js +305 -0
- package/packages/agent-client/src/remotes.js +4 -2
- package/packages/agent-client/src/runtime.js +97 -5
- package/packages/agent-client/src/types.ts +38 -0
- package/packages/mcp-server/src/handlers-capture.js +29 -2
- package/packages/mcp-server/src/handlers-dom.js +54 -3
- package/packages/mcp-server/src/handlers-page.js +180 -60
- package/packages/mcp-server/src/handlers-utils.js +35 -4
- package/packages/mcp-server/src/handlers.js +10 -3
- package/packages/mcp-server/src/server.js +75 -6
- package/packages/native-host/src/artifact-store.js +353 -0
- package/packages/native-host/src/config.js +9 -1
- package/packages/native-host/src/daemon-logger.js +3 -1
- package/packages/native-host/src/daemon.js +543 -27
- package/packages/native-host/src/native-host.js +25 -2
- package/packages/protocol/src/capability-values.js +2 -0
- package/packages/protocol/src/defaults.js +28 -0
- package/packages/protocol/src/errors.js +48 -5
- package/packages/protocol/src/incidental-sanitizer.js +197 -0
- package/packages/protocol/src/index.js +2 -0
- package/packages/protocol/src/protocol.js +316 -6
- package/packages/protocol/src/recovery-telemetry.js +302 -0
- package/packages/protocol/src/registry.js +143 -6
- package/packages/protocol/src/summary.js +116 -25
- package/packages/protocol/src/types.ts +489 -4
- package/skills/browser-bridge/SKILL.md +22 -8
- package/skills/browser-bridge/references/protocol.md +117 -13
- 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(
|
|
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
|
|
861
|
+
const isExtensionLocalRequest =
|
|
749
862
|
socket.__role === 'extension' &&
|
|
750
|
-
(message.request?.method === '
|
|
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' && !
|
|
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
|
|
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
|
|
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
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
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
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
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.
|