@browserbridge/bbx 1.7.2 → 1.7.3
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/package.json +4 -3
- package/packages/agent-client/src/cli.js +6 -0
- package/packages/agent-client/src/mcp-config.js +55 -55
- package/packages/agent-client/src/runtime.js +19 -0
- package/packages/mcp-server/src/guidance.js +4 -4
- package/packages/mcp-server/src/handlers-capture.js +166 -2
- package/packages/mcp-server/src/handlers-page.js +132 -72
- package/packages/mcp-server/src/handlers-utils.js +16 -66
- package/packages/mcp-server/src/handlers.js +2 -0
- package/packages/mcp-server/src/server.js +5 -1
- package/packages/native-host/src/daemon-process.js +16 -8
- package/packages/native-host/src/daemon.js +199 -79
- package/packages/protocol/src/defaults.js +17 -0
- package/packages/protocol/src/protocol.js +45 -2
- package/packages/protocol/src/registry.js +1 -1
- package/skills/browser-bridge/SKILL.md +12 -8
- package/skills/browser-bridge/references/interaction.md +10 -7
- package/skills/browser-bridge/references/protocol.md +6 -2
- package/skills/browser-bridge/references/token-efficiency.md +5 -4
- package/skills/browser-bridge/references/ui-workflows.md +11 -9
|
@@ -20,12 +20,14 @@ import {
|
|
|
20
20
|
createFailure,
|
|
21
21
|
createSuccess,
|
|
22
22
|
DAEMON_EXISTING_SOCKET_PING_TIMEOUT_MS,
|
|
23
|
+
DAEMON_PENDING_TIMEOUT_MARGIN_MS,
|
|
23
24
|
DAEMON_RECENT_LOG_LIMIT,
|
|
24
25
|
DEFAULT_DAEMON_PENDING_TIMEOUT_MS,
|
|
25
26
|
DEFAULT_LOG_TAIL_LIMIT,
|
|
26
27
|
ERROR_CODES,
|
|
27
28
|
getProtocolVersion,
|
|
28
29
|
getSupportedProtocolVersions,
|
|
30
|
+
MAX_DAEMON_PENDING_TIMEOUT_MS,
|
|
29
31
|
parseJsonLines,
|
|
30
32
|
setProtocolPackageVersion,
|
|
31
33
|
validateBridgeRequest,
|
|
@@ -54,7 +56,8 @@ setProtocolPackageVersion(DAEMON_VERSION);
|
|
|
54
56
|
/** @typedef {import('../../protocol/src/types.js').SetupStatus} SetupStatus */
|
|
55
57
|
/** @typedef {import('./config.js').BridgeTransport} BridgeTransport */
|
|
56
58
|
/** @typedef {import('./daemon-logger.js').DaemonLoggerLike} DaemonLoggerLike */
|
|
57
|
-
/** @typedef {
|
|
59
|
+
/** @typedef {'agent' | 'extension'} SocketRole */
|
|
60
|
+
/** @typedef {import('node:net').Socket & { readonly __role?: SocketRole, __clientId?: string, __extensionId?: string, __browserName?: string, __profileLabel?: string, __accessEnabled?: boolean, __lastActiveAt?: number }} ClientSocket */
|
|
58
61
|
/** @typedef {{ socket: ClientSocket, timeoutId: NodeJS.Timeout, source?: string, method?: string, protocolVersion?: string, targets: Set<ClientSocket>, lastErrorResponse?: import('../../protocol/src/types.js').BridgeResponse }} PendingEntry */
|
|
59
62
|
/**
|
|
60
63
|
* @typedef {{
|
|
@@ -327,6 +330,17 @@ export class BridgeDaemon {
|
|
|
327
330
|
* @returns {void}
|
|
328
331
|
*/
|
|
329
332
|
registerSocket(socket, message) {
|
|
333
|
+
if (socket.__role) {
|
|
334
|
+
void writeJsonLine(socket, {
|
|
335
|
+
type: 'registration_failed',
|
|
336
|
+
error: {
|
|
337
|
+
code: ERROR_CODES.INVALID_REQUEST,
|
|
338
|
+
message: `Socket is already registered as ${socket.__role}.`,
|
|
339
|
+
},
|
|
340
|
+
});
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
330
344
|
if (this.isAuthRequired() && normalizeBridgeAuthToken(message.authToken) !== this.authToken) {
|
|
331
345
|
this.logger.error('socket registration rejected', { role: message.role ?? null });
|
|
332
346
|
void writeJsonLine(socket, {
|
|
@@ -339,8 +353,24 @@ export class BridgeDaemon {
|
|
|
339
353
|
return;
|
|
340
354
|
}
|
|
341
355
|
|
|
356
|
+
if (message.role !== 'extension' && message.role !== 'agent') {
|
|
357
|
+
void writeJsonLine(socket, {
|
|
358
|
+
type: 'registration_failed',
|
|
359
|
+
error: {
|
|
360
|
+
code: ERROR_CODES.INVALID_REQUEST,
|
|
361
|
+
message: 'Socket role must be "agent" or "extension".',
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
Object.defineProperty(socket, '__role', {
|
|
368
|
+
value: message.role,
|
|
369
|
+
enumerable: false,
|
|
370
|
+
configurable: false,
|
|
371
|
+
writable: false,
|
|
372
|
+
});
|
|
342
373
|
if (message.role === 'extension') {
|
|
343
|
-
socket.__authenticated = true;
|
|
344
374
|
const extensionId = randomUUID();
|
|
345
375
|
socket.__extensionId = extensionId;
|
|
346
376
|
socket.__browserName =
|
|
@@ -360,7 +390,6 @@ export class BridgeDaemon {
|
|
|
360
390
|
}
|
|
361
391
|
|
|
362
392
|
if (message.role === 'agent') {
|
|
363
|
-
socket.__authenticated = true;
|
|
364
393
|
const clientId = message.clientId || randomUUID();
|
|
365
394
|
this.agentSockets.set(clientId, socket);
|
|
366
395
|
socket.__clientId = clientId;
|
|
@@ -524,36 +553,59 @@ export class BridgeDaemon {
|
|
|
524
553
|
return this.registerSocket(socket, message);
|
|
525
554
|
}
|
|
526
555
|
|
|
527
|
-
if (
|
|
528
|
-
return this.
|
|
556
|
+
if (!socket.__role) {
|
|
557
|
+
return this.rejectUnregisteredMessage(socket, message);
|
|
529
558
|
}
|
|
530
559
|
|
|
531
560
|
if (message?.type === 'log') {
|
|
561
|
+
if (socket.__role !== 'extension') {
|
|
562
|
+
return this.rejectMessageForRole(socket, message);
|
|
563
|
+
}
|
|
532
564
|
this.pushLog(message.entry ?? {});
|
|
533
565
|
return;
|
|
534
566
|
}
|
|
535
567
|
|
|
536
568
|
if (message?.type === 'extension.response') {
|
|
569
|
+
if (socket.__role !== 'extension') {
|
|
570
|
+
return this.rejectMessageForRole(socket, message);
|
|
571
|
+
}
|
|
537
572
|
return this.handleExtensionResponse(socket, message);
|
|
538
573
|
}
|
|
539
574
|
|
|
540
575
|
if (message?.type === 'extension.identity') {
|
|
576
|
+
if (socket.__role !== 'extension') {
|
|
577
|
+
return this.rejectMessageForRole(socket, message);
|
|
578
|
+
}
|
|
541
579
|
return this.handleExtensionIdentity(socket, message);
|
|
542
580
|
}
|
|
543
581
|
|
|
544
582
|
if (message?.type === 'extension.access_update') {
|
|
583
|
+
if (socket.__role !== 'extension') {
|
|
584
|
+
return this.rejectMessageForRole(socket, message);
|
|
585
|
+
}
|
|
545
586
|
return this.handleExtensionAccessUpdate(socket, message);
|
|
546
587
|
}
|
|
547
588
|
|
|
548
589
|
if (message?.type === 'extension.activity') {
|
|
590
|
+
if (socket.__role !== 'extension') {
|
|
591
|
+
return this.rejectMessageForRole(socket, message);
|
|
592
|
+
}
|
|
549
593
|
return this.handleExtensionActivity(socket, message);
|
|
550
594
|
}
|
|
551
595
|
|
|
552
596
|
if (message?.type === 'extension.setup_status.request') {
|
|
597
|
+
if (socket.__role !== 'extension') {
|
|
598
|
+
return this.rejectMessageForRole(socket, message);
|
|
599
|
+
}
|
|
553
600
|
return this.handleExtensionSetupStatus(socket, message);
|
|
554
601
|
}
|
|
555
602
|
|
|
556
603
|
if (message?.type === 'agent.request') {
|
|
604
|
+
const isExtensionSetupRequest =
|
|
605
|
+
socket.__role === 'extension' && message.request?.method === 'setup.install';
|
|
606
|
+
if (socket.__role !== 'agent' && !isExtensionSetupRequest) {
|
|
607
|
+
return this.rejectMessageForRole(socket, message);
|
|
608
|
+
}
|
|
557
609
|
return this.handleAgentRequest(socket, message);
|
|
558
610
|
}
|
|
559
611
|
|
|
@@ -571,7 +623,8 @@ export class BridgeDaemon {
|
|
|
571
623
|
* @param {DaemonMessage} message
|
|
572
624
|
* @returns {Promise<void>}
|
|
573
625
|
*/
|
|
574
|
-
async
|
|
626
|
+
async rejectUnregisteredMessage(socket, message) {
|
|
627
|
+
const authRequired = this.isAuthRequired();
|
|
575
628
|
if (message?.type === 'agent.request') {
|
|
576
629
|
const candidate =
|
|
577
630
|
message.request && typeof message.request === 'object'
|
|
@@ -579,8 +632,10 @@ export class BridgeDaemon {
|
|
|
579
632
|
: {};
|
|
580
633
|
const response = createFailure(
|
|
581
634
|
typeof candidate.id === 'string' && candidate.id.trim() ? candidate.id : 'unauthenticated',
|
|
582
|
-
ERROR_CODES.ACCESS_DENIED,
|
|
583
|
-
|
|
635
|
+
authRequired ? ERROR_CODES.ACCESS_DENIED : ERROR_CODES.INVALID_REQUEST,
|
|
636
|
+
authRequired
|
|
637
|
+
? 'Register with the daemon auth token before sending bridge requests.'
|
|
638
|
+
: 'Register the socket before sending bridge requests.',
|
|
584
639
|
null,
|
|
585
640
|
typeof candidate.method === 'string' ? { method: candidate.method } : {}
|
|
586
641
|
);
|
|
@@ -591,8 +646,25 @@ export class BridgeDaemon {
|
|
|
591
646
|
await writeJsonLine(socket, {
|
|
592
647
|
type: 'error',
|
|
593
648
|
error: {
|
|
594
|
-
code: ERROR_CODES.ACCESS_DENIED,
|
|
595
|
-
message:
|
|
649
|
+
code: authRequired ? ERROR_CODES.ACCESS_DENIED : ERROR_CODES.INVALID_REQUEST,
|
|
650
|
+
message: authRequired
|
|
651
|
+
? 'Register with the daemon auth token before sending bridge messages.'
|
|
652
|
+
: 'Register the socket before sending bridge messages.',
|
|
653
|
+
},
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* @param {ClientSocket} socket
|
|
659
|
+
* @param {DaemonMessage} message
|
|
660
|
+
* @returns {Promise<void>}
|
|
661
|
+
*/
|
|
662
|
+
async rejectMessageForRole(socket, message) {
|
|
663
|
+
await writeJsonLine(socket, {
|
|
664
|
+
type: 'error',
|
|
665
|
+
error: {
|
|
666
|
+
code: ERROR_CODES.INVALID_REQUEST,
|
|
667
|
+
message: `Message type ${JSON.stringify(message.type ?? null)} is not allowed for ${socket.__role} sockets.`,
|
|
596
668
|
},
|
|
597
669
|
});
|
|
598
670
|
}
|
|
@@ -690,6 +762,17 @@ export class BridgeDaemon {
|
|
|
690
762
|
}
|
|
691
763
|
|
|
692
764
|
if (request.method === 'setup.install') {
|
|
765
|
+
if (socket.__role === 'agent' && !this.isLocalSocket(socket)) {
|
|
766
|
+
const response = createFailure(
|
|
767
|
+
request.id,
|
|
768
|
+
ERROR_CODES.ACCESS_DENIED,
|
|
769
|
+
'setup.install is restricted to local Browser Bridge clients.',
|
|
770
|
+
null,
|
|
771
|
+
{ method: request.method }
|
|
772
|
+
);
|
|
773
|
+
await writeJsonLine(socket, { type: 'agent.response', response });
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
693
776
|
try {
|
|
694
777
|
const response = createSuccess(
|
|
695
778
|
request.id,
|
|
@@ -718,40 +801,9 @@ export class BridgeDaemon {
|
|
|
718
801
|
typeof request.meta?.target_profile === 'string' ? request.meta.target_profile : null;
|
|
719
802
|
const hasExplicitTarget = Boolean(targetBrowser || targetProfile);
|
|
720
803
|
|
|
721
|
-
|
|
722
|
-
const targets = [];
|
|
723
|
-
/** @type {ClientSocket | null} */
|
|
724
|
-
let mostRecent = null;
|
|
725
|
-
for (const extSocket of this.extensionSockets.values()) {
|
|
726
|
-
if (targetBrowser || targetProfile) {
|
|
727
|
-
if (targetBrowser && extSocket.__browserName !== targetBrowser) {
|
|
728
|
-
continue;
|
|
729
|
-
}
|
|
730
|
-
if (targetProfile && extSocket.__profileLabel !== targetProfile) {
|
|
731
|
-
continue;
|
|
732
|
-
}
|
|
733
|
-
targets.push(extSocket);
|
|
734
|
-
continue;
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
if (extSocket.__accessEnabled) {
|
|
738
|
-
targets.push(extSocket);
|
|
739
|
-
continue;
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
if (
|
|
743
|
-
!mostRecent ||
|
|
744
|
-
(typeof extSocket.__lastActiveAt === 'number' ? extSocket.__lastActiveAt : 0) >
|
|
745
|
-
(typeof mostRecent.__lastActiveAt === 'number' ? mostRecent.__lastActiveAt : 0)
|
|
746
|
-
) {
|
|
747
|
-
mostRecent = extSocket;
|
|
748
|
-
}
|
|
749
|
-
}
|
|
750
|
-
if (!hasExplicitTarget && targets.length === 0 && mostRecent) {
|
|
751
|
-
targets.push(mostRecent);
|
|
752
|
-
}
|
|
804
|
+
const target = this.selectExtensionTarget(targetBrowser, targetProfile);
|
|
753
805
|
|
|
754
|
-
if (
|
|
806
|
+
if (!target) {
|
|
755
807
|
const response = createFailure(
|
|
756
808
|
request.id,
|
|
757
809
|
ERROR_CODES.EXTENSION_DISCONNECTED,
|
|
@@ -768,7 +820,7 @@ export class BridgeDaemon {
|
|
|
768
820
|
method: request.method,
|
|
769
821
|
protocolVersion: request.meta?.protocol_version,
|
|
770
822
|
source: typeof request.meta?.source === 'string' ? request.meta.source : '',
|
|
771
|
-
targets: new Set(
|
|
823
|
+
targets: new Set([target]),
|
|
772
824
|
timeoutId: setTimeout(() => {
|
|
773
825
|
const pending = this.pendingRequests.get(request.id);
|
|
774
826
|
if (!pending) return;
|
|
@@ -789,38 +841,101 @@ export class BridgeDaemon {
|
|
|
789
841
|
message: error instanceof Error ? error.message : String(error),
|
|
790
842
|
});
|
|
791
843
|
});
|
|
792
|
-
}, this.
|
|
844
|
+
}, this.getPendingTimeoutMs(request)),
|
|
793
845
|
});
|
|
794
846
|
this.logger.info('request routed', {
|
|
795
847
|
requestId: request.id,
|
|
796
848
|
method: request.method,
|
|
797
849
|
clientId: socket.__clientId ?? null,
|
|
798
|
-
targetCount:
|
|
850
|
+
targetCount: 1,
|
|
799
851
|
});
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
852
|
+
try {
|
|
853
|
+
await writeJsonLine(target, { type: 'extension.request', request });
|
|
854
|
+
} catch (error) {
|
|
855
|
+
this.logger.error('request route failed', {
|
|
856
|
+
requestId: request.id,
|
|
857
|
+
method: request.method,
|
|
858
|
+
message: error instanceof Error ? error.message : String(error),
|
|
859
|
+
});
|
|
860
|
+
const pending = this.pendingRequests.get(request.id);
|
|
861
|
+
if (!pending) {
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
this.removePendingTarget(request.id, pending, target);
|
|
865
|
+
if (target.__extensionId && this.extensionSockets.get(target.__extensionId) === target) {
|
|
866
|
+
this.extensionSockets.delete(target.__extensionId);
|
|
867
|
+
this.invalidateConnectedExtensionsCache();
|
|
868
|
+
}
|
|
869
|
+
target.destroy(error instanceof Error ? error : undefined);
|
|
870
|
+
await this.finishPendingRequestIfExhausted(request.id, pending);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* @param {ClientSocket} socket
|
|
876
|
+
* @returns {boolean}
|
|
877
|
+
*/
|
|
878
|
+
isLocalSocket(socket) {
|
|
879
|
+
if (this.transport.type === 'socket') {
|
|
880
|
+
return true;
|
|
881
|
+
}
|
|
882
|
+
const address = socket.remoteAddress ?? '';
|
|
883
|
+
return (
|
|
884
|
+
address === '127.0.0.1' ||
|
|
885
|
+
address === '::1' ||
|
|
886
|
+
address === 'localhost' ||
|
|
887
|
+
address === '::ffff:127.0.0.1'
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Select from a snapshot so routing neither mutates nor observes mutations to
|
|
893
|
+
* the live extension map while candidates are ordered.
|
|
894
|
+
*
|
|
895
|
+
* @param {string | null} targetBrowser
|
|
896
|
+
* @param {string | null} targetProfile
|
|
897
|
+
* @returns {ClientSocket | null}
|
|
898
|
+
*/
|
|
899
|
+
selectExtensionTarget(targetBrowser, targetProfile) {
|
|
900
|
+
const candidates = Array.from(this.extensionSockets.entries()).filter(([, extSocket]) => {
|
|
901
|
+
return (
|
|
902
|
+
(!targetBrowser || extSocket.__browserName === targetBrowser) &&
|
|
903
|
+
(!targetProfile || extSocket.__profileLabel === targetProfile)
|
|
904
|
+
);
|
|
905
|
+
});
|
|
906
|
+
candidates.sort(([leftId, left], [rightId, right]) => {
|
|
907
|
+
const accessDelta =
|
|
908
|
+
Number(Boolean(right.__accessEnabled)) - Number(Boolean(left.__accessEnabled));
|
|
909
|
+
if (accessDelta !== 0) {
|
|
910
|
+
return accessDelta;
|
|
911
|
+
}
|
|
912
|
+
const activityDelta =
|
|
913
|
+
(typeof right.__lastActiveAt === 'number' ? right.__lastActiveAt : 0) -
|
|
914
|
+
(typeof left.__lastActiveAt === 'number' ? left.__lastActiveAt : 0);
|
|
915
|
+
if (activityDelta !== 0) {
|
|
916
|
+
return activityDelta;
|
|
917
|
+
}
|
|
918
|
+
return leftId < rightId ? -1 : leftId > rightId ? 1 : 0;
|
|
919
|
+
});
|
|
920
|
+
return candidates[0]?.[1] ?? null;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* @param {BridgeRequest} request
|
|
925
|
+
* @returns {number}
|
|
926
|
+
*/
|
|
927
|
+
getPendingTimeoutMs(request) {
|
|
928
|
+
const configuredBase = Math.min(
|
|
929
|
+
MAX_DAEMON_PENDING_TIMEOUT_MS,
|
|
930
|
+
Math.max(1, this.pendingTimeoutMs)
|
|
931
|
+
);
|
|
932
|
+
const operationTimeout = request.params.timeoutMs;
|
|
933
|
+
if (typeof operationTimeout !== 'number' || !Number.isFinite(operationTimeout)) {
|
|
934
|
+
return configuredBase;
|
|
935
|
+
}
|
|
936
|
+
return Math.min(
|
|
937
|
+
MAX_DAEMON_PENDING_TIMEOUT_MS,
|
|
938
|
+
Math.max(configuredBase, operationTimeout + DAEMON_PENDING_TIMEOUT_MARGIN_MS)
|
|
824
939
|
);
|
|
825
940
|
}
|
|
826
941
|
|
|
@@ -922,7 +1037,7 @@ export class BridgeDaemon {
|
|
|
922
1037
|
}
|
|
923
1038
|
|
|
924
1039
|
const pending = this.pendingRequests.get(responseMessage.id);
|
|
925
|
-
if (!pending) {
|
|
1040
|
+
if (!pending || !pending.targets.has(socket)) {
|
|
926
1041
|
return;
|
|
927
1042
|
}
|
|
928
1043
|
|
|
@@ -968,7 +1083,7 @@ export class BridgeDaemon {
|
|
|
968
1083
|
return;
|
|
969
1084
|
}
|
|
970
1085
|
|
|
971
|
-
//
|
|
1086
|
+
// A routed request has one target, so its error is final.
|
|
972
1087
|
pending.lastErrorResponse = responseMessage;
|
|
973
1088
|
|
|
974
1089
|
await this.finishPendingRequestIfExhausted(responseMessage.id, pending);
|
|
@@ -981,8 +1096,10 @@ export class BridgeDaemon {
|
|
|
981
1096
|
handleSocketClose(socket) {
|
|
982
1097
|
if (socket.__extensionId) {
|
|
983
1098
|
this.logger.info('extension disconnected', { extensionId: socket.__extensionId });
|
|
984
|
-
this.extensionSockets.
|
|
985
|
-
|
|
1099
|
+
if (this.extensionSockets.get(socket.__extensionId) === socket) {
|
|
1100
|
+
this.extensionSockets.delete(socket.__extensionId);
|
|
1101
|
+
this.invalidateConnectedExtensionsCache();
|
|
1102
|
+
}
|
|
986
1103
|
}
|
|
987
1104
|
|
|
988
1105
|
if (socket.__clientId) {
|
|
@@ -1151,11 +1268,14 @@ export async function pingExistingDaemon(transport) {
|
|
|
1151
1268
|
});
|
|
1152
1269
|
|
|
1153
1270
|
socket.once('connect', () => {
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1271
|
+
socket.write(
|
|
1272
|
+
`${JSON.stringify({
|
|
1273
|
+
type: 'register',
|
|
1274
|
+
role: 'agent',
|
|
1275
|
+
clientId: 'ping_probe',
|
|
1276
|
+
...(authToken ? { authToken } : {}),
|
|
1277
|
+
})}\n`
|
|
1278
|
+
);
|
|
1159
1279
|
socket.write(
|
|
1160
1280
|
`${JSON.stringify({
|
|
1161
1281
|
type: 'agent.request',
|
|
@@ -30,9 +30,21 @@ export const MAX_JSON_LINE_BYTES = MAX_NATIVE_MESSAGE_BYTES;
|
|
|
30
30
|
/** Default timeout for a bridge request awaiting an extension response (ms). */
|
|
31
31
|
export const DEFAULT_DAEMON_PENDING_TIMEOUT_MS = 30_000;
|
|
32
32
|
|
|
33
|
+
/** Transport allowance added after an operation's own normalized timeout. */
|
|
34
|
+
export const DAEMON_PENDING_TIMEOUT_MARGIN_MS = 2_000;
|
|
35
|
+
|
|
36
|
+
/** Upper bound for a daemon request awaiting an extension response. */
|
|
37
|
+
export const MAX_DAEMON_PENDING_TIMEOUT_MS = 122_000;
|
|
38
|
+
|
|
33
39
|
/** Default timeout for a BridgeClient request (ms). */
|
|
34
40
|
export const DEFAULT_CLIENT_REQUEST_TIMEOUT_MS = 8_000;
|
|
35
41
|
|
|
42
|
+
/** Transport allowance added after an operation timeout on the client side. */
|
|
43
|
+
export const CLIENT_REQUEST_TIMEOUT_MARGIN_MS = 4_000;
|
|
44
|
+
|
|
45
|
+
/** Upper bound for a client request, kept beyond the daemon's maximum deadline. */
|
|
46
|
+
export const MAX_CLIENT_REQUEST_TIMEOUT_MS = 124_000;
|
|
47
|
+
|
|
36
48
|
/** Maximum number of recent log entries retained by the daemon. */
|
|
37
49
|
export const DAEMON_RECENT_LOG_LIMIT = 200;
|
|
38
50
|
|
|
@@ -64,6 +76,11 @@ export const DEBUGGER_BACKED_METHODS = new Set([
|
|
|
64
76
|
'performance.get_metrics',
|
|
65
77
|
'screenshot.capture_element',
|
|
66
78
|
'screenshot.capture_region',
|
|
79
|
+
'screenshot.capture_full_page',
|
|
80
|
+
'network.intercept.add',
|
|
81
|
+
'network.intercept.remove',
|
|
82
|
+
'network.intercept.list',
|
|
83
|
+
'network.intercept.clear',
|
|
67
84
|
'cdp.get_document',
|
|
68
85
|
'cdp.get_dom_snapshot',
|
|
69
86
|
'cdp.get_box_model',
|
|
@@ -271,7 +271,23 @@ export function validateBridgeRequest(request) {
|
|
|
271
271
|
) {
|
|
272
272
|
throw new BridgeError(ERROR_CODES.INVALID_REQUEST, 'Request params must be an object.');
|
|
273
273
|
}
|
|
274
|
-
const
|
|
274
|
+
const hasTabId = Object.hasOwn(candidate, 'tab_id');
|
|
275
|
+
const candidateTabId = candidate.tab_id;
|
|
276
|
+
/** @type {number | null} */
|
|
277
|
+
let tabId = null;
|
|
278
|
+
if (hasTabId && candidateTabId !== null) {
|
|
279
|
+
if (
|
|
280
|
+
typeof candidateTabId !== 'number' ||
|
|
281
|
+
!Number.isSafeInteger(candidateTabId) ||
|
|
282
|
+
candidateTabId <= 0
|
|
283
|
+
) {
|
|
284
|
+
throw new BridgeError(
|
|
285
|
+
ERROR_CODES.INVALID_REQUEST,
|
|
286
|
+
'Request tab_id must be null or a positive safe integer.'
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
tabId = candidateTabId;
|
|
290
|
+
}
|
|
275
291
|
|
|
276
292
|
const method = /** @type {BridgeMethod} */ (candidate.method);
|
|
277
293
|
const params =
|
|
@@ -282,7 +298,7 @@ export function validateBridgeRequest(request) {
|
|
|
282
298
|
return {
|
|
283
299
|
id: candidate.id,
|
|
284
300
|
method,
|
|
285
|
-
tab_id:
|
|
301
|
+
tab_id: tabId,
|
|
286
302
|
params: normalizeRequestParams(method, params),
|
|
287
303
|
meta: {
|
|
288
304
|
...meta,
|
|
@@ -375,6 +391,33 @@ function normalizeRequestParams(method, params) {
|
|
|
375
391
|
}
|
|
376
392
|
}
|
|
377
393
|
|
|
394
|
+
/**
|
|
395
|
+
* Return the browser-operation timeout for methods that wait asynchronously.
|
|
396
|
+
* This lets clients and the daemon keep transport deadlines outside the
|
|
397
|
+
* normalized operation deadline, including when callers omit timeoutMs.
|
|
398
|
+
*
|
|
399
|
+
* @param {BridgeMethod} method
|
|
400
|
+
* @param {Record<string, unknown>} [params={}]
|
|
401
|
+
* @returns {number | null}
|
|
402
|
+
*/
|
|
403
|
+
export function getBridgeOperationTimeoutMs(method, params = {}) {
|
|
404
|
+
switch (method) {
|
|
405
|
+
case 'navigation.navigate':
|
|
406
|
+
case 'navigation.reload':
|
|
407
|
+
case 'navigation.go_back':
|
|
408
|
+
case 'navigation.go_forward':
|
|
409
|
+
return normalizeNavigationAction(params).timeoutMs;
|
|
410
|
+
case 'page.wait_for_load_state':
|
|
411
|
+
return normalizeWaitForLoadStateParams(params).timeoutMs;
|
|
412
|
+
case 'page.evaluate':
|
|
413
|
+
return normalizeEvaluateParams(params).timeoutMs;
|
|
414
|
+
case 'dom.wait_for':
|
|
415
|
+
return normalizeWaitForParams(params).timeoutMs;
|
|
416
|
+
default:
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
378
421
|
/**
|
|
379
422
|
* @param {DomQueryParams} [params={}]
|
|
380
423
|
* @returns {NormalizedDomQuery}
|
|
@@ -126,7 +126,7 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
|
|
|
126
126
|
'setup.install',
|
|
127
127
|
'system',
|
|
128
128
|
false,
|
|
129
|
-
['kind', 'target'],
|
|
129
|
+
['action', 'kind', 'target'],
|
|
130
130
|
'trivial'
|
|
131
131
|
),
|
|
132
132
|
'log.tail': createRegistryEntry('log.tail', 'system', false, ['limit'], 'trivial'),
|
|
@@ -13,7 +13,7 @@ Permission prompts are controlled by the host agent, not by Browser Bridge. In p
|
|
|
13
13
|
|
|
14
14
|
Skill name: `browser-bridge` (also known as `bbx`). In GitHub Copilot, invoke as `/browser-bridge`. `bbx` is the CLI command used throughout this skill.
|
|
15
15
|
When the runtime supports subagents, delegate bridge inspection to a smaller, lower-cost worker and return only concise findings to the parent.
|
|
16
|
-
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.
|
|
16
|
+
For open-ended investigation, start with structured reads (`page.get_state`, `dom.query`, `page.get_text`, `styles.get_computed`, and `bbx batch` for CLI or `browser_batch` for MCP) and escalate to screenshots or debugger-backed methods only when structured evidence is insufficient. `browser_call` accepts one protocol method at a time; `batch` is not a valid `browser_call` method.
|
|
17
17
|
|
|
18
18
|
## CLI
|
|
19
19
|
|
|
@@ -154,12 +154,12 @@ Error responses now include a machine-readable `error.recovery` field with `retr
|
|
|
154
154
|
6. **Rollback** - revert every patch before finishing unless user wants mutations kept.
|
|
155
155
|
7. **Confirm scope** - `status` first; stop if no extension connection.
|
|
156
156
|
8. **Screenshots last** - only when structured evidence is ambiguous; prefer `screenshot.capture_element`, then a tight `screenshot.capture_region`; use `screenshot.capture_full_page` only when document-level context matters.
|
|
157
|
-
9. **Batch reads** - combine independent reads in
|
|
157
|
+
9. **Batch reads** - combine independent reads with `bbx batch` in CLI mode or `browser_batch` in MCP mode; do not pass `batch` as a `browser_call` method.
|
|
158
158
|
10. **Avoid debugger first** - prefer DOM/content-script methods (`dom.*`, `styles.*`, `layout.get_box_model`, `page.get_console`, `page.get_text`, `page.get_storage`, `page.get_network`) before any debugger-backed method. Escalate to CDP only when those cannot answer the question.
|
|
159
159
|
11. **Evaluate only when needed** - `page.evaluate` is powerful but debugger-backed; use it only when DOM, storage, console, network, or text reads cannot expose the needed state.
|
|
160
160
|
12. **Debugger-backed methods are last resort** - treat `page.evaluate`, `dom.get_accessibility_tree`, `viewport.resize`, `performance.get_metrics`, `screenshot.capture_*`, and all `cdp.*` methods as escalation steps because they attach `chrome.debugger`.
|
|
161
|
-
13. **Wait after change** - after editing source
|
|
162
|
-
14. **
|
|
161
|
+
13. **Wait after change** - after editing source, wait for expected new text, a selector matching the changed attribute, or a detach/attach remount; waiting for `attached` on a selector that already exists does not prove HMR ran. Use `page.wait_for_load_state` for navigation, not HMR.
|
|
162
|
+
14. **Prime event buffers** - before reproducing a console or network issue, call `page.get_console` and `page.get_network` once with `clear: true`; then reproduce the event and read each buffer without clearing it.
|
|
163
163
|
15. **Semantic finding** - use `dom.find_by_text` / `dom.find_by_role` when you know the label but not the selector.
|
|
164
164
|
16. **Text extraction** - use `page.get_text` for full page text instead of `dom.query` on body.
|
|
165
165
|
17. **Network monitoring** - use `page.get_network` to inspect API calls; auto-installs interceptor.
|
|
@@ -218,8 +218,11 @@ bbx rollback patch_2 # clean up
|
|
|
218
218
|
### Verify an API call
|
|
219
219
|
|
|
220
220
|
```bash
|
|
221
|
-
bbx
|
|
222
|
-
bbx
|
|
221
|
+
bbx call page.get_console '{"clear":true}' # install + clear console capture
|
|
222
|
+
bbx call page.get_network '{"clear":true}' # install + clear network capture
|
|
223
|
+
# reproduce the API action here
|
|
224
|
+
bbx console error # read newly captured runtime errors
|
|
225
|
+
bbx network 20 # read newly captured requests
|
|
223
226
|
bbx eval 'document.querySelector("#app").__vue__.$store.state.user' # read framework state
|
|
224
227
|
bbx page-text 2000 # extract page content
|
|
225
228
|
```
|
|
@@ -249,7 +252,7 @@ When the user has a localhost dev server with watch/HMR:
|
|
|
249
252
|
3. **Identify the problem** - use `styles.get_computed`, `dom.get_html`, or `page.get_console` for errors.
|
|
250
253
|
4. **Prototype with patches** - `patch.apply_styles` / `patch.apply_dom` to verify the fix visually.
|
|
251
254
|
5. **Edit source files** - modify the actual code in the agent's workspace.
|
|
252
|
-
6. **Wait for HMR** - `dom.wait_for`
|
|
255
|
+
6. **Wait for HMR** - use `dom.wait_for` for expected new text, a selector matching the changed attribute, or a detach/attach remount. Do not wait for `attached` on an unchanged selector that already exists.
|
|
253
256
|
7. **Verify the change** - re-inspect the same area; compare with patch expectations.
|
|
254
257
|
8. **Check for regressions** - `page.get_console` for new errors; scroll and inspect adjacent areas.
|
|
255
258
|
9. **Rollback patches** - `patch.rollback` all temporary patches.
|
|
@@ -257,7 +260,8 @@ When the user has a localhost dev server with watch/HMR:
|
|
|
257
260
|
## Investigate-a-Bug Workflow
|
|
258
261
|
|
|
259
262
|
```
|
|
260
|
-
page.get_state → page.get_console (
|
|
263
|
+
page.get_state → page.get_console(clear: true) + page.get_network(clear: true)
|
|
264
|
+
→ reproduce the bug → page.get_console + page.get_network
|
|
261
265
|
→ dom.find_by_text('<error text>') or dom.query('<selector>')
|
|
262
266
|
→ styles.get_computed (check layout/visibility)
|
|
263
267
|
→ page.evaluate('document.querySelector(...).dataset') (read data attrs)
|
|
@@ -144,19 +144,22 @@ bbx call input.scroll_into_view '{"target":{"selector":"[data-testid=\"submit-bu
|
|
|
144
144
|
## Network Monitoring
|
|
145
145
|
|
|
146
146
|
```bash
|
|
147
|
-
bbx
|
|
148
|
-
bbx
|
|
149
|
-
|
|
147
|
+
bbx call page.get_console '{"clear":true}' # install capture and clear old console entries
|
|
148
|
+
bbx call page.get_network '{"clear":true}' # install capture and clear old network entries
|
|
149
|
+
# reproduce the interaction here
|
|
150
|
+
bbx network 50 # newly captured requests
|
|
151
|
+
bbx console error # newly captured errors
|
|
150
152
|
```
|
|
151
153
|
|
|
152
154
|
Each entry: `method`, `url`, `status`, `duration`, `initiator`.
|
|
153
155
|
|
|
154
156
|
Typical workflow - debug API calls:
|
|
155
157
|
|
|
156
|
-
1. `page.
|
|
157
|
-
2.
|
|
158
|
-
3.
|
|
159
|
-
4.
|
|
158
|
+
1. Prime and clear `page.get_console` and `page.get_network`
|
|
159
|
+
2. Reproduce the interaction
|
|
160
|
+
3. Read and filter `page.get_network` by URL pattern or status code
|
|
161
|
+
4. Cross-reference with `page.get_console` for errors
|
|
162
|
+
5. Use `page.evaluate` only if lighter evidence cannot expose the needed response state
|
|
160
163
|
|
|
161
164
|
## Network Interception
|
|
162
165
|
|