@browserbridge/bbx 1.12.0 → 1.12.2

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.
@@ -32,6 +32,7 @@ import {
32
32
  parseJsonLines,
33
33
  RecoveryTelemetryCollector,
34
34
  normalizeRecoveryTelemetrySummary,
35
+ sanitizeIncidentalText,
35
36
  sanitizeIncidentalValue,
36
37
  setProtocolPackageVersion,
37
38
  validateBridgeRequest,
@@ -54,6 +55,7 @@ import {
54
55
  import { normalizeDaemonLogger } from './daemon-logger.js';
55
56
  import { DomBaselineOwnerRegistry, isValidBaselineId } from './dom-baseline-owners.js';
56
57
  import { writeJsonLine } from './framing.js';
58
+ import { WindowActionRouter } from './window-actions.js';
57
59
 
58
60
  const DAEMON_VERSION = loadDaemonVersion();
59
61
  setProtocolPackageVersion(DAEMON_VERSION);
@@ -65,7 +67,7 @@ setProtocolPackageVersion(DAEMON_VERSION);
65
67
  /** @typedef {import('./config.js').BridgeTransport} BridgeTransport */
66
68
  /** @typedef {import('./daemon-logger.js').DaemonLoggerLike} DaemonLoggerLike */
67
69
  /** @typedef {'agent' | 'extension'} SocketRole */
68
- /** @typedef {import('node:net').Socket & { readonly __role?: SocketRole, __localControl?: boolean, __registrationTimeoutId?: NodeJS.Timeout, __invalidLineCount?: number, __clientId?: string, __extensionId?: string, __browserName?: string, __profileLabel?: string, __browserExtensionId?: string, __accessEnabled?: boolean, __lastActiveAt?: number }} ClientSocket */
70
+ /** @typedef {import('node:net').Socket & { readonly __role?: SocketRole, __localControl?: boolean, __registrationTimeoutId?: NodeJS.Timeout, __invalidLineCount?: number, __clientId?: string, __extensionId?: string, __browserName?: string, __profileLabel?: string, __browserExtensionId?: string, __accessEnabled?: boolean, __windowAccessUpdates?: boolean, __windowActions?: boolean, __enabledWindow?: import('../../protocol/src/window-access.js').EnabledWindowInfo | null, __lastActiveAt?: number }} ClientSocket */
69
71
  /** @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 */
70
72
  /**
71
73
  * @typedef {{
@@ -410,12 +412,20 @@ export function isWindowsNamedPipePath(socketPath) {
410
412
  * entry?: Record<string, unknown>,
411
413
  * request?: BridgeRequest,
412
414
  * status?: SetupStatus,
413
- * error?: { message?: string },
415
+ * error?: { message?: string } | string,
414
416
  * response?: import('../../protocol/src/types.js').BridgeResponse,
415
417
  * browserName?: string,
416
418
  * profileLabel?: string,
417
419
  * browserExtensionId?: string,
418
420
  * accessEnabled?: boolean,
421
+ * enabledWindow?: unknown,
422
+ * windowAccessUpdates?: boolean,
423
+ * windowActions?: boolean,
424
+ * action?: unknown,
425
+ * windowId?: unknown,
426
+ * enabledAt?: unknown,
427
+ * extensionId?: unknown,
428
+ * ok?: unknown,
419
429
  * authToken?: string,
420
430
  * at?: number
421
431
  * artifactId?: string,
@@ -466,6 +476,7 @@ export class BridgeDaemon {
466
476
  this.serverAddress = null;
467
477
  /** @type {Map<string, ClientSocket>} */
468
478
  this.extensionSockets = new Map();
479
+ this.windowActions = new WindowActionRouter(this.extensionSockets);
469
480
  /** @type {Map<string, ClientSocket>} */
470
481
  this.agentSockets = new Map();
471
482
  /** @type {Set<ClientSocket>} */
@@ -788,7 +799,9 @@ export class BridgeDaemon {
788
799
  if (typedSocket.destroyed) return;
789
800
  const message = /** @type {DaemonMessage} */ (raw);
790
801
  const admitted =
791
- message?.type === 'agent.request' || message?.type === 'extension.setup_status.request';
802
+ message?.type === 'agent.request' ||
803
+ message?.type === 'extension.setup_status.request' ||
804
+ message?.type === 'extension.window_action.request';
792
805
  const active = this.activeHandlers.get(typedSocket) ?? 0;
793
806
  if (
794
807
  admitted &&
@@ -882,6 +895,7 @@ export class BridgeDaemon {
882
895
  * @returns {Promise<void>}
883
896
  */
884
897
  async stopInternal() {
898
+ this.windowActions.clear();
885
899
  for (const pending of this.pendingRequests.values()) {
886
900
  clearTimeout(pending.timeoutId);
887
901
  }
@@ -978,6 +992,17 @@ export class BridgeDaemon {
978
992
  return this.handleExtensionAccessUpdate(socket, message);
979
993
  }
980
994
 
995
+ if (
996
+ message?.type === 'extension.window_action.request' ||
997
+ message?.type === 'extension.window_action.result'
998
+ ) {
999
+ if (socket.__role !== 'extension') return this.rejectMessageForRole(socket, message);
1000
+ if (message.type === 'extension.window_action.request')
1001
+ return this.windowActions.request(socket, message);
1002
+ this.windowActions.result(socket, message);
1003
+ return;
1004
+ }
1005
+
981
1006
  if (message?.type === 'extension.activity') {
982
1007
  if (socket.__role !== 'extension') {
983
1008
  return this.rejectMessageForRole(socket, message);
@@ -1257,7 +1282,9 @@ export class BridgeDaemon {
1257
1282
  typeof request.meta?.target_browser === 'string' ? request.meta.target_browser : null;
1258
1283
  const targetProfile =
1259
1284
  typeof request.meta?.target_profile === 'string' ? request.meta.target_profile : null;
1260
- const hasExplicitTarget = Boolean(targetBrowser || targetProfile);
1285
+ const targetExtension =
1286
+ typeof request.meta?.target_extension === 'string' ? request.meta.target_extension : null;
1287
+ const hasExplicitTarget = Boolean(targetBrowser || targetProfile || targetExtension);
1261
1288
 
1262
1289
  const ownerPendingCount = this.pendingRequestsByOwnerSocket.get(socket)?.size ?? 0;
1263
1290
  if (
@@ -1286,13 +1313,42 @@ export class BridgeDaemon {
1286
1313
  request.method === 'dom.baseline.describe' ||
1287
1314
  request.method === 'dom.baseline.release';
1288
1315
  const baselineOwner = usesBaselineOwner ? this.domBaselines.get(baselineId ?? '') : null;
1316
+ const candidates = [...this.extensionSockets.entries()].filter(
1317
+ ([id, extension]) =>
1318
+ (!targetExtension || id === targetExtension) &&
1319
+ (!targetBrowser || extension.__browserName === targetBrowser) &&
1320
+ (!targetProfile || extension.__profileLabel === targetProfile)
1321
+ );
1322
+ if (
1323
+ !usesBaselineOwner &&
1324
+ candidates.length > 1 &&
1325
+ request.method !== 'health.ping' &&
1326
+ request.method !== 'skill.get_runtime_context'
1327
+ ) {
1328
+ await writeJsonLine(socket, {
1329
+ type: 'agent.response',
1330
+ response: createFailure(
1331
+ request.id,
1332
+ ERROR_CODES.TAB_MISMATCH,
1333
+ 'Multiple browser profiles match this call. Select a connection with extensionId in MCP or meta.target_extension in raw requests, then list its tabs. A tabId alone does not select a profile. Do not disable other windows.',
1334
+ {
1335
+ reason: 'ambiguous_browser_target',
1336
+ connectedExtensions: this.getConnectedExtensionsSnapshot(),
1337
+ },
1338
+ { method: request.method }
1339
+ ),
1340
+ });
1341
+ this.recordDaemonRequestOutcome(request, false);
1342
+ return;
1343
+ }
1289
1344
  const target = usesBaselineOwner
1290
1345
  ? baselineOwner &&
1346
+ (!targetExtension || this.extensionSockets.get(targetExtension) === baselineOwner) &&
1291
1347
  (!targetBrowser || baselineOwner.__browserName === targetBrowser) &&
1292
1348
  (!targetProfile || baselineOwner.__profileLabel === targetProfile)
1293
1349
  ? baselineOwner
1294
1350
  : null
1295
- : this.selectExtensionTarget(targetBrowser, targetProfile);
1351
+ : this.selectExtensionTarget(targetBrowser, targetProfile, targetExtension);
1296
1352
 
1297
1353
  if (!target) {
1298
1354
  if (usesBaselineOwner) {
@@ -1314,9 +1370,12 @@ export class BridgeDaemon {
1314
1370
  request.id,
1315
1371
  ERROR_CODES.EXTENSION_DISCONNECTED,
1316
1372
  hasExplicitTarget
1317
- ? `No connected extension matches target_browser="${targetBrowser ?? '*'}" target_profile="${targetProfile ?? '*'}".`
1373
+ ? `No connected extension matches target_browser="${targetBrowser ?? '*'}" target_profile="${targetProfile ?? '*'}" target_extension="${targetExtension ?? '*'}".`
1318
1374
  : 'The Chrome extension is not connected to the local bridge daemon.',
1319
- null,
1375
+ {
1376
+ reason: hasExplicitTarget ? 'browser_target_disconnected' : 'extension_disconnected',
1377
+ connectedExtensions: this.getConnectedExtensionsSnapshot(),
1378
+ },
1320
1379
  { method: request.method }
1321
1380
  );
1322
1381
  await writeJsonLine(socket, { type: 'agent.response', response });
@@ -1380,6 +1439,7 @@ export class BridgeDaemon {
1380
1439
  if (target.__extensionId && this.extensionSockets.get(target.__extensionId) === target) {
1381
1440
  this.extensionSockets.delete(target.__extensionId);
1382
1441
  this.invalidateConnectedExtensionsCache();
1442
+ this.broadcastWindowAccess();
1383
1443
  }
1384
1444
  target.destroy(error instanceof Error ? error : undefined);
1385
1445
  await this.finishPendingRequestIfExhausted(request.id, pending);
@@ -1392,11 +1452,13 @@ export class BridgeDaemon {
1392
1452
  *
1393
1453
  * @param {string | null} targetBrowser
1394
1454
  * @param {string | null} targetProfile
1455
+ * @param {string | null} [targetExtension]
1395
1456
  * @returns {ClientSocket | null}
1396
1457
  */
1397
- selectExtensionTarget(targetBrowser, targetProfile) {
1398
- const candidates = Array.from(this.extensionSockets.entries()).filter(([, extSocket]) => {
1458
+ selectExtensionTarget(targetBrowser, targetProfile, targetExtension = null) {
1459
+ const candidates = Array.from(this.extensionSockets.entries()).filter(([id, extSocket]) => {
1399
1460
  return (
1461
+ (!targetExtension || id === targetExtension) &&
1400
1462
  (!targetBrowser || extSocket.__browserName === targetBrowser) &&
1401
1463
  (!targetProfile || extSocket.__profileLabel === targetProfile)
1402
1464
  );
@@ -1495,6 +1557,10 @@ export class BridgeDaemon {
1495
1557
  */
1496
1558
  handleExtensionIdentity(socket, message) {
1497
1559
  let changed = false;
1560
+ if (message.windowAccessUpdates === true) {
1561
+ socket.__windowAccessUpdates = true;
1562
+ }
1563
+ if (message.windowActions === true) socket.__windowActions = true;
1498
1564
  const browserName = normalizeRoutingLabel(message.browserName);
1499
1565
  if (browserName) {
1500
1566
  changed = changed || socket.__browserName !== browserName;
@@ -1513,6 +1579,7 @@ export class BridgeDaemon {
1513
1579
  if (changed) {
1514
1580
  this.invalidateConnectedExtensionsCache();
1515
1581
  }
1582
+ this.broadcastWindowAccess();
1516
1583
  }
1517
1584
 
1518
1585
  /**
@@ -1522,6 +1589,24 @@ export class BridgeDaemon {
1522
1589
  */
1523
1590
  handleExtensionAccessUpdate(socket, message) {
1524
1591
  const accessEnabled = Boolean(message.accessEnabled);
1592
+ const value = message.enabledWindow;
1593
+ const window =
1594
+ value && typeof value === 'object' ? /** @type {Record<string, unknown>} */ (value) : null;
1595
+ socket.__enabledWindow =
1596
+ accessEnabled &&
1597
+ window &&
1598
+ typeof window.windowId === 'number' &&
1599
+ Number.isInteger(window.windowId) &&
1600
+ window.windowId > 0 &&
1601
+ typeof window.title === 'string' &&
1602
+ typeof window.enabledAt === 'number' &&
1603
+ Number.isFinite(window.enabledAt)
1604
+ ? {
1605
+ windowId: window.windowId,
1606
+ title: sanitizeIncidentalText(window.title).slice(0, 200),
1607
+ enabledAt: window.enabledAt,
1608
+ }
1609
+ : null;
1525
1610
  if (!accessEnabled && socket.__extensionId) {
1526
1611
  this.artifactStore.deleteByExtension(socket.__extensionId);
1527
1612
  this.domBaselines.clearForSocket(socket);
@@ -1529,9 +1614,32 @@ export class BridgeDaemon {
1529
1614
  if (socket.__accessEnabled !== accessEnabled) {
1530
1615
  socket.__accessEnabled = accessEnabled;
1531
1616
  this.invalidateConnectedExtensionsCache();
1532
- return;
1533
1617
  }
1534
1618
  socket.__accessEnabled = accessEnabled;
1619
+ this.broadcastWindowAccess();
1620
+ }
1621
+
1622
+ /** Broadcast access metadata only to locally authenticated extension connections.
1623
+ * @returns {void}
1624
+ */
1625
+ broadcastWindowAccess() {
1626
+ /** @type {import('../../protocol/src/window-access.js').BrowserWindowAccess[]} */
1627
+ const enabledWindows = [...this.extensionSockets.entries()]
1628
+ .filter(([, socket]) => socket.__accessEnabled === true && !socket.destroyed)
1629
+ .map(([extensionId, socket]) => ({
1630
+ extensionId,
1631
+ browserName: socket.__browserName ?? null,
1632
+ profileLabel: socket.__profileLabel ?? null,
1633
+ window: socket.__enabledWindow ?? null,
1634
+ ...(socket.__windowActions ? { canControl: true } : {}),
1635
+ }));
1636
+ for (const [extensionId, socket] of this.extensionSockets) {
1637
+ if (socket.destroyed || !socket.__windowAccessUpdates) continue;
1638
+ void writeJsonLine(socket, {
1639
+ type: 'extension.window_access',
1640
+ otherEnabledWindows: enabledWindows.filter((window) => window.extensionId !== extensionId),
1641
+ }).catch(() => socket.destroy());
1642
+ }
1535
1643
  }
1536
1644
 
1537
1645
  /**
@@ -1787,6 +1895,7 @@ export class BridgeDaemon {
1787
1895
  * @returns {void}
1788
1896
  */
1789
1897
  handleSocketClose(socket) {
1898
+ this.windowActions.disconnect(socket);
1790
1899
  this.clientSockets.delete(socket);
1791
1900
  if (socket.__registrationTimeoutId) {
1792
1901
  clearTimeout(socket.__registrationTimeoutId);
@@ -1799,6 +1908,7 @@ export class BridgeDaemon {
1799
1908
  if (this.extensionSockets.get(socket.__extensionId) === socket) {
1800
1909
  this.extensionSockets.delete(socket.__extensionId);
1801
1910
  this.invalidateConnectedExtensionsCache();
1911
+ this.broadcastWindowAccess();
1802
1912
  }
1803
1913
  }
1804
1914
 
@@ -34,14 +34,17 @@ import { createNativeMessageReader, createNativeMessageWriter, writeJsonLine } f
34
34
  * type?: string,
35
35
  * browserName?: string,
36
36
  * profileLabel?: string,
37
- * browserExtensionId?: string
37
+ * browserExtensionId?: string,
38
+ * windowAccessUpdates?: boolean,
39
+ * windowActions?: boolean
38
40
  * }} HostIdentityMessage
39
41
  */
40
42
 
41
43
  /**
42
44
  * @typedef {{
43
45
  * type?: string,
44
- * accessEnabled: boolean
46
+ * accessEnabled: boolean,
47
+ * enabledWindow?: unknown
45
48
  * }} HostAccessUpdateMessage
46
49
  */
47
50
 
@@ -187,6 +190,23 @@ export async function runNativeHost({
187
190
  });
188
191
  return;
189
192
  }
193
+ if (message.type === 'extension.window_access') {
194
+ await writeNativeMessageQueued({
195
+ type: 'host.window_access',
196
+ otherEnabledWindows: message.otherEnabledWindows,
197
+ });
198
+ return;
199
+ }
200
+ if (
201
+ message.type === 'extension.window_action.command' ||
202
+ message.type === 'extension.window_action.response'
203
+ ) {
204
+ await writeNativeMessageQueued({
205
+ ...message,
206
+ type: message.type.replace(/^extension\./u, 'host.'),
207
+ });
208
+ return;
209
+ }
190
210
  if (
191
211
  message.type === 'extension.setup_status.response' ||
192
212
  message.type === 'extension.setup_status.error'
@@ -244,6 +264,8 @@ export async function runNativeHost({
244
264
  browserName: message.browserName,
245
265
  profileLabel: message.profileLabel,
246
266
  browserExtensionId: message.browserExtensionId,
267
+ ...(message.windowAccessUpdates === true ? { windowAccessUpdates: true } : {}),
268
+ ...(message.windowActions === true ? { windowActions: true } : {}),
247
269
  });
248
270
  return;
249
271
  }
@@ -251,6 +273,9 @@ export async function runNativeHost({
251
273
  await writeJsonLine(socket, {
252
274
  type: 'extension.access_update',
253
275
  accessEnabled: message.accessEnabled,
276
+ ...(message.enabledWindow !== undefined
277
+ ? { enabledWindow: message.enabledWindow }
278
+ : {}),
254
279
  });
255
280
  return;
256
281
  }
@@ -261,6 +286,19 @@ export async function runNativeHost({
261
286
  });
262
287
  return;
263
288
  }
289
+ if (message && typeof message === 'object') {
290
+ const record = /** @type {Record<string, unknown>} */ (message);
291
+ if (
292
+ record.type === 'host.window_action.request' ||
293
+ record.type === 'host.window_action.result'
294
+ ) {
295
+ await writeJsonLine(socket, {
296
+ ...record,
297
+ type: record.type.replace(/^host\./u, 'extension.'),
298
+ });
299
+ return;
300
+ }
301
+ }
264
302
  if (isHostArtifactMessage(message)) {
265
303
  const record = /** @type {Record<string, unknown>} */ (message);
266
304
  await writeJsonLine(socket, {
@@ -0,0 +1,115 @@
1
+ // @ts-check
2
+
3
+ import { randomUUID } from 'node:crypto';
4
+ import { sanitizeIncidentalText } from '../../protocol/src/index.js';
5
+ import { parseWindowActionRequest } from '../../protocol/src/window-actions.js';
6
+ import { writeJsonLine } from './framing.js';
7
+
8
+ /** @typedef {import('./daemon.js').ClientSocket} ClientSocket */
9
+ /** @typedef {{ source: ClientSocket, target: ClientSocket, finish: (error: string | null) => void }} PendingWindowAction */
10
+
11
+ /** Relay only user UI actions between authenticated local extensions. */
12
+ export class WindowActionRouter {
13
+ /** @param {Map<string, ClientSocket>} extensions */
14
+ constructor(extensions) {
15
+ this.extensions = extensions;
16
+ /** @type {Map<string, PendingWindowAction>} */
17
+ this.pending = new Map();
18
+ }
19
+
20
+ /**
21
+ * @param {ClientSocket} source
22
+ * @param {Record<string, unknown>} message
23
+ * @returns {Promise<void>}
24
+ */
25
+ async request(source, message) {
26
+ const request = parseWindowActionRequest(message);
27
+ const target = request ? this.extensions.get(request.extensionId) : null;
28
+ /** @type {string | null} */
29
+ let error = null;
30
+ if (
31
+ !request ||
32
+ source.__role !== 'extension' ||
33
+ !source.__windowActions ||
34
+ this.extensions.get(source.__extensionId ?? '') !== source
35
+ ) {
36
+ error = 'Invalid window action request.';
37
+ } else if (!target || target.destroyed || target === source) {
38
+ error = 'The other browser disconnected. Refresh the list and try again.';
39
+ } else if (!target.__windowActions || !target.__enabledWindow) {
40
+ error = "Update the other browser's extension and native host to control its window.";
41
+ } else if (
42
+ !target.__accessEnabled ||
43
+ target.__enabledWindow.windowId !== request.windowId ||
44
+ target.__enabledWindow.enabledAt !== request.enabledAt
45
+ ) {
46
+ error = 'Window access changed. Refresh the list and try again.';
47
+ } else if (
48
+ this.pending.size >= 64 ||
49
+ [...this.pending.values()].filter((entry) => entry.source === source).length >= 16
50
+ ) {
51
+ error = 'Too many window actions are pending. Try again shortly.';
52
+ } else {
53
+ const requestId = randomUUID();
54
+ error = await new Promise((resolve) => {
55
+ const timer = setTimeout(
56
+ () => finish('The other browser did not respond. Try again.'),
57
+ 5_000
58
+ );
59
+ /** @param {string | null} failure */
60
+ const finish = (failure) => {
61
+ if (!this.pending.delete(requestId)) return;
62
+ clearTimeout(timer);
63
+ resolve(failure);
64
+ };
65
+ this.pending.set(requestId, { source, target, finish });
66
+ void writeJsonLine(target, {
67
+ type: 'extension.window_action.command',
68
+ ...request,
69
+ requestId,
70
+ }).catch(() => finish('The other browser disconnected. Try again.'));
71
+ });
72
+ }
73
+ if (!source.destroyed) {
74
+ await writeJsonLine(source, {
75
+ type: 'extension.window_action.response',
76
+ requestId:
77
+ request?.requestId ??
78
+ (typeof message.requestId === 'string' ? message.requestId.slice(0, 80) : 'invalid'),
79
+ ok: error === null,
80
+ ...(error ? { error } : {}),
81
+ });
82
+ }
83
+ }
84
+
85
+ /**
86
+ * @param {ClientSocket} source
87
+ * @param {Record<string, unknown>} message
88
+ * @returns {void}
89
+ */
90
+ result(source, message) {
91
+ const pending =
92
+ typeof message.requestId === 'string' ? this.pending.get(message.requestId) : null;
93
+ if (!pending || pending.target !== source) return;
94
+ pending.finish(
95
+ message.ok === true
96
+ ? null
97
+ : typeof message.error === 'string'
98
+ ? sanitizeIncidentalText(message.error).slice(0, 200) || 'Window action failed.'
99
+ : 'Window action failed.'
100
+ );
101
+ }
102
+
103
+ /** @param {ClientSocket} socket @returns {void} */
104
+ disconnect(socket) {
105
+ for (const pending of this.pending.values()) {
106
+ if (pending.source === socket || pending.target === socket)
107
+ pending.finish('The browser disconnected. Try again.');
108
+ }
109
+ }
110
+
111
+ /** @returns {void} */
112
+ clear() {
113
+ for (const pending of this.pending.values()) pending.finish('The daemon stopped. Try again.');
114
+ }
115
+ }
@@ -296,7 +296,7 @@ export function createSuccess(id, result, meta = {}) {
296
296
  * @returns {BridgeFailureResponse}
297
297
  */
298
298
  export function createFailure(id, code, message, details = null, meta = {}) {
299
- const recovery = getErrorRecovery(code);
299
+ const recovery = getRoutingRecovery(code, details) ?? getErrorRecovery(code);
300
300
  const effectiveRecovery =
301
301
  recovery && meta.method === 'sensitive.read'
302
302
  ? {
@@ -322,6 +322,35 @@ export function createFailure(id, code, message, details = null, meta = {}) {
322
322
  };
323
323
  }
324
324
 
325
+ /**
326
+ * Routing errors need selection guidance, not closed-tab or global enable hints.
327
+ * @param {import('./types.js').ErrorCode} code
328
+ * @param {unknown} details
329
+ * @returns {import('./types.js').BridgeRecovery | null}
330
+ */
331
+ function getRoutingRecovery(code, details) {
332
+ const reason =
333
+ details && typeof details === 'object' && 'reason' in details ? details.reason : null;
334
+ if (
335
+ (code === ERROR_CODES.TAB_MISMATCH && reason === 'ambiguous_browser_target') ||
336
+ (code === ERROR_CODES.EXTENSION_DISCONNECTED && reason === 'browser_target_disconnected')
337
+ ) {
338
+ return {
339
+ retry: false,
340
+ alternativeMethod: 'health.ping',
341
+ hint: 'Discover connectedExtensions with health.ping or list enabled profiles with MCP tabs.list. Select extensionId and tabId for the intended page. Do not disable unrelated windows or fall back to another profile.',
342
+ };
343
+ }
344
+ if (code === ERROR_CODES.ACCESS_DENIED && reason === 'no_enabled_browser_profiles') {
345
+ return {
346
+ retry: false,
347
+ alternativeMethod: 'health.ping',
348
+ hint: "Choose the intended extensionId from connectedExtensions before requesting access. Ask the user to enable only that profile's window.",
349
+ };
350
+ }
351
+ return null;
352
+ }
353
+
325
354
  /**
326
355
  * @param {unknown} request
327
356
  * @returns {BridgeRequest}
@@ -409,6 +438,7 @@ export function validateBridgeRequest(request) {
409
438
  params: normalizeRequestParams(method, params),
410
439
  meta: {
411
440
  ...meta,
441
+ ...normalizeBrowserTargetMeta(meta),
412
442
  protocol_version:
413
443
  typeof meta.protocol_version === 'string' ? meta.protocol_version : getProtocolVersion(),
414
444
  token_budget: typeof meta.token_budget === 'number' ? meta.token_budget : null,
@@ -424,6 +454,37 @@ export function validateBridgeRequest(request) {
424
454
  };
425
455
  }
426
456
 
457
+ /**
458
+ * Never turn a malformed explicit selector into implicit routing.
459
+ * @param {Record<string, unknown>} meta
460
+ * @returns {Pick<BridgeMeta, 'target_extension' | 'target_browser' | 'target_profile'>}
461
+ */
462
+ function normalizeBrowserTargetMeta(meta) {
463
+ /** @type {Pick<BridgeMeta, 'target_extension' | 'target_browser' | 'target_profile'>} */
464
+ const target = {};
465
+ for (const field of /** @type {const} */ ([
466
+ 'target_extension',
467
+ 'target_browser',
468
+ 'target_profile',
469
+ ])) {
470
+ const value = meta[field];
471
+ if (value === undefined) continue;
472
+ if (
473
+ typeof value !== 'string' ||
474
+ !value.trim() ||
475
+ value.length > 256 ||
476
+ hasAsciiControlCharacters(value)
477
+ ) {
478
+ throw new BridgeError(
479
+ ERROR_CODES.INVALID_REQUEST,
480
+ `meta.${field} must be a non-empty string of at most 256 characters without control characters.`
481
+ );
482
+ }
483
+ target[field] = value.trim();
484
+ }
485
+ return target;
486
+ }
487
+
427
488
  /** @param {string} value @returns {boolean} */
428
489
  function hasAsciiControlCharacters(value) {
429
490
  for (let index = 0; index < value.length; index += 1) {
@@ -144,7 +144,17 @@ export type BridgeMethod =
144
144
 
145
145
  export type CostClass = 'cheap' | 'moderate' | 'heavy' | 'extreme';
146
146
 
147
+ /** Browser connection selectors, independent of the machine destination and tab. */
148
+ export interface BrowserTarget {
149
+ extensionId?: string;
150
+ targetBrowser?: string;
151
+ targetProfile?: string;
152
+ }
153
+
147
154
  export interface BridgeMeta {
155
+ target_extension?: string;
156
+ target_browser?: string;
157
+ target_profile?: string;
148
158
  protocol_version?: string;
149
159
  token_budget?: number | null;
150
160
  transport_bytes?: number;
@@ -0,0 +1,29 @@
1
+ /** Window access metadata shared by the native relay and extension UI. */
2
+ export type EnabledWindowInfo = {
3
+ windowId: number;
4
+ title: string;
5
+ enabledAt: number;
6
+ };
7
+
8
+ export type BrowserWindowAccess = {
9
+ extensionId: string;
10
+ browserName: string | null;
11
+ profileLabel: string | null;
12
+ window: EnabledWindowInfo | null;
13
+ canControl?: boolean;
14
+ };
15
+
16
+ /** Local extension UI actions. These are not agent RPC methods. */
17
+ export type WindowAction = 'focus' | 'disable';
18
+
19
+ export type WindowActionCommand = {
20
+ requestId: string;
21
+ action: WindowAction;
22
+ windowId: number;
23
+ enabledAt: number;
24
+ };
25
+
26
+ export type WindowActionRequest = WindowActionCommand & { extensionId: string };
27
+
28
+ export type WindowActionResult = { requestId: string; ok: boolean; error?: string };
29
+ export type WindowActionUiResult = { action: WindowAction; ok: boolean; error?: string };
@@ -0,0 +1,46 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * @param {unknown} value
5
+ * @returns {import('./window-access.js').WindowActionCommand | null}
6
+ */
7
+ export function parseWindowActionCommand(value) {
8
+ if (!value || typeof value !== 'object') return null;
9
+ const message = /** @type {Record<string, unknown>} */ (value);
10
+ if (
11
+ typeof message.requestId !== 'string' ||
12
+ !message.requestId ||
13
+ message.requestId.length > 80 ||
14
+ (message.action !== 'focus' && message.action !== 'disable') ||
15
+ typeof message.windowId !== 'number' ||
16
+ !Number.isSafeInteger(message.windowId) ||
17
+ message.windowId <= 0 ||
18
+ typeof message.enabledAt !== 'number' ||
19
+ !Number.isFinite(message.enabledAt) ||
20
+ message.enabledAt < 0
21
+ )
22
+ return null;
23
+ return {
24
+ requestId: message.requestId,
25
+ action: message.action,
26
+ windowId: message.windowId,
27
+ enabledAt: message.enabledAt,
28
+ };
29
+ }
30
+
31
+ /**
32
+ * @param {unknown} value
33
+ * @returns {import('./window-access.js').WindowActionRequest | null}
34
+ */
35
+ export function parseWindowActionRequest(value) {
36
+ const command = parseWindowActionCommand(value);
37
+ if (!command) return null;
38
+ const message = /** @type {Record<string, unknown>} */ (value);
39
+ if (
40
+ typeof message.extensionId !== 'string' ||
41
+ !message.extensionId ||
42
+ message.extensionId.length > 80
43
+ )
44
+ return null;
45
+ return { ...command, extensionId: message.extensionId };
46
+ }