@browserbridge/bbx 1.12.3 → 1.14.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/PRIVACY.md +4 -0
- package/README.md +2 -0
- package/SECURITY.md +10 -0
- package/package.json +6 -6
- package/packages/agent-client/src/cli.js +130 -1
- package/packages/agent-client/src/command-registry.js +6 -0
- package/packages/agent-client/src/file-transfer.js +88 -0
- package/packages/agent-client/src/record-session.js +54 -0
- package/packages/agent-client/src/replay.js +124 -0
- package/packages/mcp-server/src/guidance.js +6 -2
- package/packages/mcp-server/src/handlers-capture.js +9 -0
- package/packages/mcp-server/src/handlers-page.js +6 -0
- package/packages/mcp-server/src/handlers-utils.js +96 -0
- package/packages/mcp-server/src/server.js +24 -4
- package/packages/native-host/src/artifact-store.js +42 -4
- package/packages/native-host/src/daemon.js +142 -6
- package/packages/native-host/src/native-host.js +10 -0
- package/packages/native-host/src/recording-store.js +136 -0
- package/packages/protocol/src/defaults.js +3 -0
- package/packages/protocol/src/downloads.js +35 -0
- package/packages/protocol/src/emulation.js +155 -0
- package/packages/protocol/src/errors.js +6 -0
- package/packages/protocol/src/files.js +94 -0
- package/packages/protocol/src/index.js +1 -0
- package/packages/protocol/src/protocol.js +60 -2
- package/packages/protocol/src/recording.js +70 -0
- package/packages/protocol/src/registry.js +242 -14
- package/packages/protocol/src/replay.js +144 -0
- package/packages/protocol/src/summary.js +54 -1
- package/packages/protocol/src/types.ts +52 -2
- package/packages/protocol/src/workflows.ts +22 -0
- package/skills/browser-bridge/SKILL.md +38 -6
- package/skills/browser-bridge/references/capture-context.md +9 -0
- package/skills/browser-bridge/references/emulation.md +18 -0
- package/skills/browser-bridge/references/files.md +50 -0
- package/skills/browser-bridge/references/interaction.md +38 -2
- package/skills/browser-bridge/references/playbooks.md +48 -0
- package/skills/browser-bridge/references/protocol.md +3 -1
- package/skills/browser-bridge/references/recording.md +54 -0
|
@@ -60,6 +60,10 @@ const METHOD_POLICIES = Object.freeze({
|
|
|
60
60
|
debuggerBacked: true,
|
|
61
61
|
}),
|
|
62
62
|
automationInput: Object.freeze({ capability: CAPABILITIES.AUTOMATION_INPUT }),
|
|
63
|
+
automationInputDebugger: Object.freeze({
|
|
64
|
+
capability: CAPABILITIES.AUTOMATION_INPUT,
|
|
65
|
+
debuggerBacked: true,
|
|
66
|
+
}),
|
|
63
67
|
screenshotPartialDebugger: Object.freeze({
|
|
64
68
|
capability: CAPABILITIES.SCREENSHOT_PARTIAL,
|
|
65
69
|
debuggerBacked: true,
|
|
@@ -96,6 +100,43 @@ const METHOD_POLICIES = Object.freeze({
|
|
|
96
100
|
* @type {Readonly<Record<import('./types.js').BridgeMethod, string>>}
|
|
97
101
|
*/
|
|
98
102
|
const BRIDGE_METHOD_DESCRIPTIONS = Object.freeze({
|
|
103
|
+
'session.journal_read':
|
|
104
|
+
'Read exact base64 bytes of a durable recording journal. Requires its secret journalId. Works after extension or daemon restart; page until nextOffset=null. Expires 24 hours after the last write.',
|
|
105
|
+
'session.journal_delete': 'Delete one durable recording journal using its secret journalId.',
|
|
106
|
+
'input.paste_image':
|
|
107
|
+
'Paste a staged PNG using the real clipboard and trusted Chrome input. Overwrites clipboard. Requires a focused top-document editor. Never automatically retry; verify insertion.',
|
|
108
|
+
'download.start':
|
|
109
|
+
'Start a browser-native download without the artifact size ceiling. Returns a secret downloadId for subsequent control. Saves on the browser machine.',
|
|
110
|
+
'download.status': 'Read progress and state of one BBX-created browser download.',
|
|
111
|
+
'download.pause': 'Pause one BBX-created browser download.',
|
|
112
|
+
'download.resume': 'Resume one resumable BBX-created browser download.',
|
|
113
|
+
'download.cancel': 'Cancel one BBX-created browser download.',
|
|
114
|
+
'download.release':
|
|
115
|
+
'Release a terminal download handle without deleting its file or browser history.',
|
|
116
|
+
'session.start':
|
|
117
|
+
'Start bounded recording. activity=true; network,bodies,messages,visual,followTabs,durable,replay default false. bodies/messages require network. durationMs=1000..1800000, default 60000. followTabs tracks up to eight opener descendants inside the enabled window. replay captures the root renderer stream. durable returns a secret journalId for crash recovery. Input values are redacted; pixels and binary bodies are not. Keep sessionId on one connection; two retained sessions maximum.',
|
|
118
|
+
'session.status':
|
|
119
|
+
'Probe a recording by sessionId, including the live page recorder, counters, gaps, body backlog and visual frame count. No event payloads.',
|
|
120
|
+
'session.read':
|
|
121
|
+
'Read bounded recording events with sessionId, cursor=0, limit=25 (max 100), waitMs=0 (max 25000). Cursors belong to this sessionId. Returns nextCursor and explicit capture gaps. No response bodies or images in this read.',
|
|
122
|
+
'session.stop':
|
|
123
|
+
'Stop intake, collect final activity, drain pending body reads and stop network capture. Retains evidence for five minutes for export. Failed cleanup is retryable.',
|
|
124
|
+
'session.export':
|
|
125
|
+
'Stop and drain a recording, then export a chunked artifact. format=json includes archived events, opt-in bodies and frames. format=html is a self-contained, script-free sampled-frame replay. Gaps and dropped counts remain explicit. Same connection required for artifact.read.',
|
|
126
|
+
'session.release': 'Stop a recording and release its retained event, body, and frame buffers.',
|
|
127
|
+
'files.stage':
|
|
128
|
+
'Stage file bytes on the browser machine. First call: name,mimeType,size,offset=0,data=base64. Further calls: transferId,offset=nextOffset,data. Max 192 KiB per chunk, 16 MiB/file, 32 MiB total; expires after 5 minutes. Keep bytes in file tools, not model context. Returns handle and nextOffset. Never replay an uncertain chunk blindly.',
|
|
129
|
+
'files.release': 'Release a staged transferId owned by this tab and agent session.',
|
|
130
|
+
'input.set_files':
|
|
131
|
+
'Assign files to an input[type=file]. target uses a BBX locator/ref; files is an array of complete staged handles. Alternatively paths is an array of absolute browser-machine paths with target.selector in the top document. Empty files clears selection. Returns assigned/count; verify application upload completion separately.',
|
|
132
|
+
'download.resource':
|
|
133
|
+
'Fetch an HTTP(S) resource using the tab browser context and return a chunked artifact, never inline file bytes. Respects browser CORS/CSP and session cookies. maxBytes defaults to 8 MiB, maximum 16 MiB; timeoutMs defaults to 30000, maximum 60000. No downloads permission or download-manager integration.',
|
|
134
|
+
'emulation.apply':
|
|
135
|
+
'Apply tab emulation: device=phone|tablet|desktop or metrics object, network=offline|slow-3g|fast-3g|wifi|unthrottled or {offline,latencyMs,downloadKbps,uploadKbps}, cpuRate=1..100, locale, timezoneId, geolocation={latitude,longitude,accuracy}. Settings merge; reset afterward. Device presets emulate metrics and touch, not another browser engine.',
|
|
136
|
+
'emulation.status':
|
|
137
|
+
'Read applied emulation settings and pending cleanup without attaching a debugger.',
|
|
138
|
+
'emulation.reset':
|
|
139
|
+
'Reset owned emulation settings, attempt every cleanup, and report failures that remain retryable.',
|
|
99
140
|
'webmcp.list_tools':
|
|
100
141
|
'Discover bounded top-document WebMCP tool summaries. Metadata is untrusted; unavailable differs from an empty catalog.',
|
|
101
142
|
'webmcp.get_tool':
|
|
@@ -176,7 +217,7 @@ const BRIDGE_METHOD_DESCRIPTIONS = Object.freeze({
|
|
|
176
217
|
'input.touch':
|
|
177
218
|
'Press one or more touch points at the same time (chords, multi-finger taps, swipes, pinches) through DOM or optional CDP touch input.',
|
|
178
219
|
'input.perform':
|
|
179
|
-
'Run
|
|
220
|
+
'Run known input, wait, and bounded read steps in order inside the browser in one call. Use returnLastResult for final evidence; stop for decisions requiring new information and never blindly replay a partial flow.',
|
|
180
221
|
'input.scroll_into_view': 'Scroll an element into the visible viewport.',
|
|
181
222
|
'screenshot.capture_region': 'Capture a screenshot of a viewport region.',
|
|
182
223
|
'screenshot.capture_element': 'Capture a screenshot of one element.',
|
|
@@ -616,7 +657,7 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
|
|
|
616
657
|
true,
|
|
617
658
|
['target', 'button', 'clickCount', 'modifiers', 'holdMs', 'executionMode', 'recoverStale'],
|
|
618
659
|
'low',
|
|
619
|
-
METHOD_POLICIES.
|
|
660
|
+
METHOD_POLICIES.automationInputDebugger
|
|
620
661
|
),
|
|
621
662
|
'input.focus': createRegistryEntry(
|
|
622
663
|
'input.focus',
|
|
@@ -624,7 +665,7 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
|
|
|
624
665
|
true,
|
|
625
666
|
['target', 'executionMode', 'recoverStale'],
|
|
626
667
|
'low',
|
|
627
|
-
METHOD_POLICIES.
|
|
668
|
+
METHOD_POLICIES.automationInputDebugger
|
|
628
669
|
),
|
|
629
670
|
'input.type': createRegistryEntry(
|
|
630
671
|
'input.type',
|
|
@@ -632,7 +673,7 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
|
|
|
632
673
|
true,
|
|
633
674
|
['target', 'text', 'clear', 'submit', 'modifiers', 'executionMode', 'recoverStale'],
|
|
634
675
|
'low',
|
|
635
|
-
METHOD_POLICIES.
|
|
676
|
+
METHOD_POLICIES.automationInputDebugger
|
|
636
677
|
),
|
|
637
678
|
'input.fill': createRegistryEntry(
|
|
638
679
|
'input.fill',
|
|
@@ -640,7 +681,7 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
|
|
|
640
681
|
true,
|
|
641
682
|
['target', 'value', 'mode', 'executionMode', 'recoverStale'],
|
|
642
683
|
'low',
|
|
643
|
-
METHOD_POLICIES.
|
|
684
|
+
METHOD_POLICIES.automationInputDebugger
|
|
644
685
|
),
|
|
645
686
|
'input.press_key': createRegistryEntry(
|
|
646
687
|
'input.press_key',
|
|
@@ -648,7 +689,7 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
|
|
|
648
689
|
true,
|
|
649
690
|
['target', 'key', 'modifiers', 'holdMs', 'executionMode', 'recoverStale'],
|
|
650
691
|
'low',
|
|
651
|
-
METHOD_POLICIES.
|
|
692
|
+
METHOD_POLICIES.automationInputDebugger
|
|
652
693
|
),
|
|
653
694
|
'input.set_checked': createRegistryEntry(
|
|
654
695
|
'input.set_checked',
|
|
@@ -656,7 +697,7 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
|
|
|
656
697
|
true,
|
|
657
698
|
['target', 'checked', 'executionMode', 'recoverStale'],
|
|
658
699
|
'low',
|
|
659
|
-
METHOD_POLICIES.
|
|
700
|
+
METHOD_POLICIES.automationInputDebugger
|
|
660
701
|
),
|
|
661
702
|
'input.select_option': createRegistryEntry(
|
|
662
703
|
'input.select_option',
|
|
@@ -664,7 +705,7 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
|
|
|
664
705
|
true,
|
|
665
706
|
['target', 'values', 'labels', 'indexes', 'executionMode', 'recoverStale'],
|
|
666
707
|
'low',
|
|
667
|
-
METHOD_POLICIES.
|
|
708
|
+
METHOD_POLICIES.automationInputDebugger
|
|
668
709
|
),
|
|
669
710
|
'input.hover': createRegistryEntry(
|
|
670
711
|
'input.hover',
|
|
@@ -672,7 +713,7 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
|
|
|
672
713
|
true,
|
|
673
714
|
['target', 'duration', 'modifiers', 'executionMode', 'recoverStale'],
|
|
674
715
|
'low',
|
|
675
|
-
METHOD_POLICIES.
|
|
716
|
+
METHOD_POLICIES.automationInputDebugger
|
|
676
717
|
),
|
|
677
718
|
'input.drag': createRegistryEntry(
|
|
678
719
|
'input.drag',
|
|
@@ -680,7 +721,7 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
|
|
|
680
721
|
true,
|
|
681
722
|
['source', 'destination', 'offsetX', 'offsetY', 'executionMode', 'recoverStale'],
|
|
682
723
|
'moderate',
|
|
683
|
-
METHOD_POLICIES.
|
|
724
|
+
METHOD_POLICIES.automationInputDebugger
|
|
684
725
|
),
|
|
685
726
|
'input.touch': createRegistryEntry(
|
|
686
727
|
'input.touch',
|
|
@@ -688,15 +729,15 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
|
|
|
688
729
|
true,
|
|
689
730
|
['points', 'holdMs', 'moveSteps', 'executionMode', 'recoverStale'],
|
|
690
731
|
'moderate',
|
|
691
|
-
METHOD_POLICIES.
|
|
732
|
+
METHOD_POLICIES.automationInputDebugger
|
|
692
733
|
),
|
|
693
734
|
'input.perform': createRegistryEntry(
|
|
694
735
|
'input.perform',
|
|
695
736
|
'interact',
|
|
696
737
|
true,
|
|
697
|
-
['steps', 'executionMode', 'timeoutMs', 'continueOnError'],
|
|
738
|
+
['steps', 'executionMode', 'timeoutMs', 'continueOnError', 'returnLastResult'],
|
|
698
739
|
'moderate',
|
|
699
|
-
METHOD_POLICIES.
|
|
740
|
+
METHOD_POLICIES.automationInputDebugger
|
|
700
741
|
),
|
|
701
742
|
'input.scroll_into_view': createRegistryEntry(
|
|
702
743
|
'input.scroll_into_view',
|
|
@@ -704,7 +745,7 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
|
|
|
704
745
|
true,
|
|
705
746
|
['target'],
|
|
706
747
|
'low',
|
|
707
|
-
METHOD_POLICIES.
|
|
748
|
+
METHOD_POLICIES.automationInputDebugger
|
|
708
749
|
),
|
|
709
750
|
// capture - high (screenshots, CDP)
|
|
710
751
|
'screenshot.capture_region': createRegistryEntry(
|
|
@@ -860,6 +901,192 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
|
|
|
860
901
|
'moderate',
|
|
861
902
|
METHOD_POLICIES.automationInput
|
|
862
903
|
),
|
|
904
|
+
'emulation.apply': createRegistryEntry(
|
|
905
|
+
'emulation.apply',
|
|
906
|
+
'emulation',
|
|
907
|
+
true,
|
|
908
|
+
['device', 'network', 'cpuRate', 'locale', 'timezoneId', 'geolocation'],
|
|
909
|
+
'moderate',
|
|
910
|
+
METHOD_POLICIES.viewportControlDebugger
|
|
911
|
+
),
|
|
912
|
+
'emulation.status': createRegistryEntry(
|
|
913
|
+
'emulation.status',
|
|
914
|
+
'emulation',
|
|
915
|
+
true,
|
|
916
|
+
[],
|
|
917
|
+
'trivial',
|
|
918
|
+
METHOD_POLICIES.pageRead
|
|
919
|
+
),
|
|
920
|
+
'emulation.reset': createRegistryEntry(
|
|
921
|
+
'emulation.reset',
|
|
922
|
+
'emulation',
|
|
923
|
+
true,
|
|
924
|
+
[],
|
|
925
|
+
'low',
|
|
926
|
+
METHOD_POLICIES.viewportControlDebugger
|
|
927
|
+
),
|
|
928
|
+
'files.stage': createRegistryEntry(
|
|
929
|
+
'files.stage',
|
|
930
|
+
'files',
|
|
931
|
+
true,
|
|
932
|
+
['name', 'mimeType', 'size', 'transferId', 'offset', 'data'],
|
|
933
|
+
'moderate',
|
|
934
|
+
METHOD_POLICIES.automationInput
|
|
935
|
+
),
|
|
936
|
+
'files.release': createRegistryEntry(
|
|
937
|
+
'files.release',
|
|
938
|
+
'files',
|
|
939
|
+
true,
|
|
940
|
+
['transferId'],
|
|
941
|
+
'low',
|
|
942
|
+
METHOD_POLICIES.automationInput
|
|
943
|
+
),
|
|
944
|
+
'input.set_files': createRegistryEntry(
|
|
945
|
+
'input.set_files',
|
|
946
|
+
'files',
|
|
947
|
+
true,
|
|
948
|
+
['target', 'files', 'paths'],
|
|
949
|
+
'moderate',
|
|
950
|
+
METHOD_POLICIES.automationInputDebugger
|
|
951
|
+
),
|
|
952
|
+
'input.paste_image': createRegistryEntry(
|
|
953
|
+
'input.paste_image',
|
|
954
|
+
'files',
|
|
955
|
+
true,
|
|
956
|
+
['target', 'transferId'],
|
|
957
|
+
'moderate',
|
|
958
|
+
METHOD_POLICIES.automationInputDebugger
|
|
959
|
+
),
|
|
960
|
+
'download.resource': createRegistryEntry(
|
|
961
|
+
'download.resource',
|
|
962
|
+
'files',
|
|
963
|
+
true,
|
|
964
|
+
['url', 'maxBytes', 'timeoutMs'],
|
|
965
|
+
'moderate',
|
|
966
|
+
METHOD_POLICIES.networkRead
|
|
967
|
+
),
|
|
968
|
+
'download.start': createRegistryEntry(
|
|
969
|
+
'download.start',
|
|
970
|
+
'files',
|
|
971
|
+
true,
|
|
972
|
+
['url', 'filename', 'saveAs'],
|
|
973
|
+
'moderate',
|
|
974
|
+
METHOD_POLICIES.automationInput
|
|
975
|
+
),
|
|
976
|
+
'download.status': createRegistryEntry(
|
|
977
|
+
'download.status',
|
|
978
|
+
'files',
|
|
979
|
+
true,
|
|
980
|
+
['downloadId'],
|
|
981
|
+
'low',
|
|
982
|
+
METHOD_POLICIES.networkRead
|
|
983
|
+
),
|
|
984
|
+
'download.pause': createRegistryEntry(
|
|
985
|
+
'download.pause',
|
|
986
|
+
'files',
|
|
987
|
+
true,
|
|
988
|
+
['downloadId'],
|
|
989
|
+
'low',
|
|
990
|
+
METHOD_POLICIES.automationInput
|
|
991
|
+
),
|
|
992
|
+
'download.resume': createRegistryEntry(
|
|
993
|
+
'download.resume',
|
|
994
|
+
'files',
|
|
995
|
+
true,
|
|
996
|
+
['downloadId'],
|
|
997
|
+
'low',
|
|
998
|
+
METHOD_POLICIES.automationInput
|
|
999
|
+
),
|
|
1000
|
+
'download.cancel': createRegistryEntry(
|
|
1001
|
+
'download.cancel',
|
|
1002
|
+
'files',
|
|
1003
|
+
true,
|
|
1004
|
+
['downloadId'],
|
|
1005
|
+
'low',
|
|
1006
|
+
METHOD_POLICIES.automationInput
|
|
1007
|
+
),
|
|
1008
|
+
'download.release': createRegistryEntry(
|
|
1009
|
+
'download.release',
|
|
1010
|
+
'files',
|
|
1011
|
+
true,
|
|
1012
|
+
['downloadId'],
|
|
1013
|
+
'low',
|
|
1014
|
+
METHOD_POLICIES.automationInput
|
|
1015
|
+
),
|
|
1016
|
+
'session.start': createRegistryEntry(
|
|
1017
|
+
'session.start',
|
|
1018
|
+
'session',
|
|
1019
|
+
true,
|
|
1020
|
+
[
|
|
1021
|
+
'activity',
|
|
1022
|
+
'network',
|
|
1023
|
+
'bodies',
|
|
1024
|
+
'messages',
|
|
1025
|
+
'visual',
|
|
1026
|
+
'durationMs',
|
|
1027
|
+
'followTabs',
|
|
1028
|
+
'durable',
|
|
1029
|
+
'replay',
|
|
1030
|
+
],
|
|
1031
|
+
'moderate',
|
|
1032
|
+
METHOD_POLICIES.automationInputDebugger
|
|
1033
|
+
),
|
|
1034
|
+
'session.journal_read': createRegistryEntry(
|
|
1035
|
+
'session.journal_read',
|
|
1036
|
+
'session',
|
|
1037
|
+
false,
|
|
1038
|
+
['journalId', 'offset', 'maxBytes'],
|
|
1039
|
+
'low',
|
|
1040
|
+
METHOD_POLICIES.none
|
|
1041
|
+
),
|
|
1042
|
+
'session.journal_delete': createRegistryEntry(
|
|
1043
|
+
'session.journal_delete',
|
|
1044
|
+
'session',
|
|
1045
|
+
false,
|
|
1046
|
+
['journalId'],
|
|
1047
|
+
'low',
|
|
1048
|
+
METHOD_POLICIES.none
|
|
1049
|
+
),
|
|
1050
|
+
'session.status': createRegistryEntry(
|
|
1051
|
+
'session.status',
|
|
1052
|
+
'session',
|
|
1053
|
+
true,
|
|
1054
|
+
['sessionId'],
|
|
1055
|
+
'low',
|
|
1056
|
+
METHOD_POLICIES.pageRead
|
|
1057
|
+
),
|
|
1058
|
+
'session.read': createRegistryEntry(
|
|
1059
|
+
'session.read',
|
|
1060
|
+
'session',
|
|
1061
|
+
true,
|
|
1062
|
+
['sessionId', 'cursor', 'limit', 'waitMs'],
|
|
1063
|
+
'low',
|
|
1064
|
+
METHOD_POLICIES.pageRead
|
|
1065
|
+
),
|
|
1066
|
+
'session.stop': createRegistryEntry(
|
|
1067
|
+
'session.stop',
|
|
1068
|
+
'session',
|
|
1069
|
+
true,
|
|
1070
|
+
['sessionId'],
|
|
1071
|
+
'moderate',
|
|
1072
|
+
METHOD_POLICIES.automationInputDebugger
|
|
1073
|
+
),
|
|
1074
|
+
'session.export': createRegistryEntry(
|
|
1075
|
+
'session.export',
|
|
1076
|
+
'session',
|
|
1077
|
+
true,
|
|
1078
|
+
['sessionId', 'format'],
|
|
1079
|
+
'moderate',
|
|
1080
|
+
METHOD_POLICIES.automationInputDebugger
|
|
1081
|
+
),
|
|
1082
|
+
'session.release': createRegistryEntry(
|
|
1083
|
+
'session.release',
|
|
1084
|
+
'session',
|
|
1085
|
+
true,
|
|
1086
|
+
['sessionId'],
|
|
1087
|
+
'low',
|
|
1088
|
+
METHOD_POLICIES.automationInputDebugger
|
|
1089
|
+
),
|
|
863
1090
|
});
|
|
864
1091
|
|
|
865
1092
|
/** @type {ReadonlyArray<import('./types.js').BridgeMethod>} */
|
|
@@ -872,6 +1099,7 @@ export const METHOD_SET = new Set(BRIDGE_METHODS);
|
|
|
872
1099
|
|
|
873
1100
|
/** Methods whose read-only forms may run concurrently without side effects. */
|
|
874
1101
|
const BATCH_SAFE_METHODS = new Set([
|
|
1102
|
+
'emulation.status',
|
|
875
1103
|
'webmcp.list_tools',
|
|
876
1104
|
'webmcp.get_tool',
|
|
877
1105
|
'health.ping',
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/** @typedef {{ frames: Array<{ at: number, data: string, width?: number, height?: number, tabId?: number }>, events: Array<Record<string, unknown>>, bodies?: Array<Record<string, unknown>>, gaps: number, dropped: number, replayMode?: string, memoryWindow?: boolean }} Replay */
|
|
4
|
+
|
|
5
|
+
/** Offline renderer playback. Untrusted data is JSON text; only this fixed viewer script executes.
|
|
6
|
+
* @param {Replay} recording @returns {string} */
|
|
7
|
+
export function renderReplay(recording) {
|
|
8
|
+
const gaps = Number.isSafeInteger(recording.gaps) ? recording.gaps : 0;
|
|
9
|
+
const dropped = Number.isSafeInteger(recording.dropped) ? recording.dropped : 0;
|
|
10
|
+
const nonce = crypto.randomUUID().replaceAll('-', '');
|
|
11
|
+
const frames = recording.frames
|
|
12
|
+
.filter((frame) => Number.isFinite(frame.at) && /^[A-Za-z0-9+/]+={0,2}$/u.test(frame.data))
|
|
13
|
+
.sort((a, b) => a.at - b.at);
|
|
14
|
+
const data = JSON.stringify({
|
|
15
|
+
frames,
|
|
16
|
+
clicks: recording.events.filter((event) => event.type === 'click'),
|
|
17
|
+
})
|
|
18
|
+
.replaceAll('<', '\\u003c')
|
|
19
|
+
.replaceAll('&', '\\u0026');
|
|
20
|
+
return `<!doctype html><html lang="en"><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'"><title>BBX renderer replay</title>
|
|
21
|
+
<style>body{font:16px system-ui;background:#17212b;color:#fff;margin:24px}button,input{margin:8px}#stage{position:relative;display:inline-block;max-width:100%}img{max-width:100%;display:block}#click{position:absolute;width:24px;height:24px;border:3px solid #ff574a;border-radius:50%;transform:translate(-50%,-50%);display:none;pointer-events:none}input[type=range]{width:60%}pre{white-space:pre-wrap;overflow-wrap:anywhere}</style>
|
|
22
|
+
<h1>BBX renderer replay</h1><p>${gaps} capture gaps; ${dropped} dropped events.${recording.memoryWindow ? ' Retained memory window only; use the durable journal for the complete replay.' : ''} Frames represent visible pixels. External assets and page scripts are not loaded.</p>
|
|
23
|
+
<div><button id="play">Play</button><select id="speed"><option value="0.5">0.5×</option><option value="1" selected>1×</option><option value="2">2×</option><option value="4">4×</option></select><input id="seek" type="range" min="0" max="1" step="1" value="0" aria-label="Replay time"><output id="time"></output></div>
|
|
24
|
+
<div id="stage"><img id="frame" alt="Recorded renderer output"><span id="click"></span></div><details><summary>Activity and network events</summary><pre id="events"></pre></details>
|
|
25
|
+
<script id="data" type="application/json" nonce="${nonce}">${data}</script><script id="timeline" type="application/json" nonce="${nonce}">${JSON.stringify(recording.events).replaceAll('<', '\\u003c').replaceAll('&', '\\u0026')}</script>
|
|
26
|
+
<script nonce="${nonce}">
|
|
27
|
+
const {frames,clicks}=JSON.parse(document.getElementById('data').textContent);
|
|
28
|
+
const image=document.getElementById('frame'),seek=document.getElementById('seek'),play=document.getElementById('play'),dot=document.getElementById('click');
|
|
29
|
+
document.getElementById('events').textContent=JSON.stringify(JSON.parse(document.getElementById('timeline').textContent),null,2);
|
|
30
|
+
const start=frames[0]?.at||0,end=Math.max(start,frames.at(-1)?.at||0);seek.max=String(end-start);
|
|
31
|
+
let playing=false,position=0,last=0,index=-1,raf=0;
|
|
32
|
+
function show(){let low=0,high=frames.length-1,best=-1;while(low<=high){const mid=(low+high)>>1;if(frames[mid].at<=start+position){best=mid;low=mid+1}else high=mid-1}if(best>=0&&best!==index){index=best;image.src='data:image/jpeg;base64,'+frames[index].data}seek.value=String(position);document.getElementById('time').textContent=(position/1000).toFixed(1)+' / '+((end-start)/1000).toFixed(1)+' s';const f=frames[index];const c=clicks.findLast(c=>c.at<=start+position&&start+position-c.at<600&&(!f?.tabId||f.tabId===c.tabId));dot.style.display=c&&f?.width&&f?.height?'block':'none';if(c&&f?.width&&f?.height){dot.style.left=(100*c.x/f.width)+'%';dot.style.top=(100*c.y/f.height)+'%'}}
|
|
33
|
+
play.onclick=()=>{cancelAnimationFrame(raf);playing=!playing;if(position>=end-start)position=0;play.textContent=playing?'Pause':'Play';last=performance.now();if(playing)raf=requestAnimationFrame(tick)};seek.oninput=()=>{position=Number(seek.value);show()};
|
|
34
|
+
function tick(now){if(playing){position=Math.min(end-start,position+(now-last)*Number(document.getElementById('speed').value));show();if(position>=end-start){playing=false;play.textContent='Play'}}last=now;if(playing)raf=requestAnimationFrame(tick)}show();
|
|
35
|
+
</script></html>`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Recover complete records; report a partial tail instead of silently claiming a complete recording.
|
|
39
|
+
* @param {string} text @returns {Replay} */
|
|
40
|
+
export function parseRecordingJournal(text) {
|
|
41
|
+
/** @type {Replay} */
|
|
42
|
+
const result = {
|
|
43
|
+
frames: [],
|
|
44
|
+
events: [],
|
|
45
|
+
bodies: [],
|
|
46
|
+
gaps: 0,
|
|
47
|
+
dropped: 0,
|
|
48
|
+
replayMode: 'renderer-stream',
|
|
49
|
+
};
|
|
50
|
+
const lines = text.split('\n');
|
|
51
|
+
/** @type {Map<number, number>} */
|
|
52
|
+
const eventIndexes = new Map();
|
|
53
|
+
let ended = false;
|
|
54
|
+
if (lines.pop()) result.gaps++;
|
|
55
|
+
for (const line of lines) {
|
|
56
|
+
if (!line) continue;
|
|
57
|
+
const entry = /** @type {Record<string, unknown>} */ (JSON.parse(line));
|
|
58
|
+
if (!entry || typeof entry !== 'object') throw new Error('Invalid journal record.');
|
|
59
|
+
if (
|
|
60
|
+
entry.kind === 'frame' &&
|
|
61
|
+
entry.frame &&
|
|
62
|
+
typeof entry.frame === 'object' &&
|
|
63
|
+
'data' in entry.frame &&
|
|
64
|
+
typeof entry.frame.data === 'string' &&
|
|
65
|
+
'at' in entry.frame &&
|
|
66
|
+
typeof entry.frame.at === 'number'
|
|
67
|
+
)
|
|
68
|
+
result.frames.push(/** @type {Replay['frames'][number]} */ (entry.frame));
|
|
69
|
+
else if (
|
|
70
|
+
(entry.kind === 'event' || entry.kind === 'event-update') &&
|
|
71
|
+
entry.event &&
|
|
72
|
+
typeof entry.event === 'object'
|
|
73
|
+
) {
|
|
74
|
+
const event = /** @type {Record<string, unknown>} */ (entry.event);
|
|
75
|
+
const index =
|
|
76
|
+
entry.kind === 'event-update' && typeof event.seq === 'number'
|
|
77
|
+
? eventIndexes.get(event.seq)
|
|
78
|
+
: undefined;
|
|
79
|
+
if (index !== undefined) result.events[index] = event;
|
|
80
|
+
else {
|
|
81
|
+
if (typeof event.seq === 'number') eventIndexes.set(event.seq, result.events.length);
|
|
82
|
+
result.events.push(event);
|
|
83
|
+
}
|
|
84
|
+
} else if (entry.kind === 'body' && entry.body && typeof entry.body === 'object')
|
|
85
|
+
result.bodies?.push(/** @type {Record<string, unknown>} */ (entry.body));
|
|
86
|
+
else if (entry.kind === 'end') {
|
|
87
|
+
ended = true;
|
|
88
|
+
result.gaps += Number(entry.gaps) || 0;
|
|
89
|
+
result.dropped = Number(entry.dropped) || 0;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (!ended) result.gaps++;
|
|
93
|
+
result.events = correlateReplayEvents(result.events);
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** @template {Record<string, unknown>} T @param {T[]} events @returns {T[]} */
|
|
98
|
+
export function correlateReplayEvents(events) {
|
|
99
|
+
/** @type {Map<unknown, T[]>} */
|
|
100
|
+
const tabs = new Map();
|
|
101
|
+
for (const event of events) {
|
|
102
|
+
if (
|
|
103
|
+
event.origin !== 'trusted-event' ||
|
|
104
|
+
!['click', 'input', 'change', 'submit'].includes(String(event.type)) ||
|
|
105
|
+
!Number.isFinite(event.at)
|
|
106
|
+
)
|
|
107
|
+
continue;
|
|
108
|
+
const list = tabs.get(event.tabId) ?? [];
|
|
109
|
+
list.push(event);
|
|
110
|
+
tabs.set(event.tabId, list);
|
|
111
|
+
}
|
|
112
|
+
for (const list of tabs.values()) list.sort((a, b) => Number(a.at) - Number(b.at));
|
|
113
|
+
return events.map((event) => {
|
|
114
|
+
if (
|
|
115
|
+
!['request', 'page-error', 'navigation'].includes(String(event.type)) ||
|
|
116
|
+
!Number.isFinite(event.at)
|
|
117
|
+
)
|
|
118
|
+
return event;
|
|
119
|
+
const actions = tabs.get(event.tabId) ?? [];
|
|
120
|
+
let low = 0,
|
|
121
|
+
high = actions.length - 1,
|
|
122
|
+
best = -1;
|
|
123
|
+
while (low <= high) {
|
|
124
|
+
const mid = (low + high) >> 1;
|
|
125
|
+
if (Number(actions[mid].at) <= Number(event.at)) {
|
|
126
|
+
best = mid;
|
|
127
|
+
low = mid + 1;
|
|
128
|
+
} else high = mid - 1;
|
|
129
|
+
}
|
|
130
|
+
const action = actions[best];
|
|
131
|
+
const deltaMs = Number(event.at) - Number(action?.at);
|
|
132
|
+
return action && deltaMs <= 2000
|
|
133
|
+
? {
|
|
134
|
+
...event,
|
|
135
|
+
correlation: {
|
|
136
|
+
actionSeq: action.seq,
|
|
137
|
+
deltaMs,
|
|
138
|
+
basis: 'same-tab-time-window',
|
|
139
|
+
causality: false,
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
: event;
|
|
143
|
+
});
|
|
144
|
+
}
|
|
@@ -228,7 +228,11 @@ const ACTION_SUMMARIES = {
|
|
|
228
228
|
performed: {
|
|
229
229
|
text: (r) => {
|
|
230
230
|
const failures = Array.isArray(r.failures) ? r.failures.length : 0;
|
|
231
|
-
|
|
231
|
+
const lastResult = toRecord(r.lastResult);
|
|
232
|
+
const finalEvidence = lastResult.truncated
|
|
233
|
+
? ' Final result omitted: size limit exceeded; inspect with a narrower read, do not replay actions.'
|
|
234
|
+
: describeEffects(toRecord(lastResult.result).effects);
|
|
235
|
+
return `Performed ${r.completed ?? 0}/${r.total ?? 0} step(s) in ${r.elapsedMs ?? 0}ms${failures ? ` with ${failures} failed step(s)` : ''}.${finalEvidence}`;
|
|
232
236
|
},
|
|
233
237
|
evidence: (r) => r,
|
|
234
238
|
},
|
|
@@ -480,6 +484,55 @@ function summarizeBridgeResponseBody(response, method) {
|
|
|
480
484
|
}),
|
|
481
485
|
};
|
|
482
486
|
}
|
|
487
|
+
if (method?.startsWith('emulation.')) {
|
|
488
|
+
return {
|
|
489
|
+
ok: true,
|
|
490
|
+
summary: `Emulation ${result.active ? 'active' : 'inactive'}; ${Array.isArray(result.pendingReset) ? result.pendingReset.length : 0} setting(s) retained.`,
|
|
491
|
+
evidence: result,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
if (method === 'input.set_files') {
|
|
495
|
+
return {
|
|
496
|
+
ok: true,
|
|
497
|
+
summary: `File input ${result.assigned ? 'assigned' : 'not verified'}: ${result.count ?? 0} file(s).`,
|
|
498
|
+
evidence: result,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
if (method?.startsWith('files.')) {
|
|
502
|
+
return {
|
|
503
|
+
ok: true,
|
|
504
|
+
summary: result.released
|
|
505
|
+
? 'Staged file released.'
|
|
506
|
+
: `File staged: ${result.nextOffset ?? 0}/${result.size ?? 0} bytes.`,
|
|
507
|
+
evidence: result,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
if (method === 'download.resource') {
|
|
511
|
+
return { ok: true, summary: 'Resource downloaded to a chunked artifact.', evidence: result };
|
|
512
|
+
}
|
|
513
|
+
if (method?.startsWith('download.')) {
|
|
514
|
+
return {
|
|
515
|
+
ok: true,
|
|
516
|
+
summary: result.released
|
|
517
|
+
? 'Browser download handle released.'
|
|
518
|
+
: `Browser download ${result.state ?? 'updated'}: ${result.bytesReceived ?? 0}/${result.totalBytes ?? -1} bytes.`,
|
|
519
|
+
evidence: result,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
if (method === 'input.paste_image') {
|
|
523
|
+
return {
|
|
524
|
+
ok: true,
|
|
525
|
+
summary: 'Native image paste dispatched. Verify insertion in the editor.',
|
|
526
|
+
evidence: result,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
if (method?.startsWith('session.')) {
|
|
530
|
+
return {
|
|
531
|
+
ok: true,
|
|
532
|
+
summary: `Recording ${result.sessionId ?? ''}: ${result.released ? 'released' : result.delivery === 'artifact' ? 'exported' : result.active ? 'active' : 'stopped'}; ${result.gaps ?? 0} gap(s).`,
|
|
533
|
+
evidence: result,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
483
536
|
if (typeof result.daemon === 'string') {
|
|
484
537
|
const access =
|
|
485
538
|
result.access && typeof result.access === 'object'
|