@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.
- package/PRIVACY.md +1 -0
- package/package.json +1 -1
- package/packages/agent-client/src/cli-args.js +22 -3
- package/packages/agent-client/src/cli-batch.js +14 -3
- package/packages/agent-client/src/cli.js +4 -2
- package/packages/agent-client/src/command-registry.js +1 -1
- package/packages/agent-client/src/runtime.js +12 -7
- package/packages/mcp-server/src/guidance.js +2 -1
- package/packages/mcp-server/src/handlers-navigation.js +9 -4
- package/packages/mcp-server/src/handlers-page.js +14 -1
- package/packages/mcp-server/src/handlers-utils.js +175 -1
- package/packages/mcp-server/src/handlers.js +5 -1
- package/packages/mcp-server/src/server.js +51 -4
- package/packages/native-host/src/daemon.js +120 -10
- package/packages/native-host/src/native-host.js +40 -2
- package/packages/native-host/src/window-actions.js +115 -0
- package/packages/protocol/src/protocol.js +62 -1
- package/packages/protocol/src/types.ts +10 -0
- package/packages/protocol/src/window-access.ts +29 -0
- package/packages/protocol/src/window-actions.js +46 -0
- package/skills/browser-bridge/SKILL.md +21 -1
- package/skills/browser-bridge/references/capabilities.md +8 -6
|
@@ -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' ||
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|