@browserbridge/bbx 1.3.0 → 1.5.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 +3 -1
- package/package.json +2 -2
- package/packages/agent-client/src/cli.js +45 -16
- package/packages/agent-client/src/client.js +74 -20
- package/packages/agent-client/src/command-registry.js +2 -3
- package/packages/agent-client/src/mcp-config.js +30 -27
- package/packages/agent-client/src/runtime.js +2 -10
- package/packages/agent-client/src/types.ts +10 -1
- package/packages/mcp-server/src/guidance.js +241 -0
- package/packages/mcp-server/src/handlers-capture.js +74 -11
- package/packages/mcp-server/src/handlers-dom.js +48 -0
- package/packages/mcp-server/src/handlers-navigation.js +22 -2
- package/packages/mcp-server/src/handlers-page.js +10 -9
- package/packages/mcp-server/src/handlers-utils.js +47 -1
- package/packages/mcp-server/src/server.js +111 -29
- package/packages/native-host/src/auth-token.js +92 -0
- package/packages/native-host/src/daemon-process.js +26 -4
- package/packages/native-host/src/daemon.js +174 -28
- package/packages/native-host/src/framing.js +7 -2
- package/packages/native-host/src/native-host.js +18 -2
- package/packages/protocol/src/defaults.js +3 -0
- package/packages/protocol/src/json-lines.js +29 -1
- package/packages/protocol/src/protocol.js +6 -1
- package/packages/protocol/src/types.ts +2 -0
- package/skills/browser-bridge/SKILL.md +21 -5
- package/skills/browser-bridge/agents/openai.yaml +1 -1
- package/skills/browser-bridge/references/interaction.md +6 -6
- package/skills/browser-bridge/references/protocol.md +57 -54
- package/skills/browser-bridge/references/ui-workflows.md +1 -1
|
@@ -36,6 +36,11 @@ import {
|
|
|
36
36
|
getBridgeTransport,
|
|
37
37
|
getSocketPath,
|
|
38
38
|
} from './config.js';
|
|
39
|
+
import {
|
|
40
|
+
ensureBridgeAuthToken,
|
|
41
|
+
normalizeBridgeAuthToken,
|
|
42
|
+
readBridgeAuthToken,
|
|
43
|
+
} from './auth-token.js';
|
|
39
44
|
import { normalizeDaemonLogger } from './daemon-logger.js';
|
|
40
45
|
import { writeJsonLine } from './framing.js';
|
|
41
46
|
|
|
@@ -47,8 +52,8 @@ const DAEMON_VERSION = loadDaemonVersion();
|
|
|
47
52
|
/** @typedef {import('../../protocol/src/types.js').SetupStatus} SetupStatus */
|
|
48
53
|
/** @typedef {import('./config.js').BridgeTransport} BridgeTransport */
|
|
49
54
|
/** @typedef {import('./daemon-logger.js').DaemonLoggerLike} DaemonLoggerLike */
|
|
50
|
-
/** @typedef {import('node:net').Socket & { __clientId?: string, __extensionId?: string, __browserName?: string, __profileLabel?: string, __accessEnabled?: boolean, __lastActiveAt?: number }} ClientSocket */
|
|
51
|
-
/** @typedef {{ socket: ClientSocket, timeoutId: NodeJS.Timeout, source?: string, method?: string, targets: Set<ClientSocket>, lastErrorResponse?: import('../../protocol/src/types.js').BridgeResponse }} PendingEntry */
|
|
55
|
+
/** @typedef {import('node:net').Socket & { __clientId?: string, __extensionId?: string, __browserName?: string, __profileLabel?: string, __accessEnabled?: boolean, __lastActiveAt?: number, __authenticated?: boolean }} ClientSocket */
|
|
56
|
+
/** @typedef {{ socket: ClientSocket, timeoutId: NodeJS.Timeout, source?: string, method?: string, protocolVersion?: string, targets: Set<ClientSocket>, lastErrorResponse?: import('../../protocol/src/types.js').BridgeResponse }} PendingEntry */
|
|
52
57
|
/**
|
|
53
58
|
* @typedef {{
|
|
54
59
|
* installAgentFiles: typeof import('../../agent-client/src/install.js').installAgentFiles,
|
|
@@ -134,6 +139,7 @@ export function isWindowsNamedPipePath(socketPath) {
|
|
|
134
139
|
* browserName?: string,
|
|
135
140
|
* profileLabel?: string,
|
|
136
141
|
* accessEnabled?: boolean,
|
|
142
|
+
* authToken?: string,
|
|
137
143
|
* at?: number
|
|
138
144
|
* }} DaemonMessage
|
|
139
145
|
*/
|
|
@@ -146,7 +152,8 @@ export class BridgeDaemon {
|
|
|
146
152
|
* listenOptions?: import('node:net').ListenOptions | null,
|
|
147
153
|
* setupStatusLoader?: () => Promise<SetupStatus>,
|
|
148
154
|
* setupInstaller?: (params: Record<string, unknown>) => Promise<SetupInstallResult>,
|
|
149
|
-
* logger?: DaemonLoggerLike | Pick<Console, 'log' | 'error'
|
|
155
|
+
* logger?: DaemonLoggerLike | Pick<Console, 'log' | 'error'>,
|
|
156
|
+
* authToken?: string | null
|
|
150
157
|
* }} [options={}]
|
|
151
158
|
*/
|
|
152
159
|
constructor({
|
|
@@ -156,6 +163,7 @@ export class BridgeDaemon {
|
|
|
156
163
|
setupStatusLoader = collectSetupStatus,
|
|
157
164
|
setupInstaller = installSetupTarget,
|
|
158
165
|
logger = undefined,
|
|
166
|
+
authToken = undefined,
|
|
159
167
|
} = {}) {
|
|
160
168
|
this.transport = socketPath ? createSocketBridgeTransport(socketPath) : transport;
|
|
161
169
|
this.socketPath =
|
|
@@ -196,6 +204,15 @@ export class BridgeDaemon {
|
|
|
196
204
|
this.totalResponseTimeMs = 0;
|
|
197
205
|
/** @type {Map<string, number>} */
|
|
198
206
|
this.requestStartTimes = new Map();
|
|
207
|
+
/** @type {string | null | undefined} */
|
|
208
|
+
this.authToken = authToken;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* @returns {boolean}
|
|
213
|
+
*/
|
|
214
|
+
isAuthRequired() {
|
|
215
|
+
return Boolean(this.authToken);
|
|
199
216
|
}
|
|
200
217
|
|
|
201
218
|
/**
|
|
@@ -260,6 +277,7 @@ export class BridgeDaemon {
|
|
|
260
277
|
if (!pending) {
|
|
261
278
|
return undefined;
|
|
262
279
|
}
|
|
280
|
+
clearTimeout(pending.timeoutId);
|
|
263
281
|
this.pendingRequests.delete(requestId);
|
|
264
282
|
this.removePendingRequestIndex(this.pendingRequestsByOwnerSocket, pending.socket, requestId);
|
|
265
283
|
for (const targetSocket of pending.targets) {
|
|
@@ -306,7 +324,20 @@ export class BridgeDaemon {
|
|
|
306
324
|
* @returns {void}
|
|
307
325
|
*/
|
|
308
326
|
registerSocket(socket, message) {
|
|
327
|
+
if (this.isAuthRequired() && normalizeBridgeAuthToken(message.authToken) !== this.authToken) {
|
|
328
|
+
this.logger.error('socket registration rejected', { role: message.role ?? null });
|
|
329
|
+
void writeJsonLine(socket, {
|
|
330
|
+
type: 'registration_failed',
|
|
331
|
+
error: {
|
|
332
|
+
code: ERROR_CODES.ACCESS_DENIED,
|
|
333
|
+
message: 'Bridge daemon authentication failed.',
|
|
334
|
+
},
|
|
335
|
+
}).finally(() => socket.destroy());
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
309
339
|
if (message.role === 'extension') {
|
|
340
|
+
socket.__authenticated = true;
|
|
310
341
|
const extensionId = randomUUID();
|
|
311
342
|
socket.__extensionId = extensionId;
|
|
312
343
|
socket.__browserName =
|
|
@@ -326,6 +357,7 @@ export class BridgeDaemon {
|
|
|
326
357
|
}
|
|
327
358
|
|
|
328
359
|
if (message.role === 'agent') {
|
|
360
|
+
socket.__authenticated = true;
|
|
329
361
|
const clientId = message.clientId || randomUUID();
|
|
330
362
|
this.agentSockets.set(clientId, socket);
|
|
331
363
|
socket.__clientId = clientId;
|
|
@@ -342,6 +374,10 @@ export class BridgeDaemon {
|
|
|
342
374
|
* @returns {Promise<BridgeDaemon>}
|
|
343
375
|
*/
|
|
344
376
|
async start() {
|
|
377
|
+
if (this.authToken === undefined) {
|
|
378
|
+
this.authToken = this.transport.type === 'tcp' ? await ensureBridgeAuthToken() : null;
|
|
379
|
+
}
|
|
380
|
+
|
|
345
381
|
if (this.transport.type === 'socket' && !isWindowsNamedPipePath(this.socketPath)) {
|
|
346
382
|
const socketDir = path.dirname(this.socketPath);
|
|
347
383
|
await fs.promises.mkdir(socketDir, { recursive: true });
|
|
@@ -372,14 +408,22 @@ export class BridgeDaemon {
|
|
|
372
408
|
typedSocket.on('error', (err) => {
|
|
373
409
|
this.logger.error('socket error', { message: err.message });
|
|
374
410
|
});
|
|
375
|
-
parseJsonLines(
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
411
|
+
parseJsonLines(
|
|
412
|
+
typedSocket,
|
|
413
|
+
(raw) => {
|
|
414
|
+
const message = /** @type {DaemonMessage} */ (raw);
|
|
415
|
+
void this.handleClientMessage(typedSocket, message).catch((err) => {
|
|
416
|
+
this.logger.error('handler error', {
|
|
417
|
+
message: err instanceof Error ? err.message : String(err),
|
|
418
|
+
});
|
|
380
419
|
});
|
|
381
|
-
}
|
|
382
|
-
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
onProtocolError: (error) => {
|
|
423
|
+
this.logger.error('socket protocol error', { message: error.message });
|
|
424
|
+
},
|
|
425
|
+
}
|
|
426
|
+
);
|
|
383
427
|
typedSocket.on('close', () => this.handleSocketClose(typedSocket));
|
|
384
428
|
});
|
|
385
429
|
|
|
@@ -477,6 +521,10 @@ export class BridgeDaemon {
|
|
|
477
521
|
return this.registerSocket(socket, message);
|
|
478
522
|
}
|
|
479
523
|
|
|
524
|
+
if (this.isAuthRequired() && !socket.__authenticated) {
|
|
525
|
+
return this.rejectUnauthenticatedMessage(socket, message);
|
|
526
|
+
}
|
|
527
|
+
|
|
480
528
|
if (message?.type === 'log') {
|
|
481
529
|
this.pushLog(message.entry ?? {});
|
|
482
530
|
return;
|
|
@@ -515,13 +563,73 @@ export class BridgeDaemon {
|
|
|
515
563
|
});
|
|
516
564
|
}
|
|
517
565
|
|
|
566
|
+
/**
|
|
567
|
+
* @param {ClientSocket} socket
|
|
568
|
+
* @param {DaemonMessage} message
|
|
569
|
+
* @returns {Promise<void>}
|
|
570
|
+
*/
|
|
571
|
+
async rejectUnauthenticatedMessage(socket, message) {
|
|
572
|
+
if (message?.type === 'agent.request') {
|
|
573
|
+
const candidate =
|
|
574
|
+
message.request && typeof message.request === 'object'
|
|
575
|
+
? /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (message.request))
|
|
576
|
+
: {};
|
|
577
|
+
const response = createFailure(
|
|
578
|
+
typeof candidate.id === 'string' && candidate.id.trim() ? candidate.id : 'unauthenticated',
|
|
579
|
+
ERROR_CODES.ACCESS_DENIED,
|
|
580
|
+
'Register with the daemon auth token before sending bridge requests.',
|
|
581
|
+
null,
|
|
582
|
+
typeof candidate.method === 'string' ? { method: candidate.method } : {}
|
|
583
|
+
);
|
|
584
|
+
await writeJsonLine(socket, { type: 'agent.response', response });
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
await writeJsonLine(socket, {
|
|
589
|
+
type: 'error',
|
|
590
|
+
error: {
|
|
591
|
+
code: ERROR_CODES.ACCESS_DENIED,
|
|
592
|
+
message: 'Register with the daemon auth token before sending bridge messages.',
|
|
593
|
+
},
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
|
|
518
597
|
/**
|
|
519
598
|
* @param {ClientSocket} socket
|
|
520
599
|
* @param {DaemonMessage} message
|
|
521
600
|
* @returns {Promise<void>}
|
|
522
601
|
*/
|
|
523
602
|
async handleAgentRequest(socket, message) {
|
|
524
|
-
|
|
603
|
+
/** @type {BridgeRequest} */
|
|
604
|
+
let request;
|
|
605
|
+
try {
|
|
606
|
+
request = validateBridgeRequest(message.request);
|
|
607
|
+
} catch (error) {
|
|
608
|
+
const candidate =
|
|
609
|
+
message.request && typeof message.request === 'object'
|
|
610
|
+
? /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (message.request))
|
|
611
|
+
: {};
|
|
612
|
+
const response = createFailure(
|
|
613
|
+
typeof candidate.id === 'string' && candidate.id.trim() ? candidate.id : 'invalid_request',
|
|
614
|
+
ERROR_CODES.INVALID_REQUEST,
|
|
615
|
+
error instanceof Error ? error.message : String(error),
|
|
616
|
+
null,
|
|
617
|
+
typeof candidate.method === 'string' ? { method: candidate.method } : {}
|
|
618
|
+
);
|
|
619
|
+
await writeJsonLine(socket, { type: 'agent.response', response });
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (this.pendingRequests.has(request.id)) {
|
|
624
|
+
const response = createFailure(
|
|
625
|
+
request.id,
|
|
626
|
+
ERROR_CODES.INVALID_REQUEST,
|
|
627
|
+
`Request id ${JSON.stringify(request.id)} is already in flight.`
|
|
628
|
+
);
|
|
629
|
+
await writeJsonLine(socket, { type: 'agent.response', response });
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
|
|
525
633
|
if (request.method === 'health.ping') {
|
|
526
634
|
if (this.extensionSockets.size === 0) {
|
|
527
635
|
const response = createSuccess(request.id, {
|
|
@@ -654,6 +762,7 @@ export class BridgeDaemon {
|
|
|
654
762
|
this.trackPendingRequest(request.id, {
|
|
655
763
|
socket,
|
|
656
764
|
method: request.method,
|
|
765
|
+
protocolVersion: request.meta?.protocol_version,
|
|
657
766
|
source: typeof request.meta?.source === 'string' ? request.meta.source : '',
|
|
658
767
|
targets: new Set(targets),
|
|
659
768
|
timeoutId: setTimeout(() => {
|
|
@@ -669,6 +778,12 @@ export class BridgeDaemon {
|
|
|
669
778
|
void writeJsonLine(pending.socket, {
|
|
670
779
|
type: 'agent.response',
|
|
671
780
|
response,
|
|
781
|
+
}).catch((error) => {
|
|
782
|
+
this.logger.error('timeout response write failed', {
|
|
783
|
+
requestId: request.id,
|
|
784
|
+
method: pending.method,
|
|
785
|
+
message: error instanceof Error ? error.message : String(error),
|
|
786
|
+
});
|
|
672
787
|
});
|
|
673
788
|
}, this.pendingTimeoutMs),
|
|
674
789
|
});
|
|
@@ -792,7 +907,6 @@ export class BridgeDaemon {
|
|
|
792
907
|
this.removePendingTarget(responseMessage.id, pending, socket);
|
|
793
908
|
|
|
794
909
|
if (responseMessage.ok) {
|
|
795
|
-
clearTimeout(pending.timeoutId);
|
|
796
910
|
this.clearPendingRequest(responseMessage.id, pending);
|
|
797
911
|
this.recordRequestCompletion(responseMessage.id, true);
|
|
798
912
|
this.pushLog({
|
|
@@ -812,6 +926,7 @@ export class BridgeDaemon {
|
|
|
812
926
|
socketPath: this.socketPath,
|
|
813
927
|
transport: formatBridgeTransport(this.transport),
|
|
814
928
|
connectedExtensions: this.getConnectedExtensionsSnapshot(),
|
|
929
|
+
...getVersionNegotiationPayload(pending.protocolVersion),
|
|
815
930
|
.../** @type {Record<string, unknown>} */ (responseMessage.result),
|
|
816
931
|
daemonVersion: DAEMON_VERSION,
|
|
817
932
|
daemon_supported_versions: SUPPORTED_VERSIONS,
|
|
@@ -849,7 +964,9 @@ export class BridgeDaemon {
|
|
|
849
964
|
|
|
850
965
|
if (socket.__clientId) {
|
|
851
966
|
this.logger.info('agent disconnected', { clientId: socket.__clientId });
|
|
852
|
-
this.agentSockets.
|
|
967
|
+
if (this.agentSockets.get(socket.__clientId) === socket) {
|
|
968
|
+
this.agentSockets.delete(socket.__clientId);
|
|
969
|
+
}
|
|
853
970
|
}
|
|
854
971
|
|
|
855
972
|
const ownedRequestIds = this.pendingRequestsByOwnerSocket.get(socket);
|
|
@@ -859,7 +976,6 @@ export class BridgeDaemon {
|
|
|
859
976
|
if (!pending) {
|
|
860
977
|
continue;
|
|
861
978
|
}
|
|
862
|
-
clearTimeout(pending.timeoutId);
|
|
863
979
|
this.clearPendingRequest(id, pending);
|
|
864
980
|
this.recordRequestCompletion(id, false);
|
|
865
981
|
}
|
|
@@ -873,7 +989,13 @@ export class BridgeDaemon {
|
|
|
873
989
|
continue;
|
|
874
990
|
}
|
|
875
991
|
this.removePendingTarget(id, pending, socket);
|
|
876
|
-
void this.finishPendingRequestIfExhausted(id, pending)
|
|
992
|
+
void this.finishPendingRequestIfExhausted(id, pending).catch((error) => {
|
|
993
|
+
this.logger.error('pending exhaustion response failed', {
|
|
994
|
+
requestId: id,
|
|
995
|
+
method: pending.method,
|
|
996
|
+
message: error instanceof Error ? error.message : String(error),
|
|
997
|
+
});
|
|
998
|
+
});
|
|
877
999
|
}
|
|
878
1000
|
}
|
|
879
1001
|
}
|
|
@@ -891,7 +1013,6 @@ export class BridgeDaemon {
|
|
|
891
1013
|
return;
|
|
892
1014
|
}
|
|
893
1015
|
|
|
894
|
-
clearTimeout(pending.timeoutId);
|
|
895
1016
|
this.clearPendingRequest(requestId, pending);
|
|
896
1017
|
this.recordRequestCompletion(requestId, false);
|
|
897
1018
|
|
|
@@ -958,10 +1079,22 @@ export class BridgeDaemon {
|
|
|
958
1079
|
export async function pingExistingDaemon(transport) {
|
|
959
1080
|
const resolvedTransport =
|
|
960
1081
|
typeof transport === 'string' ? createSocketBridgeTransport(transport) : transport;
|
|
1082
|
+
const authToken = resolvedTransport.type === 'tcp' ? await readBridgeAuthToken() : null;
|
|
961
1083
|
return new Promise((resolve) => {
|
|
962
|
-
|
|
1084
|
+
let settled = false;
|
|
1085
|
+
/** @param {boolean} value */
|
|
1086
|
+
function finish(value) {
|
|
1087
|
+
if (settled) {
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
settled = true;
|
|
1091
|
+
clearTimeout(timeout);
|
|
963
1092
|
socket.destroy();
|
|
964
|
-
resolve(
|
|
1093
|
+
resolve(value);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
const timeout = setTimeout(() => {
|
|
1097
|
+
finish(false);
|
|
965
1098
|
}, DAEMON_EXISTING_SOCKET_PING_TIMEOUT_MS);
|
|
966
1099
|
|
|
967
1100
|
const socket =
|
|
@@ -969,27 +1102,37 @@ export async function pingExistingDaemon(transport) {
|
|
|
969
1102
|
? net.createConnection({ host: resolvedTransport.host, port: resolvedTransport.port })
|
|
970
1103
|
: net.createConnection(resolvedTransport.socketPath);
|
|
971
1104
|
socket.once('error', () => {
|
|
972
|
-
|
|
973
|
-
resolve(false);
|
|
1105
|
+
finish(false);
|
|
974
1106
|
});
|
|
975
1107
|
|
|
976
1108
|
let buf = '';
|
|
977
1109
|
socket.setEncoding('utf8');
|
|
978
1110
|
socket.on('data', (chunk) => {
|
|
979
1111
|
buf += chunk;
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
1112
|
+
while (buf.includes('\n')) {
|
|
1113
|
+
const index = buf.indexOf('\n');
|
|
1114
|
+
const line = buf.slice(0, index).trim();
|
|
1115
|
+
buf = buf.slice(index + 1);
|
|
1116
|
+
if (!line) {
|
|
1117
|
+
continue;
|
|
1118
|
+
}
|
|
983
1119
|
try {
|
|
984
|
-
const msg = JSON.parse(
|
|
985
|
-
|
|
1120
|
+
const msg = JSON.parse(line);
|
|
1121
|
+
if (msg?.type === 'agent.response') {
|
|
1122
|
+
finish(msg?.response?.result?.daemon === 'ok');
|
|
1123
|
+
}
|
|
986
1124
|
} catch {
|
|
987
|
-
|
|
1125
|
+
finish(false);
|
|
988
1126
|
}
|
|
989
1127
|
}
|
|
990
1128
|
});
|
|
991
1129
|
|
|
992
1130
|
socket.once('connect', () => {
|
|
1131
|
+
if (authToken) {
|
|
1132
|
+
socket.write(
|
|
1133
|
+
`${JSON.stringify({ type: 'register', role: 'agent', clientId: 'ping_probe', authToken })}\n`
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
993
1136
|
socket.write(
|
|
994
1137
|
`${JSON.stringify({
|
|
995
1138
|
type: 'agent.request',
|
|
@@ -1011,7 +1154,10 @@ export async function pingExistingDaemon(transport) {
|
|
|
1011
1154
|
* @returns {SetupInstallParams & { action: 'install' | 'uninstall', kind: 'mcp' | 'skill', target: string }}
|
|
1012
1155
|
*/
|
|
1013
1156
|
export function normalizeSetupInstallParams(params) {
|
|
1014
|
-
const action = params.action
|
|
1157
|
+
const action = params.action == null ? 'install' : params.action;
|
|
1158
|
+
if (action !== 'install' && action !== 'uninstall') {
|
|
1159
|
+
throw new Error('setup.install action must be "install" or "uninstall".');
|
|
1160
|
+
}
|
|
1015
1161
|
const kind = params.kind === 'mcp' || params.kind === 'skill' ? params.kind : null;
|
|
1016
1162
|
const target = typeof params.target === 'string' ? params.target.trim().toLowerCase() : '';
|
|
1017
1163
|
if (!kind) {
|
|
@@ -1020,7 +1166,7 @@ export function normalizeSetupInstallParams(params) {
|
|
|
1020
1166
|
if (!target) {
|
|
1021
1167
|
throw new Error('setup.install requires a target.');
|
|
1022
1168
|
}
|
|
1023
|
-
return { action, kind, target };
|
|
1169
|
+
return { action: /** @type {'install' | 'uninstall'} */ (action), kind, target };
|
|
1024
1170
|
}
|
|
1025
1171
|
|
|
1026
1172
|
/**
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { once } from 'node:events';
|
|
4
4
|
|
|
5
|
-
import { MAX_NATIVE_MESSAGE_BYTES } from '../../protocol/src/index.js';
|
|
5
|
+
import { MAX_JSON_LINE_BYTES, MAX_NATIVE_MESSAGE_BYTES } from '../../protocol/src/index.js';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* @param {NodeJS.WritableStream} stream
|
|
@@ -193,7 +193,12 @@ export function createNativeMessageReader(stream, onMessage, onProtocolError) {
|
|
|
193
193
|
* @returns {Promise<void>}
|
|
194
194
|
*/
|
|
195
195
|
export async function writeJsonLine(socket, message) {
|
|
196
|
-
|
|
196
|
+
const line = `${JSON.stringify(message)}\n`;
|
|
197
|
+
const byteLength = Buffer.byteLength(line.slice(0, -1), 'utf8');
|
|
198
|
+
if (byteLength > MAX_JSON_LINE_BYTES) {
|
|
199
|
+
throw new Error(`JSON line exceeds ${MAX_JSON_LINE_BYTES} bytes: ${byteLength}`);
|
|
200
|
+
}
|
|
201
|
+
if (!socket.write(line)) {
|
|
197
202
|
await once(socket, 'drain');
|
|
198
203
|
}
|
|
199
204
|
}
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import net from 'node:net';
|
|
4
4
|
|
|
5
|
-
import { createFailure, ERROR_CODES } from '../../protocol/src/index.js';
|
|
5
|
+
import { createFailure, ERROR_CODES, MAX_JSON_LINE_BYTES } from '../../protocol/src/index.js';
|
|
6
|
+
import { readBridgeAuthToken } from './auth-token.js';
|
|
6
7
|
import { createSocketBridgeTransport, getBridgeTransport } from './config.js';
|
|
7
8
|
import { spawnBridgeDaemonProcess } from './daemon-process.js';
|
|
8
9
|
import { createNativeMessageReader, createNativeMessageWriter, writeJsonLine } from './framing.js';
|
|
@@ -146,11 +147,21 @@ export async function runNativeHost({
|
|
|
146
147
|
socket.once('close', cleanupStdinEndListener);
|
|
147
148
|
socket.once('end', cleanupStdinEndListener);
|
|
148
149
|
socket.once('error', cleanupStdinEndListener);
|
|
149
|
-
|
|
150
|
+
const authToken = resolvedTransport.type === 'tcp' ? await readBridgeAuthToken() : null;
|
|
151
|
+
await writeJsonLine(socket, {
|
|
152
|
+
type: 'register',
|
|
153
|
+
role: 'extension',
|
|
154
|
+
...(authToken ? { authToken } : {}),
|
|
155
|
+
});
|
|
150
156
|
|
|
151
157
|
let lineBuffer = '';
|
|
152
158
|
socket.on('data', (chunk) => {
|
|
153
159
|
lineBuffer += chunk;
|
|
160
|
+
if (!lineBuffer.includes('\n') && Buffer.byteLength(lineBuffer, 'utf8') > MAX_JSON_LINE_BYTES) {
|
|
161
|
+
console.error(`native-host: daemon JSON line exceeded ${MAX_JSON_LINE_BYTES} bytes`);
|
|
162
|
+
socket.destroy();
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
154
165
|
while (lineBuffer.includes('\n')) {
|
|
155
166
|
const index = lineBuffer.indexOf('\n');
|
|
156
167
|
const line = lineBuffer.slice(0, index).trim();
|
|
@@ -158,6 +169,11 @@ export async function runNativeHost({
|
|
|
158
169
|
if (!line) {
|
|
159
170
|
continue;
|
|
160
171
|
}
|
|
172
|
+
if (Buffer.byteLength(line, 'utf8') > MAX_JSON_LINE_BYTES) {
|
|
173
|
+
console.error(`native-host: daemon JSON line exceeded ${MAX_JSON_LINE_BYTES} bytes`);
|
|
174
|
+
socket.destroy();
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
161
177
|
let message;
|
|
162
178
|
try {
|
|
163
179
|
message = JSON.parse(line);
|
|
@@ -24,6 +24,9 @@ export const DEFAULT_DEVICE_SCALE_FACTOR = 0;
|
|
|
24
24
|
/** Maximum size of a Chrome native messaging message in bytes. */
|
|
25
25
|
export const MAX_NATIVE_MESSAGE_BYTES = 1_048_576;
|
|
26
26
|
|
|
27
|
+
/** Maximum size of one newline-delimited daemon socket message in bytes. */
|
|
28
|
+
export const MAX_JSON_LINE_BYTES = MAX_NATIVE_MESSAGE_BYTES;
|
|
29
|
+
|
|
27
30
|
/** Default timeout for a bridge request awaiting an extension response (ms). */
|
|
28
31
|
export const DEFAULT_DAEMON_PENDING_TIMEOUT_MS = 30_000;
|
|
29
32
|
|
|
@@ -1,18 +1,42 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
+
import { MAX_JSON_LINE_BYTES } from './defaults.js';
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* Install a newline-delimited JSON parser on a socket's `data` event.
|
|
5
7
|
*
|
|
6
8
|
* @param {import('node:net').Socket} socket
|
|
7
9
|
* @param {(message: unknown) => void} onMessage
|
|
10
|
+
* @param {{ maxLineBytes?: number, onProtocolError?: (error: Error) => void }} [options]
|
|
8
11
|
* @returns {void}
|
|
9
12
|
*/
|
|
10
|
-
export function parseJsonLines(socket, onMessage) {
|
|
13
|
+
export function parseJsonLines(socket, onMessage, options = {}) {
|
|
11
14
|
let buffer = '';
|
|
15
|
+
const maxLineBytes =
|
|
16
|
+
typeof options.maxLineBytes === 'number' && Number.isFinite(options.maxLineBytes)
|
|
17
|
+
? Math.max(1, Math.floor(options.maxLineBytes))
|
|
18
|
+
: MAX_JSON_LINE_BYTES;
|
|
12
19
|
socket.setEncoding('utf8');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {Error} error
|
|
23
|
+
* @returns {void}
|
|
24
|
+
*/
|
|
25
|
+
function fail(error) {
|
|
26
|
+
options.onProtocolError?.(error);
|
|
27
|
+
const destroy = /** @type {{ destroy?: (() => void) | undefined }} */ (socket).destroy;
|
|
28
|
+
if (typeof destroy === 'function') {
|
|
29
|
+
destroy.call(socket);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
13
33
|
/** @param {string} chunk */
|
|
14
34
|
socket.on('data', (chunk) => {
|
|
15
35
|
buffer += chunk;
|
|
36
|
+
if (!buffer.includes('\n') && Buffer.byteLength(buffer, 'utf8') > maxLineBytes) {
|
|
37
|
+
fail(new Error(`JSON line exceeds ${maxLineBytes} bytes.`));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
16
40
|
while (buffer.includes('\n')) {
|
|
17
41
|
const index = buffer.indexOf('\n');
|
|
18
42
|
const line = buffer.slice(0, index).trim();
|
|
@@ -20,6 +44,10 @@ export function parseJsonLines(socket, onMessage) {
|
|
|
20
44
|
if (!line) {
|
|
21
45
|
continue;
|
|
22
46
|
}
|
|
47
|
+
if (Buffer.byteLength(line, 'utf8') > maxLineBytes) {
|
|
48
|
+
fail(new Error(`JSON line exceeds ${maxLineBytes} bytes.`));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
23
51
|
try {
|
|
24
52
|
onMessage(JSON.parse(line));
|
|
25
53
|
} catch {
|
|
@@ -97,7 +97,9 @@ export const SUPPORTED_VERSIONS = Object.freeze(['1.0']);
|
|
|
97
97
|
* @returns {number}
|
|
98
98
|
*/
|
|
99
99
|
function clampInt(value, min, max, fallback) {
|
|
100
|
-
|
|
100
|
+
const numeric = Number(value);
|
|
101
|
+
const integer = Number.isFinite(numeric) && numeric !== 0 ? Math.trunc(numeric) : fallback;
|
|
102
|
+
return Math.min(Math.max(integer, min), max);
|
|
101
103
|
}
|
|
102
104
|
|
|
103
105
|
/** @type {ReadonlyArray<BridgeMethod>} */
|
|
@@ -672,6 +674,9 @@ export function normalizeHoverParams(params = {}) {
|
|
|
672
674
|
: undefined
|
|
673
675
|
),
|
|
674
676
|
duration: clampInt(params.duration, 0, 5_000, 0),
|
|
677
|
+
modifiers: Array.isArray(params.modifiers)
|
|
678
|
+
? params.modifiers.filter((modifier) => typeof modifier === 'string' && modifier.trim())
|
|
679
|
+
: [],
|
|
675
680
|
};
|
|
676
681
|
}
|
|
677
682
|
|
|
@@ -460,11 +460,13 @@ export interface NormalizedGetHtmlParams extends BridgeParams {
|
|
|
460
460
|
export interface HoverParams {
|
|
461
461
|
target?: InputTarget;
|
|
462
462
|
duration?: number;
|
|
463
|
+
modifiers?: string[];
|
|
463
464
|
}
|
|
464
465
|
|
|
465
466
|
export interface NormalizedHoverParams extends BridgeParams {
|
|
466
467
|
target: InputTarget;
|
|
467
468
|
duration: number;
|
|
469
|
+
modifiers: string[];
|
|
468
470
|
}
|
|
469
471
|
|
|
470
472
|
export interface DragParams {
|
|
@@ -10,14 +10,16 @@ Token-efficient Chrome tab inspection, interaction, and CSS/DOM patching through
|
|
|
10
10
|
This CLI skill is for agents that can run shell commands and where direct `bbx` control fits better than MCP tools: manual debugging, terminal reproduction, install/doctor flows, raw protocol access, or environments without MCP.
|
|
11
11
|
|
|
12
12
|
Skill name: `browser-bridge` (also known as `bbx`). In GitHub Copilot, invoke as `/browser-bridge`. `bbx` is the CLI command used throughout this skill.
|
|
13
|
-
|
|
14
|
-
For open-ended investigation,
|
|
13
|
+
When the runtime supports subagents, delegate bridge inspection to a smaller, lower-cost worker and return only concise findings to the parent.
|
|
14
|
+
For open-ended investigation, start with structured reads (`page.get_state`, `dom.query`, `page.get_text`, `styles.get_computed`, `bbx batch`) and escalate to screenshots or debugger-backed methods only when structured evidence is insufficient.
|
|
15
15
|
|
|
16
16
|
## CLI
|
|
17
17
|
|
|
18
18
|
```bash
|
|
19
19
|
bbx status # daemon + extension health
|
|
20
20
|
bbx doctor # install/access readiness
|
|
21
|
+
bbx access-request # ask user to enable access for the focused window
|
|
22
|
+
bbx restart # start/restart the local daemon non-interactively
|
|
21
23
|
bbx call <method> '{...}' # any RPC method (raw output)
|
|
22
24
|
bbx <method> '{...}' # direct alias for an exact bridge method such as page.get_state
|
|
23
25
|
bbx call --tab 123 <method> '{...}' # explicit tab override
|
|
@@ -59,6 +61,7 @@ bbx navigate <url> # navigate to URL
|
|
|
59
61
|
bbx reload # reload current page
|
|
60
62
|
bbx back # navigate back
|
|
61
63
|
bbx forward # navigate forward
|
|
64
|
+
bbx scroll <top> [left] # scroll viewport
|
|
62
65
|
bbx resize <width> <height> # resize viewport
|
|
63
66
|
```
|
|
64
67
|
|
|
@@ -77,7 +80,7 @@ bbx patch-text <ref> <text...> # apply text patch
|
|
|
77
80
|
bbx patches # list active patches
|
|
78
81
|
bbx rollback <patchId> # rollback a patch
|
|
79
82
|
bbx screenshot <ref> [outPath] # capture partial element screenshot
|
|
80
|
-
bbx call screenshot.capture_full_page '{}' #
|
|
83
|
+
bbx call screenshot.capture_full_page '{}' # raw base64; avoid unless document context matters
|
|
81
84
|
```
|
|
82
85
|
|
|
83
86
|
## Access Flow
|
|
@@ -114,8 +117,8 @@ After access is enabled:
|
|
|
114
117
|
| `EXTENSION_DISCONNECTED` | After 3s | Check Chrome is running; `bbx status` to verify, then retry |
|
|
115
118
|
| `NATIVE_HOST_UNAVAILABLE` | No | Run `bbx doctor` to diagnose the installation |
|
|
116
119
|
| `INTERNAL_ERROR` | Once | Retry once; if persistent, check `page.get_console` for details |
|
|
117
|
-
| `DAEMON_OFFLINE` | No | Daemon not running - start with `bbx
|
|
118
|
-
| `CONNECTION_LOST` | Yes | Socket dropped mid-request - retry; if persistent, restart
|
|
120
|
+
| `DAEMON_OFFLINE` | No | Daemon not running - start with `bbx restart` |
|
|
121
|
+
| `CONNECTION_LOST` | Yes | Socket dropped mid-request - retry; if persistent, run `bbx restart` |
|
|
119
122
|
| `BRIDGE_TIMEOUT` | Once | Extension took too long - retry once with simpler call |
|
|
120
123
|
|
|
121
124
|
Error responses now include a machine-readable `error.recovery` field with `retry`, `retryAfterMs`, `alternativeMethod`, and `hint`.
|
|
@@ -160,6 +163,12 @@ For a natural-language inspection task:
|
|
|
160
163
|
4. Escalate to `screenshot.capture_element`, `screenshot.capture_region`, or other debugger-backed methods only when structured reads are ambiguous or visual confirmation is required.
|
|
161
164
|
5. Return concise findings and evidence, not raw dumps.
|
|
162
165
|
|
|
166
|
+
CLI-first starter:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
bbx batch '[{"method":"page.get_state"},{"method":"dom.query","params":{"selector":"main","maxNodes":10,"maxDepth":3,"textBudget":400,"attributeAllowlist":["id","class","data-testid"]}},{"method":"page.get_text","params":{"textBudget":2000}}]'
|
|
170
|
+
```
|
|
171
|
+
|
|
163
172
|
## Common Workflows
|
|
164
173
|
|
|
165
174
|
### Debug a CSS layout issue
|
|
@@ -269,6 +278,13 @@ Return: verdict, tab id + origin, minimal evidence set. No raw HTML or base64 im
|
|
|
269
278
|
|
|
270
279
|
Every CLI shortcut command produces consistent `{ok, summary, evidence}` JSON. Use `bbx call <method>` for raw protocol output when needed.
|
|
271
280
|
|
|
281
|
+
## CLI Raw Params Gotchas
|
|
282
|
+
|
|
283
|
+
- Use `selector`, not `scope`, to narrow `dom.find_by_text` and `dom.find_by_role`.
|
|
284
|
+
- Wrap interaction targets as `target: { elementRef }` or `target: { selector }`; `viewport.scroll` also uses the `target` wrapper for element scrolling.
|
|
285
|
+
- `input.drag` uses `source`, `destination`, and optional destination offsets `offsetX` / `offsetY`.
|
|
286
|
+
- Raw `screenshot.capture_region` and `screenshot.capture_full_page` return base64 JSON; prefer `bbx screenshot <ref> [outPath]` when one element is enough.
|
|
287
|
+
|
|
272
288
|
## Response Shapes
|
|
273
289
|
|
|
274
290
|
The summarizer auto-detects response types and produces concise summaries:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
interface:
|
|
2
2
|
display_name: 'Browser Bridge'
|
|
3
3
|
short_description: 'Token-efficient Chrome tab inspection, interaction, and patching via local bridge'
|
|
4
|
-
default_prompt: 'Use the browser-bridge skill for Chrome tab inspection, interaction, and patching. Start with `bbx status` to confirm connectivity
|
|
4
|
+
default_prompt: 'Use the browser-bridge skill for Chrome tab inspection, interaction, and patching. Start with `bbx status` to confirm connectivity; if the daemon is offline, run `bbx restart`. If ACCESS_DENIED, ask the user to click Enable in the extension popup/side panel, then retry. Default routing follows the active tab; use `--tab` only for a different tab. Prefer structured reads (`dom.query`, `styles.get_computed`) over screenshots. Batch independent reads with `bbx batch`. Use `bbx skill` for runtime presets.'
|