@omercnet/paseo-shared-browser 0.3.1-next.72.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/CHANGELOG.md +71 -0
- package/LICENSE +21 -0
- package/README.md +182 -0
- package/client/browser.tsx +2098 -0
- package/docs/images/shared-browser-compact.png +0 -0
- package/docs/images/shared-browser-wide.png +0 -0
- package/index.client.tsx +35 -0
- package/index.server.ts +118 -0
- package/package.json +78 -0
- package/paseo-plugin.json +10 -0
- package/scripts/prepare-dependencies.mjs +25 -0
- package/scripts/prepare-runtime.mjs +168 -0
- package/server/agent-browser-runtime.ts +970 -0
- package/server/browser-policy.ts +836 -0
- package/server/browser.ts +306 -0
- package/server/cdp.ts +265 -0
- package/server/electron.d.ts +1 -0
- package/server/mcp-entry.ts +194 -0
- package/server/runtime-owner.ts +122 -0
- package/server/runtime-protocol.ts +260 -0
- package/server/supervisor-client.ts +402 -0
- package/server/supervisor-entry.ts +9 -0
- package/server/supervisor.ts +1081 -0
- package/shared/browser.ts +364 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,1081 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { chmod, mkdir, open, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { createServer, type Socket } from "node:net";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import type { BrowserFrame, BrowserInputEvent, BrowserState, Viewport } from "../shared/browser";
|
|
7
|
+
import { SessionManager } from "./browser-policy";
|
|
8
|
+
import { CdpUnknownOutcomeError } from "./cdp";
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_BRIDGE_HEARTBEAT_MS,
|
|
11
|
+
DEFAULT_BRIDGE_TIMEOUT_MS,
|
|
12
|
+
DEFAULT_ORPHAN_GRACE_MS,
|
|
13
|
+
RUNTIME_PROTOCOL_VERSION,
|
|
14
|
+
RuntimeProtocolError,
|
|
15
|
+
parseRuntimeRequest,
|
|
16
|
+
type BridgeLease,
|
|
17
|
+
type JsonValue,
|
|
18
|
+
type RuntimeDescriptor,
|
|
19
|
+
type RuntimeRequest,
|
|
20
|
+
type RuntimeResponse,
|
|
21
|
+
type RuntimeResult,
|
|
22
|
+
} from "./runtime-protocol";
|
|
23
|
+
|
|
24
|
+
const DIRECTORY_MODE = 0o700;
|
|
25
|
+
const FILE_MODE = 0o600;
|
|
26
|
+
const SOCKET_MODE = 0o600;
|
|
27
|
+
const MAX_MESSAGE_BYTES = 1024 * 1024;
|
|
28
|
+
const MAX_SUPERVISOR_WORKSPACES = 8;
|
|
29
|
+
const MAX_SOCKET_IN_FLIGHT = 16;
|
|
30
|
+
const MAX_GLOBAL_IN_FLIGHT = 64;
|
|
31
|
+
const AGENT_TICKET_TTL_MS = 10 * 60_000;
|
|
32
|
+
const MAX_UNBOUND_AGENT_TICKETS = 256;
|
|
33
|
+
type SupervisorTimer = NodeJS.Timeout;
|
|
34
|
+
|
|
35
|
+
export interface RuntimeInstance {
|
|
36
|
+
runtimeId: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface RuntimeOwner<Runtime extends RuntimeInstance = RuntimeInstance> {
|
|
40
|
+
create(workspaceId: string): Promise<Runtime>;
|
|
41
|
+
request(runtime: Runtime, operation: string, input: JsonValue): Promise<JsonValue>;
|
|
42
|
+
stop(runtime: Runtime): Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface RuntimeSupervisorOptions<Runtime extends RuntimeInstance> {
|
|
46
|
+
owner: RuntimeOwner<Runtime>;
|
|
47
|
+
now?: () => number;
|
|
48
|
+
orphanGraceMs?: number;
|
|
49
|
+
heartbeatIntervalMs?: number;
|
|
50
|
+
bridgeTimeoutMs?: number;
|
|
51
|
+
maxWorkspaces?: number;
|
|
52
|
+
schedule?: (callback: () => void, delayMs: number) => SupervisorTimer;
|
|
53
|
+
cancel?: (timer: SupervisorTimer) => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface WorkspaceEntry<Runtime extends RuntimeInstance> {
|
|
57
|
+
createdAt: number;
|
|
58
|
+
runtime: Runtime;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface ActiveBridge {
|
|
62
|
+
bridgeId: string;
|
|
63
|
+
epoch: number;
|
|
64
|
+
expiresAt: number;
|
|
65
|
+
ready: Promise<void>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface AgentBinding {
|
|
69
|
+
issuedAt: number;
|
|
70
|
+
ticket: string;
|
|
71
|
+
agentId: string | null;
|
|
72
|
+
workspaceId: string | null;
|
|
73
|
+
viewerToken: string | null;
|
|
74
|
+
controlToken: string | null;
|
|
75
|
+
lastState: BrowserState | null;
|
|
76
|
+
lastFrame: BrowserFrame | null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance> {
|
|
80
|
+
private readonly owner: RuntimeOwner<Runtime>;
|
|
81
|
+
private readonly now: () => number;
|
|
82
|
+
private readonly orphanGraceMs: number;
|
|
83
|
+
private readonly heartbeatIntervalMs: number;
|
|
84
|
+
private readonly bridgeTimeoutMs: number;
|
|
85
|
+
private readonly maxWorkspaces: number;
|
|
86
|
+
private readonly schedule: (callback: () => void, delayMs: number) => SupervisorTimer;
|
|
87
|
+
private readonly cancel: (timer: SupervisorTimer) => void;
|
|
88
|
+
private readonly workspaces = new Map<string, WorkspaceEntry<Runtime>>();
|
|
89
|
+
private readonly creations = new Map<string, Promise<WorkspaceEntry<Runtime>>>();
|
|
90
|
+
private readonly workspaceOperations = new Map<string, Promise<void>>();
|
|
91
|
+
private readonly archived = new Set<string>();
|
|
92
|
+
private readonly browserPolicy: SessionManager;
|
|
93
|
+
private readonly agentBindings = new Map<string, AgentBinding>();
|
|
94
|
+
private readonly agentTickets = new Map<string, Set<string>>();
|
|
95
|
+
private activeBridge: ActiveBridge | null = null;
|
|
96
|
+
private nextEpoch = 0;
|
|
97
|
+
private bridgeTimer: SupervisorTimer | null = null;
|
|
98
|
+
private orphanTimer: SupervisorTimer | null = null;
|
|
99
|
+
private stopping: Promise<void> | null = null;
|
|
100
|
+
|
|
101
|
+
constructor(options: RuntimeSupervisorOptions<Runtime>) {
|
|
102
|
+
this.owner = options.owner;
|
|
103
|
+
this.now = options.now ?? Date.now;
|
|
104
|
+
this.orphanGraceMs = options.orphanGraceMs ?? DEFAULT_ORPHAN_GRACE_MS;
|
|
105
|
+
this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_BRIDGE_HEARTBEAT_MS;
|
|
106
|
+
this.bridgeTimeoutMs = options.bridgeTimeoutMs ?? DEFAULT_BRIDGE_TIMEOUT_MS;
|
|
107
|
+
this.maxWorkspaces = options.maxWorkspaces ?? MAX_SUPERVISOR_WORKSPACES;
|
|
108
|
+
if (!Number.isInteger(this.maxWorkspaces) || this.maxWorkspaces < 1)
|
|
109
|
+
throw new Error("maxWorkspaces must be a positive integer");
|
|
110
|
+
this.schedule = options.schedule ?? setTimeout;
|
|
111
|
+
this.cancel = options.cancel ?? clearTimeout;
|
|
112
|
+
this.browserPolicy = new SessionManager({
|
|
113
|
+
validateWorkspace: async (workspaceId) => !this.archived.has(workspaceId),
|
|
114
|
+
client: {
|
|
115
|
+
connect: async () => ({ epoch: this.nextEpoch }),
|
|
116
|
+
ensureWorkspace: (workspaceId) => this.ensureWorkspaceLocal(workspaceId),
|
|
117
|
+
requestWorkspace: (workspaceId, operation, input) =>
|
|
118
|
+
this.requestWorkspaceLocal(workspaceId, operation, input),
|
|
119
|
+
archiveWorkspace: (workspaceId) => this.archiveWorkspaceLocal(workspaceId).then(() => {}),
|
|
120
|
+
disconnect: () => {},
|
|
121
|
+
},
|
|
122
|
+
now: this.now,
|
|
123
|
+
maxSessions: this.maxWorkspaces,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
claimBridge(bridgeId: string, takeover = false): BridgeLease {
|
|
128
|
+
const active = this.activeBridge;
|
|
129
|
+
if (active && active.expiresAt > this.now() && active.bridgeId !== bridgeId && !takeover)
|
|
130
|
+
throw new RuntimeProtocolError(
|
|
131
|
+
"BRIDGE_FENCED",
|
|
132
|
+
"A Shared Browser plugin bridge is already active; explicit administrative takeover is required",
|
|
133
|
+
);
|
|
134
|
+
this.nextEpoch += 1;
|
|
135
|
+
const pending = [...this.workspaceOperations.values()];
|
|
136
|
+
this.browserPolicy.setBridgeEpoch(this.nextEpoch);
|
|
137
|
+
const bridge: ActiveBridge = {
|
|
138
|
+
bridgeId,
|
|
139
|
+
epoch: this.nextEpoch,
|
|
140
|
+
expiresAt: this.now() + this.bridgeTimeoutMs,
|
|
141
|
+
ready: Promise.allSettled(pending).then(() => undefined),
|
|
142
|
+
};
|
|
143
|
+
this.activeBridge = bridge;
|
|
144
|
+
this.cancelOrphanStop();
|
|
145
|
+
this.armBridgeTimeout(bridge);
|
|
146
|
+
return this.bridgeLease(bridge);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
heartbeat(bridgeId: string, epoch: number): BridgeLease {
|
|
150
|
+
const bridge = this.assertActiveBridge(bridgeId, epoch);
|
|
151
|
+
bridge.expiresAt = this.now() + this.bridgeTimeoutMs;
|
|
152
|
+
this.armBridgeTimeout(bridge);
|
|
153
|
+
return this.bridgeLease(bridge);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
bridgeDisconnected(bridgeId: string, epoch: number): void {
|
|
157
|
+
if (this.activeBridge?.bridgeId !== bridgeId || this.activeBridge.epoch !== epoch) return;
|
|
158
|
+
this.activeBridge = null;
|
|
159
|
+
this.clearBridgeTimer();
|
|
160
|
+
this.armOrphanStop();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async ensureWorkspace(
|
|
164
|
+
bridgeId: string,
|
|
165
|
+
epoch: number,
|
|
166
|
+
workspaceId: string,
|
|
167
|
+
): Promise<RuntimeDescriptor> {
|
|
168
|
+
const bridge = this.assertActiveBridge(bridgeId, epoch);
|
|
169
|
+
await bridge.ready;
|
|
170
|
+
this.assertActiveBridge(bridgeId, epoch);
|
|
171
|
+
const descriptor = await this.ensureWorkspaceLocal(workspaceId);
|
|
172
|
+
this.assertActiveBridge(bridgeId, epoch);
|
|
173
|
+
return descriptor;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async requestWorkspace(
|
|
177
|
+
bridgeId: string,
|
|
178
|
+
epoch: number,
|
|
179
|
+
workspaceId: string,
|
|
180
|
+
operation: string,
|
|
181
|
+
input: JsonValue,
|
|
182
|
+
): Promise<JsonValue> {
|
|
183
|
+
const bridge = this.assertActiveBridge(bridgeId, epoch);
|
|
184
|
+
await bridge.ready;
|
|
185
|
+
this.assertActiveBridge(bridgeId, epoch);
|
|
186
|
+
const result = await this.requestWorkspaceLocal(workspaceId, operation, input);
|
|
187
|
+
this.assertActiveBridge(bridgeId, epoch);
|
|
188
|
+
return result;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async archiveWorkspace(
|
|
192
|
+
bridgeId: string,
|
|
193
|
+
epoch: number,
|
|
194
|
+
workspaceId: string,
|
|
195
|
+
): Promise<{ archived: true }> {
|
|
196
|
+
const bridge = this.assertActiveBridge(bridgeId, epoch);
|
|
197
|
+
this.archived.add(workspaceId);
|
|
198
|
+
await bridge.ready;
|
|
199
|
+
const result = await this.archiveWorkspaceLocal(workspaceId);
|
|
200
|
+
this.assertActiveBridge(bridgeId, epoch);
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private ensureWorkspaceLocal(workspaceId: string): Promise<RuntimeDescriptor> {
|
|
205
|
+
return this.runWorkspaceOperation(workspaceId, async () => {
|
|
206
|
+
if (this.archived.has(workspaceId)) this.workspaceArchived(workspaceId);
|
|
207
|
+
let entry = this.workspaces.get(workspaceId);
|
|
208
|
+
if (!entry) {
|
|
209
|
+
let creation = this.creations.get(workspaceId);
|
|
210
|
+
if (!creation) {
|
|
211
|
+
if (this.workspaces.size + this.creations.size >= this.maxWorkspaces) {
|
|
212
|
+
throw new RuntimeProtocolError(
|
|
213
|
+
"RUNTIME_FAILURE",
|
|
214
|
+
`Runtime supervisor workspace limit (${this.maxWorkspaces}) reached`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
creation = this.createWorkspace(workspaceId);
|
|
218
|
+
this.creations.set(workspaceId, creation);
|
|
219
|
+
}
|
|
220
|
+
entry = await creation;
|
|
221
|
+
}
|
|
222
|
+
if (this.archived.has(workspaceId)) this.workspaceArchived(workspaceId);
|
|
223
|
+
return { workspaceId, runtimeId: entry.runtime.runtimeId, createdAt: entry.createdAt };
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private requestWorkspaceLocal(
|
|
228
|
+
workspaceId: string,
|
|
229
|
+
operation: string,
|
|
230
|
+
input: JsonValue,
|
|
231
|
+
): Promise<JsonValue> {
|
|
232
|
+
return this.runWorkspaceOperation(workspaceId, async () => {
|
|
233
|
+
if (this.archived.has(workspaceId)) this.workspaceArchived(workspaceId);
|
|
234
|
+
const entry = this.workspaces.get(workspaceId);
|
|
235
|
+
if (!entry)
|
|
236
|
+
throw new RuntimeProtocolError(
|
|
237
|
+
"WORKSPACE_NOT_FOUND",
|
|
238
|
+
`Workspace runtime not found: ${workspaceId}`,
|
|
239
|
+
);
|
|
240
|
+
try {
|
|
241
|
+
const result = await this.owner.request(entry.runtime, operation, input);
|
|
242
|
+
if (this.archived.has(workspaceId)) this.workspaceArchived(workspaceId);
|
|
243
|
+
return result;
|
|
244
|
+
} catch (error) {
|
|
245
|
+
if (error instanceof RuntimeProtocolError) throw error;
|
|
246
|
+
if (error instanceof CdpUnknownOutcomeError)
|
|
247
|
+
throw new RuntimeProtocolError(
|
|
248
|
+
"UNKNOWN_OUTCOME",
|
|
249
|
+
"Browser mutation outcome is unknown; observe the browser before sending another mutation",
|
|
250
|
+
);
|
|
251
|
+
throw new RuntimeProtocolError(
|
|
252
|
+
"RUNTIME_FAILURE",
|
|
253
|
+
`Workspace runtime ${operation} failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private archiveWorkspaceLocal(workspaceId: string): Promise<{ archived: true }> {
|
|
260
|
+
this.archived.add(workspaceId);
|
|
261
|
+
return this.runWorkspaceOperation(workspaceId, async () => {
|
|
262
|
+
const creation = this.creations.get(workspaceId);
|
|
263
|
+
if (creation) await creation.catch(() => undefined);
|
|
264
|
+
const entry = this.workspaces.get(workspaceId);
|
|
265
|
+
if (entry) {
|
|
266
|
+
this.workspaces.delete(workspaceId);
|
|
267
|
+
await this.owner.stop(entry.runtime);
|
|
268
|
+
}
|
|
269
|
+
return { archived: true };
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private async requestBrowser(
|
|
274
|
+
bridgeId: string,
|
|
275
|
+
epoch: number,
|
|
276
|
+
operation: string,
|
|
277
|
+
input: JsonValue,
|
|
278
|
+
): Promise<JsonValue> {
|
|
279
|
+
const bridge = this.assertActiveBridge(bridgeId, epoch);
|
|
280
|
+
await bridge.ready;
|
|
281
|
+
this.assertActiveBridge(bridgeId, epoch);
|
|
282
|
+
const data = asObject(input);
|
|
283
|
+
let result: unknown;
|
|
284
|
+
switch (operation) {
|
|
285
|
+
case "attach":
|
|
286
|
+
result = await this.browserPolicy.attach(
|
|
287
|
+
requireText(data, "workspaceId"),
|
|
288
|
+
requireText(data, "viewerLabel"),
|
|
289
|
+
);
|
|
290
|
+
break;
|
|
291
|
+
case "detach":
|
|
292
|
+
result = await this.browserPolicy.detach(requireText(data, "viewerToken"));
|
|
293
|
+
break;
|
|
294
|
+
case "status":
|
|
295
|
+
result = await this.browserPolicy.status(requireText(data, "viewerToken"));
|
|
296
|
+
break;
|
|
297
|
+
case "capture":
|
|
298
|
+
result = await this.browserPolicy.capture(
|
|
299
|
+
requireText(data, "viewerToken"),
|
|
300
|
+
data.quality === "low" || data.quality === "high" ? data.quality : "medium",
|
|
301
|
+
typeof data.knownFrameId === "string" ? data.knownFrameId : null,
|
|
302
|
+
);
|
|
303
|
+
break;
|
|
304
|
+
case "acquire-control": {
|
|
305
|
+
result = await this.browserPolicy.acquireControl(
|
|
306
|
+
requireText(data, "viewerToken"),
|
|
307
|
+
data.takeover === true,
|
|
308
|
+
);
|
|
309
|
+
const state = stateFromPolicyResult(result);
|
|
310
|
+
if (data.takeover === true) this.revokeAgentControl(state.workspaceId);
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
case "release-control":
|
|
314
|
+
result = await this.browserPolicy.releaseControl(
|
|
315
|
+
requireText(data, "viewerToken"),
|
|
316
|
+
requireText(data, "controlToken"),
|
|
317
|
+
);
|
|
318
|
+
break;
|
|
319
|
+
case "navigate":
|
|
320
|
+
result = await this.browserPolicy.navigate(data as never);
|
|
321
|
+
this.invalidateAgentObservations(stateFromPolicyResult(result).workspaceId);
|
|
322
|
+
break;
|
|
323
|
+
case "viewport":
|
|
324
|
+
result = await this.browserPolicy.resize(data as never);
|
|
325
|
+
this.invalidateAgentObservations(stateFromPolicyResult(result).workspaceId);
|
|
326
|
+
break;
|
|
327
|
+
case "device":
|
|
328
|
+
result = await this.browserPolicy.applyDevicePreset(data as never);
|
|
329
|
+
this.invalidateAgentObservations(stateFromPolicyResult(result).workspaceId);
|
|
330
|
+
break;
|
|
331
|
+
case "input":
|
|
332
|
+
result = await this.browserPolicy.sendInput(data as never);
|
|
333
|
+
this.invalidateAgentObservations(stateFromPolicyResult(result).workspaceId);
|
|
334
|
+
break;
|
|
335
|
+
case "list":
|
|
336
|
+
result = { workspaceIds: await this.browserPolicy.listOpenWorkspaceIds() };
|
|
337
|
+
break;
|
|
338
|
+
case "archive": {
|
|
339
|
+
const workspaceId = requireText(data, "workspaceId");
|
|
340
|
+
await this.revokeWorkspace(workspaceId);
|
|
341
|
+
await this.browserPolicy.archiveWorkspace(workspaceId);
|
|
342
|
+
result = { archived: true };
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
default:
|
|
346
|
+
throw new RuntimeProtocolError(
|
|
347
|
+
"INVALID_REQUEST",
|
|
348
|
+
`Unknown browser operation: ${operation}`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
this.assertActiveBridge(bridgeId, epoch);
|
|
352
|
+
return result as JsonValue;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private issueAgentTicket(bridgeId: string, epoch: number, ticket: string): JsonValue {
|
|
356
|
+
this.assertActiveBridge(bridgeId, epoch);
|
|
357
|
+
this.expireUnboundTickets();
|
|
358
|
+
assertOpaqueToken(ticket, "ticket");
|
|
359
|
+
if (this.agentBindings.has(ticket))
|
|
360
|
+
throw new RuntimeProtocolError("INVALID_REQUEST", "Agent ticket is already registered");
|
|
361
|
+
if (
|
|
362
|
+
[...this.agentBindings.values()].filter((binding) => binding.agentId === null).length >=
|
|
363
|
+
MAX_UNBOUND_AGENT_TICKETS
|
|
364
|
+
)
|
|
365
|
+
throw new RuntimeProtocolError(
|
|
366
|
+
"RUNTIME_BUSY",
|
|
367
|
+
"Shared Browser ticket capacity is temporarily full",
|
|
368
|
+
);
|
|
369
|
+
this.agentBindings.set(ticket, {
|
|
370
|
+
ticket,
|
|
371
|
+
issuedAt: this.now(),
|
|
372
|
+
agentId: null,
|
|
373
|
+
workspaceId: null,
|
|
374
|
+
viewerToken: null,
|
|
375
|
+
controlToken: null,
|
|
376
|
+
lastState: null,
|
|
377
|
+
lastFrame: null,
|
|
378
|
+
});
|
|
379
|
+
return { issued: true };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private bindAgentTicket(
|
|
383
|
+
bridgeId: string,
|
|
384
|
+
epoch: number,
|
|
385
|
+
ticket: string,
|
|
386
|
+
agentId: string,
|
|
387
|
+
workspaceId: string,
|
|
388
|
+
): JsonValue {
|
|
389
|
+
this.assertActiveBridge(bridgeId, epoch);
|
|
390
|
+
this.expireUnboundTickets();
|
|
391
|
+
const binding = this.agentBindings.get(ticket);
|
|
392
|
+
if (!binding) throw new RuntimeProtocolError("AUTHENTICATION_FAILED", "Unknown agent ticket");
|
|
393
|
+
if (this.archived.has(workspaceId)) this.workspaceArchived(workspaceId);
|
|
394
|
+
if (binding.agentId && (binding.agentId !== agentId || binding.workspaceId !== workspaceId))
|
|
395
|
+
throw new RuntimeProtocolError("AUTHENTICATION_FAILED", "Agent ticket is already bound");
|
|
396
|
+
binding.agentId = agentId;
|
|
397
|
+
binding.workspaceId = workspaceId;
|
|
398
|
+
let tickets = this.agentTickets.get(agentId);
|
|
399
|
+
if (!tickets) {
|
|
400
|
+
tickets = new Set();
|
|
401
|
+
this.agentTickets.set(agentId, tickets);
|
|
402
|
+
}
|
|
403
|
+
tickets.add(ticket);
|
|
404
|
+
return { bound: true };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private async revokeAgent(bridgeId: string, epoch: number, agentId: string): Promise<JsonValue> {
|
|
408
|
+
this.assertActiveBridge(bridgeId, epoch);
|
|
409
|
+
const tickets = this.agentTickets.get(agentId);
|
|
410
|
+
if (!tickets) return { revoked: 0 };
|
|
411
|
+
let revoked = 0;
|
|
412
|
+
for (const ticket of tickets) {
|
|
413
|
+
const binding = this.agentBindings.get(ticket);
|
|
414
|
+
if (!binding) continue;
|
|
415
|
+
this.agentBindings.delete(ticket);
|
|
416
|
+
revoked += 1;
|
|
417
|
+
if (binding.viewerToken)
|
|
418
|
+
await this.browserPolicy.detach(binding.viewerToken).catch(() => undefined);
|
|
419
|
+
}
|
|
420
|
+
this.agentTickets.delete(agentId);
|
|
421
|
+
return { revoked };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private async requestAgent(
|
|
425
|
+
ticket: string,
|
|
426
|
+
operation: string,
|
|
427
|
+
input: JsonValue,
|
|
428
|
+
): Promise<JsonValue> {
|
|
429
|
+
this.assertPluginAvailable();
|
|
430
|
+
const binding = this.requireAgentBinding(ticket);
|
|
431
|
+
const data = asObject(input);
|
|
432
|
+
if (operation === "status" || operation === "capture")
|
|
433
|
+
return (await this.requestAgentObservation(binding, operation, data)) as unknown as JsonValue;
|
|
434
|
+
try {
|
|
435
|
+
if (operation === "acquire-control") {
|
|
436
|
+
await this.requestAgentObservation(binding, "status", {});
|
|
437
|
+
const viewerToken = binding.viewerToken!;
|
|
438
|
+
const result = await this.browserPolicy.acquireControl(viewerToken, false);
|
|
439
|
+
binding.controlToken = result.controlToken;
|
|
440
|
+
binding.lastState = result.state;
|
|
441
|
+
return { state: result.state } as unknown as JsonValue;
|
|
442
|
+
}
|
|
443
|
+
const viewerToken = binding.viewerToken;
|
|
444
|
+
const controlToken = binding.controlToken;
|
|
445
|
+
if (!viewerToken || !controlToken)
|
|
446
|
+
throw new RuntimeProtocolError(
|
|
447
|
+
"AUTHENTICATION_FAILED",
|
|
448
|
+
"Agent does not hold browser control",
|
|
449
|
+
);
|
|
450
|
+
if (operation === "release-control") {
|
|
451
|
+
try {
|
|
452
|
+
const result = await this.browserPolicy.releaseControl(viewerToken, controlToken);
|
|
453
|
+
binding.lastState = result.state;
|
|
454
|
+
return result as unknown as JsonValue;
|
|
455
|
+
} finally {
|
|
456
|
+
binding.controlToken = null;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
const expected = expectedState(binding);
|
|
460
|
+
let result: { state: BrowserState };
|
|
461
|
+
if (operation === "navigate") {
|
|
462
|
+
result = await this.browserPolicy.navigate({
|
|
463
|
+
viewerToken,
|
|
464
|
+
controlToken,
|
|
465
|
+
expected,
|
|
466
|
+
action: data.action as never,
|
|
467
|
+
});
|
|
468
|
+
} else if (operation === "viewport") {
|
|
469
|
+
result = await this.browserPolicy.resize({
|
|
470
|
+
viewerToken,
|
|
471
|
+
controlToken,
|
|
472
|
+
expected,
|
|
473
|
+
viewport: data.viewport as Viewport,
|
|
474
|
+
});
|
|
475
|
+
} else if (operation === "input") {
|
|
476
|
+
if (!binding.lastFrame)
|
|
477
|
+
throw new RuntimeProtocolError("INVALID_REQUEST", "Capture a frame before sending input");
|
|
478
|
+
result = await this.browserPolicy.sendInput({
|
|
479
|
+
viewerToken,
|
|
480
|
+
controlToken,
|
|
481
|
+
expected,
|
|
482
|
+
target: binding.lastFrame,
|
|
483
|
+
event: data.event as BrowserInputEvent,
|
|
484
|
+
});
|
|
485
|
+
} else {
|
|
486
|
+
throw new RuntimeProtocolError("INVALID_REQUEST", `Unknown agent operation: ${operation}`);
|
|
487
|
+
}
|
|
488
|
+
binding.lastState = result.state;
|
|
489
|
+
binding.lastFrame = null;
|
|
490
|
+
return result as unknown as JsonValue;
|
|
491
|
+
} catch (error) {
|
|
492
|
+
if (error instanceof RuntimeProtocolError && error.code === "UNKNOWN_OUTCOME")
|
|
493
|
+
this.invalidateAgentObservations(binding.workspaceId!);
|
|
494
|
+
throw error;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
private async requestAgentObservation(
|
|
499
|
+
binding: AgentBinding,
|
|
500
|
+
operation: "status" | "capture",
|
|
501
|
+
input: Record<string, JsonValue>,
|
|
502
|
+
): Promise<{ state: BrowserState; frame?: BrowserFrame | null }> {
|
|
503
|
+
const run = async (viewerToken: string) =>
|
|
504
|
+
operation === "status"
|
|
505
|
+
? await this.browserPolicy.status(viewerToken)
|
|
506
|
+
: await this.browserPolicy.capture(
|
|
507
|
+
viewerToken,
|
|
508
|
+
input.quality === "low" || input.quality === "high" ? input.quality : "medium",
|
|
509
|
+
null,
|
|
510
|
+
);
|
|
511
|
+
let result: { state: BrowserState; frame?: BrowserFrame | null };
|
|
512
|
+
try {
|
|
513
|
+
result = await run(await this.ensureAgentViewer(binding));
|
|
514
|
+
} catch (error) {
|
|
515
|
+
if (!isInvalidViewer(error)) throw error;
|
|
516
|
+
binding.viewerToken = null;
|
|
517
|
+
binding.controlToken = null;
|
|
518
|
+
result = await run(await this.ensureAgentViewer(binding));
|
|
519
|
+
}
|
|
520
|
+
binding.lastState = result.state;
|
|
521
|
+
if (operation === "capture") binding.lastFrame = result.frame ?? null;
|
|
522
|
+
return result;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private async ensureAgentViewer(binding: AgentBinding): Promise<string> {
|
|
526
|
+
if (binding.viewerToken) return binding.viewerToken;
|
|
527
|
+
this.assertPluginAvailable();
|
|
528
|
+
const attached = await this.browserPolicy.attach(
|
|
529
|
+
binding.workspaceId!,
|
|
530
|
+
`Agent ${binding.agentId!.slice(0, 48)}`,
|
|
531
|
+
);
|
|
532
|
+
binding.viewerToken = attached.viewerToken;
|
|
533
|
+
binding.lastState = attached.state;
|
|
534
|
+
return attached.viewerToken;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
private requireAgentBinding(ticket: string): AgentBinding {
|
|
538
|
+
const binding = this.agentBindings.get(ticket);
|
|
539
|
+
if (!binding?.agentId || !binding.workspaceId)
|
|
540
|
+
throw new RuntimeProtocolError("AUTHENTICATION_FAILED", "Agent ticket is invalid or unbound");
|
|
541
|
+
if (this.archived.has(binding.workspaceId)) this.workspaceArchived(binding.workspaceId);
|
|
542
|
+
return binding;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
private assertPluginAvailable(): void {
|
|
546
|
+
if (!this.activeBridge || this.activeBridge.expiresAt <= this.now())
|
|
547
|
+
throw new RuntimeProtocolError("BRIDGE_FENCED", "Shared Browser plugin is unavailable");
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
private revokeAgentControl(workspaceId: string): void {
|
|
551
|
+
for (const binding of this.agentBindings.values()) {
|
|
552
|
+
if (binding.workspaceId !== workspaceId) continue;
|
|
553
|
+
binding.controlToken = null;
|
|
554
|
+
binding.lastState = null;
|
|
555
|
+
binding.lastFrame = null;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
private invalidateAgentObservations(workspaceId: string): void {
|
|
560
|
+
for (const binding of this.agentBindings.values()) {
|
|
561
|
+
if (binding.workspaceId !== workspaceId) continue;
|
|
562
|
+
binding.lastState = null;
|
|
563
|
+
binding.lastFrame = null;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
private async revokeWorkspace(workspaceId: string): Promise<void> {
|
|
568
|
+
const viewers: string[] = [];
|
|
569
|
+
for (const [ticket, binding] of this.agentBindings) {
|
|
570
|
+
if (binding.workspaceId !== workspaceId) continue;
|
|
571
|
+
this.agentBindings.delete(ticket);
|
|
572
|
+
if (binding.viewerToken) viewers.push(binding.viewerToken);
|
|
573
|
+
if (binding.agentId) {
|
|
574
|
+
const tickets = this.agentTickets.get(binding.agentId);
|
|
575
|
+
tickets?.delete(ticket);
|
|
576
|
+
if (tickets?.size === 0) this.agentTickets.delete(binding.agentId);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
await Promise.allSettled(viewers.map((viewerToken) => this.browserPolicy.detach(viewerToken)));
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async stopAll(): Promise<void> {
|
|
583
|
+
if (this.stopping) return this.stopping;
|
|
584
|
+
const operation = (async () => {
|
|
585
|
+
this.activeBridge = null;
|
|
586
|
+
this.clearBridgeTimer();
|
|
587
|
+
this.cancelOrphanStop();
|
|
588
|
+
this.browserPolicy.reset();
|
|
589
|
+
for (const binding of this.agentBindings.values()) {
|
|
590
|
+
binding.viewerToken = null;
|
|
591
|
+
binding.controlToken = null;
|
|
592
|
+
binding.lastState = null;
|
|
593
|
+
binding.lastFrame = null;
|
|
594
|
+
}
|
|
595
|
+
await Promise.allSettled(this.workspaceOperations.values());
|
|
596
|
+
await Promise.allSettled(this.creations.values());
|
|
597
|
+
const entries = [...this.workspaces.values()];
|
|
598
|
+
this.workspaces.clear();
|
|
599
|
+
const results = await Promise.allSettled(
|
|
600
|
+
entries.map((entry) => this.owner.stop(entry.runtime)),
|
|
601
|
+
);
|
|
602
|
+
const failures = results.filter(
|
|
603
|
+
(result): result is PromiseRejectedResult => result.status === "rejected",
|
|
604
|
+
);
|
|
605
|
+
if (failures.length > 0)
|
|
606
|
+
throw new AggregateError(
|
|
607
|
+
failures.map((result) => result.reason),
|
|
608
|
+
"One or more workspace runtimes failed to stop",
|
|
609
|
+
);
|
|
610
|
+
})();
|
|
611
|
+
this.stopping = operation;
|
|
612
|
+
try {
|
|
613
|
+
await operation;
|
|
614
|
+
} finally {
|
|
615
|
+
if (this.stopping === operation) this.stopping = null;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
async dispatch(request: RuntimeRequest): Promise<RuntimeResult> {
|
|
620
|
+
switch (request.method) {
|
|
621
|
+
case "bridge.claim":
|
|
622
|
+
return this.claimBridge(request.bridgeId, request.takeover);
|
|
623
|
+
case "bridge.heartbeat":
|
|
624
|
+
return this.heartbeat(request.bridgeId, request.epoch);
|
|
625
|
+
case "workspace.ensure":
|
|
626
|
+
return this.ensureWorkspace(request.bridgeId, request.epoch, request.workspaceId);
|
|
627
|
+
case "workspace.request":
|
|
628
|
+
return this.requestWorkspace(
|
|
629
|
+
request.bridgeId,
|
|
630
|
+
request.epoch,
|
|
631
|
+
request.workspaceId,
|
|
632
|
+
request.operation,
|
|
633
|
+
request.input,
|
|
634
|
+
);
|
|
635
|
+
case "workspace.archive":
|
|
636
|
+
return this.archiveWorkspace(request.bridgeId, request.epoch, request.workspaceId);
|
|
637
|
+
case "browser.request":
|
|
638
|
+
return this.requestBrowser(
|
|
639
|
+
request.bridgeId,
|
|
640
|
+
request.epoch,
|
|
641
|
+
request.operation,
|
|
642
|
+
request.input,
|
|
643
|
+
);
|
|
644
|
+
case "ticket.issue":
|
|
645
|
+
return this.issueAgentTicket(request.bridgeId, request.epoch, request.ticket);
|
|
646
|
+
case "ticket.bind":
|
|
647
|
+
return this.bindAgentTicket(
|
|
648
|
+
request.bridgeId,
|
|
649
|
+
request.epoch,
|
|
650
|
+
request.ticket,
|
|
651
|
+
request.agentId,
|
|
652
|
+
request.workspaceId,
|
|
653
|
+
);
|
|
654
|
+
case "agent.revoke":
|
|
655
|
+
return this.revokeAgent(request.bridgeId, request.epoch, request.agentId);
|
|
656
|
+
case "agent.request":
|
|
657
|
+
return this.requestAgent(request.ticket, request.operation, request.input);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
private runWorkspaceOperation<Result>(
|
|
662
|
+
workspaceId: string,
|
|
663
|
+
operation: () => Promise<Result>,
|
|
664
|
+
): Promise<Result> {
|
|
665
|
+
const previous = this.workspaceOperations.get(workspaceId) ?? Promise.resolve();
|
|
666
|
+
const result = previous.then(operation, operation);
|
|
667
|
+
const tail = result.then(
|
|
668
|
+
() => undefined,
|
|
669
|
+
() => undefined,
|
|
670
|
+
);
|
|
671
|
+
this.workspaceOperations.set(workspaceId, tail);
|
|
672
|
+
void tail.finally(() => {
|
|
673
|
+
if (this.workspaceOperations.get(workspaceId) === tail)
|
|
674
|
+
this.workspaceOperations.delete(workspaceId);
|
|
675
|
+
});
|
|
676
|
+
return result;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
private async createWorkspace(workspaceId: string): Promise<WorkspaceEntry<Runtime>> {
|
|
680
|
+
try {
|
|
681
|
+
const runtime = await this.owner.create(workspaceId);
|
|
682
|
+
const entry = { runtime, createdAt: this.now() };
|
|
683
|
+
if (this.archived.has(workspaceId)) {
|
|
684
|
+
await this.owner.stop(runtime);
|
|
685
|
+
this.workspaceArchived(workspaceId);
|
|
686
|
+
}
|
|
687
|
+
this.workspaces.set(workspaceId, entry);
|
|
688
|
+
return entry;
|
|
689
|
+
} finally {
|
|
690
|
+
this.creations.delete(workspaceId);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
private assertActiveBridge(bridgeId: string, epoch: number): ActiveBridge {
|
|
695
|
+
const bridge = this.activeBridge;
|
|
696
|
+
if (
|
|
697
|
+
!bridge ||
|
|
698
|
+
bridge.bridgeId !== bridgeId ||
|
|
699
|
+
bridge.epoch !== epoch ||
|
|
700
|
+
bridge.expiresAt <= this.now()
|
|
701
|
+
) {
|
|
702
|
+
throw new RuntimeProtocolError("BRIDGE_FENCED", "Plugin bridge lease is no longer active");
|
|
703
|
+
}
|
|
704
|
+
return bridge;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
private bridgeLease(bridge: ActiveBridge): BridgeLease {
|
|
708
|
+
return {
|
|
709
|
+
bridgeId: bridge.bridgeId,
|
|
710
|
+
epoch: bridge.epoch,
|
|
711
|
+
expiresAt: bridge.expiresAt,
|
|
712
|
+
heartbeatIntervalMs: this.heartbeatIntervalMs,
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
private workspaceArchived(workspaceId: string): never {
|
|
717
|
+
throw new RuntimeProtocolError("WORKSPACE_ARCHIVED", `Workspace is archived: ${workspaceId}`);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
private expireUnboundTickets(): void {
|
|
721
|
+
const deadline = this.now() - AGENT_TICKET_TTL_MS;
|
|
722
|
+
for (const [ticket, binding] of this.agentBindings)
|
|
723
|
+
if (binding.agentId === null && binding.issuedAt <= deadline)
|
|
724
|
+
this.agentBindings.delete(ticket);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
private armBridgeTimeout(bridge: ActiveBridge): void {
|
|
728
|
+
this.clearBridgeTimer();
|
|
729
|
+
const delay = Math.max(0, bridge.expiresAt - this.now());
|
|
730
|
+
this.bridgeTimer = this.schedule(() => {
|
|
731
|
+
if (this.activeBridge !== bridge) return;
|
|
732
|
+
if (bridge.expiresAt > this.now()) {
|
|
733
|
+
this.armBridgeTimeout(bridge);
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
this.activeBridge = null;
|
|
737
|
+
this.bridgeTimer = null;
|
|
738
|
+
this.armOrphanStop();
|
|
739
|
+
}, delay);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
private clearBridgeTimer(): void {
|
|
743
|
+
if (!this.bridgeTimer) return;
|
|
744
|
+
this.cancel(this.bridgeTimer);
|
|
745
|
+
this.bridgeTimer = null;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
private armOrphanStop(): void {
|
|
749
|
+
this.cancelOrphanStop();
|
|
750
|
+
this.orphanTimer = this.schedule(() => {
|
|
751
|
+
this.orphanTimer = null;
|
|
752
|
+
if (!this.activeBridge) void this.stopAll().catch(() => undefined);
|
|
753
|
+
}, this.orphanGraceMs);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
private cancelOrphanStop(): void {
|
|
757
|
+
if (!this.orphanTimer) return;
|
|
758
|
+
this.cancel(this.orphanTimer);
|
|
759
|
+
this.orphanTimer = null;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function asObject(value: JsonValue): Record<string, JsonValue> {
|
|
764
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
765
|
+
throw new RuntimeProtocolError("INVALID_REQUEST", "Browser input must be an object");
|
|
766
|
+
return value;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function requireText(value: Record<string, JsonValue>, key: string): string {
|
|
770
|
+
const text = value[key];
|
|
771
|
+
if (typeof text !== "string" || text.length === 0)
|
|
772
|
+
throw new RuntimeProtocolError("INVALID_REQUEST", `${key} must be a non-empty string`);
|
|
773
|
+
return text;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function assertOpaqueToken(value: string, label: string): void {
|
|
777
|
+
if (value.length < 32 || value.length > 128 || !/^[A-Za-z0-9_-]+$/.test(value))
|
|
778
|
+
throw new RuntimeProtocolError("INVALID_REQUEST", `${label} is invalid`);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function expectedState(binding: AgentBinding): {
|
|
782
|
+
sessionId: string;
|
|
783
|
+
navigationGeneration: number;
|
|
784
|
+
viewportGeneration: number;
|
|
785
|
+
runtimeId: string;
|
|
786
|
+
bridgeEpoch: number;
|
|
787
|
+
} {
|
|
788
|
+
const state = binding.lastState;
|
|
789
|
+
if (!state)
|
|
790
|
+
throw new RuntimeProtocolError("INVALID_REQUEST", "Observe the browser before mutating it");
|
|
791
|
+
if (!state.runtimeId || state.bridgeEpoch === undefined)
|
|
792
|
+
throw new RuntimeProtocolError("RUNTIME_FAILURE", "Browser observation lacks runtime identity");
|
|
793
|
+
return {
|
|
794
|
+
sessionId: state.sessionId,
|
|
795
|
+
navigationGeneration: state.navigationGeneration,
|
|
796
|
+
viewportGeneration: state.viewportGeneration,
|
|
797
|
+
runtimeId: state.runtimeId,
|
|
798
|
+
bridgeEpoch: state.bridgeEpoch,
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function stateFromPolicyResult(result: unknown): BrowserState {
|
|
803
|
+
if (!result || typeof result !== "object" || !("state" in result))
|
|
804
|
+
throw new RuntimeProtocolError("RUNTIME_FAILURE", "Browser policy returned no state");
|
|
805
|
+
return result.state as BrowserState;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function isInvalidViewer(error: unknown): boolean {
|
|
809
|
+
return error instanceof Error && error.message.includes("Viewer token is invalid or expired");
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
export interface SupervisorPaths {
|
|
813
|
+
root: string;
|
|
814
|
+
socket: string;
|
|
815
|
+
token: string;
|
|
816
|
+
endpoint: string;
|
|
817
|
+
lock: string;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
export function resolveSupervisorPaths(
|
|
821
|
+
paseoHome = process.env.PASEO_HOME ?? join(homedir(), ".paseo"),
|
|
822
|
+
): SupervisorPaths {
|
|
823
|
+
const root = join(
|
|
824
|
+
paseoHome,
|
|
825
|
+
"plugin-data",
|
|
826
|
+
"shared-browser",
|
|
827
|
+
`supervisor-v${RUNTIME_PROTOCOL_VERSION}`,
|
|
828
|
+
);
|
|
829
|
+
return {
|
|
830
|
+
root,
|
|
831
|
+
socket: join(root, "runtime.sock"),
|
|
832
|
+
token: join(root, "runtime.token"),
|
|
833
|
+
endpoint: join(root, "runtime.json"),
|
|
834
|
+
lock: join(root, "startup.lock"),
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
export async function acquireStartupLock(path: string): Promise<() => Promise<void>> {
|
|
839
|
+
await mkdir(dirname(path), { recursive: true, mode: DIRECTORY_MODE });
|
|
840
|
+
await chmod(dirname(path), DIRECTORY_MODE);
|
|
841
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
842
|
+
try {
|
|
843
|
+
await mkdir(path, { mode: DIRECTORY_MODE });
|
|
844
|
+
await writeFile(join(path, "pid"), String(process.pid), { mode: FILE_MODE });
|
|
845
|
+
return async () => rm(path, { recursive: true, force: true });
|
|
846
|
+
} catch (error) {
|
|
847
|
+
if (!isNodeError(error) || error.code !== "EEXIST") throw error;
|
|
848
|
+
const pid = Number.parseInt(await readFile(join(path, "pid"), "utf8").catch(() => ""), 10);
|
|
849
|
+
if (Number.isSafeInteger(pid) && processIsRunning(pid)) {
|
|
850
|
+
throw new Error(`Runtime supervisor already holds startup lock (${pid})`);
|
|
851
|
+
}
|
|
852
|
+
await rm(path, { recursive: true, force: true });
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
throw new Error("Could not acquire runtime supervisor startup lock");
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
export async function startSupervisorServer<Runtime extends RuntimeInstance>(
|
|
859
|
+
owner: RuntimeOwner<Runtime>,
|
|
860
|
+
paths = resolveSupervisorPaths(),
|
|
861
|
+
): Promise<{ close: () => Promise<void>; supervisor: RuntimeSupervisor<Runtime> }> {
|
|
862
|
+
const releaseLock = await acquireStartupLock(paths.lock);
|
|
863
|
+
const supervisor = new RuntimeSupervisor({ owner });
|
|
864
|
+
const sockets = new Set<Socket>();
|
|
865
|
+
let globalInFlight = 0;
|
|
866
|
+
const token = randomBytes(32).toString("base64url");
|
|
867
|
+
const server = createServer((socket) => {
|
|
868
|
+
sockets.add(socket);
|
|
869
|
+
let buffer = "";
|
|
870
|
+
let claimed: { bridgeId: string; epoch: number } | null = null;
|
|
871
|
+
let socketInFlight = 0;
|
|
872
|
+
let processing = Promise.resolve();
|
|
873
|
+
socket.setEncoding("utf8");
|
|
874
|
+
|
|
875
|
+
const rejectBusy = (line: string): boolean => {
|
|
876
|
+
let id: string;
|
|
877
|
+
try {
|
|
878
|
+
const request = JSON.parse(line) as { id?: unknown };
|
|
879
|
+
if (typeof request.id !== "string") throw new Error("Missing request id");
|
|
880
|
+
id = request.id;
|
|
881
|
+
} catch {
|
|
882
|
+
socket.destroy();
|
|
883
|
+
return false;
|
|
884
|
+
}
|
|
885
|
+
socket.pause();
|
|
886
|
+
socket.write(
|
|
887
|
+
`${JSON.stringify({
|
|
888
|
+
id,
|
|
889
|
+
ok: false,
|
|
890
|
+
error: {
|
|
891
|
+
code: "RUNTIME_BUSY",
|
|
892
|
+
message: "Shared Browser supervisor is busy; retry the request",
|
|
893
|
+
},
|
|
894
|
+
})}\n`,
|
|
895
|
+
(error) => {
|
|
896
|
+
if (error) socket.destroy();
|
|
897
|
+
else socket.resume();
|
|
898
|
+
},
|
|
899
|
+
);
|
|
900
|
+
return false;
|
|
901
|
+
};
|
|
902
|
+
|
|
903
|
+
const enqueue = (line: string): boolean => {
|
|
904
|
+
if (socketInFlight >= MAX_SOCKET_IN_FLIGHT || globalInFlight >= MAX_GLOBAL_IN_FLIGHT)
|
|
905
|
+
return rejectBusy(line);
|
|
906
|
+
socketInFlight += 1;
|
|
907
|
+
globalInFlight += 1;
|
|
908
|
+
processing = processing
|
|
909
|
+
.then(async () => {
|
|
910
|
+
const { response, lease } = await handleLine(line, token, supervisor);
|
|
911
|
+
if (lease) claimed = lease;
|
|
912
|
+
if (!socket.destroyed) {
|
|
913
|
+
await new Promise<void>((resolve, reject) => {
|
|
914
|
+
socket.write(`${JSON.stringify(response)}\n`, (error) =>
|
|
915
|
+
error ? reject(error) : resolve(),
|
|
916
|
+
);
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
})
|
|
920
|
+
.catch(() => undefined)
|
|
921
|
+
.finally(() => {
|
|
922
|
+
socketInFlight -= 1;
|
|
923
|
+
globalInFlight -= 1;
|
|
924
|
+
socket.resume();
|
|
925
|
+
});
|
|
926
|
+
return true;
|
|
927
|
+
};
|
|
928
|
+
|
|
929
|
+
socket.on("data", (chunk: string) => {
|
|
930
|
+
buffer += chunk;
|
|
931
|
+
if (Buffer.byteLength(buffer) > MAX_MESSAGE_BYTES) {
|
|
932
|
+
socket.destroy();
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
let newline = buffer.indexOf("\n");
|
|
936
|
+
while (newline >= 0) {
|
|
937
|
+
const line = buffer.slice(0, newline);
|
|
938
|
+
buffer = buffer.slice(newline + 1);
|
|
939
|
+
if (line.length > 0 && !enqueue(line)) return;
|
|
940
|
+
newline = buffer.indexOf("\n");
|
|
941
|
+
}
|
|
942
|
+
});
|
|
943
|
+
socket.once("close", () => {
|
|
944
|
+
sockets.delete(socket);
|
|
945
|
+
if (claimed) supervisor.bridgeDisconnected(claimed.bridgeId, claimed.epoch);
|
|
946
|
+
});
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
try {
|
|
950
|
+
await mkdir(paths.root, { recursive: true, mode: DIRECTORY_MODE });
|
|
951
|
+
await chmod(paths.root, DIRECTORY_MODE);
|
|
952
|
+
const tokenHandle = await open(paths.token, "w", FILE_MODE);
|
|
953
|
+
try {
|
|
954
|
+
await tokenHandle.writeFile(token);
|
|
955
|
+
} finally {
|
|
956
|
+
await tokenHandle.close();
|
|
957
|
+
}
|
|
958
|
+
await chmod(paths.token, FILE_MODE);
|
|
959
|
+
await rm(paths.socket, { force: true });
|
|
960
|
+
await new Promise<void>((resolve, reject) => {
|
|
961
|
+
server.once("error", reject);
|
|
962
|
+
server.listen(paths.socket, () => {
|
|
963
|
+
server.off("error", reject);
|
|
964
|
+
resolve();
|
|
965
|
+
});
|
|
966
|
+
});
|
|
967
|
+
await chmod(paths.socket, SOCKET_MODE);
|
|
968
|
+
await writeFile(
|
|
969
|
+
paths.endpoint,
|
|
970
|
+
JSON.stringify({ version: RUNTIME_PROTOCOL_VERSION, socket: paths.socket }),
|
|
971
|
+
{ mode: FILE_MODE },
|
|
972
|
+
);
|
|
973
|
+
await chmod(paths.endpoint, FILE_MODE);
|
|
974
|
+
} catch (error) {
|
|
975
|
+
server.close();
|
|
976
|
+
await Promise.allSettled([
|
|
977
|
+
rm(paths.socket, { force: true }),
|
|
978
|
+
rm(paths.endpoint, { force: true }),
|
|
979
|
+
rm(paths.token, { force: true }),
|
|
980
|
+
releaseLock(),
|
|
981
|
+
]);
|
|
982
|
+
throw error;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
let closed = false;
|
|
986
|
+
return {
|
|
987
|
+
supervisor,
|
|
988
|
+
close: async () => {
|
|
989
|
+
if (closed) return;
|
|
990
|
+
closed = true;
|
|
991
|
+
for (const socket of sockets) socket.destroy();
|
|
992
|
+
const results = await Promise.allSettled([
|
|
993
|
+
new Promise<void>((resolve, reject) =>
|
|
994
|
+
server.close((error) => (error ? reject(error) : resolve())),
|
|
995
|
+
),
|
|
996
|
+
supervisor.stopAll(),
|
|
997
|
+
]);
|
|
998
|
+
try {
|
|
999
|
+
const shutdownFailures = results.filter(
|
|
1000
|
+
(result): result is PromiseRejectedResult => result.status === "rejected",
|
|
1001
|
+
);
|
|
1002
|
+
if (shutdownFailures.length > 0)
|
|
1003
|
+
throw new AggregateError(
|
|
1004
|
+
shutdownFailures.map((result) => result.reason),
|
|
1005
|
+
"Runtime supervisor shutdown failed",
|
|
1006
|
+
);
|
|
1007
|
+
} finally {
|
|
1008
|
+
await Promise.allSettled([
|
|
1009
|
+
rm(paths.socket, { force: true }),
|
|
1010
|
+
rm(paths.endpoint, { force: true }),
|
|
1011
|
+
rm(paths.token, { force: true }),
|
|
1012
|
+
releaseLock(),
|
|
1013
|
+
]);
|
|
1014
|
+
}
|
|
1015
|
+
},
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
async function handleLine<Runtime extends RuntimeInstance>(
|
|
1019
|
+
line: string,
|
|
1020
|
+
token: string,
|
|
1021
|
+
supervisor: RuntimeSupervisor<Runtime>,
|
|
1022
|
+
): Promise<{ response: RuntimeResponse; lease: { bridgeId: string; epoch: number } | null }> {
|
|
1023
|
+
let id = "unknown";
|
|
1024
|
+
try {
|
|
1025
|
+
const raw: unknown = JSON.parse(line);
|
|
1026
|
+
const request = parseRuntimeRequest(raw);
|
|
1027
|
+
id = request.id;
|
|
1028
|
+
if (request.method !== "agent.request" && !tokensEqual(request.token, token))
|
|
1029
|
+
throw new RuntimeProtocolError("AUTHENTICATION_FAILED", "Invalid supervisor token");
|
|
1030
|
+
const result = await supervisor.dispatch(request);
|
|
1031
|
+
let lease: { bridgeId: string; epoch: number } | null = null;
|
|
1032
|
+
if (request.method === "bridge.claim") {
|
|
1033
|
+
if (!result || typeof result !== "object" || !("epoch" in result))
|
|
1034
|
+
throw new RuntimeProtocolError("RUNTIME_FAILURE", "Bridge claim returned no epoch");
|
|
1035
|
+
lease = { bridgeId: request.bridgeId, epoch: Number(result.epoch) };
|
|
1036
|
+
}
|
|
1037
|
+
return { response: { id, ok: true, result }, lease };
|
|
1038
|
+
} catch (error) {
|
|
1039
|
+
const protocolError =
|
|
1040
|
+
error instanceof RuntimeProtocolError
|
|
1041
|
+
? error
|
|
1042
|
+
: new RuntimeProtocolError(
|
|
1043
|
+
"RUNTIME_FAILURE",
|
|
1044
|
+
error instanceof Error ? error.message : "Runtime operation failed",
|
|
1045
|
+
);
|
|
1046
|
+
return {
|
|
1047
|
+
response: {
|
|
1048
|
+
id,
|
|
1049
|
+
ok: false,
|
|
1050
|
+
error: { code: protocolError.code, message: protocolError.message },
|
|
1051
|
+
},
|
|
1052
|
+
lease: null,
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function tokensEqual(left: string, right: string): boolean {
|
|
1058
|
+
const leftBytes = Buffer.from(left);
|
|
1059
|
+
const rightBytes = Buffer.from(right);
|
|
1060
|
+
return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
|
1064
|
+
return error instanceof Error && "code" in error;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function processIsRunning(pid: number): boolean {
|
|
1068
|
+
try {
|
|
1069
|
+
process.kill(pid, 0);
|
|
1070
|
+
return true;
|
|
1071
|
+
} catch (error) {
|
|
1072
|
+
return isNodeError(error) && error.code === "EPERM";
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
export async function runStandaloneSupervisor(owner: RuntimeOwner): Promise<void> {
|
|
1077
|
+
const running = await startSupervisorServer(owner);
|
|
1078
|
+
const shutdown = () => void running.close().finally(() => process.exit(0));
|
|
1079
|
+
process.once("SIGINT", shutdown);
|
|
1080
|
+
process.once("SIGTERM", shutdown);
|
|
1081
|
+
}
|