@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
package/server/supervisor.ts
CHANGED
|
@@ -1,9 +1,26 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
1
2
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
3
|
import { chmod, mkdir, open, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
4
|
import { createServer, type Socket } from "node:net";
|
|
4
5
|
import { homedir, tmpdir } from "node:os";
|
|
5
6
|
import { dirname, join } from "node:path";
|
|
6
|
-
import type {
|
|
7
|
+
import type { ZodType } from "zod";
|
|
8
|
+
import type { BrowserFrame, BrowserState } from "../shared/browser";
|
|
9
|
+
import {
|
|
10
|
+
beginBrowserGestureRpc,
|
|
11
|
+
captureBrowserRpc,
|
|
12
|
+
closeBrowserRpc,
|
|
13
|
+
endBrowserGestureRpc,
|
|
14
|
+
MAX_BROWSER_TABS,
|
|
15
|
+
navigateBrowserRpc,
|
|
16
|
+
resizeBrowserRpc,
|
|
17
|
+
sendBrowserInputRpc,
|
|
18
|
+
setCaptureDensityRpc,
|
|
19
|
+
updateBrowserGestureRpc,
|
|
20
|
+
} from "../shared/browser";
|
|
21
|
+
import { readBrowserVideoRpc } from "../shared/browser-video";
|
|
22
|
+
import { DEFAULT_CAPTURE_QUALITY } from "../shared/capture-settings";
|
|
23
|
+
import { MAX_AGENT_VIEWPORT } from "../shared/viewport-limits";
|
|
7
24
|
import { SessionManager } from "./browser-policy";
|
|
8
25
|
import { CdpUnknownOutcomeError } from "./cdp";
|
|
9
26
|
import {
|
|
@@ -15,6 +32,7 @@ import {
|
|
|
15
32
|
parseRuntimeRequest,
|
|
16
33
|
RUNTIME_PROTOCOL_VERSION,
|
|
17
34
|
type RuntimeDescriptor,
|
|
35
|
+
RuntimeLostError,
|
|
18
36
|
RuntimeProtocolError,
|
|
19
37
|
type RuntimeRequest,
|
|
20
38
|
type RuntimeResponse,
|
|
@@ -28,6 +46,13 @@ const MAX_MESSAGE_BYTES = 1024 * 1024;
|
|
|
28
46
|
const MAX_SUPERVISOR_WORKSPACES = 8;
|
|
29
47
|
const MAX_SOCKET_IN_FLIGHT = 16;
|
|
30
48
|
const MAX_GLOBAL_IN_FLIGHT = 64;
|
|
49
|
+
// Optional pixels must leave bounded headroom for ordered input and cleanup.
|
|
50
|
+
// JPEG fallback shares the media budget instead of filling those same slots.
|
|
51
|
+
const MAX_SOCKET_MEDIA_IN_FLIGHT = 12;
|
|
52
|
+
const MAX_GLOBAL_MEDIA_IN_FLIGHT = 48;
|
|
53
|
+
// The one active bridge needs maintenance capacity even when normal reads fill
|
|
54
|
+
// both limits. Duplicate maintenance remains bounded independently of work.
|
|
55
|
+
const MAX_HEARTBEATS_IN_FLIGHT = 1;
|
|
31
56
|
const AGENT_TICKET_TTL_MS = 10 * 60_000;
|
|
32
57
|
const MAX_UNBOUND_AGENT_TICKETS = 256;
|
|
33
58
|
type SupervisorTimer = NodeJS.Timeout;
|
|
@@ -55,6 +80,7 @@ export interface RuntimeSupervisorOptions<Runtime extends RuntimeInstance> {
|
|
|
55
80
|
|
|
56
81
|
interface WorkspaceEntry<Runtime extends RuntimeInstance> {
|
|
57
82
|
createdAt: number;
|
|
83
|
+
lost?: boolean;
|
|
58
84
|
runtime: Runtime;
|
|
59
85
|
}
|
|
60
86
|
|
|
@@ -70,12 +96,23 @@ interface AgentBinding {
|
|
|
70
96
|
ticket: string;
|
|
71
97
|
agentId: string | null;
|
|
72
98
|
workspaceId: string | null;
|
|
99
|
+
tabId: string | null;
|
|
100
|
+
selectionRevision: number;
|
|
73
101
|
viewerToken: string | null;
|
|
74
102
|
controlToken: string | null;
|
|
75
103
|
lastState: BrowserState | null;
|
|
76
104
|
lastFrame: BrowserFrame | null;
|
|
77
105
|
}
|
|
78
106
|
|
|
107
|
+
/** Pair cleanup with a down acknowledged in this exact request/runtime. */
|
|
108
|
+
interface AgentPublicationContext {
|
|
109
|
+
binding: AgentBinding;
|
|
110
|
+
selectionRevision: number;
|
|
111
|
+
keys: Map<string, RuntimeInstance>;
|
|
112
|
+
buttons: Map<string, RuntimeInstance>;
|
|
113
|
+
channels: Map<string, RuntimeInstance>;
|
|
114
|
+
}
|
|
115
|
+
|
|
79
116
|
export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance> {
|
|
80
117
|
private readonly owner: RuntimeOwner<Runtime>;
|
|
81
118
|
private readonly now: () => number;
|
|
@@ -90,6 +127,7 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
|
|
|
90
127
|
private readonly workspaceOperations = new Map<string, Promise<void>>();
|
|
91
128
|
private readonly archived = new Set<string>();
|
|
92
129
|
private readonly browserPolicy: SessionManager;
|
|
130
|
+
private readonly agentRequestContext = new AsyncLocalStorage<AgentPublicationContext>();
|
|
93
131
|
private readonly agentBindings = new Map<string, AgentBinding>();
|
|
94
132
|
private readonly agentTickets = new Map<string, Set<string>>();
|
|
95
133
|
private activeBridge: ActiveBridge | null = null;
|
|
@@ -113,14 +151,19 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
|
|
|
113
151
|
validateWorkspace: async (workspaceId) => !this.archived.has(workspaceId),
|
|
114
152
|
client: {
|
|
115
153
|
connect: async () => ({ epoch: this.nextEpoch }),
|
|
116
|
-
ensureWorkspace: (workspaceId) =>
|
|
154
|
+
ensureWorkspace: (workspaceId, options) =>
|
|
155
|
+
this.ensureWorkspaceLocal(workspaceId, options?.replaceLost === true),
|
|
117
156
|
requestWorkspace: (workspaceId, operation, input) =>
|
|
118
157
|
this.requestWorkspaceLocal(workspaceId, operation, input),
|
|
119
158
|
archiveWorkspace: (workspaceId) => this.archiveWorkspaceLocal(workspaceId).then(() => {}),
|
|
159
|
+
closeWorkspace: (workspaceId, runtimeId) =>
|
|
160
|
+
this.closeWorkspaceLocal(workspaceId, runtimeId),
|
|
120
161
|
disconnect: () => {},
|
|
121
162
|
},
|
|
122
163
|
now: this.now,
|
|
123
|
-
|
|
164
|
+
// Each workspace owns one Chromium runtime but may have several
|
|
165
|
+
// independent page sessions with separate viewers and control leases.
|
|
166
|
+
maxSessions: this.maxWorkspaces * MAX_BROWSER_TABS,
|
|
124
167
|
});
|
|
125
168
|
}
|
|
126
169
|
|
|
@@ -153,6 +196,17 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
|
|
|
153
196
|
return this.bridgeLease(bridge);
|
|
154
197
|
}
|
|
155
198
|
|
|
199
|
+
/** Read-only admission for the reserved lane; dispatch still verifies the lease again. */
|
|
200
|
+
isCurrentBridgeLease(bridgeId: string, epoch: number): boolean {
|
|
201
|
+
const bridge = this.activeBridge;
|
|
202
|
+
return Boolean(
|
|
203
|
+
bridge &&
|
|
204
|
+
bridge.bridgeId === bridgeId &&
|
|
205
|
+
bridge.epoch === epoch &&
|
|
206
|
+
bridge.expiresAt > this.now(),
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
156
210
|
bridgeDisconnected(bridgeId: string, epoch: number): void {
|
|
157
211
|
if (this.activeBridge?.bridgeId !== bridgeId || this.activeBridge.epoch !== epoch) return;
|
|
158
212
|
this.activeBridge = null;
|
|
@@ -201,10 +255,41 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
|
|
|
201
255
|
return result;
|
|
202
256
|
}
|
|
203
257
|
|
|
204
|
-
|
|
258
|
+
async closeWorkspace(
|
|
259
|
+
bridgeId: string,
|
|
260
|
+
epoch: number,
|
|
261
|
+
workspaceId: string,
|
|
262
|
+
runtimeId: string,
|
|
263
|
+
): Promise<void> {
|
|
264
|
+
const bridge = this.assertActiveBridge(bridgeId, epoch);
|
|
265
|
+
await bridge.ready;
|
|
266
|
+
this.assertActiveBridge(bridgeId, epoch);
|
|
267
|
+
await this.closeWorkspaceLocal(workspaceId, runtimeId);
|
|
268
|
+
this.assertActiveBridge(bridgeId, epoch);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private ensureWorkspaceLocal(
|
|
272
|
+
workspaceId: string,
|
|
273
|
+
replaceLost = false,
|
|
274
|
+
): Promise<RuntimeDescriptor> {
|
|
205
275
|
return this.runWorkspaceOperation(workspaceId, async () => {
|
|
206
276
|
if (this.archived.has(workspaceId)) this.workspaceArchived(workspaceId);
|
|
207
277
|
let entry = this.workspaces.get(workspaceId);
|
|
278
|
+
if (entry?.lost) {
|
|
279
|
+
// Only the in-process explicit human attach carries this authority; protocol
|
|
280
|
+
// workspace.ensure and implicit agent paths observe the loss instead.
|
|
281
|
+
if (!replaceLost)
|
|
282
|
+
throw new RuntimeProtocolError(
|
|
283
|
+
"RUNTIME_LOST",
|
|
284
|
+
"Workspace runtime was lost; reconnect explicitly to replace it",
|
|
285
|
+
);
|
|
286
|
+
// Fail closed: release the dead runtime before any replacement exists.
|
|
287
|
+
// Stop first: if the daemon's exit cannot be confirmed the entry stays lost and
|
|
288
|
+
// no replacement is created behind a possibly live session.
|
|
289
|
+
await this.owner.stop(entry.runtime);
|
|
290
|
+
this.workspaces.delete(workspaceId);
|
|
291
|
+
entry = undefined;
|
|
292
|
+
}
|
|
208
293
|
if (!entry) {
|
|
209
294
|
let creation = this.creations.get(workspaceId);
|
|
210
295
|
if (!creation) {
|
|
@@ -229,7 +314,10 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
|
|
|
229
314
|
operation: string,
|
|
230
315
|
input: JsonValue,
|
|
231
316
|
): Promise<JsonValue> {
|
|
232
|
-
|
|
317
|
+
// Capture the caller before entering the workspace tail. Revocation must
|
|
318
|
+
// fence queued native publication, not only the initial ticket lookup.
|
|
319
|
+
const agent = this.agentRequestContext.getStore();
|
|
320
|
+
const execute = async () => {
|
|
233
321
|
if (this.archived.has(workspaceId)) this.workspaceArchived(workspaceId);
|
|
234
322
|
const entry = this.workspaces.get(workspaceId);
|
|
235
323
|
if (!entry)
|
|
@@ -237,12 +325,47 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
|
|
|
237
325
|
"WORKSPACE_NOT_FOUND",
|
|
238
326
|
`Workspace runtime not found: ${workspaceId}`,
|
|
239
327
|
);
|
|
328
|
+
const data = input && typeof input === "object" && !Array.isArray(input) ? input : {};
|
|
329
|
+
const key = typeof data.key === "string" ? data.key : "";
|
|
330
|
+
const button = typeof data.button === "string" ? data.button : "";
|
|
331
|
+
const channelId = typeof data.gestureId === "string" ? data.gestureId : "";
|
|
332
|
+
const pairedRelease =
|
|
333
|
+
agent &&
|
|
334
|
+
((operation === "key.up" && agent.keys.get(key) === entry.runtime) ||
|
|
335
|
+
(operation === "mouse.up" && agent.buttons.get(button) === entry.runtime) ||
|
|
336
|
+
(operation === "input.end" && agent.channels.get(channelId) === entry.runtime));
|
|
337
|
+
if (agent && !pairedRelease) {
|
|
338
|
+
this.assertAgentBindingCurrent(agent.binding);
|
|
339
|
+
if (agent.binding.selectionRevision !== agent.selectionRevision) {
|
|
340
|
+
throw new RuntimeProtocolError("AUTHENTICATION_FAILED", "Agent tab selection changed");
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
// A lost down/begin ACK still requires cleanup. Record intent only after
|
|
344
|
+
// the current binding fence, immediately before native publication.
|
|
345
|
+
if (agent) {
|
|
346
|
+
if (operation === "key.down") agent.keys.set(key, entry.runtime);
|
|
347
|
+
if (operation === "mouse.down") agent.buttons.set(button, entry.runtime);
|
|
348
|
+
if (operation === "input.begin") agent.channels.set(channelId, entry.runtime);
|
|
349
|
+
}
|
|
240
350
|
try {
|
|
241
351
|
const result = await this.owner.request(entry.runtime, operation, input);
|
|
352
|
+
// Revocation blocks new publication, but never strands a possibly published press.
|
|
353
|
+
// These records allow only its policy-generated paired release, on the
|
|
354
|
+
// original runtime, before the outer request refuses its revoked result.
|
|
355
|
+
if (agent) {
|
|
356
|
+
if (operation === "key.up") agent.keys.delete(key);
|
|
357
|
+
if (operation === "mouse.up") agent.buttons.delete(button);
|
|
358
|
+
}
|
|
242
359
|
if (this.archived.has(workspaceId)) this.workspaceArchived(workspaceId);
|
|
360
|
+
if (this.workspaces.get(workspaceId) !== entry)
|
|
361
|
+
throw new RuntimeProtocolError("RUNTIME_FAILURE", "Video runtime was replaced");
|
|
243
362
|
return result;
|
|
244
363
|
} catch (error) {
|
|
245
364
|
if (error instanceof RuntimeProtocolError) throw error;
|
|
365
|
+
if (error instanceof RuntimeLostError) {
|
|
366
|
+
entry.lost = true;
|
|
367
|
+
throw new RuntimeProtocolError("RUNTIME_LOST", error.message);
|
|
368
|
+
}
|
|
246
369
|
if (error instanceof CdpUnknownOutcomeError)
|
|
247
370
|
throw new RuntimeProtocolError(
|
|
248
371
|
"UNKNOWN_OUTCOME",
|
|
@@ -252,8 +375,13 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
|
|
|
252
375
|
"RUNTIME_FAILURE",
|
|
253
376
|
`Workspace runtime ${operation} failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
254
377
|
);
|
|
378
|
+
} finally {
|
|
379
|
+
if (agent && operation === "input.end") agent.channels.delete(channelId);
|
|
255
380
|
}
|
|
256
|
-
}
|
|
381
|
+
};
|
|
382
|
+
return operation === "video.read"
|
|
383
|
+
? execute()
|
|
384
|
+
: this.runWorkspaceOperation(workspaceId, execute);
|
|
257
385
|
}
|
|
258
386
|
|
|
259
387
|
private archiveWorkspaceLocal(workspaceId: string): Promise<{ archived: true }> {
|
|
@@ -270,6 +398,19 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
|
|
|
270
398
|
});
|
|
271
399
|
}
|
|
272
400
|
|
|
401
|
+
/** Stop the exact runtime confirmed by the viewer while keeping the workspace reusable. */
|
|
402
|
+
private closeWorkspaceLocal(workspaceId: string, runtimeId: string): Promise<void> {
|
|
403
|
+
return this.runWorkspaceOperation(workspaceId, async () => {
|
|
404
|
+
const entry = this.workspaces.get(workspaceId);
|
|
405
|
+
if (!entry || entry.runtime.runtimeId !== runtimeId)
|
|
406
|
+
throw new RuntimeProtocolError("RUNTIME_FAILURE", "Browser runtime was replaced");
|
|
407
|
+
this.invalidateAgentObservations(workspaceId);
|
|
408
|
+
this.revokeAgentControl(workspaceId);
|
|
409
|
+
await this.owner.stop(entry.runtime);
|
|
410
|
+
if (this.workspaces.get(workspaceId) === entry) this.workspaces.delete(workspaceId);
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
273
414
|
private async requestBrowser(
|
|
274
415
|
bridgeId: string,
|
|
275
416
|
epoch: number,
|
|
@@ -286,6 +427,9 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
|
|
|
286
427
|
result = await this.browserPolicy.attach(
|
|
287
428
|
requireText(data, "workspaceId"),
|
|
288
429
|
requireText(data, "viewerLabel"),
|
|
430
|
+
typeof data.tabId === "string" ? data.tabId : undefined,
|
|
431
|
+
// Only this bridge-authorised human attach may replace a lost runtime.
|
|
432
|
+
{ replaceLost: true },
|
|
289
433
|
);
|
|
290
434
|
break;
|
|
291
435
|
case "detach":
|
|
@@ -294,20 +438,28 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
|
|
|
294
438
|
case "status":
|
|
295
439
|
result = await this.browserPolicy.status(requireText(data, "viewerToken"));
|
|
296
440
|
break;
|
|
297
|
-
case "
|
|
441
|
+
case "video.read":
|
|
442
|
+
result = await this.browserPolicy.readVideo(readBrowserVideoRpc.input.parse(data));
|
|
443
|
+
break;
|
|
444
|
+
case "capture": {
|
|
445
|
+
const capture = captureBrowserRpc.input.parse(data);
|
|
298
446
|
result = await this.browserPolicy.capture(
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
447
|
+
capture.viewerToken,
|
|
448
|
+
capture.quality,
|
|
449
|
+
capture.knownFrameId,
|
|
302
450
|
);
|
|
303
451
|
break;
|
|
452
|
+
}
|
|
453
|
+
case "capture.density":
|
|
454
|
+
result = await this.browserPolicy.setCaptureDensity(setCaptureDensityRpc.input.parse(data));
|
|
455
|
+
break;
|
|
304
456
|
case "acquire-control": {
|
|
305
457
|
result = await this.browserPolicy.acquireControl(
|
|
306
458
|
requireText(data, "viewerToken"),
|
|
307
459
|
data.takeover === true,
|
|
308
460
|
);
|
|
309
461
|
const state = stateFromPolicyResult(result);
|
|
310
|
-
if (data.takeover === true) this.revokeAgentControl(state.workspaceId);
|
|
462
|
+
if (data.takeover === true) this.revokeAgentControl(state.workspaceId, state.tabId);
|
|
311
463
|
break;
|
|
312
464
|
}
|
|
313
465
|
case "release-control":
|
|
@@ -318,23 +470,53 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
|
|
|
318
470
|
break;
|
|
319
471
|
case "navigate":
|
|
320
472
|
result = await this.browserPolicy.navigate(data as never);
|
|
321
|
-
this.
|
|
473
|
+
this.invalidateAgentObservationsForState(result);
|
|
322
474
|
break;
|
|
323
475
|
case "viewport":
|
|
324
476
|
result = await this.browserPolicy.resize(data as never);
|
|
325
|
-
this.
|
|
477
|
+
this.invalidateAgentObservationsForState(result);
|
|
326
478
|
break;
|
|
327
479
|
case "device":
|
|
328
480
|
result = await this.browserPolicy.applyDevicePreset(data as never);
|
|
329
|
-
this.
|
|
481
|
+
this.invalidateAgentObservationsForState(result);
|
|
330
482
|
break;
|
|
331
483
|
case "input":
|
|
332
484
|
result = await this.browserPolicy.sendInput(data as never);
|
|
333
|
-
this.
|
|
485
|
+
this.invalidateAgentObservationsForState(result);
|
|
486
|
+
break;
|
|
487
|
+
case "gesture.begin":
|
|
488
|
+
result = await this.browserPolicy.beginGesture(beginBrowserGestureRpc.input.parse(data));
|
|
489
|
+
break;
|
|
490
|
+
case "gesture.update":
|
|
491
|
+
result = await this.browserPolicy.updateGesture(updateBrowserGestureRpc.input.parse(data));
|
|
492
|
+
this.invalidateAgentObservationsForState(result);
|
|
493
|
+
break;
|
|
494
|
+
case "gesture.end":
|
|
495
|
+
result = await this.browserPolicy.endGesture(endBrowserGestureRpc.input.parse(data));
|
|
496
|
+
this.invalidateAgentObservationsForState(result);
|
|
334
497
|
break;
|
|
335
498
|
case "list":
|
|
336
499
|
result = { workspaceIds: await this.browserPolicy.listOpenWorkspaceIds() };
|
|
337
500
|
break;
|
|
501
|
+
case "tabs.list":
|
|
502
|
+
result = await this.browserPolicy.listTabsForViewer(requireText(data, "viewerToken"));
|
|
503
|
+
break;
|
|
504
|
+
case "tabs.create":
|
|
505
|
+
result = await this.browserPolicy.createTab(requireText(data, "viewerToken"));
|
|
506
|
+
break;
|
|
507
|
+
case "tabs.close":
|
|
508
|
+
result = await this.browserPolicy.closeTab({
|
|
509
|
+
viewerToken: requireText(data, "viewerToken"),
|
|
510
|
+
controlToken: requireText(data, "controlToken"),
|
|
511
|
+
tabId: requireText(data, "tabId"),
|
|
512
|
+
});
|
|
513
|
+
break;
|
|
514
|
+
case "close":
|
|
515
|
+
result = await this.browserPolicy.closeBrowser(closeBrowserRpc.input.parse(data));
|
|
516
|
+
break;
|
|
517
|
+
case "reopen":
|
|
518
|
+
result = await this.browserPolicy.reopenBrowser(requireText(data, "workspaceId"));
|
|
519
|
+
break;
|
|
338
520
|
case "archive": {
|
|
339
521
|
const workspaceId = requireText(data, "workspaceId");
|
|
340
522
|
await this.revokeWorkspace(workspaceId);
|
|
@@ -371,6 +553,8 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
|
|
|
371
553
|
issuedAt: this.now(),
|
|
372
554
|
agentId: null,
|
|
373
555
|
workspaceId: null,
|
|
556
|
+
tabId: null,
|
|
557
|
+
selectionRevision: 0,
|
|
374
558
|
viewerToken: null,
|
|
375
559
|
controlToken: null,
|
|
376
560
|
lastState: null,
|
|
@@ -408,34 +592,125 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
|
|
|
408
592
|
this.assertActiveBridge(bridgeId, epoch);
|
|
409
593
|
const tickets = this.agentTickets.get(agentId);
|
|
410
594
|
if (!tickets) return { revoked: 0 };
|
|
595
|
+
const viewers: string[] = [];
|
|
411
596
|
let revoked = 0;
|
|
597
|
+
// Remove every credential synchronously before waiting on viewer cleanup.
|
|
412
598
|
for (const ticket of tickets) {
|
|
413
599
|
const binding = this.agentBindings.get(ticket);
|
|
414
600
|
if (!binding) continue;
|
|
415
601
|
this.agentBindings.delete(ticket);
|
|
416
602
|
revoked += 1;
|
|
417
|
-
if (binding.viewerToken)
|
|
418
|
-
await this.browserPolicy.detach(binding.viewerToken).catch(() => undefined);
|
|
603
|
+
if (binding.viewerToken) viewers.push(binding.viewerToken);
|
|
419
604
|
}
|
|
420
605
|
this.agentTickets.delete(agentId);
|
|
606
|
+
await Promise.allSettled(viewers.map((viewerToken) => this.browserPolicy.detach(viewerToken)));
|
|
421
607
|
return { revoked };
|
|
422
608
|
}
|
|
423
609
|
|
|
424
|
-
|
|
425
|
-
|
|
610
|
+
/** Carry exact ticket identity through awaited policy and native publication. */
|
|
611
|
+
private requestAgent(ticket: string, operation: string, input: JsonValue): Promise<JsonValue> {
|
|
612
|
+
this.assertPluginAvailable();
|
|
613
|
+
const binding = this.requireAgentBinding(ticket);
|
|
614
|
+
const execute = () => this.executeAgentRequest(binding, operation, input);
|
|
615
|
+
// Attachment constructs shared session state; its late viewer is removed
|
|
616
|
+
// explicitly. Only actionable requests carry a native publication fence.
|
|
617
|
+
if (
|
|
618
|
+
operation === "status" ||
|
|
619
|
+
operation === "capture" ||
|
|
620
|
+
operation === "acquire-control" ||
|
|
621
|
+
operation === "tabs.list"
|
|
622
|
+
) {
|
|
623
|
+
return execute();
|
|
624
|
+
}
|
|
625
|
+
return this.agentRequestContext.run(
|
|
626
|
+
{
|
|
627
|
+
binding,
|
|
628
|
+
selectionRevision: binding.selectionRevision,
|
|
629
|
+
keys: new Map(),
|
|
630
|
+
buttons: new Map(),
|
|
631
|
+
channels: new Map(),
|
|
632
|
+
},
|
|
633
|
+
execute,
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
private async executeAgentRequest(
|
|
638
|
+
binding: AgentBinding,
|
|
426
639
|
operation: string,
|
|
427
640
|
input: JsonValue,
|
|
428
641
|
): Promise<JsonValue> {
|
|
429
|
-
this.
|
|
430
|
-
const binding = this.requireAgentBinding(ticket);
|
|
642
|
+
this.assertAgentBindingCurrent(binding);
|
|
431
643
|
const data = asObject(input);
|
|
432
644
|
if (operation === "status" || operation === "capture")
|
|
433
645
|
return (await this.requestAgentObservation(binding, operation, data)) as unknown as JsonValue;
|
|
646
|
+
if (operation === "tabs.list") {
|
|
647
|
+
const listed = await this.browserPolicy.listTabs(binding.workspaceId!);
|
|
648
|
+
return { ...listed, selectedTabId: binding.tabId };
|
|
649
|
+
}
|
|
650
|
+
if (operation === "tabs.select") {
|
|
651
|
+
const tabId = requireText(data, "tabId");
|
|
652
|
+
return { state: await this.selectAgentTab(binding, tabId) } as unknown as JsonValue;
|
|
653
|
+
}
|
|
654
|
+
if (operation === "tabs.create") {
|
|
655
|
+
const create = async () =>
|
|
656
|
+
this.browserPolicy.createTab(await this.ensureAgentViewer(binding));
|
|
657
|
+
let created: { tabId: string };
|
|
658
|
+
try {
|
|
659
|
+
created = await create();
|
|
660
|
+
} catch (error) {
|
|
661
|
+
// Only a refusal before dispatch is recoverable: the lapsed agent viewer is replaced
|
|
662
|
+
// once. A lease that ends after the page was created is a distinct, non-retried error.
|
|
663
|
+
if (!isInvalidViewer(error)) throw error;
|
|
664
|
+
binding.viewerToken = null;
|
|
665
|
+
binding.controlToken = null;
|
|
666
|
+
created = await create();
|
|
667
|
+
}
|
|
668
|
+
return {
|
|
669
|
+
tabId: created.tabId,
|
|
670
|
+
state: await this.selectAgentTab(binding, created.tabId),
|
|
671
|
+
} as unknown as JsonValue;
|
|
672
|
+
}
|
|
673
|
+
if (operation === "tabs.close") {
|
|
674
|
+
const viewerToken = binding.viewerToken;
|
|
675
|
+
const controlToken = binding.controlToken;
|
|
676
|
+
const tabId = binding.tabId;
|
|
677
|
+
if (!viewerToken || !controlToken || !tabId) {
|
|
678
|
+
throw new RuntimeProtocolError("AUTHENTICATION_FAILED", "Agent does not control a tab");
|
|
679
|
+
}
|
|
680
|
+
await this.browserPolicy.closeTab({ viewerToken, controlToken, tabId });
|
|
681
|
+
binding.viewerToken = null;
|
|
682
|
+
binding.tabId = null;
|
|
683
|
+
binding.controlToken = null;
|
|
684
|
+
binding.lastState = null;
|
|
685
|
+
binding.lastFrame = null;
|
|
686
|
+
const { tabs } = await this.browserPolicy.listTabs(binding.workspaceId!);
|
|
687
|
+
const next = tabs[0];
|
|
688
|
+
if (!next) throw new RuntimeProtocolError("RUNTIME_FAILURE", "Browser has no remaining tab");
|
|
689
|
+
return { state: await this.selectAgentTab(binding, next.id) } as unknown as JsonValue;
|
|
690
|
+
}
|
|
434
691
|
try {
|
|
435
692
|
if (operation === "acquire-control") {
|
|
693
|
+
const selectionRevision = binding.selectionRevision;
|
|
436
694
|
await this.requestAgentObservation(binding, "status", {});
|
|
695
|
+
this.assertAgentBindingCurrent(binding);
|
|
696
|
+
if (binding.selectionRevision !== selectionRevision) {
|
|
697
|
+
throw new RuntimeProtocolError(
|
|
698
|
+
"INVALID_REQUEST",
|
|
699
|
+
"Agent tab changed during control acquisition",
|
|
700
|
+
);
|
|
701
|
+
}
|
|
437
702
|
const viewerToken = binding.viewerToken!;
|
|
438
703
|
const result = await this.browserPolicy.acquireControl(viewerToken, false);
|
|
704
|
+
this.assertAgentBindingCurrent(binding);
|
|
705
|
+
if (binding.selectionRevision !== selectionRevision) {
|
|
706
|
+
await this.browserPolicy
|
|
707
|
+
.releaseControl(viewerToken, result.controlToken)
|
|
708
|
+
.catch(() => undefined);
|
|
709
|
+
throw new RuntimeProtocolError(
|
|
710
|
+
"INVALID_REQUEST",
|
|
711
|
+
"Agent tab changed during control acquisition",
|
|
712
|
+
);
|
|
713
|
+
}
|
|
439
714
|
binding.controlToken = result.controlToken;
|
|
440
715
|
binding.lastState = result.state;
|
|
441
716
|
return { state: result.state } as unknown as JsonValue;
|
|
@@ -456,35 +731,54 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
|
|
|
456
731
|
binding.controlToken = null;
|
|
457
732
|
}
|
|
458
733
|
}
|
|
734
|
+
this.assertAgentBindingCurrent(binding);
|
|
735
|
+
const selectionRevision = binding.selectionRevision;
|
|
459
736
|
const expected = expectedState(binding);
|
|
460
737
|
let result: { state: BrowserState };
|
|
461
738
|
if (operation === "navigate") {
|
|
462
|
-
result = await this.browserPolicy.navigate(
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
739
|
+
result = await this.browserPolicy.navigate(
|
|
740
|
+
parseAgentInput(navigateBrowserRpc.input, {
|
|
741
|
+
viewerToken,
|
|
742
|
+
controlToken,
|
|
743
|
+
expected,
|
|
744
|
+
action: data.action,
|
|
745
|
+
}),
|
|
746
|
+
);
|
|
468
747
|
} else if (operation === "viewport") {
|
|
469
|
-
|
|
748
|
+
const resize = parseAgentInput(resizeBrowserRpc.input, {
|
|
470
749
|
viewerToken,
|
|
471
750
|
controlToken,
|
|
472
751
|
expected,
|
|
473
|
-
viewport: data.viewport
|
|
752
|
+
viewport: data.viewport,
|
|
474
753
|
});
|
|
754
|
+
// The shared human schema allows MAX_VIEWPORT; an agent ticket keeps the
|
|
755
|
+
// smaller original bound, enforced here before any mutation.
|
|
756
|
+
if (
|
|
757
|
+
resize.viewport.width > MAX_AGENT_VIEWPORT.width ||
|
|
758
|
+
resize.viewport.height > MAX_AGENT_VIEWPORT.height
|
|
759
|
+
) {
|
|
760
|
+
throw new RuntimeProtocolError("INVALID_REQUEST", "Invalid agent browser request");
|
|
761
|
+
}
|
|
762
|
+
result = await this.browserPolicy.resize(resize);
|
|
475
763
|
} else if (operation === "input") {
|
|
476
764
|
if (!binding.lastFrame)
|
|
477
765
|
throw new RuntimeProtocolError("INVALID_REQUEST", "Capture a frame before sending input");
|
|
478
|
-
result = await this.browserPolicy.sendInput(
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
766
|
+
result = await this.browserPolicy.sendInput(
|
|
767
|
+
parseAgentInput(sendBrowserInputRpc.input, {
|
|
768
|
+
viewerToken,
|
|
769
|
+
controlToken,
|
|
770
|
+
expected,
|
|
771
|
+
target: binding.lastFrame,
|
|
772
|
+
event: data.event,
|
|
773
|
+
}),
|
|
774
|
+
);
|
|
485
775
|
} else {
|
|
486
776
|
throw new RuntimeProtocolError("INVALID_REQUEST", `Unknown agent operation: ${operation}`);
|
|
487
777
|
}
|
|
778
|
+
this.assertAgentBindingCurrent(binding);
|
|
779
|
+
if (binding.selectionRevision !== selectionRevision) {
|
|
780
|
+
throw new RuntimeProtocolError("INVALID_REQUEST", "Agent tab changed during action");
|
|
781
|
+
}
|
|
488
782
|
binding.lastState = result.state;
|
|
489
783
|
binding.lastFrame = null;
|
|
490
784
|
return result as unknown as JsonValue;
|
|
@@ -500,12 +794,15 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
|
|
|
500
794
|
operation: "status" | "capture",
|
|
501
795
|
input: Record<string, JsonValue>,
|
|
502
796
|
): Promise<{ state: BrowserState; frame?: BrowserFrame | null }> {
|
|
797
|
+
const selectionRevision = binding.selectionRevision;
|
|
503
798
|
const run = async (viewerToken: string) =>
|
|
504
799
|
operation === "status"
|
|
505
800
|
? await this.browserPolicy.status(viewerToken)
|
|
506
801
|
: await this.browserPolicy.capture(
|
|
507
802
|
viewerToken,
|
|
508
|
-
input.quality === "low" || input.quality === "
|
|
803
|
+
input.quality === "low" || input.quality === "medium" || input.quality === "high"
|
|
804
|
+
? input.quality
|
|
805
|
+
: DEFAULT_CAPTURE_QUALITY,
|
|
509
806
|
null,
|
|
510
807
|
);
|
|
511
808
|
let result: { state: BrowserState; frame?: BrowserFrame | null };
|
|
@@ -517,23 +814,102 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
|
|
|
517
814
|
binding.controlToken = null;
|
|
518
815
|
result = await run(await this.ensureAgentViewer(binding));
|
|
519
816
|
}
|
|
817
|
+
this.assertAgentBindingCurrent(binding);
|
|
818
|
+
if (binding.selectionRevision !== selectionRevision) {
|
|
819
|
+
throw new RuntimeProtocolError("INVALID_REQUEST", "Agent tab changed during observation");
|
|
820
|
+
}
|
|
520
821
|
binding.lastState = result.state;
|
|
822
|
+
binding.tabId = result.state.tabId ?? null;
|
|
521
823
|
if (operation === "capture") binding.lastFrame = result.frame ?? null;
|
|
522
824
|
return result;
|
|
523
825
|
}
|
|
524
826
|
|
|
827
|
+
/** Move only this agent's viewer to a page, preserving every other viewer's selection. */
|
|
828
|
+
private async selectAgentTab(binding: AgentBinding, tabId: string): Promise<BrowserState> {
|
|
829
|
+
this.assertAgentBindingCurrent(binding);
|
|
830
|
+
if (binding.tabId === tabId && binding.viewerToken) {
|
|
831
|
+
// Same-tab selection needs the same expiry recovery as status/capture.
|
|
832
|
+
const current = await this.requestAgentObservation(binding, "status", {});
|
|
833
|
+
if (binding.tabId !== tabId) {
|
|
834
|
+
throw new RuntimeProtocolError("INVALID_REQUEST", "Agent tab changed during selection");
|
|
835
|
+
}
|
|
836
|
+
binding.lastState = current.state;
|
|
837
|
+
return current.state;
|
|
838
|
+
}
|
|
839
|
+
const revision = ++binding.selectionRevision;
|
|
840
|
+
const requestContext = this.agentRequestContext.getStore();
|
|
841
|
+
if (requestContext?.binding === binding) requestContext.selectionRevision = revision;
|
|
842
|
+
const previousViewer = binding.viewerToken;
|
|
843
|
+
binding.controlToken = null;
|
|
844
|
+
binding.lastState = null;
|
|
845
|
+
binding.lastFrame = null;
|
|
846
|
+
const attached = await this.browserPolicy.attach(
|
|
847
|
+
binding.workspaceId!,
|
|
848
|
+
`Agent ${binding.agentId!.slice(0, 48)}`,
|
|
849
|
+
tabId,
|
|
850
|
+
);
|
|
851
|
+
try {
|
|
852
|
+
this.assertAgentBindingCurrent(binding);
|
|
853
|
+
if (binding.selectionRevision !== revision) {
|
|
854
|
+
throw new RuntimeProtocolError("INVALID_REQUEST", "Agent tab selection was replaced");
|
|
855
|
+
}
|
|
856
|
+
if (previousViewer) await this.browserPolicy.detach(previousViewer);
|
|
857
|
+
this.assertAgentBindingCurrent(binding);
|
|
858
|
+
binding.viewerToken = attached.viewerToken;
|
|
859
|
+
binding.tabId = attached.state.tabId ?? tabId;
|
|
860
|
+
binding.controlToken = null;
|
|
861
|
+
binding.lastState = attached.state;
|
|
862
|
+
binding.lastFrame = null;
|
|
863
|
+
return attached.state;
|
|
864
|
+
} catch (error) {
|
|
865
|
+
await this.browserPolicy.detach(attached.viewerToken).catch(() => undefined);
|
|
866
|
+
throw error;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/** Publish an attached viewer only while its original credential still exists. */
|
|
525
871
|
private async ensureAgentViewer(binding: AgentBinding): Promise<string> {
|
|
872
|
+
this.assertAgentBindingCurrent(binding);
|
|
526
873
|
if (binding.viewerToken) return binding.viewerToken;
|
|
527
|
-
|
|
874
|
+
const selectionRevision = binding.selectionRevision;
|
|
528
875
|
const attached = await this.browserPolicy.attach(
|
|
529
876
|
binding.workspaceId!,
|
|
530
877
|
`Agent ${binding.agentId!.slice(0, 48)}`,
|
|
878
|
+
binding.tabId ?? undefined,
|
|
531
879
|
);
|
|
880
|
+
try {
|
|
881
|
+
this.assertAgentBindingCurrent(binding);
|
|
882
|
+
if (binding.selectionRevision !== selectionRevision) {
|
|
883
|
+
throw new RuntimeProtocolError("INVALID_REQUEST", "Agent tab changed during attachment");
|
|
884
|
+
}
|
|
885
|
+
} catch (error) {
|
|
886
|
+
// Cleanup must run outside the revoked caller's publication fence.
|
|
887
|
+
await this.agentRequestContext
|
|
888
|
+
.exit(() => this.browserPolicy.detach(attached.viewerToken))
|
|
889
|
+
.catch(() => undefined);
|
|
890
|
+
throw error;
|
|
891
|
+
}
|
|
892
|
+
if (binding.viewerToken) {
|
|
893
|
+
// Parallel observations may both attach. Keep the first published viewer.
|
|
894
|
+
await this.agentRequestContext.exit(() => this.browserPolicy.detach(attached.viewerToken));
|
|
895
|
+
this.assertAgentBindingCurrent(binding);
|
|
896
|
+
return binding.viewerToken;
|
|
897
|
+
}
|
|
532
898
|
binding.viewerToken = attached.viewerToken;
|
|
533
899
|
binding.lastState = attached.state;
|
|
900
|
+
binding.tabId = attached.state.tabId ?? null;
|
|
534
901
|
return attached.viewerToken;
|
|
535
902
|
}
|
|
536
903
|
|
|
904
|
+
/** A deleted or replaced ticket cannot regain authority after an awaited step. */
|
|
905
|
+
private assertAgentBindingCurrent(binding: AgentBinding): void {
|
|
906
|
+
this.assertPluginAvailable();
|
|
907
|
+
if (this.agentBindings.get(binding.ticket) !== binding) {
|
|
908
|
+
throw new RuntimeProtocolError("AUTHENTICATION_FAILED", "Agent ticket was revoked");
|
|
909
|
+
}
|
|
910
|
+
if (this.archived.has(binding.workspaceId!)) this.workspaceArchived(binding.workspaceId!);
|
|
911
|
+
}
|
|
912
|
+
|
|
537
913
|
private requireAgentBinding(ticket: string): AgentBinding {
|
|
538
914
|
const binding = this.agentBindings.get(ticket);
|
|
539
915
|
if (!binding?.agentId || !binding.workspaceId)
|
|
@@ -547,18 +923,25 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
|
|
|
547
923
|
throw new RuntimeProtocolError("BRIDGE_FENCED", "Shared Browser plugin is unavailable");
|
|
548
924
|
}
|
|
549
925
|
|
|
550
|
-
private revokeAgentControl(workspaceId: string): void {
|
|
926
|
+
private revokeAgentControl(workspaceId: string, tabId?: string): void {
|
|
551
927
|
for (const binding of this.agentBindings.values()) {
|
|
552
928
|
if (binding.workspaceId !== workspaceId) continue;
|
|
929
|
+
if (tabId && binding.tabId !== tabId) continue;
|
|
553
930
|
binding.controlToken = null;
|
|
554
931
|
binding.lastState = null;
|
|
555
932
|
binding.lastFrame = null;
|
|
556
933
|
}
|
|
557
934
|
}
|
|
558
935
|
|
|
559
|
-
private
|
|
936
|
+
private invalidateAgentObservationsForState(result: unknown): void {
|
|
937
|
+
const state = stateFromPolicyResult(result);
|
|
938
|
+
this.invalidateAgentObservations(state.workspaceId, state.tabId);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
private invalidateAgentObservations(workspaceId: string, tabId?: string): void {
|
|
560
942
|
for (const binding of this.agentBindings.values()) {
|
|
561
943
|
if (binding.workspaceId !== workspaceId) continue;
|
|
944
|
+
if (tabId && binding.tabId !== tabId) continue;
|
|
562
945
|
binding.lastState = null;
|
|
563
946
|
binding.lastFrame = null;
|
|
564
947
|
}
|
|
@@ -634,6 +1017,13 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
|
|
|
634
1017
|
);
|
|
635
1018
|
case "workspace.archive":
|
|
636
1019
|
return this.archiveWorkspace(request.bridgeId, request.epoch, request.workspaceId);
|
|
1020
|
+
case "workspace.close":
|
|
1021
|
+
return this.closeWorkspace(
|
|
1022
|
+
request.bridgeId,
|
|
1023
|
+
request.epoch,
|
|
1024
|
+
request.workspaceId,
|
|
1025
|
+
request.runtimeId,
|
|
1026
|
+
).then(() => ({ closed: true }));
|
|
637
1027
|
case "browser.request":
|
|
638
1028
|
return this.requestBrowser(
|
|
639
1029
|
request.bridgeId,
|
|
@@ -760,6 +1150,16 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
|
|
|
760
1150
|
}
|
|
761
1151
|
}
|
|
762
1152
|
|
|
1153
|
+
/** Raw agent sockets reuse the public mutation contract. Never expose parser
|
|
1154
|
+
* issues or submitted values in errors from this trusted-ticket boundary. */
|
|
1155
|
+
function parseAgentInput<Value>(schema: ZodType<Value>, value: unknown): Value {
|
|
1156
|
+
const parsed = schema.safeParse(value);
|
|
1157
|
+
if (!parsed.success) {
|
|
1158
|
+
throw new RuntimeProtocolError("INVALID_REQUEST", "Invalid agent browser request");
|
|
1159
|
+
}
|
|
1160
|
+
return parsed.data;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
763
1163
|
function asObject(value: JsonValue): Record<string, JsonValue> {
|
|
764
1164
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
765
1165
|
throw new RuntimeProtocolError("INVALID_REQUEST", "Browser input must be an object");
|
|
@@ -872,13 +1272,18 @@ export async function startSupervisorServer<Runtime extends RuntimeInstance>(
|
|
|
872
1272
|
const supervisor = new RuntimeSupervisor({ owner });
|
|
873
1273
|
const sockets = new Set<Socket>();
|
|
874
1274
|
let globalInFlight = 0;
|
|
1275
|
+
let globalMediaInFlight = 0;
|
|
1276
|
+
let globalHeartbeatsInFlight = 0;
|
|
875
1277
|
const token = randomBytes(32).toString("base64url");
|
|
876
1278
|
const server = createServer((socket) => {
|
|
877
1279
|
sockets.add(socket);
|
|
878
1280
|
let buffer = "";
|
|
879
1281
|
let claimed: { bridgeId: string; epoch: number } | null = null;
|
|
880
1282
|
let socketInFlight = 0;
|
|
1283
|
+
let socketMediaInFlight = 0;
|
|
1284
|
+
let socketHeartbeatsInFlight = 0;
|
|
881
1285
|
let processing = Promise.resolve();
|
|
1286
|
+
let busyResponsePending = false;
|
|
882
1287
|
socket.setEncoding("utf8");
|
|
883
1288
|
|
|
884
1289
|
const rejectBusy = (line: string): boolean => {
|
|
@@ -891,6 +1296,7 @@ export async function startSupervisorServer<Runtime extends RuntimeInstance>(
|
|
|
891
1296
|
socket.destroy();
|
|
892
1297
|
return false;
|
|
893
1298
|
}
|
|
1299
|
+
busyResponsePending = true;
|
|
894
1300
|
socket.pause();
|
|
895
1301
|
socket.write(
|
|
896
1302
|
`${JSON.stringify({
|
|
@@ -902,19 +1308,46 @@ export async function startSupervisorServer<Runtime extends RuntimeInstance>(
|
|
|
902
1308
|
},
|
|
903
1309
|
})}\n`,
|
|
904
1310
|
(error) => {
|
|
1311
|
+
busyResponsePending = false;
|
|
905
1312
|
if (error) socket.destroy();
|
|
906
|
-
else
|
|
1313
|
+
else drainBufferedLines();
|
|
907
1314
|
},
|
|
908
1315
|
);
|
|
909
1316
|
return false;
|
|
910
1317
|
};
|
|
911
1318
|
|
|
912
1319
|
const enqueue = (line: string): boolean => {
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
1320
|
+
const concurrent = classifyConcurrentRequest(line, token);
|
|
1321
|
+
const maintenance = concurrent === "heartbeat" && isCurrentHeartbeat(line, supervisor);
|
|
1322
|
+
const media = !maintenance && isMediaRequest(line, token);
|
|
1323
|
+
if (maintenance) {
|
|
1324
|
+
if (
|
|
1325
|
+
socketHeartbeatsInFlight >= MAX_HEARTBEATS_IN_FLIGHT ||
|
|
1326
|
+
globalHeartbeatsInFlight >= MAX_HEARTBEATS_IN_FLIGHT
|
|
1327
|
+
)
|
|
1328
|
+
return rejectBusy(line);
|
|
1329
|
+
socketHeartbeatsInFlight += 1;
|
|
1330
|
+
globalHeartbeatsInFlight += 1;
|
|
1331
|
+
} else {
|
|
1332
|
+
if (socketInFlight >= MAX_SOCKET_IN_FLIGHT || globalInFlight >= MAX_GLOBAL_IN_FLIGHT)
|
|
1333
|
+
return rejectBusy(line);
|
|
1334
|
+
if (
|
|
1335
|
+
media &&
|
|
1336
|
+
(socketMediaInFlight >= MAX_SOCKET_MEDIA_IN_FLIGHT ||
|
|
1337
|
+
globalMediaInFlight >= MAX_GLOBAL_MEDIA_IN_FLIGHT)
|
|
1338
|
+
)
|
|
1339
|
+
return rejectBusy(line);
|
|
1340
|
+
socketInFlight += 1;
|
|
1341
|
+
globalInFlight += 1;
|
|
1342
|
+
if (media) {
|
|
1343
|
+
socketMediaInFlight += 1;
|
|
1344
|
+
globalMediaInFlight += 1;
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
// Video admission/result fencing already belongs to each workspace's
|
|
1348
|
+
// policy queue. Another workspace's teardown must not block that lane.
|
|
1349
|
+
const before = concurrent === null ? processing : Promise.resolve();
|
|
1350
|
+
const execution = before
|
|
918
1351
|
.then(async () => {
|
|
919
1352
|
const { response, lease } = await handleLine(line, token, supervisor);
|
|
920
1353
|
if (lease) claimed = lease;
|
|
@@ -928,19 +1361,32 @@ export async function startSupervisorServer<Runtime extends RuntimeInstance>(
|
|
|
928
1361
|
})
|
|
929
1362
|
.catch(() => undefined)
|
|
930
1363
|
.finally(() => {
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
1364
|
+
if (maintenance) {
|
|
1365
|
+
socketHeartbeatsInFlight -= 1;
|
|
1366
|
+
globalHeartbeatsInFlight -= 1;
|
|
1367
|
+
} else {
|
|
1368
|
+
socketInFlight -= 1;
|
|
1369
|
+
globalInFlight -= 1;
|
|
1370
|
+
if (media) {
|
|
1371
|
+
socketMediaInFlight -= 1;
|
|
1372
|
+
globalMediaInFlight -= 1;
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
drainBufferedLines();
|
|
934
1376
|
});
|
|
1377
|
+
// Heartbeats must pass slow page operations, otherwise a healthy bridge
|
|
1378
|
+
// expires and its browser is stopped. Video waits also leave the command
|
|
1379
|
+
// tail. Only the authenticated current lease uses the separately bounded
|
|
1380
|
+
// maintenance lane. Auth, epoch checks and whole ID-keyed writes still apply.
|
|
1381
|
+
if (concurrent === null) processing = execution;
|
|
935
1382
|
return true;
|
|
936
1383
|
};
|
|
937
1384
|
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
}
|
|
1385
|
+
// Pausing transport does not consume lines already delivered in its current
|
|
1386
|
+
// chunk. Drain those after each bounded busy response, without replaying a
|
|
1387
|
+
// refused line or creating an unbounded queue of busy-response writes.
|
|
1388
|
+
function drainBufferedLines(): void {
|
|
1389
|
+
if (socket.destroyed || busyResponsePending) return;
|
|
944
1390
|
let newline = buffer.indexOf("\n");
|
|
945
1391
|
while (newline >= 0) {
|
|
946
1392
|
const line = buffer.slice(0, newline);
|
|
@@ -948,6 +1394,16 @@ export async function startSupervisorServer<Runtime extends RuntimeInstance>(
|
|
|
948
1394
|
if (line.length > 0 && !enqueue(line)) return;
|
|
949
1395
|
newline = buffer.indexOf("\n");
|
|
950
1396
|
}
|
|
1397
|
+
socket.resume();
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
socket.on("data", (chunk: string) => {
|
|
1401
|
+
buffer += chunk;
|
|
1402
|
+
if (Buffer.byteLength(buffer) > MAX_MESSAGE_BYTES) {
|
|
1403
|
+
socket.destroy();
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
drainBufferedLines();
|
|
951
1407
|
});
|
|
952
1408
|
socket.once("close", () => {
|
|
953
1409
|
sockets.delete(socket);
|
|
@@ -1024,6 +1480,48 @@ export async function startSupervisorServer<Runtime extends RuntimeInstance>(
|
|
|
1024
1480
|
},
|
|
1025
1481
|
};
|
|
1026
1482
|
}
|
|
1483
|
+
/** Classification already verified the token; stale leases cannot consume reserved capacity. */
|
|
1484
|
+
function isCurrentHeartbeat(line: string, supervisor: RuntimeSupervisor): boolean {
|
|
1485
|
+
const request = parseRuntimeRequest(JSON.parse(line));
|
|
1486
|
+
return (
|
|
1487
|
+
request.method === "bridge.heartbeat" &&
|
|
1488
|
+
supervisor.isCurrentBridgeLease(request.bridgeId, request.epoch)
|
|
1489
|
+
);
|
|
1490
|
+
}
|
|
1491
|
+
/** Capacity-only classification, never an authorization or command-tail bypass. */
|
|
1492
|
+
function isMediaRequest(line: string, token: string): boolean {
|
|
1493
|
+
try {
|
|
1494
|
+
const request = parseRuntimeRequest(JSON.parse(line));
|
|
1495
|
+
if (request.method === "agent.request") return request.operation === "capture";
|
|
1496
|
+
if (!tokensEqual(request.token, token)) return false;
|
|
1497
|
+
if (request.method !== "browser.request" && request.method !== "workspace.request")
|
|
1498
|
+
return false;
|
|
1499
|
+
return (
|
|
1500
|
+
request.operation === "video.read" ||
|
|
1501
|
+
request.operation === (request.method === "browser.request" ? "capture" : "frame")
|
|
1502
|
+
);
|
|
1503
|
+
} catch {
|
|
1504
|
+
return false;
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
/** Classify authenticated maintenance/media calls without weakening command ordering. */
|
|
1508
|
+
function classifyConcurrentRequest(line: string, token: string): "heartbeat" | "video" | null {
|
|
1509
|
+
try {
|
|
1510
|
+
const request = parseRuntimeRequest(JSON.parse(line));
|
|
1511
|
+
if (request.method === "agent.request" || !tokensEqual(request.token, token)) return null;
|
|
1512
|
+
if (request.method === "bridge.heartbeat") return "heartbeat";
|
|
1513
|
+
if (request.method !== "browser.request" && request.method !== "workspace.request") return null;
|
|
1514
|
+
if (request.operation !== "video.read") return null;
|
|
1515
|
+
const schema =
|
|
1516
|
+
request.method === "browser.request"
|
|
1517
|
+
? readBrowserVideoRpc.input
|
|
1518
|
+
: readBrowserVideoRpc.input.omit({ viewerToken: true });
|
|
1519
|
+
return schema.safeParse(request.input).success ? "video" : null;
|
|
1520
|
+
} catch {
|
|
1521
|
+
return null;
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1027
1525
|
async function handleLine<Runtime extends RuntimeInstance>(
|
|
1028
1526
|
line: string,
|
|
1029
1527
|
token: string,
|