@omercnet/paseo-shared-browser 1.2.2 → 2.0.0-next.249.1
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/OVERVIEW.md +2 -2
- package/README.md +316 -27
- package/REMOTE_BROWSER_RESEARCH.md +395 -0
- package/STREAMING_DESIGN.md +196 -0
- package/client/browser-auto-capture-density.test.ts +102 -0
- package/client/browser-auto-capture-density.ts +30 -0
- package/client/browser-canvas-input.test.ts +198 -0
- package/client/browser-canvas-input.ts +202 -0
- package/client/browser-canvas-keyboard-web.test.ts +113 -0
- package/client/browser-canvas-keyboard-web.ts +230 -0
- package/client/browser-canvas-keyboard.test.ts +123 -0
- package/client/browser-canvas-keyboard.ts +126 -0
- package/client/browser-canvas-layout.test.ts +109 -0
- package/client/browser-canvas-layout.ts +51 -0
- package/client/browser-canvas-viewport-web.test.ts +77 -0
- package/client/browser-canvas-viewport-web.ts +63 -0
- package/client/browser-canvas-viewport.tsx +152 -0
- package/client/browser-capture-cadence.test.ts +9 -0
- package/client/browser-capture-cadence.ts +6 -0
- package/client/browser-capture-density-controls.tsx +75 -0
- package/client/browser-chrome.tsx +234 -0
- package/client/browser-client-presence.ts +184 -0
- package/client/browser-compose-controls.test.tsx +275 -0
- package/client/browser-compose-controls.tsx +174 -0
- package/client/browser-control-button.tsx +109 -0
- package/client/browser-control-dialog.tsx +71 -0
- package/client/browser-control-request.test.ts +27 -0
- package/client/browser-control-request.ts +57 -0
- package/client/browser-control-tooltip-web.ts +10 -0
- package/client/browser-dialog-body.tsx +22 -0
- package/client/browser-emulation-mode.test.ts +75 -0
- package/client/browser-emulation-mode.ts +98 -0
- package/client/browser-fill-viewport.test.ts +45 -0
- package/client/browser-fill-viewport.ts +28 -0
- package/client/browser-frame-buffer.test.ts +157 -0
- package/client/browser-frame-buffer.ts +148 -0
- package/client/browser-frame-image.tsx +62 -0
- package/client/browser-image-load.test.ts +23 -0
- package/client/browser-image-load.ts +26 -0
- package/client/browser-input-queue.test.ts +1254 -0
- package/client/browser-input-queue.ts +519 -0
- package/client/browser-menu-placement.test.ts +90 -0
- package/client/browser-menu-placement.ts +78 -0
- package/client/browser-panel-styles.ts +384 -0
- package/client/browser-quality-controls.test.ts +26 -0
- package/client/browser-quality-controls.tsx +139 -0
- package/client/browser-resolution-picker.tsx +189 -0
- package/client/browser-tabs.tsx +117 -0
- package/client/browser-toolbar-menu-web.test.ts +185 -0
- package/client/browser-toolbar-menu-web.ts +159 -0
- package/client/browser-toolbar-menu.tsx +255 -0
- package/client/browser-toolbar-submenu.tsx +93 -0
- package/client/browser-video-bytes.test.ts +44 -0
- package/client/browser-video-currency.ts +45 -0
- package/client/browser-video-decoder.test.ts +473 -0
- package/client/browser-video-decoder.ts +311 -0
- package/client/browser-video-surface.tsx +21 -0
- package/client/browser-video-visibility.test.ts +45 -0
- package/client/browser-viewer-recovery.test.ts +111 -0
- package/client/browser-viewer-recovery.ts +78 -0
- package/client/browser.tsx +1650 -1579
- package/client/favorite-star.tsx +23 -0
- package/client/frame-lifecycle.ts +62 -59
- package/client/sidebar.tsx +1 -2
- package/client/use-browser-auto-capture-density.test.ts +104 -0
- package/client/use-browser-auto-capture-density.ts +43 -0
- package/client/use-browser-canvas-input.ts +375 -0
- package/client/use-browser-capture-density.ts +87 -0
- package/client/use-browser-emulation-mode.ts +56 -0
- package/client/use-browser-fill-viewport.test.ts +108 -0
- package/client/use-browser-fill-viewport.ts +62 -0
- package/client/use-browser-frame-buffer.ts +66 -0
- package/client/use-browser-image-capture.ts +139 -0
- package/client/use-browser-scoped-mutation.test.ts +118 -0
- package/client/use-browser-scoped-mutation.ts +59 -0
- package/client/use-browser-video.test.ts +1060 -0
- package/client/use-browser-video.ts +481 -0
- package/client/use-browser-viewer-recovery.ts +26 -0
- package/client/use-resolution-favorites.ts +90 -0
- package/client/web.test.ts +125 -0
- package/client/web.ts +405 -0
- package/index.client.tsx +0 -10
- package/index.server.ts +32 -4
- package/npm-shrinkwrap.json +9 -9
- package/package.json +10 -5
- package/paseo-plugin.json +1 -1
- package/scripts/prepare-runtime.mjs +1 -1
- package/server/agent-browser-runtime.ts +1601 -314
- package/server/browser-cursor.test.ts +64 -0
- package/server/browser-cursor.ts +69 -0
- package/server/browser-frame-context.ts +22 -0
- package/server/browser-frame-epoch.test.ts +95 -0
- package/server/browser-frame-rejection.ts +17 -0
- package/server/browser-gesture.ts +120 -0
- package/server/browser-operation-diagnostics.test.ts +79 -0
- package/server/browser-operation-diagnostics.ts +103 -0
- package/server/browser-policy.ts +1316 -151
- package/server/browser-video-input-authority.test.ts +211 -0
- package/server/browser-video-policy.test.ts +564 -0
- package/server/browser-video-socket.test.ts +368 -0
- package/server/browser.ts +151 -16
- package/server/capture-transport-policy.test.ts +105 -0
- package/server/capture-transport-policy.ts +154 -0
- package/server/capture-transport-runtime.test.ts +213 -0
- package/server/daemon-launch-chain.test.ts +70 -0
- package/server/daemon-shutdown.test.ts +289 -0
- package/server/discrete-input-freshness.test.ts +364 -0
- package/server/discrete-input-native-lifetime.test.ts +150 -0
- package/server/display-loss-recovery.test.ts +294 -0
- package/server/emulation-restoration.test.ts +262 -0
- package/server/input-cleanup.ts +23 -0
- package/server/input-generation.test.ts +31 -0
- package/server/input-generation.ts +36 -0
- package/server/jpeg-capture-demand.test.ts +76 -0
- package/server/jpeg-capture-demand.ts +98 -0
- package/server/jpeg-frame.ts +29 -0
- package/server/keyboard-input.test.ts +61 -0
- package/server/keyboard-input.ts +104 -0
- package/server/live-input-policy.test.ts +958 -0
- package/server/live-input-runtime.test.ts +732 -0
- package/server/live-input-supervisor.test.ts +147 -0
- package/server/mcp-entry.ts +44 -2
- package/server/native-video-backpressure.test.ts +228 -0
- package/server/native-video-buffer.test.ts +141 -0
- package/server/native-video-buffer.ts +114 -0
- package/server/native-video-capture.test.ts +599 -0
- package/server/native-video-capture.ts +497 -0
- package/server/native-video-extension-source.test.ts +201 -0
- package/server/native-video-extension-source.ts +364 -0
- package/server/native-video-extension.test.ts +41 -0
- package/server/native-video-extension.ts +83 -0
- package/server/native-video-packet.ts +9 -0
- package/server/native-video-settings.test.ts +245 -0
- package/server/navigation-budget.ts +8 -0
- package/server/navigation-commit.ts +125 -0
- package/server/navigation-policy.test.ts +153 -0
- package/server/process-identity.ts +58 -0
- package/server/reattach-held-input.test.ts +143 -0
- package/server/runtime-frame-reuse.test.ts +295 -0
- package/server/runtime-jpeg-demand.test.ts +200 -0
- package/server/runtime-jpeg-shutdown.test.ts +210 -0
- package/server/runtime-jpeg-source-order.test.ts +201 -0
- package/server/runtime-launch-environment.test.ts +111 -0
- package/server/runtime-navigation.test.ts +327 -0
- package/server/runtime-owner-display.test.ts +152 -0
- package/server/runtime-owner.ts +340 -31
- package/server/runtime-page-state.test.ts +249 -0
- package/server/runtime-protocol.ts +32 -2
- package/server/runtime-target-retention.test.ts +181 -0
- package/server/runtime-video-opt-in.test.ts +82 -0
- package/server/runtime-video.test.ts +444 -0
- package/server/screencast-source-time.ts +46 -0
- package/server/stale-wheel-admission.test.ts +136 -0
- package/server/supervisor-client.ts +28 -4
- package/server/supervisor-heartbeat.test.ts +80 -0
- package/server/supervisor.ts +553 -55
- package/server/tab-close-confirmation.test.ts +97 -0
- package/server/video-source-recovery.test.ts +20 -0
- package/server/video-source-recovery.ts +64 -0
- package/server/viewer-capture-lifetime.ts +51 -0
- package/server/virtual-display.test.ts +184 -0
- package/server/virtual-display.ts +236 -0
- package/shared/browser-display-preferences.ts +46 -0
- package/shared/browser-video.ts +83 -0
- package/shared/browser.ts +286 -65
- package/shared/capture-density.ts +16 -0
- package/shared/capture-settings.ts +17 -0
- package/shared/device-presets.ts +90 -0
- package/shared/resolution-menu.test.ts +120 -0
- package/shared/resolution-menu.ts +72 -0
- package/shared/video-settings.test.ts +38 -0
- package/shared/video-settings.ts +34 -0
- package/shared/viewport-limits.ts +5 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** Exercise the actual page program with bounded DOM fixtures; native geometry is covered by smoke. */
|
|
2
|
+
import { runInNewContext } from "node:vm";
|
|
3
|
+
import { expect, it } from "vitest";
|
|
4
|
+
import { browserCursorExpression } from "./browser-cursor";
|
|
5
|
+
|
|
6
|
+
function fixture({
|
|
7
|
+
cursor = "auto",
|
|
8
|
+
userSelect = "auto",
|
|
9
|
+
writingMode = "horizontal-tb",
|
|
10
|
+
editable = false,
|
|
11
|
+
contains = true,
|
|
12
|
+
legacy = false,
|
|
13
|
+
} = {}) {
|
|
14
|
+
const node = { nodeType: 3, length: 5 };
|
|
15
|
+
const element = { isContentEditable: editable, contains: () => contains };
|
|
16
|
+
const range = {
|
|
17
|
+
setStart() {},
|
|
18
|
+
setEnd() {},
|
|
19
|
+
getClientRects: () => [{ left: 10, right: 40, top: 10, bottom: 25 }],
|
|
20
|
+
};
|
|
21
|
+
const position = { offsetNode: node, offset: 2 };
|
|
22
|
+
const document = {
|
|
23
|
+
elementFromPoint: () => element,
|
|
24
|
+
createRange: () => range,
|
|
25
|
+
...(legacy
|
|
26
|
+
? { caretRangeFromPoint: () => ({ startContainer: node, startOffset: 2 }) }
|
|
27
|
+
: { caretPositionFromPoint: () => position }),
|
|
28
|
+
};
|
|
29
|
+
return {
|
|
30
|
+
document,
|
|
31
|
+
sample: (x = 20, y = 15) =>
|
|
32
|
+
runInNewContext(browserCursorExpression(x, y), {
|
|
33
|
+
document,
|
|
34
|
+
getComputedStyle: () => ({ cursor, userSelect, writingMode }),
|
|
35
|
+
}),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
it("resolves auto over selectable text, including the legacy caret API", () => {
|
|
40
|
+
expect(fixture().sample()).toBe("text");
|
|
41
|
+
expect(fixture({ legacy: true }).sample()).toBe("text");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("preserves explicit cursor choices even over selectable text", () => {
|
|
45
|
+
expect(fixture({ cursor: "default" }).sample()).toBe("default");
|
|
46
|
+
expect(fixture({ cursor: "pointer" }).sample()).toBe("pointer");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("does not mistake nearby text, overlays or non-selectable content for text", () => {
|
|
50
|
+
expect(fixture().sample(90, 15)).toBe("default");
|
|
51
|
+
expect(fixture({ contains: false }).sample()).toBe("default");
|
|
52
|
+
expect(fixture({ userSelect: "none" }).sample()).toBe("default");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("resolves empty editing surfaces and vertical text without moving selection", () => {
|
|
56
|
+
expect(fixture({ editable: true }).sample(90, 15)).toBe("text");
|
|
57
|
+
expect(fixture({ writingMode: "vertical-rl" }).sample()).toBe("vertical-text");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("rejects invalid coordinates before constructing page code", () => {
|
|
61
|
+
for (const x of [Number.NaN, Number.POSITIVE_INFINITY, -1]) {
|
|
62
|
+
expect(() => browserCursorExpression(x, 10)).toThrow("finite non-negative");
|
|
63
|
+
}
|
|
64
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only cursor resolution for the guarded native page in agent-browser-runtime.
|
|
3
|
+
* CSS keeps `auto` as its computed value; resolve selectable glyphs/editing surfaces
|
|
4
|
+
* without exporting page text, changing selection, or inspecting a whole subtree.
|
|
5
|
+
* The page program handles the top document and exposed shadow roots. Opaque
|
|
6
|
+
* frames remain subject to the existing top-document hit-test limitation.
|
|
7
|
+
*/
|
|
8
|
+
const CURSOR_HIT_TEST = `(x, y) => {
|
|
9
|
+
let element = document.elementFromPoint(x, y);
|
|
10
|
+
if (!element) return null;
|
|
11
|
+
|
|
12
|
+
const shadowRoots = [];
|
|
13
|
+
// Bound descent and retain roots for Chromium's caret hit-test options.
|
|
14
|
+
for (let depth = 0; depth < 16 && element.shadowRoot; depth++) {
|
|
15
|
+
const root = element.shadowRoot;
|
|
16
|
+
const inner = root.elementFromPoint(x, y);
|
|
17
|
+
if (!inner || inner === element) break;
|
|
18
|
+
shadowRoots.push(root);
|
|
19
|
+
element = inner;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const style = getComputedStyle(element);
|
|
23
|
+
if (style.cursor !== 'auto') return style.cursor;
|
|
24
|
+
if (style.userSelect === 'none') return 'default';
|
|
25
|
+
|
|
26
|
+
const textCursor = style.writingMode.startsWith('vertical') ||
|
|
27
|
+
style.writingMode.startsWith('sideways') ? 'vertical-text' : 'text';
|
|
28
|
+
if (element.isContentEditable) return textCursor;
|
|
29
|
+
|
|
30
|
+
// Explicit auto on text controls must also work over empty editable space.
|
|
31
|
+
const textInputTypes = ['text', 'search', 'url', 'tel', 'email', 'password', 'number'];
|
|
32
|
+
const textControl = element.tagName === 'TEXTAREA' ||
|
|
33
|
+
(element.tagName === 'INPUT' && textInputTypes.includes(element.type));
|
|
34
|
+
if (textControl && !element.disabled) return textCursor;
|
|
35
|
+
|
|
36
|
+
let node;
|
|
37
|
+
let offset;
|
|
38
|
+
if (document.caretPositionFromPoint) {
|
|
39
|
+
const position = document.caretPositionFromPoint(x, y, { shadowRoots });
|
|
40
|
+
node = position?.offsetNode;
|
|
41
|
+
offset = position?.offset;
|
|
42
|
+
} else if (document.caretRangeFromPoint) {
|
|
43
|
+
const position = document.caretRangeFromPoint(x, y);
|
|
44
|
+
node = position?.startContainer;
|
|
45
|
+
offset = position?.startOffset;
|
|
46
|
+
}
|
|
47
|
+
if (!node || node.nodeType !== 3 || !element.contains(node) ||
|
|
48
|
+
!Number.isInteger(offset) || node.length === 0) return 'default';
|
|
49
|
+
|
|
50
|
+
// Caret APIs can snap to nearby text even over padding. Check only adjacent
|
|
51
|
+
// glyph rectangles so blank areas and overlays never inherit a text cursor.
|
|
52
|
+
const range = document.createRange();
|
|
53
|
+
range.setStart(node, Math.max(0, offset - 1));
|
|
54
|
+
range.setEnd(node, Math.min(node.length, offset + 1));
|
|
55
|
+
for (const rect of range.getClientRects()) {
|
|
56
|
+
if (x >= rect.left && x < rect.right && y >= rect.top && y < rect.bottom) {
|
|
57
|
+
return textCursor;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return 'default';
|
|
61
|
+
}`;
|
|
62
|
+
|
|
63
|
+
/** Build the fixed page program with numeric CSS coordinates, never executable caller input. */
|
|
64
|
+
export function browserCursorExpression(x: number, y: number): string {
|
|
65
|
+
if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || y < 0) {
|
|
66
|
+
throw new Error("Cursor coordinates must be finite non-negative CSS pixels");
|
|
67
|
+
}
|
|
68
|
+
return `(${CURSOR_HIT_TEST})(${x}, ${y})`;
|
|
69
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Matches issued pixels to their exact browser attachment and display generations. */
|
|
2
|
+
import type { BrowserFrame, BrowserState } from "../shared/browser";
|
|
3
|
+
|
|
4
|
+
/** Pixel equality alone cannot authorize a different document, runtime or viewport. */
|
|
5
|
+
export function frameMatchesBrowserContext(
|
|
6
|
+
frame: Pick<
|
|
7
|
+
BrowserFrame,
|
|
8
|
+
"sessionId" | "runtimeId" | "captureEpoch" | "navigationGeneration" | "viewportGeneration"
|
|
9
|
+
>,
|
|
10
|
+
state: Pick<
|
|
11
|
+
BrowserState,
|
|
12
|
+
"sessionId" | "runtimeId" | "bridgeEpoch" | "navigationGeneration" | "viewportGeneration"
|
|
13
|
+
>,
|
|
14
|
+
): boolean {
|
|
15
|
+
return (
|
|
16
|
+
frame.sessionId === state.sessionId &&
|
|
17
|
+
frame.runtimeId === state.runtimeId &&
|
|
18
|
+
frame.captureEpoch === state.bridgeEpoch &&
|
|
19
|
+
frame.navigationGeneration === state.navigationGeneration &&
|
|
20
|
+
frame.viewportGeneration === state.viewportGeneration
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { expect, it } from "vitest";
|
|
2
|
+
import type { BrowserState } from "../shared/browser";
|
|
3
|
+
import { SessionManager } from "./browser-policy";
|
|
4
|
+
|
|
5
|
+
/** Snapshot the current attachment, independently of a previously painted token. */
|
|
6
|
+
function expected(state: BrowserState) {
|
|
7
|
+
return {
|
|
8
|
+
sessionId: state.sessionId,
|
|
9
|
+
runtimeId: state.runtimeId!,
|
|
10
|
+
bridgeEpoch: state.bridgeEpoch!,
|
|
11
|
+
navigationGeneration: state.navigationGeneration,
|
|
12
|
+
viewportGeneration: state.viewportGeneration,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
it("revokes old bridge pixel tokens and admits a new capture without publishing stale input", async () => {
|
|
17
|
+
let ids = 0;
|
|
18
|
+
const calls: string[] = [];
|
|
19
|
+
const manager = new SessionManager({
|
|
20
|
+
validateWorkspace: async () => true,
|
|
21
|
+
issueToken: () => String(++ids).padStart(32, "0"),
|
|
22
|
+
client: {
|
|
23
|
+
connect: async () => ({ epoch: 1 }),
|
|
24
|
+
ensureWorkspace: async (workspaceId) => ({
|
|
25
|
+
workspaceId,
|
|
26
|
+
runtimeId: "r".repeat(32),
|
|
27
|
+
createdAt: 1,
|
|
28
|
+
}),
|
|
29
|
+
archiveWorkspace: async () => {},
|
|
30
|
+
closeWorkspace: async () => {},
|
|
31
|
+
disconnect: () => {},
|
|
32
|
+
requestWorkspace: async (_workspace, operation) => {
|
|
33
|
+
calls.push(operation);
|
|
34
|
+
if (operation === "identity") return { userAgent: "Fixture" };
|
|
35
|
+
if (operation === "state")
|
|
36
|
+
return {
|
|
37
|
+
url: "https://fixture.invalid/",
|
|
38
|
+
title: "Fixture",
|
|
39
|
+
inputGeneration: "1:1",
|
|
40
|
+
canGoBack: false,
|
|
41
|
+
canGoForward: false,
|
|
42
|
+
};
|
|
43
|
+
if (operation === "frame")
|
|
44
|
+
return {
|
|
45
|
+
dataBase64: "AA==",
|
|
46
|
+
byteLength: 1,
|
|
47
|
+
width: 1280,
|
|
48
|
+
height: 800,
|
|
49
|
+
capturedAt: new Date().toISOString(),
|
|
50
|
+
};
|
|
51
|
+
return null;
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
try {
|
|
56
|
+
await manager.connect();
|
|
57
|
+
const { viewerToken } = await manager.attach("owned", "Fixture");
|
|
58
|
+
const { controlToken } = await manager.acquireControl(viewerToken);
|
|
59
|
+
const captured = await manager.capture(viewerToken);
|
|
60
|
+
manager.setBridgeEpoch(2);
|
|
61
|
+
const { state } = await manager.status(viewerToken);
|
|
62
|
+
const old = captured.frame!;
|
|
63
|
+
const refused = await manager.beginGesture({
|
|
64
|
+
viewerToken,
|
|
65
|
+
controlToken,
|
|
66
|
+
pointerKind: "mouse",
|
|
67
|
+
expected: expected(state),
|
|
68
|
+
target: {
|
|
69
|
+
frameId: old.frameId,
|
|
70
|
+
navigationGeneration: old.navigationGeneration,
|
|
71
|
+
viewportGeneration: old.viewportGeneration,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
expect(refused).toMatchObject({ admission: "stale-frame" });
|
|
75
|
+
expect(calls).not.toContain("input.begin");
|
|
76
|
+
|
|
77
|
+
const fresh = await manager.capture(viewerToken);
|
|
78
|
+
expect(fresh.frame!.captureEpoch).toBe(2);
|
|
79
|
+
const admitted = await manager.beginGesture({
|
|
80
|
+
viewerToken,
|
|
81
|
+
controlToken,
|
|
82
|
+
pointerKind: "mouse",
|
|
83
|
+
expected: expected(fresh.state),
|
|
84
|
+
target: {
|
|
85
|
+
frameId: fresh.frame!.frameId,
|
|
86
|
+
navigationGeneration: fresh.frame!.navigationGeneration,
|
|
87
|
+
viewportGeneration: fresh.frame!.viewportGeneration,
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
expect(admitted).toHaveProperty("gestureId");
|
|
91
|
+
expect(calls).toContain("input.begin");
|
|
92
|
+
} finally {
|
|
93
|
+
manager.disconnect();
|
|
94
|
+
}
|
|
95
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explains stale input observations through ordinary RPC errors. Only fixed
|
|
3
|
+
* stage/reason labels and bounded elapsed age leave the policy boundary; frame
|
|
4
|
+
* IDs, page details and control authority stay private. Diagnostics grant no
|
|
5
|
+
* retry or non-publication guarantee for an already admitted input.
|
|
6
|
+
*/
|
|
7
|
+
export function createBrowserFrameRejection(
|
|
8
|
+
stage: "admission" | "admitted-input",
|
|
9
|
+
reason: "expired" | "missing-or-revoked" | "context-changed",
|
|
10
|
+
ageMs?: number,
|
|
11
|
+
): Error {
|
|
12
|
+
const details = [`stage=${stage}`, `reason=${reason}`];
|
|
13
|
+
if (ageMs !== undefined && Number.isFinite(ageMs)) {
|
|
14
|
+
details.push(`ageMs=${Math.min(60_000, Math.max(0, Math.floor(ageMs)))}`);
|
|
15
|
+
}
|
|
16
|
+
return new Error(`Browser frame is stale (${details.join("; ")})`);
|
|
17
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One ordered live input channel, anchored to a decoded frame by browser-policy.
|
|
3
|
+
* Owns only bounded gesture state/coordinates. The host supplies serialized
|
|
4
|
+
* authority checks and transport cleanup; this module never grants a lease.
|
|
5
|
+
*/
|
|
6
|
+
import type { BrowserGestureEvent, Viewport } from "../shared/browser";
|
|
7
|
+
import { MAX_HELD_BROWSER_KEYS, mapDisplayedPoint } from "../shared/browser";
|
|
8
|
+
|
|
9
|
+
export const GESTURE_IDLE_MS = 5_000;
|
|
10
|
+
export const GESTURE_LIFETIME_MS = 5 * 60_000;
|
|
11
|
+
|
|
12
|
+
export class BrowserGesture {
|
|
13
|
+
nextSequence = 1;
|
|
14
|
+
idleUntil: number;
|
|
15
|
+
readonly expiresAt: number;
|
|
16
|
+
readonly buttons = new Set<string>();
|
|
17
|
+
readonly touches = new Set<number>();
|
|
18
|
+
readonly keys = new Set<string>();
|
|
19
|
+
private display: { width: number; height: number } | null = null;
|
|
20
|
+
timer: ReturnType<typeof setTimeout> | null = null;
|
|
21
|
+
lastPoint: { x: number; y: number } | null = null;
|
|
22
|
+
|
|
23
|
+
constructor(
|
|
24
|
+
readonly id: string,
|
|
25
|
+
readonly viewerToken: string,
|
|
26
|
+
readonly controlToken: string,
|
|
27
|
+
readonly pointerKind: "mouse" | "touch",
|
|
28
|
+
readonly expected: {
|
|
29
|
+
sessionId: string;
|
|
30
|
+
runtimeId: string;
|
|
31
|
+
bridgeEpoch: number;
|
|
32
|
+
navigationGeneration: number;
|
|
33
|
+
viewportGeneration: number;
|
|
34
|
+
},
|
|
35
|
+
readonly viewport: Viewport,
|
|
36
|
+
now: number,
|
|
37
|
+
readonly runtimeInputGeneration: string | null = null,
|
|
38
|
+
) {
|
|
39
|
+
this.idleUntil = now + GESTURE_IDLE_MS;
|
|
40
|
+
this.expiresAt = now + GESTURE_LIFETIME_MS;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Reject dropped/replayed packets and expire even if a delayed queue resumes later. */
|
|
44
|
+
assertSequence(sequence: number, now: number): void {
|
|
45
|
+
if (sequence !== this.nextSequence) throw new Error("Browser gesture sequence is stale");
|
|
46
|
+
if (now >= this.idleUntil || now >= this.expiresAt) throw new Error("Browser gesture expired");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Pin displayed geometry on first point; a mid-gesture local layout change must cancel. */
|
|
50
|
+
mapPoint(point: { x: number; y: number; width: number; height: number }) {
|
|
51
|
+
if (!this.display) this.display = { width: point.width, height: point.height };
|
|
52
|
+
if (this.display.width !== point.width || this.display.height !== point.height) {
|
|
53
|
+
throw new Error("Browser gesture display geometry changed");
|
|
54
|
+
}
|
|
55
|
+
const mapped = mapDisplayedPoint(point, this.viewport);
|
|
56
|
+
this.lastPoint = mapped;
|
|
57
|
+
return mapped;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Validate edges before publication. Continuation never invents a missing button/touch start. */
|
|
61
|
+
validate(event: BrowserGestureEvent): void {
|
|
62
|
+
if (event.kind === "leave" && this.buttons.size > 0)
|
|
63
|
+
throw new Error("Release held mouse buttons before leaving");
|
|
64
|
+
if (event.kind === "key") {
|
|
65
|
+
if (event.type === "down" && this.keys.has(event.code) !== event.repeat) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
event.repeat ? "Key repeat has no matching press" : "Key is already pressed",
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (event.type === "up" && !this.keys.has(event.code)) {
|
|
71
|
+
throw new Error("Key release has no matching press");
|
|
72
|
+
}
|
|
73
|
+
if (
|
|
74
|
+
event.type === "down" &&
|
|
75
|
+
!this.keys.has(event.code) &&
|
|
76
|
+
this.keys.size >= MAX_HELD_BROWSER_KEYS
|
|
77
|
+
) {
|
|
78
|
+
throw new Error("Too many simultaneously held browser keys");
|
|
79
|
+
}
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (event.kind === "text") return;
|
|
83
|
+
if ((event.kind === "touch") !== (this.pointerKind === "touch"))
|
|
84
|
+
throw new Error("Browser gesture pointer kind changed");
|
|
85
|
+
if (event.kind === "down" && this.buttons.has(event.button))
|
|
86
|
+
throw new Error("Mouse button is already pressed");
|
|
87
|
+
if (event.kind === "up" && !this.buttons.has(event.button))
|
|
88
|
+
throw new Error("Mouse button is not pressed");
|
|
89
|
+
if (event.kind === "touch") {
|
|
90
|
+
if (
|
|
91
|
+
event.type === "move" &&
|
|
92
|
+
(this.touches.size === 0 || event.points.some((point) => !this.touches.has(point.id)))
|
|
93
|
+
) {
|
|
94
|
+
throw new Error("Touch move has no matching start");
|
|
95
|
+
}
|
|
96
|
+
if (event.type === "start" && event.points.every((point) => this.touches.has(point.id))) {
|
|
97
|
+
throw new Error("Touch start must add a touch identifier");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Commit the acknowledged edge, preserving explicitly released touch IDs. */
|
|
103
|
+
acknowledge(event: BrowserGestureEvent, now: number): void {
|
|
104
|
+
if (event.kind === "key" && event.type === "down") this.keys.add(event.code);
|
|
105
|
+
if (event.kind === "key" && event.type === "up") this.keys.delete(event.code);
|
|
106
|
+
if (event.kind === "down") this.buttons.add(event.button);
|
|
107
|
+
if (event.kind === "up") this.buttons.delete(event.button);
|
|
108
|
+
if (event.kind === "touch") {
|
|
109
|
+
this.touches.clear();
|
|
110
|
+
for (const point of event.points) this.touches.add(point.id);
|
|
111
|
+
}
|
|
112
|
+
this.nextSequence += 1;
|
|
113
|
+
this.idleUntil = now + GESTURE_IDLE_MS;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Only an unpressed mouse move leaves existing bounded frame authority usable for a new press. */
|
|
117
|
+
isHover(event: BrowserGestureEvent): boolean {
|
|
118
|
+
return (event.kind === "move" || event.kind === "leave") && this.buttons.size === 0;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
type BrowserFailureDiagnostic,
|
|
4
|
+
classifyBrowserFailure,
|
|
5
|
+
createBrowserOperationDiagnostics,
|
|
6
|
+
} from "./browser-operation-diagnostics";
|
|
7
|
+
|
|
8
|
+
describe("browser operation diagnostics", () => {
|
|
9
|
+
it("does not copy an arbitrary method-shaped secret into logs", () => {
|
|
10
|
+
expect(classifyBrowserFailure(new Error("Page.SecretPassword timed out"))).toEqual({
|
|
11
|
+
category: "timeout",
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
it("classifies protocol codes independently of their user-facing message", () => {
|
|
15
|
+
expect(
|
|
16
|
+
classifyBrowserFailure(
|
|
17
|
+
Object.assign(new Error("Shared Browser supervisor is busy; retry the request"), {
|
|
18
|
+
code: "RUNTIME_BUSY",
|
|
19
|
+
}),
|
|
20
|
+
),
|
|
21
|
+
).toEqual({ category: "capacity" });
|
|
22
|
+
expect(
|
|
23
|
+
classifyBrowserFailure(
|
|
24
|
+
Object.assign(new Error("Obsolete connection"), { code: "BRIDGE_FENCED" }),
|
|
25
|
+
),
|
|
26
|
+
).toEqual({ category: "expired-authority" });
|
|
27
|
+
});
|
|
28
|
+
it("retains the failed CDP method without recording a URL, token or input", () => {
|
|
29
|
+
const error = new Error(
|
|
30
|
+
"Workspace frame failed: Page.getLayoutMetrics timed out after 15000ms https://private.invalid/?token=secret typed password",
|
|
31
|
+
);
|
|
32
|
+
expect(classifyBrowserFailure(error)).toEqual({
|
|
33
|
+
category: "timeout",
|
|
34
|
+
cdpMethod: "Page.getLayoutMetrics",
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("rate limits independently, preserves errors and admits a later report", async () => {
|
|
39
|
+
let now = 0;
|
|
40
|
+
const reports: BrowserFailureDiagnostic[] = [];
|
|
41
|
+
const diagnose = createBrowserOperationDiagnostics({
|
|
42
|
+
now: () => now,
|
|
43
|
+
report: (value) => reports.push(value),
|
|
44
|
+
});
|
|
45
|
+
const error = new Error("Viewer token is invalid or expired: secret");
|
|
46
|
+
const fail = async () => {
|
|
47
|
+
now += 10;
|
|
48
|
+
throw error;
|
|
49
|
+
};
|
|
50
|
+
await expect(diagnose("capture", fail)).rejects.toBe(error);
|
|
51
|
+
await expect(diagnose("capture", fail)).rejects.toBe(error);
|
|
52
|
+
await expect(diagnose("video.read", fail)).rejects.toBe(error);
|
|
53
|
+
expect(reports).toHaveLength(2);
|
|
54
|
+
expect(reports[0]).toEqual({
|
|
55
|
+
operation: "capture",
|
|
56
|
+
category: "expired-authority",
|
|
57
|
+
durationMs: 10,
|
|
58
|
+
});
|
|
59
|
+
now += 30_000;
|
|
60
|
+
await expect(diagnose("capture", fail)).rejects.toBe(error);
|
|
61
|
+
expect(reports).toHaveLength(3);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("does not alter successful results or let a failing logger replace errors", async () => {
|
|
65
|
+
const diagnose = createBrowserOperationDiagnostics({
|
|
66
|
+
report() {
|
|
67
|
+
throw new Error("logger failed");
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
const value = {};
|
|
71
|
+
expect(await diagnose("capture", async () => value)).toBe(value);
|
|
72
|
+
const error = new Error("private unrelated message");
|
|
73
|
+
await expect(
|
|
74
|
+
diagnose("capture", async () => {
|
|
75
|
+
throw error;
|
|
76
|
+
}),
|
|
77
|
+
).rejects.toBe(error);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Records bounded, redacted RPC failures in the plugin process so Paseo retains
|
|
3
|
+
* them. Never log URLs, input text, credentials, tokens or raw remote messages.
|
|
4
|
+
* Repeated failures in one operation/category emit at most once each 30 seconds.
|
|
5
|
+
*/
|
|
6
|
+
const REPORT_INTERVAL_MS = 30_000;
|
|
7
|
+
const MAX_DIAGNOSTIC_KEYS = 64;
|
|
8
|
+
const SAFE_CDP_TIMEOUT_METHODS = new Set([
|
|
9
|
+
"Page.enable",
|
|
10
|
+
"Page.getLayoutMetrics",
|
|
11
|
+
"Page.captureScreenshot",
|
|
12
|
+
"Page.startScreencast",
|
|
13
|
+
"Page.stopScreencast",
|
|
14
|
+
"Runtime.enable",
|
|
15
|
+
"Runtime.evaluate",
|
|
16
|
+
"Runtime.addBinding",
|
|
17
|
+
"Target.createTarget",
|
|
18
|
+
"Target.attachToTarget",
|
|
19
|
+
"Target.getTargets",
|
|
20
|
+
"Target.closeTarget",
|
|
21
|
+
"Performance.enable",
|
|
22
|
+
"Performance.getMetrics",
|
|
23
|
+
"Emulation.setDeviceMetricsOverride",
|
|
24
|
+
"Emulation.setTouchEmulationEnabled",
|
|
25
|
+
"Emulation.setUserAgentOverride",
|
|
26
|
+
"Input.dispatchMouseEvent",
|
|
27
|
+
"Input.dispatchKeyEvent",
|
|
28
|
+
"Input.dispatchTouchEvent",
|
|
29
|
+
"Input.insertText",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
export interface BrowserFailureDiagnostic {
|
|
33
|
+
operation: string;
|
|
34
|
+
category: "timeout" | "stale-frame" | "expired-authority" | "capacity" | "connection" | "other";
|
|
35
|
+
cdpMethod?: string;
|
|
36
|
+
durationMs: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Return only fixed categories and a fixed allowlisted CDP method from an error. */
|
|
40
|
+
export function classifyBrowserFailure(
|
|
41
|
+
error: unknown,
|
|
42
|
+
): Omit<BrowserFailureDiagnostic, "operation" | "durationMs"> {
|
|
43
|
+
const message = error instanceof Error ? error.message : "";
|
|
44
|
+
const code = error instanceof Error && "code" in error ? error.code : undefined;
|
|
45
|
+
if (code === "RUNTIME_BUSY") return { category: "capacity" };
|
|
46
|
+
if (code === "BRIDGE_FENCED") return { category: "expired-authority" };
|
|
47
|
+
const candidateMethod =
|
|
48
|
+
/\b((?:Page|Runtime|Target|Input|Emulation|Performance|Browser)\.[A-Za-z]{1,64}) timed out\b/.exec(
|
|
49
|
+
message,
|
|
50
|
+
)?.[1];
|
|
51
|
+
const method =
|
|
52
|
+
candidateMethod && SAFE_CDP_TIMEOUT_METHODS.has(candidateMethod) ? candidateMethod : undefined;
|
|
53
|
+
if (/timed out|timeout/i.test(message))
|
|
54
|
+
return { category: "timeout", ...(method ? { cdpMethod: method } : {}) };
|
|
55
|
+
if (/frame is stale/i.test(message)) return { category: "stale-frame" };
|
|
56
|
+
if (/token is invalid or expired|lease is invalid or expired|BRIDGE_FENCED/.test(message))
|
|
57
|
+
return { category: "expired-authority" };
|
|
58
|
+
if (/RUNTIME_BUSY|capacity|too many/i.test(message)) return { category: "capacity" };
|
|
59
|
+
if (/connection closed|unavailable|disconnected/i.test(message))
|
|
60
|
+
return { category: "connection" };
|
|
61
|
+
return { category: "other" };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Wrap one operation, preserving its original error and success result exactly. */
|
|
65
|
+
export function createBrowserOperationDiagnostics(
|
|
66
|
+
options: { now?: () => number; report?: (diagnostic: BrowserFailureDiagnostic) => void } = {},
|
|
67
|
+
) {
|
|
68
|
+
const now = options.now ?? Date.now;
|
|
69
|
+
const report =
|
|
70
|
+
options.report ??
|
|
71
|
+
((diagnostic) => console.warn("[shared-browser] operation failed", diagnostic));
|
|
72
|
+
const lastReported = new Map<string, number>();
|
|
73
|
+
return async function diagnose<T>(operation: string, perform: () => Promise<T>): Promise<T> {
|
|
74
|
+
const startedAt = now();
|
|
75
|
+
try {
|
|
76
|
+
return await perform();
|
|
77
|
+
} catch (error) {
|
|
78
|
+
const details = classifyBrowserFailure(error);
|
|
79
|
+
// Operations come from internal method names, not client-provided strings.
|
|
80
|
+
const safeOperation = /^[a-z][a-z.-]{0,63}$/.test(operation) ? operation : "unknown";
|
|
81
|
+
const key = `${safeOperation}:${details.category}`;
|
|
82
|
+
const previous = lastReported.get(key);
|
|
83
|
+
const observedAt = now();
|
|
84
|
+
if (previous === undefined || observedAt - previous >= REPORT_INTERVAL_MS) {
|
|
85
|
+
if (lastReported.size >= MAX_DIAGNOSTIC_KEYS) {
|
|
86
|
+
const oldest = lastReported.keys().next().value;
|
|
87
|
+
if (oldest !== undefined) lastReported.delete(oldest);
|
|
88
|
+
}
|
|
89
|
+
lastReported.set(key, observedAt);
|
|
90
|
+
try {
|
|
91
|
+
report({
|
|
92
|
+
operation: safeOperation,
|
|
93
|
+
...details,
|
|
94
|
+
durationMs: Math.max(0, observedAt - startedAt),
|
|
95
|
+
});
|
|
96
|
+
} catch {
|
|
97
|
+
// Observability must never replace the original operation failure.
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
}
|