@browserbridge/bbx 1.7.8 → 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 +57 -12
- package/README.md +19 -8
- package/SECURITY.md +12 -0
- package/package.json +15 -6
- package/packages/agent-client/src/cli-args.js +59 -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 +272 -70
- package/packages/agent-client/src/client.js +296 -8
- package/packages/agent-client/src/command-registry.js +64 -55
- package/packages/agent-client/src/config.js +195 -0
- package/packages/agent-client/src/content-extract.js +198 -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 +864 -39
- package/packages/agent-client/src/types.ts +169 -6
- package/packages/mcp-server/src/guidance.js +1 -1
- package/packages/mcp-server/src/handlers-capture.js +69 -6
- package/packages/mcp-server/src/handlers-dom.js +61 -4
- package/packages/mcp-server/src/handlers-navigation.js +2 -1
- package/packages/mcp-server/src/handlers-page.js +211 -54
- package/packages/mcp-server/src/handlers-utils.js +101 -10
- package/packages/mcp-server/src/handlers.js +14 -7
- package/packages/mcp-server/src/server.js +176 -11
- 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 +701 -33
- package/packages/native-host/src/native-host.js +28 -3
- package/packages/protocol/src/capability-values.js +2 -0
- package/packages/protocol/src/defaults.js +31 -0
- package/packages/protocol/src/errors.js +102 -6
- package/packages/protocol/src/incidental-sanitizer.js +197 -0
- package/packages/protocol/src/index.js +2 -0
- package/packages/protocol/src/protocol.js +612 -6
- package/packages/protocol/src/recovery-telemetry.js +302 -0
- package/packages/protocol/src/registry.js +206 -38
- package/packages/protocol/src/summary.js +203 -30
- package/packages/protocol/src/types.ts +721 -2
- package/skills/browser-bridge/SKILL.md +54 -19
- package/skills/browser-bridge/references/interaction.md +55 -11
- package/skills/browser-bridge/references/patch-workflow.md +2 -0
- package/skills/browser-bridge/references/protocol.md +265 -86
- package/skills/browser-bridge/references/token-efficiency.md +29 -13
|
@@ -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';
|
|
@@ -53,8 +60,8 @@ setProtocolPackageVersion(DAEMON_VERSION);
|
|
|
53
60
|
/** @typedef {import('./config.js').BridgeTransport} BridgeTransport */
|
|
54
61
|
/** @typedef {import('./daemon-logger.js').DaemonLoggerLike} DaemonLoggerLike */
|
|
55
62
|
/** @typedef {'agent' | 'extension'} SocketRole */
|
|
56
|
-
/** @typedef {import('node:net').Socket & { readonly __role?: SocketRole, __clientId?: string, __extensionId?: string, __browserName?: string, __profileLabel?: 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 {import('node:net').Socket & { readonly __role?: SocketRole, __clientId?: string, __extensionId?: string, __browserName?: string, __profileLabel?: string, __browserExtensionId?: string, __accessEnabled?: boolean, __lastActiveAt?: number }} ClientSocket */
|
|
64
|
+
/** @typedef {{ socket: ClientSocket, timeoutId: NodeJS.Timeout, source?: string, method?: string, protocolVersion?: string, baselineId?: string | null, automaticMcpRetry?: boolean, targets: Set<ClientSocket>, lastErrorResponse?: import('../../protocol/src/types.js').BridgeResponse }} PendingEntry */
|
|
58
65
|
/**
|
|
59
66
|
* @typedef {{
|
|
60
67
|
* installAgentFiles: typeof import('../../agent-client/src/install.js').installAgentFiles,
|
|
@@ -85,6 +92,220 @@ function compareProtocolVersions(left, right) {
|
|
|
85
92
|
return 0;
|
|
86
93
|
}
|
|
87
94
|
|
|
95
|
+
/**
|
|
96
|
+
* @param {unknown} value
|
|
97
|
+
* @returns {string | undefined}
|
|
98
|
+
*/
|
|
99
|
+
function normalizeBrowserExtensionId(value) {
|
|
100
|
+
return typeof value === 'string' && /^[a-p]{32}$/u.test(value) ? value : undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const HEALTH_DIAGNOSTIC_COUNT_LIMIT = 10_000;
|
|
104
|
+
const HEALTH_ACCESS_REASONS = new Set([
|
|
105
|
+
'enabled',
|
|
106
|
+
'access_disabled',
|
|
107
|
+
'enabled_window_missing',
|
|
108
|
+
'no_routable_active_tab',
|
|
109
|
+
'restricted_page',
|
|
110
|
+
]);
|
|
111
|
+
const HEALTH_DEBUGGER_STATUSES = new Set(['idle', 'active']);
|
|
112
|
+
const HEALTH_DEBUGGER_REASONS = new Set([
|
|
113
|
+
'debugger_conflict',
|
|
114
|
+
'debugger_detached',
|
|
115
|
+
'debugger_replaced',
|
|
116
|
+
'debugger_canceled',
|
|
117
|
+
'target_closed',
|
|
118
|
+
]);
|
|
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
|
+
]);
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @param {unknown} value
|
|
130
|
+
* @returns {Record<string, unknown> | null}
|
|
131
|
+
*/
|
|
132
|
+
function asHealthRecord(value) {
|
|
133
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
134
|
+
? /** @type {Record<string, unknown>} */ (value)
|
|
135
|
+
: null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @param {unknown} value
|
|
140
|
+
* @returns {number | undefined}
|
|
141
|
+
*/
|
|
142
|
+
function normalizeHealthCount(value) {
|
|
143
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0
|
|
144
|
+
? Math.min(HEALTH_DIAGNOSTIC_COUNT_LIMIT, Math.trunc(value))
|
|
145
|
+
: undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Copy only extension-owned health fields into the daemon response. This keeps
|
|
150
|
+
* transport, connectivity, daemon identity, and daemon protocol negotiation
|
|
151
|
+
* authoritative even if an extension response is malformed or hostile.
|
|
152
|
+
*
|
|
153
|
+
* @param {unknown} value
|
|
154
|
+
* @returns {Record<string, unknown>}
|
|
155
|
+
*/
|
|
156
|
+
function normalizeExtensionHealthResult(value) {
|
|
157
|
+
const result = asHealthRecord(value);
|
|
158
|
+
if (!result) return {};
|
|
159
|
+
|
|
160
|
+
/** @type {Record<string, unknown>} */
|
|
161
|
+
const normalized = {};
|
|
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
|
+
}
|
|
169
|
+
|
|
170
|
+
const access = asHealthRecord(result.access);
|
|
171
|
+
if (access) {
|
|
172
|
+
/** @type {Record<string, unknown>} */
|
|
173
|
+
const normalizedAccess = {};
|
|
174
|
+
if (typeof access.enabled === 'boolean') normalizedAccess.enabled = access.enabled;
|
|
175
|
+
if (
|
|
176
|
+
access.windowId === null ||
|
|
177
|
+
(Number.isSafeInteger(access.windowId) && Number(access.windowId) > 0)
|
|
178
|
+
) {
|
|
179
|
+
normalizedAccess.windowId = access.windowId;
|
|
180
|
+
}
|
|
181
|
+
if (
|
|
182
|
+
access.routeTabId === null ||
|
|
183
|
+
(Number.isSafeInteger(access.routeTabId) && Number(access.routeTabId) > 0)
|
|
184
|
+
) {
|
|
185
|
+
normalizedAccess.routeTabId = access.routeTabId;
|
|
186
|
+
}
|
|
187
|
+
if (typeof access.routeReady === 'boolean') normalizedAccess.routeReady = access.routeReady;
|
|
188
|
+
if (typeof access.reason === 'string' && HEALTH_ACCESS_REASONS.has(access.reason)) {
|
|
189
|
+
normalizedAccess.reason = access.reason;
|
|
190
|
+
}
|
|
191
|
+
normalized.access = normalizedAccess;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const debuggerDiagnostics = asHealthRecord(result.debugger);
|
|
195
|
+
if (debuggerDiagnostics) {
|
|
196
|
+
/** @type {Record<string, unknown>} */
|
|
197
|
+
const normalizedDebugger = {};
|
|
198
|
+
if (
|
|
199
|
+
typeof debuggerDiagnostics.status === 'string' &&
|
|
200
|
+
HEALTH_DEBUGGER_STATUSES.has(debuggerDiagnostics.status)
|
|
201
|
+
) {
|
|
202
|
+
normalizedDebugger.status = debuggerDiagnostics.status;
|
|
203
|
+
}
|
|
204
|
+
for (const key of ['attachedTabCount', 'heldTabCount', 'pendingTabCount']) {
|
|
205
|
+
const count = normalizeHealthCount(debuggerDiagnostics[key]);
|
|
206
|
+
if (count !== undefined) normalizedDebugger[key] = count;
|
|
207
|
+
}
|
|
208
|
+
if (
|
|
209
|
+
typeof debuggerDiagnostics.recentReason === 'string' &&
|
|
210
|
+
HEALTH_DEBUGGER_REASONS.has(debuggerDiagnostics.recentReason)
|
|
211
|
+
) {
|
|
212
|
+
normalizedDebugger.recentReason = debuggerDiagnostics.recentReason;
|
|
213
|
+
} else if (debuggerDiagnostics.recentReason === null) {
|
|
214
|
+
normalizedDebugger.recentReason = null;
|
|
215
|
+
}
|
|
216
|
+
normalized.debugger = normalizedDebugger;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const capture = asHealthRecord(result.capture);
|
|
220
|
+
if (capture) {
|
|
221
|
+
/** @type {Record<string, unknown>} */
|
|
222
|
+
const normalizedCapture = {};
|
|
223
|
+
if (typeof capture.state === 'string' && HEALTH_CAPTURE_STATES.has(capture.state)) {
|
|
224
|
+
normalizedCapture.state = capture.state;
|
|
225
|
+
}
|
|
226
|
+
for (const key of [
|
|
227
|
+
'activeTabCount',
|
|
228
|
+
'ownershipCount',
|
|
229
|
+
'inflightCount',
|
|
230
|
+
'interceptionActiveTabCount',
|
|
231
|
+
'interceptionRuleCount',
|
|
232
|
+
]) {
|
|
233
|
+
const count = normalizeHealthCount(capture[key]);
|
|
234
|
+
if (count !== undefined) normalizedCapture[key] = count;
|
|
235
|
+
}
|
|
236
|
+
normalized.capture = normalizedCapture;
|
|
237
|
+
}
|
|
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
|
+
|
|
282
|
+
const extensionSupportedVersions = Array.isArray(result.supported_versions)
|
|
283
|
+
? result.supported_versions
|
|
284
|
+
.filter(
|
|
285
|
+
(version) =>
|
|
286
|
+
typeof version === 'string' &&
|
|
287
|
+
version.length <= 32 &&
|
|
288
|
+
/^\d+\.\d+(?:\.\d+)?(?:[-+][A-Za-z0-9.-]+)?$/u.test(version)
|
|
289
|
+
)
|
|
290
|
+
.slice(0, 10)
|
|
291
|
+
: [];
|
|
292
|
+
normalized.extension_supported_versions = extensionSupportedVersions;
|
|
293
|
+
return normalized;
|
|
294
|
+
}
|
|
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
|
+
|
|
88
309
|
/**
|
|
89
310
|
* @param {string | undefined} requestedVersion
|
|
90
311
|
* @returns {{ supported_versions: readonly string[], deprecated_since?: string, migration_hint?: string }}
|
|
@@ -140,12 +361,19 @@ export function isWindowsNamedPipePath(socketPath) {
|
|
|
140
361
|
* response?: import('../../protocol/src/types.js').BridgeResponse,
|
|
141
362
|
* browserName?: string,
|
|
142
363
|
* profileLabel?: string,
|
|
364
|
+
* browserExtensionId?: string,
|
|
143
365
|
* accessEnabled?: boolean,
|
|
144
366
|
* authToken?: string,
|
|
145
367
|
* at?: number
|
|
368
|
+
* artifactId?: string,
|
|
369
|
+
* artifact?: Record<string, unknown>,
|
|
370
|
+
* chunkIndex?: number,
|
|
371
|
+
* data?: string
|
|
146
372
|
* }} DaemonMessage
|
|
147
373
|
*/
|
|
148
374
|
|
|
375
|
+
/** @typedef {import('../../protocol/src/types.js').ArtifactKind} ArtifactKind */
|
|
376
|
+
|
|
149
377
|
export class BridgeDaemon {
|
|
150
378
|
/**
|
|
151
379
|
* @param {{
|
|
@@ -155,7 +383,8 @@ export class BridgeDaemon {
|
|
|
155
383
|
* setupStatusLoader?: () => Promise<SetupStatus>,
|
|
156
384
|
* setupInstaller?: (params: Record<string, unknown>) => Promise<SetupInstallResult>,
|
|
157
385
|
* logger?: DaemonLoggerLike | Pick<Console, 'log' | 'error'>,
|
|
158
|
-
* authToken?: string | null
|
|
386
|
+
* authToken?: string | null,
|
|
387
|
+
* artifactStore?: ArtifactStore
|
|
159
388
|
* }} [options={}]
|
|
160
389
|
*/
|
|
161
390
|
constructor({
|
|
@@ -166,6 +395,7 @@ export class BridgeDaemon {
|
|
|
166
395
|
setupInstaller = installSetupTarget,
|
|
167
396
|
logger = undefined,
|
|
168
397
|
authToken = undefined,
|
|
398
|
+
artifactStore = new ArtifactStore(getArtifactStorePath()),
|
|
169
399
|
} = {}) {
|
|
170
400
|
this.transport = socketPath ? createSocketBridgeTransport(socketPath) : transport;
|
|
171
401
|
this.socketPath =
|
|
@@ -192,7 +422,7 @@ export class BridgeDaemon {
|
|
|
192
422
|
this.pendingTimeoutMs = DEFAULT_DAEMON_PENDING_TIMEOUT_MS;
|
|
193
423
|
/** @type {Record<string, unknown>[]} */
|
|
194
424
|
this.recentLog = [];
|
|
195
|
-
/** @type {Array<{ extensionId: string, browserName: string | null, profileLabel: string | null, accessEnabled: boolean }> | null} */
|
|
425
|
+
/** @type {Array<{ extensionId: string, browserExtensionId: string | null, browserName: string | null, profileLabel: string | null, accessEnabled: boolean }> | null} */
|
|
196
426
|
this.connectedExtensionsCache = null;
|
|
197
427
|
/** @type {Promise<void> | null} */
|
|
198
428
|
this.stopPromise = null;
|
|
@@ -208,6 +438,13 @@ export class BridgeDaemon {
|
|
|
208
438
|
this.requestStartTimes = new Map();
|
|
209
439
|
/** @type {string | null | undefined} */
|
|
210
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();
|
|
211
448
|
}
|
|
212
449
|
|
|
213
450
|
/**
|
|
@@ -275,11 +512,17 @@ export class BridgeDaemon {
|
|
|
275
512
|
* @param {PendingEntry | undefined} [pending]
|
|
276
513
|
* @returns {PendingEntry | undefined}
|
|
277
514
|
*/
|
|
278
|
-
clearPendingRequest(
|
|
515
|
+
clearPendingRequest(
|
|
516
|
+
requestId,
|
|
517
|
+
pending = this.pendingRequests.get(requestId),
|
|
518
|
+
preserveArtifacts = false
|
|
519
|
+
) {
|
|
279
520
|
if (!pending) {
|
|
280
521
|
return undefined;
|
|
281
522
|
}
|
|
282
523
|
clearTimeout(pending.timeoutId);
|
|
524
|
+
if (preserveArtifacts) this.artifactStore.abortRequest(requestId);
|
|
525
|
+
else this.artifactStore.deleteRequest(requestId);
|
|
283
526
|
this.pendingRequests.delete(requestId);
|
|
284
527
|
this.removePendingRequestIndex(this.pendingRequestsByOwnerSocket, pending.socket, requestId);
|
|
285
528
|
for (const targetSocket of pending.targets) {
|
|
@@ -302,7 +545,7 @@ export class BridgeDaemon {
|
|
|
302
545
|
}
|
|
303
546
|
|
|
304
547
|
/**
|
|
305
|
-
* @returns {Array<{ extensionId: string, browserName: string | null, profileLabel: string | null, accessEnabled: boolean }>}
|
|
548
|
+
* @returns {Array<{ extensionId: string, browserExtensionId: string | null, browserName: string | null, profileLabel: string | null, accessEnabled: boolean }>}
|
|
306
549
|
*/
|
|
307
550
|
getConnectedExtensionsSnapshot() {
|
|
308
551
|
if (this.connectedExtensionsCache) {
|
|
@@ -312,6 +555,7 @@ export class BridgeDaemon {
|
|
|
312
555
|
this.connectedExtensionsCache = Array.from(this.extensionSockets.entries()).map(
|
|
313
556
|
([extensionId, extSocket]) => ({
|
|
314
557
|
extensionId,
|
|
558
|
+
browserExtensionId: extSocket.__browserExtensionId ?? null,
|
|
315
559
|
browserName: extSocket.__browserName ?? null,
|
|
316
560
|
profileLabel: extSocket.__profileLabel ?? null,
|
|
317
561
|
accessEnabled: extSocket.__accessEnabled ?? false,
|
|
@@ -373,6 +617,7 @@ export class BridgeDaemon {
|
|
|
373
617
|
typeof message.browserName === 'string' ? message.browserName : undefined;
|
|
374
618
|
socket.__profileLabel =
|
|
375
619
|
typeof message.profileLabel === 'string' ? message.profileLabel : undefined;
|
|
620
|
+
socket.__browserExtensionId = normalizeBrowserExtensionId(message.browserExtensionId);
|
|
376
621
|
socket.__lastActiveAt = Date.now();
|
|
377
622
|
this.extensionSockets.set(extensionId, socket);
|
|
378
623
|
this.invalidateConnectedExtensionsCache();
|
|
@@ -470,6 +715,9 @@ export class BridgeDaemon {
|
|
|
470
715
|
await fs.promises.chmod(this.socketPath, 0o600);
|
|
471
716
|
}
|
|
472
717
|
|
|
718
|
+
this.artifactStore.reset();
|
|
719
|
+
this.artifactStoreInitialized = true;
|
|
720
|
+
|
|
473
721
|
this.logger.info('Daemon listening', {
|
|
474
722
|
transport: formatBridgeTransport(this.transport),
|
|
475
723
|
socketPath: this.socketPath ?? null,
|
|
@@ -517,6 +765,12 @@ export class BridgeDaemon {
|
|
|
517
765
|
}
|
|
518
766
|
this.extensionSockets.clear();
|
|
519
767
|
this.invalidateConnectedExtensionsCache();
|
|
768
|
+
this.domBaselineOwners.clear();
|
|
769
|
+
this.abandonedDomBaselineCreates.clear();
|
|
770
|
+
if (this.artifactStoreInitialized) {
|
|
771
|
+
this.artifactStore.reset();
|
|
772
|
+
this.artifactStoreInitialized = false;
|
|
773
|
+
}
|
|
520
774
|
|
|
521
775
|
if (this.server) {
|
|
522
776
|
const server = this.server;
|
|
@@ -589,6 +843,13 @@ export class BridgeDaemon {
|
|
|
589
843
|
return this.handleExtensionActivity(socket, message);
|
|
590
844
|
}
|
|
591
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
|
+
|
|
592
853
|
if (message?.type === 'extension.setup_status.request') {
|
|
593
854
|
if (socket.__role !== 'extension') {
|
|
594
855
|
return this.rejectMessageForRole(socket, message);
|
|
@@ -597,11 +858,12 @@ export class BridgeDaemon {
|
|
|
597
858
|
}
|
|
598
859
|
|
|
599
860
|
if (message?.type === 'agent.request') {
|
|
600
|
-
const
|
|
861
|
+
const isExtensionLocalRequest =
|
|
601
862
|
socket.__role === 'extension' &&
|
|
602
|
-
(message.request?.method === '
|
|
863
|
+
(message.request?.method === 'health.ping' ||
|
|
864
|
+
message.request?.method === 'setup.get_status' ||
|
|
603
865
|
message.request?.method === 'setup.install');
|
|
604
|
-
if (socket.__role !== 'agent' && !
|
|
866
|
+
if (socket.__role !== 'agent' && !isExtensionLocalRequest) {
|
|
605
867
|
return this.rejectMessageForRole(socket, message);
|
|
606
868
|
}
|
|
607
869
|
return this.handleAgentRequest(socket, message);
|
|
@@ -700,6 +962,7 @@ export class BridgeDaemon {
|
|
|
700
962
|
`Request id ${JSON.stringify(request.id)} is already in flight.`
|
|
701
963
|
);
|
|
702
964
|
await writeJsonLine(socket, { type: 'agent.response', response });
|
|
965
|
+
this.recordDaemonRequestOutcome(request, false);
|
|
703
966
|
return;
|
|
704
967
|
}
|
|
705
968
|
|
|
@@ -713,6 +976,9 @@ export class BridgeDaemon {
|
|
|
713
976
|
transport: formatBridgeTransport(this.transport),
|
|
714
977
|
proxy: this.getProxyStatusPayload(),
|
|
715
978
|
connectedExtensions: [],
|
|
979
|
+
recovery: {
|
|
980
|
+
daemon: this.recoveryTelemetry.snapshot('daemon'),
|
|
981
|
+
},
|
|
716
982
|
daemon_supported_versions: getSupportedProtocolVersions(),
|
|
717
983
|
...getVersionNegotiationPayload(request.meta?.protocol_version),
|
|
718
984
|
});
|
|
@@ -732,6 +998,7 @@ export class BridgeDaemon {
|
|
|
732
998
|
}
|
|
733
999
|
|
|
734
1000
|
if (request.method === 'daemon.metrics') {
|
|
1001
|
+
this.pruneDomBaselineOwners();
|
|
735
1002
|
const now = Date.now();
|
|
736
1003
|
const uptimeMs = this.startedAt > 0 ? now - this.startedAt : 0;
|
|
737
1004
|
const avgResponseTimeMs =
|
|
@@ -746,11 +1013,62 @@ export class BridgeDaemon {
|
|
|
746
1013
|
requestsProcessed: this.requestsProcessed,
|
|
747
1014
|
requestsFailed: this.requestsFailed,
|
|
748
1015
|
avgResponseTimeMs,
|
|
1016
|
+
domBaselineOwners: this.domBaselineOwners.size,
|
|
1017
|
+
recovery: this.recoveryTelemetry.snapshot('daemon'),
|
|
749
1018
|
});
|
|
750
1019
|
await writeJsonLine(socket, { type: 'agent.response', response });
|
|
751
1020
|
return;
|
|
752
1021
|
}
|
|
753
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
|
+
|
|
754
1072
|
if (request.method === 'setup.get_status') {
|
|
755
1073
|
const response = createSuccess(request.id, await this.setupStatusLoader(), {
|
|
756
1074
|
method: request.method,
|
|
@@ -799,17 +1117,48 @@ export class BridgeDaemon {
|
|
|
799
1117
|
typeof request.meta?.target_profile === 'string' ? request.meta.target_profile : null;
|
|
800
1118
|
const hasExplicitTarget = Boolean(targetBrowser || targetProfile);
|
|
801
1119
|
|
|
802
|
-
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);
|
|
803
1134
|
|
|
804
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
|
+
}
|
|
805
1151
|
const response = createFailure(
|
|
806
1152
|
request.id,
|
|
807
1153
|
ERROR_CODES.EXTENSION_DISCONNECTED,
|
|
808
1154
|
hasExplicitTarget
|
|
809
1155
|
? `No connected extension matches target_browser="${targetBrowser ?? '*'}" target_profile="${targetProfile ?? '*'}".`
|
|
810
|
-
: '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 }
|
|
811
1159
|
);
|
|
812
1160
|
await writeJsonLine(socket, { type: 'agent.response', response });
|
|
1161
|
+
this.recordDaemonRequestOutcome(request, false);
|
|
813
1162
|
return;
|
|
814
1163
|
}
|
|
815
1164
|
|
|
@@ -818,16 +1167,21 @@ export class BridgeDaemon {
|
|
|
818
1167
|
method: request.method,
|
|
819
1168
|
protocolVersion: request.meta?.protocol_version,
|
|
820
1169
|
source: typeof request.meta?.source === 'string' ? request.meta.source : '',
|
|
1170
|
+
baselineId,
|
|
1171
|
+
automaticMcpRetry: isAutomaticMcpRetry(request.meta),
|
|
821
1172
|
targets: new Set([target]),
|
|
822
1173
|
timeoutId: setTimeout(() => {
|
|
823
1174
|
const pending = this.pendingRequests.get(request.id);
|
|
824
1175
|
if (!pending) return;
|
|
1176
|
+
this.markAbandonedDomBaselineCreate(request.id, pending);
|
|
825
1177
|
this.clearPendingRequest(request.id, pending);
|
|
826
|
-
this.recordRequestCompletion(request.id, false);
|
|
1178
|
+
this.recordRequestCompletion(request.id, false, pending);
|
|
827
1179
|
const response = createFailure(
|
|
828
1180
|
request.id,
|
|
829
1181
|
ERROR_CODES.TIMEOUT,
|
|
830
|
-
'Extension did not respond in time.'
|
|
1182
|
+
'Extension did not respond in time.',
|
|
1183
|
+
null,
|
|
1184
|
+
{ method: pending.method }
|
|
831
1185
|
);
|
|
832
1186
|
void writeJsonLine(pending.socket, {
|
|
833
1187
|
type: 'agent.response',
|
|
@@ -918,6 +1272,113 @@ export class BridgeDaemon {
|
|
|
918
1272
|
return candidates[0]?.[1] ?? null;
|
|
919
1273
|
}
|
|
920
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
|
+
|
|
921
1382
|
/**
|
|
922
1383
|
* @param {BridgeRequest} request
|
|
923
1384
|
* @returns {number}
|
|
@@ -975,6 +1436,11 @@ export class BridgeDaemon {
|
|
|
975
1436
|
changed = changed || socket.__profileLabel !== message.profileLabel;
|
|
976
1437
|
socket.__profileLabel = message.profileLabel;
|
|
977
1438
|
}
|
|
1439
|
+
if (typeof message.browserExtensionId === 'string') {
|
|
1440
|
+
const browserExtensionId = normalizeBrowserExtensionId(message.browserExtensionId);
|
|
1441
|
+
changed = changed || socket.__browserExtensionId !== browserExtensionId;
|
|
1442
|
+
socket.__browserExtensionId = browserExtensionId;
|
|
1443
|
+
}
|
|
978
1444
|
if (changed) {
|
|
979
1445
|
this.invalidateConnectedExtensionsCache();
|
|
980
1446
|
}
|
|
@@ -987,6 +1453,10 @@ export class BridgeDaemon {
|
|
|
987
1453
|
*/
|
|
988
1454
|
handleExtensionAccessUpdate(socket, message) {
|
|
989
1455
|
const accessEnabled = Boolean(message.accessEnabled);
|
|
1456
|
+
if (!accessEnabled && socket.__extensionId) {
|
|
1457
|
+
this.artifactStore.deleteByExtension(socket.__extensionId);
|
|
1458
|
+
this.clearDomBaselineOwnersForSocket(socket);
|
|
1459
|
+
}
|
|
990
1460
|
if (socket.__accessEnabled !== accessEnabled) {
|
|
991
1461
|
socket.__accessEnabled = accessEnabled;
|
|
992
1462
|
this.invalidateConnectedExtensionsCache();
|
|
@@ -995,6 +1465,58 @@ export class BridgeDaemon {
|
|
|
995
1465
|
socket.__accessEnabled = accessEnabled;
|
|
996
1466
|
}
|
|
997
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
|
+
|
|
998
1520
|
/**
|
|
999
1521
|
* Structured remote-exposure status for health payloads. The proxy counts
|
|
1000
1522
|
* as enabled only when the daemon listens on a TCP address other than
|
|
@@ -1035,22 +1557,88 @@ export class BridgeDaemon {
|
|
|
1035
1557
|
}
|
|
1036
1558
|
|
|
1037
1559
|
const pending = this.pendingRequests.get(responseMessage.id);
|
|
1038
|
-
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)) {
|
|
1039
1576
|
return;
|
|
1040
1577
|
}
|
|
1041
1578
|
|
|
1042
1579
|
this.removePendingTarget(responseMessage.id, pending, socket);
|
|
1043
1580
|
|
|
1044
1581
|
if (responseMessage.ok) {
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
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
|
+
}
|
|
1054
1642
|
const response =
|
|
1055
1643
|
pending.method === 'health.ping'
|
|
1056
1644
|
? createSuccess(
|
|
@@ -1061,27 +1649,54 @@ export class BridgeDaemon {
|
|
|
1061
1649
|
socketPath: this.socketPath,
|
|
1062
1650
|
transport: formatBridgeTransport(this.transport),
|
|
1063
1651
|
connectedExtensions: this.getConnectedExtensionsSnapshot(),
|
|
1652
|
+
...normalizeExtensionHealthResult(responseMessage.result),
|
|
1064
1653
|
...getVersionNegotiationPayload(pending.protocolVersion),
|
|
1065
|
-
.../** @type {Record<string, unknown>} */ (responseMessage.result),
|
|
1066
1654
|
daemonVersion: DAEMON_VERSION,
|
|
1067
1655
|
daemon_supported_versions: getSupportedProtocolVersions(),
|
|
1068
1656
|
proxy: this.getProxyStatusPayload(),
|
|
1657
|
+
recovery: createHealthRecoveryPayload(
|
|
1658
|
+
this.recoveryTelemetry.snapshot('daemon'),
|
|
1659
|
+
responseMessage.result
|
|
1660
|
+
),
|
|
1069
1661
|
},
|
|
1070
1662
|
{
|
|
1071
|
-
|
|
1072
|
-
method: responseMessage.meta?.method ?? pending.method,
|
|
1663
|
+
method: pending.method,
|
|
1073
1664
|
}
|
|
1074
1665
|
)
|
|
1075
1666
|
: responseMessage;
|
|
1076
1667
|
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
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,
|
|
1080
1692
|
});
|
|
1081
1693
|
return;
|
|
1082
1694
|
}
|
|
1083
1695
|
|
|
1084
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
|
+
}
|
|
1085
1700
|
pending.lastErrorResponse = responseMessage;
|
|
1086
1701
|
|
|
1087
1702
|
await this.finishPendingRequestIfExhausted(responseMessage.id, pending);
|
|
@@ -1093,6 +1708,8 @@ export class BridgeDaemon {
|
|
|
1093
1708
|
*/
|
|
1094
1709
|
handleSocketClose(socket) {
|
|
1095
1710
|
if (socket.__extensionId) {
|
|
1711
|
+
this.artifactStore.deleteByExtension(socket.__extensionId);
|
|
1712
|
+
this.clearDomBaselineOwnersForSocket(socket);
|
|
1096
1713
|
this.logger.info('extension disconnected', { extensionId: socket.__extensionId });
|
|
1097
1714
|
if (this.extensionSockets.get(socket.__extensionId) === socket) {
|
|
1098
1715
|
this.extensionSockets.delete(socket.__extensionId);
|
|
@@ -1114,8 +1731,9 @@ export class BridgeDaemon {
|
|
|
1114
1731
|
if (!pending) {
|
|
1115
1732
|
continue;
|
|
1116
1733
|
}
|
|
1734
|
+
this.markAbandonedDomBaselineCreate(id, pending);
|
|
1117
1735
|
this.clearPendingRequest(id, pending);
|
|
1118
|
-
this.recordRequestCompletion(id, false);
|
|
1736
|
+
this.recordRequestCompletion(id, false, pending);
|
|
1119
1737
|
}
|
|
1120
1738
|
}
|
|
1121
1739
|
|
|
@@ -1152,7 +1770,7 @@ export class BridgeDaemon {
|
|
|
1152
1770
|
}
|
|
1153
1771
|
|
|
1154
1772
|
this.clearPendingRequest(requestId, pending);
|
|
1155
|
-
this.recordRequestCompletion(requestId, false);
|
|
1773
|
+
this.recordRequestCompletion(requestId, false, pending);
|
|
1156
1774
|
|
|
1157
1775
|
const response =
|
|
1158
1776
|
pending.lastErrorResponse ??
|
|
@@ -1181,9 +1799,10 @@ export class BridgeDaemon {
|
|
|
1181
1799
|
/**
|
|
1182
1800
|
* @param {string} requestId
|
|
1183
1801
|
* @param {boolean} ok
|
|
1802
|
+
* @param {PendingEntry | undefined} [pending]
|
|
1184
1803
|
* @returns {void}
|
|
1185
1804
|
*/
|
|
1186
|
-
recordRequestCompletion(requestId, ok) {
|
|
1805
|
+
recordRequestCompletion(requestId, ok, pending) {
|
|
1187
1806
|
const startedAt = this.requestStartTimes.get(requestId);
|
|
1188
1807
|
this.requestStartTimes.delete(requestId);
|
|
1189
1808
|
this.requestsProcessed += 1;
|
|
@@ -1193,6 +1812,34 @@ export class BridgeDaemon {
|
|
|
1193
1812
|
if (typeof startedAt === 'number') {
|
|
1194
1813
|
this.totalResponseTimeMs += Date.now() - startedAt;
|
|
1195
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
|
+
}
|
|
1196
1843
|
}
|
|
1197
1844
|
|
|
1198
1845
|
/**
|
|
@@ -1200,13 +1847,34 @@ export class BridgeDaemon {
|
|
|
1200
1847
|
* @returns {void}
|
|
1201
1848
|
*/
|
|
1202
1849
|
pushLog(entry) {
|
|
1203
|
-
this.recentLog.push(entry);
|
|
1850
|
+
this.recentLog.push(/** @type {Record<string, unknown>} */ (sanitizeIncidentalValue(entry)));
|
|
1204
1851
|
if (this.recentLog.length > DAEMON_RECENT_LOG_LIMIT) {
|
|
1205
1852
|
this.recentLog.shift();
|
|
1206
1853
|
}
|
|
1207
1854
|
}
|
|
1208
1855
|
}
|
|
1209
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
|
+
|
|
1210
1878
|
/**
|
|
1211
1879
|
* Check whether a daemon is already listening on the given transport.
|
|
1212
1880
|
* Connects, sends a health.ping, and waits up to 500 ms for a response.
|