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